From 026a338c9e488f6ae536e9a93f11c72c2d2e2bc5 Mon Sep 17 00:00:00 2001 From: HugoFara Date: Tue, 3 Nov 2026 13:29:59 +0100 Subject: [PATCH] feat(anki): pull reviews from Anki over AnkiConnect (#264) Step 1 of the build order in the sync spike. A page at /vocabulary/anki/sync reads every review of LWT's notes from Anki through AnkiConnect on the learner's machine, maps cards to terms by the LwtId field, and posts them to POST /api/v1/anki/reviews in batches. Anki is only read; it asks once whether to trust the page (requestPermission). The server merges each term's reviews with MergeReviewHistory: reviews LWT already has (same second and grade) are dropped, so pulling twice is harmless; newer ones are appended from the stored state; and a review older than LWT's latest is fitted in where it happened, with the later history recomputed in one transaction. The .apkg importer now merges the same way, which fixes its old limit of skipping such reviews. The state a rebuild starts from is reconstructed exactly from the log; only a review predating a seeded term's whole history falls back to a fresh seed of its current status (seedFor). connect-src gains AnkiConnect on loopback. Strings in all 11 locales. Co-Authored-By: Claude --- CHANGELOG.md | 15 ++ cypress/e2e/21-anki-import.cy.ts | 56 +++++ docs-src/developer/anki-sync-conflicts.md | 15 +- docs-src/developer/term-status-fsrs.md | 21 +- locale/ar/vocabulary.json | 49 +++- locale/de/vocabulary.json | 29 ++- locale/en/vocabulary.json | 29 ++- locale/es/vocabulary.json | 29 ++- locale/fa/vocabulary.json | 24 +- locale/fr/vocabulary.json | 29 ++- locale/it/vocabulary.json | 29 ++- locale/ja/vocabulary.json | 24 +- locale/pt/vocabulary.json | 29 ++- locale/ru/vocabulary.json | 39 ++- locale/zh/vocabulary.json | 24 +- .../UseCases/MergeReviewHistory.php | 222 ++++++++++++++++++ .../Domain/Scheduling/HistoricReview.php | 47 ++++ .../TermScheduleRepositoryInterface.php | 27 +++ .../MySqlTermScheduleRepository.php | 69 +++++- .../Services/Anki/AnkiReviewPull.php | 142 +++++++++++ .../Services/Anki/ScheduleReplay.php | 67 ++---- .../Vocabulary/Http/AnkiApiHandler.php | 80 +++++++ .../Vocabulary/Http/ApkgController.php | 12 + src/Modules/Vocabulary/Views/anki_sync.php | 76 ++++++ src/Modules/Vocabulary/Views/apkg_import.php | 6 + .../Infrastructure/Http/SecurityHeaders.php | 7 +- src/Shared/Infrastructure/Routing/routes.php | 9 + src/backend/Api/V1/ApiV1.php | 2 + src/backend/Api/V1/Endpoints.php | 1 + src/frontend/js/modules/vocabulary/index.ts | 1 + .../js/modules/vocabulary/pages/anki_sync.ts | 129 ++++++++++ .../vocabulary/services/anki_connect.ts | 151 ++++++++++++ .../UseCases/MergeReviewHistoryTest.php | 175 ++++++++++++++ .../TermScheduleRepositoryIntegrationTest.php | 48 ++++ .../Services/Anki/AnkiReviewPullTest.php | 164 +++++++++++++ .../Anki/InMemoryTermScheduleRepository.php | 62 ++++- .../Services/Anki/ScheduleReplayTest.php | 70 +++++- .../Vocabulary/Http/AnkiApiHandlerTest.php | 66 ++++++ tests/frontend/vocabulary/anki_sync.test.ts | 121 ++++++++++ 39 files changed, 2119 insertions(+), 76 deletions(-) create mode 100644 src/Modules/Review/Application/UseCases/MergeReviewHistory.php create mode 100644 src/Modules/Review/Domain/Scheduling/HistoricReview.php create mode 100644 src/Modules/Vocabulary/Application/Services/Anki/AnkiReviewPull.php create mode 100644 src/Modules/Vocabulary/Http/AnkiApiHandler.php create mode 100644 src/Modules/Vocabulary/Views/anki_sync.php create mode 100644 src/frontend/js/modules/vocabulary/pages/anki_sync.ts create mode 100644 src/frontend/js/modules/vocabulary/services/anki_connect.ts create mode 100644 tests/backend/Modules/Review/Application/UseCases/MergeReviewHistoryTest.php create mode 100644 tests/backend/Modules/Vocabulary/Application/Services/Anki/AnkiReviewPullTest.php create mode 100644 tests/backend/Modules/Vocabulary/Http/AnkiApiHandlerTest.php create mode 100644 tests/frontend/vocabulary/anki_sync.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 14d36addd..143133fb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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 diff --git a/cypress/e2e/21-anki-import.cy.ts b/cypress/e2e/21-anki-import.cy.ts index aea0b4388..5dcd279da 100644 --- a/cypress/e2e/21-anki-import.cy.ts +++ b/cypress/e2e/21-anki-import.cy.ts @@ -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 = { + 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'); + }); + }); }); diff --git a/docs-src/developer/anki-sync-conflicts.md b/docs-src/developer/anki-sync-conflicts.md index 173b7f251..ea0146e12 100644 --- a/docs-src/developer/anki-sync-conflicts.md +++ b/docs-src/developer/anki-sync-conflicts.md @@ -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."* @@ -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 diff --git a/docs-src/developer/term-status-fsrs.md b/docs-src/developer/term-status-fsrs.md index 3e4e5d6c4..9b14b1d5a 100644 --- a/docs-src/developer/term-status-fsrs.md +++ b/docs-src/developer/term-status-fsrs.md @@ -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 diff --git a/locale/ar/vocabulary.json b/locale/ar/vocabulary.json index 2e104e984..2a091f552 100644 --- a/locale/ar/vocabulary.json +++ b/locale/ar/vocabulary.json @@ -521,6 +521,52 @@ "anki.apkg.settings_import": "وفي الاتجاه المعاكس، فعّل Import any learning progress عند استيراد رزمة LWT إلى Anki، وإلا بدأ Anki كل بطاقة من الصفر وتجاهل الجدول الذي صدّرته هذه الصفحة.", "anki.apkg.deck_notice_title": "هل تستورد رزمة أنشأتها في Anki؟", "anki.apkg.deck_notice": "لا تحدّث هذه الصفحة إلا المصطلحات التي صدّرها LWT في الأصل — فرزمة من Anki أو AnkiWeb ليس لها ما تُطابَق معه هنا، فلن يتغير شيء. استخدم استيراد رزمة Anki بدلًا من ذلك، الذي يُنشئ مصطلحات جديدة ويستنتج مدى معرفتك بكل كلمة من جدولة Anki نفسها.", + "anki.sync.link": "سحب المراجعات من Anki (AnkiConnect)", + "anki.sync.intro": "انقل المراجعات التي أجريتها في Anki إلى جدول LWT مباشرةً من Anki على هذا الحاسوب: دون تصدير أي ملف أو استيراده.", + "anki.sync.step_addon_html": "ثبّت الإضافة AnkiConnect، وأبقِ Anki مفتوحًا.", + "anki.sync.step_export_html": "يجب أن تأتي بطاقاتك من LWT: صدّرها مرة واحدة عبر تصدير اللغة إلى حزمة Anki (.apkg)، لكي تحمل كل ملاحظة المعرّف LwtId الخاص بها.", + "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 على هذا الحاسوب، أو يحظره؛ والسماح بـالوصول إلى الشبكة المحلية لهذا الموقع يحل ذلك. أما تبادل ملفات ‎.apkg فيعمل في كل مكان.", "titles.terms": "المصطلحات", "titles.term": "مصطلح", "titles.term_named": "المصطلح: {term}", @@ -530,5 +576,6 @@ "titles.not_found": "غير موجود", "titles.error": "خطأ", "titles.status_changed": "تم تغيير الحالة", - "titles.status_set": "تم تعيين الحالة" + "titles.status_set": "تم تعيين الحالة", + "titles.anki_sync": "مزامنة المراجعات من Anki" } diff --git a/locale/de/vocabulary.json b/locale/de/vocabulary.json index e7f169a1e..252baf972 100644 --- a/locale/de/vocabulary.json +++ b/locale/de/vocabulary.json @@ -432,6 +432,32 @@ "anki.apkg.settings_import": "Umgekehrt aktivieren Sie Lernfortschritt importieren, 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 Ein Anki-Deck importieren: 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 AnkiConnect und lassen Sie Anki geöffnet.", + "anki.sync.step_export_html": "Ihre Karten müssen aus LWT stammen: Exportieren Sie sie einmal mit Sprache als Anki-Paket (.apkg) exportieren, damit jede Notiz ihre LwtId 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 Zugriff auf das lokale Netzwerk erlauben, ist das behoben. Der Weg über eine .apkg-Datei funktioniert überall.", "titles.terms": "Begriffe", "titles.term": "Begriff", "titles.term_named": "Begriff: {term}", @@ -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" } diff --git a/locale/en/vocabulary.json b/locale/en/vocabulary.json index b9f3f545b..0ed51c261 100644 --- a/locale/en/vocabulary.json +++ b/locale/en/vocabulary.json @@ -419,6 +419,32 @@ "anki.apkg.settings_import": "Going the other way, tick Import any learning progress when you import an LWT deck into Anki, or Anki starts every card from scratch and discards the schedule this page exported.", "anki.apkg.deck_notice_title": "Importing a deck you built in Anki?", "anki.apkg.deck_notice": "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.", + "anki.sync.link": "Pull reviews from Anki (AnkiConnect)", + "anki.sync.intro": "Bring the reviews you did in Anki into LWT's schedule, straight from Anki on this computer: no file to export or import.", + "anki.sync.step_addon_html": "Install the AnkiConnect add-on, and keep Anki open.", + "anki.sync.step_export_html": "Your cards must come from LWT: export them once with Export Language to Anki package (.apkg), so that each note carries its LwtId.", + "anki.sync.step_open": "Click the button below. The first time, Anki asks whether to trust this page: accept.", + "anki.sync.button": "Pull reviews from Anki", + "anki.sync.connecting": "Connecting to Anki...", + "anki.sync.reading_notes": "Reading notes: {done} of {total}", + "anki.sync.reading_reviews": "Reading reviews: {done} of {total} cards", + "anki.sync.sending": "Sending to LWT: {done} of {total} reviews", + "anki.sync.done": "Reviews pulled from Anki.", + "anki.sync.applied_one": "{count} new review merged", + "anki.sync.applied_other": "{count} new reviews merged", + "anki.sync.terms_one": "{count} term rescheduled", + "anki.sync.terms_other": "{count} terms rescheduled", + "anki.sync.duplicates_one": "{count} review LWT already had", + "anki.sync.duplicates_other": "{count} reviews LWT already had", + "anki.sync.rebuilt_one": "{count} term had an older Anki review fitted into its history", + "anki.sync.rebuilt_other": "{count} terms had older Anki reviews fitted into their history", + "anki.sync.skipped_one": "{count} review skipped (ignored, well-known or deleted term)", + "anki.sync.skipped_other": "{count} reviews skipped (ignored, well-known or deleted terms)", + "anki.sync.error_unreachable": "Could not reach Anki at {url}. Is Anki open, with the AnkiConnect add-on installed?", + "anki.sync.error_denied": "Anki refused access. Click the button again and accept the prompt in Anki.", + "anki.sync.error_failed": "The pull failed: {message}", + "anki.sync.how_html": "Only reviews travel, from Anki to LWT: nothing in Anki is changed. Each review joins the term's history at the moment it happened, so studying in both apps between two pulls gives the schedule you would have had in one. Pulling again is safe: reviews LWT already has are skipped.", + "anki.sync.hosted_html": "If LWT runs on another server, your browser may ask for permission to reach Anki on this computer, or block it; allowing local network access for this site fixes that. The .apkg file round trip works everywhere.", "titles.terms": "Terms", "titles.term": "Term", "titles.term_named": "Term: {term}", @@ -428,5 +454,6 @@ "titles.not_found": "Not Found", "titles.error": "Error", "titles.status_changed": "Status Changed", - "titles.status_set": "Status Set" + "titles.status_set": "Status Set", + "titles.anki_sync": "Sync Reviews from Anki" } diff --git a/locale/es/vocabulary.json b/locale/es/vocabulary.json index 98c15e774..3d12be594 100644 --- a/locale/es/vocabulary.json +++ b/locale/es/vocabulary.json @@ -436,6 +436,32 @@ "anki.apkg.settings_import": "En sentido inverso, marca Importar el progreso de aprendizaje cuando importes un mazo de LWT en Anki, o Anki empezará cada tarjeta desde cero y descartará la programación que exportó esta página.", "anki.apkg.deck_notice_title": "¿Vas a importar un mazo que creaste en Anki?", "anki.apkg.deck_notice": "Esta página solo actualiza términos que LWT exportó en primer lugar: un mazo de Anki o de AnkiWeb no tiene nada aquí con lo que emparejarse, así que no cambiaría nada. Usa en su lugar Importar un mazo de Anki, que crea términos nuevos y deduce lo bien que conoces cada palabra a partir de la propia programación de Anki.", + "anki.sync.link": "Traer repasos desde Anki (AnkiConnect)", + "anki.sync.intro": "Lleva los repasos que hiciste en Anki a la programación de LWT, directamente desde Anki en este ordenador: sin archivos que exportar ni importar.", + "anki.sync.step_addon_html": "Instala el complemento AnkiConnect y deja Anki abierto.", + "anki.sync.step_export_html": "Tus tarjetas deben venir de LWT: expórtalas una vez con Exportar idioma a paquete Anki (.apkg), para que cada nota lleve su LwtId.", + "anki.sync.step_open": "Haz clic en el botón de abajo. La primera vez, Anki pregunta si confiar en esta página: acepta.", + "anki.sync.button": "Traer repasos desde Anki", + "anki.sync.connecting": "Conectando con Anki...", + "anki.sync.reading_notes": "Leyendo notas: {done} de {total}", + "anki.sync.reading_reviews": "Leyendo repasos: {done} de {total} tarjetas", + "anki.sync.sending": "Enviando a LWT: {done} de {total} repasos", + "anki.sync.done": "Repasos traídos desde Anki.", + "anki.sync.applied_one": "{count} repaso nuevo incorporado", + "anki.sync.applied_other": "{count} repasos nuevos incorporados", + "anki.sync.terms_one": "{count} término reprogramado", + "anki.sync.terms_other": "{count} términos reprogramados", + "anki.sync.duplicates_one": "{count} repaso que LWT ya tenía", + "anki.sync.duplicates_other": "{count} repasos que LWT ya tenía", + "anki.sync.rebuilt_one": "{count} término recibió en su historial un repaso anterior de Anki", + "anki.sync.rebuilt_other": "{count} términos recibieron en su historial repasos anteriores de Anki", + "anki.sync.skipped_one": "{count} repaso omitido (término ignorado, bien conocido o eliminado)", + "anki.sync.skipped_other": "{count} repasos omitidos (términos ignorados, bien conocidos o eliminados)", + "anki.sync.error_unreachable": "No se pudo conectar con Anki en {url}. ¿Está Anki abierto y el complemento AnkiConnect instalado?", + "anki.sync.error_denied": "Anki denegó el acceso. Vuelve a hacer clic en el botón y acepta el aviso en Anki.", + "anki.sync.error_failed": "No se pudieron traer los repasos: {message}", + "anki.sync.how_html": "Solo viajan los repasos, de Anki a LWT: no se cambia nada en Anki. Cada repaso se incorpora al historial del término en el momento en que ocurrió, así que estudiar en ambas aplicaciones entre dos sincronizaciones da la misma programación que habrías tenido en una sola. Volver a traerlos es seguro: se omiten los repasos que LWT ya tiene.", + "anki.sync.hosted_html": "Si LWT se ejecuta en otro servidor, tu navegador puede pedir permiso para acceder a Anki en este ordenador, o bloquearlo; permitir el acceso a la red local para este sitio lo soluciona. El intercambio con un archivo .apkg funciona en todas partes.", "titles.terms": "Términos", "titles.term": "Término", "titles.term_named": "Término: {term}", @@ -445,5 +471,6 @@ "titles.not_found": "No encontrado", "titles.error": "Error", "titles.status_changed": "Estado cambiado", - "titles.status_set": "Estado establecido" + "titles.status_set": "Estado establecido", + "titles.anki_sync": "Sincronizar repasos desde Anki" } diff --git a/locale/fa/vocabulary.json b/locale/fa/vocabulary.json index 6ec4d24fe..730bf3370 100644 --- a/locale/fa/vocabulary.json +++ b/locale/fa/vocabulary.json @@ -407,6 +407,27 @@ "anki.apkg.settings_import": "در جهت عکس، هنگام درون‌ریزی یک دستهٔ LWT در Anki، گزینهٔ Import any learning progress را علامت بزنید، وگرنه Anki همهٔ کارت‌ها را از صفر شروع می‌کند و زمان‌بندی‌ای را که این صفحه برون‌بری کرده دور می‌ریزد.", "anki.apkg.deck_notice_title": "دسته‌ای را درون‌ریزی می‌کنید که در Anki ساخته‌اید؟", "anki.apkg.deck_notice": "این صفحه فقط واژه‌هایی را به‌روزرسانی می‌کند که در اصل از LWT برون‌بری شده‌اند — دسته‌ای از Anki یا AnkiWeb در اینجا چیزی برای تطبیق ندارد، پس چیزی تغییر نمی‌کند. به‌جای آن از درون‌ریزی یک دستهٔ Anki استفاده کنید که واژه‌های جدید می‌سازد و میزان آشنایی شما با هر واژه را از زمان‌بندی خودِ Anki استخراج می‌کند.", + "anki.sync.link": "دریافت مرورها از Anki (AnkiConnect)", + "anki.sync.intro": "مرورهایی را که در Anki انجام داده‌اید، مستقیماً از Anki روی همین رایانه به زمان‌بندی LWT بیاورید: بدون برون‌بری یا درون‌ریزی فایل.", + "anki.sync.step_addon_html": "افزونهٔ AnkiConnect را نصب کنید و Anki را باز نگه دارید.", + "anki.sync.step_export_html": "کارت‌های شما باید از LWT آمده باشند: یک بار آن‌ها را با برون‌بری زبان به بستهٔ Anki (.apkg) برون‌بری کنید تا هر یادداشت LwtId خود را داشته باشد.", + "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_other": "{count} مرور جدید ادغام شد", + "anki.sync.terms_other": "زمان‌بندی {count} واژه به‌روزرسانی شد", + "anki.sync.duplicates_other": "{count} مرور از قبل در LWT بود", + "anki.sync.rebuilt_other": "در تاریخچهٔ {count} واژه، مرورهای قدیمی‌تری از Anki جای داده شد", + "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 روی این رایانه اجازه بخواهد یا آن را مسدود کند؛ اجازه دادن دسترسی به شبکهٔ محلی برای این سایت مشکل را حل می‌کند. رفت‌وبرگشت با فایل .apkg همه‌جا کار می‌کند.", "titles.terms": "واژه‌ها", "titles.term": "واژه", "titles.term_named": "واژه: {term}", @@ -416,5 +437,6 @@ "titles.not_found": "یافت نشد", "titles.error": "خطا", "titles.status_changed": "وضعیت تغییر کرد", - "titles.status_set": "وضعیت تنظیم شد" + "titles.status_set": "وضعیت تنظیم شد", + "titles.anki_sync": "همگام‌سازی مرورها از Anki" } diff --git a/locale/fr/vocabulary.json b/locale/fr/vocabulary.json index fb75ed97c..4cb4063c7 100644 --- a/locale/fr/vocabulary.json +++ b/locale/fr/vocabulary.json @@ -435,6 +435,32 @@ "anki.apkg.settings_import": "Dans l'autre sens, cochez Importer la progression d'apprentissage lorsque vous importez un paquet LWT dans Anki, sinon Anki repart de zéro pour chaque carte et ignore la planification exportée par cette page.", "anki.apkg.deck_notice_title": "Vous importez un paquet créé dans Anki ?", "anki.apkg.deck_notice": "Cette page ne met à jour que les termes exportés par LWT au départ — un paquet venant d'Anki ou d'AnkiWeb n'a rien ici à quoi correspondre, rien ne changerait donc. Utilisez plutôt Importer un paquet Anki, qui crée de nouveaux termes et détermine votre niveau de connaissance de chaque mot à partir de la planification d'Anki.", + "anki.sync.link": "Récupérer les révisions d'Anki (AnkiConnect)", + "anki.sync.intro": "Intégrez les révisions faites dans Anki à la planification de LWT, directement depuis Anki sur cet ordinateur : aucun fichier à exporter ni à importer.", + "anki.sync.step_addon_html": "Installez l'extension AnkiConnect et laissez Anki ouvert.", + "anki.sync.step_export_html": "Vos cartes doivent venir de LWT : exportez-les une fois avec Exporter la langue en paquet Anki (.apkg), pour que chaque note porte son LwtId.", + "anki.sync.step_open": "Cliquez sur le bouton ci-dessous. La première fois, Anki demande s'il faut faire confiance à cette page : acceptez.", + "anki.sync.button": "Récupérer les révisions d'Anki", + "anki.sync.connecting": "Connexion à Anki...", + "anki.sync.reading_notes": "Lecture des notes : {done} sur {total}", + "anki.sync.reading_reviews": "Lecture des révisions : {done} cartes sur {total}", + "anki.sync.sending": "Envoi à LWT : {done} révisions sur {total}", + "anki.sync.done": "Révisions récupérées depuis Anki.", + "anki.sync.applied_one": "{count} nouvelle révision intégrée", + "anki.sync.applied_other": "{count} nouvelles révisions intégrées", + "anki.sync.terms_one": "{count} terme replanifié", + "anki.sync.terms_other": "{count} termes replanifiés", + "anki.sync.duplicates_one": "{count} révision déjà présente dans LWT", + "anki.sync.duplicates_other": "{count} révisions déjà présentes dans LWT", + "anki.sync.rebuilt_one": "{count} terme a reçu une révision Anki plus ancienne dans son historique", + "anki.sync.rebuilt_other": "{count} termes ont reçu des révisions Anki plus anciennes dans leur historique", + "anki.sync.skipped_one": "{count} révision sautée (terme ignoré, bien connu ou supprimé)", + "anki.sync.skipped_other": "{count} révisions sautées (termes ignorés, bien connus ou supprimés)", + "anki.sync.error_unreachable": "Impossible de joindre Anki à l'adresse {url}. Anki est-il ouvert, avec l'extension AnkiConnect installée ?", + "anki.sync.error_denied": "Anki a refusé l'accès. Cliquez à nouveau sur le bouton et acceptez la demande dans Anki.", + "anki.sync.error_failed": "La récupération a échoué : {message}", + "anki.sync.how_html": "Seules les révisions voyagent, d'Anki vers LWT : rien n'est modifié dans Anki. Chaque révision rejoint l'historique du terme au moment où elle a eu lieu ; étudier dans les deux applications entre deux récupérations donne donc la planification que vous auriez eue dans une seule. Récupérer à nouveau est sans risque : les révisions que LWT possède déjà sont ignorées.", + "anki.sync.hosted_html": "Si LWT tourne sur un autre serveur, votre navigateur peut demander l'autorisation de joindre Anki sur cet ordinateur, ou le bloquer ; autoriser l'accès au réseau local pour ce site règle le problème. L'aller-retour par fichier .apkg fonctionne partout.", "titles.terms": "Termes", "titles.term": "Terme", "titles.term_named": "Terme: {term}", @@ -444,5 +470,6 @@ "titles.not_found": "Introuvable", "titles.error": "Erreur", "titles.status_changed": "Statut modifié", - "titles.status_set": "Statut défini" + "titles.status_set": "Statut défini", + "titles.anki_sync": "Synchroniser les révisions depuis Anki" } diff --git a/locale/it/vocabulary.json b/locale/it/vocabulary.json index d9e486c2e..eb02eb2a6 100644 --- a/locale/it/vocabulary.json +++ b/locale/it/vocabulary.json @@ -436,6 +436,32 @@ "anki.apkg.settings_import": "Nella direzione opposta, spunta Importa i progressi di apprendimento quando importi un mazzo LWT in Anki, altrimenti Anki ricomincia ogni carta da zero e scarta la pianificazione esportata da questa pagina.", "anki.apkg.deck_notice_title": "Stai importando un mazzo creato in Anki?", "anki.apkg.deck_notice": "Questa pagina aggiorna solo i termini che LWT ha esportato in origine: un mazzo proveniente da Anki o AnkiWeb non ha nulla qui con cui essere confrontato, quindi non cambierebbe niente. Usa invece Importa un mazzo Anki, che crea nuovi termini e deduce quanto conosci ogni parola dalla pianificazione di Anki.", + "anki.sync.link": "Recupera le ripetizioni da Anki (AnkiConnect)", + "anki.sync.intro": "Porta le ripetizioni fatte in Anki nella pianificazione di LWT, direttamente da Anki su questo computer: nessun file da esportare o importare.", + "anki.sync.step_addon_html": "Installa il componente aggiuntivo AnkiConnect e tieni Anki aperto.", + "anki.sync.step_export_html": "Le tue carte devono provenire da LWT: esportale una volta con Esporta lingua come pacchetto Anki (.apkg), così che ogni nota contenga il suo LwtId.", + "anki.sync.step_open": "Fai clic sul pulsante qui sotto. La prima volta Anki chiede se fidarsi di questa pagina: accetta.", + "anki.sync.button": "Recupera le ripetizioni da Anki", + "anki.sync.connecting": "Connessione ad Anki...", + "anki.sync.reading_notes": "Lettura delle note: {done} di {total}", + "anki.sync.reading_reviews": "Lettura delle ripetizioni: {done} di {total} carte", + "anki.sync.sending": "Invio a LWT: {done} di {total} ripetizioni", + "anki.sync.done": "Ripetizioni recuperate da Anki.", + "anki.sync.applied_one": "{count} nuova ripetizione integrata", + "anki.sync.applied_other": "{count} nuove ripetizioni integrate", + "anki.sync.terms_one": "{count} termine ripianificato", + "anki.sync.terms_other": "{count} termini ripianificati", + "anki.sync.duplicates_one": "{count} ripetizione già presente in LWT", + "anki.sync.duplicates_other": "{count} ripetizioni già presenti in LWT", + "anki.sync.rebuilt_one": "{count} termine ha ricevuto nella sua cronologia una ripetizione Anki precedente", + "anki.sync.rebuilt_other": "{count} termini hanno ricevuto nella loro cronologia ripetizioni Anki precedenti", + "anki.sync.skipped_one": "{count} ripetizione saltata (termine ignorato, ben noto o eliminato)", + "anki.sync.skipped_other": "{count} ripetizioni saltate (termini ignorati, ben noti o eliminati)", + "anki.sync.error_unreachable": "Impossibile raggiungere Anki su {url}. Anki è aperto e il componente aggiuntivo AnkiConnect è installato?", + "anki.sync.error_denied": "Anki ha negato l'accesso. Fai di nuovo clic sul pulsante e accetta la richiesta in Anki.", + "anki.sync.error_failed": "Recupero non riuscito: {message}", + "anki.sync.how_html": "Viaggiano solo le ripetizioni, da Anki a LWT: in Anki non cambia nulla. Ogni ripetizione entra nella cronologia del termine nel momento in cui è avvenuta, quindi studiare in entrambe le app tra due recuperi dà la stessa pianificazione che avresti avuto in una sola. Recuperare di nuovo è sicuro: le ripetizioni che LWT ha già vengono saltate.", + "anki.sync.hosted_html": "Se LWT è in esecuzione su un altro server, il browser potrebbe chiedere il permesso di raggiungere Anki su questo computer, oppure bloccarlo; consentire l'accesso alla rete locale per questo sito risolve il problema. Il passaggio tramite file .apkg funziona ovunque.", "titles.terms": "Termini", "titles.term": "Termine", "titles.term_named": "Termine: {term}", @@ -445,5 +471,6 @@ "titles.not_found": "Non trovato", "titles.error": "Errore", "titles.status_changed": "Stato modificato", - "titles.status_set": "Stato impostato" + "titles.status_set": "Stato impostato", + "titles.anki_sync": "Sincronizza le ripetizioni da Anki" } diff --git a/locale/ja/vocabulary.json b/locale/ja/vocabulary.json index 31e4abb40..5748564cc 100644 --- a/locale/ja/vocabulary.json +++ b/locale/ja/vocabulary.json @@ -418,6 +418,27 @@ "anki.apkg.settings_import": "逆方向では、LWT のデッキを Anki にインポートする際に 学習の進捗をインポートする にチェックを入れてください。そうしないと Anki はすべてのカードを最初から始め、このページがエクスポートしたスケジュールを破棄します。", "anki.apkg.deck_notice_title": "Anki で作成したデッキをインポートしますか?", "anki.apkg.deck_notice": "このページは、もともと LWT がエクスポートした用語だけを更新します。Anki や AnkiWeb のデッキには照合できるものがここにないため、何も変わりません。代わりに Anki デッキをインポート を使用してください。新しい用語を作成し、Anki 自身のスケジュールから各単語の習熟度を判定します。", + "anki.sync.link": "Anki から復習を取り込む(AnkiConnect)", + "anki.sync.intro": "Anki で行った復習を、このコンピューター上の Anki から直接 LWT のスケジュールに取り込みます。ファイルのエクスポートやインポートは不要です。", + "anki.sync.step_addon_html": "AnkiConnect アドオンをインストールし、Anki を開いたままにしてください。", + "anki.sync.step_export_html": "カードは LWT から作成したものである必要があります。各ノートが LwtId を持つよう、一度 言語全体を Anki パッケージ(.apkg)にエクスポート でエクスポートしてください。", + "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_other": "{count} 件の新しい復習を統合しました", + "anki.sync.terms_other": "{count} 件の用語のスケジュールを更新しました", + "anki.sync.duplicates_other": "{count} 件の復習は LWT に既にありました", + "anki.sync.rebuilt_other": "{count} 件の用語で、Anki の過去の復習を履歴に組み込みました", + "anki.sync.skipped_other": "{count} 件の復習をスキップしました(無視・よく知っている・削除済みの用語)", + "anki.sync.error_unreachable": "{url} で Anki に接続できませんでした。Anki が開いていて、AnkiConnect アドオンがインストールされているか確認してください。", + "anki.sync.error_denied": "Anki がアクセスを拒否しました。もう一度ボタンをクリックし、Anki の確認画面で許可してください。", + "anki.sync.error_failed": "取り込みに失敗しました: {message}", + "anki.sync.how_html": "復習だけが Anki から LWT へ送られ、Anki 側は何も変更されません。各復習は実際に行われた時点で用語の履歴に加わるため、2 回の取り込みの間に両方のアプリで学習しても、1 つのアプリだけで学習した場合と同じスケジュールになります。何度取り込んでも安全です。LWT に既にある復習はスキップされます。", + "anki.sync.hosted_html": "LWT が別のサーバーで動作している場合、ブラウザーがこのコンピューター上の Anki へのアクセス許可を求めたり、アクセスをブロックしたりすることがあります。このサイトに ローカルネットワークへのアクセス を許可すれば解決します。.apkg ファイルによる往復 はどの環境でも使えます。", "titles.terms": "用語", "titles.term": "用語", "titles.term_named": "用語:{term}", @@ -427,5 +448,6 @@ "titles.not_found": "見つかりません", "titles.error": "エラー", "titles.status_changed": "ステータスを変更しました", - "titles.status_set": "ステータスを設定しました" + "titles.status_set": "ステータスを設定しました", + "titles.anki_sync": "Anki から復習を同期" } diff --git a/locale/pt/vocabulary.json b/locale/pt/vocabulary.json index 17e8f10d2..ab4e40734 100644 --- a/locale/pt/vocabulary.json +++ b/locale/pt/vocabulary.json @@ -435,6 +435,32 @@ "anki.apkg.settings_import": "No sentido inverso, marque Importar qualquer progresso de aprendizagem ao importar um baralho do LWT no Anki, ou o Anki começará cada cartão do zero e descartará o agendamento que esta página exportou.", "anki.apkg.deck_notice_title": "Vai importar um baralho que você criou no Anki?", "anki.apkg.deck_notice": "Esta página só atualiza termos que o LWT exportou originalmente — um baralho vindo do Anki ou do AnkiWeb não tem nada aqui com que corresponder, então nada mudaria. Use Importar um baralho do Anki, que cria termos novos e deduz o quanto você conhece cada palavra a partir do próprio agendamento do Anki.", + "anki.sync.link": "Trazer revisões do Anki (AnkiConnect)", + "anki.sync.intro": "Traga as revisões que você fez no Anki para o agendamento do LWT, direto do Anki neste computador: nenhum arquivo para exportar ou importar.", + "anki.sync.step_addon_html": "Instale o complemento AnkiConnect e mantenha o Anki aberto.", + "anki.sync.step_export_html": "Seus cartões precisam vir do LWT: exporte-os uma vez com Exportar idioma para pacote Anki (.apkg), para que cada nota leve o seu LwtId.", + "anki.sync.step_open": "Clique no botão abaixo. Na primeira vez, o Anki pergunta se deve confiar nesta página: aceite.", + "anki.sync.button": "Trazer revisões do Anki", + "anki.sync.connecting": "Conectando ao Anki...", + "anki.sync.reading_notes": "Lendo notas: {done} de {total}", + "anki.sync.reading_reviews": "Lendo revisões: {done} de {total} cartões", + "anki.sync.sending": "Enviando ao LWT: {done} de {total} revisões", + "anki.sync.done": "Revisões trazidas do Anki.", + "anki.sync.applied_one": "{count} revisão nova incorporada", + "anki.sync.applied_other": "{count} revisões novas incorporadas", + "anki.sync.terms_one": "{count} termo reagendado", + "anki.sync.terms_other": "{count} termos reagendados", + "anki.sync.duplicates_one": "{count} revisão que o LWT já tinha", + "anki.sync.duplicates_other": "{count} revisões que o LWT já tinha", + "anki.sync.rebuilt_one": "{count} termo recebeu em seu histórico uma revisão mais antiga do Anki", + "anki.sync.rebuilt_other": "{count} termos receberam em seu histórico revisões mais antigas do Anki", + "anki.sync.skipped_one": "{count} revisão pulada (termo ignorado, bem conhecido ou excluído)", + "anki.sync.skipped_other": "{count} revisões puladas (termos ignorados, bem conhecidos ou excluídos)", + "anki.sync.error_unreachable": "Não foi possível acessar o Anki em {url}. O Anki está aberto, com o complemento AnkiConnect instalado?", + "anki.sync.error_denied": "O Anki recusou o acesso. Clique no botão novamente e aceite o pedido no Anki.", + "anki.sync.error_failed": "Falha ao trazer as revisões: {message}", + "anki.sync.how_html": "Só as revisões viajam, do Anki para o LWT: nada no Anki é alterado. Cada revisão entra no histórico do termo no momento em que aconteceu, então estudar nos dois apps entre duas sincronizações dá o agendamento que você teria em um só. Trazer de novo é seguro: as revisões que o LWT já tem são ignoradas.", + "anki.sync.hosted_html": "Se o LWT roda em outro servidor, seu navegador pode pedir permissão para acessar o Anki neste computador, ou bloquear o acesso; permitir o acesso à rede local para este site resolve isso. A troca por arquivo .apkg funciona em qualquer lugar.", "titles.terms": "Termos", "titles.term": "Termo", "titles.term_named": "Termo: {term}", @@ -444,5 +470,6 @@ "titles.not_found": "Não encontrado", "titles.error": "Erro", "titles.status_changed": "Status alterado", - "titles.status_set": "Status definido" + "titles.status_set": "Status definido", + "titles.anki_sync": "Sincronizar revisões do Anki" } diff --git a/locale/ru/vocabulary.json b/locale/ru/vocabulary.json index 42240b8e0..a92a7b963 100644 --- a/locale/ru/vocabulary.json +++ b/locale/ru/vocabulary.json @@ -466,6 +466,42 @@ "anki.apkg.settings_import": "В обратную сторону: при импорте колоды LWT в Anki отметьте Импортировать прогресс изучения, иначе Anki начнёт каждую карточку с нуля и отбросит расписание, экспортированное этой страницей.", "anki.apkg.deck_notice_title": "Импортируете колоду, созданную в Anki?", "anki.apkg.deck_notice": "Эта страница обновляет только те слова, которые изначально экспортировал LWT: колоде из Anki или AnkiWeb здесь не с чем сопоставляться, поэтому ничего не изменится. Используйте вместо этого импорт колоды Anki — он создаёт новые слова и определяет, насколько хорошо вы знаете каждое из них, по расписанию самого Anki.", + "anki.sync.link": "Получить повторения из Anki (AnkiConnect)", + "anki.sync.intro": "Перенесите повторения, сделанные в Anki, в расписание LWT прямо из Anki на этом компьютере: без экспорта и импорта файлов.", + "anki.sync.step_addon_html": "Установите дополнение AnkiConnect и не закрывайте Anki.", + "anki.sync.step_export_html": "Ваши карточки должны быть из LWT: один раз экспортируйте их через Экспортировать язык в пакет Anki (.apkg), чтобы каждая заметка содержала свой LwtId.", + "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_one": "Добавлено {count} новое повторение", + "anki.sync.applied_few": "Добавлено {count} новых повторения", + "anki.sync.applied_many": "Добавлено {count} новых повторений", + "anki.sync.applied_other": "Добавлено {count} новых повторений", + "anki.sync.terms_one": "Расписание обновлено для {count} слова", + "anki.sync.terms_few": "Расписание обновлено для {count} слов", + "anki.sync.terms_many": "Расписание обновлено для {count} слов", + "anki.sync.terms_other": "Расписание обновлено для {count} слов", + "anki.sync.duplicates_one": "{count} повторение уже было в LWT", + "anki.sync.duplicates_few": "{count} повторения уже были в LWT", + "anki.sync.duplicates_many": "{count} повторений уже было в LWT", + "anki.sync.duplicates_other": "{count} повторений уже было в LWT", + "anki.sync.rebuilt_one": "В историю {count} слова вставлены более ранние повторения из Anki", + "anki.sync.rebuilt_few": "В историю {count} слов вставлены более ранние повторения из Anki", + "anki.sync.rebuilt_many": "В историю {count} слов вставлены более ранние повторения из Anki", + "anki.sync.rebuilt_other": "В историю {count} слов вставлены более ранние повторения из Anki", + "anki.sync.skipped_one": "Пропущено {count} повторение (слово игнорируется, хорошо известно или удалено)", + "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 на этом компьютере или заблокировать его; разрешите этому сайту доступ к локальной сети, и всё заработает. Обмен файлами .apkg работает везде.", "titles.terms": "Слова", "titles.term": "Слово", "titles.term_named": "Слово: {term}", @@ -475,5 +511,6 @@ "titles.not_found": "Не найдено", "titles.error": "Ошибка", "titles.status_changed": "Статус изменён", - "titles.status_set": "Статус установлен" + "titles.status_set": "Статус установлен", + "titles.anki_sync": "Синхронизация повторений из Anki" } diff --git a/locale/zh/vocabulary.json b/locale/zh/vocabulary.json index caebd8053..40ff662d8 100644 --- a/locale/zh/vocabulary.json +++ b/locale/zh/vocabulary.json @@ -397,6 +397,27 @@ "anki.apkg.settings_import": "反向操作时,将 LWT 牌组导入 Anki 请勾选 导入学习进度,否则 Anki 会让每张卡片从头开始,并丢弃此页面导出的排程。", "anki.apkg.deck_notice_title": "要导入您在 Anki 中创建的牌组吗?", "anki.apkg.deck_notice": "此页面只更新最初由 LWT 导出的词条——来自 Anki 或 AnkiWeb 的牌组在这里没有可匹配的内容,因此不会有任何改变。请改用 导入 Anki 牌组,它会创建新词条,并根据 Anki 自身的排程判断您对每个单词的掌握程度。", + "anki.sync.link": "从 Anki 拉取复习记录(AnkiConnect)", + "anki.sync.intro": "直接从本机上的 Anki 将您在 Anki 中完成的复习导入 LWT 的排程:无需导出或导入文件。", + "anki.sync.step_addon_html": "安装 AnkiConnect 插件,并保持 Anki 处于打开状态。", + "anki.sync.step_export_html": "您的卡片必须来自 LWT:先用 将整个语言导出为 Anki 包(.apkg) 导出一次,使每条笔记都带有自己的 LwtId。", + "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_other": "已合并 {count} 条新复习记录", + "anki.sync.terms_other": "已重新排程 {count} 个词条", + "anki.sync.duplicates_other": "{count} 条复习记录 LWT 中已存在", + "anki.sync.rebuilt_other": "{count} 个词条的历史中补入了较早的 Anki 复习记录", + "anki.sync.skipped_other": "已跳过 {count} 条复习记录(词条已忽略、熟知或已删除)", + "anki.sync.error_unreachable": "无法通过 {url} 连接 Anki。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 的权限,或直接阻止访问;为此网站允许 本地网络访问 即可解决。.apkg 文件往返 在任何环境下都可用。", "flash.term_saved": "词条已保存", "flash.term_updated": "词条已更新", "flash.term_updated_short": "已更新", @@ -415,5 +436,6 @@ "titles.not_found": "未找到", "titles.error": "错误", "titles.status_changed": "状态已更改", - "titles.status_set": "状态已设置" + "titles.status_set": "状态已设置", + "titles.anki_sync": "从 Anki 同步复习记录" } diff --git a/src/Modules/Review/Application/UseCases/MergeReviewHistory.php b/src/Modules/Review/Application/UseCases/MergeReviewHistory.php new file mode 100644 index 000000000..f213579cb --- /dev/null +++ b/src/Modules/Review/Application/UseCases/MergeReviewHistory.php @@ -0,0 +1,222 @@ + + * @link https://github.com/HugoFara/lwt + */ + +declare(strict_types=1); + +namespace Lwt\Modules\Review\Application\UseCases; + +use DateInterval; +use Lwt\Modules\Review\Domain\Scheduling\Fsrs6Scheduler; +use Lwt\Modules\Review\Domain\Scheduling\HistoricReview; +use Lwt\Modules\Review\Domain\Scheduling\MemoryState; +use Lwt\Modules\Review\Domain\Scheduling\Rating; +use Lwt\Modules\Review\Domain\Scheduling\ReviewLogEntry; +use Lwt\Modules\Review\Domain\Scheduling\SchedulerInterface; +use Lwt\Modules\Review\Domain\Scheduling\SchedulingState; +use Lwt\Modules\Review\Domain\TermScheduleRepositoryInterface; +use Lwt\Modules\Review\Infrastructure\MySqlTermScheduleRepository; + +/** + * Fold reviews done in another app into a term's history, in time order. + * + * A term's schedule is its review history folded through the scheduler. When + * reviews arrive from Anki they join that history: those LWT already holds + * (a review it exported and is now reading back) are recognised by second and + * grade and dropped, and the rest are applied in the order they happened. + * + * If every new review is later than LWT's latest, they are simply appended. + * If one predates it -- studied on the phone in the morning, in LWT at noon, + * synced in the evening -- the history is recomputed from that review + * onwards, LWT's later reviews included, so the result is what it would have + * been had both been done in one place (#264). + * + * The state the recomputation starts from is rebuilt exactly from the last + * review before it: the log stores stability, difficulty and interval after + * each review, and reps, lapses and state follow from the grades. The one + * thing not stored is the seed a term had before its first graded review, so a + * review predating a seeded term's whole history starts from a fresh seed of + * its current status instead (see {@see TermScheduleRepositoryInterface::seedFor()}). + */ +final class MergeReviewHistory +{ + private SchedulerInterface $scheduler; + private TermScheduleRepositoryInterface $repository; + + public function __construct( + ?SchedulerInterface $scheduler = null, + ?TermScheduleRepositoryInterface $repository = null, + ) { + $this->scheduler = $scheduler ?? new Fsrs6Scheduler(); + $this->repository = $repository ?? new MySqlTermScheduleRepository(); + } + + /** + * Merge reviews into one term's history. + * + * The caller decides whether the term may be scheduled at all (ignored and + * well-known terms are not in the queue); this only orders and folds. + * + * @param int $wordId Term the reviews belong to + * @param list $incoming Reviews from elsewhere, any order + * + * @return array{applied: int, duplicates: int, rebuilt: bool} New reviews + * recorded, reviews already known, and whether later history had + * to be recomputed + */ + public function execute(int $wordId, array $incoming): array + { + $history = $this->repository->historyFor([$wordId])[$wordId] ?? []; + + [$new, $duplicates] = $this->unseen($history, $incoming); + if ($new === []) { + return ['applied' => 0, 'duplicates' => $duplicates, 'rebuilt' => false]; + } + + $from = $new[0]->reviewedAt; + $earlier = []; + $later = []; + foreach ($history as $entry) { + if ($entry->reviewedAt < $from) { + $earlier[] = $entry; + } else { + $later[] = $entry; + } + } + + // Appending after everything known: the stored state is the truest + // start, since a due date set by hand moves it without a log row. + $start = $later === [] && $history !== [] + ? ($this->repository->find($wordId) ?? self::stateAfter($history)) + : $this->startingState($wordId, $history, $earlier); + + // LWT's own later reviews are replayed alongside the new ones. Listed + // first, so a stable sort keeps them ahead of an Anki review in the + // same second. + $events = []; + foreach ($later as $entry) { + $events[] = new HistoricReview($entry->reviewedAt, $entry->grade); + } + $events = array_merge($events, $new); + usort( + $events, + static fn(HistoricReview $a, HistoricReview $b): int => $a->reviewedAt <=> $b->reviewedAt + ); + + $reviews = []; + $state = $start; + foreach ($events as $event) { + $result = $this->scheduler->review($state, $event->rating, $event->reviewedAt); + $reviews[] = [ + 'result' => $result, + 'rating' => $event->rating, + 'stateBefore' => $state?->state->value ?? SchedulingState::New->value, + ]; + $state = $result->state; + } + + if (!$this->repository->replaceHistoryFrom($wordId, $from, $reviews)) { + return ['applied' => 0, 'duplicates' => $duplicates, 'rebuilt' => false]; + } + + return ['applied' => count($new), 'duplicates' => $duplicates, 'rebuilt' => $later !== []]; + } + + /** + * The incoming reviews LWT does not have yet, oldest first. + * + * @param list $history + * @param list $incoming + * + * @return array{0: list, 1: int} New reviews and how many + * were already known (including repeats within $incoming) + */ + private function unseen(array $history, array $incoming): array + { + $known = []; + foreach ($history as $entry) { + $known[(new HistoricReview($entry->reviewedAt, $entry->grade))->key()] = true; + } + + usort( + $incoming, + static fn(HistoricReview $a, HistoricReview $b): int => $a->reviewedAt <=> $b->reviewedAt + ); + + $new = []; + $duplicates = 0; + foreach ($incoming as $review) { + $key = $review->key(); + if (isset($known[$key])) { + $duplicates++; + continue; + } + $known[$key] = true; + $new[] = $review; + } + + return [$new, $duplicates]; + } + + /** + * The state to fold the merged reviews from. + * + * @param list $history All of the term's reviews + * @param list $earlier Those before the first new review + */ + private function startingState(int $wordId, array $history, array $earlier): ?MemoryState + { + if ($earlier !== []) { + return self::stateAfter($earlier); + } + + if ($history === []) { + // Never graded here: its current state is its seed (or nothing). + return $this->repository->findOrSeed($wordId); + } + + // Every recorded review is being recomputed. Start where the first + // one did: from nothing if the term was new, otherwise from a seed. + return $history[0]->stateBefore === SchedulingState::New + ? null + : $this->repository->seedFor($wordId); + } + + /** + * The memory state right after the last of these reviews. + * + * Mirrors what the scheduler produced: reps count every review, lapses + * count Again, and Again leaves the term relearning. + * + * @param non-empty-list $entries A term's reviews, oldest first + */ + private static function stateAfter(array $entries): MemoryState + { + $last = $entries[count($entries) - 1]; + $lapses = 0; + foreach ($entries as $entry) { + if ($entry->grade === Rating::Again) { + $lapses++; + } + } + + return new MemoryState( + stability: $last->stability, + difficulty: $last->difficulty, + due: $last->reviewedAt->add(new DateInterval('P' . $last->scheduledDays . 'D')), + lastReview: $last->reviewedAt, + reps: count($entries), + lapses: $lapses, + state: $last->grade === Rating::Again ? SchedulingState::Relearning : SchedulingState::Review, + ); + } +} diff --git a/src/Modules/Review/Domain/Scheduling/HistoricReview.php b/src/Modules/Review/Domain/Scheduling/HistoricReview.php new file mode 100644 index 000000000..3292e17c5 --- /dev/null +++ b/src/Modules/Review/Domain/Scheduling/HistoricReview.php @@ -0,0 +1,47 @@ + + * @link https://github.com/HugoFara/lwt + */ + +declare(strict_types=1); + +namespace Lwt\Modules\Review\Domain\Scheduling; + +use DateTimeImmutable; + +/** + * One past answer: when it was given and which grade. + * + * Only what the learner did, not what any scheduler made of it. Anki's own + * interval and memory state are deliberately not carried, because LWT folds + * every review through its own scheduler (#264). + * + * @phpstan-immutable + */ +final class HistoricReview +{ + public function __construct( + public readonly DateTimeImmutable $reviewedAt, + public readonly Rating $rating, + ) { + } + + /** + * Identity used to recognise the same review coming back. + * + * A review LWT exported to Anki returns with the same second and grade, + * and `review_log` stores seconds, so those two are what can be compared. + */ + public function key(): string + { + return $this->reviewedAt->format('U') . ':' . $this->rating->value; + } +} diff --git a/src/Modules/Review/Domain/TermScheduleRepositoryInterface.php b/src/Modules/Review/Domain/TermScheduleRepositoryInterface.php index 7074280e0..7789c8a7d 100644 --- a/src/Modules/Review/Domain/TermScheduleRepositoryInterface.php +++ b/src/Modules/Review/Domain/TermScheduleRepositoryInterface.php @@ -90,4 +90,31 @@ public function findMany(array $wordIds): array; * @return array> Keyed by word ID */ public function historyFor(array $wordIds): array; + + /** + * Rewrite a term's history from a point in time onwards. + * + * Deletes the term's `review_log` rows at or after $from, appends one row + * per result in order, and sets the term's state to the last result's -- + * all or nothing, in one transaction. Used when a review arrives that + * happened before reviews already recorded, so the later ones have to be + * recomputed on top of it (#264). + * + * @param int $wordId Term to rewrite + * @param DateTimeImmutable $from First moment rewritten + * @param list $reviews In time order + * + * @return bool Whether the history was rewritten (false for a term the + * current user does not own, or with no reviews to write) + */ + public function replaceHistoryFrom(int $wordId, DateTimeImmutable $from, array $reviews): bool; + + /** + * A starting state for a term whose earliest known review was seeded. + * + * The seed a term had before its first graded review is not stored, so + * this recomputes one from its current status, anchored at its creation. + * Null for a term that is not owned or not schedulable. + */ + public function seedFor(int $wordId): ?MemoryState; } diff --git a/src/Modules/Review/Infrastructure/MySqlTermScheduleRepository.php b/src/Modules/Review/Infrastructure/MySqlTermScheduleRepository.php index 9b95cc285..d182e26ef 100644 --- a/src/Modules/Review/Infrastructure/MySqlTermScheduleRepository.php +++ b/src/Modules/Review/Infrastructure/MySqlTermScheduleRepository.php @@ -103,8 +103,67 @@ public function saveReview(int $wordId, SchedulingResult $result, Rating $rating return; } - $state = $result->state; + $this->writeState($wordId, $result->state); + $this->writeLog($wordId, $result, $rating, $stateBefore); + } + + /** + * {@inheritdoc} + */ + public function replaceHistoryFrom(int $wordId, DateTimeImmutable $from, array $reviews): bool + { + if ($reviews === [] || !ScheduleSql::hasScheduleTable() || !$this->ownsWord($wordId)) { + return false; + } + + $db = Globals::getDbConnection(); + $db?->begin_transaction(); + try { + Connection::preparedExecute( + 'DELETE FROM review_log WHERE RlWoID = ? AND RlReviewedAt >= ?', + [$wordId, $from->format(self::DATETIME_FORMAT)] + ); + foreach ($reviews as $review) { + $this->writeLog($wordId, $review['result'], $review['rating'], $review['stateBefore']); + } + $this->writeState($wordId, $reviews[count($reviews) - 1]['result']->state); + $db?->commit(); + } catch (\Throwable $e) { + $db?->rollback(); + throw $e; + } + + return true; + } + + /** + * {@inheritdoc} + */ + public function seedFor(int $wordId): ?MemoryState + { + $params = [$wordId]; + $scope = $this->appendUserScope($params); + $row = Connection::preparedFetchOne( + 'SELECT WoStatus, WoCreated FROM words WHERE WoID = ?' . $scope, + $params + ); + + if ($row === null) { + return null; + } + + return LegacyStatusSeed::forStatus( + (int) $row['WoStatus'], + new DateTimeImmutable((string) $row['WoCreated']) + ); + } + + /** + * Upsert a term's current memory state. + */ + private function writeState(int $wordId, MemoryState $state): void + { Connection::preparedExecute( 'INSERT INTO term_schedule (TsWoID, TsStability, TsDifficulty, TsDue, TsLastReview, TsReps, TsLapses, TsState) @@ -128,6 +187,14 @@ public function saveReview(int $wordId, SchedulingResult $result, Rating $rating $state->state->value, ] ); + } + + /** + * Append one review_log row. + */ + private function writeLog(int $wordId, SchedulingResult $result, Rating $rating, int $stateBefore): void + { + $state = $result->state; Connection::preparedExecute( 'INSERT INTO review_log diff --git a/src/Modules/Vocabulary/Application/Services/Anki/AnkiReviewPull.php b/src/Modules/Vocabulary/Application/Services/Anki/AnkiReviewPull.php new file mode 100644 index 000000000..3d60847a6 --- /dev/null +++ b/src/Modules/Vocabulary/Application/Services/Anki/AnkiReviewPull.php @@ -0,0 +1,142 @@ + + * @link https://github.com/HugoFara/lwt + */ + +declare(strict_types=1); + +namespace Lwt\Modules\Vocabulary\Application\Services\Anki; + +use DateTimeImmutable; +use DateTimeZone; +use Lwt\Modules\Review\Application\UseCases\MergeReviewHistory; +use Lwt\Modules\Review\Domain\Scheduling\HistoricReview; +use Lwt\Modules\Review\Domain\Scheduling\Rating; +use Lwt\Modules\Vocabulary\Domain\TermRepositoryInterface; +use Lwt\Modules\Vocabulary\Infrastructure\MySqlTermRepository; + +/** + * The server half of pulling reviews from Anki (#264, step 1). + * + * The browser reads the learner's Anki collection through AnkiConnect, maps + * each card back to its term through the note's LwtId field, and posts the + * reviews here. Each term's reviews are merged into its history by + * {@see MergeReviewHistory}: reviews LWT already holds are recognised and + * dropped, so pulling the same collection twice changes nothing, and reviews + * that interleave with LWT's own are put in their place. + * + * Only terms the current user owns are touched, and only schedulable ones: + * ignored and well-known terms are not in the review queue, and a review in + * Anki is not a reason to put them back. + */ +final class AnkiReviewPull +{ + /** + * Most reviews accepted in one request, to bound the work per call. The + * browser sends a large collection in several batches. + */ + public const MAX_REVIEWS = 5000; + + public function __construct( + private readonly TermRepositoryInterface $terms, + private readonly MergeReviewHistory $merge, + ) { + } + + public static function default(): self + { + return new self(new MySqlTermRepository(), new MergeReviewHistory()); + } + + /** + * Merge a batch of Anki reviews. + * + * @param list $rows Each `{term_id, reviewed_at, ease}`: the LWT + * term id, Anki's revlog id (epoch milliseconds, + * which is when the review happened) and the + * button pressed, 1 Again to 4 Easy + * + * @return array{terms: int, applied: int, duplicates: int, rebuilt: int, skipped: int} + * Terms that changed, reviews new to LWT, reviews it already had, + * terms whose later history was recomputed, and reviews ignored + * (malformed, unknown or unschedulable term, or a grade-less row + * such as Anki's "set due date") + */ + public function apply(array $rows): array + { + $byTerm = []; + $skipped = 0; + $parsed = array_map(self::parse(...), array_slice($rows, 0, self::MAX_REVIEWS)); + foreach ($parsed as $review) { + if ($review === null) { + $skipped++; + continue; + } + $byTerm[$review[0]][] = $review[1]; + } + $skipped += max(0, count($rows) - self::MAX_REVIEWS); + + $summary = ['terms' => 0, 'applied' => 0, 'duplicates' => 0, 'rebuilt' => 0, 'skipped' => $skipped]; + foreach ($byTerm as $termId => $reviews) { + $term = $this->terms->find($termId); + if ($term === null || $term->status()->isSpecial()) { + $summary['skipped'] += count($reviews); + continue; + } + + $result = $this->merge->execute($termId, $reviews); + $summary['applied'] += $result['applied']; + $summary['duplicates'] += $result['duplicates']; + if ($result['applied'] > 0) { + $summary['terms']++; + } + if ($result['rebuilt']) { + $summary['rebuilt']++; + } + } + + return $summary; + } + + /** + * Read one posted review, or null if it is not one we can use. + * + * @return array{0: int, 1: HistoricReview}|null Term id and the review + */ + private static function parse(mixed $row): ?array + { + if (!is_array($row)) { + return null; + } + + $termId = filter_var($row['term_id'] ?? null, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]); + $reviewedAt = filter_var($row['reviewed_at'] ?? null, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]); + $ease = filter_var($row['ease'] ?? null, FILTER_VALIDATE_INT); + if ($termId === false || $reviewedAt === false || $ease === false) { + return null; + } + + // Anki writes ease 0 for rows that are not answers ("set due date", + // "forget"); those carry no grade to fold. + $rating = Rating::tryFrom($ease); + if ($rating === null) { + return null; + } + + // A revlog id is the review time in milliseconds. review_log keeps + // whole seconds in the server's zone, exactly as ApkgReader stores a + // review read from a file, so the two paths agree on every review. + $when = (new DateTimeImmutable('@' . intdiv($reviewedAt, 1000))) + ->setTimezone(new DateTimeZone(date_default_timezone_get())); + + return [$termId, new HistoricReview($when, $rating)]; + } +} diff --git a/src/Modules/Vocabulary/Application/Services/Anki/ScheduleReplay.php b/src/Modules/Vocabulary/Application/Services/Anki/ScheduleReplay.php index dc3cafeef..646c147e4 100644 --- a/src/Modules/Vocabulary/Application/Services/Anki/ScheduleReplay.php +++ b/src/Modules/Vocabulary/Application/Services/Anki/ScheduleReplay.php @@ -5,7 +5,8 @@ namespace Lwt\Modules\Vocabulary\Application\Services\Anki; use DateTimeImmutable; -use Lwt\Modules\Review\Application\UseCases\RecordScheduledReview; +use Lwt\Modules\Review\Application\UseCases\MergeReviewHistory; +use Lwt\Modules\Review\Domain\Scheduling\HistoricReview; use Lwt\Modules\Review\Domain\Scheduling\Rating; use Lwt\Modules\Review\Domain\TermScheduleRepositoryInterface; use Lwt\Modules\Review\Infrastructure\MySqlTermScheduleRepository; @@ -27,29 +28,24 @@ * whether the collection used FSRS or not: `revlog` records what the learner * actually did, which is the durable half of a schedule. * - * **Only reviews later than LWT's own last review are replayed.** An exported - * file carries LWT's history back out with it, so replaying everything would - * apply each review a second time and collapse the term's interval; and a - * review predating LWT's latest one has already been overtaken by it. + * **Reviews join the term's history in time order** ({@see MergeReviewHistory}). + * An exported file carries LWT's history back out with it; those reviews come + * back with the same second and grade and are recognised and dropped, so + * nothing is applied twice. The rest are folded in where they happened: after + * LWT's own reviews they are appended, and before one of them the later + * history is recomputed on top, so studying in both places between two + * imports gives the schedule it would have had in one (#264). * - * That rule is the whole conflict policy, and it 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". A term studied in only one - * of the two places is the degenerate case of the same rule. - * - * Two limits worth stating. Comparison is at second precision, which is what - * `review_log` stores, so an Anki review in the same second as LWT's most - * recent one is dropped — the safe way round. And an Anki review that happened - * *before* LWT's last review but was never seen here is skipped rather than - * interleaved; catching those would mean rebuilding the term's state from both - * histories, which is a bigger change than the one it would fix. + * That is the whole conflict policy, and it needs no sync protocol: reviews + * are facts with timestamps, so the merge is their union in time order. A term + * studied in only one of the two places is the degenerate case. */ final class ScheduleReplay { public function __construct( private readonly TermRepositoryInterface $terms, private readonly TermScheduleRepositoryInterface $schedules, - private readonly RecordScheduledReview $reviews, + private readonly MergeReviewHistory $merge, ) { } @@ -58,7 +54,7 @@ public static function default(): self return new self( new MySqlTermRepository(), new MySqlTermScheduleRepository(), - new RecordScheduledReview(), + new MergeReviewHistory(), ); } @@ -88,7 +84,7 @@ public function apply(array $notes): array } $since = $states[$termId]?->lastReview ?? null; - $applied = $this->replay($termId, $schedule->reviews, $since); + $applied = $this->replay($termId, $schedule->reviews); $moved = $this->applyManualDueDate($termId, $schedule, $since); if ($applied > 0 || $moved) { @@ -185,36 +181,23 @@ private function isSchedulable(int $termId): bool } /** - * Replay one term's newer reviews through the scheduler, oldest first. - * - * Each call reads back the state the previous one wrote, so a run of - * reviews compounds exactly as it would have had the user done them here. + * Merge one term's reviews from the file into its history. * - * @param int $termId Term to reschedule - * @param list $reviews Its history from the file - * @param DateTimeImmutable|null $since LWT's own last review, if any + * @param int $termId Term to reschedule + * @param list $reviews Its history from the file * - * @return int How many reviews were applied + * @return int How many reviews were new here */ - private function replay(int $termId, array $reviews, ?DateTimeImmutable $since): int + private function replay(int $termId, array $reviews): int { - $newer = array_values(array_filter( - $reviews, - static fn(ApkgReview $review) => $since === null || $review->reviewedAt > $since - )); - usort($newer, static fn(ApkgReview $a, ApkgReview $b) => $a->reviewedAt <=> $b->reviewedAt); - - $applied = 0; - foreach ($newer as $review) { + $historic = []; + foreach ($reviews as $review) { $rating = Rating::tryFrom($review->ease); - if ($rating === null) { - continue; - } - if ($this->reviews->execute($termId, $rating, $review->reviewedAt)) { - $applied++; + if ($rating !== null) { + $historic[] = new HistoricReview($review->reviewedAt, $rating); } } - return $applied; + return $this->merge->execute($termId, $historic)['applied']; } } diff --git a/src/Modules/Vocabulary/Http/AnkiApiHandler.php b/src/Modules/Vocabulary/Http/AnkiApiHandler.php new file mode 100644 index 000000000..a594872e7 --- /dev/null +++ b/src/Modules/Vocabulary/Http/AnkiApiHandler.php @@ -0,0 +1,80 @@ + + * @link https://hugofara.github.io/lwt/developer/api + */ + +declare(strict_types=1); + +namespace Lwt\Modules\Vocabulary\Http; + +use Lwt\Api\V1\Response; +use Lwt\Modules\Vocabulary\Application\Services\Anki\AnkiReviewPull; +use Lwt\Shared\Http\ApiRoutableInterface; +use Lwt\Shared\Infrastructure\Http\JsonResponse; + +/** + * Endpoints the browser uses while syncing with Anki over AnkiConnect (#264). + * + * POST /api/v1/anki/reviews: merge reviews read from the learner's Anki + * collection into LWT's schedules. Body: `{"reviews": [{"term_id": 12, + * "reviewed_at": 1767225600000, "ease": 3}, ...]}`, at most + * {@see AnkiReviewPull::MAX_REVIEWS} per request. + */ +class AnkiApiHandler implements ApiRoutableInterface +{ + private AnkiReviewPull $pull; + + public function __construct(?AnkiReviewPull $pull = null) + { + $this->pull = $pull ?? AnkiReviewPull::default(); + } + + /** + * {@inheritdoc} + */ + public function routeGet(array $fragments, array $params): JsonResponse + { + return Response::error('Method Not Allowed', 405); + } + + /** + * {@inheritdoc} + */ + public function routePost(array $fragments, array $params): JsonResponse + { + if (($fragments[1] ?? '') !== 'reviews') { + return Response::error('Expected "reviews"', 404); + } + + $reviews = $params['reviews'] ?? null; + if (!is_array($reviews) || !array_is_list($reviews)) { + return Response::error('"reviews" must be a list', 400); + } + + return Response::success($this->pull->apply($reviews)); + } + + /** + * {@inheritdoc} + */ + public function routePut(array $fragments, array $params): JsonResponse + { + return Response::error('Method Not Allowed', 405); + } + + /** + * {@inheritdoc} + */ + public function routeDelete(array $fragments, array $params): JsonResponse + { + return Response::error('Method Not Allowed', 405); + } +} diff --git a/src/Modules/Vocabulary/Http/ApkgController.php b/src/Modules/Vocabulary/Http/ApkgController.php index 9b5b3d966..8dfba38ee 100644 --- a/src/Modules/Vocabulary/Http/ApkgController.php +++ b/src/Modules/Vocabulary/Http/ApkgController.php @@ -127,6 +127,18 @@ private function handleImportSubmit(): void $this->renderImportForm(null, $result); } + /** + * The page that pulls reviews from Anki over AnkiConnect (#264). + * + * @param array $params Route params (unused). + */ + public function sync(array $params): void + { + PageLayoutHelper::renderPageStart(__('vocabulary.titles.anki_sync'), true); + $this->render('anki_sync', []); + PageLayoutHelper::renderPageEnd(); + } + /** * The upload form, plus the summary once an import has run. * diff --git a/src/Modules/Vocabulary/Views/anki_sync.php b/src/Modules/Vocabulary/Views/anki_sync.php new file mode 100644 index 000000000..a520edf3e --- /dev/null +++ b/src/Modules/Vocabulary/Views/anki_sync.php @@ -0,0 +1,76 @@ + + * @link https://hugofara.github.io/lwt/developer/api + */ + +declare(strict_types=1); + +namespace Lwt\Modules\Vocabulary\Views; + +use Lwt\Modules\Vocabulary\Application\Services\Anki\AnkiReviewPull; +use Lwt\Modules\Vocabulary\Infrastructure\Anki\AnkiSchema; +use Lwt\Shared\UI\Helpers\ConfigIsland; + +ConfigIsland::render('anki-sync-config', [ + 'ankiConnectUrl' => 'http://127.0.0.1:8765', + 'noteType' => AnkiSchema::NOTETYPE_NAME, + 'idField' => AnkiSchema::FIELD_LWT_ID, + 'batchSize' => AnkiReviewPull::MAX_REVIEWS, +]); +?> +
+

+ + around Anki's own names, so it is not escaped. ?> +
    +
  1. +
  2. +
  3. +
+ +
+
+ +
+
+ +

+ +
+

+
+ +
+

+
    +
  • +
  • +
  • +
  • +
  • +
+
+ + and , so it is not escaped. ?> +
+ +
+
+ +
+
diff --git a/src/Modules/Vocabulary/Views/apkg_import.php b/src/Modules/Vocabulary/Views/apkg_import.php index db4dd9d1a..9127d766d 100644 --- a/src/Modules/Vocabulary/Views/apkg_import.php +++ b/src/Modules/Vocabulary/Views/apkg_import.php @@ -101,6 +101,12 @@ around a status name, so it is not escaped. ?>

+

+ + + +

+ get( + '/vocabulary/anki/sync', + 'Lwt\\Modules\\Vocabulary\\Http\\ApkgController@sync', + 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. diff --git a/src/backend/Api/V1/ApiV1.php b/src/backend/Api/V1/ApiV1.php index 9638946ad..3424914e2 100644 --- a/src/backend/Api/V1/ApiV1.php +++ b/src/backend/Api/V1/ApiV1.php @@ -33,6 +33,7 @@ use Lwt\Modules\Tags\Http\TagApiHandler; use Lwt\Modules\Admin\Http\AdminApiHandler; use Lwt\Modules\Admin\Http\UserManagementApiHandler; +use Lwt\Modules\Vocabulary\Http\AnkiApiHandler; use Lwt\Modules\Vocabulary\Http\VocabularyApiRouter; use Lwt\Modules\Vocabulary\Http\WordFamilyApiHandler; use Lwt\Modules\Text\Http\TextApiHandler; @@ -88,6 +89,7 @@ class ApiV1 * @var array> */ private const HANDLER_MAP = [ + 'anki' => AnkiApiHandler::class, 'auth' => UserApiHandler::class, 'languages' => LanguageApiHandler::class, 'review' => ReviewApiHandler::class, diff --git a/src/backend/Api/V1/Endpoints.php b/src/backend/Api/V1/Endpoints.php index c001a3be2..6809700ba 100644 --- a/src/backend/Api/V1/Endpoints.php +++ b/src/backend/Api/V1/Endpoints.php @@ -39,6 +39,7 @@ class Endpoints 'auth/logout' => ['POST'], 'auth/me' => ['GET'], + 'anki/reviews' => ['POST'], 'languages' => ['GET', 'POST', 'PUT', 'DELETE'], 'languages/definitions' => ['GET'], 'languages/with-texts' => ['GET'], diff --git a/src/frontend/js/modules/vocabulary/index.ts b/src/frontend/js/modules/vocabulary/index.ts index 2bd2de119..2e87efd8a 100644 --- a/src/frontend/js/modules/vocabulary/index.ts +++ b/src/frontend/js/modules/vocabulary/index.ts @@ -50,3 +50,4 @@ import './pages/expression_interactable'; import './pages/translation_page'; import './pages/starter_vocab'; import './pages/term_edit_page'; +import './pages/anki_sync'; diff --git a/src/frontend/js/modules/vocabulary/pages/anki_sync.ts b/src/frontend/js/modules/vocabulary/pages/anki_sync.ts new file mode 100644 index 000000000..35f0f4ffa --- /dev/null +++ b/src/frontend/js/modules/vocabulary/pages/anki_sync.ts @@ -0,0 +1,129 @@ +/** + * The "pull reviews from Anki" page (#264, step 1). + * + * Reads LWT's notes and their reviews from the learner's Anki over + * AnkiConnect, then posts the reviews to LWT in batches. The server merges + * them into each term's history and drops any it already has, so pulling the + * same collection twice is harmless. + * + * @license Unlicense + * @since 3.8.0 + */ + +import Alpine from 'alpinejs'; +import { apiPost } from '@shared/api/client'; +import { t, tn } from '@shared/i18n/translator'; +import { readPageConfig } from '@shared/utils/page_config'; +import { AnkiConnect, AnkiConnectError, type PulledReview } from '../services/anki_connect'; + +interface AnkiSyncConfig { + ankiConnectUrl: string; + noteType: string; + idField: string; + batchSize: number; +} + +/** What POST /anki/reviews reports, summed over the batches. */ +export interface PullSummary { + terms: number; + applied: number; + duplicates: number; + rebuilt: number; + skipped: number; +} + +const EMPTY: PullSummary = { terms: 0, applied: 0, duplicates: 0, rebuilt: 0, skipped: 0 }; + +/** + * Post reviews to LWT in batches and add up what it did. + */ +export async function sendReviews( + reviews: PulledReview[], + batchSize: number, + onBatch: (done: number, total: number) => void = () => {} +): Promise { + const total: PullSummary = { ...EMPTY }; + for (let i = 0; i < reviews.length; i += batchSize) { + const response = await apiPost('/anki/reviews', { + reviews: reviews.slice(i, i + batchSize), + }); + if (response.error || !response.data) { + throw new Error(response.error ?? 'Empty response'); + } + for (const key of Object.keys(total) as (keyof PullSummary)[]) { + total[key] += response.data[key] ?? 0; + } + onBatch(Math.min(i + batchSize, reviews.length), reviews.length); + } + return total; +} + +export function ankiSync() { + const config = readPageConfig('anki-sync-config', { + ankiConnectUrl: 'http://127.0.0.1:8765', + noteType: 'LWT Term', + idField: 'LwtId', + batchSize: 5000, + }); + + return { + busy: false, + progress: '', + error: '', + summary: null as PullSummary | null, + + async pull(): Promise { + this.busy = true; + this.error = ''; + this.summary = null; + this.progress = t('vocabulary.anki.sync.connecting'); + + try { + const anki = new AnkiConnect(config.ankiConnectUrl); + if (!(await anki.requestPermission())) { + throw new AnkiConnectError(t('vocabulary.anki.sync.error_denied')); + } + + const reviews = await anki.pullReviews(config.noteType, config.idField, (step, done, total) => { + this.progress = t(`vocabulary.anki.sync.reading_${step}`, { done, total }); + }); + + this.summary = await sendReviews(reviews, config.batchSize, (done, total) => { + this.progress = t('vocabulary.anki.sync.sending', { done, total }); + }); + this.progress = ''; + } catch (err) { + this.progress = ''; + this.error = err instanceof AnkiConnectError && err.unreachable + ? t('vocabulary.anki.sync.error_unreachable', { url: config.ankiConnectUrl }) + : t('vocabulary.anki.sync.error_failed', { message: err instanceof Error ? err.message : String(err) }); + } finally { + this.busy = false; + } + }, + + appliedLabel(): string { + return tn('vocabulary.anki.sync.applied', this.summary?.applied ?? 0); + }, + termsLabel(): string { + return tn('vocabulary.anki.sync.terms', this.summary?.terms ?? 0); + }, + duplicatesLabel(): string { + return tn('vocabulary.anki.sync.duplicates', this.summary?.duplicates ?? 0); + }, + rebuiltLabel(): string { + return tn('vocabulary.anki.sync.rebuilt', this.summary?.rebuilt ?? 0); + }, + skippedLabel(): string { + return tn('vocabulary.anki.sync.skipped', this.summary?.skipped ?? 0); + }, + hasRebuilt(): boolean { + return (this.summary?.rebuilt ?? 0) > 0; + }, + hasSkipped(): boolean { + return (this.summary?.skipped ?? 0) > 0; + }, + }; +} + +Alpine.data('ankiSync', ankiSync); diff --git a/src/frontend/js/modules/vocabulary/services/anki_connect.ts b/src/frontend/js/modules/vocabulary/services/anki_connect.ts new file mode 100644 index 000000000..6dbd841e3 --- /dev/null +++ b/src/frontend/js/modules/vocabulary/services/anki_connect.ts @@ -0,0 +1,151 @@ +/** + * A minimal client for AnkiConnect, and the review pull built on it (#264). + * + * AnkiConnect is an Anki add-on serving a JSON API on the learner's own + * machine (127.0.0.1:8765 by default). A server cannot reach it, so the pull + * runs here, in the browser: read LWT's notes and their reviews from Anki, + * map each card back to its term through the note's LwtId field, and post the + * reviews to LWT, which merges them into each term's history. + * + * Only the actions this needs are wrapped. The request shape is AnkiConnect's + * own: `{action, version, params}` in, `{result, error}` out. + * + * @license Unlicense + * @since 3.8.0 + */ + +/** AnkiConnect API version spoken here. */ +const API_VERSION = 6; + +/** Notes and cards per AnkiConnect request, to keep each response small. */ +const CHUNK = 500; + +/** Thrown when AnkiConnect cannot be reached or answers with an error. */ +export class AnkiConnectError extends Error { + constructor( + message: string, + /** True when nothing answered at all: Anki closed, or the add-on missing. */ + public readonly unreachable = false + ) { + super(message); + this.name = 'AnkiConnectError'; + } +} + +interface AnkiResponse { + result: T; + error: string | null; +} + +/** One `revlog` row as getReviewsOfCards returns it. */ +export interface AnkiRevlogEntry { + /** Review time in epoch milliseconds (the revlog id). */ + id: number; + /** Button pressed: 1 Again .. 4 Easy, 0 for "set due date" / "forget". */ + ease: number; +} + +interface AnkiNoteInfo { + noteId: number; + cards: number[]; + fields: Record; +} + +/** A review on its way to POST /api/v1/anki/reviews. */ +export interface PulledReview { + term_id: number; + reviewed_at: number; + ease: number; +} + +export class AnkiConnect { + constructor(private readonly url: string) {} + + /** + * Call one AnkiConnect action. + * + * @throws AnkiConnectError when nothing answers, or AnkiConnect reports one + */ + async invoke(action: string, params: Record = {}): Promise { + let response: Response; + try { + response = await fetch(this.url, { + method: 'POST', + body: JSON.stringify({ action, version: API_VERSION, params }), + }); + } catch { + throw new AnkiConnectError(`Could not reach AnkiConnect at ${this.url}`, true); + } + + const body = (await response.json()) as AnkiResponse; + if (body.error) { + throw new AnkiConnectError(body.error); + } + return body.result; + } + + /** + * Ask Anki to trust this page's origin. + * + * Anki shows the learner a dialog the first time; after they accept, the + * origin is remembered, so nobody has to edit webCorsOriginList by hand. + * + * @returns Whether access was granted + */ + async requestPermission(): Promise { + const result = await this.invoke<{ permission: string }>('requestPermission'); + return result.permission === 'granted'; + } + + /** + * Every review of every LWT note in the collection, as rows for LWT. + * + * @param noteType Anki note type LWT exports ("LWT Term") + * @param idField Field carrying the LWT term id ("LwtId") + * @param onProgress Called with a short status line as the pull advances + */ + async pullReviews( + noteType: string, + idField: string, + onProgress: (step: 'notes' | 'reviews', done: number, total: number) => void = () => {} + ): Promise { + const noteIds = await this.invoke('findNotes', { + query: `"note:${noteType.replace(/"/g, '\\"')}"`, + }); + + const termByCard = new Map(); + for (let i = 0; i < noteIds.length; i += CHUNK) { + const notes = await this.invoke('notesInfo', { + notes: noteIds.slice(i, i + CHUNK), + }); + for (const note of notes) { + const termId = Number.parseInt(note.fields[idField]?.value ?? '', 10); + if (Number.isInteger(termId) && termId > 0) { + for (const card of note.cards) termByCard.set(card, termId); + } + } + onProgress('notes', Math.min(i + CHUNK, noteIds.length), noteIds.length); + } + + const cards = [...termByCard.keys()]; + const reviews: PulledReview[] = []; + for (let i = 0; i < cards.length; i += CHUNK) { + const byCard = await this.invoke>('getReviewsOfCards', { + cards: cards.slice(i, i + CHUNK).map(String), + }); + for (const [card, entries] of Object.entries(byCard)) { + const termId = termByCard.get(Number(card)); + if (termId === undefined) continue; + for (const entry of entries) { + // Grade-less rows ("set due date", "forget") are not answers. + if (entry.ease >= 1 && entry.ease <= 4) { + reviews.push({ term_id: termId, reviewed_at: entry.id, ease: entry.ease }); + } + } + } + onProgress('reviews', Math.min(i + CHUNK, cards.length), cards.length); + } + + return reviews; + } +} diff --git a/tests/backend/Modules/Review/Application/UseCases/MergeReviewHistoryTest.php b/tests/backend/Modules/Review/Application/UseCases/MergeReviewHistoryTest.php new file mode 100644 index 000000000..a474d0ae7 --- /dev/null +++ b/tests/backend/Modules/Review/Application/UseCases/MergeReviewHistoryTest.php @@ -0,0 +1,175 @@ +repository = new InMemoryTermScheduleRepository(); + $this->merge = new MergeReviewHistory(new Fsrs6Scheduler(), $this->repository); + } + + public function testNothingNewChangesNothing(): void + { + $this->reviewedHere(['2026-02-01 10:00:00' => Rating::Good]); + + $result = $this->merge->execute(7, [$this->review('2026-02-01 10:00:00', Rating::Good)]); + + self::assertSame(['applied' => 0, 'duplicates' => 1, 'rebuilt' => false], $result); + self::assertCount(1, $this->repository->history[7]); + } + + public function testRepeatsWithinOneBatchCountOnce(): void + { + $result = $this->merge->execute(7, [ + $this->review('2026-02-01 10:00:00', Rating::Good), + $this->review('2026-02-01 10:00:00', Rating::Good), + ]); + + self::assertSame(['applied' => 1, 'duplicates' => 1, 'rebuilt' => false], $result); + self::assertCount(1, $this->repository->history[7]); + } + + public function testMillisecondsDoNotHideAReviewWeExported(): void + { + // review_log stores seconds; Anki's revlog id is milliseconds. + $this->reviewedHere(['2026-02-01 10:00:00' => Rating::Good]); + + $result = $this->merge->execute(7, [$this->review('2026-02-01 10:00:00.734', Rating::Good)]); + + self::assertSame(0, $result['applied']); + } + + public function testTwoGradesInTheSameSecondAreBothKept(): void + { + $this->reviewedHere(['2026-02-01 10:00:00' => Rating::Good]); + + $result = $this->merge->execute(7, [$this->review('2026-02-01 10:00:00', Rating::Again)]); + + self::assertSame(1, $result['applied']); + self::assertCount(2, $this->repository->history[7]); + } + + public function testNewerReviewsAreAppendedFromTheStoredState(): void + { + // A due date set by hand moves the stored state with no log row; an + // append must start from it, not from a state rebuilt from the log. + $this->reviewedHere(['2026-02-01 10:00:00' => Rating::Good]); + $this->repository->reschedule(7, new DateTimeImmutable('2026-02-04 00:00:00')); + $stored = $this->repository->states[7]; + + $result = $this->merge->execute(7, [$this->review('2026-02-03 10:00:00', Rating::Good)]); + + self::assertFalse($result['rebuilt']); + $expected = (new Fsrs6Scheduler())->review( + $stored, + Rating::Good, + new DateTimeImmutable('2026-02-03 10:00:00') + ); + self::assertEqualsWithDelta($expected->state->stability, $this->repository->states[7]->stability, 1e-9); + } + + public function testAnOlderReviewRebuildsTheLaterHistory(): void + { + $this->reviewedHere(['2026-02-01 10:00:00' => Rating::Good, '2026-02-10 10:00:00' => Rating::Good]); + $before = $this->repository->history[7][1]->stability; + + $result = $this->merge->execute(7, [$this->review('2026-02-05 10:00:00', Rating::Again)]); + + self::assertSame(['applied' => 1, 'duplicates' => 0, 'rebuilt' => true], $result); + self::assertSame( + [Rating::Good, Rating::Again, Rating::Good], + array_map(static fn(ReviewLogEntry $e) => $e->grade, $this->repository->history[7]) + ); + // A lapse before it, so LWT's own second review now starts lower. + self::assertLessThan($before, $this->repository->history[7][2]->stability); + self::assertSame(SchedulingState::Relearning, $this->repository->history[7][2]->stateBefore); + } + + public function testAReviewBeforeAllHistoryOfANewTermStartsFromNothing(): void + { + // First recorded review was from New: the fold starts from no state. + $this->reviewedHere(['2026-02-10 10:00:00' => Rating::Good]); + + $this->merge->execute(7, [$this->review('2026-02-01 10:00:00', Rating::Easy)]); + + $history = $this->repository->history[7]; + self::assertSame(SchedulingState::New, $history[0]->stateBefore); + self::assertSame(Rating::Easy, $history[0]->grade); + $reference = (new Fsrs6Scheduler())->review(null, Rating::Easy, new DateTimeImmutable('2026-02-01 10:00:00')); + self::assertEqualsWithDelta($reference->state->stability, $history[0]->stability, 1e-9); + } + + public function testAReviewBeforeAllHistoryOfASeededTermStartsFromAFreshSeed(): void + { + $seed = LegacyStatusSeed::forStatus(3, new DateTimeImmutable('2026-01-01 00:00:00')); + self::assertNotNull($seed); + $this->repository->states[7] = $seed; + $this->reviewedHere(['2026-02-10 10:00:00' => Rating::Good]); + $this->repository->seeds[7] = $seed; + + $this->merge->execute(7, [$this->review('2026-02-01 10:00:00', Rating::Good)]); + + $first = $this->repository->history[7][0]; + self::assertSame(SchedulingState::Review, $first->stateBefore); + $reference = (new Fsrs6Scheduler())->review($seed, Rating::Good, new DateTimeImmutable('2026-02-01 10:00:00')); + self::assertEqualsWithDelta($reference->state->stability, $first->stability, 1e-9); + } + + public function testATermTheRepositoryRefusesIsReportedAsUnchanged(): void + { + $repository = new class extends InMemoryTermScheduleRepository { + public function replaceHistoryFrom(int $wordId, DateTimeImmutable $from, array $reviews): bool + { + return false; + } + }; + $merge = new MergeReviewHistory(new Fsrs6Scheduler(), $repository); + + $result = $merge->execute(7, [$this->review('2026-02-01 10:00:00', Rating::Good)]); + + self::assertSame(0, $result['applied']); + } + + /** + * @param array $reviews Grade by review time + */ + private function reviewedHere(array $reviews): void + { + $record = new RecordScheduledReview(new Fsrs6Scheduler(), $this->repository); + foreach ($reviews as $at => $rating) { + $record->execute(7, $rating, new DateTimeImmutable($at)); + } + } + + private function review(string $at, Rating $rating): HistoricReview + { + return new HistoricReview(new DateTimeImmutable($at), $rating); + } +} diff --git a/tests/backend/Modules/Review/Infrastructure/TermScheduleRepositoryIntegrationTest.php b/tests/backend/Modules/Review/Infrastructure/TermScheduleRepositoryIntegrationTest.php index 6671bb961..031f70206 100644 --- a/tests/backend/Modules/Review/Infrastructure/TermScheduleRepositoryIntegrationTest.php +++ b/tests/backend/Modules/Review/Infrastructure/TermScheduleRepositoryIntegrationTest.php @@ -5,8 +5,10 @@ namespace Lwt\Tests\Modules\Review\Infrastructure; use DateTimeImmutable; +use Lwt\Modules\Review\Application\UseCases\MergeReviewHistory; use Lwt\Modules\Review\Application\UseCases\RecordScheduledReview; use Lwt\Modules\Review\Domain\Scheduling\Fsrs6Scheduler; +use Lwt\Modules\Review\Domain\Scheduling\HistoricReview; use Lwt\Modules\Review\Domain\Scheduling\Rating; use Lwt\Modules\Review\Infrastructure\MySqlTermScheduleRepository; use Lwt\Shared\Infrastructure\Bootstrap\EnvLoader; @@ -245,4 +247,50 @@ public function testStateForAMissingTermIsNull(): void $this->assertNull($repo->find(999999999)); $this->assertNull($repo->findOrSeed(999999999)); } + + public function testAnOlderReviewIsInterleavedInTheStoredHistory(): void + { + // #264: an Anki review older than LWT's latest is merged in its place, + // and the log rows after it are recomputed in one go. + $repo = new MySqlTermScheduleRepository(); + $record = new RecordScheduledReview(new Fsrs6Scheduler(), $repo); + $wordId = $this->createTerm('interleaveme', 3, '2026-01-01 12:00:00'); + $record->execute($wordId, Rating::Good, new DateTimeImmutable('2026-02-01 09:00:00')); + $record->execute($wordId, Rating::Good, new DateTimeImmutable('2026-02-10 09:00:00')); + + $result = (new MergeReviewHistory(new Fsrs6Scheduler(), $repo))->execute($wordId, [ + new HistoricReview(new DateTimeImmutable('2026-02-05 09:00:00'), Rating::Again), + new HistoricReview(new DateTimeImmutable('2026-02-10 09:00:00'), Rating::Good), + ]); + + $this->assertSame(['applied' => 1, 'duplicates' => 1, 'rebuilt' => true], $result); + $rows = Connection::preparedFetchAll( + 'SELECT RlGrade, RlReviewedAt FROM review_log WHERE RlWoID = ? ORDER BY RlReviewedAt, RlID', + [$wordId] + ); + $this->assertSame( + [[3, '2026-02-01 09:00:00'], [1, '2026-02-05 09:00:00'], [3, '2026-02-10 09:00:00']], + array_map(static fn(array $r) => [(int) $r['RlGrade'], (string) $r['RlReviewedAt']], $rows) + ); + $stored = $repo->find($wordId); + $this->assertNotNull($stored); + $this->assertSame(3, $stored->reps); + $this->assertSame(1, $stored->lapses); + } + + public function testSeedForAnchorsTheStatusAtCreation(): void + { + $repo = new MySqlTermScheduleRepository(); + $wordId = $this->createTerm('seedforme', 4, '2026-03-01 12:00:00'); + + $seed = $repo->seedFor($wordId); + + $this->assertNotNull($seed); + $this->assertEqualsWithDelta(27.0, $seed->stability, 1e-9); + $created = (string) Connection::preparedFetchValue( + 'SELECT WoCreated AS value FROM words WHERE WoID = ?', + [$wordId] + ); + $this->assertSame(substr($created, 0, 10), $seed->lastReview?->format('Y-m-d')); + } } diff --git a/tests/backend/Modules/Vocabulary/Application/Services/Anki/AnkiReviewPullTest.php b/tests/backend/Modules/Vocabulary/Application/Services/Anki/AnkiReviewPullTest.php new file mode 100644 index 000000000..173edd5ff --- /dev/null +++ b/tests/backend/Modules/Vocabulary/Application/Services/Anki/AnkiReviewPullTest.php @@ -0,0 +1,164 @@ +schedules = new InMemoryTermScheduleRepository(); + } + + public function testReviewsAreMergedPerTerm(): void + { + $pull = $this->pullFor([7 => TermStatus::LEARNING_3, 8 => TermStatus::LEARNING_2]); + + $result = $pull->apply([ + $this->row(7, '2026-02-01 10:00:00', 3), + $this->row(8, '2026-02-02 10:00:00', 1), + $this->row(7, '2026-02-05 10:00:00', 4), + ]); + + self::assertSame(['terms' => 2, 'applied' => 3, 'duplicates' => 0, 'rebuilt' => 0, 'skipped' => 0], $result); + self::assertCount(2, $this->schedules->history[7]); + self::assertSame(Rating::Again, $this->schedules->history[8][0]->grade); + } + + public function testPullingTheSameCollectionTwiceChangesNothing(): void + { + $pull = $this->pullFor([7 => TermStatus::LEARNING_3]); + $rows = [$this->row(7, '2026-02-01 10:00:00', 3), $this->row(7, '2026-02-05 10:00:00', 3)]; + + $pull->apply($rows); + $again = $pull->apply($rows); + + self::assertSame(['terms' => 0, 'applied' => 0, 'duplicates' => 2, 'rebuilt' => 0, 'skipped' => 0], $again); + self::assertCount(2, $this->schedules->history[7]); + } + + public function testTheRevlogIdIsTheReviewTime(): void + { + $pull = $this->pullFor([7 => TermStatus::LEARNING_3]); + + $pull->apply([['term_id' => 7, 'reviewed_at' => 1770000000123, 'ease' => 3]]); + + self::assertSame( + 1770000000, + (int) $this->schedules->history[7][0]->reviewedAt->format('U') + ); + } + + public function testUnknownIgnoredAndWellKnownTermsAreSkipped(): void + { + $pull = $this->pullFor([1 => TermStatus::IGNORED, 2 => TermStatus::WELL_KNOWN]); + + $result = $pull->apply([ + $this->row(1, '2026-02-01 10:00:00', 3), + $this->row(2, '2026-02-01 10:00:00', 3), + $this->row(99, '2026-02-01 10:00:00', 3), + ]); + + self::assertSame(3, $result['skipped']); + self::assertSame(0, $result['applied']); + self::assertSame([], $this->schedules->history); + } + + public function testRowsThatAreNotAnswersAreSkipped(): void + { + // Anki's "set due date" and "forget" rows carry ease 0. + $pull = $this->pullFor([7 => TermStatus::LEARNING_3]); + + $result = $pull->apply([ + $this->row(7, '2026-02-01 10:00:00', 0), + ['term_id' => 'seven', 'reviewed_at' => 1770000000000, 'ease' => 3], + ['term_id' => 7, 'ease' => 3], + 'not a row', + ]); + + self::assertSame(4, $result['skipped']); + self::assertSame(0, $result['applied']); + } + + public function testABatchLargerThanTheLimitIsCutAndCounted(): void + { + $pull = $this->pullFor([7 => TermStatus::LEARNING_3]); + $rows = []; + $start = (new DateTimeImmutable('2026-01-01 00:00:00'))->getTimestamp(); + for ($i = 0; $i < AnkiReviewPull::MAX_REVIEWS + 3; $i++) { + $rows[] = ['term_id' => 7, 'reviewed_at' => ($start + $i * 86400) * 1000, 'ease' => 3]; + } + + $result = $pull->apply($rows); + + self::assertSame(AnkiReviewPull::MAX_REVIEWS, $result['applied']); + self::assertSame(3, $result['skipped']); + } + + /** + * @param array $statusByTermId + */ + private function pullFor(array $statusByTermId): AnkiReviewPull + { + $terms = $this->createMock(TermRepositoryInterface::class); + $terms->method('find')->willReturnCallback( + static function (int $id) use ($statusByTermId): ?Term { + $status = $statusByTermId[$id] ?? null; + if ($status === null) { + return null; + } + $now = new DateTimeImmutable(); + + return Term::reconstitute( + id: $id, + languageId: 1, + text: 'hola', + textLowercase: 'hola', + lemma: null, + lemmaLc: null, + status: $status, + translation: 'hello', + sentence: '', + notes: '', + romanization: '', + wordCount: 1, + createdAt: $now, + statusChangedAt: $now, + ); + } + ); + + return new AnkiReviewPull($terms, new MergeReviewHistory(new Fsrs6Scheduler(), $this->schedules)); + } + + /** + * @return array{term_id: int, reviewed_at: int, ease: int} + */ + private function row(int $termId, string $at, int $ease): array + { + return [ + 'term_id' => $termId, + 'reviewed_at' => (new DateTimeImmutable($at))->getTimestamp() * 1000, + 'ease' => $ease, + ]; + } +} diff --git a/tests/backend/Modules/Vocabulary/Application/Services/Anki/InMemoryTermScheduleRepository.php b/tests/backend/Modules/Vocabulary/Application/Services/Anki/InMemoryTermScheduleRepository.php index 24b136b96..6c39efa51 100644 --- a/tests/backend/Modules/Vocabulary/Application/Services/Anki/InMemoryTermScheduleRepository.php +++ b/tests/backend/Modules/Vocabulary/Application/Services/Anki/InMemoryTermScheduleRepository.php @@ -9,12 +9,13 @@ use Lwt\Modules\Review\Domain\Scheduling\Rating; use Lwt\Modules\Review\Domain\Scheduling\ReviewLogEntry; use Lwt\Modules\Review\Domain\Scheduling\SchedulingResult; +use Lwt\Modules\Review\Domain\Scheduling\SchedulingState; use Lwt\Modules\Review\Domain\TermScheduleRepositoryInterface; /** * Schedule persistence in memory, recording what was saved. */ -final class InMemoryTermScheduleRepository implements TermScheduleRepositoryInterface +class InMemoryTermScheduleRepository implements TermScheduleRepositoryInterface { /** @var array */ public array $states = []; @@ -25,6 +26,12 @@ final class InMemoryTermScheduleRepository implements TermScheduleRepositoryInte /** @var list */ public array $rescheduled = []; + /** @var array> Review history, oldest first */ + public array $history = []; + + /** @var array What seedFor() answers, per term */ + public array $seeds = []; + public function find(int $wordId): ?MemoryState { return $this->states[$wordId] ?? null; @@ -38,6 +45,7 @@ public function findOrSeed(int $wordId): ?MemoryState public function saveReview(int $wordId, SchedulingResult $result, Rating $rating, int $stateBefore): void { $this->states[$wordId] = $result->state; + $this->history[$wordId][] = self::logEntry($wordId, $result, $rating, $stateBefore); $this->saved[] = [ 'wordId' => $wordId, 'result' => $result, @@ -96,6 +104,56 @@ public function findMany(array $wordIds): array */ public function historyFor(array $wordIds): array { - return []; + $out = []; + foreach ($wordIds as $wordId) { + if (isset($this->history[$wordId])) { + $out[$wordId] = $this->history[$wordId]; + } + } + + return $out; + } + + public function replaceHistoryFrom(int $wordId, DateTimeImmutable $from, array $reviews): bool + { + if ($reviews === []) { + return false; + } + + $kept = array_values(array_filter( + $this->history[$wordId] ?? [], + static fn(ReviewLogEntry $entry): bool => $entry->reviewedAt < $from + )); + foreach ($reviews as $review) { + $kept[] = self::logEntry($wordId, $review['result'], $review['rating'], $review['stateBefore']); + $this->saved[] = ['wordId' => $wordId] + $review; + } + $this->history[$wordId] = $kept; + $this->states[$wordId] = $reviews[count($reviews) - 1]['result']->state; + + return true; + } + + public function seedFor(int $wordId): ?MemoryState + { + return $this->seeds[$wordId] ?? null; + } + + private static function logEntry( + int $wordId, + SchedulingResult $result, + Rating $rating, + int $stateBefore + ): ReviewLogEntry { + return new ReviewLogEntry( + wordId: $wordId, + grade: $rating, + stateBefore: SchedulingState::from($stateBefore), + stability: $result->state->stability, + difficulty: $result->state->difficulty, + elapsedDays: $result->elapsedDays, + scheduledDays: $result->intervalDays, + reviewedAt: $result->state->lastReview ?? new DateTimeImmutable(), + ); } } diff --git a/tests/backend/Modules/Vocabulary/Application/Services/Anki/ScheduleReplayTest.php b/tests/backend/Modules/Vocabulary/Application/Services/Anki/ScheduleReplayTest.php index 498d92a41..4f8e28943 100644 --- a/tests/backend/Modules/Vocabulary/Application/Services/Anki/ScheduleReplayTest.php +++ b/tests/backend/Modules/Vocabulary/Application/Services/Anki/ScheduleReplayTest.php @@ -5,6 +5,7 @@ namespace Tests\Backend\Modules\Vocabulary\Application\Services\Anki; use DateTimeImmutable; +use Lwt\Modules\Review\Application\UseCases\MergeReviewHistory; use Lwt\Modules\Review\Application\UseCases\RecordScheduledReview; use Lwt\Modules\Review\Domain\Scheduling\Fsrs6Scheduler; use Lwt\Modules\Review\Domain\Scheduling\MemoryState; @@ -96,7 +97,7 @@ public function testReviewsAlreadyKnownHereAreNotAppliedAgain(): void // An exported file carries LWT's own history back out with it. Without // this rule a re-import would replay every review a second time and // collapse the term's interval. - $this->schedules->states[7] = $this->stateLastReviewedOn('2026-02-06 10:00:00'); + $this->reviewedHere(7, ['2026-02-01 10:00:00' => Rating::Good, '2026-02-06 10:00:00' => Rating::Easy]); $replay = $this->replayFor([7 => TermStatus::LEARNING_3]); $result = $replay->apply([$this->note(7, [ @@ -110,7 +111,7 @@ public function testReviewsAlreadyKnownHereAreNotAppliedAgain(): void public function testOnlyTheReviewsNewerThanOursAreApplied(): void { - $this->schedules->states[7] = $this->stateLastReviewedOn('2026-02-06 10:00:00'); + $this->reviewedHere(7, ['2026-02-01 10:00:00' => Rating::Good, '2026-02-06 10:00:00' => Rating::Easy]); $replay = $this->replayFor([7 => TermStatus::LEARNING_3]); $result = $replay->apply([$this->note(7, [ @@ -123,6 +124,55 @@ public function testOnlyTheReviewsNewerThanOursAreApplied(): void self::assertSame(Rating::Hard, $this->schedules->saved[0]['rating']); } + public function testAnAnkiReviewOlderThanOursIsInterleavedNotSkipped(): void + { + // Studied in Anki on the 3rd, in LWT on the 6th, imported after both. + // The Anki answer happened first, so the history is recomputed with it + // in its place rather than dropped (#264). + $this->reviewedHere(7, ['2026-02-01 10:00:00' => Rating::Good, '2026-02-06 10:00:00' => Rating::Good]); + $replay = $this->replayFor([7 => TermStatus::LEARNING_3]); + + $result = $replay->apply([$this->note(7, [$this->review('2026-02-03 10:00:00', Rating::Again)])]); + + self::assertSame(['terms' => 1, 'reviews' => 1, 'dueDatesMoved' => 0], $result); + $history = $this->schedules->history[7]; + self::assertSame( + ['2026-02-01 10:00:00', '2026-02-03 10:00:00', '2026-02-06 10:00:00'], + array_map(static fn(ReviewLogEntry $e) => $e->reviewedAt->format('Y-m-d H:i:s'), $history) + ); + self::assertSame([Rating::Good, Rating::Again, Rating::Good], array_map( + static fn(ReviewLogEntry $e) => $e->grade, + $history + )); + self::assertSame(3, $this->schedules->states[7]->reps); + self::assertSame(1, $this->schedules->states[7]->lapses); + } + + public function testInterleavingGivesTheScheduleOfOneHistory(): void + { + // The merged result must equal doing all three reviews in one app. + $this->reviewedHere(7, ['2026-02-01 10:00:00' => Rating::Good, '2026-02-06 10:00:00' => Rating::Good]); + $this->replayFor([7 => TermStatus::LEARNING_3]) + ->apply([$this->note(7, [$this->review('2026-02-03 10:00:00', Rating::Hard)])]); + + $reference = new InMemoryTermScheduleRepository(); + $record = new RecordScheduledReview(new Fsrs6Scheduler(), $reference); + $oneHistory = [ + '2026-02-01 10:00:00' => Rating::Good, + '2026-02-03 10:00:00' => Rating::Hard, + '2026-02-06 10:00:00' => Rating::Good, + ]; + foreach ($oneHistory as $at => $rating) { + $record->execute(7, $rating, new DateTimeImmutable($at)); + } + + $merged = $this->schedules->states[7]; + $expected = $reference->states[7]; + self::assertEqualsWithDelta($expected->stability, $merged->stability, 1e-9); + self::assertEqualsWithDelta($expected->difficulty, $merged->difficulty, 1e-9); + self::assertEquals($expected->due, $merged->due); + } + public function testReviewsOutOfOrderInTheFileAreAppliedInTimeOrder(): void { $replay = $this->replayFor([7 => TermStatus::LEARNING_3]); @@ -286,7 +336,7 @@ function (int $id) use ($statusByTermId): ?Term { return new ScheduleReplay( $terms, $this->schedules, - new RecordScheduledReview(new Fsrs6Scheduler(), $this->schedules), + new MergeReviewHistory(new Fsrs6Scheduler(), $this->schedules), ); } @@ -360,6 +410,20 @@ private function review(string $at, Rating $rating): ApkgReview ); } + /** + * Record reviews done in LWT itself, through the real scheduler. + * + * @param array $reviews Grade by review time + */ + private function reviewedHere(int $termId, array $reviews): void + { + $record = new RecordScheduledReview(new Fsrs6Scheduler(), $this->schedules); + foreach ($reviews as $at => $rating) { + $record->execute($termId, $rating, new DateTimeImmutable($at)); + } + $this->schedules->saved = []; + } + private function stateLastReviewedOn(string $at): MemoryState { $lastReview = new DateTimeImmutable($at); diff --git a/tests/backend/Modules/Vocabulary/Http/AnkiApiHandlerTest.php b/tests/backend/Modules/Vocabulary/Http/AnkiApiHandlerTest.php new file mode 100644 index 000000000..188564a55 --- /dev/null +++ b/tests/backend/Modules/Vocabulary/Http/AnkiApiHandlerTest.php @@ -0,0 +1,66 @@ +handler = new AnkiApiHandler(new AnkiReviewPull( + $this->createMock(TermRepositoryInterface::class), + new MergeReviewHistory(new Fsrs6Scheduler(), new InMemoryTermScheduleRepository()) + )); + } + + public function testReviewsMustBeAList(): void + { + $response = $this->handler->routePost(['anki', 'reviews'], ['reviews' => ['a' => 1]]); + + self::assertSame(400, $response->getStatusCode()); + } + + public function testAMissingListIsRejected(): void + { + self::assertSame(400, $this->handler->routePost(['anki', 'reviews'], [])->getStatusCode()); + } + + public function testAnUnknownSubrouteIsNotFound(): void + { + self::assertSame(404, $this->handler->routePost(['anki', 'notes'], ['reviews' => []])->getStatusCode()); + } + + public function testAnEmptyListIsASummaryOfNothing(): void + { + $response = $this->handler->routePost(['anki', 'reviews'], ['reviews' => []]); + + self::assertSame(200, $response->getStatusCode()); + self::assertSame( + ['terms' => 0, 'applied' => 0, 'duplicates' => 0, 'rebuilt' => 0, 'skipped' => 0], + $response->getData() + ); + } + + public function testOnlyPostIsAllowed(): void + { + self::assertSame(405, $this->handler->routeGet(['anki', 'reviews'], [])->getStatusCode()); + self::assertSame(405, $this->handler->routePut(['anki', 'reviews'], [])->getStatusCode()); + self::assertSame(405, $this->handler->routeDelete(['anki', 'reviews'], [])->getStatusCode()); + } +} diff --git a/tests/frontend/vocabulary/anki_sync.test.ts b/tests/frontend/vocabulary/anki_sync.test.ts new file mode 100644 index 000000000..9524c22f8 --- /dev/null +++ b/tests/frontend/vocabulary/anki_sync.test.ts @@ -0,0 +1,121 @@ +/** + * Pulling reviews from Anki over AnkiConnect (#264, step 1): the AnkiConnect + * client and the batching that sends the reviews to LWT. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +const apiPost = vi.fn(); +vi.mock('@shared/api/client', () => ({ apiPost: (...args: unknown[]) => apiPost(...args) })); +vi.mock('alpinejs', () => ({ default: { data: vi.fn() } })); + +import { AnkiConnect, AnkiConnectError } from '../../../src/frontend/js/modules/vocabulary/services/anki_connect'; +import { sendReviews } from '../../../src/frontend/js/modules/vocabulary/pages/anki_sync'; + +const originalFetch = global.fetch; + +/** A fake AnkiConnect answering per action. */ +function ankiAnswering(handlers: Record) => unknown>) { + const calls: { action: string; params: Record }[] = []; + global.fetch = vi.fn(async (_url: unknown, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)) as { action: string; version: number; params: Record }; + expect(body.version).toBe(6); + calls.push({ action: body.action, params: body.params }); + const handler = handlers[body.action]; + const payload = handler + ? { result: handler(body.params), error: null } + : { result: null, error: `unsupported action ${body.action}` }; + return { json: async () => payload } as Response; + }) as typeof fetch; + return calls; +} + +describe('AnkiConnect client', () => { + afterEach(() => { + global.fetch = originalFetch; + }); + + it('maps each card to its term through LwtId and keeps only real answers', async () => { + const calls = ankiAnswering({ + findNotes: () => [100, 101, 102], + notesInfo: () => [ + { noteId: 100, cards: [1000], fields: { LwtId: { value: '7', order: 4 } } }, + { noteId: 101, cards: [1001], fields: { LwtId: { value: '8', order: 4 } } }, + // A note without an id cannot be matched to a term. + { noteId: 102, cards: [1002], fields: { LwtId: { value: '', order: 4 } } }, + ], + getReviewsOfCards: () => ({ + 1000: [{ id: 1770000000000, ease: 3 }, { id: 1770086400000, ease: 0 }], + 1001: [{ id: 1770000500000, ease: 1 }], + }), + }); + + const reviews = await new AnkiConnect('http://127.0.0.1:8765').pullReviews('LWT Term', 'LwtId'); + + expect(reviews).toEqual([ + { term_id: 7, reviewed_at: 1770000000000, ease: 3 }, + { term_id: 8, reviewed_at: 1770000500000, ease: 1 }, + ]); + expect(calls[0]).toEqual({ action: 'findNotes', params: { query: '"note:LWT Term"' } }); + // Card ids go to getReviewsOfCards as strings, as AnkiConnect expects. + expect(calls.find((c) => c.action === 'getReviewsOfCards')?.params).toEqual({ cards: ['1000', '1001'] }); + }); + + it('reads a large collection in chunks', async () => { + const noteIds = Array.from({ length: 1200 }, (_, i) => i + 1); + const calls = ankiAnswering({ + findNotes: () => noteIds, + notesInfo: (params) => (params.notes as number[]).map((id) => ({ + noteId: id, cards: [id * 10], fields: { LwtId: { value: String(id), order: 4 } }, + })), + getReviewsOfCards: () => ({}), + }); + + await new AnkiConnect('http://127.0.0.1:8765').pullReviews('LWT Term', 'LwtId'); + + expect(calls.filter((c) => c.action === 'notesInfo')).toHaveLength(3); + expect(calls.filter((c) => c.action === 'getReviewsOfCards')).toHaveLength(3); + }); + + it('reports an unreachable Anki distinctly from an error Anki returns', async () => { + global.fetch = vi.fn(async () => { throw new TypeError('Failed to fetch'); }) as typeof fetch; + const unreachable = await new AnkiConnect('http://127.0.0.1:8765').invoke('version').catch((e) => e); + expect(unreachable).toBeInstanceOf(AnkiConnectError); + expect(unreachable.unreachable).toBe(true); + + ankiAnswering({}); + const refused = await new AnkiConnect('http://127.0.0.1:8765').invoke('version').catch((e) => e); + expect(refused).toBeInstanceOf(AnkiConnectError); + expect(refused.unreachable).toBe(false); + }); + + it('asks Anki for permission and reads the answer', async () => { + ankiAnswering({ requestPermission: () => ({ permission: 'denied' }) }); + + expect(await new AnkiConnect('http://127.0.0.1:8765').requestPermission()).toBe(false); + }); +}); + +describe('sendReviews', () => { + beforeEach(() => { + apiPost.mockReset(); + }); + + it('posts in batches and adds up what LWT reports', async () => { + apiPost.mockResolvedValue({ data: { terms: 1, applied: 2, duplicates: 1, rebuilt: 0, skipped: 0 } }); + const reviews = Array.from({ length: 5 }, (_, i) => ({ term_id: 7, reviewed_at: 1770000000000 + i, ease: 3 })); + const batches: number[] = []; + + const summary = await sendReviews(reviews, 2, (done) => batches.push(done)); + + expect(apiPost).toHaveBeenCalledTimes(3); + expect(apiPost.mock.calls[0]).toEqual(['/anki/reviews', { reviews: reviews.slice(0, 2) }]); + expect(batches).toEqual([2, 4, 5]); + expect(summary).toEqual({ terms: 3, applied: 6, duplicates: 3, rebuilt: 0, skipped: 0 }); + }); + + it('stops at the first failed batch', async () => { + apiPost.mockResolvedValueOnce({ error: 'Forbidden' }); + + await expect(sendReviews([{ term_id: 7, reviewed_at: 1, ease: 3 }], 10)).rejects.toThrow('Forbidden'); + }); +});