- Melos 8.9 as the root's dev dependency, with scripts across the workspace (analyze, test:dart, test:flutter, generate, check) and its filters (--diff, --include-dependents); cli_util overridden to 0.5.2 for it, since Learn's flutter_launcher_icons asks for 0.4 and uses only its logger, unchanged in 0.5. - tool/check.dart in place of tool/check.sh: packages, formatting of our own files, analysis, the tools' tests, then every package's tests through Melos (--changed-since=<commit> for only what changed and what depends on it). tool/pre-push is the hook: the signature check, then the checks. CI generates every server's code and runs the same check. - The images build from the root with a workspace narrowed to what they need (tool/workspace_only.sh); compose, the deploy's migration test, the snap, the release script, the editor's launch files and Learn's asset tools (run from apps/learn) follow the new layout. - The project's source is named as project-halkyon/halkyon (source link, snap, F-Droid listing, release uploads); the AGPL packages carry the licence's own text. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> 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 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,
|
|
["<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()
|