Import Anki decks with their files and history

Files in content: the person's images and sounds as asset rows synced like
all content, their files kept by hash on each device, uploaded after each
sync, downloaded on demand and checked against their hash
(core/asset/AssetRepository). Rich text names them as asset:<id> URLs, in
the editor's image block and an audio block of ours; any other URL is never
fetched. Sounds play through audioplayers, one at a time. Content holding
only an image or a sound isn't blank, and lists show it as such.

The Anki import ("Import from Anki" under Add material): .apkg and .colpkg
packages in all three formats Anki has written, read on an isolate of their
own. Modern packages compress everything with zstd in frames that don't
record their size, which pure_zstd v1.0.0 refuses; our fork decodes them.
Fields become rich text by a converter of our own (lines, lists, formatting,
images, sounds, cloze deletions hidden on the front). One card per note,
the files first, studysets as chosen (flatten, sub-studysets, or tags by
subdeck), the notes' tags, and, if wanted, the review history, which FSRS
replays: the first template's reviews to the card's schedule, a reversed
note type's second to the reverse one.

Imported cards say where they came from in their first revision (card
schema 3); the dashboard doesn't count them as written. The test fixtures
are exported by Anki itself (tool/anki/make_fixtures.py).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-02 04:52:02 +02:00
commit a6d5769a2e
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

126
tool/anki/make_fixtures.py Normal file
View file

@ -0,0 +1,126 @@
#!/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).
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")
# A 1x1 PNG, and the start of an MP3 file: the import doesn't decode either.
PNG = bytes.fromhex(
"89504e470d0a1a0a0000000d4948445200000001000000010806000000"
"1f15c4890000000d49444154789c6360000002000154a24f5d0000000049454e44ae426082"
)
MP3 = b"ID3" + bytes(200)
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())
if __name__ == "__main__":
main()