#!/usr/bin/env python3 """Builds the Anki packages the app's import tests read, with Anki itself. The `anki` Python package is Anki's own backend, so the packages are exactly what Anki exports: one collection, exported in the latest format, in the legacy format ("Support older Anki versions"), and repacked the way Anki before 2.1.50 wrote packages (only `collection.anki2`, no `meta`). The collection holds, on purpose: - a Basic note with bold text, a line break, an image, and two tags, one of them hierarchical (deck "Japanese"); - a "Basic (and reversed card)" note with a sound, both its cards answered, one of them first wrong (deck "Japanese::Animals"); - a Cloze note with two deletions, never reviewed; - a Basic note with a list and HTML entities ("Japanese::Animals"); - a manual reschedule in the review log (no answer). A second deck (`anki_furigana*.apkg`) has a note type that shows its Reading through Anki's furigana filter, and the same text in its Expression. Run from apps/learn, in a virtual environment with Anki's package: python3 -m venv /tmp/anki && /tmp/anki/bin/pip install anki==26.9.3 /tmp/anki/bin/python tool/anki/make_fixtures.py It rewrites halkyon_learn_flutter/test/feature/anki/fixtures/*.apkg. Ids and times differ on every run; the tests don't depend on them. """ import io import os import tempfile import zipfile from pathlib import Path from anki.collection import Collection, ExportAnkiPackageOptions from anki.decks import DeckId OUT = Path("halkyon_learn_flutter/test/feature/anki/fixtures") # Valid files, so the packages also serve to try the import by hand: a 1x1 # PNG (one orange pixel), and 0.26 s of silence as an MP3 (ten MPEG-1 Layer # III frames, 128 kbit/s, 44.1 kHz, mono, whose zeroed side information # decodes to silence). PNG = bytes.fromhex( "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489" "0000000d49444154789c63f8dfc0f01f000680027f104c1be10000000049454e44ae426082" ) MP3 = (bytes.fromhex("fffb90c4") + bytes(413)) * 10 AGAIN, GOOD, EASY = 0, 2, 3 def main(): OUT.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory() as work: col = Collection(os.path.join(work, "collection.anki2")) col.media.write_data("cat.png", PNG) col.media.write_data("neko.mp3", MP3) japanese = col.decks.id("Japanese") animals = col.decks.id("Japanese::Animals") def add(model, deck, fields, tags): note = col.new_note(col.models.by_name(model)) for i, field in enumerate(fields): note.fields[i] = field note.tags = tags col.add_note(note, DeckId(deck)) return note def answer(card_id, rating): card = col.get_card(card_id) card.start_timer() states = col._backend.get_scheduling_states(card.id) col.sched.answer_card( col.sched.build_answer(card=card, states=states, rating=rating) ) cat = add( "Basic", japanese, ["猫", 'cat
a pet'], ["animal", "JLPT::N5"], ) dog = add( "Basic (and reversed card)", animals, ["犬 [sound:neko.mp3]", "dog"], ["animal"], ) add("Cloze", japanese, ["{{c1::Tokyo}} is the capital of {{c2::Japan::country}}", "extra"], []) add( "Basic", animals, ["", "<tag> & more"], [], ) answer(cat.card_ids()[0], EASY) forward, reverse = sorted(dog.card_ids(), key=lambda c: col.get_card(c).ord) answer(forward, AGAIN) answer(forward, GOOD) answer(reverse, EASY) col.sched.set_due_date([cat.card_ids()[0]], "5") for legacy, name in [(False, "anki_latest.apkg"), (True, "anki_legacy.apkg")]: col.export_anki_package( out_path=str(OUT / name), options=ExportAnkiPackageOptions( with_scheduling=True, with_deck_configs=False, with_media=True, legacy=legacy, ), limit=None, ) col.close() # Before 2.1.50: the collection as collection.anki2, no meta, no stub. with zipfile.ZipFile(OUT / "anki_legacy.apkg") as legacy: out = io.BytesIO() with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as old: for info in legacy.infolist(): if info.filename in ("meta", "collection.anki2"): continue name = "collection.anki2" if info.filename == "collection.anki21" else info.filename old.writestr(name, legacy.read(info.filename)) (OUT / "anki_before_2_1_50.apkg").write_bytes(out.getvalue()) def furigana(): """A deck whose note type shows a field through Anki's furigana filter, in the latest format and the legacy one: the import turns that field's 漢字[かんじ] into readings, and leaves the same text in another field.""" with tempfile.TemporaryDirectory() as work: col = Collection(os.path.join(work, "collection.anki2")) models = col.models model = models.new("Japanese (furigana)") for name in ("Expression", "Reading", "Meaning"): models.add_field(model, models.new_field(name)) template = models.new_template("Recognition") template["qfmt"] = "{{Expression}}" template["afmt"] = "{{FrontSide}}
{{furigana:Reading}}
{{Meaning}}" models.add_template(model, template) models.add(model) note = col.new_note(col.models.by_name("Japanese (furigana)")) note.fields[0] = "日本語[にほんご]" note.fields[1] = "日本語[にほんご]を 勉強[べんきょう]する" note.fields[2] = "to study Japanese" col.add_note(note, DeckId(col.decks.id("Furigana"))) for legacy, name in [ (False, "anki_furigana.apkg"), (True, "anki_furigana_legacy.apkg"), ]: col.export_anki_package( out_path=str(OUT / name), options=ExportAnkiPackageOptions( with_scheduling=False, with_deck_configs=False, with_media=False, legacy=legacy, ), limit=None, ) col.close() if __name__ == "__main__": main() furigana()