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>
170 lines
6.5 KiB
Python
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>", "<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}}<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()
|