From ab3e532241c17d1ddc7a59fae516d8b280c9c40e Mon Sep 17 00:00:00 2001 From: HugoFara Date: Tue, 3 Nov 2026 14:04:42 +0100 Subject: [PATCH] feat(anki): send LWT reviews to Anki, tested against real Anki (#264) Step 2 of the sync build order. After pulling Anki's reviews, the sync page asks POST /api/v1/anki/push-plan what Anki lacks (AnkiPushPlan): LWT reviews missing from each card's revlog, shaped as the .apkg writer shapes them, and the card's due date, interval, reps and lapses. The browser writes them with insertReviews, setDueDate and setSpecificValueOfCard. A sync right after changes nothing either way. Measured against Anki 26.9.3 with AnkiConnect, FSRS on and off: insertReviews only appends history and Anki never replays it, so the schedule is set explicitly; AnkiConnect refuses to write FSRS memory state, so with FSRS on Anki's estimate catches up on reoptimising; setDueDate adds a grade-less revlog row, which the pull ignores. scripts/anki/container builds that Anki headless from first-party sources (PyPI, AnkiWeb with a pinned hash), with npm run anki:build / anki:up / anki:down and ANKI_FSRS to choose the scheduler. cypress/e2e/24-anki-live.cy.ts runs the whole loop against it (export, import, answer in Anki, sync, answer in LWT, sync, check Anki) and skips itself when no Anki answers, so CI is unaffected. Co-Authored-By: Claude --- .gitignore | 1 + CHANGELOG.md | 18 +- cypress/e2e/24-anki-live.cy.ts | 215 ++++++++++++++++++ docs-src/developer/anki-sync-conflicts.md | 75 ++++-- locale/ar/vocabulary.json | 28 ++- locale/de/vocabulary.json | 20 +- locale/en/vocabulary.json | 20 +- locale/es/vocabulary.json | 20 +- locale/fa/vocabulary.json | 18 +- locale/fr/vocabulary.json | 20 +- locale/it/vocabulary.json | 20 +- locale/ja/vocabulary.json | 18 +- locale/pt/vocabulary.json | 20 +- locale/ru/vocabulary.json | 24 +- locale/zh/vocabulary.json | 18 +- package.json | 3 + scripts/anki/container/Containerfile | 50 ++++ scripts/anki/container/ankiconnect-meta.json | 14 ++ scripts/anki/container/start.py | 62 +++++ .../Services/Anki/AnkiPushPlan.php | 210 +++++++++++++++++ .../Vocabulary/Http/AnkiApiHandler.php | 24 +- src/Modules/Vocabulary/Views/anki_sync.php | 7 +- src/backend/Api/V1/Endpoints.php | 1 + .../js/modules/vocabulary/pages/anki_sync.ts | 66 +++++- .../vocabulary/services/anki_connect.ts | 84 ++++++- .../Services/Anki/AnkiPushPlanTest.php | 182 +++++++++++++++ tests/frontend/vocabulary/anki_sync.test.ts | 65 +++++- 27 files changed, 1184 insertions(+), 119 deletions(-) create mode 100644 cypress/e2e/24-anki-live.cy.ts create mode 100644 scripts/anki/container/Containerfile create mode 100644 scripts/anki/container/ankiconnect-meta.json create mode 100644 scripts/anki/container/start.py create mode 100644 src/Modules/Vocabulary/Application/Services/Anki/AnkiPushPlan.php create mode 100644 tests/backend/Modules/Vocabulary/Application/Services/Anki/AnkiPushPlanTest.php diff --git a/.gitignore b/.gitignore index ffc2b0e34..60d31a5c1 100644 --- a/.gitignore +++ b/.gitignore @@ -74,6 +74,7 @@ coverage.xml /cypress/screenshots/ /cypress/videos/ /cypress/downloads/ +/cypress/anki-exchange/ /cypress/coverage/ # Lighthouse reports diff --git a/CHANGELOG.md b/CHANGELOG.md index 143133fb2..3ffaba974 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,13 +28,17 @@ 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. +* **Sync reviews with Anki without a file** (#264). A new page, + `/vocabulary/anki/sync`, linked from the `.apkg` import page, talks to Anki + on your computer through the AnkiConnect add-on. It brings the reviews you + did in Anki into LWT's schedule, then sends the reviews you did in LWT to + Anki and gives the cards LWT's due dates. Anki asks once whether to trust + the page. Syncing again is safe: reviews either side already has are + skipped. Only cards LWT exported are matched, and only reviews and schedules + are synced, not translations or tags. With FSRS on in Anki, Anki's own + memory estimates catch up when you next optimise, since AnkiConnect cannot + write them. 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 diff --git a/cypress/e2e/24-anki-live.cy.ts b/cypress/e2e/24-anki-live.cy.ts new file mode 100644 index 000000000..2253a3d0d --- /dev/null +++ b/cypress/e2e/24-anki-live.cy.ts @@ -0,0 +1,215 @@ +/// + +/** + * Pulling reviews from a real Anki (#264), not a stub. + * + * Needs Anki with AnkiConnect listening on 127.0.0.1:8765 and sharing + * cypress/anki-exchange with this machine as /exchange. The repo builds one: + * + * npm run anki:build && npm run anki:up (podman; see scripts/anki/container) + * + * Without it every test here is skipped, so CI and a plain checkout are + * unaffected. With it, this is the loop a learner runs: export from LWT, study + * in Anki, pull the reviews back on /vocabulary/anki/sync. + */ + +const apiBase = '/api/v1'; +const anki = 'http://127.0.0.1:8765'; + +function unwrap(body: unknown): T { + const wrapped = body as { data?: T }; + return (wrapped && typeof wrapped === 'object' && 'data' in wrapped ? wrapped.data : body) as T; +} + +/** Call AnkiConnect directly, failing the test on an AnkiConnect error. */ +function ankiConnect(action: string, params: Record = {}): Cypress.Chainable { + return cy + .request({ method: 'POST', url: anki, body: { action, version: 6, params } }) + .then((response) => { + const body = response.body as { result: T; error: string | null }; + expect(body.error, `AnkiConnect ${action}`).to.eq(null); + return body.result; + }); +} + +/** How many of these terms are due by the end of tomorrow, as the review page counts them. */ +function dueByTomorrow(termIds: number[]): Cypress.Chainable { + return cy + .request(`${apiBase}/review/tomorrow-count?review_key=words&selection=${termIds.join(',')}`) + .then((response) => unwrap<{ count: number }>(response.body).count); +} + +describe('Sync reviews with a real Anki', () => { + let ankiUp = false; + let languageId = 0; + const termIds: number[] = []; + let noteIds: number[] = []; + let deckName = ''; + let cardIds: number[] = []; + + before(() => { + cy.request({ method: 'POST', url: anki, body: { action: 'version', version: 6 }, failOnStatusCode: false, timeout: 3000 }) + .then((response) => { + ankiUp = response.status === 200 && (response.body as { result?: number }).result === 6; + }, () => { + ankiUp = false; + }); + }); + + beforeEach(function () { + if (!ankiUp) { + cy.log('AnkiConnect not reachable on 127.0.0.1:8765; run npm run anki:up'); + this.skip(); + } + }); + + after(() => { + if (!ankiUp) return; + if (noteIds.length > 0) { + ankiConnect('deleteNotes', { notes: noteIds }); + } + if (deckName !== '') { + ankiConnect('deleteDecks', { decks: [deckName], cardsToo: true }); + } + termIds.forEach((id) => { + cy.apiRequest({ method: 'DELETE', url: `${apiBase}/terms/${id}`, failOnStatusCode: false }); + }); + if (languageId > 0) { + cy.apiRequest({ method: 'DELETE', url: `${apiBase}/languages/${languageId}`, failOnStatusCode: false }); + } + }); + + it('brings Anki answers into LWT and sends LWT answers to Anki, once each way', () => { + const name = `E2E Anki ${Date.now()}`; + const file = 'lwt-e2e.apkg'; + + // 1. Terms in LWT, exported as an Anki package into the shared folder. + cy.apiRequest({ method: 'POST', url: `${apiBase}/languages`, body: { name } }).then((response) => { + languageId = unwrap<{ id: number }>(response.body).id; + ['uno', 'dos', 'tres'].forEach((text) => { + cy.apiRequest({ + method: 'POST', + url: `${apiBase}/terms/for-language`, + body: { language_id: languageId, text, translation: `${text} meaning`, status: 1 }, + }).then((termResponse) => { + termIds.push(unwrap<{ id: number }>(termResponse.body).id); + }); + }); + }); + cy.then(() => + cy.request({ url: `/vocabulary/apkg/export?lang_id=${languageId}`, encoding: 'binary' }) + .then((response) => cy.writeFile(`cypress/anki-exchange/${file}`, response.body, 'binary')) + ); + + // 2. Imported into Anki, which now holds one LWT note per term. + cy.then(() => ankiConnect('importPackage', { path: `/exchange/${file}` })); + cy.then(() => + ankiConnect('findNotes', { query: '"note:LWT Term"' }).then((all) => + ankiConnect<{ noteId: number; cards: number[]; fields: Record }[]>( + 'notesInfo', + { notes: all } + ).then((notes) => { + const ours = notes.filter((n) => termIds.includes(Number(n.fields.LwtId?.value))); + expect(ours, 'one Anki note per exported term').to.have.length(3); + noteIds = ours.map((n) => n.noteId); + // In term order, so the grades below land on the terms named later. + ours.sort((a, b) => termIds.indexOf(Number(a.fields.LwtId.value)) - termIds.indexOf(Number(b.fields.LwtId.value))); + const cards = ours.map((n) => n.cards[0]); + cardIds = cards; + ankiConnect<{ deckName: string }[]>('cardsInfo', { cards }).then((info) => { + deckName = info[0].deckName; + }); + + // 3. Studied in Anki: Good, Again, Easy. + ankiConnect('answerCards', { + answers: cards.map((cardId, i) => ({ cardId, ease: [3, 1, 4][i] })), + }).then((answered) => expect(answered).to.deep.equal([true, true, true])); + }) + ) + ); + + // Before the pull, all three new terms fall due by tomorrow. + cy.then(() => dueByTomorrow(termIds).then((count) => expect(count, 'due before the pull').to.eq(3))); + + // 4. Synced through the page. + cy.intercept('POST', '**/api/v1/anki/reviews').as('pull'); + cy.intercept('POST', '**/api/v1/anki/push-plan').as('plan'); + cy.visit('/vocabulary/anki/sync'); + cy.waitForAlpine(); + cy.get('[x-data="ankiSync"] button.is-primary').click(); + cy.wait('@pull').then(({ response }) => { + const summary = unwrap<{ applied: number; terms: number; duplicates: number }>(response?.body); + expect(summary.applied, 'reviews merged').to.eq(3); + expect(summary.terms, 'terms rescheduled').to.eq(3); + }); + // LWT had nothing Anki lacks: everything it holds came from Anki. + cy.wait('@plan').then(({ response }) => { + const plan = unwrap<{ reviews: unknown[]; cards: unknown[] }>(response?.body); + expect(plan.reviews, 'nothing to send back yet').to.have.length(0); + }); + cy.get('[x-data="ankiSync"] .notification.is-success').should('be.visible'); + + // The answers now decide the schedule: the term answered Again in Anki is + // still due by tomorrow, the one answered Easy is pushed out. + cy.then(() => { + const [, againTerm, easyTerm] = termIds; + dueByTomorrow([againTerm]).then((count) => expect(count, 'Again term due').to.eq(1)); + dueByTomorrow([easyTerm]).then((count) => expect(count, 'Easy term due').to.eq(0)); + }); + + // 5. Studied in LWT: the first term, answered Easy here. + cy.then(() => + cy.apiRequest({ method: 'PUT', url: `${apiBase}/review/status`, body: { term_id: termIds[0], grade: 4 } }) + ); + + // 6. The next sync sends that review and the schedule it led to. + let planned: { card_id: number; due_days: number; ivl: number } | undefined; + cy.get('[x-data="ankiSync"] button.is-primary').click(); + cy.wait('@pull').then(({ response }) => { + const summary = unwrap<{ applied: number; duplicates: number }>(response?.body); + expect(summary.applied, 'nothing new from Anki').to.eq(0); + expect(summary.duplicates).to.eq(3); + }); + cy.wait('@plan').then(({ response }) => { + const plan = unwrap<{ reviews: number[][]; cards: { card_id: number; due_days: number; ivl: number }[] }>( + response?.body + ); + expect(plan.reviews, 'the LWT review').to.have.length(1); + expect(plan.reviews[0][1]).to.eq(cardIds[0]); + expect(plan.reviews[0][3], 'its grade').to.eq(4); + expect(plan.cards).to.have.length(1); + planned = plan.cards[0]; + }); + // The page writes to Anki after the plan arrives; wait for it to finish. + cy.get('[x-data="ankiSync"] button.is-primary').should('not.be.disabled'); + cy.get('[x-data="ankiSync"] .notification.is-success').should('be.visible'); + + // Anki now has it in the card's history, and LWT's schedule on the card. + cy.then(() => { + ankiConnect>('getReviewsOfCards', { cards: [String(cardIds[0])] }) + .then((byCard) => { + // setDueDate adds Anki's own grade-less "rescheduled" row (ease 0), + // which the pull ignores; the answers are the Anki one and LWT's. + const answers = byCard[String(cardIds[0])].filter((row) => row.ease > 0); + // Order is not checked: LWT keeps seconds, so its answer's id can sort + // before an Anki answer given earlier within the same second. + expect(answers.map((row) => row.ease), 'Anki answer and LWT answer').to.have.members([3, 4]); + }); + ankiConnect<{ interval: number; reps: number; type: number }[]>('cardsInfo', { cards: [cardIds[0]] }) + .then(([card]) => { + expect(card.type, 'a review card').to.eq(2); + expect(card.interval, 'LWT interval').to.eq(planned?.ivl); + expect(card.reps, 'both answers counted').to.eq(2); + }); + }); + + // 7. And a sync right after changes nothing in either direction. + cy.get('[x-data="ankiSync"] button.is-primary').click(); + cy.wait('@pull').then(({ response }) => { + expect(unwrap<{ applied: number }>(response?.body).applied).to.eq(0); + }); + cy.wait('@plan').then(({ response }) => { + expect(unwrap<{ reviews: unknown[] }>(response?.body).reviews).to.have.length(0); + }); + }); +}); diff --git a/docs-src/developer/anki-sync-conflicts.md b/docs-src/developer/anki-sync-conflicts.md index ea0146e12..47340637d 100644 --- a/docs-src/developer/anki-sync-conflicts.md +++ b/docs-src/developer/anki-sync-conflicts.md @@ -5,11 +5,13 @@ description: How LWT and Anki should merge when both change the same terms betwe # Spike: Conflict Resolution for Live Anki Sync -**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 +**Status:** Spike result (#264). **Steps 1 and 2 of the build order are +implemented**, and tested against a real Anki in a container (see +[Testing against real Anki](#testing-against-real-anki)): `/vocabulary/anki/sync` +pulls Anki's reviews into LWT through `MergeReviewHistory`, which fits an +older review into the history instead of skipping it, then sends LWT's reviews +and schedule back to Anki. The `.apkg` importer merges the same way. Step 3 +(fields, tags, flags, existence) is 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."* @@ -58,7 +60,8 @@ Checked in the code and in AnkiConnect's API. These details come from the typed client `yanki-connect` 4.0.6, written against AnkiConnect 25.11.9.0; the official README at `git.sr.ht/~foosoft/anki-connect` was unreachable when this was written. -**Verify each against it before coding.** +**Since verified** against AnkiConnect's own source (the AnkiWeb package) and +by running it, for everything steps 1 and 2 use; see below. - `notesInfo` returns fields, tags, cards and a `mod` time per **note** — one timestamp for the whole note, not per field. @@ -123,12 +126,22 @@ from the earliest unseen review rather than append after the latest. comes back in `cardReviews`. Key each review by (term, timestamp to the second, grade) and drop repeats. Exports already round-trip LWT's own history this way, and the importer already relies on it. -- **Into Anki.** Push LWT's unseen reviews with `insertReviews`, then - `setDueDate` the card to LWT's due date so the next Anki session agrees. Anki - keeps its own memory state in `cards.data`; whether Anki recomputes it from - the inserted revlog, and when, **is unverified** and decides whether Anki's - next interval matches LWT's. If it does not, the due date is still right and - the next answer in either app re-converges. +- **Into Anki.** Push LWT's unseen reviews with `insertReviews`, then set the + card's due date (`setDueDate`) and its interval, reps and lapses + (`setSpecificValueOfCard`, with `warning_check`) so the next Anki session + agrees. Measured against Anki 26.9.3 and AnkiConnect, FSRS on and off: + - `insertReviews` only appends history. The card's interval, due date, reps, + lapses and FSRS stability are untouched, and Anki's next answer comes out + exactly as on a twin card without the inserted review: Anki does not + replay revlog when answering. So the schedule has to be set separately. + - AnkiConnect refuses to write FSRS memory state ("Coupled scheduler state + must use native scheduling transitions"). With FSRS on, Anki's estimate + for a card therefore lags until the learner reoptimises, which recomputes + it from the history this push sends. The due date is right regardless. + - `setDueDate` adds Anki's own grade-less "rescheduled" revlog row, which + the pull ignores like any ease-0 row. + - `insertReviews` rejects a whole batch if one id already exists, so the + plan only ever sends ids Anki does not have. ### Everything else: three-way merge against a sync base @@ -249,10 +262,11 @@ The spike's acceptance list, to become the merge function's unit tests: ## Open questions to settle before building -- Does Anki recompute `cards.data` from inserted revlog rows, and when? Test - against real Anki, as `composer test:anki-oracle` does for files. -- Confirm every AnkiConnect detail above against the official README. -- Which Anki deck(s) map to which LWT language: one deck per language, chosen +- ~~Does Anki recompute `cards.data` from inserted revlog rows?~~ Answered by + measurement: no; only reoptimising does. See *Into Anki* above. +- ~~Confirm every AnkiConnect detail above.~~ Done for steps 1 and 2, against + AnkiConnect's source and a running Anki. +- For step 3: which Anki deck(s) map to which LWT language: one deck per language, chosen once, is the simplest rule. - Whether status 1–5 should follow Anki at all, or stay derived from LWT's own schedule (phase 2 decision 1 says derived). @@ -268,10 +282,35 @@ live sync is built, sequence it so each step is useful alone: `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. +2. **Push LWT reviews** (`insertReviews` + `setDueDate` + + `setSpecificValueOfCard`). ✅ Implemented: after the pull, the browser asks + `POST /api/v1/anki/push-plan` what Anki lacks (`AnkiPushPlan`) and writes + it. FSRS memory state cannot be written; see above. 3. **Fields, tags, flags and existence** through the sync base, with the conflict report. Step 1 alone delivers most of what "sync with Anki" means to a learner who reviews on the phone and reads in LWT. + +## Testing against real Anki + +`scripts/anki/container` builds Anki 26.9.3 with AnkiConnect, headless (Qt's +offscreen platform), from first-party sources: Anki from PyPI, AnkiConnect +from AnkiWeb checked against a pinned hash. It trusts LWT's dev-server origins +and shares `cypress/anki-exchange` as `/exchange` for package imports. + +```bash +npm run anki:build # once +npm run anki:up # Anki on 127.0.0.1:8765 +npm run serve # LWT on :8000, in another terminal +npx cypress run --spec cypress/e2e/24-anki-live.cy.ts +npm run anki:down +``` + +`24-anki-live.cy.ts` runs the learner's loop for real: export terms from LWT, +import them into Anki (`importPackage`), answer them there (`answerCards`), +sync, answer one in LWT, sync again, and check Anki's history and card, then +that a third sync changes nothing. It skips itself when nothing answers on +8765, so CI is unaffected. `ANKI_FSRS=1` on `podman run` starts Anki with FSRS +on; the spec passes in both modes. + diff --git a/locale/ar/vocabulary.json b/locale/ar/vocabulary.json index 2a091f552..1639a9c59 100644 --- a/locale/ar/vocabulary.json +++ b/locale/ar/vocabulary.json @@ -521,17 +521,17 @@ "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.link": "مزامنة المراجعات مع Anki (AnkiConnect)", + "anki.sync.intro": "انقل المراجعات التي أجريتها في Anki إلى LWT، وأرسل تلك التي أجريتها في LWT إلى Anki، مباشرةً من 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.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.done": "تمت مزامنة المراجعات مع Anki.", "anki.sync.applied_zero": "لم تُدمج أي مراجعة جديدة", "anki.sync.applied_one": "دُمجت مراجعة جديدة واحدة", "anki.sync.applied_two": "دُمجت مراجعتان جديدتان", @@ -564,9 +564,23 @@ "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.error_failed": "فشلت المزامنة: {message}", + "anki.sync.how_html": "تنتقل المراجعات في الاتجاهين. تنضم كل مراجعة إلى سجل المصطلح في اللحظة التي حدثت فيها، لذا فإن الدراسة في التطبيقين بين مزامنتين تعطي الجدول نفسه الذي كنت ستحصل عليه في تطبيق واحد، ثم تأخذ بطاقات Anki مواعيد الاستحقاق من LWT. المزامنة مرة أخرى آمنة: تُتجاوز المراجعات الموجودة مسبقًا لدى أي من الطرفين. تُزامَن المراجعات والجداول فقط، لا الترجمات ولا الوسوم.", "anki.sync.hosted_html": "إذا كان LWT يعمل على خادم آخر، فقد يطلب متصفحك إذنًا للوصول إلى Anki على هذا الحاسوب، أو يحظره؛ والسماح بـالوصول إلى الشبكة المحلية لهذا الموقع يحل ذلك. أما تبادل ملفات ‎.apkg فيعمل في كل مكان.", + "anki.sync.writing": "الكتابة إلى Anki: {done} من {total}", + "anki.sync.pushed_zero": "لم تُرسل أي مراجعة من LWT إلى Anki", + "anki.sync.pushed_one": "أُرسلت مراجعة واحدة من LWT إلى Anki", + "anki.sync.pushed_two": "أُرسلت مراجعتان من LWT إلى Anki", + "anki.sync.pushed_few": "أُرسلت {count} مراجعات من LWT إلى Anki", + "anki.sync.pushed_many": "أُرسلت {count} مراجعةً من LWT إلى Anki", + "anki.sync.pushed_other": "أُرسلت {count} مراجعة من LWT إلى Anki", + "anki.sync.cards_zero": "لم تأخذ أي بطاقة في Anki جدول LWT", + "anki.sync.cards_one": "أخذت بطاقة واحدة في Anki جدول LWT", + "anki.sync.cards_two": "أخذت بطاقتان في Anki جدول LWT", + "anki.sync.cards_few": "أخذت {count} بطاقات في Anki جدول LWT", + "anki.sync.cards_many": "أخذت {count} بطاقةً في Anki جدول LWT", + "anki.sync.cards_other": "أخذت {count} بطاقة في Anki جدول LWT", + "anki.sync.fsrs_html": "عند تفعيل FSRS في Anki، يحتفظ Anki أيضًا بتقديره الخاص للذاكرة لكل بطاقة، ولا تحدّثه المراجعات المرسلة من LWT: إذ لا يستطيع AnkiConnect كتابته. مواعيد الاستحقاق صحيحة؛ ولمواءمة التقديرات أيضًا، شغّل تحسين في خيارات المجموعة في Anki من حين لآخر.", "titles.terms": "المصطلحات", "titles.term": "مصطلح", "titles.term_named": "المصطلح: {term}", @@ -577,5 +591,5 @@ "titles.error": "خطأ", "titles.status_changed": "تم تغيير الحالة", "titles.status_set": "تم تعيين الحالة", - "titles.anki_sync": "مزامنة المراجعات من Anki" + "titles.anki_sync": "مزامنة المراجعات مع Anki" } diff --git a/locale/de/vocabulary.json b/locale/de/vocabulary.json index 252baf972..5818f9bbf 100644 --- a/locale/de/vocabulary.json +++ b/locale/de/vocabulary.json @@ -432,17 +432,17 @@ "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.link": "Wiederholungen mit Anki synchronisieren (AnkiConnect)", + "anki.sync.intro": "Übernehmen Sie Ihre Wiederholungen aus Anki in LWT und senden Sie die in LWT erledigten an Anki – direkt mit Anki auf diesem Computer, 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.button": "Wiederholungen mit Anki synchronisieren", "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.done": "Wiederholungen mit Anki synchronisiert.", "anki.sync.applied_one": "{count} neue Wiederholung übernommen", "anki.sync.applied_other": "{count} neue Wiederholungen übernommen", "anki.sync.terms_one": "{count} Begriff neu geplant", @@ -455,9 +455,15 @@ "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.error_failed": "Synchronisierung fehlgeschlagen: {message}", + "anki.sync.how_html": "Wiederholungen werden in beide Richtungen übertragen. Jede wird zum Zeitpunkt, an dem sie stattfand, in den Verlauf des Begriffs eingefügt. Wer zwischen zwei Synchronisierungen in beiden Apps lernt, erhält also denselben Lernplan wie in einer einzigen, und die Karten in Anki übernehmen danach die Fälligkeitsdaten von LWT. Erneutes Synchronisieren ist unbedenklich: Wiederholungen, die eine Seite bereits hat, werden übersprungen. Synchronisiert werden nur Wiederholungen und Lernpläne, keine Übersetzungen oder Schlagwörter.", "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.", + "anki.sync.writing": "Schreiben in Anki: {done} von {total}", + "anki.sync.pushed_one": "{count} LWT-Wiederholung an Anki gesendet", + "anki.sync.pushed_other": "{count} LWT-Wiederholungen an Anki gesendet", + "anki.sync.cards_one": "{count} Anki-Karte hat den Lernplan von LWT erhalten", + "anki.sync.cards_other": "{count} Anki-Karten haben den Lernplan von LWT erhalten", + "anki.sync.fsrs_html": "Ist FSRS in Anki aktiviert, führt Anki für jede Karte zusätzlich eine eigene Gedächtnisschätzung, die von LWT gesendete Wiederholungen nicht aktualisieren: AnkiConnect kann sie nicht schreiben. Die Fälligkeitsdaten stimmen; um auch die Schätzungen anzugleichen, führen Sie ab und zu Optimieren in den Stapeloptionen von Anki aus.", "titles.terms": "Begriffe", "titles.term": "Begriff", "titles.term_named": "Begriff: {term}", @@ -468,5 +474,5 @@ "titles.error": "Fehler", "titles.status_changed": "Status geändert", "titles.status_set": "Status gesetzt", - "titles.anki_sync": "Wiederholungen aus Anki synchronisieren" + "titles.anki_sync": "Wiederholungen mit Anki synchronisieren" } diff --git a/locale/en/vocabulary.json b/locale/en/vocabulary.json index 0ed51c261..1c878e208 100644 --- a/locale/en/vocabulary.json +++ b/locale/en/vocabulary.json @@ -419,17 +419,17 @@ "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.link": "Sync reviews with Anki (AnkiConnect)", + "anki.sync.intro": "Bring the reviews you did in Anki into LWT, and send the ones you did in LWT to Anki, 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.button": "Sync reviews with 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.done": "Reviews synced with Anki.", "anki.sync.applied_one": "{count} new review merged", "anki.sync.applied_other": "{count} new reviews merged", "anki.sync.terms_one": "{count} term rescheduled", @@ -442,9 +442,15 @@ "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.error_failed": "The sync failed: {message}", + "anki.sync.how_html": "Reviews travel both ways. Each joins the term's history at the moment it happened, so studying in both apps between two syncs gives the schedule you would have had in one, and Anki's cards then get LWT's due dates. Syncing again is safe: reviews either side already has are skipped. Only reviews and schedules are synced, not translations or tags.", "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.", + "anki.sync.writing": "Writing to Anki: {done} of {total}", + "anki.sync.pushed_one": "{count} LWT review sent to Anki", + "anki.sync.pushed_other": "{count} LWT reviews sent to Anki", + "anki.sync.cards_one": "{count} Anki card given LWT's schedule", + "anki.sync.cards_other": "{count} Anki cards given LWT's schedule", + "anki.sync.fsrs_html": "With FSRS on in Anki, Anki also keeps its own memory estimate for each card, which reviews sent from LWT do not update: AnkiConnect cannot write it. The due dates are right; to bring the estimates in line too, run Optimize in Anki's deck options now and then.", "titles.terms": "Terms", "titles.term": "Term", "titles.term_named": "Term: {term}", @@ -455,5 +461,5 @@ "titles.error": "Error", "titles.status_changed": "Status Changed", "titles.status_set": "Status Set", - "titles.anki_sync": "Sync Reviews from Anki" + "titles.anki_sync": "Sync Reviews with Anki" } diff --git a/locale/es/vocabulary.json b/locale/es/vocabulary.json index 3d12be594..aed95abe0 100644 --- a/locale/es/vocabulary.json +++ b/locale/es/vocabulary.json @@ -436,17 +436,17 @@ "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.link": "Sincronizar repasos con Anki (AnkiConnect)", + "anki.sync.intro": "Lleva a LWT los repasos que hiciste en Anki y envía a Anki los que hiciste en 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.button": "Sincronizar repasos con 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.done": "Repasos sincronizados con 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", @@ -459,9 +459,15 @@ "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.error_failed": "La sincronización falló: {message}", + "anki.sync.how_html": "Los repasos viajan en ambos sentidos. Cada uno 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, y luego las tarjetas de Anki reciben las fechas de vencimiento de LWT. Volver a sincronizar es seguro: se omiten los repasos que cualquiera de los dos lados ya tiene. Solo se sincronizan los repasos y la programación, no las traducciones ni las etiquetas.", "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.", + "anki.sync.writing": "Escribiendo en Anki: {done} de {total}", + "anki.sync.pushed_one": "{count} repaso de LWT enviado a Anki", + "anki.sync.pushed_other": "{count} repasos de LWT enviados a Anki", + "anki.sync.cards_one": "{count} tarjeta de Anki recibió la programación de LWT", + "anki.sync.cards_other": "{count} tarjetas de Anki recibieron la programación de LWT", + "anki.sync.fsrs_html": "Con FSRS activado en Anki, Anki también guarda su propia estimación de memoria para cada tarjeta, que los repasos enviados desde LWT no actualizan: AnkiConnect no puede escribirla. Las fechas de vencimiento son correctas; para ajustar también las estimaciones, ejecuta de vez en cuando Optimizar en las opciones del mazo de Anki.", "titles.terms": "Términos", "titles.term": "Término", "titles.term_named": "Término: {term}", @@ -472,5 +478,5 @@ "titles.error": "Error", "titles.status_changed": "Estado cambiado", "titles.status_set": "Estado establecido", - "titles.anki_sync": "Sincronizar repasos desde Anki" + "titles.anki_sync": "Sincronizar repasos con Anki" } diff --git a/locale/fa/vocabulary.json b/locale/fa/vocabulary.json index 730bf3370..5958c8246 100644 --- a/locale/fa/vocabulary.json +++ b/locale/fa/vocabulary.json @@ -407,17 +407,17 @@ "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.link": "همگام‌سازی مرورها با Anki (AnkiConnect)", + "anki.sync.intro": "مرورهایی را که در Anki انجام داده‌اید به LWT بیاورید و مرورهای LWT را به Anki بفرستید، مستقیماً از 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.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.done": "مرورها با Anki همگام شدند.", "anki.sync.applied_other": "{count} مرور جدید ادغام شد", "anki.sync.terms_other": "زمان‌بندی {count} واژه به‌روزرسانی شد", "anki.sync.duplicates_other": "{count} مرور از قبل در LWT بود", @@ -425,9 +425,13 @@ "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.error_failed": "همگام‌سازی ناموفق بود: {message}", + "anki.sync.how_html": "مرورها در هر دو جهت جابه‌جا می‌شوند. هر مرور در همان لحظه‌ای که انجام شده به تاریخچهٔ واژه می‌پیوندد، پس مطالعه در هر دو برنامه بین دو همگام‌سازی، همان زمان‌بندی‌ای را می‌دهد که با یک برنامه می‌داشتید، و سپس کارت‌های Anki موعدهای LWT را می‌گیرند. همگام‌سازی دوباره بی‌خطر است: مرورهایی که هر طرف از قبل دارد رد می‌شوند. فقط مرورها و زمان‌بندی‌ها همگام می‌شوند، نه ترجمه‌ها یا برچسب‌ها.", "anki.sync.hosted_html": "اگر LWT روی سرور دیگری اجرا می‌شود، مرورگر شما ممکن است برای دسترسی به Anki روی این رایانه اجازه بخواهد یا آن را مسدود کند؛ اجازه دادن دسترسی به شبکهٔ محلی برای این سایت مشکل را حل می‌کند. رفت‌وبرگشت با فایل .apkg همه‌جا کار می‌کند.", + "anki.sync.writing": "نوشتن در Anki: {done} از {total}", + "anki.sync.pushed_other": "{count} مرور LWT به Anki فرستاده شد", + "anki.sync.cards_other": "زمان‌بندی LWT به {count} کارت Anki داده شد", + "anki.sync.fsrs_html": "اگر FSRS در Anki روشن باشد، Anki برای هر کارت برآورد حافظهٔ خودش را هم نگه می‌دارد که مرورهای فرستاده‌شده از LWT آن را به‌روز نمی‌کنند: AnkiConnect نمی‌تواند آن را بنویسد. موعدها درست‌اند؛ برای هماهنگ کردن برآوردها نیز، هر از گاهی بهینه‌سازی را در گزینه‌های دستهٔ Anki اجرا کنید.", "titles.terms": "واژه‌ها", "titles.term": "واژه", "titles.term_named": "واژه: {term}", @@ -438,5 +442,5 @@ "titles.error": "خطا", "titles.status_changed": "وضعیت تغییر کرد", "titles.status_set": "وضعیت تنظیم شد", - "titles.anki_sync": "همگام‌سازی مرورها از Anki" + "titles.anki_sync": "همگام‌سازی مرورها با Anki" } diff --git a/locale/fr/vocabulary.json b/locale/fr/vocabulary.json index 4cb4063c7..608ed5ef8 100644 --- a/locale/fr/vocabulary.json +++ b/locale/fr/vocabulary.json @@ -435,17 +435,17 @@ "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.link": "Synchroniser les révisions avec Anki (AnkiConnect)", + "anki.sync.intro": "Intégrez à LWT les révisions faites dans Anki, et envoyez à Anki celles faites dans 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.button": "Synchroniser les révisions avec 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.done": "Révisions synchronisées avec 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é", @@ -458,9 +458,15 @@ "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.error_failed": "La synchronisation a échoué : {message}", + "anki.sync.how_html": "Les révisions voyagent dans les deux sens. Chacune rejoint l'historique du terme au moment où elle a eu lieu ; étudier dans les deux applications entre deux synchronisations donne donc la planification que vous auriez eue dans une seule, et les cartes d'Anki reçoivent ensuite les échéances de LWT. Synchroniser à nouveau est sans risque : les révisions que l'un ou l'autre côté possède déjà sont ignorées. Seules les révisions et les planifications sont synchronisées, pas les traductions ni les étiquettes.", "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.", + "anki.sync.writing": "Écriture dans Anki : {done} sur {total}", + "anki.sync.pushed_one": "{count} révision LWT envoyée à Anki", + "anki.sync.pushed_other": "{count} révisions LWT envoyées à Anki", + "anki.sync.cards_one": "{count} carte Anki a reçu la planification de LWT", + "anki.sync.cards_other": "{count} cartes Anki ont reçu la planification de LWT", + "anki.sync.fsrs_html": "Avec FSRS activé dans Anki, Anki tient aussi sa propre estimation de mémorisation pour chaque carte, que les révisions envoyées depuis LWT ne mettent pas à jour : AnkiConnect ne peut pas l'écrire. Les échéances sont justes ; pour aligner aussi les estimations, lancez de temps en temps Optimiser dans les options du paquet d'Anki.", "titles.terms": "Termes", "titles.term": "Terme", "titles.term_named": "Terme: {term}", @@ -471,5 +477,5 @@ "titles.error": "Erreur", "titles.status_changed": "Statut modifié", "titles.status_set": "Statut défini", - "titles.anki_sync": "Synchroniser les révisions depuis Anki" + "titles.anki_sync": "Synchroniser les révisions avec Anki" } diff --git a/locale/it/vocabulary.json b/locale/it/vocabulary.json index eb02eb2a6..35ebc54d7 100644 --- a/locale/it/vocabulary.json +++ b/locale/it/vocabulary.json @@ -436,17 +436,17 @@ "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.link": "Sincronizza le ripetizioni con Anki (AnkiConnect)", + "anki.sync.intro": "Porta in LWT le ripetizioni fatte in Anki e invia ad Anki quelle fatte in 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.button": "Sincronizza le ripetizioni con 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.done": "Ripetizioni sincronizzate con Anki.", "anki.sync.applied_one": "{count} nuova ripetizione integrata", "anki.sync.applied_other": "{count} nuove ripetizioni integrate", "anki.sync.terms_one": "{count} termine ripianificato", @@ -459,9 +459,15 @@ "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.error_failed": "Sincronizzazione non riuscita: {message}", + "anki.sync.how_html": "Le ripetizioni viaggiano in entrambe le direzioni. Ognuna entra nella cronologia del termine nel momento in cui è avvenuta, quindi studiare in entrambe le app tra due sincronizzazioni dà la stessa pianificazione che avresti avuto in una sola, e le carte di Anki ricevono poi le scadenze di LWT. Sincronizzare di nuovo è sicuro: le ripetizioni che una delle due parti ha già vengono saltate. Si sincronizzano solo ripetizioni e pianificazioni, non traduzioni o etichette.", "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.", + "anki.sync.writing": "Scrittura in Anki: {done} di {total}", + "anki.sync.pushed_one": "{count} ripetizione LWT inviata ad Anki", + "anki.sync.pushed_other": "{count} ripetizioni LWT inviate ad Anki", + "anki.sync.cards_one": "{count} carta Anki ha ricevuto la pianificazione di LWT", + "anki.sync.cards_other": "{count} carte Anki hanno ricevuto la pianificazione di LWT", + "anki.sync.fsrs_html": "Con FSRS attivo in Anki, Anki tiene anche una propria stima della memoria per ogni carta, che le ripetizioni inviate da LWT non aggiornano: AnkiConnect non può scriverla. Le scadenze sono corrette; per allineare anche le stime, esegui ogni tanto Ottimizza nelle opzioni del mazzo di Anki.", "titles.terms": "Termini", "titles.term": "Termine", "titles.term_named": "Termine: {term}", @@ -472,5 +478,5 @@ "titles.error": "Errore", "titles.status_changed": "Stato modificato", "titles.status_set": "Stato impostato", - "titles.anki_sync": "Sincronizza le ripetizioni da Anki" + "titles.anki_sync": "Sincronizza le ripetizioni con Anki" } diff --git a/locale/ja/vocabulary.json b/locale/ja/vocabulary.json index 5748564cc..53c00120b 100644 --- a/locale/ja/vocabulary.json +++ b/locale/ja/vocabulary.json @@ -418,17 +418,17 @@ "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.link": "Anki と復習を同期する(AnkiConnect)", + "anki.sync.intro": "Anki で行った復習を LWT に取り込み、LWT で行った復習を Anki に送ります。このコンピューター上の Anki と直接やり取りするので、ファイルのエクスポートやインポートは不要です。", "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.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.done": "Anki と復習を同期しました。", "anki.sync.applied_other": "{count} 件の新しい復習を統合しました", "anki.sync.terms_other": "{count} 件の用語のスケジュールを更新しました", "anki.sync.duplicates_other": "{count} 件の復習は LWT に既にありました", @@ -436,9 +436,13 @@ "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.error_failed": "同期に失敗しました: {message}", + "anki.sync.how_html": "復習は双方向にやり取りされます。各復習は実際に行われた時点で用語の履歴に加わるため、2 回の同期の間に両方のアプリで学習しても、1 つのアプリだけで学習した場合と同じスケジュールになり、その後 Anki のカードに LWT の期日が反映されます。何度同期しても安全です。どちらかに既にある復習はスキップされます。同期されるのは復習とスケジュールだけで、訳語やタグは同期されません。", "anki.sync.hosted_html": "LWT が別のサーバーで動作している場合、ブラウザーがこのコンピューター上の Anki へのアクセス許可を求めたり、アクセスをブロックしたりすることがあります。このサイトに ローカルネットワークへのアクセス を許可すれば解決します。.apkg ファイルによる往復 はどの環境でも使えます。", + "anki.sync.writing": "Anki に書き込み中: {done} / {total}", + "anki.sync.pushed_other": "LWT の復習 {count} 件を Anki に送信しました", + "anki.sync.cards_other": "Anki のカード {count} 枚に LWT のスケジュールを適用しました", + "anki.sync.fsrs_html": "Anki で FSRS を有効にしている場合、Anki はカードごとに独自の記憶の推定値も保持していますが、LWT から送った復習ではこれは更新されません。AnkiConnect では書き込めないためです。期日は正しく設定されます。推定値も合わせるには、Anki のデッキオプションで時々 最適化 を実行してください。", "titles.terms": "用語", "titles.term": "用語", "titles.term_named": "用語:{term}", @@ -449,5 +453,5 @@ "titles.error": "エラー", "titles.status_changed": "ステータスを変更しました", "titles.status_set": "ステータスを設定しました", - "titles.anki_sync": "Anki から復習を同期" + "titles.anki_sync": "Anki と復習を同期" } diff --git a/locale/pt/vocabulary.json b/locale/pt/vocabulary.json index ab4e40734..b66597249 100644 --- a/locale/pt/vocabulary.json +++ b/locale/pt/vocabulary.json @@ -435,17 +435,17 @@ "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.link": "Sincronizar revisões com o Anki (AnkiConnect)", + "anki.sync.intro": "Traga para o LWT as revisões que você fez no Anki e envie ao Anki as que fez no 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.button": "Sincronizar revisões com o 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.done": "Revisões sincronizadas com o 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", @@ -458,9 +458,15 @@ "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.error_failed": "Falha na sincronização: {message}", + "anki.sync.how_html": "As revisões viajam nos dois sentidos. Cada uma 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ó, e os cartões do Anki recebem depois as datas de vencimento do LWT. Sincronizar de novo é seguro: as revisões que qualquer um dos lados já tem são ignoradas. Só revisões e agendamentos são sincronizados, não traduções nem etiquetas.", "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.", + "anki.sync.writing": "Gravando no Anki: {done} de {total}", + "anki.sync.pushed_one": "{count} revisão do LWT enviada ao Anki", + "anki.sync.pushed_other": "{count} revisões do LWT enviadas ao Anki", + "anki.sync.cards_one": "{count} cartão do Anki recebeu o agendamento do LWT", + "anki.sync.cards_other": "{count} cartões do Anki receberam o agendamento do LWT", + "anki.sync.fsrs_html": "Com o FSRS ativado no Anki, o Anki também mantém sua própria estimativa de memória para cada cartão, que as revisões enviadas pelo LWT não atualizam: o AnkiConnect não consegue gravá-la. As datas de vencimento estão certas; para alinhar também as estimativas, execute de vez em quando Otimizar nas opções do baralho do Anki.", "titles.terms": "Termos", "titles.term": "Termo", "titles.term_named": "Termo: {term}", @@ -471,5 +477,5 @@ "titles.error": "Erro", "titles.status_changed": "Status alterado", "titles.status_set": "Status definido", - "titles.anki_sync": "Sincronizar revisões do Anki" + "titles.anki_sync": "Sincronizar revisões com o Anki" } diff --git a/locale/ru/vocabulary.json b/locale/ru/vocabulary.json index a92a7b963..d20658f8c 100644 --- a/locale/ru/vocabulary.json +++ b/locale/ru/vocabulary.json @@ -466,17 +466,17 @@ "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.link": "Синхронизировать повторения с Anki (AnkiConnect)", + "anki.sync.intro": "Перенесите повторения, сделанные в Anki, в LWT, а сделанные в LWT — в Anki, прямо из 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.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.done": "Повторения синхронизированы с Anki.", "anki.sync.applied_one": "Добавлено {count} новое повторение", "anki.sync.applied_few": "Добавлено {count} новых повторения", "anki.sync.applied_many": "Добавлено {count} новых повторений", @@ -499,9 +499,19 @@ "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.error_failed": "Не удалось выполнить синхронизацию: {message}", + "anki.sync.how_html": "Повторения передаются в обе стороны. Каждое попадает в историю слова в тот момент, когда оно было сделано, поэтому занятия в обоих приложениях между двумя синхронизациями дают то же расписание, что и в одном, а карточки Anki затем получают сроки из LWT. Повторная синхронизация безопасна: повторения, которые уже есть на другой стороне, пропускаются. Синхронизируются только повторения и расписания, но не переводы и не метки.", "anki.sync.hosted_html": "Если LWT работает на другом сервере, браузер может запросить разрешение на доступ к Anki на этом компьютере или заблокировать его; разрешите этому сайту доступ к локальной сети, и всё заработает. Обмен файлами .apkg работает везде.", + "anki.sync.writing": "Запись в Anki: {done} из {total}", + "anki.sync.pushed_one": "{count} повторение из LWT отправлено в Anki", + "anki.sync.pushed_few": "{count} повторения из LWT отправлены в Anki", + "anki.sync.pushed_many": "{count} повторений из LWT отправлено в Anki", + "anki.sync.pushed_other": "{count} повторений из LWT отправлено в Anki", + "anki.sync.cards_one": "Расписание LWT применено к {count} карточке Anki", + "anki.sync.cards_few": "Расписание LWT применено к {count} карточкам Anki", + "anki.sync.cards_many": "Расписание LWT применено к {count} карточкам Anki", + "anki.sync.cards_other": "Расписание LWT применено к {count} карточкам Anki", + "anki.sync.fsrs_html": "Если в Anki включён FSRS, Anki также хранит собственную оценку запоминания для каждой карточки, а повторения, отправленные из LWT, её не обновляют: AnkiConnect не может её записать. Сроки повторений верны; чтобы привести в соответствие и оценки, время от времени запускайте Оптимизировать в параметрах колоды Anki.", "titles.terms": "Слова", "titles.term": "Слово", "titles.term_named": "Слово: {term}", @@ -512,5 +522,5 @@ "titles.error": "Ошибка", "titles.status_changed": "Статус изменён", "titles.status_set": "Статус установлен", - "titles.anki_sync": "Синхронизация повторений из Anki" + "titles.anki_sync": "Синхронизация повторений с Anki" } diff --git a/locale/zh/vocabulary.json b/locale/zh/vocabulary.json index 40ff662d8..eb4db991d 100644 --- a/locale/zh/vocabulary.json +++ b/locale/zh/vocabulary.json @@ -397,17 +397,17 @@ "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.link": "与 Anki 同步复习记录(AnkiConnect)", + "anki.sync.intro": "将您在 Anki 中完成的复习导入 LWT,并将您在 LWT 中完成的复习发送到 Anki,直接通过本机上的 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.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.done": "复习记录已与 Anki 同步。", "anki.sync.applied_other": "已合并 {count} 条新复习记录", "anki.sync.terms_other": "已重新排程 {count} 个词条", "anki.sync.duplicates_other": "{count} 条复习记录 LWT 中已存在", @@ -415,9 +415,13 @@ "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.error_failed": "同步失败:{message}", + "anki.sync.how_html": "复习记录双向传输。每条复习记录都会按其实际发生的时间加入词条的历史,因此在两次同步之间同时使用两个应用学习,得到的排程与只用一个应用时相同,随后 Anki 的卡片会采用 LWT 的到期日期。重复同步是安全的:任一方已有的复习记录会被跳过。只同步复习记录和排程,不同步翻译或标签。", "anki.sync.hosted_html": "如果 LWT 运行在其他服务器上,浏览器可能会请求访问本机 Anki 的权限,或直接阻止访问;为此网站允许 本地网络访问 即可解决。.apkg 文件往返 在任何环境下都可用。", + "anki.sync.writing": "正在写入 Anki:{done} / {total}", + "anki.sync.pushed_other": "已将 {count} 条 LWT 复习记录发送到 Anki", + "anki.sync.cards_other": "已为 {count} 张 Anki 卡片应用 LWT 的排程", + "anki.sync.fsrs_html": "如果在 Anki 中启用了 FSRS,Anki 还会为每张卡片保存自己的记忆估计值,而从 LWT 发送的复习不会更新它:AnkiConnect 无法写入该值。到期日期是正确的;若要让估计值也保持一致,请不时在 Anki 的牌组选项中运行 优化。", "flash.term_saved": "词条已保存", "flash.term_updated": "词条已更新", "flash.term_updated_short": "已更新", @@ -437,5 +441,5 @@ "titles.error": "错误", "titles.status_changed": "状态已更改", "titles.status_set": "状态已设置", - "titles.anki_sync": "从 Anki 同步复习记录" + "titles.anki_sync": "与 Anki 同步复习记录" } diff --git a/package.json b/package.json index f65d5ee1a..288df297a 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,9 @@ "e2e": "cypress run", "cy:open": "cypress open", "cy:run": "cypress run", + "anki:build": "podman build -t lwt-anki scripts/anki/container", + "anki:up": "mkdir -p cypress/anki-exchange && podman run --rm -d --name lwt-anki -p 127.0.0.1:8765:8765 -v ./cypress/anki-exchange:/exchange:Z lwt-anki", + "anki:down": "podman stop lwt-anki", "docs:dev": "node scripts/docs-pre-build.js && vitepress dev docs-src", "docs:build": "node scripts/docs-pre-build.js && vitepress build docs-src", "docs:preview": "vitepress preview docs-src" diff --git a/scripts/anki/container/Containerfile b/scripts/anki/container/Containerfile new file mode 100644 index 000000000..d803b994b --- /dev/null +++ b/scripts/anki/container/Containerfile @@ -0,0 +1,50 @@ +# Real Anki with AnkiConnect, headless, for testing LWT's Anki sync (#264). +# +# podman build -t lwt-anki scripts/anki/container +# podman run --rm -d --name lwt-anki -p 127.0.0.1:8765:8765 \ +# -v /tmp/lwt-anki-exchange:/exchange:Z lwt-anki +# +# Anki runs with Qt's offscreen platform, so no display is needed. Everything +# comes from first-party sources: Anki from PyPI (published by Ankitects) and +# AnkiConnect from AnkiWeb, the official add-on channel, checked against a +# pinned hash. /exchange is where tests put .apkg files for importPackage. +# +# Test-only: AnkiConnect binds to every interface inside the container and +# trusts LWT's dev-server origins, so never publish the port beyond loopback. + +FROM docker.io/library/python:3.12-slim-bookworm + +ARG ANKI_VERSION=26.9.3 +ARG ANKICONNECT_URL="https://ankiweb.net/shared/download/2055492159?v=2.1&p=260900" +ARG ANKICONNECT_SHA256=456a546fd9e1bf53962c70618f7d72d10b1768690ce88fa5c9c6909ae5e9e1ec + +# Qt 6's runtime libraries; the offscreen platform still loads GL and fonts. +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates curl \ + libgl1 libegl1 libopengl0 libxkbcommon0 libfontconfig1 libfreetype6 \ + libdbus-1-3 libglib2.0-0 libnss3 libasound2 libgbm1 libxshmfence1 \ + libxcomposite1 libxdamage1 libxrandr2 libxtst6 libxkbfile1 libxi6 \ + libxrender1 libxcursor1 libxcb-cursor0 fonts-dejavu-core \ + && rm -rf /var/lib/apt/lists/* + +RUN pip install --no-cache-dir "aqt[qt6]==${ANKI_VERSION}" + +RUN useradd --create-home anki && mkdir /exchange && chown anki /exchange +USER anki +ENV ANKI_BASE=/home/anki/anki \ + QT_QPA_PLATFORM=offscreen \ + QTWEBENGINE_CHROMIUM_FLAGS=--no-sandbox \ + PYTHONUNBUFFERED=1 + +RUN mkdir -p "$ANKI_BASE/addons21/2055492159" \ + && curl -fsSL -o /tmp/ankiconnect.zip "$ANKICONNECT_URL" \ + && echo "$ANKICONNECT_SHA256 /tmp/ankiconnect.zip" | sha256sum -c - \ + && python -m zipfile -e /tmp/ankiconnect.zip "$ANKI_BASE/addons21/2055492159" \ + && rm /tmp/ankiconnect.zip + +COPY --chown=anki ankiconnect-meta.json /home/anki/anki/addons21/2055492159/meta.json +COPY --chown=anki start.py /home/anki/start.py + +EXPOSE 8765 +CMD ["python", "/home/anki/start.py"] diff --git a/scripts/anki/container/ankiconnect-meta.json b/scripts/anki/container/ankiconnect-meta.json new file mode 100644 index 000000000..641bdb9f0 --- /dev/null +++ b/scripts/anki/container/ankiconnect-meta.json @@ -0,0 +1,14 @@ +{ + "config": { + "apiKey": null, + "apiLogPath": null, + "webBindAddress": "0.0.0.0", + "webBindPort": 8765, + "webCorsOriginList": [ + "http://localhost", + "http://localhost:8000", + "http://127.0.0.1:8000" + ], + "ignoreOriginList": [] + } +} diff --git a/scripts/anki/container/start.py b/scripts/anki/container/start.py new file mode 100644 index 000000000..581052c53 --- /dev/null +++ b/scripts/anki/container/start.py @@ -0,0 +1,62 @@ +"""Start headless Anki with one ready profile, so AnkiConnect comes up unattended. + +A first launch would stop at Anki's language and profile dialogs, which nobody +can answer offscreen. Creating the profile beforehand through Anki's own +ProfileManager skips them; after that this is a plain `anki -b BASE -p PROFILE`. +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +BASE = Path(os.environ.get("ANKI_BASE", "/home/anki/anki")) +PROFILE = os.environ.get("ANKI_PROFILE", "User 1") + + +def ensure_profile() -> None: + if (BASE / "prefs21.db").exists(): + return + + from aqt.profiles import ProfileManager + + manager = ProfileManager(BASE) + manager.setupMeta() + manager.setLang("en_US") + manager.create(PROFILE) + + +def set_scheduler() -> None: + """Switch FSRS on or off when ANKI_FSRS says so (1 / 0); leave it alone otherwise. + + Anki's default is off, and AnkiConnect cannot change it, so tests of the + FSRS case set it here, before Anki holds the collection open. + """ + wanted = os.environ.get("ANKI_FSRS") + if wanted not in ("0", "1"): + return + + from anki.collection import Collection + + (BASE / PROFILE).mkdir(parents=True, exist_ok=True) + col = Collection(str(BASE / PROFILE / "collection.anki2")) + try: + col.set_config("fsrs", wanted == "1") + finally: + col.close() + + +def main() -> None: + BASE.mkdir(parents=True, exist_ok=True) + ensure_profile() + set_scheduler() + + import aqt + + sys.argv = ["anki", "-b", str(BASE), "-p", PROFILE] + aqt.run() + + +if __name__ == "__main__": + main() diff --git a/src/Modules/Vocabulary/Application/Services/Anki/AnkiPushPlan.php b/src/Modules/Vocabulary/Application/Services/Anki/AnkiPushPlan.php new file mode 100644 index 000000000..817be8052 --- /dev/null +++ b/src/Modules/Vocabulary/Application/Services/Anki/AnkiPushPlan.php @@ -0,0 +1,210 @@ + + * @link https://github.com/HugoFara/lwt + */ + +declare(strict_types=1); + +namespace Lwt\Modules\Vocabulary\Application\Services\Anki; + +use DateTimeImmutable; +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; +use Lwt\Modules\Vocabulary\Domain\TermRepositoryInterface; +use Lwt\Modules\Vocabulary\Infrastructure\MySqlTermRepository; + +/** + * The server half of pushing reviews to Anki (#264, step 2). + * + * Runs after a pull, so LWT's history already holds every review from both + * apps. For each Anki card, the browser sends the reviews Anki has; this + * answers with what Anki is missing, as AnkiConnect calls for the browser to + * make: + * + * - `insertReviews` rows for LWT reviews absent from the card's revlog, + * shaped exactly as the .apkg exporter writes them (ApkgWriter); + * - the card's due date (`setDueDate`) and its interval, reps and lapses + * (`setSpecificValueOfCard`), so Anki's next session agrees with LWT. + * + * Measured against Anki 26.9 with AnkiConnect: `insertReviews` only appends + * history, leaving the card untouched, so the schedule has to be set + * separately; and AnkiConnect refuses to write FSRS memory state at all. With + * FSRS on, Anki's memory state therefore lags until the learner reoptimises, + * which recomputes it from the history this sends (see the sync doc). + * + * Cards whose history Anki already has get no action, so a second push right + * after the first plans nothing. + */ +final class AnkiPushPlan +{ + /** Anki's default ease factor, in permille, as ApkgWriter writes it. */ + private const DEFAULT_EASE_FACTOR = 2500; + + /** revlog.type for a review-stage answer, as ApkgWriter writes it. */ + private const REVLOG_TYPE_REVIEW = 1; + + /** Most cards accepted in one request. */ + public const MAX_CARDS = 2000; + + public function __construct( + private readonly TermRepositoryInterface $terms, + private readonly TermScheduleRepositoryInterface $schedules, + ) { + } + + public static function default(): self + { + return new self(new MySqlTermRepository(), new MySqlTermScheduleRepository()); + } + + /** + * Plan the writes that bring Anki up to LWT for these cards. + * + * @param list $cards Each `{term_id, card_id, reviews}`, where + * `reviews` is the card's revlog as + * `[[id, ease], ...]` (id in epoch ms) + * @param DateTimeImmutable|null $now Reference time for due dates + * + * @return array{ + * reviews: list>, + * cards: list, + * skipped: int + * } revlog rows for insertReviews (Anki's 9-integer shape), per-card + * schedule updates, and cards ignored (malformed, unknown, unschedulable) + */ + public function plan(array $cards, ?DateTimeImmutable $now = null): array + { + $now ??= new DateTimeImmutable(); + $plan = ['reviews' => [], 'cards' => [], 'skipped' => max(0, count($cards) - self::MAX_CARDS)]; + + foreach (array_map(self::parse(...), array_slice($cards, 0, self::MAX_CARDS)) as $card) { + if ($card === null) { + $plan['skipped']++; + continue; + } + [$termId, $cardId, $ankiKeys, $ankiIds] = $card; + + $term = $this->terms->find($termId); + if ($term === null || $term->status()->isSpecial()) { + $plan['skipped']++; + continue; + } + + $history = $this->schedules->historyFor([$termId])[$termId] ?? []; + $state = $this->schedules->find($termId); + if ($history === [] || $state === null) { + continue; + } + + $missing = 0; + $previousInterval = 0; + $lapses = 0; + foreach ($history as $entry) { + if ($entry->grade === Rating::Again) { + $lapses++; + } + $key = (new HistoricReview($entry->reviewedAt, $entry->grade))->key(); + if (!isset($ankiKeys[$key])) { + $id = $entry->reviewedAt->getTimestamp() * 1000; + while (isset($ankiIds[$id])) { + $id++; + } + $ankiIds[$id] = true; + $plan['reviews'][] = [ + $id, + $cardId, + -1, + $entry->grade->value, + $entry->scheduledDays, + $previousInterval, + self::DEFAULT_EASE_FACTOR, + 0, + self::REVLOG_TYPE_REVIEW, + ]; + $missing++; + } + $previousInterval = $entry->scheduledDays; + } + + if ($missing === 0) { + continue; + } + + $last = $history[count($history) - 1]; + $plan['cards'][] = [ + 'card_id' => $cardId, + 'due_days' => self::daysUntil($state->due, $now), + 'ivl' => max(1, $last->scheduledDays), + 'reps' => count($history), + 'lapses' => $lapses, + ]; + } + + return $plan; + } + + /** + * Whole days from today to $due, 0 for today or overdue. + * + * Counted in calendar days, as Anki's setDueDate counts them; Anki's own + * day starts at its rollover hour (4 a.m. by default), so a term due in + * the small hours may land a day apart. + */ + private static function daysUntil(DateTimeImmutable $due, DateTimeImmutable $now): int + { + $today = $now->setTime(0, 0); + $dueDay = $due->setTimezone($now->getTimezone())->setTime(0, 0); + + return max(0, (int) $today->diff($dueDay)->format('%r%a')); + } + + /** + * Read one posted card, or null if it is not one we can use. + * + * @return array{0: int, 1: int, 2: array, 3: array}|null + * Term id, card id, the card's review keys, and its revlog ids + */ + private static function parse(mixed $card): ?array + { + if (!is_array($card)) { + return null; + } + $termId = filter_var($card['term_id'] ?? null, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]); + $cardId = filter_var($card['card_id'] ?? null, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]); + $reviews = $card['reviews'] ?? []; + if ($termId === false || $cardId === false || !is_array($reviews)) { + return null; + } + + $keys = []; + $ids = []; + foreach ($reviews as $review) { + if (!is_array($review) || !isset($review[0], $review[1])) { + continue; + } + $id = filter_var($review[0], FILTER_VALIDATE_INT); + $ease = filter_var($review[1], FILTER_VALIDATE_INT); + if ($id === false || $ease === false) { + continue; + } + $ids[$id] = true; + $rating = Rating::tryFrom($ease); + if ($rating !== null) { + $at = new DateTimeImmutable('@' . intdiv($id, 1000)); + $keys[(new HistoricReview($at, $rating))->key()] = true; + } + } + + return [$termId, $cardId, $keys, $ids]; + } +} diff --git a/src/Modules/Vocabulary/Http/AnkiApiHandler.php b/src/Modules/Vocabulary/Http/AnkiApiHandler.php index a594872e7..a92ea274a 100644 --- a/src/Modules/Vocabulary/Http/AnkiApiHandler.php +++ b/src/Modules/Vocabulary/Http/AnkiApiHandler.php @@ -16,6 +16,7 @@ namespace Lwt\Modules\Vocabulary\Http; use Lwt\Api\V1\Response; +use Lwt\Modules\Vocabulary\Application\Services\Anki\AnkiPushPlan; use Lwt\Modules\Vocabulary\Application\Services\Anki\AnkiReviewPull; use Lwt\Shared\Http\ApiRoutableInterface; use Lwt\Shared\Infrastructure\Http\JsonResponse; @@ -27,14 +28,22 @@ * collection into LWT's schedules. Body: `{"reviews": [{"term_id": 12, * "reviewed_at": 1767225600000, "ease": 3}, ...]}`, at most * {@see AnkiReviewPull::MAX_REVIEWS} per request. + * + * POST /api/v1/anki/push-plan: after a pull, what to write to Anki so it has + * LWT's reviews and schedule. Body: `{"cards": [{"term_id": 12, "card_id": + * 1767..., "reviews": [[1767225600000, 3], ...]}, ...]}`, each card's revlog as + * Anki holds it. Answers with AnkiConnect-ready rows; the browser makes the + * calls ({@see AnkiPushPlan}). */ class AnkiApiHandler implements ApiRoutableInterface { private AnkiReviewPull $pull; + private AnkiPushPlan $push; - public function __construct(?AnkiReviewPull $pull = null) + public function __construct(?AnkiReviewPull $pull = null, ?AnkiPushPlan $push = null) { $this->pull = $pull ?? AnkiReviewPull::default(); + $this->push = $push ?? AnkiPushPlan::default(); } /** @@ -50,8 +59,17 @@ public function routeGet(array $fragments, array $params): JsonResponse */ public function routePost(array $fragments, array $params): JsonResponse { - if (($fragments[1] ?? '') !== 'reviews') { - return Response::error('Expected "reviews"', 404); + $route = $fragments[1] ?? ''; + if ($route === 'push-plan') { + $cards = $params['cards'] ?? null; + if (!is_array($cards) || !array_is_list($cards)) { + return Response::error('"cards" must be a list', 400); + } + + return Response::success($this->push->plan($cards)); + } + if ($route !== 'reviews') { + return Response::error('Expected "reviews" or "push-plan"', 404); } $reviews = $params['reviews'] ?? null; diff --git a/src/Modules/Vocabulary/Views/anki_sync.php b/src/Modules/Vocabulary/Views/anki_sync.php index a520edf3e..3970b519d 100644 --- a/src/Modules/Vocabulary/Views/anki_sync.php +++ b/src/Modules/Vocabulary/Views/anki_sync.php @@ -1,7 +1,7 @@
  • +
  • +
  • @@ -70,6 +72,9 @@
    +
    + +
    diff --git a/src/backend/Api/V1/Endpoints.php b/src/backend/Api/V1/Endpoints.php index 6809700ba..20d0efbff 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/push-plan' => ['POST'], 'anki/reviews' => ['POST'], 'languages' => ['GET', 'POST', 'PUT', 'DELETE'], 'languages/definitions' => ['GET'], diff --git a/src/frontend/js/modules/vocabulary/pages/anki_sync.ts b/src/frontend/js/modules/vocabulary/pages/anki_sync.ts index 35f0f4ffa..d4c996e88 100644 --- a/src/frontend/js/modules/vocabulary/pages/anki_sync.ts +++ b/src/frontend/js/modules/vocabulary/pages/anki_sync.ts @@ -1,10 +1,11 @@ /** - * The "pull reviews from Anki" page (#264, step 1). + * The "sync reviews with Anki" page (#264, steps 1 and 2). * * 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. + * AnkiConnect and posts the reviews to LWT, which merges them into each + * term's history and drops any it already has. LWT then answers with what + * Anki is missing -- its own reviews and the resulting schedule -- and this + * writes that to Anki. Running it twice in a row changes nothing. * * @license Unlicense * @since 3.8.0 @@ -14,7 +15,13 @@ 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'; +import { + AnkiConnect, + AnkiConnectError, + type CardHistory, + type PulledReview, + type PushPlan, +} from '../services/anki_connect'; interface AnkiSyncConfig { ankiConnectUrl: string; @@ -32,8 +39,36 @@ export interface PullSummary { skipped: number; } +/** What the sync did in both directions. */ +export interface SyncSummary extends PullSummary { + /** LWT reviews written to Anki. */ + pushed: number; + /** Anki cards given LWT's schedule. */ + cardsRescheduled: number; +} + const EMPTY: PullSummary = { terms: 0, applied: 0, duplicates: 0, rebuilt: 0, skipped: 0 }; +/** Cards per push-plan request, matching AnkiPushPlan::MAX_CARDS. */ +const PLAN_BATCH = 2000; + +/** + * Ask LWT what Anki is missing, in batches, as one plan. + */ +export async function planPush(cards: CardHistory[]): Promise { + const plan: PushPlan = { reviews: [], cards: [], skipped: 0 }; + for (let i = 0; i < cards.length; i += PLAN_BATCH) { + const response = await apiPost('/anki/push-plan', { cards: cards.slice(i, i + PLAN_BATCH) }); + if (response.error || !response.data) { + throw new Error(response.error ?? 'Empty response'); + } + plan.reviews.push(...response.data.reviews); + plan.cards.push(...response.data.cards); + plan.skipped += response.data.skipped; + } + return plan; +} + /** * Post reviews to LWT in batches and add up what it did. */ @@ -70,7 +105,7 @@ export function ankiSync() { busy: false, progress: '', error: '', - summary: null as PullSummary | null, + summary: null as SyncSummary | null, async pull(): Promise { this.busy = true; @@ -84,13 +119,22 @@ export function ankiSync() { throw new AnkiConnectError(t('vocabulary.anki.sync.error_denied')); } - const reviews = await anki.pullReviews(config.noteType, config.idField, (step, done, total) => { + const { reviews, cards } = 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) => { + // Anki -> LWT first, so the plan below is computed from both histories. + const pulled = await sendReviews(reviews, config.batchSize, (done, total) => { this.progress = t('vocabulary.anki.sync.sending', { done, total }); }); + + // LWT -> Anki: the reviews Anki lacks, and the schedule they lead to. + const plan = await planPush(cards); + const written = await anki.applyPushPlan(plan, (done, total) => { + this.progress = t('vocabulary.anki.sync.writing', { done, total }); + }); + + this.summary = { ...pulled, pushed: written.reviews, cardsRescheduled: written.cards }; this.progress = ''; } catch (err) { this.progress = ''; @@ -114,6 +158,12 @@ export function ankiSync() { rebuiltLabel(): string { return tn('vocabulary.anki.sync.rebuilt', this.summary?.rebuilt ?? 0); }, + pushedLabel(): string { + return tn('vocabulary.anki.sync.pushed', this.summary?.pushed ?? 0); + }, + cardsLabel(): string { + return tn('vocabulary.anki.sync.cards', this.summary?.cardsRescheduled ?? 0); + }, skippedLabel(): string { return tn('vocabulary.anki.sync.skipped', this.summary?.skipped ?? 0); }, diff --git a/src/frontend/js/modules/vocabulary/services/anki_connect.ts b/src/frontend/js/modules/vocabulary/services/anki_connect.ts index 6dbd841e3..a6052b1be 100644 --- a/src/frontend/js/modules/vocabulary/services/anki_connect.ts +++ b/src/frontend/js/modules/vocabulary/services/anki_connect.ts @@ -58,6 +58,21 @@ export interface PulledReview { ease: number; } +/** One Anki card of an LWT term, with its revlog as `[id, ease]` pairs. */ +export interface CardHistory { + term_id: number; + card_id: number; + reviews: [number, number][]; +} + +/** What LWT asks to be written to Anki (POST /api/v1/anki/push-plan). */ +export interface PushPlan { + /** revlog rows for insertReviews, Anki's nine-integer shape. */ + reviews: number[][]; + cards: { card_id: number; due_days: number; ivl: number; reps: number; lapses: number }[]; + skipped: number; +} + export class AnkiConnect { constructor(private readonly url: string) {} @@ -98,7 +113,10 @@ export class AnkiConnect { } /** - * Every review of every LWT note in the collection, as rows for LWT. + * Every review of every LWT note in the collection. + * + * Returned twice over: as rows for LWT to merge, and per card with every + * revlog row (answers or not), which the push plan compares against. * * @param noteType Anki note type LWT exports ("LWT Term") * @param idField Field carrying the LWT term id ("LwtId") @@ -108,7 +126,7 @@ export class AnkiConnect { noteType: string, idField: string, onProgress: (step: 'notes' | 'reviews', done: number, total: number) => void = () => {} - ): Promise { + ): Promise<{ reviews: PulledReview[]; cards: CardHistory[] }> { const noteIds = await this.invoke('findNotes', { query: `"note:${noteType.replace(/"/g, '\\"')}"`, }); @@ -129,6 +147,10 @@ export class AnkiConnect { const cards = [...termByCard.keys()]; const reviews: PulledReview[] = []; + const histories = new Map(); + for (const [card, termId] of termByCard) { + histories.set(card, { term_id: termId, card_id: card, reviews: [] }); + } for (let i = 0; i < cards.length; i += CHUNK) { const byCard = await this.invoke>('getReviewsOfCards', { cards: cards.slice(i, i + CHUNK).map(String), @@ -137,6 +159,7 @@ export class AnkiConnect { const termId = termByCard.get(Number(card)); if (termId === undefined) continue; for (const entry of entries) { + histories.get(Number(card))?.reviews.push([entry.id, entry.ease]); // 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 }); @@ -146,6 +169,61 @@ export class AnkiConnect { onProgress('reviews', Math.min(i + CHUNK, cards.length), cards.length); } - return reviews; + return { reviews, cards: [...histories.values()] }; + } + + /** + * Write what LWT planned: missing reviews, then each card's schedule. + * + * insertReviews refuses a whole batch if one id already exists, so the plan + * only ever carries ids Anki does not have. + * + * @returns How many reviews and cards were written + */ + async applyPushPlan( + plan: PushPlan, + onProgress: (done: number, total: number) => void = () => {} + ): Promise<{ reviews: number; cards: number }> { + const total = plan.reviews.length + plan.cards.length; + for (let i = 0; i < plan.reviews.length; i += CHUNK) { + await this.invoke('insertReviews', { reviews: plan.reviews.slice(i, i + CHUNK) }); + onProgress(Math.min(i + CHUNK, plan.reviews.length), total); + } + + // setDueDate takes one day count for many cards. + const byDays = new Map(); + for (const card of plan.cards) { + byDays.set(card.due_days, [...(byDays.get(card.due_days) ?? []), card.card_id]); + } + for (const [days, cards] of byDays) { + await this.invoke('setDueDate', { cards, days: String(days) }); + } + + // Interval, reps and lapses are protected fields: AnkiConnect wants + // warning_check to write them. + // Each answer inside multi is its own {result, error}, and this action + // reports a refused write as [[false, reason]] rather than as an error. + for (let i = 0; i < plan.cards.length; i += CHUNK) { + const replies = await this.invoke[]>('multi', { + actions: plan.cards.slice(i, i + CHUNK).map((card) => ({ + action: 'setSpecificValueOfCard', + params: { + card: card.card_id, + keys: ['ivl', 'reps', 'lapses'], + newValues: [card.ivl, card.reps, card.lapses], + warning_check: true, + }, + })), + }); + for (const reply of replies) { + const refused = Array.isArray(reply.result) && Array.isArray(reply.result[0]) ? reply.result[0][1] : null; + if (reply.error || refused) { + throw new AnkiConnectError(String(reply.error ?? refused)); + } + } + onProgress(plan.reviews.length + Math.min(i + CHUNK, plan.cards.length), total); + } + + return { reviews: plan.reviews.length, cards: plan.cards.length }; } } diff --git a/tests/backend/Modules/Vocabulary/Application/Services/Anki/AnkiPushPlanTest.php b/tests/backend/Modules/Vocabulary/Application/Services/Anki/AnkiPushPlanTest.php new file mode 100644 index 000000000..a981342fc --- /dev/null +++ b/tests/backend/Modules/Vocabulary/Application/Services/Anki/AnkiPushPlanTest.php @@ -0,0 +1,182 @@ +schedules = new InMemoryTermScheduleRepository(); + } + + public function testReviewsAnkiLacksBecomeRevlogRowsShapedLikeTheExport(): void + { + $this->reviewedHere(7, ['2026-02-01 10:00:00' => Rating::Good, '2026-02-04 10:00:00' => Rating::Again]); + + $plan = $this->planFor([7 => TermStatus::LEARNING_3])->plan( + [['term_id' => 7, 'card_id' => self::CARD, 'reviews' => []]], + new DateTimeImmutable('2026-02-04 12:00:00') + ); + + self::assertCount(2, $plan['reviews']); + [$first, $second] = $plan['reviews']; + $history = $this->schedules->history[7]; + // id, cid, usn, ease, ivl, lastIvl, factor, time, type + self::assertSame( + [(new DateTimeImmutable('2026-02-01 10:00:00'))->getTimestamp() * 1000, self::CARD, -1, 3, + $history[0]->scheduledDays, 0, 2500, 0, 1], + $first + ); + self::assertSame(1, $second[3]); + self::assertSame($history[0]->scheduledDays, $second[5], 'lastIvl is the previous interval'); + } + + public function testTheCardGetsLwtsScheduleAfterThePush(): void + { + $this->reviewedHere(7, ['2026-02-01 10:00:00' => Rating::Good, '2026-02-04 10:00:00' => Rating::Again]); + $state = $this->schedules->states[7]; + + $plan = $this->planFor([7 => TermStatus::LEARNING_3])->plan( + [['term_id' => 7, 'card_id' => self::CARD, 'reviews' => []]], + new DateTimeImmutable('2026-02-04 12:00:00') + ); + + self::assertSame([[ + 'card_id' => self::CARD, + 'due_days' => (int) (new DateTimeImmutable('2026-02-04'))->diff($state->due->setTime(0, 0))->format('%a'), + 'ivl' => $this->schedules->history[7][1]->scheduledDays, + 'reps' => 2, + 'lapses' => 1, + ]], $plan['cards']); + } + + public function testOnlyTheReviewsAnkiDoesNotHaveAreSent(): void + { + $this->reviewedHere(7, ['2026-02-01 10:00:00' => Rating::Good, '2026-02-04 10:00:00' => Rating::Good]); + $ankiHas = [[(new DateTimeImmutable('2026-02-01 10:00:00'))->getTimestamp() * 1000 + 512, 3]]; + + $plan = $this->planFor([7 => TermStatus::LEARNING_3])->plan( + [['term_id' => 7, 'card_id' => self::CARD, 'reviews' => $ankiHas]] + ); + + self::assertCount(1, $plan['reviews']); + self::assertSame((new DateTimeImmutable('2026-02-04 10:00:00'))->getTimestamp() * 1000, $plan['reviews'][0][0]); + } + + public function testACardAnkiIsUpToDateOnPlansNothing(): void + { + $this->reviewedHere(7, ['2026-02-01 10:00:00' => Rating::Good]); + $ankiHas = [[(new DateTimeImmutable('2026-02-01 10:00:00'))->getTimestamp() * 1000, 3]]; + + $plan = $this->planFor([7 => TermStatus::LEARNING_3])->plan( + [['term_id' => 7, 'card_id' => self::CARD, 'reviews' => $ankiHas]] + ); + + self::assertSame(['reviews' => [], 'cards' => [], 'skipped' => 0], $plan); + } + + public function testARevlogIdAlreadyTakenInAnkiIsSteppedPast(): void + { + // A different review of Anki's in the same millisecond as ours would + // make insertReviews fail the whole batch. + $this->reviewedHere(7, ['2026-02-01 10:00:00' => Rating::Good]); + $taken = (new DateTimeImmutable('2026-02-01 10:00:00'))->getTimestamp() * 1000; + + $plan = $this->planFor([7 => TermStatus::LEARNING_3])->plan( + [['term_id' => 7, 'card_id' => self::CARD, 'reviews' => [[$taken, 1]]]] + ); + + self::assertSame($taken + 1, $plan['reviews'][0][0]); + } + + public function testUnknownUnschedulableAndMalformedCardsAreSkipped(): void + { + $plan = $this->planFor([1 => TermStatus::IGNORED])->plan([ + ['term_id' => 1, 'card_id' => self::CARD, 'reviews' => []], + ['term_id' => 99, 'card_id' => self::CARD, 'reviews' => []], + ['term_id' => 'x', 'card_id' => self::CARD], + 'nope', + ]); + + self::assertSame(4, $plan['skipped']); + self::assertSame([], $plan['reviews']); + } + + public function testATermNeverReviewedInLwtHasNothingToSend(): void + { + $plan = $this->planFor([7 => TermStatus::LEARNING_3])->plan( + [['term_id' => 7, 'card_id' => self::CARD, 'reviews' => []]] + ); + + self::assertSame(['reviews' => [], 'cards' => [], 'skipped' => 0], $plan); + } + + /** + * @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)); + } + } + + /** + * @param array $statusByTermId + */ + private function planFor(array $statusByTermId): AnkiPushPlan + { + $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 AnkiPushPlan($terms, $this->schedules); + } +} diff --git a/tests/frontend/vocabulary/anki_sync.test.ts b/tests/frontend/vocabulary/anki_sync.test.ts index 9524c22f8..d3ab65340 100644 --- a/tests/frontend/vocabulary/anki_sync.test.ts +++ b/tests/frontend/vocabulary/anki_sync.test.ts @@ -9,7 +9,7 @@ vi.mock('@shared/api/client', () => ({ apiPost: (...args: unknown[]) => apiPost( 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'; +import { planPush, sendReviews } from '../../../src/frontend/js/modules/vocabulary/pages/anki_sync'; const originalFetch = global.fetch; @@ -49,8 +49,13 @@ describe('AnkiConnect client', () => { }), }); - const reviews = await new AnkiConnect('http://127.0.0.1:8765').pullReviews('LWT Term', 'LwtId'); + const { reviews, cards } = await new AnkiConnect('http://127.0.0.1:8765').pullReviews('LWT Term', 'LwtId'); + // Every revlog row per card, answers or not: the push plan needs them all. + expect(cards).toEqual([ + { term_id: 7, card_id: 1000, reviews: [[1770000000000, 3], [1770086400000, 0]] }, + { term_id: 8, card_id: 1001, reviews: [[1770000500000, 1]] }, + ]); expect(reviews).toEqual([ { term_id: 7, reviewed_at: 1770000000000, ease: 3 }, { term_id: 8, reviewed_at: 1770000500000, ease: 1 }, @@ -119,3 +124,59 @@ describe('sendReviews', () => { await expect(sendReviews([{ term_id: 7, reviewed_at: 1, ease: 3 }], 10)).rejects.toThrow('Forbidden'); }); }); + +describe('pushing to Anki', () => { + afterEach(() => { + global.fetch = originalFetch; + }); + + it('writes reviews, then due dates grouped by day, then card values', async () => { + const calls = ankiAnswering({ + insertReviews: () => null, + setDueDate: () => true, + multi: (params) => (params.actions as unknown[]).map(() => ({ result: [true], error: null })), + }); + + const written = await new AnkiConnect('http://127.0.0.1:8765').applyPushPlan({ + reviews: [[1770000000000, 1000, -1, 3, 2, 0, 2500, 0, 1]], + cards: [ + { card_id: 1000, due_days: 2, ivl: 2, reps: 1, lapses: 0 }, + { card_id: 1001, due_days: 2, ivl: 3, reps: 2, lapses: 1 }, + { card_id: 1002, due_days: 9, ivl: 9, reps: 3, lapses: 0 }, + ], + skipped: 0, + }); + + expect(written).toEqual({ reviews: 1, cards: 3 }); + expect(calls.map((c) => c.action)).toEqual(['insertReviews', 'setDueDate', 'setDueDate', 'multi']); + expect(calls[1].params).toEqual({ cards: [1000, 1001], days: '2' }); + const first = (calls[3].params.actions as { params: Record }[])[0].params; + expect(first).toEqual({ card: 1000, keys: ['ivl', 'reps', 'lapses'], newValues: [2, 1, 0], warning_check: true }); + }); + + it('fails when Anki refuses a card value', async () => { + ankiAnswering({ + setDueDate: () => true, + multi: () => [{ result: [[false, 'Use native scheduling commands']], error: null }], + }); + + await expect(new AnkiConnect('http://127.0.0.1:8765').applyPushPlan({ + reviews: [], + cards: [{ card_id: 1000, due_days: 2, ivl: 2, reps: 1, lapses: 0 }], + skipped: 0, + })).rejects.toThrow('Use native scheduling commands'); + }); + + it('asks LWT for the plan in batches and joins them', async () => { + apiPost.mockReset(); + apiPost.mockResolvedValue({ data: { reviews: [[1, 2, -1, 3, 1, 0, 2500, 0, 1]], cards: [], skipped: 1 } }); + const cards = Array.from({ length: 2001 }, (_, i) => ({ term_id: i + 1, card_id: i + 1000, reviews: [] as [number, number][] })); + + const plan = await planPush(cards); + + expect(apiPost).toHaveBeenCalledTimes(2); + expect(apiPost.mock.calls[0][0]).toBe('/anki/push-plan'); + expect(plan.reviews).toHaveLength(2); + expect(plan.skipped).toBe(2); + }); +});