200 lines
6.3 KiB
Python
200 lines
6.3 KiB
Python
#!/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()
|