diff --git a/DESIGN.md b/DESIGN.md index 81f589f..3771594 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -480,9 +480,10 @@ amateurfunk-anki [--data DIR] [--out DIR] [--seed STR] [--epoch INT] - `--seed STR` — deterministic seed for answer shuffling. Omit it for the normal study-deck behavior: a fresh seed is generated for each build, so answers are reshuffled every time decks are rebuilt. -- `--epoch INT` — override the package timestamp epoch. By default we - derive it from the manifest's `fetched_at`; this flag is mainly for - tests and explicit rebuilds. +- `--epoch INT` — override the package and note-modification timestamp + epoch. By default we use the current rebuild time so Anki recognizes + reshuffled notes as newer on re-import; this flag is mainly for tests + and explicit reproducible builds. Exit codes: `0` success, `1` configuration / catalog / build error. There is no Stage-2 equivalent of the fetcher's `EXIT_BAD_STATE` — @@ -602,13 +603,14 @@ same `.apkg` bytes out. Determinism rests on three things: 2. **Stable shuffle.** `randomized_answers()` builds a per-question `random.Random` seeded from SHA-256 of `f"{cli_seed}:{question_number}"`. -3. **Stable timestamps.** Every `now` value in the collection (the - `mod` columns, the JSON config blob timestamps) is fixed to - `build_epoch` — derived from the manifest's `fetched_at`, or - overridden via `--epoch`. ZIP member timestamps are also fixed - via `ZipInfo(name, zip_datetime(build_epoch))`. Without this - last step, the inner SQLite would be identical but the - archive's per-entry mtimes would still vary between runs. +3. **Controlled timestamps.** Every `now` value in the collection (the + `mod` columns and JSON config blob timestamps) uses `build_epoch`. + A normal rebuild sets it to the current time because the fresh + shuffle changes note HTML and Anki only updates an existing note + when the imported copy is newer. `--epoch` fixes it explicitly for + reproducible builds. ZIP member timestamps use the same value via + `ZipInfo(name, zip_datetime(build_epoch))`; otherwise the inner + SQLite could be identical while the archive bytes still differed. The combined effect: two runs with the same `data/`, `--seed`, and timestamp inputs produce byte-identical sha256 on each `.apkg`. diff --git a/amateurfunk_anki.py b/amateurfunk_anki.py index 215638a..bf8b0e6 100644 --- a/amateurfunk_anki.py +++ b/amateurfunk_anki.py @@ -37,6 +37,7 @@ import secrets import shutil import sqlite3 import sys +import time import unicodedata import zipfile from pathlib import Path @@ -1790,6 +1791,19 @@ def resolve_shuffle_seed(seed): return secrets.token_hex(16) +def build_epoch_for_rebuild(override_epoch=None): + """Return the modification epoch for a multiple-choice deck build. + + A normal rebuild reshuffles the answer HTML, so its notes must carry + the current time for Anki to recognize the imported copies as newer. + `--epoch` remains the explicit escape hatch for byte-reproducible + builds, where the caller intentionally fixes every timestamp. + """ + if override_epoch is not None: + return int(override_epoch) + return int(time.time()) + + def build_all(data_dir, out_dir, seed, override_epoch=None, explanations_path=None): """Build every category's `.apkg` and return their result dicts. @@ -1798,8 +1812,8 @@ def build_all(data_dir, out_dir, seed, override_epoch=None, explanations_path=No `.apkg` each. Raises `AnkiBuildError` on configuration / catalog / explanation-schema problems. """ - edition_dir, manifest, catalog = load_latest_catalog(data_dir) - build_epoch = build_epoch_from_manifest(manifest, override_epoch) + edition_dir, _manifest, catalog = load_latest_catalog(data_dir) + build_epoch = build_epoch_for_rebuild(override_epoch) explanations = load_explanations( explanations_path if explanations_path is not None else DEFAULT_EXPLANATIONS_PATH @@ -1866,8 +1880,8 @@ def _parse_args(argv): type=int, default=None, help=( - "override the package timestamp epoch; by default this is " - "derived from manifest.json's fetched_at" + "override the package and note modification timestamp epoch; " + "by default the current rebuild time is used" ), ) parser.add_argument( diff --git a/test_amateurfunk_anki.py b/test_amateurfunk_anki.py index 8b8cc36..591ad0d 100644 --- a/test_amateurfunk_anki.py +++ b/test_amateurfunk_anki.py @@ -127,6 +127,19 @@ class TestResolveShuffleSeed(unittest.TestCase): self.assertNotEqual(first, second) +class TestBuildEpochForRebuild(unittest.TestCase): + """Multiple-choice rebuilds must look newer to Anki by default.""" + + def test_default_uses_current_time(self): + with patch("amateurfunk_anki.time.time", return_value=1777777777.9): + self.assertEqual(aa.build_epoch_for_rebuild(), 1777777777) + + def test_explicit_epoch_is_preserved_for_reproducible_builds(self): + with patch("amateurfunk_anki.time.time") as current_time: + self.assertEqual(aa.build_epoch_for_rebuild(1234567890), 1234567890) + current_time.assert_not_called() + + class TestAnkiBuild(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() @@ -662,6 +675,42 @@ class TestAnkiBuild(unittest.TestCase): ] self.assertFalse(all(byte_equal)) + def test_cli_default_rebuild_stamps_notes_as_newer(self): + out_a = self.root / "anki-current-a" + out_b = self.root / "anki-current-b" + common = [ + "--data", str(self.data_dir), + "--explanations", str(self.explanations_path), + ] + + with ( + patch( + "amateurfunk_anki.resolve_shuffle_seed", + side_effect=["fresh-a", "fresh-b"], + ), + patch( + "amateurfunk_anki.time.time", + side_effect=[1777777777.9, 1777777788.1], + ), + ): + aa.main([*common, "--out", str(out_a)]) + aa.main([*common, "--out", str(out_b)]) + + def note_mods(out_dir): + apkg = out_dir / "amateurfunk-technische-kenntnisse-n.apkg" + with tempfile.TemporaryDirectory() as tmp: + db_path, _media, _names = extract_collection(apkg, Path(tmp)) + conn = sqlite3.connect(db_path) + try: + return { + row[0] for row in conn.execute("select mod from notes") + } + finally: + conn.close() + + self.assertEqual(note_mods(out_a), {1777777777}) + self.assertEqual(note_mods(out_b), {1777777788}) + class TestIntrinsicSvgSize(unittest.TestCase): """Parsing of native figure dimensions — SVG only."""