Skip to content

feat(anki): import a deck built in Anki to seed known words (#228) - #265

Merged
HugoFara merged 1 commit into
developfrom
feature/anki-deck-import
Aug 5, 2026
Merged

feat(anki): import a deck built in Anki to seed known words (#228)#265
HugoFara merged 1 commit into
developfrom
feature/anki-deck-import

Conversation

@HugoFara

@HugoFara HugoFara commented Aug 5, 2026

Copy link
Copy Markdown
Owner

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 .apkg support in #259 does not provide that, despite sharing the file format — it round-trips LWT's own exports, matching notes by lwt- guid. I verified the gap rather than assuming it, by building a stock Anki "Basic" deck and running it through ApkgReader:

ApkgReader returned: 3 notes
  lwtTermId=0  term=''  translation=''  suspended=no   (x3)
Notes ApkgImportService would skip as 'no LWT id': 3 of 3

Three notes read, three skipped, nothing changed. This PR adds the missing direction.

How it works

Two steps, because an .apkg records neither a language nor which field means what:

  1. Upload — LWT lists the note types in the file with their field names and note counts.
  2. Map — pick the note type, the term field, an optional translation field, the language, and how status should be decided.

Status is derived from Anki's own scheduling, which is what makes this faster than reclassifying:

In Anki Becomes
Suspended 98 — Ignored
Never studied 1
1–6 days 2
7–13 days 3
14–20 days 4
21+ days 99 — Well known

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

  • 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.
  • Create-only. Re-importing the same deck is safe; duplicates within one file collapse case-insensitively.
  • The parked upload is keyed by a session entry, not a request parameter, so step 2 can't be pointed at an arbitrary path on disk.

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.1 so a server can't reach it, AnkiWeb has no public API, and bidirectional sync is the Phase 4 conflict-resolution problem.

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
HugoFara force-pushed the feature/anki-deck-import branch from 819fd6c to 2409b36 Compare August 5, 2026 20:38
@HugoFara
HugoFara merged commit fa912c4 into develop Aug 5, 2026
14 checks passed
@HugoFara
HugoFara deleted the feature/anki-deck-import branch August 6, 2026 12:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant