aboutsummaryrefslogtreecommitdiff
path: root/.ai/scripts/apkg-to-orgdrill.py
diff options
context:
space:
mode:
Diffstat (limited to '.ai/scripts/apkg-to-orgdrill.py')
-rwxr-xr-x.ai/scripts/apkg-to-orgdrill.py251
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,
+ &amp;/&lt;/&gt; 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 ``&lt;`` 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("&lt;", "<").replace("&gt;", ">").replace("&amp;", "&")
+ 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())