diff --git a/CHANGELOG.md b/CHANGELOG.md
index 967257521..a2ec8a84d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,16 @@ ones are marked like "v1.0.0-fork".
### Added
+* **Import an Anki deck to seed known words** (#228): point LWT at a deck you
+ already study in Anki and it creates the terms for you, working out how well
+ you know each word from Anki's own scheduling — mature cards become *well
+ known*, younger ones get a learning status, suspended ones become *ignored*.
+ You pick the note type, which field holds the term, and the language, since an
+ `.apkg` records none of that. Importing is create-only, so running it twice is
+ safe. This is the direction #259 did **not** cover: that one round-trips LWT's
+ own exports and silently matches nothing in a deck built in Anki. See
+ `docs/reference/anki-deck-import`.
+
* **FSRS scheduling groundwork** (#238, phase 2a): LWT now records FSRS-6 memory
state per term — stability, difficulty, due date and a review history.
Nothing user-visible changes yet: the legacy scoring still drives the review
diff --git a/docs-src/.vitepress/config.ts b/docs-src/.vitepress/config.ts
index 829a0746f..7422adaab 100644
--- a/docs-src/.vitepress/config.ts
+++ b/docs-src/.vitepress/config.ts
@@ -81,6 +81,7 @@ export default defineConfig({
{ text: 'Text Parsers', link: '/reference/parsers' },
{ text: 'Lemmatization', link: '/reference/lemmatization' },
{ text: 'Anki Export & Import', link: '/reference/anki-export-import' },
+ { text: 'Import an Anki Deck', link: '/reference/anki-deck-import' },
{ text: 'Term Scores', link: '/reference/term-scores' },
{ text: 'Export Templates', link: '/reference/export-templates' },
{ text: 'Database Schema', link: '/reference/database-schema' },
diff --git a/docs-src/reference/anki-deck-import.md b/docs-src/reference/anki-deck-import.md
new file mode 100644
index 000000000..d4d83a8a2
--- /dev/null
+++ b/docs-src/reference/anki-deck-import.md
@@ -0,0 +1,118 @@
+---
+title: "Import an Anki Deck"
+description: Seed your LWT vocabulary from a deck you already study in Anki, using Anki's own scheduling data to work out which words you already know.
+---
+
+# Import an Anki Deck
+
+If you already study a language in Anki, you probably know thousands of words
+that LWT does not know about yet. Importing your deck marks those words as known
+in one step, instead of you reclassifying each one as you meet it while reading.
+
+**Where:** *Terms → Import an Anki deck*, or `/vocabulary/anki-deck/import`.
+
+::: tip This is not the same as Anki Export & Import
+[Anki Export & Import](/reference/anki-export-import) is a **round trip** for
+terms that started life in LWT: export them, study in Anki, import the file back
+to update them. It matches notes by an `lwt-` identifier that only LWT-exported
+files carry.
+
+This page is the opposite direction: a deck **you built in Anki**, which has no
+such identifier, imported to **create new terms**. Feeding an Anki-built deck to
+the round-trip importer does nothing at all — it finds nothing to match.
+:::
+
+## Getting the file out of Anki
+
+In Anki: **File → Export**, choose **Anki Deck Package (.apkg)**, pick the deck,
+and keep **Include scheduling information** ticked. The scheduling data is what
+lets LWT tell an word you have known for a year from one you saw yesterday.
+
+## The import in two steps
+
+**1. Upload.** LWT reads the file and lists the note types it contains, with
+their field names and note counts.
+
+**2. Map.** An `.apkg` records neither a language nor which field means what, so
+you choose:
+
+| Choice | Why LWT has to ask |
+| --- | --- |
+| **Note type** | A deck can contain several; you may only want one |
+| **Term field** | Field names are arbitrary — `Front`, `Expression`, `Word`, `Vocab`… |
+| **Translation field** | Optional. Pick `(none)` to import words only |
+| **Language** | An `.apkg` does not record one |
+| **Word status** | Derived from Anki, or one fixed status for everything |
+| **Import tags** | Anki tags become LWT term tags |
+
+## How word status is decided
+
+With **Derive from Anki** (the default), each note's status comes from how well
+Anki thinks you know it — specifically the card's interval:
+
+| In Anki | Becomes in LWT |
+| --- | --- |
+| Suspended | **98** — Ignored |
+| Never studied | **1** — Learning, level 1 |
+| Interval 1–6 days | **2** |
+| Interval 7–13 days | **3** |
+| Interval 14–20 days | **4** |
+| Interval 21+ days | **99** — Well known |
+
+The 21-day line is Anki's own definition of a *mature* card, the same threshold
+its statistics screen uses, so it should match your intuition about the deck.
+
+A note with several cards (forward and reverse, say) is judged by its **strongest**
+card: knowing the word in one direction is enough to count. A note only counts as
+suspended if *every* one of its cards is suspended.
+
+If that mapping does not suit your deck, choose **Give every word the same
+status** instead — useful for a deck you have fully mastered (set everything to
+*Well known*) or one you are only starting.
+
+## What gets cleaned up
+
+Anki fields are HTML, and real decks lean on it. Before storing a term LWT
+removes formatting tags, converts ` ` and block boundaries to spaces, decodes
+entities like ` ` and `é`, drops `[sound:…]` media references, and
+keeps the answer out of `{{c1::…}}` cloze markers. Without that, markup would end
+up in the reading view and terms would never match the words in your texts.
+
+## Importing twice is safe
+
+The importer only ever **creates**. It never modifies or deletes a term you
+already have, so re-importing the same deck — or a bigger version of it later —
+adds only what is genuinely new. Words already in LWT are reported as
+"already in LWT" and left alone.
+
+Duplicates *within* one file are collapsed case-insensitively, so forward and
+reverse notes for the same word produce one term.
+
+## Reading the summary
+
+The summary accounts for every note read:
+
+- **Terms created** — new terms now in your vocabulary
+- **Already in LWT** — the word existed, so it was left untouched
+- **Skipped (empty term field)** — the note had nothing in the chosen field
+- **Skipped (too long to store)** — the term exceeded 250 characters
+
+If *everything* was skipped as empty, you almost certainly picked the wrong term
+field. Go back and choose another; nothing was written.
+
+## Limits
+
+- **Only `.apkg` files.** Anki's newer `.colpkg` whole-collection format and
+ compressed exports are not supported yet.
+- **Requires `pdo_sqlite`**, since an `.apkg` is a zipped SQLite database. See
+ [Anki Export & Import](/reference/anki-export-import#requirements).
+- **No sentences or images.** Only the term, its translation and its tags are
+ imported.
+- **Scheduling is read, not carried over.** Anki's intervals decide the starting
+ status, but LWT then schedules the term with its own system — the two do not
+ stay in sync. Live sync is tracked separately and is deliberately deferred.
+
+## See also
+
+- [Anki Export & Import](/reference/anki-export-import) — the LWT→Anki→LWT round trip
+- [Term Scores](/reference/term-scores) — how LWT schedules reviews afterwards
diff --git a/docs-src/reference/anki-export-import.md b/docs-src/reference/anki-export-import.md
index c2b6b3b43..dc5194bcc 100644
--- a/docs-src/reference/anki-export-import.md
+++ b/docs-src/reference/anki-export-import.md
@@ -2,6 +2,16 @@
LWT can export your vocabulary to a real Anki package (`.apkg`) and read changes back from a `.apkg` you re-export from Anki. The flow is **manual round-trip** — not a live sync — but the file format is universal, so any Anki client (desktop, AnkiMobile, AnkiDroid, AnkiWeb) can study the deck.
+::: warning Importing a deck you built in Anki?
+This page covers the **round trip** for terms that started in LWT: export them,
+study in Anki, import the file back to update them. Notes are matched by an
+`lwt-` identifier that only LWT-exported files carry, so a deck built in Anki or
+downloaded from AnkiWeb has nothing to match and **nothing would change**.
+
+To seed LWT from a deck you already study, use
+[Import an Anki Deck](/reference/anki-deck-import) instead.
+:::
+
## When to use this
- You already have an Anki workflow and want LWT to seed it from your reading.
diff --git a/src/Modules/Vocabulary/Application/Services/Anki/AnkiDeckImportService.php b/src/Modules/Vocabulary/Application/Services/Anki/AnkiDeckImportService.php
new file mode 100644
index 000000000..1c5e2573f
--- /dev/null
+++ b/src/Modules/Vocabulary/Application/Services/Anki/AnkiDeckImportService.php
@@ -0,0 +1,137 @@
+reader->notes($apkgPath, $settings->notetypeId);
+
+ $created = 0;
+ $skippedExisting = 0;
+ $skippedEmpty = 0;
+ $skippedTooLong = 0;
+ $statusCounts = [];
+ $samples = [];
+
+ // Terms already created in this run, so a deck containing the same word
+ // twice (common with forward/reverse notes split across notetypes)
+ // does not report a spurious "already existed".
+ $seen = [];
+
+ foreach ($notes as $note) {
+ $term = $this->cleanFieldValue($note->field($settings->termField));
+
+ if ($term === '') {
+ $skippedEmpty++;
+ continue;
+ }
+
+ if (mb_strlen($term) > self::MAX_TERM_LENGTH) {
+ $skippedTooLong++;
+ continue;
+ }
+
+ $termLc = mb_strtolower($term, 'UTF-8');
+ if (isset($seen[$termLc])) {
+ $skippedExisting++;
+ continue;
+ }
+ $seen[$termLc] = true;
+
+ $status = $settings->statusFor($note);
+
+ $result = $this->words->create([
+ 'WoLgID' => $settings->languageId,
+ 'WoText' => $term,
+ 'WoStatus' => $status,
+ 'WoTranslation' => $this->translationFor($note, $settings),
+ 'WoSentence' => '',
+ 'WoNotes' => '',
+ 'WoRomanization' => '',
+ ]);
+
+ if ($result['success'] !== true) {
+ // WordCrudService reports a duplicate as a failed create; the
+ // unique key on (WoTextLC, WoLgID) is what actually enforces it,
+ // so this is the authoritative "already had it" signal.
+ $skippedExisting++;
+ continue;
+ }
+
+ $created++;
+ $statusCounts[$status] = ($statusCounts[$status] ?? 0) + 1;
+
+ if (count($samples) < self::SAMPLE_SIZE) {
+ $samples[] = $term;
+ }
+
+ if ($settings->importTags && $note->tags !== []) {
+ TermTagService::saveWordTags($result['id'], $note->tags);
+ }
+ }
+
+ ksort($statusCounts);
+
+ return new DeckImportResult(
+ totalNotes: count($notes),
+ created: $created,
+ skippedExisting: $skippedExisting,
+ skippedEmpty: $skippedEmpty,
+ skippedTooLong: $skippedTooLong,
+ statusCounts: $statusCounts,
+ samples: $samples,
+ );
+ }
+
+ private function translationFor(ForeignNote $note, DeckImportSettings $settings): string
+ {
+ if ($settings->translationField === null) {
+ return '';
+ }
+
+ return $this->cleanFieldValue($note->field($settings->translationField));
+ }
+
+ private function cleanFieldValue(string $value): string
+ {
+ return AnkiFieldText::toPlainText($value);
+ }
+}
diff --git a/src/Modules/Vocabulary/Application/Services/Anki/DeckImportResult.php b/src/Modules/Vocabulary/Application/Services/Anki/DeckImportResult.php
new file mode 100644
index 000000000..355354b9d
--- /dev/null
+++ b/src/Modules/Vocabulary/Application/Services/Anki/DeckImportResult.php
@@ -0,0 +1,34 @@
+ $statusCounts status => number of terms created
+ * @param list $samples A few created terms, for the summary
+ */
+ public function __construct(
+ public readonly int $totalNotes,
+ public readonly int $created,
+ public readonly int $skippedExisting,
+ public readonly int $skippedEmpty,
+ public readonly int $skippedTooLong,
+ public readonly array $statusCounts,
+ public readonly array $samples,
+ ) {
+ }
+}
diff --git a/src/Modules/Vocabulary/Application/Services/Anki/DeckImportSettings.php b/src/Modules/Vocabulary/Application/Services/Anki/DeckImportSettings.php
new file mode 100644
index 000000000..f01125435
--- /dev/null
+++ b/src/Modules/Vocabulary/Application/Services/Anki/DeckImportSettings.php
@@ -0,0 +1,97 @@
+ 98 (ignored) — the user parked it in Anki
+ * never studied -> 1 (learning) — no evidence of knowledge
+ * < 21 days -> 2..4 — young, scaled by interval
+ * >= 21 days -> 99 (well-known) — Anki's own "mature" threshold
+ */
+ public function statusFor(ForeignNote $note): int
+ {
+ if (!$this->deriveStatus) {
+ return $this->fixedStatus;
+ }
+
+ if ($note->suspended) {
+ return 98;
+ }
+
+ if ($note->isNew || $note->interval <= 0) {
+ return 1;
+ }
+
+ if ($note->interval >= self::MATURE_INTERVAL_DAYS) {
+ return 99;
+ }
+
+ // 1..20 days spread across the middle learning statuses.
+ if ($note->interval >= 14) {
+ return 4;
+ }
+ if ($note->interval >= 7) {
+ return 3;
+ }
+
+ return 2;
+ }
+}
diff --git a/src/Modules/Vocabulary/Http/AnkiDeckImportController.php b/src/Modules/Vocabulary/Http/AnkiDeckImportController.php
new file mode 100644
index 000000000..c886e325d
--- /dev/null
+++ b/src/Modules/Vocabulary/Http/AnkiDeckImportController.php
@@ -0,0 +1,389 @@
+ configure, or configure -> import
+ *
+ * The uploaded file is parked in a temp path between the two steps, keyed by a
+ * token held in the session so the second request cannot be pointed at an
+ * arbitrary path.
+ */
+class AnkiDeckImportController extends VocabularyBaseController
+{
+ private const SESSION_KEY = 'lwt_anki_deck_import';
+
+ private LanguageFacade $languageFacade;
+ private ForeignApkgReader $reader;
+ private ?AnkiDeckImportService $importService;
+
+ public function __construct(
+ ?LanguageFacade $languageFacade = null,
+ ?ForeignApkgReader $reader = null,
+ ?AnkiDeckImportService $importService = null,
+ ) {
+ parent::__construct();
+ $this->languageFacade = $languageFacade ?? new LanguageFacade();
+ $this->reader = $reader ?? new ForeignApkgReader();
+ $this->importService = $importService;
+ }
+
+ /**
+ * @param array $params Route params (unused).
+ */
+ public function index(array $params): void
+ {
+ PageLayoutHelper::renderPageStart('Import an Anki deck', true);
+
+ try {
+ if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
+ $this->renderUploadForm(null);
+ } elseif (InputValidator::getString('step') === 'import') {
+ $this->handleImport();
+ } else {
+ $this->handleUpload();
+ }
+ } catch (Throwable $e) {
+ $this->renderUploadForm($e->getMessage());
+ }
+
+ PageLayoutHelper::renderPageEnd();
+ }
+
+ /**
+ * Step 1: accept the upload, park it, show the mapping form.
+ */
+ private function handleUpload(): void
+ {
+ $file = InputValidator::getUploadedFile('apkg');
+ if ($file === null) {
+ $this->renderUploadForm('No file was uploaded.');
+ return;
+ }
+
+ if (!str_ends_with(strtolower($file['name']), '.apkg')) {
+ $this->renderUploadForm('Only .apkg files are accepted.');
+ return;
+ }
+
+ $parked = tempnam(sys_get_temp_dir(), 'lwt_deck_');
+ if ($parked === false) {
+ $this->renderUploadForm('Could not store the uploaded file.');
+ return;
+ }
+ if (!move_uploaded_file($file['tmp_name'], $parked)) {
+ // move_uploaded_file only accepts a genuine HTTP upload; fall back
+ // to a copy so the flow stays exercisable outside a web request.
+ if (!copy($file['tmp_name'], $parked)) {
+ @unlink($parked);
+ $this->renderUploadForm('Could not store the uploaded file.');
+ return;
+ }
+ }
+
+ $notetypes = $this->reader->notetypes($parked);
+ if ($notetypes === []) {
+ @unlink($parked);
+ $this->renderUploadForm('No notetypes found in this file — it may not be an Anki deck.');
+ return;
+ }
+
+ $this->rememberParkedFile($parked);
+ $this->renderMappingForm($notetypes, null);
+ }
+
+ /**
+ * Step 2: apply the chosen mapping.
+ */
+ private function handleImport(): void
+ {
+ $parked = $this->parkedFile();
+ if ($parked === null) {
+ $this->renderUploadForm('That upload has expired. Please choose the file again.');
+ return;
+ }
+
+ $notetypes = $this->reader->notetypes($parked);
+
+ try {
+ $settings = new DeckImportSettings(
+ notetypeId: InputValidator::getInt('notetype') ?? 0,
+ termField: InputValidator::getString('term_field'),
+ translationField: $this->optionalField('translation_field'),
+ languageId: InputValidator::getInt('language') ?? 0,
+ deriveStatus: InputValidator::getString('status_mode') !== 'fixed',
+ fixedStatus: InputValidator::getInt('fixed_status') ?? 1,
+ importTags: InputValidator::getString('import_tags') !== '',
+ );
+ } catch (\InvalidArgumentException $e) {
+ $this->renderMappingForm($notetypes, $e->getMessage());
+ return;
+ }
+
+ $result = $this->importSvc()->import($parked, $settings);
+
+ $this->forgetParkedFile();
+ $this->renderSummary($result, $settings);
+ }
+
+ private function optionalField(string $key): ?string
+ {
+ $value = InputValidator::getString($key);
+
+ return $value === '' ? null : $value;
+ }
+
+ private function renderUploadForm(?string $error): void
+ {
+ echo '
Import an Anki deck
';
+
+ if ($error !== null) {
+ echo '
' . $this->esc($error) . '
';
+ }
+
+ echo '
Already study this language in Anki? Import the deck and LWT will '
+ . 'mark those words as known, so you do not have to reclassify them while reading. '
+ . 'Export your deck from Anki with File → Export → Anki Deck Package '
+ . '(.apkg), including scheduling information.
';
+
+ echo '';
+
+ echo '
This creates new terms. It never changes terms you already '
+ . 'have — importing the same deck twice is safe.
';
+ }
+
+ private function statusLabel(int $status): string
+ {
+ return match ($status) {
+ 98 => 'Ignored',
+ 99 => 'Well known',
+ default => 'Level ' . $status,
+ };
+ }
+
+ private function csrfField(): string
+ {
+ return '';
+ }
+
+ private function esc(string $value): string
+ {
+ return htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
+ }
+
+ private function rememberParkedFile(string $path): void
+ {
+ if (session_status() === PHP_SESSION_ACTIVE) {
+ $_SESSION[self::SESSION_KEY] = $path;
+ }
+ }
+
+ /**
+ * The parked upload for this session, if it is still there.
+ *
+ * Reading the path from the session rather than the request is what stops
+ * step 2 being pointed at an arbitrary file on disk.
+ */
+ private function parkedFile(): ?string
+ {
+ if (session_status() !== PHP_SESSION_ACTIVE) {
+ return null;
+ }
+
+ $path = $_SESSION[self::SESSION_KEY] ?? null;
+ if (!is_string($path) || !is_file($path)) {
+ return null;
+ }
+
+ return $path;
+ }
+
+ private function forgetParkedFile(): void
+ {
+ $path = $this->parkedFile();
+ if ($path !== null) {
+ @unlink($path);
+ }
+ unset($_SESSION[self::SESSION_KEY]);
+ }
+
+ private function importSvc(): AnkiDeckImportService
+ {
+ return $this->importService ??= AnkiDeckImportService::default();
+ }
+}
diff --git a/src/Modules/Vocabulary/Http/ApkgController.php b/src/Modules/Vocabulary/Http/ApkgController.php
index 34d41b721..5ba1f35f8 100644
--- a/src/Modules/Vocabulary/Http/ApkgController.php
+++ b/src/Modules/Vocabulary/Http/ApkgController.php
@@ -148,7 +148,7 @@ private function renderImportForm(?string $error, ?ImportResult $result): void
. '
Updated: ' . $result->updated . '
'
. '
Unchanged: ' . $result->unchanged . '
'
. '
Skipped (term not found): ' . $result->skippedMissing . '
'
- . '
Skipped (no LWT id): ' . $result->skippedUnknown . '
'
+ . '
Not created by LWT: ' . $result->skippedUnknown . '
'
. '
Demoted to Ignored from suspended: ' . $result->statusSetToIgnored . '
'
. '
Tag changes applied: ' . $result->tagsChanged . '
'
. '';
@@ -173,6 +173,17 @@ private function renderImportForm(?string $error, ?ImportResult $result): void
. 'Translations, romanizations, notes, and tags are updated. '
. 'Cards suspended in Anki demote learning-status terms to Ignored.'
. '
';
+
+ // The commonest wrong turn: arriving here with a deck built in Anki,
+ // which has no LWT guids and so updates nothing at all.
+ echo '
'
+ . 'Importing a deck you built in Anki? This page only updates terms '
+ . 'that LWT exported in the first place — a deck from Anki or AnkiWeb has nothing here '
+ . 'to match against, so nothing would change. Use '
+ . 'Import an Anki deck instead, which '
+ . 'creates new terms and works out how well you know each word from Anki\'s own '
+ . 'scheduling.'
+ . '
';
}
private function streamDownload(string $path, string $filename): never
diff --git a/src/Modules/Vocabulary/Infrastructure/Anki/AnkiFieldText.php b/src/Modules/Vocabulary/Infrastructure/Anki/AnkiFieldText.php
new file mode 100644
index 000000000..f9df3f038
--- /dev/null
+++ b/src/Modules/Vocabulary/Infrastructure/Anki/AnkiFieldText.php
@@ -0,0 +1,38 @@
+`
+ * wrappers, ``/`` emphasis, ` ` line breaks, ` `,
+ * `[sound:...]` media references and `{{c1::...}}` cloze markers. Storing any
+ * of it raw would put markup into the reading view and stop terms matching the
+ * words in a text, so it is all reduced to text here.
+ */
+final class AnkiFieldText
+{
+ public static function toPlainText(string $value): string
+ {
+ // Anki media syntax — meaningless outside Anki, and never part of the word.
+ $value = preg_replace('/\[sound:[^\]]*\]/u', '', $value) ?? $value;
+
+ // Cloze deletions: {{c1::answer}} and {{c1::answer::hint}} keep the answer.
+ $value = preg_replace('/\{\{c\d+::(.*?)(?:::[^}]*)?\}\}/u', '$1', $value) ?? $value;
+
+ // Structural breaks carry a word boundary; drop the rest of the markup.
+ $value = preg_replace('#<\s*(br|/div|/p|/li|/tr|/td)\s*/?\s*>#iu', ' ', $value) ?? $value;
+ $value = strip_tags($value);
+
+ $value = html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
+
+ // Anki emits non-breaking spaces liberally; treat them as whitespace.
+ $value = str_replace("\u{00A0}", ' ', $value);
+ $value = preg_replace('/\s+/u', ' ', $value) ?? $value;
+
+ return trim($value);
+ }
+}
diff --git a/src/Modules/Vocabulary/Infrastructure/Anki/ForeignApkgReader.php b/src/Modules/Vocabulary/Infrastructure/Anki/ForeignApkgReader.php
new file mode 100644
index 000000000..f74691dd3
--- /dev/null
+++ b/src/Modules/Vocabulary/Infrastructure/Anki/ForeignApkgReader.php
@@ -0,0 +1,280 @@
+
+ */
+ public function notetypes(string $apkgPath): array
+ {
+ return $this->withCollection($apkgPath, function (PDO $pdo): array {
+ $models = $this->decodeModels($pdo);
+ $counts = $this->noteCountsByModel($pdo);
+
+ $out = [];
+ foreach ($models as $mid => $model) {
+ $out[] = new ForeignNotetype(
+ id: $mid,
+ name: $model['name'],
+ fields: $model['fields'],
+ noteCount: $counts[$mid] ?? 0,
+ );
+ }
+
+ usort($out, static fn(ForeignNotetype $a, ForeignNotetype $b) => $b->noteCount <=> $a->noteCount);
+
+ return $out;
+ });
+ }
+
+ /**
+ * Every note using one notetype, with fields unmapped.
+ *
+ * @return list
+ */
+ public function notes(string $apkgPath, int $notetypeId): array
+ {
+ return $this->withCollection($apkgPath, function (PDO $pdo) use ($notetypeId): array {
+ $models = $this->decodeModels($pdo);
+ if (!isset($models[$notetypeId])) {
+ throw new RuntimeException("Notetype {$notetypeId} not found in this file");
+ }
+
+ $fieldNames = $models[$notetypeId]['fields'];
+ $cardsByNote = $this->cardStateByNote($pdo);
+
+ $stmt = $pdo->prepare('SELECT id, tags, flds FROM notes WHERE mid = ?');
+ $stmt->execute([$notetypeId]);
+
+ $out = [];
+ foreach ($stmt as $row) {
+ if (!is_array($row)) {
+ continue;
+ }
+
+ $values = explode(AnkiSchema::FIELD_SEPARATOR, (string) ($row['flds'] ?? ''));
+
+ $fields = [];
+ foreach ($fieldNames as $ord => $name) {
+ $fields[$name] = $values[$ord] ?? '';
+ }
+
+ $noteId = (int) ($row['id'] ?? 0);
+ $state = $cardsByNote[$noteId] ?? ['interval' => 0, 'suspended' => false, 'new' => true];
+
+ $out[] = new ForeignNote(
+ fields: $fields,
+ tags: $this->decodeTags((string) ($row['tags'] ?? '')),
+ interval: $state['interval'],
+ suspended: $state['suspended'],
+ isNew: $state['new'],
+ );
+ }
+
+ return $out;
+ });
+ }
+
+ /**
+ * Extract the collection to a temp file and hand a PDO handle to $work.
+ *
+ * @template T
+ * @param callable(PDO): T $work
+ * @return T
+ */
+ private function withCollection(string $apkgPath, callable $work): mixed
+ {
+ AnkiSchema::assertSqliteAvailable();
+
+ if (!is_file($apkgPath)) {
+ throw new RuntimeException("APKG file not found: {$apkgPath}");
+ }
+
+ $zip = new ZipArchive();
+ if ($zip->open($apkgPath) !== true) {
+ throw new RuntimeException("Could not open APKG: {$apkgPath}");
+ }
+
+ $collectionName = null;
+ foreach (['collection.anki21', 'collection.anki2'] as $candidate) {
+ if ($zip->locateName($candidate) !== false) {
+ $collectionName = $candidate;
+ break;
+ }
+ }
+ if ($collectionName === null) {
+ $zip->close();
+ throw new RuntimeException(
+ 'This file contains no Anki collection. Newer Anki exports using the '
+ . '.colpkg format or a compressed collection are not supported yet.'
+ );
+ }
+
+ $contents = $zip->getFromName($collectionName);
+ $zip->close();
+ if ($contents === false) {
+ throw new RuntimeException("Failed to extract {$collectionName} from APKG");
+ }
+
+ $tmpDb = tempnam(sys_get_temp_dir(), 'lwt_apkg_foreign_');
+ if ($tmpDb === false) {
+ throw new RuntimeException('Could not allocate temp file');
+ }
+ file_put_contents($tmpDb, $contents);
+
+ try {
+ $pdo = new PDO('sqlite:' . $tmpDb);
+ $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
+
+ return $work($pdo);
+ } finally {
+ unlink($tmpDb);
+ }
+ }
+
+ /**
+ * Notetype id => name + ordered field names.
+ *
+ * @return array}>
+ */
+ private function decodeModels(PDO $pdo): array
+ {
+ $stmt = $pdo->query('SELECT models FROM col LIMIT 1');
+ $modelsJson = $stmt === false ? null : $stmt->fetchColumn();
+ if (!is_string($modelsJson) || $modelsJson === '') {
+ throw new RuntimeException('Could not read col.models');
+ }
+
+ /** @var mixed $decoded */
+ $decoded = json_decode($modelsJson, true);
+ if (!is_array($decoded)) {
+ throw new RuntimeException('Could not decode col.models');
+ }
+
+ $out = [];
+ /** @var mixed $model */
+ foreach ($decoded as $mid => $model) {
+ if (!is_array($model) || !isset($model['flds']) || !is_array($model['flds'])) {
+ continue;
+ }
+
+ // Anki stores fields with an explicit ordinal; sort by it rather
+ // than trusting array order, which shared decks do not guarantee.
+ $byOrd = [];
+ /** @var mixed $fld */
+ foreach ($model['flds'] as $fld) {
+ if (!is_array($fld) || !isset($fld['name'], $fld['ord'])) {
+ continue;
+ }
+ $byOrd[(int) $fld['ord']] = (string) $fld['name'];
+ }
+ ksort($byOrd);
+
+ if ($byOrd === []) {
+ continue;
+ }
+
+ $out[(int) $mid] = [
+ 'name' => isset($model['name']) ? (string) $model['name'] : 'Unnamed notetype',
+ 'fields' => array_values($byOrd),
+ ];
+ }
+
+ return $out;
+ }
+
+ /**
+ * @return array notetype id => note count
+ */
+ private function noteCountsByModel(PDO $pdo): array
+ {
+ $stmt = $pdo->query('SELECT mid, COUNT(*) AS c FROM notes GROUP BY mid');
+ if ($stmt === false) {
+ return [];
+ }
+
+ $out = [];
+ /** @var mixed $row */
+ foreach ($stmt as $row) {
+ if (is_array($row)) {
+ $out[(int) ($row['mid'] ?? 0)] = (int) ($row['c'] ?? 0);
+ }
+ }
+
+ return $out;
+ }
+
+ /**
+ * Collapse each note's cards into the facts the status mapping needs.
+ *
+ * A note can have several cards (forward/reverse). The strongest card wins:
+ * if the user knows the word in one direction it is worth importing as
+ * known, and a note only counts as suspended when every card is.
+ *
+ * @return array
+ */
+ private function cardStateByNote(PDO $pdo): array
+ {
+ $stmt = $pdo->query('SELECT nid, ivl, queue, type FROM cards');
+ if ($stmt === false) {
+ return [];
+ }
+
+ $out = [];
+ foreach ($stmt as $row) {
+ if (!is_array($row)) {
+ continue;
+ }
+
+ $nid = (int) ($row['nid'] ?? 0);
+ $ivl = (int) ($row['ivl'] ?? 0);
+ $queue = (int) ($row['queue'] ?? 0);
+ $type = (int) ($row['type'] ?? 0);
+
+ // Anki stores sub-day learning intervals as negative seconds.
+ $intervalDays = $ivl < 0 ? 0 : $ivl;
+
+ if (!isset($out[$nid])) {
+ $out[$nid] = ['interval' => 0, 'suspended' => true, 'new' => true];
+ }
+
+ $out[$nid]['interval'] = max($out[$nid]['interval'], $intervalDays);
+ $out[$nid]['suspended'] = $out[$nid]['suspended'] && $queue === -1;
+ $out[$nid]['new'] = $out[$nid]['new'] && $type === 0;
+ }
+
+ return $out;
+ }
+
+ /**
+ * @return list
+ */
+ private function decodeTags(string $tags): array
+ {
+ $parts = preg_split('/\s+/', trim($tags)) ?: [];
+
+ return array_values(array_filter($parts, static fn(string $t): bool => $t !== ''));
+ }
+}
diff --git a/src/Modules/Vocabulary/Infrastructure/Anki/ForeignNote.php b/src/Modules/Vocabulary/Infrastructure/Anki/ForeignNote.php
new file mode 100644
index 000000000..3b9752b31
--- /dev/null
+++ b/src/Modules/Vocabulary/Infrastructure/Anki/ForeignNote.php
@@ -0,0 +1,40 @@
+ $fields Field name => value, as stored in Anki
+ * @param list $tags Anki tags on the note
+ * @param int $interval Largest `cards.ivl` across the note's
+ * cards, in days. 0 for unseen cards.
+ * @param bool $suspended Whether every card is suspended
+ * @param bool $isNew Whether the note has never been studied
+ */
+ public function __construct(
+ public readonly array $fields,
+ public readonly array $tags,
+ public readonly int $interval,
+ public readonly bool $suspended,
+ public readonly bool $isNew,
+ ) {
+ }
+
+ /**
+ * Value of one field, or '' when the notetype has no such field.
+ */
+ public function field(string $name): string
+ {
+ return $this->fields[$name] ?? '';
+ }
+}
diff --git a/src/Modules/Vocabulary/Infrastructure/Anki/ForeignNotetype.php b/src/Modules/Vocabulary/Infrastructure/Anki/ForeignNotetype.php
new file mode 100644
index 000000000..fcb3f25cc
--- /dev/null
+++ b/src/Modules/Vocabulary/Infrastructure/Anki/ForeignNotetype.php
@@ -0,0 +1,30 @@
+ $fields Field names, in Anki's ordinal order
+ * @param int $noteCount How many notes use this notetype
+ */
+ public function __construct(
+ public readonly int $id,
+ public readonly string $name,
+ public readonly array $fields,
+ public readonly int $noteCount,
+ ) {
+ }
+}
diff --git a/src/Shared/Infrastructure/Routing/routes.php b/src/Shared/Infrastructure/Routing/routes.php
index 4e6c31fc1..4800c32e2 100644
--- a/src/Shared/Infrastructure/Routing/routes.php
+++ b/src/Shared/Infrastructure/Routing/routes.php
@@ -334,6 +334,20 @@ function registerRoutes(Router $router): void
AUTH_MIDDLEWARE
);
+ // Import a deck built in Anki as new terms (AnkiDeckImportController).
+ // Distinct from the apkg routes above: those round-trip LWT's own export,
+ // this one seeds terms from a foreign deck.
+ $router->get(
+ '/vocabulary/anki-deck/import',
+ 'Lwt\\Modules\\Vocabulary\\Http\\AnkiDeckImportController@index',
+ AUTH_MIDDLEWARE
+ );
+ $router->post(
+ '/vocabulary/anki-deck/import',
+ 'Lwt\\Modules\\Vocabulary\\Http\\AnkiDeckImportController@index',
+ AUTH_MIDDLEWARE
+ );
+
// Review status change during review (iframe/ajax view).
// NB: the dead text-reader frame routes (set-status, delete-term,
// delete-multi, insert-wellknown, insert-ignore) are unregistered here —
diff --git a/tests/backend/Modules/Vocabulary/Application/Services/Anki/AnkiDeckImportIntegrationTest.php b/tests/backend/Modules/Vocabulary/Application/Services/Anki/AnkiDeckImportIntegrationTest.php
new file mode 100644
index 000000000..733ee2c87
--- /dev/null
+++ b/tests/backend/Modules/Vocabulary/Application/Services/Anki/AnkiDeckImportIntegrationTest.php
@@ -0,0 +1,293 @@
+markTestSkipped('pdo_sqlite required to read an .apkg collection');
+ }
+ if (!defined('LWT_TEST_DB_AVAILABLE') || !LWT_TEST_DB_AVAILABLE) {
+ $this->markTestSkipped('Database connection required');
+ }
+ if (!self::$dbConnected) {
+ $this->markTestSkipped('Test database setup failed');
+ }
+
+ $tmp = tempnam(sys_get_temp_dir(), 'lwt_deckimport_');
+ $this->deckPath = $tmp !== false ? $tmp . '.apkg' : '';
+ }
+
+ protected function tearDown(): void
+ {
+ if ($this->deckPath !== '') {
+ ForeignDeckBuilder::cleanup($this->deckPath);
+ }
+ if (self::$dbConnected && self::$languageId > 0) {
+ Connection::query(
+ 'DELETE FROM word_tag_map WHERE WtWoID IN ('
+ . 'SELECT WoID FROM words WHERE WoLgID = ' . self::$languageId . ')'
+ );
+ Connection::query('DELETE FROM words WHERE WoLgID = ' . self::$languageId);
+ }
+ }
+
+ private function settings(bool $derive = true, ?string $translation = 'Back'): DeckImportSettings
+ {
+ return new DeckImportSettings(
+ notetypeId: 1500000000000,
+ termField: 'Front',
+ translationField: $translation,
+ languageId: self::$languageId,
+ deriveStatus: $derive,
+ );
+ }
+
+ public function testImportsAForeignDeckAndDerivesStatusFromMaturity(): void
+ {
+ (new ForeignDeckBuilder())
+ ->addMatureNote(['hund', 'the dog'], 200)
+ ->addYoungNote(['katze', 'the cat'], 8)
+ ->addNote(['pferd', 'the horse'])
+ ->addSuspendedNote(['maus', 'the mouse'])
+ ->build($this->deckPath);
+
+ $result = AnkiDeckImportService::default()->import($this->deckPath, $this->settings());
+
+ $this->assertSame(4, $result->totalNotes);
+ $this->assertSame(4, $result->created);
+ $this->assertSame(0, $result->skippedExisting);
+
+ $rows = Connection::preparedFetchAll(
+ 'SELECT WoTextLC, WoStatus, WoTranslation FROM words WHERE WoLgID = ? ORDER BY WoTextLC',
+ [self::$languageId]
+ );
+
+ $byTerm = [];
+ foreach ($rows as $row) {
+ $byTerm[(string) $row['WoTextLC']] = $row;
+ }
+
+ $this->assertSame(99, (int) $byTerm['hund']['WoStatus'], 'a mature card is well known');
+ $this->assertSame(3, (int) $byTerm['katze']['WoStatus'], '8-day interval lands mid-learning');
+ $this->assertSame(1, (int) $byTerm['pferd']['WoStatus'], 'an unstudied card starts at 1');
+ $this->assertSame(98, (int) $byTerm['maus']['WoStatus'], 'a suspended card is ignored');
+
+ $this->assertSame('the dog', (string) $byTerm['hund']['WoTranslation']);
+ }
+
+ public function testReimportingTheSameDeckCreatesNothingNew(): void
+ {
+ (new ForeignDeckBuilder())
+ ->addMatureNote(['wiederholung', 'repetition'], 60)
+ ->build($this->deckPath);
+
+ $service = AnkiDeckImportService::default();
+
+ $first = $service->import($this->deckPath, $this->settings());
+ $this->assertSame(1, $first->created);
+
+ $second = $service->import($this->deckPath, $this->settings());
+ $this->assertSame(0, $second->created, 'importing twice must not duplicate');
+ $this->assertSame(1, $second->skippedExisting);
+
+ $count = (int) Connection::preparedFetchValue(
+ 'SELECT COUNT(*) AS value FROM words WHERE WoLgID = ? AND WoTextLC = ?',
+ [self::$languageId, 'wiederholung']
+ );
+ $this->assertSame(1, $count);
+ }
+
+ public function testDuplicateWordsWithinOneDeckAreCollapsed(): void
+ {
+ // Forward and reverse notes for the same word are common.
+ (new ForeignDeckBuilder())
+ ->addMatureNote(['gleich', 'same'], 40)
+ ->addMatureNote(['Gleich', 'same (capitalised)'], 40)
+ ->build($this->deckPath);
+
+ $result = AnkiDeckImportService::default()->import($this->deckPath, $this->settings());
+
+ $this->assertSame(1, $result->created, 'case-insensitive duplicates collapse');
+ $this->assertSame(1, $result->skippedExisting);
+ }
+
+ public function testHtmlInFieldsIsStrippedBeforeStoring(): void
+ {
+ (new ForeignDeckBuilder())
+ ->addMatureNote(['
der Baum [sound:baum.mp3]
', 'the tree'], 50)
+ ->build($this->deckPath);
+
+ AnkiDeckImportService::default()->import($this->deckPath, $this->settings());
+
+ $row = Connection::preparedFetchOne(
+ 'SELECT WoText, WoTranslation FROM words WHERE WoLgID = ?',
+ [self::$languageId]
+ );
+
+ $this->assertNotNull($row);
+ $this->assertSame('der Baum', (string) $row['WoText']);
+ $this->assertSame('the tree', (string) $row['WoTranslation']);
+ }
+
+ public function testEmptyTermFieldsAreSkippedNotStored(): void
+ {
+ (new ForeignDeckBuilder())
+ ->addMatureNote(['', 'orphan translation'], 30)
+ ->addMatureNote(['gut', 'good'], 30)
+ ->build($this->deckPath);
+
+ $result = AnkiDeckImportService::default()->import($this->deckPath, $this->settings());
+
+ $this->assertSame(1, $result->created);
+ $this->assertSame(1, $result->skippedEmpty);
+ }
+
+ public function testFixedStatusAppliesToEveryTerm(): void
+ {
+ (new ForeignDeckBuilder())
+ ->addMatureNote(['eins', 'one'], 200)
+ ->addNote(['zwei', 'two'])
+ ->build($this->deckPath);
+
+ $settings = new DeckImportSettings(
+ notetypeId: 1500000000000,
+ termField: 'Front',
+ translationField: 'Back',
+ languageId: self::$languageId,
+ deriveStatus: false,
+ fixedStatus: 99,
+ );
+
+ AnkiDeckImportService::default()->import($this->deckPath, $settings);
+
+ $statuses = Connection::preparedFetchAll(
+ 'SELECT DISTINCT WoStatus FROM words WHERE WoLgID = ?',
+ [self::$languageId]
+ );
+
+ $this->assertCount(1, $statuses);
+ $this->assertSame(99, (int) $statuses[0]['WoStatus']);
+ }
+
+ public function testTagsAreImportedWhenRequested(): void
+ {
+ (new ForeignDeckBuilder())
+ ->addMatureNote(['getaggt', 'tagged'], 40, 'noun core1000')
+ ->build($this->deckPath);
+
+ AnkiDeckImportService::default()->import($this->deckPath, $this->settings());
+
+ $wordId = (int) Connection::preparedFetchValue(
+ 'SELECT WoID AS value FROM words WHERE WoLgID = ? AND WoTextLC = ?',
+ [self::$languageId, 'getaggt']
+ );
+
+ $tagCount = (int) Connection::preparedFetchValue(
+ 'SELECT COUNT(*) AS value FROM word_tag_map WHERE WtWoID = ?',
+ [$wordId]
+ );
+
+ $this->assertSame(2, $tagCount);
+ }
+
+ public function testTranslationIsOptional(): void
+ {
+ (new ForeignDeckBuilder())
+ ->addMatureNote(['nurwort', 'ignored'], 40)
+ ->build($this->deckPath);
+
+ $result = AnkiDeckImportService::default()
+ ->import($this->deckPath, $this->settings(translation: null));
+
+ $this->assertSame(1, $result->created);
+
+ $row = Connection::preparedFetchOne(
+ 'SELECT WoTranslation FROM words WHERE WoLgID = ?',
+ [self::$languageId]
+ );
+ $this->assertNotNull($row);
+ // WordCrudService normalises an empty translation to the '*' placeholder.
+ $this->assertContains((string) $row['WoTranslation'], ['', '*']);
+ }
+}
diff --git a/tests/backend/Modules/Vocabulary/Application/Services/Anki/DeckImportSettingsTest.php b/tests/backend/Modules/Vocabulary/Application/Services/Anki/DeckImportSettingsTest.php
new file mode 100644
index 000000000..df97df363
--- /dev/null
+++ b/tests/backend/Modules/Vocabulary/Application/Services/Anki/DeckImportSettingsTest.php
@@ -0,0 +1,122 @@
+ 'w', 'Back' => 't'],
+ tags: [],
+ interval: $interval,
+ suspended: $suspended,
+ isNew: $isNew,
+ );
+ }
+
+ public function testMatureCardsBecomeWellKnown(): void
+ {
+ $settings = $this->settings();
+
+ $this->assertSame(99, $settings->statusFor($this->note(21)), 'exactly at the threshold');
+ $this->assertSame(99, $settings->statusFor($this->note(365)));
+ }
+
+ public function testYoungCardsScaleWithInterval(): void
+ {
+ $settings = $this->settings();
+
+ $this->assertSame(2, $settings->statusFor($this->note(1)));
+ $this->assertSame(2, $settings->statusFor($this->note(6)));
+ $this->assertSame(3, $settings->statusFor($this->note(7)));
+ $this->assertSame(3, $settings->statusFor($this->note(13)));
+ $this->assertSame(4, $settings->statusFor($this->note(14)));
+ $this->assertSame(4, $settings->statusFor($this->note(20)));
+ }
+
+ public function testUnstudiedCardsStartAtTheLowestStatus(): void
+ {
+ $settings = $this->settings();
+
+ $this->assertSame(1, $settings->statusFor($this->note(0, isNew: true)));
+ $this->assertSame(1, $settings->statusFor($this->note(0)));
+ }
+
+ public function testSuspendedCardsBecomeIgnored(): void
+ {
+ $settings = $this->settings();
+
+ // Suspension wins over a long interval: the user parked it deliberately.
+ $this->assertSame(98, $settings->statusFor($this->note(200, suspended: true)));
+ }
+
+ public function testFixedStatusOverridesTheDerivation(): void
+ {
+ $settings = $this->settings(derive: false, fixed: 99);
+
+ $this->assertSame(99, $settings->statusFor($this->note(0, isNew: true)));
+ $this->assertSame(99, $settings->statusFor($this->note(200, suspended: true)));
+ }
+
+ public function testDerivedStatusIsAlwaysAValidTermStatus(): void
+ {
+ $settings = $this->settings();
+ $valid = [1, 2, 3, 4, 5, 98, 99];
+
+ foreach ([0, 1, 5, 7, 13, 14, 20, 21, 100, 10000] as $interval) {
+ foreach ([true, false] as $suspended) {
+ foreach ([true, false] as $isNew) {
+ $this->assertContains(
+ $settings->statusFor($this->note($interval, $suspended, $isNew)),
+ $valid,
+ "interval={$interval}"
+ );
+ }
+ }
+ }
+ }
+
+ public function testRejectsMissingTermField(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ new DeckImportSettings(1, '', 'Back', 3);
+ }
+
+ public function testRejectsMissingLanguage(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ new DeckImportSettings(1, 'Front', 'Back', 0);
+ }
+
+ public function testRejectsInvalidFixedStatus(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ new DeckImportSettings(1, 'Front', 'Back', 3, false, 42);
+ }
+}
diff --git a/tests/backend/Modules/Vocabulary/Infrastructure/Anki/AnkiFieldTextTest.php b/tests/backend/Modules/Vocabulary/Infrastructure/Anki/AnkiFieldTextTest.php
new file mode 100644
index 000000000..799d0c26b
--- /dev/null
+++ b/tests/backend/Modules/Vocabulary/Infrastructure/Anki/AnkiFieldTextTest.php
@@ -0,0 +1,61 @@
+
+ */
+ public static function fieldProvider(): array
+ {
+ return [
+ 'plain text is untouched' => ['der Hund', 'der Hund'],
+ 'strips bold' => ['der Hund', 'der Hund'],
+ 'strips styled spans' => ['Katze', 'Katze'],
+ 'strips div wrappers' => ['