Answering "can we ask Stalwart to serve German folder names": no, and it would not help if we could. The account locale exists in `x:AccountSettings`, and ihasmail already reads it -- that is what "Your mail server reports German" comes from -- but writing it needs `sysAccountSettingsSet`, which the built-in user role does not carry; only an admin could. And even then it would change nothing, because folder names are stored data written once when the account is provisioned. No server renames them afterwards; every other client has them mapped. The role is the way through. JMAP tags the standard folders and ihasmail already trusts the role over the name everywhere it matters, so the *displayed* name can follow the interface language with nothing written to the server. A folder somebody made and called "Newsletters" keeps that name: those are their words, and translating them would name a folder they never created. The cost is real and worth stating: Thunderbird on the same account still shows "Deleted Items", because that is what the folder is called. Inside ihasmail it stays consistent -- everything that names a folder goes through one function, including the "moved to …" toast, which exists precisely so that message does not name somewhere the reader cannot find. Renaming still edits the server's own name, never the localised one. Three things fell out of it. The message list refreshed for ever after a language change, which is the one somebody noticed. The root keys its tree on the language version, so a publish remounts everything; remounting re-runs the effect that loads the account's settings, which calls applyLang, which called setCatalog again -- with an identical tag and an identical catalogue -- and publishing that non-change went round again. setCatalog now returns early when nothing changed. Measured rather than assumed: three consecutive five-second windows with no JMAP calls at all, against a pre-change count that never settled. Calendar months and weekdays stayed English, because formatting locale and interface language are separate settings and only the first feeds Intl. Keeping them separate is right -- German dates with an English interface is a real preference -- but somebody who picks German and is shown "September" has not got what they asked for. A chosen interface language now joins the *automatic* chain ahead of the server and the browser. Setting a formatting locale explicitly still wins, and English is not counted, so an English interface on a German browser keeps German dates exactly as before. And the Archive folder read "Archivieren", which is the verb. English uses one word for the button and the folder; German does not, and neither does "Important", which is also a priority tag. tc(context, source) keys the catalogue on both and falls back to the plain English, which was right in English all along -- the gettext approach, including the control character as separator so no real string can collide. The catalogue checker needed teaching about tc() twice: first it reported the eight contextual entries as stale, then it asked for the plain fallbacks as though they were a second obligation. A check that reports work which does not exist gets switched off, which is worse than not having one.
153 lines
5.9 KiB
TypeScript
153 lines
5.9 KiB
TypeScript
import { afterEach, describe, expect, it } from "vitest";
|
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
import { CONTEXT_SEPARATOR, currentLanguage, interpolate, plural, setCatalog, subscribeForTest, t, tc, tNode, type Catalog } from "@/lib/i18n";
|
|
|
|
const de: Catalog = {
|
|
strings: {
|
|
"Archive": "Archivieren",
|
|
"Move {n} to {folder}": "{n} nach {folder} verschieben",
|
|
// German puts the parts in a different order, which is the whole reason
|
|
// the element is a named hole rather than a split sentence.
|
|
"Open {scheme} links here": "{scheme}-Links hier öffnen",
|
|
},
|
|
plurals: { "{n} messages": { one: "{n} Nachricht", other: "{n} Nachrichten" } },
|
|
};
|
|
/* Russian is the reason plural() does not take (one, other): it needs three
|
|
forms, and which one applies is not a question about the number 1. */
|
|
const ru: Catalog = {
|
|
strings: {},
|
|
plurals: { "{n} messages": { one: "{n} сообщение", few: "{n} сообщения", many: "{n} сообщений", other: "{n} сообщения" } },
|
|
};
|
|
|
|
afterEach(() => setCatalog("en", { strings: {}, plurals: {} }));
|
|
|
|
describe("t", () => {
|
|
it("returns the English it was given when nothing is loaded", () => {
|
|
// The whole point of English-as-key: a missing translation degrades to
|
|
// readable English rather than to a symbolic name leaking into the UI.
|
|
expect(t("Archive")).toBe("Archive");
|
|
expect(currentLanguage()).toBe("en");
|
|
});
|
|
|
|
it("translates once a catalogue is in force", () => {
|
|
setCatalog("de", de);
|
|
expect(t("Archive")).toBe("Archivieren");
|
|
});
|
|
|
|
it("falls back per string, not per catalogue", () => {
|
|
setCatalog("de", de);
|
|
expect(t("Report spam")).toBe("Report spam");
|
|
});
|
|
});
|
|
|
|
describe("interpolation", () => {
|
|
it("fills named placeholders", () => {
|
|
expect(interpolate("Move {n} to {folder}", { n: 3, folder: "Archive" })).toBe("Move 3 to Archive");
|
|
});
|
|
|
|
it("survives a translator reordering the sentence", () => {
|
|
// Positional arguments would not: German moves the parts around and means
|
|
// the same thing.
|
|
setCatalog("de", de);
|
|
expect(t("Move {n} to {folder}", { n: 3, folder: "Archiv" })).toBe("3 nach Archiv verschieben");
|
|
});
|
|
|
|
it("leaves an unknown placeholder alone rather than printing undefined", () => {
|
|
expect(interpolate("Hello {who}", {})).toBe("Hello {who}");
|
|
});
|
|
});
|
|
|
|
describe("plural", () => {
|
|
const FORMS = { one: "{n} message", other: "{n} messages" };
|
|
|
|
it("picks the English form without a catalogue", () => {
|
|
expect(plural(1, FORMS)).toBe("1 message");
|
|
expect(plural(0, FORMS)).toBe("0 messages");
|
|
expect(plural(5, FORMS)).toBe("5 messages");
|
|
});
|
|
|
|
it("uses the target language's own rule, not English's", () => {
|
|
setCatalog("ru", ru);
|
|
expect(plural(1, FORMS)).toBe("1 сообщение"); // one
|
|
expect(plural(3, FORMS)).toBe("3 сообщения"); // few
|
|
expect(plural(7, FORMS)).toBe("7 сообщений"); // many
|
|
});
|
|
|
|
it("falls back to `other` when the catalogue lacks the category", () => {
|
|
setCatalog("de", de);
|
|
// German has no "few"; asking for 3 must not render undefined.
|
|
expect(plural(3, FORMS)).toBe("3 Nachrichten");
|
|
});
|
|
|
|
it("takes extra variables alongside the count", () => {
|
|
expect(plural(2, { one: "{n} message in {folder}", other: "{n} messages in {folder}" }, { folder: "Inbox" }))
|
|
.toBe("2 messages in Inbox");
|
|
});
|
|
});
|
|
|
|
describe("tNode", () => {
|
|
const render = (node: React.ReactNode) => renderToStaticMarkup(<>{node}</>);
|
|
|
|
it("keeps an element inside the sentence", () => {
|
|
expect(render(tNode("Open {scheme} links here", { scheme: <code>mailto:</code> })))
|
|
.toBe("Open <code>mailto:</code> links here");
|
|
});
|
|
|
|
it("lets a translator move the element", () => {
|
|
// Splitting the sentence into two t() calls could not do this: the
|
|
// fragments would render in the English order whatever the catalogue said.
|
|
setCatalog("de", de);
|
|
expect(render(tNode("Open {scheme} links here", { scheme: <code>mailto:</code> })))
|
|
.toBe("<code>mailto:</code>-Links hier öffnen");
|
|
});
|
|
|
|
it("leaves a placeholder alone when nothing is supplied for it", () => {
|
|
expect(render(tNode("Open {scheme} links here", {}))).toBe("Open {scheme} links here");
|
|
});
|
|
|
|
it("takes plain variables alongside elements", () => {
|
|
expect(render(tNode("{count} of {scheme}", { scheme: <b>x</b> }, { count: 3 }))).toBe("3 of <b>x</b>");
|
|
});
|
|
});
|
|
|
|
describe("setCatalog", () => {
|
|
it("does not announce a change that did not happen", () => {
|
|
/*
|
|
* The root keys its tree on the language version, so every publish
|
|
* remounts the app -- which re-runs the effect that loads the account's
|
|
* settings, which calls applyLang, which lands back in setCatalog with the
|
|
* same language. Publishing that non-change looped for ever, and from the
|
|
* outside it looked like the message list refreshing without end.
|
|
*/
|
|
const seen: number[] = [];
|
|
const stop = subscribeForTest(() => seen.push(1));
|
|
const cat: Catalog = { strings: { Archive: "Archivieren" }, plurals: {} };
|
|
setCatalog("de", cat);
|
|
setCatalog("de", cat);
|
|
setCatalog("de", cat);
|
|
expect(seen.length).toBe(1);
|
|
setCatalog("en", { strings: {}, plurals: {} });
|
|
expect(seen.length).toBe(2);
|
|
stop();
|
|
});
|
|
});
|
|
|
|
describe("tc", () => {
|
|
it("tells apart an English word doing two jobs", () => {
|
|
// "Archive" is the button and the folder; German wants a different word
|
|
// for each, and one key cannot hold both.
|
|
setCatalog("de", {
|
|
strings: { "Archive": "Archivieren", [`folder${CONTEXT_SEPARATOR}Archive`]: "Archiv" },
|
|
plurals: {},
|
|
});
|
|
expect(t("Archive")).toBe("Archivieren");
|
|
expect(tc("folder", "Archive")).toBe("Archiv");
|
|
});
|
|
|
|
it("falls back to the plain translation, then to English", () => {
|
|
setCatalog("de", { strings: { "Drafts": "Entwürfe" }, plurals: {} });
|
|
expect(tc("folder", "Drafts")).toBe("Entwürfe"); // no context entry yet
|
|
expect(tc("folder", "Sent")).toBe("Sent"); // nothing at all
|
|
});
|
|
});
|