"""Build a synthetic Anki collection with official Anki and export it as APKG. Every card is created and every review is answered through Anki's own scheduler (anki==26.9.3), so the package is what a real Anki user exports. Writes, next to this script: anki-26.9.3-migration-proof.apkg package with scheduling, presets, media anki-26.9.3-source-snapshot.json per-card state read back from Anki Run with the official package: python -m pip install anki==26.9.3 pillow Regenerating changes GUIDs and dates; update the import date in test/exchange/anki_migration_proof_test.dart and the website guide with it. """ import hashlib import io import json import math import os import shutil import struct import sys import wave from anki.collection import Collection, ExportAnkiPackageOptions, ExportLimit, DeckIdLimit from anki.scheduler.v3 import CardAnswer from PIL import Image, ImageDraw RATINGS = {1: CardAnswer.AGAIN, 2: CardAnswer.HARD, 3: CardAnswer.GOOD, 4: CardAnswer.EASY} HERE = os.path.dirname(os.path.abspath(__file__)) OUT = HERE WORK = os.path.join(HERE, "..", "..", "..", "..", "build", "migration-proof-work") def png_bytes(color, label): img = Image.new("RGB", (320, 200), color) draw = ImageDraw.Draw(img) draw.rectangle((20, 20, 300, 180), outline=(255, 255, 255), width=6) draw.text((40, 90), label, fill=(255, 255, 255)) buf = io.BytesIO() img.save(buf, "PNG") return buf.getvalue() def wav_bytes(freq): buf = io.BytesIO() with wave.open(buf, "wb") as w: w.setnchannels(1) w.setsampwidth(2) w.setframerate(16000) frames = b"".join( struct.pack("{name}?", symbol], ["chemistry", "elements"]) words = [ ("la pomme", "the apple"), ("le livre", "the book"), ("la clé", "the key"), ("le pont", "the bridge"), ("la fenêtre", "the window"), ("l'arbre", "the tree"), ] for fr, en in words: add(reversed_, "Vocabulary", [fr, en], ["vocabulary", "french"]) clozes = [ "Water boils at {{c1::100}} °C at {{c2::sea level}}.", "The {{c1::mitochondrion}} produces most of the cell's {{c2::ATP}}.", "FSRS models {{c1::stability}} and {{c2::difficulty}} for each card.", "Light travels at about {{c1::300 000}} km per {{c2::second}}.", ] for text in clozes: add(cloze, "Cloze", [text, "Synthetic test note"], ["cloze"]) media = { "migration-test-red.png": png_bytes((200, 40, 40), "RED"), "migration-test-blue.png": png_bytes((40, 60, 200), "BLUE"), "migration-test-tone.wav": wav_bytes(440), } stored = {} for name, data in media.items(): stored[name] = col.media.write_data(name, data) add(basic, "Media", [f'Colour of this square? ', "Red"], ["media"]) add(basic, "Media", [f'Colour of this square? ', "Blue"], ["media"]) add(basic, "Media", [f'Which note is this? [sound:{stored["migration-test-tone.wav"]}]', "A (440 Hz)"], ["media", "audio"]) # Review through Anki's scheduler. Ratings follow a fixed pattern so the # package has new, learning, relearning and review cards. pattern = {0: [], 1: [4], 2: [3, 3], 3: [3, 1], 4: [4, 3], 5: [2, 3, 3]} cids = sorted(col.find_cards(f'"deck:{root}"')) for index, cid in enumerate(cids): for rating in pattern[index % 6]: card = col.get_card(cid) card.start_timer() states = col._backend.get_scheduling_states(cid) answer = col.sched.build_answer(card=card, states=states, rating=RATINGS[rating]) col.sched.answer_card(answer) # Flags, a suspended card and a buried card. col.set_user_flag_for_cards(1, [cids[1]]) col.sched.suspend_cards([cids[6]]) snapshot = snapshot_collection(col, root) snapshot["media"] = { name: hashlib.sha256(data).hexdigest() for name, data in media.items() } snapshot["anki_version"] = "26.9.3" path = os.path.join(OUT, "anki-26.9.3-migration-proof.apkg") options = ExportAnkiPackageOptions( with_scheduling=True, with_deck_configs=True, with_media=True, legacy=False ) count = col.export_anki_package( out_path=path, options=options, limit=DeckIdLimit(col.decks.id(root)), ) snapshot["exported_notes"] = count snapshot["apkg_sha256"] = hashlib.sha256(open(path, "rb").read()).hexdigest() col.close() with open(os.path.join(OUT, "anki-26.9.3-source-snapshot.json"), "w") as f: json.dump(snapshot, f, indent=1, ensure_ascii=False, sort_keys=True) print(json.dumps(snapshot["summary"], indent=1)) def snapshot_collection(col, root): cards = [] for cid in sorted(col.find_cards(f'"deck:{root}"')): card = col.get_card(cid) note = card.note() ms = card.memory_state cards.append({ "guid": note.guid, "ord": card.ord, "deck": col.decks.name(card.did), "notetype": note.note_type()["name"], "type": card.type, "queue": card.queue, "due": card.due, "ivl": card.ivl, "reps": card.reps, "lapses": card.lapses, "flags": card.user_flag(), "stability": round(ms.stability, 4) if ms else None, "difficulty": round(ms.difficulty, 4) if ms else None, "fields": list(note.fields), "tags": sorted(note.tags), }) revlog = col.db.scalar( "select count() from revlog where cid in (select id from cards where did in %s)" % ("(" + ",".join(str(i) for i in col.decks.deck_and_child_ids(col.decks.id(root))) + ")") ) queues = {} for c in cards: queues[str(c["queue"])] = queues.get(str(c["queue"]), 0) + 1 return { "summary": { "notes": len({c["guid"] for c in cards}), "cards": len(cards), "revlog": revlog, "cards_by_queue": queues, "flagged": sum(1 for c in cards if c["flags"]), "with_memory_state": sum(1 for c in cards if c["stability"] is not None), "decks": sorted({c["deck"] for c in cards}), }, "cards": cards, } if __name__ == "__main__": sys.exit(main())