Files
llm_wiki/tools/scripts/fix-yaml-frontmatter.py

200 lines
6.3 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()