feat(vault): Phase 3 tools + batch citation/relation enrichment
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user