Skip to content

feat(anki): sync fields, tags, suspension and existence with Anki (#264) - #329

Merged
HugoFara merged 1 commit into
developfrom
feat/264-note-sync
Nov 3, 2026
Merged

HugoFara merged 1 commit into
developfrom
feat/264-note-sync

Conversation

@HugoFara

@HugoFara HugoFara commented Nov 3, 2026

Copy link
Copy Markdown
Owner

Step 3, the last piece of #264. The sync page now keeps LWT and Anki in step for everything the export carries, not only reviews. Tested against real Anki in the container from #328.

What syncs

On top of reviews (steps 1 and 2), for every term whose language has an LWT::{Language} deck in Anki (the export's naming, so no setup):

  • Translation, romanization, notes: a three-way merge against anki_sync_base, the values both sides agreed on at the last sync. A change on one side is copied to the other, and edits to different fields of one term both survive.
  • Tags: merged as sets, so additions on both sides survive and a removal on either side applies.
  • Suspension ↔ ignored (98): suspending in Anki ignores the term and remembers its status; unsuspending gives the status back. Ignoring in LWT suspends the card. Status 1–5 stays LWT's own.
  • New terms and notes: a term in a synced language is added to Anki. A note added to an LWT:: deck becomes a term, and its LwtId is written back.
  • Deletions: a term or note deleted on one side is listed with a "Delete them" button and only removed on the other side once confirmed. If the other side changed the item since the last sync (fields, tags, flag or a review), the item is re-created on the deleting side instead.

The two choices that differ from the spike

Both lean towards losing nothing:

  • True conflicts are reported, not decided. A field changed differently on both sides, or that differs on the very first sync, is listed with both values and left alone. The base keeps the old agreement until the learner makes the two match. This needs no WoUpdated column and no clock comparison between server and desktop.
  • Deletions are confirmed, as above.

How it runs

The server does the planning and the browser does the writes:

  1. POST /api/v1/anki/notes-plan returns Anki's half.
  2. The browser writes it through AnkiConnect.
  3. POST /api/v1/anki/notes-commit recomputes the same plan, applies LWT's half and stores the new base, all in one transaction.

An interrupted sync converges on the next run: writes that landed read as already agreed, and a note whose LwtId write failed still pairs through the base by note id.

  • NoteSyncPlanner is pure: no database, no Anki.
  • AnkiNoteSync builds the inputs through ApkgTermMapper, so values match the export exactly.
  • anki_sync_base (new migration) deliberately has no foreign key to words: a row whose term is gone is how a deletion in LWT reaches Anki. It's scoped per user, note ids are unique per user, and restore and wipe clear it.

Bug found by the live test

Actions inside AnkiConnect's multi need their own version. Without it they answer in the old format, and the per-card error check added in #328 never fired. All multi calls now send version: 6.

Tests

  • NoteSyncPlannerTest (21): every scenario in the spike's acceptance list, plus first-sync conflicts, pairing through the base, orphans, deck scoping and status restore.
  • AnkiNoteSyncIntegrationTest (4, MySQL): an edit in Anki reaching LWT and a quiet second sync, an addition recorded after the browser makes it, a note becoming a term, and a deletion held until confirmed.
  • 24-anki-live.cy.ts, new test, against real Anki:
    • edits on both sides, an Anki tag, an Anki suspension, a note added in Anki and a term added in LWT;
    • after a sync, both sides are checked, and a fresh plan from Anki's state moves nothing;
    • then a note is deleted in Anki, and the term only goes once confirmed.
    • It passes three times in a row with FSRS off and once with it on, and cleans up after itself even when a run fails part-way.
  • Gates: composer test:no-coverage (9245), npm test (4440), Psalm (no errors), phpcs, lint, build:all and docs:build all pass. 21 to 24 pass, and check-locales reports 100% for all 11 locales.

Docs: the spike now reads "implemented in full", with the two deviations written up. CHANGELOG updated.

🤖 Generated with Claude Code

Step 3 of the sync build order, so the sync page now keeps LWT and Anki
in step for everything the export carries, not only reviews.

NoteSyncPlanner merges each term and its note three-way against
anki_sync_base, what both sides agreed on at the last sync: a field
changed on one side is copied to the other, edits to different fields
both survive, tags merge as sets, and suspending in Anki ignores the
term (and unsuspending restores its status). A field changed differently
on both sides, or never agreed, is reported and left alone rather than
decided by a clock. New terms in a language with an LWT:: deck are added
to Anki; notes added to such a deck become terms. A deletion on one side
is only applied on the other once the learner confirms it, and is turned
into a re-creation if the other side changed since.

AnkiNoteSync runs it as plan, apply to Anki, commit (one transaction),
so an interrupted sync converges on the next run. The base has no foreign
key to words, by design, and restore or wipe clears it.

Also fixes the AnkiConnect client: actions inside multi need their own
API version, or they answer in the old format; the push's per-card error
check never fired without it. Found by the live test against real Anki,
which now covers the note sync too.

Co-Authored-By: Claude <noreply@anthropic.com>
@HugoFara
HugoFara merged commit 86a1c1a into develop Nov 3, 2026
14 checks passed
@HugoFara
HugoFara deleted the feat/264-note-sync branch November 3, 2026 13:36
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