feat(vault): Phase 3 tools + batch citation/relation enrichment

This commit is contained in:
2026-07-01 15:20:40 +08:00
parent 8273017082
commit 407a9213bf
548 changed files with 53410 additions and 2113 deletions
+199
View File
@@ -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()