diff options
Diffstat (limited to '.ai/scripts/apkg-to-orgdrill.py')
| -rwxr-xr-x | .ai/scripts/apkg-to-orgdrill.py | 251 |
1 files changed, 251 insertions, 0 deletions
diff --git a/.ai/scripts/apkg-to-orgdrill.py b/.ai/scripts/apkg-to-orgdrill.py new file mode 100755 index 0000000..79e24a4 --- /dev/null +++ b/.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 -> ** <Front> :drill: + - Note Back (HTML) -> entry body (<br> -> newlines, + &/</> unescaped, + <hr id="answer"> stripped) + - Note tag -> * <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 <input.apkg> # one <deck-slug>.org per deck in cwd + apkg-to-orgdrill.py <input.apkg> --output-dir DIR + apkg-to-orgdrill.py <input.apkg> --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"<br\s*/?>", re.IGNORECASE) +_ANSWER_HR_RE = re.compile(r'<hr id="answer">', re.IGNORECASE) +_MEDIA_RE = re.compile(r"<img\b|\[sound:|<audio\b|<video\b", re.IGNORECASE) + + +@dataclass +class Note: + deck: str + front: str + back_html: str + tag: str + + +def html_to_org_body(back_html: str) -> list[str]: + """Invert flashcard-to-anki.py's back-of-card HTML into org body lines. + + <br> (all spellings) and a stray answer <hr> 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()) |
