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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions docs/security/review-v1.0.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 6 additions & 3 deletions extension/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}

Expand Down Expand Up @@ -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) =>
Expand Down
5 changes: 3 additions & 2 deletions site/src/content/docs/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ Kotiko never assumes you read English.

<CardGrid>
<Card title="Type a word and its meaning" icon="pencil">
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.
</Card>
<Card title="Connect a free AI key" icon="rocket">
With a free OpenRouter key, ask in your own words: "how do you say cat in Japanese".
Expand All @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion site/src/content/docs/start.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
16 changes: 16 additions & 0 deletions test/bg/local-mode.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down