Skip to content

Commit 7ca0704

Browse files
committed
fix(vocabulary): refuse an .apkg LWT can only half-read
Ran the round-trip against real Anki (pylib 26.08.1) instead of against our own reader: LWT export -> Anki import -> answered with Anki's own v3/FSRS scheduler -> Anki export -> LWT import. The data crosses intact. Stability 100.3036 and difficulty 2.1043 arrive in Anki as 100.303596 / 2.104, due dates and intervals match, revlog survives, suspension is preserved; coming back, a Good raised stability 100.30 -> 161.81 and an Again dropped 18.50 -> 1.88 with the lapse counted, replaying only the two new reviews and not the three already ours. That also settles the "nobody has yet opened one of these decks in a real Anki install" caveat in the FSRS doc. What does not survive is Anki's defaults, in both directions, quietly. Exporting from Anki, "Support older Anki versions" is off by default, so the collection is written zstd-compressed as collection.anki21b and collection.anki2 is left behind as a stub holding one note: "Please update to the latest Anki version, then import the .colpkg/.apkg file again." Reading that stub succeeds. So a user who exported with Anki's defaults got "notes read: 1, not created by LWT: 1" and a green success box, having lost every review. ApkgReader now refuses a package containing collection.anki21b and names the setting; reading it properly would need zstd, which PHP has no bundled extension for. Importing into Anki, "Import any learning progress" is also off by default, and without it every card arrives new -- ivl 0, no revlog, no memory state, suspension dropped. Nothing to fix on our side, so the import page now says which two boxes to tick, in Anki's own wording.
1 parent 9664899 commit 7ca0704

5 files changed

Lines changed: 94 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,15 @@ ones are marked like "v1.0.0-fork".
5353
MeCab now always stays on the built-in pipeline, which is the one every
5454
install has been using.
5555

