Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ ones are marked like "v1.0.0-fork".
complete and machine-translated, with the same invitation: please open
an issue for any string that reads wrong.

* **Pull reviews from Anki without a file** (#264). A new page,
`/vocabulary/anki/sync`, linked from the `.apkg` import page, reads the reviews you did in Anki straight from Anki on your computer,
through the AnkiConnect add-on, and merges them into LWT's schedule. Anki
asks once whether to trust the page; nothing in Anki is changed. Pulling
again is safe, since reviews LWT already has are recognised and skipped.
Only cards LWT exported are matched. On a hosted LWT the browser may ask
for local network access to reach Anki.

* **Language-aware plural forms** (#319). Counted messages now take the
form the interface language needs, not just English's singular and
plural: Arabic's six forms (0, 1, 2, 3–10, 11–99, 100…), Russian's
Expand Down Expand Up @@ -96,6 +104,13 @@ ones are marked like "v1.0.0-fork".

### Fixed

* **An Anki review older than LWT's latest is no longer dropped** (#264).
Importing an `.apkg` used to skip any Anki review that happened before the
term's last review in LWT, so studying in both apps between two imports lost
the Anki side. Reviews now join the history where they happened and the
later ones are recomputed on top, giving the schedule a single history
would have.

* **The statistics and admin pages are translated.** The streak ("1 day"),
the activity calendar's weekday and month labels, its tooltips and its
error message were English in every locale, as were the titles of the
Expand Down
56 changes: 56 additions & 0 deletions cypress/e2e/21-anki-import.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,60 @@ describe('Anki import pages', () => {
cy.get('body').should('not.contain', 'vocabulary.anki.');
});
});

// #264: the page that pulls reviews from Anki over AnkiConnect. Anki is not
// running under test, so this checks the page and its failure path; the
// merge itself is covered by MergeReviewHistoryTest and AnkiReviewPullTest.
describe('Pull reviews from Anki', () => {
beforeEach(() => {
cy.visit('/vocabulary/anki/sync');
cy.waitForAlpine();
});

it('should load, with every label translated', () => {
cy.get('h1').should('be.visible');
cy.contains('Internal Server Error').should('not.exist');
cy.get('body').should('not.contain', 'vocabulary.anki.');
});

it('should be linked from the .apkg import page', () => {
cy.visit('/vocabulary/apkg/import');
cy.get('a[href="/vocabulary/anki/sync"]').should('exist');
});

it('should say so when Anki cannot be reached', () => {
cy.intercept('POST', 'http://127.0.0.1:8765/', { forceNetworkError: true });
cy.get('[x-data="ankiSync"] button.is-primary').click();
cy.get('[x-data="ankiSync"] .notification.is-danger')
.should('be.visible')
.and('contain', '127.0.0.1:8765');
});

it('should post what Anki returns and show the summary', () => {
cy.intercept('POST', 'http://127.0.0.1:8765/', (req) => {
// AnkiConnect requests go out as a plain-text JSON body.
const body = (typeof req.body === 'string' ? JSON.parse(req.body) : req.body) as { action: string };
const { action } = body;
const results: Record<string, unknown> = {
requestPermission: { permission: 'granted' },
findNotes: [100],
notesInfo: [{ noteId: 100, cards: [1000], fields: { LwtId: { value: '999999', order: 4 } } }],
getReviewsOfCards: { 1000: [{ id: 1770000000000, ease: 3 }] },
};
req.reply({ result: results[action] ?? null, error: null });
});
cy.intercept('POST', '**/api/v1/anki/reviews').as('pull');

cy.get('[x-data="ankiSync"] button.is-primary').click();

cy.wait('@pull').then(({ request }) => {
const sent = (typeof request.body === 'string' ? JSON.parse(request.body) : request.body) as {
reviews: unknown[];
};
expect(sent.reviews).to.deep.equal([{ term_id: 999999, reviewed_at: 1770000000000, ease: 3 }]);
});
// No term 999999 here, so the one review is reported as skipped.
cy.get('[x-data="ankiSync"] .notification.is-success').should('be.visible');
});
});
});
15 changes: 11 additions & 4 deletions docs-src/developer/anki-sync-conflicts.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ description: How LWT and Anki should merge when both change the same terms betwe

