213 lines
7.2 KiB
Python
Executable File
213 lines
7.2 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Verify Obsidian paper-note schema, Zotero-key coverage, and optional inventory consistency."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
REQUIRED_HEADINGS = (
|
|
"## Claim",
|
|
"## Method",
|
|
"## Evidence",
|
|
"## Limitation",
|
|
"## Direct relevance to repo",
|
|
"## Relation to other papers",
|
|
)
|
|
|
|
REQUIRED_FRONTMATTER_FIELDS = (
|
|
"type",
|
|
"project",
|
|
"title",
|
|
"zotero_key",
|
|
"linked_knowledge",
|
|
"paper_relationships",
|
|
"updated",
|
|
)
|
|
|
|
ZOTERO_KEY_RE = re.compile(r'^zotero_key:\s*"?([A-Z0-9]+)"?', re.MULTILINE)
|
|
FRONTMATTER_RE = re.compile(r'^---\n(.*?)\n---\n', re.DOTALL)
|
|
FIELD_RE_TEMPLATE = r'^{}:\s*'
|
|
INVENTORY_ROW_RE = re.compile(r'^\|\s*([A-Z0-9]+)\s*\|\s*(.*?)\|\s*(.*?)\|\s*(.*?)\|\s*$')
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(
|
|
description="Verify paper-note schema and optional Zotero-key / inventory coverage."
|
|
)
|
|
parser.add_argument("--papers-dir", required=True, help="Directory containing canonical paper notes.")
|
|
parser.add_argument(
|
|
"--expected-zotero-keys",
|
|
default="",
|
|
help="Comma-separated Zotero keys expected to be covered.",
|
|
)
|
|
parser.add_argument(
|
|
"--inventory-note",
|
|
default="",
|
|
help="Optional collection inventory note to cross-check against canonical paper notes.",
|
|
)
|
|
parser.add_argument(
|
|
"--strict-missing-zotero-key",
|
|
action="store_true",
|
|
help="Treat notes without zotero_key as errors. Default behavior skips them.",
|
|
)
|
|
return parser.parse_args()
|
|
|
|
|
|
def load_expected_keys(raw_keys: str) -> list[str]:
|
|
if not raw_keys.strip():
|
|
return []
|
|
return [key.strip() for key in raw_keys.split(",") if key.strip()]
|
|
|
|
|
|
def extract_frontmatter(text: str) -> str:
|
|
match = FRONTMATTER_RE.match(text)
|
|
return match.group(1) if match else ""
|
|
|
|
|
|
def missing_frontmatter_fields(frontmatter: str) -> list[str]:
|
|
missing: list[str] = []
|
|
for field in REQUIRED_FRONTMATTER_FIELDS:
|
|
if not re.search(FIELD_RE_TEMPLATE.format(re.escape(field)), frontmatter, re.MULTILINE):
|
|
missing.append(field)
|
|
return missing
|
|
|
|
|
|
def collect_note_status(
|
|
papers_dir: Path,
|
|
strict_missing_zotero_key: bool,
|
|
) -> tuple[dict[str, str], list[str], list[str]]:
|
|
key_to_file: dict[str, str] = {}
|
|
issues: list[str] = []
|
|
skipped_without_key: list[str] = []
|
|
|
|
for path in sorted(papers_dir.glob("*.md")):
|
|
text = path.read_text(encoding="utf-8")
|
|
frontmatter = extract_frontmatter(text)
|
|
match = ZOTERO_KEY_RE.search(text)
|
|
if not match:
|
|
skipped_without_key.append(path.name)
|
|
if strict_missing_zotero_key:
|
|
issues.append(f"{path.name}: missing zotero_key")
|
|
continue
|
|
|
|
zotero_key = match.group(1)
|
|
if zotero_key in key_to_file:
|
|
issues.append(
|
|
f"{path.name}: duplicate zotero_key {zotero_key} also used by {key_to_file[zotero_key]}"
|
|
)
|
|
key_to_file[zotero_key] = path.name
|
|
|
|
missing_headings = [heading for heading in REQUIRED_HEADINGS if heading not in text]
|
|
if missing_headings:
|
|
issues.append(f"{path.name}: missing headings -> {', '.join(missing_headings)}")
|
|
|
|
missing_fields = missing_frontmatter_fields(frontmatter)
|
|
if missing_fields:
|
|
issues.append(f"{path.name}: missing frontmatter fields -> {', '.join(missing_fields)}")
|
|
|
|
return key_to_file, issues, skipped_without_key
|
|
|
|
|
|
def parse_inventory_note(inventory_path: Path) -> tuple[dict[str, str], list[str]]:
|
|
text = inventory_path.read_text(encoding="utf-8")
|
|
mapping: dict[str, str] = {}
|
|
issues: list[str] = []
|
|
in_table = False
|
|
|
|
for line in text.splitlines():
|
|
if line.strip() == "## Item Mapping":
|
|
in_table = True
|
|
continue
|
|
if in_table and line.startswith("## "):
|
|
break
|
|
if not in_table or not line.startswith("|"):
|
|
continue
|
|
if "---" in line:
|
|
continue
|
|
match = INVENTORY_ROW_RE.match(line)
|
|
if not match:
|
|
continue
|
|
key, _title, note_path, _status = [part.strip() for part in match.groups()]
|
|
if key in mapping:
|
|
issues.append(f"inventory: duplicate zotero key {key}")
|
|
mapping[key] = note_path
|
|
|
|
if not mapping:
|
|
issues.append("inventory: no item mapping rows found")
|
|
return mapping, issues
|
|
|
|
|
|
def main() -> int:
|
|
args = parse_args()
|
|
papers_dir = Path(args.papers_dir).expanduser()
|
|
expected_keys = load_expected_keys(args.expected_zotero_keys)
|
|
|
|
if not papers_dir.exists():
|
|
print(f"ERROR: papers dir not found: {papers_dir}")
|
|
return 1
|
|
|
|
note_files = list(papers_dir.glob("*.md"))
|
|
key_to_file, issues, skipped_without_key = collect_note_status(
|
|
papers_dir, args.strict_missing_zotero_key
|
|
)
|
|
|
|
print(f"Papers dir: {papers_dir}")
|
|
print(f"Paper notes scanned: {len(note_files)}")
|
|
print(f"Notes with zotero_key: {len(key_to_file)}")
|
|
if skipped_without_key:
|
|
print(f"Notes skipped without zotero_key: {len(skipped_without_key)}")
|
|
|
|
if expected_keys:
|
|
missing = [key for key in expected_keys if key not in key_to_file]
|
|
extras = sorted(set(key_to_file) - set(expected_keys))
|
|
print(f"Expected Zotero keys: {len(expected_keys)}")
|
|
print(f"Covered Zotero keys: {len(expected_keys) - len(missing)} / {len(expected_keys)}")
|
|
if missing:
|
|
issues.append(f"Missing expected keys: {', '.join(missing)}")
|
|
if extras:
|
|
print(f"Extra zotero_key notes: {', '.join(extras)}")
|
|
|
|
if args.inventory_note:
|
|
inventory_path = Path(args.inventory_note).expanduser()
|
|
if not inventory_path.exists():
|
|
issues.append(f"inventory note not found: {inventory_path}")
|
|
else:
|
|
inventory_mapping, inventory_issues = parse_inventory_note(inventory_path)
|
|
issues.extend(inventory_issues)
|
|
if inventory_mapping:
|
|
for key, note_path in inventory_mapping.items():
|
|
expected_name = key_to_file.get(key)
|
|
if expected_name is None:
|
|
issues.append(f"inventory: key {key} not found in paper notes")
|
|
continue
|
|
if Path(note_path).name != expected_name:
|
|
issues.append(
|
|
f"inventory: key {key} points to {note_path}, expected file ending {expected_name}"
|
|
)
|
|
missing_in_inventory = sorted(set(key_to_file) - set(inventory_mapping))
|
|
if missing_in_inventory:
|
|
issues.append(
|
|
f"inventory: missing keys present in paper notes -> {', '.join(missing_in_inventory)}"
|
|
)
|
|
|
|
if skipped_without_key:
|
|
print("Skipped note files without zotero_key:")
|
|
for name in skipped_without_key:
|
|
print(f"- {name}")
|
|
|
|
if issues:
|
|
print("\nISSUES:")
|
|
for issue in issues:
|
|
print(f"- {issue}")
|
|
return 1
|
|
|
|
print("\nOK: schema and coverage checks passed.")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|