#!/usr/bin/env python3 """Inventory + workflow-violation warnings for an org-drill deck source file. Reports counts and flags violations: - Total cards (depth-2 `:drill:` headings) - PROPERTIES drawer count (should match card count) - `*** Answer` sub-header count (should be 0 per drill-deck-review.org) - Cards missing :ID: (loses identity across versions, risks SRS-state loss) - Cards whose heading lacks `?` (likely a topic-as-heading not yet rewritten) Exits 0 when clean, 1 when any warnings are present. Use as a gate before regenerating the Anki deck or running drill-deck-sync. Usage: drill-deck-stats.py """ from __future__ import annotations import re import sys from pathlib import Path CARD_RE = re.compile(r"^\*\*\s+(.+?)\s+:drill:\s*$") ANSWER_RE = re.compile(r"^\*\*\*\s+Answer\b") PROP_START_RE = re.compile(r"^\s*:PROPERTIES:\s*$") PROP_END_RE = re.compile(r"^\s*:END:\s*$") ID_RE = re.compile(r"^\s*:ID:\s+(\S+)\s*$") TITLE_RE = re.compile(r"^#\+TITLE:\s*(.+?)\s*$", re.IGNORECASE) SOURCE_TOOL_RE = re.compile(r"\borg[-\s]?drill\b", re.IGNORECASE) # A heading qualifies as "prompt form" if it contains `?` or starts with # one of these imperative verbs (directive prompts like "Spell these out" # and "Introduce yourself" are valid even without `?`). IMPERATIVE_VERBS = frozenset({ "spell", "describe", "explain", "name", "list", "give", "show", "tell", "define", "compare", "identify", "outline", "introduce", "walk", "state", "recite", "recall", "summarize", }) def is_prompt_form(heading: str) -> bool: """True if the heading reads as a question or imperative prompt.""" if "?" in heading: return True first_word = heading.split(None, 1)[0].lower().rstrip(":,;") return first_word in IMPERATIVE_VERBS def main() -> int: if len(sys.argv) != 2: print(f"usage: {sys.argv[0]} ", file=sys.stderr) return 2 path = Path(sys.argv[1]).expanduser().resolve() if not path.is_file(): print(f"error: {path} not found", file=sys.stderr) return 2 lines = path.read_text(encoding="utf-8").splitlines() title: str | None = None for line in lines[:20]: m = TITLE_RE.match(line) if m: title = m.group(1).strip() break cards: list[tuple[str, bool, bool]] = [] # (heading, has_id, has_answer_subheader) answer_count = 0 prop_count = 0 i = 0 while i < len(lines): m = CARD_RE.match(lines[i]) if m: heading = m.group(1).strip() i += 1 has_id = False has_answer = False in_drawer = False while i < len(lines): line = lines[i] if line.startswith("* ") or CARD_RE.match(line): break if PROP_START_RE.match(line): prop_count += 1 in_drawer = True elif in_drawer and PROP_END_RE.match(line): in_drawer = False elif in_drawer and ID_RE.match(line): has_id = True elif ANSWER_RE.match(line): answer_count += 1 has_answer = True i += 1 cards.append((heading, has_id, has_answer)) continue i += 1 not_prompt = [h for h, _, _ in cards if not is_prompt_form(h)] no_id = [h for h, has_id, _ in cards if not has_id] print(f"{path.name} — drill deck stats") print() title_display = title if title else "(no #+TITLE)" print(f"Deck title: {title_display}") print(f"Cards: {len(cards)}") drawer_status = "match" if prop_count == len(cards) else f"mismatch (expected {len(cards)})" print(f"PROPERTIES drawers: {prop_count} ({drawer_status})") answer_status = "clean" if answer_count == 0 else "workflow violation" print(f"*** Answer sub-headers: {answer_count} ({answer_status})") print(f"Cards missing :ID:: {len(no_id)}") print(f"Cards with non-prompt heading: {len(not_prompt)}") print() warnings = 0 if title is None: warnings += 1 print("WARN: no #+TITLE: line found; deck name will fall back to the file basename") elif SOURCE_TOOL_RE.search(title): warnings += 1 print(f"WARN: #+TITLE contains source-tool jargon ('{title}'); the deck name shows in Anki — drop 'Org-Drill' for a name that reads well on the consumption side") if answer_count: warnings += 1 print(f"WARN: {answer_count} cards have *** Answer sub-headers (drop per drill-deck-review.org)") if prop_count != len(cards): warnings += 1 print(f"WARN: PROPERTIES count {prop_count} does not match card count {len(cards)}") if no_id: warnings += 1 print(f"WARN: {len(no_id)} cards missing :ID:; losing identity risks SRS-state loss across rewrites") for h in no_id[:5]: print(f" - {h}") if len(no_id) > 5: print(f" - ... and {len(no_id) - 5} more") if not_prompt: warnings += 1 print(f"WARN: {len(not_prompt)} cards have non-prompt headings (no '?' and no imperative-verb start); likely topic-as-heading not yet rewritten") for h in not_prompt[:5]: print(f" - {h}") if len(not_prompt) > 5: print(f" - ... and {len(not_prompt) - 5} more") if warnings == 0: print("clean") return 0 return 1 if __name__ == "__main__": raise SystemExit(main())