# Spike: Conflict Resolution for Live Anki Sync

**Status:** Spike result, no sync code written (#264). The roadmap asked for
**Status:** Spike result (#264). **Step 1 of the build order is implemented**:
reviews are pulled from Anki over AnkiConnect at `/vocabulary/anki/sync`, and
both that and the `.apkg` importer merge through `MergeReviewHistory`, which
fits an older review into the history instead of skipping it. Steps 2 and 3
are not built. The roadmap asked for
this before any: *"Spike conflict resolution FIRST (the Anki problem) — it
looks easy and isn't. Decide last-writer-wins+tombstones vs CRDT before
committing."*
Expand Down Expand Up @@ -258,9 +262,12 @@ The spike's acceptance list, to become the merge function's unit tests:
The `.apkg` round trip already covers the common case without a protocol. If
live sync is built, sequence it so each step is useful alone:

1. **Pull Anki reviews** (`cardReviews` since the stored high-water mark) and
fold them in, with the interleaving fix. One direction, reviews only, no
field conflicts possible.
1. **Pull Anki reviews** and fold them in, with the interleaving fix. One
direction, reviews only, no field conflicts possible. ✅ Implemented: the
browser reads every review of every `LWT Term` note with
`getReviewsOfCards` rather than `cardReviews` from a high-water mark, and
the server drops those it already has. Simpler and needs no stored cursor;
revisit if large collections make the pull slow.
2. **Push LWT reviews** (`insertReviews` + `setDueDate`), once the
`cards.data` question is answered.
3. **Fields, tags, flags and existence** through the sync base, with the
Expand Down
21 changes: 10 additions & 11 deletions docs-src/developer/term-status-fsrs.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,17 +266,16 @@ endpoint was always fine.
which is the durable half; replaying it keeps one model answering for the
whole vocabulary and works the same whether the collection used FSRS or not.

Only reviews later than LWT's own last review are replayed, which is the
entire conflict policy and needs no sync protocol: both sides timestamp their
reviews, so the merge is "apply what happened after the state we already hold,
in the order it happened". It has to be there anyway — an exported file
carries LWT's history back out with it, so replaying everything would apply
each review twice and collapse the interval. Two limits: comparison is at the
second precision `review_log` stores, so an Anki review in the same second as
ours is dropped (the safe way round); and an Anki review that predates our
last one is skipped rather than interleaved, since catching those means
rebuilding state from both histories. Ignored and well-known terms are left
alone — they were never in the queue.
Reviews join the term's history in time order (`MergeReviewHistory`), which
is the entire conflict policy and needs no sync protocol: reviews are
timestamped facts, so the merge is their union. An exported file carries
LWT's history back out with it; those reviews return with the same second
and grade and are dropped, so nothing is applied twice. A review predating
LWT's latest is fitted in where it happened and the later history is
recomputed on top (#264). The one approximation: the seed a term had before
its first graded review is not stored, so a review older than a seeded
term's whole history starts from a fresh seed of its current status.
Ignored and well-known terms are left alone — they were never in the queue.

- **The legacy scoring is retired.** `SCORE_FORMULA_*`,
`makeScoreRandomInsertUpdate()` and the three columns are gone, along with
Expand Down
49 changes: 48 additions & 1 deletion locale/ar/vocabulary.json
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,52 @@
"anki.apkg.settings_import": "وفي الاتجاه المعاكس، فعّل <em>Import any learning progress</em> عند استيراد رزمة LWT إلى Anki، وإلا بدأ Anki كل بطاقة من الصفر وتجاهل الجدول الذي صدّرته هذه الصفحة.",
"anki.apkg.deck_notice_title": "هل تستورد رزمة أنشأتها في Anki؟",
"anki.apkg.deck_notice": "لا تحدّث هذه الصفحة إلا المصطلحات التي صدّرها LWT في الأصل — فرزمة من Anki أو AnkiWeb ليس لها ما تُطابَق معه هنا، فلن يتغير شيء. استخدم <a href=\"{url}\">استيراد رزمة Anki</a> بدلًا من ذلك، الذي يُنشئ مصطلحات جديدة ويستنتج مدى معرفتك بكل كلمة من جدولة Anki نفسها.",
"anki.sync.link": "سحب المراجعات من Anki (AnkiConnect)",
"anki.sync.intro": "انقل المراجعات التي أجريتها في Anki إلى جدول LWT مباشرةً من Anki على هذا الحاسوب: دون تصدير أي ملف أو استيراده.",
"anki.sync.step_addon_html": "ثبّت الإضافة <a href=\"https://ankiweb.net/shared/info/2055492159\" target=\"_blank\" rel=\"noopener\">AnkiConnect</a>، وأبقِ Anki مفتوحًا.",
"anki.sync.step_export_html": "يجب أن تأتي بطاقاتك من LWT: صدّرها مرة واحدة عبر <em>تصدير اللغة إلى حزمة Anki (.apkg)</em>، لكي تحمل كل ملاحظة المعرّف <code>LwtId</code> الخاص بها.",
"anki.sync.step_open": "انقر الزر أدناه. في المرة الأولى، يسألك Anki هل يثق بهذه الصفحة: وافق.",
"anki.sync.button": "سحب المراجعات من Anki",
"anki.sync.connecting": "جارٍ الاتصال بـ Anki...",
"anki.sync.reading_notes": "قراءة الملاحظات: {done} من {total}",
"anki.sync.reading_reviews": "قراءة المراجعات: البطاقة {done} من {total}",
"anki.sync.sending": "الإرسال إلى LWT: المراجعة {done} من {total}",
"anki.sync.done": "سُحبت المراجعات من Anki.",
"anki.sync.applied_zero": "لم تُدمج أي مراجعة جديدة",
"anki.sync.applied_one": "دُمجت مراجعة جديدة واحدة",
"anki.sync.applied_two": "دُمجت مراجعتان جديدتان",
"anki.sync.applied_few": "دُمجت {count} مراجعات جديدة",
"anki.sync.applied_many": "دُمجت {count} مراجعةً جديدةً",
"anki.sync.applied_other": "دُمجت {count} مراجعة جديدة",
"anki.sync.terms_zero": "لم تُعَد جدولة أي مصطلح",
"anki.sync.terms_one": "أُعيدت جدولة مصطلح واحد",
"anki.sync.terms_two": "أُعيدت جدولة مصطلحين",
"anki.sync.terms_few": "أُعيدت جدولة {count} مصطلحات",
"anki.sync.terms_many": "أُعيدت جدولة {count} مصطلحًا",
"anki.sync.terms_other": "أُعيدت جدولة {count} مصطلح",
"anki.sync.duplicates_zero": "لا توجد مراجعات كانت لدى LWT مسبقًا",
"anki.sync.duplicates_one": "مراجعة واحدة كانت لدى LWT مسبقًا",
"anki.sync.duplicates_two": "مراجعتان كانتا لدى LWT مسبقًا",
"anki.sync.duplicates_few": "{count} مراجعات كانت لدى LWT مسبقًا",
"anki.sync.duplicates_many": "{count} مراجعةً كانت لدى LWT مسبقًا",
"anki.sync.duplicates_other": "{count} مراجعة كانت لدى LWT مسبقًا",
"anki.sync.rebuilt_zero": "لم تُدرج مراجعات أقدم من Anki في سجل أي مصطلح",
"anki.sync.rebuilt_one": "مصطلح واحد أُدرجت في سجله مراجعة أقدم من Anki",
"anki.sync.rebuilt_two": "مصطلحان أُدرجت في سجلّيهما مراجعات أقدم من Anki",
"anki.sync.rebuilt_few": "{count} مصطلحات أُدرجت في سجلاتها مراجعات أقدم من Anki",
"anki.sync.rebuilt_many": "{count} مصطلحًا أُدرجت في سجلاتها مراجعات أقدم من Anki",
"anki.sync.rebuilt_other": "{count} مصطلح أُدرجت في سجلاتها مراجعات أقدم من Anki",
"anki.sync.skipped_zero": "لم يُتجاوز أي مراجعة",
"anki.sync.skipped_one": "تم تجاوز مراجعة واحدة (مصطلح متجاهَل أو معروف جيدًا أو محذوف)",
"anki.sync.skipped_two": "تم تجاوز مراجعتين (مصطلحات متجاهَلة أو معروفة جيدًا أو محذوفة)",
"anki.sync.skipped_few": "تم تجاوز {count} مراجعات (مصطلحات متجاهَلة أو معروفة جيدًا أو محذوفة)",
"anki.sync.skipped_many": "تم تجاوز {count} مراجعةً (مصطلحات متجاهَلة أو معروفة جيدًا أو محذوفة)",
"anki.sync.skipped_other": "تم تجاوز {count} مراجعة (مصطلحات متجاهَلة أو معروفة جيدًا أو محذوفة)",
"anki.sync.error_unreachable": "تعذّر الوصول إلى Anki على {url}. هل Anki مفتوح، وإضافة AnkiConnect مثبّتة؟",
"anki.sync.error_denied": "رفض Anki الوصول. انقر الزر مرة أخرى ووافق على الطلب في Anki.",
"anki.sync.error_failed": "فشل السحب: {message}",
"anki.sync.how_html": "تنتقل المراجعات وحدها، من Anki إلى LWT: لا يتغير شيء في Anki. تنضم كل مراجعة إلى سجل المصطلح في اللحظة التي حدثت فيها، لذا فإن الدراسة في التطبيقين بين سحبين تعطي الجدول نفسه الذي كنت ستحصل عليه في تطبيق واحد. السحب مرة أخرى آمن: تُتجاوز المراجعات الموجودة مسبقًا في LWT.",
"anki.sync.hosted_html": "إذا كان LWT يعمل على خادم آخر، فقد يطلب متصفحك إذنًا للوصول إلى Anki على هذا الحاسوب، أو يحظره؛ والسماح بـ<em>الوصول إلى الشبكة المحلية</em> لهذا الموقع يحل ذلك. أما <a href=\"/vocabulary/apkg/import\">تبادل ملفات ‎.apkg</a> فيعمل في كل مكان.",
"titles.terms": "المصطلحات",
"titles.term": "مصطلح",
"titles.term_named": "المصطلح: {term}",
Expand All @@ -530,5 +576,6 @@
"titles.not_found": "غير موجود",
"titles.error": "خطأ",
"titles.status_changed": "تم تغيير الحالة",
"titles.status_set": "تم تعيين الحالة"
"titles.status_set": "تم تعيين الحالة",
"titles.anki_sync": "مزامنة المراجعات من Anki"
}
29 changes: 28 additions & 1 deletion locale/de/vocabulary.json
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,32 @@
"anki.apkg.settings_import": "Umgekehrt aktivieren Sie <em>Lernfortschritt importieren</em>, wenn Sie ein LWT-Deck in Anki importieren, sonst beginnt Anki jede Karte von vorn und verwirft den von dieser Seite exportierten Lernplan.",
"anki.apkg.deck_notice_title": "Möchten Sie ein in Anki erstelltes Deck importieren?",
"anki.apkg.deck_notice": "Diese Seite aktualisiert nur Begriffe, die ursprünglich von LWT exportiert wurden – ein Deck aus Anki oder AnkiWeb hat hier nichts, womit es abgeglichen werden könnte, es würde sich also nichts ändern. Verwenden Sie stattdessen <a href=\"{url}\">Ein Anki-Deck importieren</a>: Dort werden neue Begriffe erstellt und anhand von Ankis eigenem Lernplan ermittelt, wie gut Sie jedes Wort kennen.",
"anki.sync.link": "Wiederholungen aus Anki abrufen (AnkiConnect)",
"anki.sync.intro": "Übernehmen Sie Ihre Wiederholungen aus Anki direkt von diesem Computer in den Lernplan von LWT – ohne Datei zum Exportieren oder Importieren.",
"anki.sync.step_addon_html": "Installieren Sie das Add-on <a href=\"https://ankiweb.net/shared/info/2055492159\" target=\"_blank\" rel=\"noopener\">AnkiConnect</a> und lassen Sie Anki geöffnet.",
"anki.sync.step_export_html": "Ihre Karten müssen aus LWT stammen: Exportieren Sie sie einmal mit <em>Sprache als Anki-Paket (.apkg) exportieren</em>, damit jede Notiz ihre <code>LwtId</code> enthält.",
"anki.sync.step_open": "Klicken Sie auf die Schaltfläche unten. Beim ersten Mal fragt Anki, ob es dieser Seite vertrauen soll: Bestätigen Sie.",
"anki.sync.button": "Wiederholungen aus Anki abrufen",
"anki.sync.connecting": "Verbindung zu Anki wird hergestellt...",
"anki.sync.reading_notes": "Notizen werden gelesen: {done} von {total}",
"anki.sync.reading_reviews": "Wiederholungen werden gelesen: {done} von {total} Karten",
"anki.sync.sending": "Senden an LWT: {done} von {total} Wiederholungen",
"anki.sync.done": "Wiederholungen aus Anki abgerufen.",
"anki.sync.applied_one": "{count} neue Wiederholung übernommen",
"anki.sync.applied_other": "{count} neue Wiederholungen übernommen",
"anki.sync.terms_one": "{count} Begriff neu geplant",
"anki.sync.terms_other": "{count} Begriffe neu geplant",
"anki.sync.duplicates_one": "{count} Wiederholung war in LWT bereits vorhanden",
"anki.sync.duplicates_other": "{count} Wiederholungen waren in LWT bereits vorhanden",
"anki.sync.rebuilt_one": "Bei {count} Begriff wurde eine ältere Anki-Wiederholung in den Verlauf eingefügt",
"anki.sync.rebuilt_other": "Bei {count} Begriffen wurden ältere Anki-Wiederholungen in den Verlauf eingefügt",
"anki.sync.skipped_one": "{count} Wiederholung übersprungen (ignorierter, bekannter oder gelöschter Begriff)",
"anki.sync.skipped_other": "{count} Wiederholungen übersprungen (ignorierte, bekannte oder gelöschte Begriffe)",
"anki.sync.error_unreachable": "Anki unter {url} nicht erreichbar. Ist Anki geöffnet und das Add-on AnkiConnect installiert?",
"anki.sync.error_denied": "Anki hat den Zugriff verweigert. Klicken Sie erneut auf die Schaltfläche und bestätigen Sie die Abfrage in Anki.",
"anki.sync.error_failed": "Abruf fehlgeschlagen: {message}",
"anki.sync.how_html": "Nur Wiederholungen werden übertragen, von Anki nach LWT: In Anki wird nichts geändert. Jede Wiederholung wird zum Zeitpunkt, an dem sie stattfand, in den Verlauf des Begriffs eingefügt. Wer zwischen zwei Abrufen in beiden Apps lernt, erhält also denselben Lernplan wie in einer einzigen. Erneutes Abrufen ist unbedenklich: Wiederholungen, die LWT bereits hat, werden übersprungen.",
"anki.sync.hosted_html": "Läuft LWT auf einem anderen Server, fragt Ihr Browser möglicherweise nach der Berechtigung, Anki auf diesem Computer zu erreichen, oder blockiert dies; wenn Sie für diese Website den <em>Zugriff auf das lokale Netzwerk</em> erlauben, ist das behoben. Der <a href=\"/vocabulary/apkg/import\">Weg über eine .apkg-Datei</a> funktioniert überall.",
"titles.terms": "Begriffe",
"titles.term": "Begriff",
"titles.term_named": "Begriff: {term}",
Expand All @@ -441,5 +467,6 @@
"titles.not_found": "Nicht gefunden",
"titles.error": "Fehler",
"titles.status_changed": "Status geändert",
"titles.status_set": "Status gesetzt"
"titles.status_set": "Status gesetzt",
"titles.anki_sync": "Wiederholungen aus Anki synchronisieren"
}
Loading
Loading