feat(anki): import a deck built in Anki to seed known words (#228) - #265
Merged
Conversation
Issue #228 asks for "Anki server sync", but its body describes something narrower and far more valuable: "many users have anki decks with thousands of words, it would be much faster than reclassifying whether each word is known". That is a one-time bulk import, not live sync. The .apkg support in #259 does not provide it, despite sharing the file format. That reader only understands LWT's own notetype and matches notes by `lwt-` guid. Feeding it a deck built in Anki returns notes with empty fields and no id, all of which are skipped — verified by building a stock "Basic" deck and running it through ApkgReader: 3 notes read, 3 skipped, nothing changed. This adds the missing direction: * ForeignApkgReader reads any .apkg without assuming field names, reporting the notetypes present (with fields and note counts) so the user can map them, plus per-note scheduling facts. * DeckImportSettings maps Anki maturity onto LWT status. Suspended -> 98, unstudied -> 1, 1-6d -> 2, 7-13d -> 3, 14-20d -> 4, 21d+ -> 99. The 21-day line is Anki's own "mature card" threshold, so it matches what users already see in Anki's statistics. A fixed-status mode is offered for decks where that does not fit. * A note with several cards is judged by its strongest card, and only counts as suspended when every card is — forward/reverse pairs would otherwise under-report knowledge. * AnkiFieldText reduces Anki's HTML fields to plain text: tags stripped, <br> and block ends to spaces, entities decoded, [sound:...] removed, {{c1::...}} cloze answers kept. Without it, markup lands in the reading view and terms never match words in a text. * AnkiDeckImportService only ever creates, so re-importing is safe; duplicates within one file collapse case-insensitively. * Two-step controller at /vocabulary/anki-deck/import. The upload is parked in a temp file keyed by a session entry rather than a request parameter, so step 2 cannot be pointed at an arbitrary path. Also fixes the misleading report in the round-trip importer: foreign notes were counted as "Skipped (no LWT id)", which reads like a bug to someone importing their own deck. It now says the notes were not created by LWT, and the page links to the new importer. Verified: 44 new tests. Reader tests cover arbitrary field names, scheduling extraction, negative (sub-day) intervals, and tags; status mapping is pinned at every boundary; HTML cleaning is table-driven including non-Latin scripts; and integration tests run the whole path against real MySQL, asserting derived statuses, re-import safety, in-file duplicate collapsing and HTML stripping. psalm 0 errors, phpcs PSR12 clean, 9143 tests green.
HugoFara
force-pushed
the
feature/anki-deck-import
branch
from
August 5, 2026 20:38
819fd6c to
2409b36
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements what #228 actually asks for, following this review.
The gap this closes
#228's body: "Many users have anki decks with thousands of words, it would be much faster than reclassifying whether each word is known."
The
.apkgsupport in #259 does not provide that, despite sharing the file format — it round-trips LWT's own exports, matching notes bylwt-guid. I verified the gap rather than assuming it, by building a stock Anki "Basic" deck and running it throughApkgReader:Three notes read, three skipped, nothing changed. This PR adds the missing direction.
How it works
Two steps, because an
.apkgrecords neither a language nor which field means what:Status is derived from Anki's own scheduling, which is what makes this faster than reclassifying:
The 21-day line is Anki's own mature card threshold, the same one its statistics screen uses, so it matches what users already believe about their deck. A fixed-status mode is available when that doesn't fit.
A note with several cards is judged by its strongest card, and only counts as suspended when every card is — otherwise forward/reverse pairs would under-report knowledge.
Details worth reviewing
AnkiFieldTextreduces Anki's HTML fields to plain text — tags stripped,<br>and block ends to spaces, entities decoded,[sound:…]removed,{{c1::…}}cloze answers kept. Without it markup lands in the reading view and terms never match words in a text.Also fixed
The round-trip importer reported foreign notes as "Skipped (no LWT id)" — which reads like a bug to someone importing their own deck. It now says the notes weren't created by LWT, and both the page and the docs link to the new importer. The docs page for the old feature gained a warning up top, since arriving at the wrong one is the obvious wrong turn.
Verification
44 new tests: reader coverage for arbitrary field names, scheduling extraction, negative (sub-day) intervals and tags; status mapping pinned at every boundary; table-driven HTML cleaning including non-Latin scripts; and integration tests running the whole path against real MySQL — derived statuses, re-import safety, in-file duplicate collapsing, HTML stripping, optional translation, tag import.
psalm 0 errors, phpcs PSR12 clean, typecheck clean, 9143 tests green, integration suite 165.
Not in scope
Live sync — split out to #264 with the reasoning: AnkiConnect binds to
127.0.0.1so a server can't reach it, AnkiWeb has no public API, and bidirectional sync is the Phase 4 conflict-resolution problem.