diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fbfc7a..e55b813 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ Conventional Commits by release-please. ## Unreleased +- Fixing a word's language no longer makes the word disappear when the model answers with the + same word in the same language ("Already in your list"): the word stays in your list. Before, + it went to Recently deleted. + - Privacy policy version 6: "What websites can see" now names every way a page can hide text that Kotiko still swaps (a transparent text colour, text turned away), and says a disguised word's card can open on a click as well as a resting pointer. diff --git a/README.md b/README.md index 83c5821..ce99eba 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ data folders and the service file. ## Privacy The text of the pages you read never leaves your browser. A word you add goes only to the AI -service you chose, or to your own server. Kotiko has no servers of its own and collects +service you chose, or to your own server, and, for its pronunciation, to English Wiktionary. Kotiko has no servers of its own and collects nothing. The [privacy policy](https://kotiko.org/privacy/) (its source is [docs/privacy/en.md](docs/privacy/en.md)) says exactly what is kept and sent, and [docs/privacy/inventory.md](docs/privacy/inventory.md) lists every request with the code diff --git a/docs/security/review-v1.0.0.md b/docs/security/review-v1.0.0.md index 53cebf7..56b88a7 100644 --- a/docs/security/review-v1.0.0.md +++ b/docs/security/review-v1.0.0.md @@ -134,8 +134,11 @@ rc.4 zips rebuild byte for byte from the tag. with issues: SCR-836 (treat a transparent text colour and text turned away as hidden) and SCR-837 (read the report the way a CommonMark renderer shows it). The privacy policy is now version 6, which names every way a page can hide text that Kotiko still swaps. -v1.0.0 differs from rc.4 only in documentation (this report, the privacy policy's wording -and other docs) and the release notes. +v1.0.0 differs from rc.4 in documentation (this report, the privacy policy's wording and +other docs), the release notes, and one fix from the launch checks that touches no security +control: fixing a word's language no longer deletes the word when the new add lands on it +(`retireReplaced` in `extension/background.js` keeps that word; launch check F1, with a +regression test). One slip: a misquoted test setting sent two of F's test lookups ("shukran", "gato") to OpenRouter's public endpoint, with no key, token or identity. OpenRouter refused them diff --git a/extension/background.js b/extension/background.js index f6fd3b8..60ed3db 100644 --- a/extension/background.js +++ b/extension/background.js @@ -1995,7 +1995,10 @@ async function relang(m) { async function retireReplaced(job) { const { id, key } = job.replaces; - const res = await undoWord(id, key); + // The new add can land on the very word the old one made ("Already in your list", when the + // model answers the same word in the same language): that word stays. + const keep = new Set((job.results ?? []).map((r) => r.wordId).filter(Boolean)); + const res = await undoWord(id, key, { keep }); if (res.ok) await queue.patch(id, { replacedBy: job.id }); } @@ -2065,8 +2068,8 @@ const jobWords = async (id, key) => { }; const writeOps = async (ops) => (await wordRoutes["words.write"].run({ ops })).results; -async function undoWord(id, key) { - const records = (await jobWords(id, key)).filter((r) => r.result !== "unchanged" && r.undo !== "done"); +async function undoWord(id, key, { keep = new Set() } = {}) { + const records = (await jobWords(id, key)).filter((r) => r.result !== "unchanged" && r.undo !== "done" && !keep.has(r.wordId)); if (!records.length) return { ok: true }; await queue.setUndo(id, key, { undo: "pending", undoError: null }); const ops = records.map((r) => diff --git a/site/src/content/docs/index.mdx b/site/src/content/docs/index.mdx index e2d2974..76c1ce7 100644 --- a/site/src/content/docs/index.mdx +++ b/site/src/content/docs/index.mdx @@ -48,7 +48,7 @@ Kotiko never assumes you read English. - Write `perro = dog` and it's yours. No account, no key, nothing leaves your browser. + Write `perro = dog` and it's yours. No account, no key, no AI service. With a free OpenRouter key, ask in your own words: "how do you say cat in Japanese". @@ -71,7 +71,8 @@ Until the store listings are live, you can [install Kotiko from its source](/ins ## Private by design - The text of the pages you read never leaves your browser. -- A word you add goes only to the AI service you chose, or to your own server. +- A word you add goes only to the AI service you chose, or to your own server, and, for its + pronunciation, to English Wiktionary. - Kotiko has no servers of its own, no accounts, no ads and no tracking. Neither does this site. Read the [privacy policy](/privacy/): it takes about two minutes. diff --git a/site/src/content/docs/start.md b/site/src/content/docs/start.md index 02fbc8d..0b24ef8 100644 --- a/site/src/content/docs/start.md +++ b/site/src/content/docs/start.md @@ -23,7 +23,9 @@ Pick one of three ways. You can change it at any time in **Settings**, **Word lo ### No key: type words with their meaning Select **Skip: I'll type meanings myself**. You add words as `word = meaning`, like -`perro = dog` or `спасибо (spasibo) = thanks`. Nothing leaves your browser. This always works, +`perro = dog` or `спасибо (spasibo) = thanks`. No AI service is asked. For a language with +word stress, such as Russian or Spanish, Kotiko looks up only the word on English +Wiktionary to get its pronunciation right ([privacy policy](/privacy/)). This always works, even with a key set up. ### A free OpenRouter key (recommended) diff --git a/test/bg/local-mode.test.mjs b/test/bg/local-mode.test.mjs index ae9f4e6..2f43933 100644 --- a/test/bg/local-mode.test.mjs +++ b/test/bg/local-mode.test.mjs @@ -128,6 +128,22 @@ describe("a fresh install keeps words in this browser (slice 11)", () => { assert.equal(bg.store.addJobs.find((j) => j.id === first.job.id).replacedBy, null); }); + // Launch check F1: the model can answer the same word in the same language ("Already in + // your list"); retiring the old add must not delete the word the new one landed on. + test("another language that comes back as the same word keeps the word", async () => { + const bg = loadBackground({ local: { baseLangs: ["en"] } }); + await bg.k.ready(); + const first = await bg.send({ type: "add", text: "gato = cat", hintLang: "es" }, POPUP); + await bg.until(() => bg.store.addJobs?.find((j) => j.id === first.job.id && j.state === "done")); + const relang = await bg.send({ type: "jobs.relang", id: first.job.id, key: "es\u001fgato", lang: "es" }, POPUP); + assert.equal(relang.ok, true); + const next = await bg.until(() => bg.store.addJobs?.find((j) => j.id === relang.newId && j.state === "done")); + assert.equal(next.results[0].result, "unchanged", "the new add found the same word"); + await bg.until(() => bg.store.addJobs?.find((j) => j.id === first.job.id && j.replacedBy === relang.newId)); + const active = (await (await bg.k.getStore()).list()).map((w) => `${w.lang}:${w.native}`); + assert.deepEqual(active, ["es:gato"], "the word is still in the list"); + }); + test("Focus on one language decides a 'native = meaning' word's language", async () => { const bg = loadBackground({ local: { baseLangs: ["en"] } }); await bg.k.ready();