#!/usr/bin/env python3 """ Fix broken wiki links in knowledge-base repo by rewriting them as proper source-relative paths (the format Forgejo/Gitea Markdown rendering expects). Bug classes handled: A. [[wiki/...]] → strip 'wiki/' prefix, then resolve source-relative B. [[concepts/...]] from wrong directory → resolve to ../concepts/... C. [[concepts/...]] from same directory → resolve to basename D. [[plain-name.md]] from wrong directory → resolve to ../.../plain-name.md """ import os import re import sys from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent WIKI_DIR = REPO_ROOT / "wiki" LINK_RE = re.compile(r'\[\[([^\]|]+)(?:\|([^\]]+))?\]\]') def find_wiki_files(): files = [] for root, dirs, filenames in os.walk(WIKI_DIR): for f in filenames: if f.endswith('.md'): files.append(Path(root) / f) return sorted(files) def find_target(link_path: str): """ Given a link path as written, find the actual target file. Supports wiki/, raw/, and root-relative interpretations. Returns absolute Path to the target file, or None if not found. """ basename = Path(link_path).name if not basename.endswith('.md'): basename_candidate = basename + '.md' else: basename_candidate = basename candidates = [] # Interpretation 1: path starts with "wiki/" — strip and look in WIKI_DIR if link_path.startswith('wiki/'): stripped = link_path[5:] if not stripped.endswith('.md'): stripped += '.md' target = WIKI_DIR / stripped if target.exists(): candidates.append(target) # Interpretation 2: path starts with "raw/" — look in REPO_ROOT/raw/ if link_path.startswith('raw/'): target = REPO_ROOT / link_path if not target.exists() and not link_path.endswith('.md'): target = REPO_ROOT / (link_path + '.md') if target.exists(): candidates.append(target) # Interpretation 3: path contains "/" — interpret as root-relative (but skip if starts with ../) if '/' in link_path and not link_path.startswith('../'): # Try as wiki/-root-relative if not link_path.endswith('.md'): root_relative = WIKI_DIR / (link_path + '.md') else: root_relative = WIKI_DIR / link_path candidates.append(root_relative) # Also try as repo-root-relative if not link_path.endswith('.md'): candidates.append(REPO_ROOT / (link_path + '.md')) else: candidates.append(REPO_ROOT / link_path) # Interpretation 4: plain basename — find by basename if '/' not in link_path: matches = [m for m in WIKI_DIR.rglob(basename_candidate) if 'test-fixtures' not in str(m)] # Also check raw/ matches += [m for m in (REPO_ROOT / 'raw').rglob(basename_candidate) if 'test-fixtures' not in str(m)] matches += [m for m in REPO_ROOT.rglob(basename_candidate) if 'test-fixtures' not in str(m)] if len(matches) == 1: candidates.append(matches[0]) elif len(matches) > 1: non_fixture = [m for m in matches if 'test-fixtures' not in str(m)] if len(non_fixture) == 1: candidates.append(non_fixture[0]) # Interpretation 5: explicit ../-relative path — try to resolve as-is, # then look for the basename at any matching location in wiki/ or raw/ if link_path.startswith('../'): # Find the actual basename (last component, with .md suffix if needed) path_parts = Path(link_path).parts search_basename = path_parts[-1] if not search_basename.endswith('.md'): search_basename += '.md' # Find all files with this name in wiki/ matches = [m for m in WIKI_DIR.rglob(search_basename) if 'test-fixtures' not in str(m)] if len(matches) == 1: candidates.append(matches[0]) elif len(matches) > 1: non_fixture = [m for m in matches if 'test-fixtures' not in str(m)] if len(non_fixture) == 1: candidates.append(non_fixture[0]) # Try each candidate for c in candidates: if c.exists() and 'test-fixtures' not in str(c): return c return None def make_source_relative(from_file: Path, to_file: Path) -> str: """Make a relative path from from_file's directory to to_file.""" rel = os.path.relpath(to_file, start=from_file.parent) return rel def fix_link(link_path: str, source_file: Path) -> str: """Fix a broken wiki link. Returns the corrected link path.""" if link_path.startswith('http'): return link_path # Strip anchor anchor = '' if '#' in link_path: link_path, anchor = link_path.split('#', 1) anchor = '#' + anchor # Already valid? (source-relative resolves correctly) target = (source_file.parent / link_path).resolve() if target.exists(): return link_path + anchor # Try with .md appended if not link_path.endswith('.md'): target = (source_file.parent / (link_path + '.md')).resolve() if target.exists(): return link_path + '.md' + anchor # Find the actual target file by interpreting the link in different ways actual_target = find_target(link_path) if actual_target and actual_target != source_file: corrected = make_source_relative(source_file, actual_target) return corrected + anchor return link_path + anchor # no fix found def process_file(source_file: Path, dry_run=False): content = source_file.read_text(encoding='utf-8') def repl(match): link_path = match.group(1).strip() label = match.group(2) fixed = fix_link(link_path, source_file) if fixed != link_path: if label: return f'[[{fixed}|{label}]]' return f'[[{fixed}]]' return match.group(0) new_content = LINK_RE.sub(repl, content) if new_content != content: if not dry_run: source_file.write_text(new_content, encoding='utf-8') return True return False def main(): dry_run = '--dry-run' in sys.argv files = find_wiki_files() fixed_count = 0 fixed_files = [] for f in files: if process_file(f, dry_run=dry_run): fixed_count += 1 fixed_files.append(f.relative_to(REPO_ROOT)) print(f"{'Would fix' if dry_run else 'Fixed'}: {fixed_count} file(s)") if fixed_files: for rel in fixed_files: print(f" - {rel}") if dry_run and fixed_count > 0: print("\nRun without --dry-run to apply changes.") if __name__ == '__main__': main()