From a143679a50f5d3a84aea3ed4effcb3ac9e5af69f Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Sun, 19 Jul 2026 18:30:47 -0500 Subject: feat(flashcard): add apkg-to-orgdrill.py, the inverse of flashcard-to-anki The flashcard pipeline was one-directional (org-drill to apkg). A deck curated on the phone, or an orphaned apkg whose org source was never saved, had no way back into the org source of truth. apkg-to-orgdrill.py closes the loop. An apkg is a zip holding an Anki sqlite collection, so reading it needs no third-party library: stdlib zipfile + sqlite3. genanki is only needed to write apkgs, not read them, so the converter has no runtime deps. It mirrors flashcard-to-anki.py inverted: deck name to #+TITLE, note Front to the ** heading, note Back HTML to the body (
to newlines, entities unescaped, the answer
stripped), the note tag to a * section, and a fresh :ID: UUID per card so the output is org-drill-valid. The entity unescape runs in the order that inverts escape_html, which escaped & first, so & is unescaped last here. Otherwise a literally-escaped < in the source would wrongly collapse to <. Only Front/Back note types convert; a cloze or other model is skipped with a warning rather than silently dropped, and a note referencing media is flagged since the org side has no media path. 17 tests cover the HTML-to-org conversion, the apkg read (single and multiple decks, Default-deck exclusion, non-basic skip, .anki2 vs .anki21, media flag), the org emission, and errors. The round-trip test closes the loop through flashcard-to-anki.py's own parse(): a known org source becomes an apkg fixture, converts back, and the recovered (front, back, tag) tuples match the original. Verified end to end against a real genanki-produced apkg too. --- claude-templates/.ai/scripts/apkg-to-orgdrill.py | 251 +++++++++++++++++ .../.ai/scripts/tests/test_apkg_to_orgdrill.py | 299 +++++++++++++++++++++ 2 files changed, 550 insertions(+) create mode 100755 claude-templates/.ai/scripts/apkg-to-orgdrill.py create mode 100644 claude-templates/.ai/scripts/tests/test_apkg_to_orgdrill.py (limited to 'claude-templates') diff --git a/claude-templates/.ai/scripts/apkg-to-orgdrill.py b/claude-templates/.ai/scripts/apkg-to-orgdrill.py new file mode 100755 index 0000000..79e24a4 --- /dev/null +++ b/claude-templates/.ai/scripts/apkg-to-orgdrill.py @@ -0,0 +1,251 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.11" +# dependencies = [] +# /// +"""Convert an Anki .apkg deck into an org-drill file (inverse of flashcard-to-anki.py). + +The flashcard pipeline is otherwise one-directional (org-drill -> apkg). +Decks curated on the phone, and orphaned apkgs whose .org source was never +saved, can't get back into the org source of truth. This recovers them. + +Reading needs no third-party library: an apkg is a zip holding +collection.anki2 / .anki21 (an Anki sqlite db) plus a media blob, so stdlib +zipfile + sqlite3 suffice. genanki is only needed to write apkgs, not read +them. + +Mapping (mirrors flashcard-to-anki.py's parse/build, inverted): + - Deck name (from the apkg) -> #+TITLE: + - Note Front -> ** :drill: + - Note Back (HTML) -> entry body (
-> newlines, + &/</> unescaped, +
stripped) + - Note tag -> * section grouping + (best-effort: the tag is a slug, + so it won't round-trip to the exact + original section title — a human + retitles) + - A fresh :ID: UUID per card -> so the output is org-drill-valid + +GUIDs in flashcard-to-anki.py are derived from the Front text, not the +:ID:, so a deck regenerated from recovered org still matches existing phone +cards by Front. Only Front/Back (Basic) note types convert; other models +(cloze, etc.) are skipped with a warning rather than silently dropped. + +Usage: + apkg-to-orgdrill.py # one .org per deck in cwd + apkg-to-orgdrill.py --output-dir DIR + apkg-to-orgdrill.py --deck "Name" --output deck.org +""" +from __future__ import annotations + +import argparse +import json +import re +import sqlite3 +import sys +import tempfile +import uuid +import zipfile +from collections import OrderedDict +from dataclasses import dataclass +from pathlib import Path + +# Collection member names Anki uses, newest schema first. +COLLECTION_NAMES = ("collection.anki21", "collection.anki2") + +_BR_RE = re.compile(r"", re.IGNORECASE) +_ANSWER_HR_RE = re.compile(r'
', re.IGNORECASE) +_MEDIA_RE = re.compile(r" list[str]: + """Invert flashcard-to-anki.py's back-of-card HTML into org body lines. + +
(all spellings) and a stray answer
become line breaks; the + entity unescape undoes escape_html, which escaped ``&`` first — so ``&`` + is unescaped last here, or a literally-escaped ``<`` in the source + would wrongly collapse to ``<``. + """ + if not back_html: + return [] + s = _ANSWER_HR_RE.sub("\n", back_html) + s = _BR_RE.sub("\n", s) + s = s.replace("<", "<").replace(">", ">").replace("&", "&") + return s.split("\n") + + +def _slug(title: str) -> str: + return re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") + + +def _read_collection(db_path: Path) -> list[Note]: + con = sqlite3.connect(db_path) + try: + row = con.execute("SELECT decks, models FROM col LIMIT 1").fetchone() + if row is None: + raise ValueError("collection has no col row") + decks_json, models_json = row + decks = {int(k): v["name"] for k, v in json.loads(decks_json).items()} + models = { + int(k): [f["name"] for f in v["flds"]] + for k, v in json.loads(models_json).items() + } + + # A note's deck comes from its card; the Default deck (id 1) carries + # no cards from this pipeline, so it never shows up here. + nid_to_did: dict[int, int] = {} + for nid, did in con.execute("SELECT nid, did FROM cards"): + nid_to_did.setdefault(nid, did) + + notes: list[Note] = [] + for nid, mid, flds, tags in con.execute( + "SELECT id, mid, flds, tags FROM notes" + ): + field_names = models.get(mid) + if not field_names or "Front" not in field_names or "Back" not in field_names: + print( + f"apkg-to-orgdrill: skip note {nid} — model is not a Front/Back " + f"type (fields={field_names})", + file=sys.stderr, + ) + continue + fields = flds.split("\x1f") + fi, bi = field_names.index("Front"), field_names.index("Back") + front = fields[fi] if fi < len(fields) else "" + back_html = fields[bi] if bi < len(fields) else "" + + did = nid_to_did.get(nid) + if did is None: + continue # note with no card — orphan + deck = decks.get(did) + if deck is None: + continue + + tag_list = tags.split() + tag = tag_list[0] if tag_list else "drill" + + if _MEDIA_RE.search(back_html): + print( + f"apkg-to-orgdrill: note {nid} references media; org has no " + f"media path (left inline for a human to resolve)", + file=sys.stderr, + ) + notes.append(Note(deck=deck, front=front, back_html=back_html, tag=tag)) + return notes + finally: + con.close() + + +def read_apkg(path: Path) -> list[Note]: + """Read an .apkg and return its Front/Back notes. Raises on a malformed file.""" + with zipfile.ZipFile(path) as z: # BadZipFile if it isn't a zip + names = set(z.namelist()) + col_name = next((n for n in COLLECTION_NAMES if n in names), None) + if col_name is None: + raise ValueError(f"{path}: no collection.anki2/.anki21 inside the apkg") + with tempfile.TemporaryDirectory() as td: + db_path = Path(td) / col_name + db_path.write_bytes(z.read(col_name)) + return _read_collection(db_path) + + +def notes_to_org(notes: list[Note], deck_name: str, *, new_id=None) -> str: + """Render one deck's notes as an org-drill file in the house shape.""" + if new_id is None: + new_id = lambda: str(uuid.uuid4()) # noqa: E731 + groups: "OrderedDict[str, list[Note]]" = OrderedDict() + for n in notes: + groups.setdefault(n.tag, []).append(n) + + lines: list[str] = [f"#+TITLE: {deck_name}", ""] + for tag, group in groups.items(): + lines.append(f"* {tag}") + for n in group: + lines.append(f"** {n.front} :drill:") + lines.append(":PROPERTIES:") + lines.append(f":ID: {new_id()}") + lines.append(":END:") + lines.extend(html_to_org_body(n.back_html)) + lines.append("") + return "\n".join(lines).rstrip("\n") + "\n" + + +def convert(apkg_path: Path, *, new_id=None) -> "OrderedDict[str, str]": + """apkg -> {deck_name: org_text}, one entry per deck that has Front/Back cards.""" + by_deck: "OrderedDict[str, list[Note]]" = OrderedDict() + for n in read_apkg(apkg_path): + by_deck.setdefault(n.deck, []).append(n) + out: "OrderedDict[str, str]" = OrderedDict() + for deck, deck_notes in by_deck.items(): + out[deck] = notes_to_org(deck_notes, deck, new_id=new_id) + return out + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Convert an Anki .apkg deck into an org-drill file.", + ) + parser.add_argument("input", type=Path, help="Path to the .apkg file.") + parser.add_argument("--deck", help="Only convert the deck with this exact name.") + parser.add_argument( + "--output", + type=Path, + help="Output .org path. Requires a single deck (use --deck to pick one).", + ) + parser.add_argument( + "--output-dir", + type=Path, + help="Directory for per-deck .org files (default: current directory).", + ) + args = parser.parse_args() + + input_path = args.input.expanduser().resolve() + if not input_path.is_file(): + print(f"error: {input_path} not found", file=sys.stderr) + return 1 + + by_deck = convert(input_path) + if args.deck: + by_deck = OrderedDict((k, v) for k, v in by_deck.items() if k == args.deck) + if not by_deck: + print(f"error: no deck named {args.deck!r} in {input_path}", file=sys.stderr) + return 1 + if not by_deck: + print(f"error: no Front/Back cards found in {input_path}", file=sys.stderr) + return 1 + + if args.output: + if len(by_deck) != 1: + print( + f"error: --output needs a single deck; {input_path} has " + f"{len(by_deck)} ({', '.join(by_deck)}). Use --deck or --output-dir.", + file=sys.stderr, + ) + return 1 + out = args.output.expanduser().resolve() + out.parent.mkdir(parents=True, exist_ok=True) + deck, org = next(iter(by_deck.items())) + out.write_text(org, encoding="utf-8") + print(f"wrote {out} ({org.count(':drill:')} cards, deck {deck!r})") + return 0 + + out_dir = (args.output_dir or Path.cwd()).expanduser().resolve() + out_dir.mkdir(parents=True, exist_ok=True) + for deck, org in by_deck.items(): + out = out_dir / f"{_slug(deck) or 'deck'}.org" + out.write_text(org, encoding="utf-8") + print(f"wrote {out} ({org.count(':drill:')} cards, deck {deck!r})") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/claude-templates/.ai/scripts/tests/test_apkg_to_orgdrill.py b/claude-templates/.ai/scripts/tests/test_apkg_to_orgdrill.py new file mode 100644 index 0000000..adffef0 --- /dev/null +++ b/claude-templates/.ai/scripts/tests/test_apkg_to_orgdrill.py @@ -0,0 +1,299 @@ +"""Tests for apkg-to-orgdrill.py — the inverse of flashcard-to-anki.py. + +The converter reads an Anki .apkg (a zip holding collection.anki2 / .anki21 +sqlite) and emits an org-drill .org in the house canonical shape. It is +stdlib-only (zipfile + sqlite3), so it imports directly — no genanki stub. + +The apkg schema these tests build by hand mirrors what genanki actually +writes, confirmed against a real apkg generated from flashcard-to-anki.py: + - col.decks : JSON {did: {"name": ...}}, always including id-1 "Default" + - col.models : JSON {mid: {"name": ..., "flds": [{"name": "Front"}, ...]}} + - notes.flds : fields joined by \x1f; tags space-padded (" tag ") + - cards : nid -> did (the Default deck carries no cards) + +The round-trip test closes the loop through flashcard-to-anki.py's own +parse(): original org -> forward parse tuples -> apkg fixture -> converter +-> recovered org -> forward parse -> assert the (front, back, tag) tuples +match. Only the apkg materialization is hand-built (the genanki boundary); +everything else is the real code on both sides. +""" +from __future__ import annotations + +import importlib.util +import json +import sqlite3 +import sys +import types +import zipfile +from pathlib import Path + +import pytest + +SCRIPTS = Path(__file__).resolve().parents[1] +CONVERTER = SCRIPTS / "apkg-to-orgdrill.py" +FORWARD = SCRIPTS / "flashcard-to-anki.py" + + +def _load(path: Path, name: str, stub_genanki: bool = False): + if stub_genanki: + sys.modules.setdefault("genanki", types.ModuleType("genanki")) + spec = importlib.util.spec_from_file_location(name, path) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + # Register before exec: @dataclass resolves cls.__module__ via sys.modules + # (Python 3.14), which is None for an unregistered importlib module. + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +@pytest.fixture(scope="module") +def conv(): + return _load(CONVERTER, "apkg_to_orgdrill") + + +@pytest.fixture(scope="module") +def forward(): + return _load(FORWARD, "flashcard_to_anki", stub_genanki=True) + + +# --- fixture builder: write a genanki-shaped apkg by hand ------------------ + +def _make_apkg( + path: Path, + decks: dict[int, str], + models: dict[int, list[str]], + notes: list[tuple[int, int, list[str], str]], # (nid, mid, fields, tag) + cards: list[tuple[int, int]], # (nid, did) + *, + media: str = "{}", +) -> None: + """Materialize a minimal apkg matching genanki's collection.anki2 shape.""" + col_dir = path.parent / f"{path.stem}-build" + col_dir.mkdir(parents=True, exist_ok=True) + db = col_dir / "collection.anki2" + if db.exists(): + db.unlink() + con = sqlite3.connect(db) + con.execute("CREATE TABLE col (id INTEGER, decks TEXT, models TEXT)") + decks_json = {"1": {"name": "Default"}} + decks_json.update({str(did): {"name": name} for did, name in decks.items()}) + models_json = { + str(mid): {"name": f"{decks.get(list(decks)[0], 'M')} model", + "flds": [{"name": n, "ord": i} for i, n in enumerate(flds)]} + for mid, flds in models.items() + } + con.execute("INSERT INTO col (id, decks, models) VALUES (1, ?, ?)", + (json.dumps(decks_json), json.dumps(models_json))) + con.execute("CREATE TABLE notes (id INTEGER, mid INTEGER, flds TEXT, tags TEXT)") + for nid, mid, fields, tag in notes: + con.execute("INSERT INTO notes (id, mid, flds, tags) VALUES (?, ?, ?, ?)", + (nid, mid, "\x1f".join(fields), f" {tag} " if tag else " ")) + con.execute("CREATE TABLE cards (id INTEGER, nid INTEGER, did INTEGER)") + for i, (nid, did) in enumerate(cards): + con.execute("INSERT INTO cards (id, nid, did) VALUES (?, ?, ?)", (1000 + i, nid, did)) + con.commit() + con.close() + with zipfile.ZipFile(path, "w") as z: + z.write(db, "collection.anki2") + z.writestr("media", media) + + +# --- html_to_org_body ------------------------------------------------------ + +def test_html_to_org_splits_br_into_lines(conv): + assert conv.html_to_org_body("one
two
three") == ["one", "two", "three"] + + +def test_html_to_org_handles_br_variants(conv): + assert conv.html_to_org_body("a
b
c
d") == ["a", "b", "c", "d"] + + +def test_html_to_org_unescapes_entities_amp_last(conv): + # Inverts escape_html (which escapes & first): < > & -> < > &. + assert conv.html_to_org_body("x <tag> & y") == ["x & y"] + + +def test_html_to_org_preserves_a_literal_escaped_entity(conv): + # Forward-escaping the literal "<" yields "&lt;"; the inverse must + # recover "<", not "<". + assert conv.html_to_org_body("&lt;") == ["<"] + + +def test_html_to_org_strips_answer_hr(conv): + assert conv.html_to_org_body('front
back') == ["front", "back"] + + +def test_html_to_org_empty_back_is_empty(conv): + assert conv.html_to_org_body("") == [] + + +# --- read_apkg ------------------------------------------------------------- + +def test_read_apkg_single_deck_recovers_front_back_tag_deck(conv, tmp_path): + apkg = tmp_path / "d.apkg" + _make_apkg( + apkg, + decks={20: "My Deck"}, + models={9: ["Front", "Back"]}, + notes=[(100, 9, ["Q1?", "A1.
line2"], "sec-one")], + cards=[(100, 20)], + ) + recovered = conv.read_apkg(apkg) + assert len(recovered) == 1 + note = recovered[0] + assert note.deck == "My Deck" + assert note.front == "Q1?" + assert note.back_html == "A1.
line2" + assert note.tag == "sec-one" + + +def test_read_apkg_multiple_decks_grouped(conv, tmp_path): + apkg = tmp_path / "multi.apkg" + _make_apkg( + apkg, + decks={20: "Deck A", 21: "Deck B"}, + models={9: ["Front", "Back"]}, + notes=[(100, 9, ["QA?", "AA"], "ta"), (101, 9, ["QB?", "AB"], "tb")], + cards=[(100, 20), (101, 21)], + ) + decks = {n.deck for n in conv.read_apkg(apkg)} + assert decks == {"Deck A", "Deck B"} + + +def test_read_apkg_skips_default_deck_without_cards(conv, tmp_path): + apkg = tmp_path / "def.apkg" + _make_apkg( + apkg, + decks={20: "Real Deck"}, + models={9: ["Front", "Back"]}, + notes=[(100, 9, ["Q?", "A"], "t")], + cards=[(100, 20)], + ) + assert {n.deck for n in conv.read_apkg(apkg)} == {"Real Deck"} + + +def test_read_apkg_warns_and_skips_non_basic_model(conv, tmp_path, capsys): + apkg = tmp_path / "cloze.apkg" + _make_apkg( + apkg, + decks={20: "Cloze Deck"}, + models={9: ["Text", "Extra"]}, # not Front/Back + notes=[(100, 9, ["some {{c1::text}}", "extra"], "t")], + cards=[(100, 20)], + ) + recovered = conv.read_apkg(apkg) + assert recovered == [] + assert "skip" in capsys.readouterr().err.lower() + + +def test_read_apkg_reads_anki21_collection_name(conv, tmp_path): + # A .anki21 collection filename must be read the same as .anki2. + apkg = tmp_path / "new.apkg" + _make_apkg( + apkg, + decks={20: "Deck"}, + models={9: ["Front", "Back"]}, + notes=[(100, 9, ["Q?", "A"], "t")], + cards=[(100, 20)], + ) + # Rewrite the zip renaming the collection member to .anki21. + with zipfile.ZipFile(apkg) as z: + data = z.read("collection.anki2") + media = z.read("media") + with zipfile.ZipFile(apkg, "w") as z: + z.writestr("collection.anki21", data) + z.writestr("media", media) + assert conv.read_apkg(apkg)[0].front == "Q?" + + +def test_read_apkg_flags_media_reference(conv, tmp_path, capsys): + apkg = tmp_path / "media.apkg" + _make_apkg( + apkg, + decks={20: "Deck"}, + models={9: ["Front", "Back"]}, + notes=[(100, 9, ["Q?", 'see '], "t")], + cards=[(100, 20)], + ) + conv.read_apkg(apkg) + assert "media" in capsys.readouterr().err.lower() + + +# --- notes_to_org ---------------------------------------------------------- + +def test_notes_to_org_emits_canonical_shape(conv): + Note = conv.Note + notes = [ + Note(deck="My Deck", front="Q1?", back_html="A1.", tag="alpha"), + Note(deck="My Deck", front="Q2?", back_html="A2.", tag="alpha"), + ] + ids = iter(["id-1", "id-2"]) + org = conv.notes_to_org(notes, "My Deck", new_id=lambda: next(ids)) + assert "#+TITLE: My Deck" in org + assert "* alpha" in org + assert "** Q1? :drill:" in org + assert ":ID: id-1" in org + assert ":ID: id-2" in org + assert org.count("* alpha") == 1 # both cards share one section + + +def test_notes_to_org_distinct_tags_get_distinct_sections(conv): + Note = conv.Note + notes = [ + Note(deck="D", front="Qa?", back_html="a", tag="alpha"), + Note(deck="D", front="Qb?", back_html="b", tag="beta"), + ] + org = conv.notes_to_org(notes, "D", new_id=lambda: "x") + assert "* alpha" in org and "* beta" in org + + +# --- round-trip through the real forward parse() --------------------------- + +def test_round_trip_matches_forward_parse_tuples(conv, forward, tmp_path): + original = ( + "#+TITLE: RT Deck\n" + "\n" + "* First Section\n" + "** What is 2+2? :drill:\n" + ":PROPERTIES:\n:ID: aaaa\n:END:\n" + "Four.\n" + "Second line with & amp.\n" + "\n" + "* Second Section\n" + "** Capital of France? :drill:\n" + "Paris.\n" + ) + tuples = forward.parse(original) # [(front, back_html, tag), ...] + assert len(tuples) == 2 + + apkg = tmp_path / "rt.apkg" + _make_apkg( + apkg, + decks={20: "RT Deck"}, + models={9: ["Front", "Back"]}, + notes=[(100 + i, 9, [f, b], t) for i, (f, b, t) in enumerate(tuples)], + cards=[(100 + i, 20) for i in range(len(tuples))], + ) + + by_deck = conv.convert(apkg) + assert set(by_deck) == {"RT Deck"} + recovered_tuples = forward.parse(by_deck["RT Deck"]) + assert recovered_tuples == tuples + + +# --- errors ---------------------------------------------------------------- + +def test_read_apkg_missing_collection_errors(conv, tmp_path): + bad = tmp_path / "bad.apkg" + with zipfile.ZipFile(bad, "w") as z: + z.writestr("media", "{}") + with pytest.raises(Exception): + conv.read_apkg(bad) + + +def test_read_apkg_not_a_zip_errors(conv, tmp_path): + notzip = tmp_path / "plain.apkg" + notzip.write_text("not a zip") + with pytest.raises(Exception): + conv.read_apkg(notzip) -- cgit v1.2.3