159 lines
4.4 KiB
Python
159 lines
4.4 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
"""Auto-generate Anki flashcards from problem .org files.
|
||
|
|
|
||
|
|
Generates one recognition card per problem:
|
||
|
|
Front: Problem number + name
|
||
|
|
Back: Problem statement (first paragraph)
|
||
|
|
"""
|
||
|
|
|
||
|
|
import re
|
||
|
|
import sys
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
BASE = Path(__file__).parent
|
||
|
|
DSA = BASE / "dsa"
|
||
|
|
OUTPUT = BASE / "toolkit" / "gen-problem-cards.org"
|
||
|
|
|
||
|
|
TOPIC_DISPLAY = {
|
||
|
|
"1-d-dynamic-programming": "1D DP",
|
||
|
|
"2-d-dynamic-programming": "2D DP",
|
||
|
|
"advanced-graphs": "Advanced Graphs",
|
||
|
|
"arrays-hashing": "Arrays & Hashing",
|
||
|
|
"backtracking": "Backtracking",
|
||
|
|
"binary-search": "Binary Search",
|
||
|
|
"bit-manipulation": "Bit Manipulation",
|
||
|
|
"graphs": "Graphs",
|
||
|
|
"greedy": "Greedy",
|
||
|
|
"heap-priority-queue": "Heap / Priority Queue",
|
||
|
|
"intervals": "Intervals",
|
||
|
|
"linked-list": "Linked List",
|
||
|
|
"math-geometry": "Math & Geometry",
|
||
|
|
"sliding-window": "Sliding Window",
|
||
|
|
"stack": "Stack",
|
||
|
|
"trees": "Trees",
|
||
|
|
"tries": "Tries",
|
||
|
|
"two-pointers": "Two Pointers",
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def parse_file(path: Path) -> dict | None:
|
||
|
|
"""Extract problem metadata and statement from an .org file."""
|
||
|
|
text = path.read_text()
|
||
|
|
|
||
|
|
# Match heading: * TODO 0217. Contains Duplicate :easy:
|
||
|
|
m = re.search(
|
||
|
|
r"^\* (?:TODO|DONE) (\d{4})\. (.+?) :(easy|medium|hard):",
|
||
|
|
text,
|
||
|
|
re.MULTILINE,
|
||
|
|
)
|
||
|
|
if not m:
|
||
|
|
return None
|
||
|
|
|
||
|
|
num = m.group(1)
|
||
|
|
name = m.group(2).strip()
|
||
|
|
difficulty = m.group(3)
|
||
|
|
|
||
|
|
# Topic = parent directory name
|
||
|
|
topic_slug = path.parent.name
|
||
|
|
topic = TOPIC_DISPLAY.get(topic_slug, topic_slug.replace("-", " ").title())
|
||
|
|
|
||
|
|
# Problem statement: first non-empty, non-heading, non-property line after :END:
|
||
|
|
after_props = text[m.end():]
|
||
|
|
lines = after_props.split("\n")
|
||
|
|
statement_lines = []
|
||
|
|
in_block = False
|
||
|
|
for line in lines:
|
||
|
|
stripped = line.strip()
|
||
|
|
if stripped == ":END:":
|
||
|
|
continue
|
||
|
|
if not stripped:
|
||
|
|
if statement_lines:
|
||
|
|
break
|
||
|
|
continue
|
||
|
|
if stripped.startswith("#+") or stripped.startswith("*") or stripped.startswith(":"):
|
||
|
|
if statement_lines:
|
||
|
|
break
|
||
|
|
continue
|
||
|
|
statement_lines.append(stripped)
|
||
|
|
|
||
|
|
statement = " ".join(statement_lines).strip()
|
||
|
|
# Clean up org markup for plain text
|
||
|
|
statement = re.sub(r"[~=*/_]", "", statement)
|
||
|
|
|
||
|
|
return {
|
||
|
|
"num": num,
|
||
|
|
"name": name,
|
||
|
|
"difficulty": difficulty,
|
||
|
|
"topic": topic,
|
||
|
|
"statement": statement,
|
||
|
|
"path": path,
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def generate_cards(problems: list[dict]) -> str:
|
||
|
|
"""Generate org-mode flashcard content."""
|
||
|
|
parts = []
|
||
|
|
parts.append("#+TITLE: Auto-Generated Problem Cards")
|
||
|
|
parts.append("#+ANKI_DECK: study_deck_02")
|
||
|
|
parts.append("")
|
||
|
|
|
||
|
|
for p in sorted(problems, key=lambda x: int(x["num"])):
|
||
|
|
num = p["num"]
|
||
|
|
name = p["name"]
|
||
|
|
diff = p["difficulty"]
|
||
|
|
topic = p["topic"]
|
||
|
|
stmt = p["statement"]
|
||
|
|
|
||
|
|
# Card: given problem name → what does it ask?
|
||
|
|
title = f"LC {num}. {name} — what does it ask?"
|
||
|
|
tags = f":leetcode:{diff}:{topic.replace(' ', '-').lower()}:retrieval::recognition:"
|
||
|
|
|
||
|
|
parts.append(f"* {title} {tags}")
|
||
|
|
parts.append(":PROPERTIES:")
|
||
|
|
parts.append(":ANKI_NOTE_TYPE: Basic")
|
||
|
|
parts.append(":END:")
|
||
|
|
parts.append("** Front")
|
||
|
|
parts.append(f"What does LeetCode {num} *{name}* ask you to do?")
|
||
|
|
parts.append("** Back")
|
||
|
|
parts.append(stmt)
|
||
|
|
parts.append("")
|
||
|
|
|
||
|
|
return "\n".join(parts)
|
||
|
|
|
||
|
|
|
||
|
|
def main():
|
||
|
|
dry_run = "--dry-run" in sys.argv
|
||
|
|
|
||
|
|
org_files = sorted(DSA.rglob("*.org"))
|
||
|
|
org_files = [f for f in org_files if f.name != "udfs.org"]
|
||
|
|
print(f"Found {len(org_files)} problem files")
|
||
|
|
|
||
|
|
problems = []
|
||
|
|
skipped = 0
|
||
|
|
for f in org_files:
|
||
|
|
p = parse_file(f)
|
||
|
|
if p:
|
||
|
|
problems.append(p)
|
||
|
|
else:
|
||
|
|
skipped += 1
|
||
|
|
print(f" skipped: {f.name}")
|
||
|
|
|
||
|
|
print(f"Parsed: {len(problems)} problems, {skipped} skipped")
|
||
|
|
|
||
|
|
content = generate_cards(problems)
|
||
|
|
|
||
|
|
if dry_run:
|
||
|
|
print(f"\nWould write {len(problems)} cards to {OUTPUT}")
|
||
|
|
# Show first 3 cards as preview
|
||
|
|
preview = content.split("\n\n\n")[:3]
|
||
|
|
print("\n--- Preview ---")
|
||
|
|
print("\n\n".join(preview))
|
||
|
|
else:
|
||
|
|
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
|
||
|
|
OUTPUT.write_text(content)
|
||
|
|
print(f"\nWrote {len(problems)} cards to {OUTPUT}")
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
main()
|