56+
* **An .apkg from a current Anki is refused instead of silently half-read**
57+
(#264). Anki's export writes the collection zstd-compressed as
58+
`collection.anki21b` unless *Support older Anki versions* is switched on, and
59+
leaves `collection.anki2` behind as a stub holding a single "please update
60+
Anki" note. LWT read that stub quite happily, reported one unrecognised note,
61+
and told the user the import had succeeded. It now refuses the file and names
62+
the two settings to change. Found by running the round-trip against real Anki
63+
(pylib 26.08.1) rather than against our own reader.
64+
5665
* **Reviews done in Anki come back** (#264). The `.apkg` exporter has carried
5766
LWT's scheduling into Anki since 3.6.0, but the importer discarded everything
5867
Anki sent back — study a deck there, re-import it, and none of it counted.

docs-src/developer/term-status-fsrs.md

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -285,9 +285,27 @@ endpoint was always fine.
285285
fifteen more. `ReviewService` — 847 lines duplicating the queue with no
286286
caller — was deleted rather than migrated.
287287

288-
**Still unverified:** the .apkg export has been read back with SQLite and
289-
matches what Anki documents, but nobody has yet opened one of these decks in a
290-
real Anki install.
288+
**Verified against real Anki** (pylib 26.08.1, #264). The full loop was run:
289+
LWT export → Anki import → answered with Anki's own v3/FSRS scheduler → Anki
290+
export → LWT import. Memory state crosses unchanged (stability 100.3036 and
291+
difficulty 2.1043 arrive as 100.303596 / 2.104), due dates and intervals match,
292+
`revlog` rows survive, and suspension is preserved. Coming back, a Good raised
293+
stability 100.30 → 161.81 and an Again dropped 18.50 → 1.88 with the lapse
294+
counted — with only the two new reviews replayed, not the three already ours.
295+
296+
**Anki's defaults break the loop in both directions, and both fail quietly.**
297+
Neither is a defect in the file we write; both are options the user has to set:
298+
299+
- Importing into Anki, *Import any learning progress* defaults **off**. Without
300+
it every card arrives new — `type`/`queue` 0, `ivl` 0, no revlog, no memory
301+
state, suspension dropped.
302+
- Exporting from Anki, *Support older Anki versions* defaults **off**, so Anki
303+
writes the collection zstd-compressed as `collection.anki21b` and leaves
304+
`collection.anki2` as a stub holding one note reading "Please update to the
305+
latest Anki version…". Reading that stub succeeds, so the import used to
306+
report one unrecognised note and nothing else. `ApkgReader` now refuses a
307+
package containing `collection.anki21b` and names both settings; supporting
308+
the format itself would need zstd, which PHP has no bundled extension for.
291309

292310
## Trade-offs & open questions
293311

src/Modules/Vocabulary/Http/ApkgController.php

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,10 +172,25 @@ private function renderImportForm(?string $error, ?ImportResult $result): void
172172

173173
echo '<p class="help mt-4">'
174174
. 'Notes from this file are matched to existing LWT terms by guid. '
175-
. 'Translations, romanizations, notes, and tags are updated. '
175+
. 'Translations, romanizations, notes, and tags are updated, and reviews you did in '
176+
. 'Anki are replayed into this term\'s schedule. '
176177
. 'Cards suspended in Anki demote learning-status terms to <em>Ignored</em>.'
177178
. '</p>';
178179

180+
// Both of Anki's defaults work against the round-trip, and both fail
181+
// quietly: without the legacy format LWT reads a stub instead of the
182+
// collection, and without scheduling the reviews never leave Anki.
183+
// Verified against Anki 26.08.
184+
echo '<div class="notification is-warning is-light mt-4">'
185+
. '<strong>Two settings to check in Anki before you export.</strong> Switch on '
186+
. '<em>Support older Anki versions (slower/larger files)</em> — without it Anki writes a '
187+
. 'compressed collection LWT cannot read — and <em>Include scheduling information</em>, '
188+
. 'without which your reviews stay behind in Anki. '
189+
. 'Going the other way, tick <em>Import any learning progress</em> when you import an '
190+
. 'LWT deck into Anki, or Anki starts every card from scratch and discards the schedule '
191+
. 'this page exported.'
192+
. '</div>';
193+
179194
// The commonest wrong turn: arriving here with a deck built in Anki,
180195
// which has no LWT guids and so updates nothing at all.
181196
echo '<div class="notification is-info is-light mt-4">'

src/Modules/Vocabulary/Infrastructure/Anki/ApkgReader.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,24 @@ public function read(string $apkgPath): array
5050
throw new RuntimeException("Could not open APKG: {$apkgPath}");
5151
}
5252

53+
// Anki's current export compresses the collection into
54+
// collection.anki21b with zstd, and leaves collection.anki2 behind as a
55+
// stub holding one note reading "Please update to the latest Anki
56+
// version, then import the .colpkg/.apkg file again." Reading that stub
57+
// succeeds, which is the problem: the import would report one
58+
// unrecognised note and nothing else, and the user would be told their
59+
// file imported fine. Refuse it by name instead, and say what to change
60+
// -- the wording is Anki's own, from its export dialog.
61+
if ($zip->locateName('collection.anki21b') !== false) {
62+
$zip->close();
63+
throw new RuntimeException(
64+
'This .apkg uses Anki\'s newer compressed collection format, which LWT cannot read. '
65+
. 'Export it from Anki again with "Support older Anki versions (slower/larger files)" '
66+
. 'switched on, and keep "Include scheduling information" switched on so your reviews '
67+
. 'come back with it.'
68+
);
69+
}
70+
5371
$collectionName = null;
5472
foreach (['collection.anki21', 'collection.anki2'] as $candidate) {
5573
if ($zip->locateName($candidate) !== false) {

tests/backend/Modules/Vocabulary/Infrastructure/Anki/ApkgWriterReaderTest.php

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,36 @@ public function testAManualRescheduleIsNotReadAsAReview(): void
443443
self::assertSame(3, $note->schedule->reviews[0]->ease);
444444
}
445445

446+
public function testAModernAnkiPackageIsRefusedRatherThanMisread(): void
447+
{
448+
// Anki's current export compresses the collection into
449+
// collection.anki21b and leaves collection.anki2 behind as a stub
450+
// holding one "please update Anki" note. Reading that stub *succeeds*,
451+
// which is the trap: the import would report one unrecognised note and
452+
// call it a day, and the user would never learn their reviews had not
453+
// arrived. Verified against a real file from Anki 26.08.
454+
$this->tmpFile = $this->makeTmpPath();
455+
(new ApkgWriter())->write(
456+
$this->tmpFile,
457+
ApkgDeck::forLanguage(1, 'English'),
458+
[new ApkgNote(1, 'a', 'b', '', '', [], false)],
459+
);
460+
461+
$zip = new ZipArchive();
462+
self::assertTrue($zip->open($this->tmpFile) === true);
463+
$stub = $zip->getFromName('collection.anki21');
464+
self::assertNotFalse($stub);
465+
$zip->deleteName('collection.anki21');
466+
// Only the name matters here; the reader must refuse before it looks.
467+
$zip->addFromString('collection.anki21b', "\x28\xb5\x2f\xfd compressed");
468+
$zip->addFromString('collection.anki2', $stub);
469+
$zip->close();
470+
471+
$this->expectException(\RuntimeException::class);
472+
$this->expectExceptionMessageMatches('/Support older Anki versions/');
473+
(new ApkgReader())->read($this->tmpFile);
474+
}
475+
446476
/**
447477
* Write notes to an .apkg and read them straight back.
448478
*

0 commit comments

Comments
 (0)