knowledge-base/scripts/fix-wiki-links.py

194 lines
6.6 KiB
Python
Raw Normal View History

#!/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()