#!/usr/bin/env python # -*- coding: utf-8 -*- """ fix-yaml-frontmatter.py — 批量修复 wiki frontmatter YAML 错误 修复类型: 1. Windows 反斜杠路径 → 正斜杠(source 字段 \ → /) 2. 缺空格的 tag 列表项(-tag → - tag) 3. 断裂的 type 字段(type: place + 孤儿 - "Region" → 合并为列表) 4. relations 字段格式标准化(Obsidian 兼容:缩进列表 + 双引号) 5. frontmatter 后空行规范化 用法: python tools/scripts/fix-yaml-frontmatter.py --dry-run python tools/scripts/fix-yaml-frontmatter.py --apply """ import argparse import re import sys from pathlib import Path try: import yaml except ImportError: print("ERROR: pyyaml required"); sys.exit(1) VAULT = Path(r"D:\Applications\app\kepano-obsidian-main") WIKI = VAULT / "wiki" EXCLUDE = {"index.md", "log.md"} class IndentedDumper(yaml.Dumper): """Custom dumper that indents sequences under their key (Obsidian-friendly)""" def increase_indent(self, flow=False, indentless=False): return super().increase_indent(flow, False) def yaml_dump(front: dict) -> str: """Dump frontmatter with Obsidian-compatible formatting""" return yaml.dump( front, Dumper=IndentedDumper, allow_unicode=True, default_flow_style=False, sort_keys=False, width=1000, # prevent line wrapping ) def split_frontmatter(content: str): """Split into (frontmatter_text, body) preserving original formatting""" m = re.match(r"^(---\n)(.*?)(\n---\n?)(.*)", content, re.DOTALL) if not m: return None, content, None return m.group(2), m.group(4), m def fix_backslashes(fm_text: str) -> tuple[str, bool]: """Fix Windows backslash paths in source field""" changed = False def replacer(m): nonlocal changed val = m.group(0) if '\\' in val: changed = True return val.replace('\\', '/') return val result = re.sub(r'source:\s*".*?"', replacer, fm_text) return result, changed def fix_dash_nospace(fm_text: str) -> tuple[str, bool]: """Fix list items missing space after dash: -tag → - tag""" changed = False lines = fm_text.split('\n') for i, line in enumerate(lines): m = re.match(r'^(\s+)-(\S)', line) if m: lines[i] = m.group(1) + '- ' + m.group(2) + line[m.end():] changed = True return '\n'.join(lines), changed def fix_broken_type(fm_text: str) -> tuple[str, bool]: """Fix orphaned type values: type: place\n- "Region" → type list""" changed = False lines = fm_text.split('\n') result = [] i = 0 while i < len(lines): line = lines[i] type_match = re.match(r'^(\s*)type:\s*(\S+)', line) if type_match and i + 1 < len(lines): next_stripped = lines[i + 1].strip() if next_stripped.startswith('- ') or next_stripped.startswith('-"'): indent = type_match.group(1) main_type = type_match.group(2) extra = re.match(r'^-\s*"?(.+?)"?$', next_stripped) extra_val = extra.group(1) if extra else next_stripped.lstrip('- ') result.append(f'{indent}type:') result.append(f'{indent} - "{main_type}"') result.append(f'{indent} - "{extra_val}"') changed = True i += 2 continue result.append(line) i += 1 return '\n'.join(result), changed def process_file(fp: Path, apply: bool) -> dict: content = fp.read_text(encoding="utf-8") m = re.match(r"^(---\n)(.*?)(\n---\n?)(.*)", content, re.DOTALL) if not m: return {"file": fp.name, "skipped": "no frontmatter"} fm_text = m.group(2) body = m.group(4) fixes = [] # Phase 1: Regex fixes for known error patterns fm_text, changed = fix_backslashes(fm_text) if changed: fixes.append("backslash") fm_text, changed = fix_dash_nospace(fm_text) if changed: fixes.append("dash_nospace") fm_text, changed = fix_broken_type(fm_text) if changed: fixes.append("broken_type") # Phase 2: Parse → Re-dump with Obsidian-compatible formatting try: front = yaml.safe_load(fm_text) except Exception as e: return {"file": fp.name, "error": f"YAML still invalid after regex fixes: {e}"} if not isinstance(front, dict): return {"file": fp.name, "skipped": "frontmatter not a dict"} # Re-dump with IndentedDumper (proper sequence indentation + double quotes) new_fm = yaml_dump(front) # Check if re-dumping changed anything (format normalization) if new_fm.rstrip() != fm_text.rstrip(): fixes.append("format_normalize") # Validate the re-dumped YAML try: yaml.safe_load(new_fm) except Exception as e: return {"file": fp.name, "error": f"Re-dumped YAML invalid: {e}"} if not fixes: return {"file": fp.name, "skipped": "no fixes needed"} # Reconstruct file new_content = f"---\n{new_fm}---\n\n{body.lstrip()}" if apply: fp.write_text(new_content, encoding="utf-8") return {"file": fp.name, "fixes": fixes} def main(): parser = argparse.ArgumentParser(description="Fix YAML frontmatter errors in wiki") parser.add_argument("--dry-run", action="store_true", help="Preview without writing") parser.add_argument("--apply", action="store_true", help="Write fixes") args = parser.parse_args() if not args.dry_run and not args.apply: args.dry_run = True mode = "DRY RUN" if args.dry_run else "APPLYING" print(f"=== {mode} ===\n") stats = {} for fp in sorted(WIKI.glob("*.md")): if fp.name in EXCLUDE: continue result = process_file(fp, args.apply) if "fixes" in result: print(f" {result['file']}: {', '.join(result['fixes'])}") for f in result["fixes"]: stats[f] = stats.get(f, 0) + 1 elif "error" in result: print(f" ERROR: {result['file']}: {result['error']}") print(f"\n=== Summary ===") for fix, count in sorted(stats.items()): print(f" {fix}: {count} files") print(f" Total files fixed: {sum(stats.values())}") if __name__ == "__main__": main()