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:
parent
3fbb45574f
commit
a6d5769a2e
1 changed files with 5643 additions and 37 deletions
126
tool/anki/make_fixtures.py
Normal file
126
tool/anki/make_fixtures.py
Normal 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>", "<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())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Reference in a new issue