halkyon/tool/anki/make_fixtures.py
Tizian 「ティツィアーン」 dd36424917
feat(app): Anki's bracket furigana becomes readings where its templates show them
A field a note type shows through Anki's furigana filters
({{furigana:Reading}}, kanji:, kana:) holds 漢字[かんじ]; the import now
turns it into readings over the text before the brackets, as Anki's own
filters read it (back to a space, which goes). Other fields stay as
written; a sound stays a sound. The note types' templates and field
names are read from both package formats (Anki's unicase collation
registered so their tables can be read at all). New fixtures exported
by Anki itself (tool/anki/make_fixtures.py) test both formats.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 18:09:30 +02:00

170 lines
6.5 KiB
Python

#!/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 the repository root, 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,
["<b>猫</b>", 'cat<br>a pet<img src="cat.png">'],
["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,
["<ul><li>one</li><li>two</li></ul>", "&lt;tag&gt; &amp; 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}}<hr id=answer>{{furigana:Reading}}<br>{{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()