diff --git a/package.json b/package.json index df65d48..82e0a57 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "mock": "npm run mock -w server", "dev:mock": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock -w server\" \"STALWART_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\"", "dev:mock:no-future-release": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock:no-future-release -w server\" \"STALWART_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\"", - "i18n:coverage": "node scripts/i18n-coverage.mjs" + "i18n:coverage": "node scripts/i18n-coverage.mjs", + "i18n:check": "node scripts/i18n-catalog-check.mjs" }, "devDependencies": { "concurrently": "^9.1.2", diff --git a/scripts/i18n-catalog-check.mjs b/scripts/i18n-catalog-check.mjs new file mode 100644 index 0000000..695857a --- /dev/null +++ b/scripts/i18n-catalog-check.mjs @@ -0,0 +1,84 @@ +#!/usr/bin/env node +/* + * Check a catalogue against the strings the code actually asks for. + * + * Two failures, and only one of them is visible without this. + * + * A *missing* key renders English. That is the designed fallback and shows up + * as an untranslated word on screen, which somebody will eventually notice. + * + * A *stale* key -- one whose English no longer exists, usually because it was + * mistyped when the catalogue was written -- is silent. The translation sits + * in the file looking correct, is never looked up, and the app renders English + * for ever. Nothing warns, because a catalogue is only ever read by key. + */ +import ts from "typescript"; +import { readFileSync, globSync } from "node:fs"; + +const wanted = new Set(); +for (const file of globSync("web/src/**/*.{ts,tsx}").filter((f) => !f.includes("__tests__") && !f.includes("/locales/"))) { + const src = ts.createSourceFile(file, readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + const visit = (n) => { + /* + * Labels held in a constant and translated where they render -- t(s.label) + * -- reach t() as a variable, so there is no literal for this to find and + * every one of them looked "stale". They are collected from the constants + * instead: a `label:` property, or a value in an object of them. Without + * this the stale check cried wolf 33 times and would have been switched + * off, which is the only outcome worse than not having it. + */ + if (ts.isPropertyAssignment(n) && n.name.getText(src) === "label" && ts.isStringLiteral(n.initializer)) wanted.add(n.initializer.text); + if (ts.isVariableDeclaration(n) && ts.isIdentifier(n.name) && /_LABELS?$/.test(n.name.text)) { + const walk = (x) => { if (ts.isStringLiteral(x)) wanted.add(x.text); ts.forEachChild(x, walk); }; + if (n.initializer) walk(n.initializer); + } + if (ts.isCallExpression(n) && ts.isIdentifier(n.expression)) { + const fn = n.expression.text, a0 = n.arguments[0]; + if ((fn === "t" || fn === "translate" || fn === "tNode") && a0 && ts.isStringLiteral(a0)) wanted.add(a0.text); + // tc(context, source) keys the catalogue on both, joined by the same + // control character tc() uses. Without this the contextual entries all + // looked stale, which is the checker's own false alarm rather than a + // catalogue problem. + if (fn === "tc" && a0 && ts.isStringLiteral(a0) && n.arguments[1] && ts.isStringLiteral(n.arguments[1])) { + // Only the contextual key is required. The plain one is tc()'s + // fallback, not a second obligation -- asking for both would report + // work that does not exist. + wanted.add(`${a0.text}\u0004${n.arguments[1].text}`); + } + if (fn === "plural" && n.arguments[1] && ts.isObjectLiteralExpression(n.arguments[1])) { + for (const p of n.arguments[1].properties) { + if (ts.isPropertyAssignment(p) && p.name.getText(src) === "other" && ts.isStringLiteral(p.initializer)) wanted.add(p.initializer.text); + } + } + } + ts.forEachChild(n, visit); + }; + visit(src); +} + +let failed = false; +for (const file of globSync("web/src/locales/*.ts")) { + const tag = file.split("/").pop().replace(".ts", ""); + const src = ts.createSourceFile(file, readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + const have = new Set(); + const visit = (n) => { + if (ts.isPropertyAssignment(n) && ts.isStringLiteral(n.name)) have.add(n.name.text); + ts.forEachChild(n, visit); + }; + visit(src); + const stale = [...have].filter((k) => !wanted.has(k) && !["one", "other", "few", "many", "zero", "two"].includes(k)); + const missing = [...wanted].filter((k) => !have.has(k)); + const pct = Math.round(((wanted.size - missing.length) / wanted.size) * 100); + console.log(`${tag}: ${wanted.size - missing.length}/${wanted.size} translated (${pct}%), ${missing.length} falling back to English`); + if (stale.length) { + failed = true; + console.log(`\n ${stale.length} STALE key(s) — translated but never looked up, so they do nothing:`); + for (const k of stale.slice(0, 25)) console.log(` ${JSON.stringify(k)}`); + if (stale.length > 25) console.log(` …and ${stale.length - 25} more`); + } + if (process.argv.includes("--missing")) { + console.log(`\n missing:`); + for (const k of missing) console.log(` ${JSON.stringify(k)}`); + } +} +if (failed && process.argv.includes("--check")) process.exit(1); diff --git a/scripts/i18n-strings.mjs b/scripts/i18n-strings.mjs new file mode 100644 index 0000000..0089e7a --- /dev/null +++ b/scripts/i18n-strings.mjs @@ -0,0 +1,45 @@ +#!/usr/bin/env node +/* + * Every source string a catalogue needs, straight out of the calls. + * + * The English text is the key, so the catalogue's keys are not a list somebody + * maintains -- they are whatever t(), tNode() and plural() are actually asked + * for. Reading them from the code means a catalogue can never drift out of + * step with the app in the one direction that matters: a key that no longer + * exists is dead weight, but a call with no key is an untranslated string + * nobody noticed. + */ +import ts from "typescript"; +import { readFileSync, globSync } from "node:fs"; + +const strings = new Set(); +const plurals = new Set(); + +for (const file of globSync("web/src/**/*.{ts,tsx}").filter((f) => !f.includes("__tests__"))) { + const src = ts.createSourceFile(file, readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + const visit = (node) => { + if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) { + const fn = node.expression.text; + const a0 = node.arguments[0]; + if ((fn === "t" || fn === "translate" || fn === "tNode") && a0 && ts.isStringLiteral(a0)) strings.add(a0.text); + if (fn === "plural" && node.arguments[1] && ts.isObjectLiteralExpression(node.arguments[1])) { + const other = node.arguments[1].properties.find((p) => ts.isPropertyAssignment(p) && p.name.getText(src) === "other"); + const forms = {}; + for (const p of node.arguments[1].properties) { + if (ts.isPropertyAssignment(p) && ts.isStringLiteral(p.initializer)) forms[p.name.getText(src)] = p.initializer.text; + } + if (other) plurals.add(JSON.stringify(forms)); + } + } + ts.forEachChild(node, visit); + }; + visit(src); +} + +const out = { strings: [...strings].sort(), plurals: [...plurals].map((p) => JSON.parse(p)) }; +if (process.argv.includes("--json")) console.log(JSON.stringify(out, null, 2)); +else { + console.log(`${out.strings.length} strings, ${out.plurals.length} plural sets`); + const short = out.strings.filter((s) => s.length <= 30).length; + console.log(` ${short} short (<=30 chars), ${out.strings.length - short} longer`); +} diff --git a/web/src/lib/__tests__/i18n.test.tsx b/web/src/lib/__tests__/i18n.test.tsx index 24fe203..d5a64a4 100644 --- a/web/src/lib/__tests__/i18n.test.tsx +++ b/web/src/lib/__tests__/i18n.test.tsx @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; -import { currentLanguage, interpolate, plural, setCatalog, t, tNode, type Catalog } from "@/lib/i18n"; +import { CONTEXT_SEPARATOR, currentLanguage, interpolate, plural, setCatalog, subscribeForTest, t, tc, tNode, type Catalog } from "@/lib/i18n"; const de: Catalog = { strings: { @@ -109,3 +109,44 @@ describe("tNode", () => { expect(render(tNode("{count} of {scheme}", { scheme: x }, { count: 3 }))).toBe("3 of x"); }); }); + +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 + }); +}); diff --git a/web/src/lib/__tests__/languages.test.ts b/web/src/lib/__tests__/languages.test.ts index eda00fa..a864580 100644 --- a/web/src/lib/__tests__/languages.test.ts +++ b/web/src/lib/__tests__/languages.test.ts @@ -20,12 +20,20 @@ describe("resolveUiLanguage", () => { it("refuses a language whose strings are not shipped", () => { // The account travels between machines and can outlive a catalogue. A - // page that says lang="de" while rendering English is worse than one that + // page that says lang="fr" while rendering English is worse than one that // admits to English: it stops the reader translating it themselves. - expect(resolveUiLanguage("de")).toBe("en"); + expect(resolveUiLanguage("fr")).toBe("en"); expect(resolveUiLanguage("xx-XX")).toBe("en"); }); + it("carries the Beta flag until a person has signed the language off", () => { + // Not a completeness measure. A catalogue can be word-for-word finished + // and still read like a machine wrote it, which is what this marks. + const de = UI_LANGUAGES.find((l) => l.tag === "de"); + expect(de?.beta).toBe(true); + expect(UI_LANGUAGES.find((l) => l.tag === "en")?.beta).toBeUndefined(); + }); + it("honours one that is", () => { for (const l of UI_LANGUAGES) expect(resolveUiLanguage(l.tag)).toBe(l.tag); }); diff --git a/web/src/lib/__tests__/mailboxName.test.ts b/web/src/lib/__tests__/mailboxName.test.ts new file mode 100644 index 0000000..555065d --- /dev/null +++ b/web/src/lib/__tests__/mailboxName.test.ts @@ -0,0 +1,68 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { isLocalisedName, mailboxDisplayName, mailboxDisplayPath } from "@/lib/mailboxName"; +import { setCatalog, type Catalog } from "@/lib/i18n"; +import type { Mailbox } from "@/jmap/types"; + +/** + * Stalwart names the standard folders once, at account creation, and never + * renames them — so a German reader on an English-provisioned account would + * otherwise see "Deleted Items" in an otherwise German app. The role is what + * lets ihasmail say "Papierkorb" without writing anything to the server. + */ +const de: Catalog = { + strings: { Inbox: "Posteingang", "Deleted Items": "Papierkorb", Drafts: "Entwürfe" }, + plurals: {}, +}; +const mb = (id: string, name: string, role: string | null = null, parentId: string | null = null) => + ({ id, name, role, parentId } as unknown as Mailbox); + +afterEach(() => setCatalog("en", { strings: {}, plurals: {} })); + +describe("mailboxDisplayName", () => { + it("is the server's name until a catalogue says otherwise", () => { + expect(mailboxDisplayName(mb("1", "Deleted Items", "trash"))).toBe("Deleted Items"); + }); + + it("follows the interface language for a folder carrying a role", () => { + setCatalog("de", de); + expect(mailboxDisplayName(mb("1", "Deleted Items", "trash"))).toBe("Papierkorb"); + expect(mailboxDisplayName(mb("2", "Inbox", "inbox"))).toBe("Posteingang"); + }); + + it("leaves a folder somebody made alone", () => { + // "Newsletters" is their word. Translating it would name a folder they + // never created, and it would not match what any other client shows. + setCatalog("de", de); + expect(mailboxDisplayName(mb("3", "Newsletters"))).toBe("Newsletters"); + expect(mailboxDisplayName(mb("4", "Work", "subscribed"))).toBe("Work"); + }); + + it("survives a missing mailbox rather than printing undefined", () => { + expect(mailboxDisplayName(null)).toBe(""); + expect(mailboxDisplayName(undefined)).toBe(""); + }); +}); + +describe("isLocalisedName", () => { + it("tells an editor when the name on screen is not the server's", () => { + // A rename box prefilled with "Papierkorb" would rename the folder to that + // the moment somebody pressed Save — a real change made by accident. + expect(isLocalisedName(mb("1", "Deleted Items", "trash"))).toBe(true); + expect(isLocalisedName(mb("2", "Newsletters"))).toBe(false); + expect(isLocalisedName(mb("3", "Work", "subscribed"))).toBe(false); + }); +}); + +describe("mailboxDisplayPath", () => { + it("localises each part that has a role and leaves the rest", () => { + setCatalog("de", de); + const all = { a: mb("a", "Inbox", "inbox"), b: mb("b", "Projects", null, "a") }; + expect(mailboxDisplayPath(all.b!, all)).toBe("Posteingang / Projects"); + }); + + it("stops rather than looping on a parent cycle", () => { + // A malformed tree from the server must not hang the folder picker. + const all: Record = { a: mb("a", "A", null, "b"), b: mb("b", "B", null, "a") }; + expect(mailboxDisplayPath(all.a!, all)).toBe("B / A"); + }); +}); diff --git a/web/src/lib/datetime.ts b/web/src/lib/datetime.ts index d31d06b..7d6e15b 100644 --- a/web/src/lib/datetime.ts +++ b/web/src/lib/datetime.ts @@ -24,6 +24,28 @@ export interface DateTimePrefs { const DEFAULT_PREFS: DateTimePrefs = { locale: "", dateFormat: "auto", timeFormat: "auto" }; +/** + * The interface language, when one has been chosen over the default. + * + * Formatting and interface language are separate settings on purpose -- German + * dates with an English interface is a real preference. But somebody who picks + * German and is then shown "September" and "Monday" has not got what they + * asked for: choosing a language *is* a statement about language, and month + * names are language. + * + * So it joins the automatic chain, ahead of the server and the browser, and + * only while the formatting locale is left on "Automatic". Setting one + * explicitly still wins over everything, which is what that setting is for. + * English is not counted, because it is the default nobody has to choose -- + * an English interface on a German browser should keep German dates, as it + * always has. + */ +let uiLanguage: string | null = null; + +export function setUiLanguageForFormatting(tag: string | null | undefined): void { + uiLanguage = tag && tag !== "en" ? tag : null; +} + let prefs: DateTimePrefs = DEFAULT_PREFS; let serverLocale: string | null = null; @@ -90,9 +112,9 @@ export function normalizeLocale(raw: string | null | undefined): string | null { } } -/** The locale Intl should use: explicit choice → server → browser default. */ +/** Explicit choice → chosen interface language → server → browser default. */ export function resolvedLocale(): string | undefined { - return prefs.locale || serverLocale || undefined; + return prefs.locale || uiLanguage || serverLocale || undefined; } /** Where the effective locale came from — used to label the "Automatic" option. */ @@ -524,7 +546,7 @@ let optionsExtras = ""; * outside the generated list is still selectable). */ export function localeOptions(): LocaleOption[] { - const extras = `${serverLocale ?? ""}|${prefs.locale}`; + const extras = `${serverLocale ?? ""}|${prefs.locale}|${uiLanguage ?? ""}`; if (optionsCache && optionsExtras === extras) return optionsCache; const tags = new Set(LOCALE_TAGS); if (serverLocale) tags.add(serverLocale); diff --git a/web/src/lib/i18n.ts b/web/src/lib/i18n.ts index 51af7b9..ebe4510 100644 --- a/web/src/lib/i18n.ts +++ b/web/src/lib/i18n.ts @@ -61,6 +61,30 @@ export function t(source: string, vars?: Vars): string { return interpolate(current.strings[source] ?? source, vars); } +/** + * Translate where the English word is doing two jobs. + * + * English-as-key has one real weakness and this is it: "Archive" is the button + * that archives a message and the folder the message lands in, and German + * needs "Archivieren" for the first and "Archiv" for the second. One key + * cannot hold both. "Important" is the same — a priority tag and a folder. + * + * So a context can be given, and the lookup becomes context + source while the + * fallback stays the plain English. A translator sees the context and knows + * which sense to render; a catalogue that has not got round to it still + * renders the English word, which was right in English all along. + * + * The separator is a control character rather than a punctuation mark, which + * is the gettext convention and for the same reason: no English string can + * contain it by accident. + */ +export const CONTEXT_SEPARATOR = "\u0004"; + +export function tc(context: string, source: string, vars?: Vars): string { + const keyed = current.strings[`${context}${CONTEXT_SEPARATOR}${source}`]; + return interpolate(keyed ?? current.strings[source] ?? source, vars); +} + /** * Translate a counted thing. * @@ -119,6 +143,12 @@ export function tNode(source: string, parts: Record, vars?: V return out; } +/** Subscribe to catalogue changes without React. Used by the tests. */ +export function subscribeForTest(fn: () => void): () => void { + listeners.add(fn); + return () => void listeners.delete(fn); +} + /** The language in force, for anything that needs the tag itself. */ export function currentLanguage(): string { return currentTag; @@ -132,6 +162,23 @@ export function currentLanguage(): string { * language's rules against another's forms. */ export function setCatalog(tag: string, catalog: Catalog): void { + /* + * Publishing only when something actually changed is not an optimisation + * here, it is the thing that stops an infinite loop. + * + * The root keys its tree on the language version, so a publish remounts + * everything. Remounting re-runs the effect that fetches the account's + * settings file, which calls `hydrate`, which calls `applyLang`, which lands + * back here -- with the identical tag and the identical catalogue. Publishing + * that non-change bumped the version again and went round for ever: the + * message list refetched on every pass, which is what it looked like from + * the outside. + * + * Reference equality is enough. `EMPTY` is a module constant and a + * dynamically imported catalogue is cached, so the same language really does + * hand back the same object. + */ + if (currentTag === tag && current === catalog) return; currentTag = tag; current = catalog; publish(); diff --git a/web/src/lib/languages.ts b/web/src/lib/languages.ts index 3260cd6..87cbca3 100644 --- a/web/src/lib/languages.ts +++ b/web/src/lib/languages.ts @@ -23,12 +23,26 @@ export interface UiLanguage { tag: string; /** The language's name in that language, which is how a picker should read. */ name: string; + /** + * Machine-translated and not yet checked by somebody who speaks it. + * + * Stays true until a native speaker has actually read the catalogue and said + * so. It is not a measure of how complete the file is -- a catalogue can be + * word-for-word finished and still read like a machine wrote it, which is + * the thing this flag is about. Removing it is a deliberate act by a person, + * not something a coverage number earns. + */ + beta?: boolean; } export const UI_LANGUAGES: readonly UiLanguage[] = [ { tag: "en", name: "English" }, + { tag: "de", name: "Deutsch", beta: true }, ]; +/** Where to report a bad translation. Beta languages depend on it. */ +export const TRANSLATION_ISSUE_URL = "https://github.com/Coffey-Labs/ihasmail/issues/new?title=Translation%3A%20"; + export const DEFAULT_UI_LANGUAGE = "en"; /** diff --git a/web/src/lib/mailboxName.ts b/web/src/lib/mailboxName.ts new file mode 100644 index 0000000..2dbef51 --- /dev/null +++ b/web/src/lib/mailboxName.ts @@ -0,0 +1,79 @@ +import { tc } from "@/lib/i18n"; +import type { Mailbox } from "@/jmap/types"; + +/** + * What to call a folder on screen. + * + * Stalwart names the standard folders once, when the account is created, in + * whatever language the server was set up in — and never renames them + * afterwards, because the name is stored data every other client has mapped. + * So a German reader on an English-provisioned account sees "Deleted Items" + * in an otherwise German app, and there is nothing the server can be asked to + * do about it: the account locale exists in `x:AccountSettings`, but writing it + * needs `sysAccountSettingsSet`, which the built-in user role does not carry. + * + * The role is the way out. JMAP tags the standard folders — `inbox`, `trash`, + * `drafts` and the rest — and ihasmail already trusts the role rather than the + * name everywhere it matters, so the display name can follow the interface + * language without anything being written to the server. + * + * Only the roles. A folder somebody made and called "Newsletters" keeps that + * name, because those are their words and translating them would be inventing + * a folder they never made. + * + * The cost, and it is real: another client on the same account still shows + * "Deleted Items", because that is what the folder is called. Within ihasmail + * this stays consistent — everything that names a folder goes through here, + * including the "moved to …" toast, which exists precisely so that message + * does not name somewhere the reader cannot find. + */ +/* + * Every one of these is translated in the "folder" context, including the + * unambiguous ones. Two of them genuinely need it -- "Archive" is also the + * button that archives, "Important" is also a priority tag, and German wants a + * different word for each -- and applying it to only those two would leave the + * next person to notice which. A context on all of them is one rule. + */ +const ROLE_NAMES: Record string> = { + inbox: () => tc("folder", "Inbox"), + archive: () => tc("folder", "Archive"), + drafts: () => tc("folder", "Drafts"), + sent: () => tc("folder", "Sent"), + trash: () => tc("folder", "Deleted Items"), + junk: () => tc("folder", "Junk Mail"), + important: () => tc("folder", "Important"), + all: () => tc("folder", "All mail"), +}; + +/** The folder's name as the reader should see it. */ +export function mailboxDisplayName(mailbox: { name: string; role?: string | null } | null | undefined): string { + if (!mailbox) return ""; + const localised = mailbox.role ? ROLE_NAMES[mailbox.role] : undefined; + return localised ? localised() : mailbox.name; +} + +/** + * Whether this folder's displayed name is ihasmail's rather than the server's. + * + * Anything that *edits* the name has to know: a rename dialog prefilled with + * "Papierkorb" would rename the folder to that on the server the moment + * somebody pressed Save, which is a real change made by accident to a folder + * they were only looking at. Renaming a role folder is refused anyway, but + * relying on that would be relying on a rule enforced somewhere else. + */ +export function isLocalisedName(mailbox: { role?: string | null } | null | undefined): boolean { + return Boolean(mailbox?.role && mailbox.role in ROLE_NAMES); +} + +/** A path of folder names, for a picker that shows where a folder sits. */ +export function mailboxDisplayPath(mailbox: Mailbox, all: Record): string { + const parts: string[] = []; + let cur: Mailbox | undefined = mailbox; + const seen = new Set(); + while (cur && !seen.has(cur.id)) { + seen.add(cur.id); + parts.unshift(mailboxDisplayName(cur)); + cur = cur.parentId ? all[cur.parentId] : undefined; + } + return parts.join(" / "); +} diff --git a/web/src/locales/de.ts b/web/src/locales/de.ts new file mode 100644 index 0000000..449e095 --- /dev/null +++ b/web/src/locales/de.ts @@ -0,0 +1,884 @@ +import type { Catalog } from "@/lib/i18n"; + +/** + * German — generated by AI, and not reviewed by a native speaker. + * + * That is stated in the app as well as here, because it is the fact a reader + * needs in order to judge what they are looking at. Somebody told the + * translation is unchecked forgives an odd sentence and reports it; somebody + * told it was reviewed reasonably concludes the product is sloppy. The report + * link in Settings is the entire review process, and it only works if the app + * is honest about needing one. + * + * It was written against the standard terminology every German mail client + * already uses rather than invented, and the glossary below is the part that + * makes it read as one voice. But "written carefully" and "correct" are + * different claims and only the first is being made. The language stays marked + * Beta until a person who speaks German has read it and said otherwise -- + * which is a decision somebody makes, not one a completeness percentage + * earns. + * + * A string missing from here renders its English source, so this file can be + * incomplete or wrong in places without the app breaking. Deleting an entry is + * a valid fix. + * + * ── Decisions this file is consistent about ────────────────────────────── + * + * Register: **Sie**, throughout. Thunderbird and Outlook use it, and ihasmail + * is as often a company's mail as somebody's own — "du" from a mail client + * that a workplace deployed reads as presumptuous in a way "Sie" never does. + * Where a string can avoid the question entirely it does ("Nachricht löschen?" + * rather than "Möchten Sie die Nachricht löschen?"), which is ordinary good + * German UI and sidesteps the choice; the choice is still pinned for the + * strings that cannot. + * + * Terminology, fixed once so it cannot drift: + * + * Inbox Posteingang Archive (verb) archivieren + * Drafts Entwürfe Delete löschen + * Sent Gesendet Move to verschieben nach + * Deleted Items Papierkorb Reply antworten + * Junk / Spam Spam Reply all allen antworten + * Folder Ordner Forward weiterleiten + * Label Label Star markieren + * Conversation Konversation Read / unread gelesen / ungelesen + * Message Nachricht Settings Einstellungen + * Attachment Anhang Signature Signatur + * Contact Kontakt Identity Identität + * + * "Label" is left in English because IMAP keywords are what they are and every + * German mail client leaves it: translating it to "Etikett" would name + * something the reader cannot find anywhere else. "Spam" likewise — "Junk" is + * the English word Germans do not use, "Spam" is the one they do. + * + * Product names are never translated: ihasmail, Stalwart, JMAP, Sieve, vCard. + */ +export const catalog: Catalog = { + strings: { + // ── Actions ──────────────────────────────────────────────────────── + "Archive": "Archivieren", + "Archive (e)": "Archivieren (e)", + "Delete": "Löschen", + "Delete (#)": "Löschen (#)", + "Reply": "Antworten", + "Reply (r)": "Antworten (r)", + "Reply all": "Allen antworten", + "Forward": "Weiterleiten", + "Move to…": "Verschieben nach…", + "Move to (v)": "Verschieben nach (v)", + "Move to folder": "In Ordner verschieben", + "Move here": "Hierher verschieben", + "Mark as read": "Als gelesen markieren", + "Mark as read (Shift+I)": "Als gelesen markieren (Umschalt+I)", + "Mark as unread": "Als ungelesen markieren", + "Mark as unread (Shift+U)": "Als ungelesen markieren (Umschalt+U)", + "Mark all as read": "Alle als gelesen markieren", + "Mark all as read, incl. subfolders": "Alle als gelesen markieren, inkl. Unterordner", + "Star": "Markieren", + "Labels": "Labels", + "Labels (l)": "Labels (l)", + "Label as": "Label vergeben", + "Label…": "Label…", + "Compose": "Verfassen", + "Compose message": "Nachricht verfassen", + "Send": "Senden", + "Send at": "Senden am", + "Cancel send": "Senden abbrechen", + "Schedule send": "Senden planen", + "Save": "Speichern", + "Save & activate": "Speichern & aktivieren", + "Save & close (Esc)": "Speichern & schließen (Esc)", + "Save as template": "Als Vorlage speichern", + "Save draft now": "Entwurf jetzt speichern", + "Cancel": "Abbrechen", + "Close": "Schließen", + "Done": "Fertig", + "Continue": "Weiter", + "Edit": "Bearbeiten", + "Edit…": "Bearbeiten…", + "Rename": "Umbenennen", + "Remove": "Entfernen", + "Restore": "Wiederherstellen", + "Retry": "Erneut versuchen", + "Reload": "Neu laden", + "Refresh": "Aktualisieren", + "Copy": "Kopieren", + "Copy email address": "E-Mail-Adresse kopieren", + "Download": "Herunterladen", + "Download all": "Alle herunterladen", + "Download (.eml)": "Herunterladen (.eml)", + "Download latest as .eml": "Neueste als .eml herunterladen", + "Upload": "Hochladen", + "Upload files…": "Dateien hochladen…", + "Print": "Drucken", + "Print conversation": "Konversation drucken", + "Undo (Ctrl+Z)": "Rückgängig (Strg+Z)", + "Redo": "Wiederherstellen", + "Dismiss": "Schließen", + "Discard changes": "Änderungen verwerfen", + "Discard draft": "Entwurf verwerfen", + "Duplicate": "Duplizieren", + "Validate": "Prüfen", + "Revoke": "Widerrufen", + "Turn off": "Deaktivieren", + "Clear": "Leeren", + "Clear selection": "Auswahl aufheben", + "Clear custom colour": "Eigene Farbe entfernen", + "Select": "Auswählen", + "Select all": "Alle auswählen", + "Unsubscribe": "Abbestellen", + "Share…": "Freigeben…", + "Stop sharing": "Freigabe aufheben", + "Open": "Öffnen", + "Open in new tab": "In neuem Tab öffnen", + "Open in calendar": "Im Kalender öffnen", + "Back": "Zurück", + "Back (u)": "Zurück (u)", + "Back to list": "Zurück zur Liste", + "Back to my files": "Zurück zu meinen Dateien", + "Go back to the list": "Zurück zur Liste", + "Next": "Weiter", + "Previous": "Zurück", + "More": "Mehr", + "More actions": "Weitere Aktionen", + "More options": "Weitere Optionen", + "Move up": "Nach oben", + "Move down": "Nach unten", + "Drag to reorder": "Zum Umsortieren ziehen", + "Right-click for options": "Rechtsklick für Optionen", + + // ── Mail ─────────────────────────────────────────────────────────── + "Mail": "E-Mail", + "Message": "Nachricht", + "Messages": "Nachrichten", + "Message body": "Nachrichtentext", + "Message headers": "Kopfzeilen", + "Message size": "Nachrichtengröße", + "Message-ID": "Message-ID", + "Original message": "Ursprüngliche Nachricht", + "Delete this message": "Diese Nachricht löschen", + "New message to this address": "Neue Nachricht an diese Adresse", + "Conversation view": "Konversationsansicht", + "Draft": "Entwurf", + "Unread": "Ungelesen", + "Unread only": "Nur ungelesene", + "All mail": "Alle Nachrichten", + "Sender": "Absender", + "Sender domain": "Absenderdomäne", + "Recipients": "Empfänger", + "From": "Von", + "To": "An", + "Cc": "Cc", + "Bcc": "Bcc", + "Subject": "Betreff", + "Subject (optional)": "Betreff (optional)", + "Body": "Text", + "Body text": "Fließtext", + "Attach files": "Dateien anhängen", + "Attach from Files": "Aus Dateien anhängen", + "Remove attachment": "Anhang entfernen", + "Has attachment": "Hat Anhang", + "Has the words": "Enthält die Wörter", + "Header name": "Name der Kopfzeile", + "Show headers": "Kopfzeilen anzeigen", + "Show original": "Original anzeigen", + "Show details": "Details anzeigen", + "Show images": "Bilder anzeigen", + "Remote images": "Externe Bilder", + "Remote images are blocked to protect your privacy.": "Externe Bilder werden zum Schutz Ihrer Privatsphäre blockiert.", + "Always from {email}": "Immer von {email}", + "This looks like a mailing list.": "Das sieht nach einer Mailingliste aus.", + "This folder is empty": "Dieser Ordner ist leer", + "This folder is empty.": "Dieser Ordner ist leer.", + "Delete all spam now": "Gesamten Spam jetzt löschen", + "Deleting spam is permanent — it does not go to Deleted Items first.": "Das Löschen von Spam ist endgültig — er wandert nicht zuerst in den Papierkorb.", + "Keep in Inbox": "Im Posteingang behalten", + "Newer (k)": "Neuere (k)", + "Older (j)": "Ältere (j)", + "Open the next (older) conversation": "Nächste (ältere) Konversation öffnen", + "Open the previous (newer) conversation": "Vorherige (neuere) Konversation öffnen", + "Loading conversation…": "Konversation wird geladen…", + "Important": "Wichtig", + "Unverified": "Nicht verifiziert", + "Priority": "Priorität", + "High": "Hoch", + "Normal": "Normal", + "Low": "Niedrig", + "to {recipients}": "an {recipients}", + "From: {sender}": "Von: {sender}", + "Waiting on the server — goes out {when}.": "Wartet auf dem Server — geht {when} raus.", + "Scheduled — click to clear the schedule": "Geplant — zum Aufheben klicken", + "Nothing scheduled": "Nichts geplant", + "The message waits on the server, so it goes out whether or not ihasmail is open.": "Die Nachricht wartet auf dem Server und wird gesendet, ob ihasmail geöffnet ist oder nicht.", + "This server holds a message for up to {span}.": "Dieser Server hält eine Nachricht bis zu {span} zurück.", + "Date and time to send": "Datum und Uhrzeit für den Versand", + "Undo send window": "Zeitfenster zum Rückgängigmachen", + "Read receipt requested": "Lesebestätigung angefordert", + "The sender asked for a read receipt.": "Der Absender hat eine Lesebestätigung angefordert.", + "Request read receipt": "Lesebestätigung anfordern", + "Always request read receipts": "Immer Lesebestätigungen anfordern", + "Receipt": "Bestätigung", + "Never send one": "Nie senden", + "Not this time": "Diesmal nicht", + "It would go to {address}, which is not where the message came from.": "Sie ginge an {address} — nicht dorthin, woher die Nachricht kam.", + "Use “Show original” for the complete raw message.": "Nutzen Sie „Original anzeigen“ für die vollständige Rohnachricht.", + + // ── Folders, calendar, contacts, files ───────────────────────────── + "Folder": "Ordner", + "Folders": "Ordner", + "Folder options": "Ordneroptionen", + "New folder": "Neuer Ordner", + "New subfolder": "Neuer Unterordner", + "Delete folder": "Ordner löschen", + "No matching folders": "Keine passenden Ordner", + "No subfolders here.": "Hier gibt es keine Unterordner.", + "Type a folder name…": "Ordnernamen eingeben…", + "+ New folder…": "+ Neuer Ordner…", + "Create, rename and hide folders.": "Ordner erstellen, umbenennen und ausblenden.", + "Show unsubscribed (hidden) folders": "Nicht abonnierte (ausgeblendete) Ordner anzeigen", + "Storage: {used} of {total} used.": "Speicher: {used} von {total} belegt.", + "{used} of {total}": "{used} von {total}", + + "Calendar": "Kalender", + "My calendars": "Meine Kalender", + "New calendar": "Neuer Kalender", + "Calendar options": "Kalenderoptionen", + "Calendar & contacts": "Kalender & Kontakte", + "Calendar is not available": "Kalender ist nicht verfügbar", + "Event": "Termin", + "New event": "Neuer Termin", + "(new event)": "(neuer Termin)", + "New all-day event": "Neuer ganztägiger Termin", + "Add title": "Titel hinzufügen", + "Add location": "Ort hinzufügen", + "Add to calendar": "Zum Kalender hinzufügen", + "Add to my calendar": "Zu meinem Kalender hinzufügen", + "Remove from calendar": "Aus dem Kalender entfernen", + "Remove from my calendar": "Aus meinem Kalender entfernen", + "All day": "Ganztägig", + "all-day": "ganztägig", + "Starts": "Beginnt", + "Starts (optional)": "Beginnt (optional)", + "Ends": "Endet", + "Ends (optional)": "Endet (optional)", + "Day": "Tag", + "Week": "Woche", + "Month": "Monat", + "Agenda": "Agenda", + "Today": "Heute", + "Go to day": "Zum Tag", + "Go to week": "Zur Woche", + "Previous month": "Voriger Monat", + "Next month": "Nächster Monat", + "Does not repeat": "Wiederholt sich nicht", + "Daily": "Täglich", + "Every weekday": "Jeden Wochentag", + "Yearly": "Jährlich", + "Custom…": "Benutzerdefiniert…", + "Weekly on {weekday}": "Wöchentlich am {weekday}", + "Monthly on day {day}": "Monatlich am {day}.", + "Repeat every": "Wiederholen alle", + "Repeat until": "Wiederholen bis", + "after N times": "nach N Malen", + "on date": "am Datum", + "never": "nie", + "day(s)": "Tag(e)", + "week(s)": "Woche(n)", + "month(s)": "Monat(e)", + "year(s)": "Jahr(e)", + "Reminders": "Erinnerungen", + "Add reminder": "Erinnerung hinzufügen", + "Remove reminder": "Erinnerung entfernen", + "Default reminder": "Standarderinnerung", + "At time of event": "Zum Zeitpunkt des Termins", + "5 minutes before": "5 Minuten vorher", + "10 minutes before": "10 Minuten vorher", + "15 minutes before": "15 Minuten vorher", + "30 minutes before": "30 Minuten vorher", + "1 hour before": "1 Stunde vorher", + "1 day before": "1 Tag vorher", + "15 minutes": "15 Minuten", + "30 minutes": "30 Minuten", + "45 minutes": "45 Minuten", + "1 hour": "1 Stunde", + "1.5 hours": "1,5 Stunden", + "2 hours": "2 Stunden", + "Default event length": "Standarddauer für Termine", + "Default view": "Standardansicht", + "Guests": "Teilnehmer", + "Add guests by name or email": "Teilnehmer nach Name oder E-Mail hinzufügen", + "Send invitation emails to guests": "Einladungen per E-Mail an Teilnehmer senden", + "Going?": "Nehmen Sie teil?", + "Yes": "Ja", + "No": "Nein", + "Maybe": "Vielleicht", + "Confirmed": "Zugesagt", + "Tentative": "Vorläufig", + "Cancelled": "Abgesagt", + "organizer": "Organisator", + "Organizer: {name}": "Organisator: {name}", + "Free": "Frei", + "Busy": "Gebucht", + "Free/busy": "Frei/Gebucht", + "Show as": "Anzeigen als", + "Availability on {date}": "Verfügbarkeit am {date}", + "Count all events as busy": "Alle Termine als gebucht zählen", + "Only events I'm attending": "Nur Termine, an denen ich teilnehme", + "Don't include in availability": "Nicht in die Verfügbarkeit einbeziehen", + "Meeting link": "Besprechungslink", + "No events in the next 60 days.": "Keine Termine in den nächsten 60 Tagen.", + "Working hours": "Arbeitszeiten", + "Working hours start": "Arbeitszeit beginnt", + "Working hours end": "Arbeitszeit endet", + "Colour categories": "Farbkategorien", + "Category": "Kategorie", + "No category": "Keine Kategorie", + "New category": "Neue Kategorie", + "Delete category": "Kategorie löschen", + "Manage categories…": "Kategorien verwalten…", + "Use category color": "Kategoriefarbe verwenden", + "Use calendar color": "Kalenderfarbe verwenden", + "Use the default colour": "Standardfarbe verwenden", + "+{n} more": "+{n} weitere", + + "Contacts": "Kontakte", + "Contacts are not available": "Kontakte sind nicht verfügbar", + "New contact": "Neuer Kontakt", + "Edit contact": "Kontakt bearbeiten", + "Select a contact": "Kontakt auswählen", + "All contacts": "Alle Kontakte", + "Search contacts": "Kontakte durchsuchen", + "Search contacts to add…": "Kontakte zum Hinzufügen suchen…", + "Loading contacts…": "Kontakte werden geladen…", + "Add to contacts": "Zu Kontakten hinzufügen", + "Add to my contacts": "Zu meinen Kontakten hinzufügen", + "Remove from my contacts": "Aus meinen Kontakten entfernen", + "Address book": "Adressbuch", + "Address books": "Adressbücher", + "All address books": "Alle Adressbücher", + "My address books": "Meine Adressbücher", + "New address book": "Neues Adressbuch", + "No address books yet.": "Noch keine Adressbücher.", + "Choose from address books": "Aus Adressbüchern wählen", + "Import vCard": "vCard importieren", + "Export all": "Alle exportieren", + "Export book": "Adressbuch exportieren", + "Email group": "Gruppe anschreiben", + "Email everyone": "Alle anschreiben", + "Members": "Mitglieder", + "Members ({count})": "Mitglieder ({count})", + "Group": "Gruppe", + "· group": "· Gruppe", + "Person": "Person", + "First name": "Vorname", + "Last name": "Nachname", + "More name fields": "Weitere Namensfelder", + "Nickname": "Spitzname", + "Prefix": "Anrede", + "Suffix": "Namenszusatz", + "Dr.": "Dr.", + "Jr.": "Jr.", + "Display name": "Anzeigename", + "Job title": "Position", + "Organization": "Organisation", + "Company": "Firma", + "Birthday": "Geburtstag", + "Notes": "Notizen", + "Website": "Website", + "Phone": "Telefon", + "Add phone": "Telefon hinzufügen", + "Add email": "E-Mail hinzufügen", + "Add address": "Adresse hinzufügen", + "Address": "Adresse", + "Street": "Straße", + "City": "Stadt", + "State / Region": "Bundesland / Region", + "Postal code": "Postleitzahl", + "Country": "Land", + "Change photo": "Foto ändern", + "Remove photo": "Foto entfernen", + "Updated {date}": "Aktualisiert am {date}", + "Search names and addresses": "Namen und Adressen durchsuchen", + "Add a person or group…": "Person oder Gruppe hinzufügen…", + "Choose recipients": "Empfänger auswählen", + "Available to add": "Zum Hinzufügen verfügbar", + "vCard": "vCard", + "vCard attachment": "vCard-Anhang", + + "Files": "Dateien", + "My files": "Meine Dateien", + "File storage is not available": "Dateispeicher ist nicht verfügbar", + "Drag files here or use Upload.": "Dateien hierher ziehen oder „Hochladen“ nutzen.", + "Shared": "Freigegeben", + "Shared with me": "Für mich freigegeben", + "Nothing is shared with you.": "Für Sie ist nichts freigegeben.", + "Not shared with anyone yet.": "Noch für niemanden freigegeben.", + "Check for new shares": "Nach neuen Freigaben suchen", + "Shared files are copied to your account when attached.": "Freigegebene Dateien werden beim Anhängen in Ihr Konto kopiert.", + "Viewer": "Leseberechtigt", + "Editor": "Bearbeitungsberechtigt", + "Size": "Größe", + + // ── Settings ─────────────────────────────────────────────────────── + "Settings": "Einstellungen", + "All settings": "Alle Einstellungen", + "Sections": "Bereiche", + "General": "Allgemein", + "Appearance": "Darstellung", + "Make ihasmail yours.": "Machen Sie ihasmail zu Ihrem.", + "Reading": "Lesen", + "Reading pane": "Lesebereich", + "Reading, sending and list behaviour. Settings are stored in this browser.": "Verhalten beim Lesen, Senden und in der Liste. Die Einstellungen werden in diesem Browser gespeichert.", + "Right of the list": "Rechts von der Liste", + "Below the list": "Unter der Liste", + "Hidden (open full width)": "Ausgeblendet (in voller Breite öffnen)", + "Off (open messages full width)": "Aus (Nachrichten in voller Breite öffnen)", + "Off": "Aus", + "Composing": "Verfassen", + "Default format": "Standardformat", + "Rich text (HTML)": "Formatierter Text (HTML)", + "Plain text": "Nur Text", + "Quote original message in replies": "Ursprüngliche Nachricht in Antworten zitieren", + "Place signature above quoted text": "Signatur über dem zitierten Text platzieren", + "Attachment reminder": "Anhang-Erinnerung", + "Warn when the message mentions an attachment but none is attached.": "Warnen, wenn die Nachricht einen Anhang erwähnt, aber keiner angehängt ist.", + "Spell check while typing": "Rechtschreibprüfung während der Eingabe", + "Confirm before deleting": "Vor dem Löschen bestätigen", + "Show message snippets": "Nachrichtenvorschau anzeigen", + "Preview the first line of each message in the list.": "Die erste Zeile jeder Nachricht in der Liste anzeigen.", + "Show sender avatars": "Absenderbilder anzeigen", + "Group messages from the same thread together.": "Nachrichten desselben Threads zusammenfassen.", + "After archiving or deleting": "Nach Archivieren oder Löschen", + "Ask before showing (recommended)": "Vor dem Anzeigen fragen (empfohlen)", + "Always (all messages)": "Immer (alle Nachrichten)", + "Show automatically from my contacts": "Bei meinen Kontakten automatisch anzeigen", + "Immediately when opened": "Sofort beim Öffnen", + "After 2 seconds": "Nach 2 Sekunden", + "After 5 seconds": "Nach 5 Sekunden", + "Never automatically": "Nie automatisch", + "When someone requests a read receipt": "Wenn jemand eine Lesebestätigung anfordert", + "Ask me on each message": "Bei jeder Nachricht fragen", + "5 seconds": "5 Sekunden", + "8 seconds": "8 Sekunden", + "15 seconds": "15 Sekunden", + "30 seconds": "30 Sekunden", + "Locale": "Regionales", + "Language": "Sprache", + "Interface language": "Sprache der Oberfläche", + "Language & region": "Sprache & Region", + "Date format": "Datumsformat", + "Time format": "Zeitformat", + "Time zone": "Zeitzone", + "Week starts on": "Woche beginnt am", + "Monday": "Montag", + "Saturday": "Samstag", + "Sunday": "Sonntag", + "12-hour clock (6:23 PM)": "12-Stunden-Format (6:23 PM)", + "24-hour clock (18:23)": "24-Stunden-Format (18:23)", + "Browser default ({zone})": "Browser-Standard ({zone})", + "Default ({zone})": "Standard ({zone})", + "Automatic ({example})": "Automatisch ({example})", + "Automatic ({locale})": "Automatisch ({locale})", + "Preview: {example}": "Vorschau: {example}", + "Dates, times and month names follow this choice.": "Datum, Uhrzeit und Monatsnamen richten sich nach dieser Auswahl.", + "Your mail server reports {name} ({tag}).": "Ihr Mailserver meldet {name} ({tag}).", + "Your mail server does not report a locale, so the browser's is used.": "Ihr Mailserver meldet keine Spracheinstellung, daher wird die des Browsers verwendet.", + "Dates": "Datum", + "Date": "Datum", + "Time": "Uhrzeit", + "When": "Wann", + "Then": "Dann", + "then": "dann", + "Theme": "Design", + "Accent color": "Akzentfarbe", + "Color": "Farbe", + "Colour": "Farbe", + "Text color": "Textfarbe", + "Density & text": "Dichte & Text", + "Display density": "Anzeigedichte", + "Comfortable": "Komfortabel", + "Cozy (default)": "Ausgewogen (Standard)", + "Compact": "Kompakt", + "Text size": "Schriftgröße", + "Font size": "Schriftgröße", + "Small": "Klein", + "Medium": "Mittel", + "Large": "Groß", + "Huge": "Sehr groß", + "Sidebar": "Seitenleiste", + "Show labels in the sidebar": "Labels in der Seitenleiste anzeigen", + "Collapse sidebar to icons": "Seitenleiste auf Symbole verkleinern", + "Apply the theme to messages too": "Design auch auf Nachrichten anwenden", + "Swiping": "Wischgesten", + "Swipe left": "Nach links wischen", + "Swipe right": "Nach rechts wischen", + "Backup": "Sicherung", + "Export settings": "Einstellungen exportieren", + "Import settings": "Einstellungen importieren", + "Settings imported": "Einstellungen importiert", + "Invalid settings file": "Ungültige Einstellungsdatei", + "Reset to defaults": "Auf Standard zurücksetzen", + "Default mail app": "Standard-E-Mail-Programm", + "Documentation": "Dokumentation", + "About ihasmail": "Über ihasmail", + "Server": "Server", + "Server capabilities": "Server-Funktionen", + "Accounts": "Konten", + "Account": "Konto", + "Max upload": "Maximaler Upload", + "{size} MB": "{size} MB", + "KB": "KB", + "Image privacy proxy": "Bild-Datenschutz-Proxy", + "enabled": "aktiviert", + "disabled": "deaktiviert", + "Enabled": "Aktiviert", + "active": "aktiv", + "hidden": "ausgeblendet", + "connected": "verbunden", + "reconnecting…": "Verbindung wird wiederhergestellt…", + "AGPL-3.0 source": "AGPL-3.0-Quellcode", + + // ── Identities, templates, filters ───────────────────────────────── + "Identities & signatures": "Identitäten & Signaturen", + "Add identity": "Identität hinzufügen", + "Delete identity": "Identität löschen", + "Make default": "Als Standard festlegen", + "Default": "Standard", + "Show when composing": "Beim Verfassen anzeigen", + "Hide when composing": "Beim Verfassen ausblenden", + "Signature": "Signatur", + "Your signature…": "Ihre Signatur…", + "Reply-To": "Antwort an", + "Reply-To (optional)": "Antwort an (optional)", + "Reply-To: {addresses}": "Antwort an: {addresses}", + "Replies go to…": "Antworten gehen an…", + "Set a Reply-To address": "Antwortadresse festlegen", + "{email} is now your default identity": "{email} ist jetzt Ihre Standardidentität", + "Templates": "Vorlagen", + "New template": "Neue Vorlage", + "Delete template": "Vorlage löschen", + "Insert template": "Vorlage einfügen", + "Template text…": "Vorlagentext…", + "Subject: {subject}": "Betreff: {subject}", + "Filters & rules": "Filter & Regeln", + "Filters unavailable": "Filter nicht verfügbar", + "Rules": "Regeln", + "Rule name": "Regelname", + "New rule": "Neue Regel", + "Delete rule": "Regel löschen", + "No filters yet": "Noch keine Filter", + "Add condition": "Bedingung hinzufügen", + "Remove condition": "Bedingung entfernen", + "Add action": "Aktion hinzufügen", + "Remove action": "Aktion entfernen", + "all of the following match": "alle folgenden zutreffen", + "any of the following match": "eine der folgenden zutrifft", + "contains": "enthält", + "does not contain": "enthält nicht", + "is larger than": "ist größer als", + "is smaller than": "ist kleiner als", + "Stop processing more rules": "Keine weiteren Regeln verarbeiten", + "keep copy": "Kopie behalten", + "Forward to": "Weiterleiten an", + "Reject with message": "Mit Nachricht abweisen", + "Scripts": "Skripte", + "Scripts (advanced)": "Skripte (fortgeschritten)", + "Script name": "Skriptname", + "New script": "Neues Skript", + "Delete script": "Skript löschen", + "Sieve source": "Sieve-Quelltext", + "Preview generated Sieve script": "Erzeugtes Sieve-Skript ansehen", + "Start with rules": "Mit Regeln beginnen", + "Switch to rules?": "Zu Regeln wechseln?", + "Create filter": "Filter erstellen", + "Filter messages like this": "Nachrichten wie diese filtern", + "Filter messages like this…": "Nachrichten wie diese filtern…", + "Also apply to existing messages in": "Auch auf vorhandene Nachrichten anwenden in", + "keyword (e.g. $important, work)": "Schlüsselwort (z. B. $important, work)", + "Out of office": "Abwesenheit", + "Auto-reply enabled": "Automatische Antwort aktiviert", + + // ── Security, sessions, notifications ────────────────────────────── + "Security & sessions": "Sicherheit & Sitzungen", + "Password": "Passwort", + "Your password": "Ihr Passwort", + "Current password": "Aktuelles Passwort", + "New password": "Neues Passwort", + "Confirm new password": "Neues Passwort bestätigen", + "Current code": "Aktueller Code", + "Code from your authenticator": "Code aus Ihrer Authenticator-App", + "Two-factor authentication": "Zwei-Faktor-Authentifizierung", + "Turn off two-factor authentication": "Zwei-Faktor-Authentifizierung deaktivieren", + "Your password alone will be enough to sign in again.": "Ihr Passwort allein genügt dann wieder zum Anmelden.", + "App passwords": "App-Passwörter", + "New app password for": "Neues App-Passwort für", + "Your new app password": "Ihr neues App-Passwort", + "Secret": "Geheimnis", + "Thunderbird on my laptop": "Thunderbird auf meinem Laptop", + "Active webmail sessions": "Aktive Webmail-Sitzungen", + "Sign out": "Abmelden", + "Sign out here": "Hier abmelden", + "Sign out all other sessions": "Alle anderen Sitzungen abmelden", + "Signed in as": "Angemeldet als", + "This is my own device": "Das ist mein eigenes Gerät", + "this device": "dieses Gerät", + "Device": "Gerät", + "IP": "IP", + "Last active": "Zuletzt aktiv", + "Created": "Erstellt", + "Expires": "Läuft ab", + "Status": "Status", + "Online": "Online", + "Reason": "Grund", + "Type": "Typ", + "Email or username": "E-Mail oder Benutzername", + "Use your usual address as the username.": "Verwenden Sie Ihre gewohnte Adresse als Benutzernamen.", + "Fast, friendly webmail. Your mailbox, your way.": "Schnelle, freundliche Webmail. Ihr Postfach, wie Sie es wollen.", + + "Notifications": "Benachrichtigungen", + "Notifications are blocked in your browser settings.": "Benachrichtigungen sind in Ihren Browsereinstellungen blockiert.", + "Not supported in this browser.": "In diesem Browser nicht unterstützt.", + "Desktop notifications while ihasmail is open": "Desktop-Benachrichtigungen, solange ihasmail geöffnet ist", + "Notify me even when ihasmail is closed": "Auch benachrichtigen, wenn ihasmail geschlossen ist", + "Play a sound for new mail": "Ton bei neuer E-Mail abspielen", + "Test notification": "Testbenachrichtigung", + "Background notifications are on": "Hintergrundbenachrichtigungen sind aktiviert", + "The tab title and favicon always show your unread Inbox count.": "Tab-Titel und Favicon zeigen immer die Anzahl ungelesener Nachrichten im Posteingang.", + "Live updates are delivered via JMAP push ({state}).": "Live-Aktualisierungen kommen über JMAP-Push ({state}).", + "Shows a system notification when new mail arrives in your Inbox while the tab is in the background.": "Zeigt eine Systembenachrichtigung, wenn neue Nachrichten im Posteingang eintreffen, während der Tab im Hintergrund ist.", + + // ── Editor, search, shortcuts, misc ──────────────────────────────── + "Formatting": "Formatierung", + "Formatting options": "Formatierungsoptionen", + "Remove formatting": "Formatierung entfernen", + "Bold (Ctrl+B)": "Fett (Strg+B)", + "Italic (Ctrl+I)": "Kursiv (Strg+I)", + "Underline (Ctrl+U)": "Unterstrichen (Strg+U)", + "Strikethrough": "Durchgestrichen", + "Highlight": "Hervorheben", + "Bulleted list": "Aufzählung", + "Numbered list": "Nummerierte Liste", + "Increase indent": "Einzug vergrößern", + "Decrease indent": "Einzug verkleinern", + "Align left": "Linksbündig", + "Align right": "Rechtsbündig", + "Center": "Zentriert", + "Quote": "Zitat", + "Code block": "Codeblock", + "Normal text": "Normaler Text", + "Insert link (Ctrl+K)": "Link einfügen (Strg+K)", + "Insert image": "Bild einfügen", + "Link": "Link", + "List": "Liste", + "Emoji": "Emoji", + "Write your message…": "Schreiben Sie Ihre Nachricht…", + "Search": "Suchen", + "Search mail": "E-Mail durchsuchen", + "Advanced search": "Erweiterte Suche", + "Keyboard shortcuts": "Tastenkürzel", + "Keyboard shortcuts (?)": "Tastenkürzel (?)", + "Shortcuts": "Kürzel", + "Go to": "Gehe zu", + "Menu": "Menü", + "Options": "Optionen", + "Send options": "Sendeoptionen", + "Name": "Name", + "Email": "E-Mail", + "Email address": "E-Mail-Adresse", + "Description": "Beschreibung", + "Location": "Ort", + "Visibility": "Sichtbarkeit", + "Private": "Privat", + "Work": "Arbeit", + "Loading…": "Wird geladen…", + "None": "Keine", + "optional": "optional", + "Always show": "Immer anzeigen", + "to": "an", + "Received": "Empfangen", + "In-Reply-To": "In-Reply-To", + "References": "References", + "Add label / keyword": "Label / Schlüsselwort hinzufügen", + "Manage labels": "Labels verwalten", + "Create “{name}”": "„{name}“ erstellen", + "Type a name to create your first label.": "Geben Sie einen Namen ein, um Ihr erstes Label zu erstellen.", + "Labels are IMAP keywords stored on your messages, so they sync to other clients. Names and colours are kept in this browser.": "Labels sind IMAP-Schlüsselwörter, die in Ihren Nachrichten gespeichert werden und daher mit anderen Clients synchronisiert werden. Namen und Farben bleiben in diesem Browser.", + "PDF": "PDF", + "Large attachments may be rejected by some servers": "Große Anhänge werden von manchen Servern abgelehnt", + "Images are stored in your Files (folder “ihasmail”) and embedded when you send.": "Bilder werden in Ihren Dateien (Ordner „ihasmail“) gespeichert und beim Senden eingebettet.", + "Thanks for your message. I'm away until … and will reply when I'm back.": "Vielen Dank für Ihre Nachricht. Ich bin bis … abwesend und melde mich nach meiner Rückkehr.", + "Automatically reply to people who email you while you're away. Each sender gets at most one reply.": "Automatisch auf E-Mails antworten, während Sie abwesend sind. Jeder Absender erhält höchstens eine Antwort.", + "Sort incoming mail automatically. Rules run on the server (Sieve), so they work for every client you use.": "Eingehende Nachrichten automatisch sortieren. Die Regeln laufen auf dem Server (Sieve) und gelten daher für jeden Client, den Sie nutzen.", + "Canned responses you can insert into any message from the composer's template button.": "Vorgefertigte Antworten, die Sie über die Vorlagen-Schaltfläche im Editor in jede Nachricht einfügen können.", + "Create a rule to move newsletters to a folder, flag important senders, or forward mail.": "Erstellen Sie eine Regel, um Newsletter in einen Ordner zu verschieben, wichtige Absender zu markieren oder Nachrichten weiterzuleiten.", + "Advanced: manage raw Sieve scripts. Only one script can be active at a time.": "Fortgeschritten: Sieve-Skripte direkt verwalten. Es kann immer nur ein Skript aktiv sein.", + "Only part of your filter script arrived.": "Ihr Filterskript ist nur teilweise angekommen.", + "Your active script “{name}” was written by hand.": "Ihr aktives Skript „{name}“ wurde von Hand geschrieben.", + "Another script (“{name}”) is active. Saving rules here will activate the “ihasmail” script instead.": "Ein anderes Skript („{name}“) ist aktiv. Wenn Sie hier Regeln speichern, wird stattdessen das Skript „ihasmail“ aktiviert.", + "“{name}” will be deactivated (not deleted) and a new “ihasmail” script will take over.": "„{name}“ wird deaktiviert (nicht gelöscht) und ein neues Skript „ihasmail“ übernimmt.", + "Sieve filtering is not available for this account.": "Sieve-Filterung ist für dieses Konto nicht verfügbar.", + "Sieve filtering is not enabled for this account.": "Sieve-Filterung ist für dieses Konto nicht aktiviert.", + "Vacation responses are not available for this account.": "Abwesenheitsnotizen sind für dieses Konto nicht verfügbar.", + "This account does not have the JMAP calendars capability.": "Dieses Konto verfügt nicht über die JMAP-Kalenderfunktion.", + "This account does not have the JMAP contacts capability.": "Dieses Konto verfügt nicht über die JMAP-Kontaktfunktion.", + "This account does not have the JMAP file storage capability.": "Dieses Konto verfügt nicht über die JMAP-Dateispeicherfunktion.", + + "tell us about it": "sagen Sie uns Bescheid", + "This translation was generated by AI and has not been checked by a native speaker, so it is marked Beta until somebody who speaks it signs it off. Anything that reads wrongly is worth reporting — {report}.": "Diese Übersetzung wurde von einer KI erstellt und nicht von einer Person mit Muttersprache Deutsch geprüft; sie ist daher als Beta gekennzeichnet, bis jemand sie freigibt. Alles, was falsch klingt, ist eine Meldung wert — {report}.", + + // ── Longer prose ─────────────────────────────────────────────────── + // Product names, example addresses and bare URLs are deliberately absent: + // they fall back to English because that is what they should say. + "After": "Nach", + "Before": "Vor", + "Modified": "Geändert", + "Middle name": "Zweiter Vorname", + "Minimize": "Minimieren", + "New label": "Neues Label", + "Delete label": "Label löschen", + "Choose a date": "Datum wählen", + "Choose a date and time": "Datum und Uhrzeit wählen", + "Pick date and time…": "Datum und Uhrzeit wählen…", + "Search mail (from:, to:, subject:, has:attachment, is:unread, in:, before:, after:)": "E-Mail durchsuchen (from:, to:, subject:, has:attachment, is:unread, in:, before:, after:)", + "Settings → Filters & rules": "Einstellungen → Filter & Regeln", + "Open the Mail view to see all shortcuts.": "Öffnen Sie die E-Mail-Ansicht, um alle Tastenkürzel zu sehen.", + "Gmail-style shortcuts are always on. Press {key} anywhere to see this list.": "Tastenkürzel im Gmail-Stil sind immer aktiv. Drücken Sie überall {key}, um diese Liste zu sehen.", + "Select a conversation to read it here · Press {key} for shortcuts": "Wählen Sie eine Konversation, um sie hier zu lesen · {key} für Tastenkürzel", + "Tip: press {key} on a conversation to apply labels. Search with {operator}.": "Tipp: Drücken Sie {key} auf einer Konversation, um Labels zu vergeben. Suchen Sie mit {operator}.", + "A fast, friendly, open-source webmail for {server}, built on JMAP.": "Eine schnelle, freundliche Open-Source-Webmail für {server}, auf JMAP aufgebaut.", + "Defaults for the calendar views and new events.": "Vorgaben für die Kalenderansichten und neue Termine.", + "Replies will go to this address instead of the From address": "Antworten gehen an diese Adresse statt an die Absenderadresse", + "Replies to mail sent from this identity go here instead of the From address.": "Antworten auf Nachrichten von dieser Identität gehen hierhin statt an die Absenderadresse.", + "New identities must use an address this account is allowed to send from (aliases configured on the server).": "Neue Identitäten müssen eine Adresse verwenden, von der dieses Konto senden darf (auf dem Server eingerichtete Aliase).", + "Not offered when composing. It still receives mail, and you can still send from it by showing it again.": "Wird beim Verfassen nicht angeboten. Die Adresse empfängt weiterhin Nachrichten, und Sie können wieder von ihr senden, indem Sie sie erneut einblenden.", + "Each identity is a sender address with its own name, Reply-To and signature. The default identity is preselected when you compose; set a Reply-To when replies should go somewhere other than the From address.": "Jede Identität ist eine Absenderadresse mit eigenem Namen, eigener Antwortadresse und eigener Signatur. Die Standardidentität ist beim Verfassen vorausgewählt; legen Sie eine Antwortadresse fest, wenn Antworten woanders hingehen sollen als an die Absenderadresse.", + "This signature is larger than the server's {limit}-byte limit. ihasmail will keep the full version in your Files and store a short text fallback on the server — other mail clients will see the plain-text version.": "Diese Signatur überschreitet das Limit des Servers von {limit} Byte. ihasmail behält die vollständige Fassung in Ihren Dateien und speichert eine kurze Textfassung auf dem Server — andere E-Mail-Programme sehen die Nur-Text-Fassung.", + "Outlook-style categories you can assign to events from the right-click menu or the event editor. The category name is stored on the event, so it syncs to other clients.": "Kategorien im Outlook-Stil, die Sie Terminen über das Rechtsklick-Menü oder den Termin-Editor zuweisen können. Der Kategoriename wird im Termin gespeichert und daher mit anderen Clients synchronisiert.", + "Plain-text mail already follows the theme. With this on, HTML mail that brings no colours of its own does as well, instead of sitting on a white card. Messages that style themselves are left exactly as the sender designed them.": "Nur-Text-Nachrichten folgen dem Design bereits. Ist dies aktiviert, gilt das auch für HTML-Nachrichten ohne eigene Farben, statt sie auf einer weißen Fläche darzustellen. Nachrichten mit eigener Gestaltung bleiben genau so, wie der Absender sie entworfen hat.", + "This is separate from {setting} in General, which decides how dates, times and numbers are written. You can read an English interface with German dates, or the other way round.": "Das ist unabhängig von {setting} unter „Allgemein“, wo festgelegt wird, wie Datum, Uhrzeit und Zahlen geschrieben werden. Sie können eine englische Oberfläche mit deutschen Datumsangaben lesen — oder umgekehrt.", + "On a touchscreen, drag a message sideways to act on it. Each direction can do one thing, or nothing. These follow your account, so a phone and a tablet agree; a mouse ignores them and keeps dragging messages into folders instead.": "Auf einem Touchscreen ziehen Sie eine Nachricht zur Seite, um sie zu bearbeiten. Jede Richtung kann eine Aktion ausführen — oder keine. Die Einstellung folgt Ihrem Konto, sodass Telefon und Tablet übereinstimmen; mit der Maus wird sie ignoriert und Nachrichten werden weiterhin in Ordner gezogen.", + "This screen has no touchscreen, so nothing here changes what it does. Your phone or tablet will pick these up.": "Dieser Bildschirm hat keinen Touchscreen, hier ändert sich also nichts. Ihr Telefon oder Tablet übernimmt diese Einstellungen.", + "Holding a message selects it, and holding a folder opens its menu. Pull the top of the message list down to check for new mail.": "Eine Nachricht gedrückt halten wählt sie aus, einen Ordner gedrückt halten öffnet dessen Menü. Ziehen Sie die Nachrichtenliste nach unten, um nach neuer Post zu sehen.", + "A receipt tells whoever asked that this address is live and when the message was read, and the sender chooses where it goes — so there is no automatic option. Bulk mail, mailing lists and anything marked auto-submitted are never offered one at all.": "Eine Bestätigung verrät dem Anfragenden, dass diese Adresse aktiv ist und wann die Nachricht gelesen wurde, und der Absender bestimmt, wohin sie geht — deshalb gibt es keine automatische Option. Bei Massensendungen, Mailinglisten und allem, was als automatisch versendet gekennzeichnet ist, wird sie nie angeboten.", + "This browser cannot register apps for {scheme} links. Safari, in particular, has no such API — you can still make ihasmail the default from your operating system if you install it as an app.": "Dieser Browser kann keine Programme für {scheme}-Links registrieren. Safari hat insbesondere keine solche Schnittstelle — Sie können ihasmail dennoch über Ihr Betriebssystem als Standard festlegen, wenn Sie es als App installieren.", + "Registering for {scheme} links requires a secure (HTTPS) connection.": "Für die Registrierung von {scheme}-Links ist eine sichere Verbindung (HTTPS) erforderlich.", + "Open {scheme} links — in web pages, documents and other apps — in ihasmail instead of a desktop mail client. Your browser will ask you to confirm, and you can change it later in its own settings (Chrome: Settings › Privacy and security › Site settings › Protocol handlers; Firefox: Settings › General › Applications).": "{scheme}-Links — auf Webseiten, in Dokumenten und anderen Programmen — in ihasmail öffnen statt in einem Desktop-Mailprogramm. Ihr Browser fragt nach einer Bestätigung, und Sie können das später in seinen eigenen Einstellungen ändern (Chrome: Einstellungen › Datenschutz und Sicherheit › Website-Einstellungen › Protokoll-Handler; Firefox: Einstellungen › Allgemein › Anwendungen).", + "Requested in this browser. Whether it took effect is up to the browser — check its settings if mail links still open elsewhere.": "In diesem Browser angefordert. Ob es gewirkt hat, entscheidet der Browser — prüfen Sie dessen Einstellungen, falls E-Mail-Links weiterhin anderswo geöffnet werden.", + "For a system-wide default, install ihasmail as an app first (in Chrome: the install icon in the address bar). Your operating system can then offer ihasmail directly wherever it asks which mail app to use.": "Für einen systemweiten Standard installieren Sie ihasmail zuerst als App (in Chrome: das Installationssymbol in der Adressleiste). Ihr Betriebssystem kann ihasmail dann überall dort direkt anbieten, wo es nach einem E-Mail-Programm fragt.", + "Needs a browser with the Push API and a mail server that publishes a push key.": "Erfordert einen Browser mit Push-API und einen Mailserver, der einen Push-Schlüssel veröffentlicht.", + "Your mail server delivers these straight to your browser, so they arrive with no ihasmail tab open, naming the sender and subject. Your browser still has to be running — if you quit it completely, notifications wait and arrive when you open it again.": "Ihr Mailserver stellt diese direkt an Ihren Browser zu, sodass sie auch ohne geöffneten ihasmail-Tab ankommen — mit Absender und Betreff. Ihr Browser muss dennoch laufen: Beenden Sie ihn vollständig, warten die Benachrichtigungen und kommen an, sobald Sie ihn wieder öffnen.", + "Your mail server can wake this browser, but will not include the sender or subject. Your browser still has to be running.": "Ihr Mailserver kann diesen Browser wecken, übermittelt aber weder Absender noch Betreff. Ihr Browser muss dennoch laufen.", + "This is what a new-mail notification looks like.": "So sieht eine Benachrichtigung über neue Post aus.", + "You're signed in as {user}. Your password is never stored in the browser; the server keeps it encrypted per-session for talking to Stalwart.": "Sie sind als {user} angemeldet. Ihr Passwort wird nie im Browser gespeichert; der Server hält es pro Sitzung verschlüsselt vor, um mit Stalwart zu kommunizieren.", + "App passwords are managed by your mail administrator.": "App-Passwörter werden von Ihrer Mail-Administration verwaltet.", + "Changing your password signs out your other webmail sessions. Any app passwords keep working.": "Wenn Sie Ihr Passwort ändern, werden Ihre anderen Webmail-Sitzungen abgemeldet. App-Passwörter funktionieren weiterhin.", + "This account has two-factor authentication on. ihasmail can't sign you in with a code yet, so signing in on another device needs an app password — or you can turn two-factor authentication off here.": "Für dieses Konto ist die Zwei-Faktor-Authentifizierung aktiviert. ihasmail kann Sie noch nicht per Code anmelden; die Anmeldung auf einem anderen Gerät benötigt daher ein App-Passwort — oder Sie deaktivieren die Zwei-Faktor-Authentifizierung hier.", + "A separate password for a mail app or device, which you can revoke on its own. App passwords skip two-factor codes, so they keep working in apps that can't ask for one.": "Ein eigenes Passwort für ein E-Mail-Programm oder Gerät, das Sie einzeln widerrufen können. App-Passwörter umgehen Zwei-Faktor-Codes und funktionieren daher auch in Programmen, die keinen abfragen können.", + "Copy it into {name} now — it isn't shown again.": "Übertragen Sie es jetzt nach {name} — es wird nicht erneut angezeigt.", + "No other users found in the directory, so nobody new can be added. Sharing already in place is listed below and can still be removed.": "Im Verzeichnis wurden keine weiteren Benutzer gefunden, es kann also niemand Neues hinzugefügt werden. Bestehende Freigaben sind unten aufgeführt und können weiterhin entfernt werden.", + "Stalwart does not publish its version number to mail clients, so ihasmail reports the edition where the server gives one. ihasmail requires 0.16 or newer, and sign-in refuses anything older.": "Stalwart gibt seine Versionsnummer nicht an E-Mail-Programme weiter, daher nennt ihasmail die Edition, sofern der Server eine angibt. ihasmail benötigt 0.16 oder neuer; die Anmeldung verweigert ältere Versionen.", + "It {damage}, so the rules in it can't be shown or edited — saving what did arrive would write it back over the rest. Reload the page to try again. Your rules are still on the server; nothing here has changed them.": "Es {damage}, daher können die enthaltenen Regeln weder angezeigt noch bearbeitet werden — das Speichern des angekommenen Teils würde den Rest überschreiben. Laden Sie die Seite neu und versuchen Sie es erneut. Ihre Regeln liegen weiterhin auf dem Server; hier wurde nichts daran geändert.", + "The visual rule editor only manages scripts it created. You can edit the script in the {tab} tab, or start fresh with rules (the existing script will be kept but deactivated).": "Der visuelle Regeleditor verwaltet nur Skripte, die er selbst erstellt hat. Sie können das Skript im Reiter {tab} bearbeiten oder neu mit Regeln beginnen (das vorhandene Skript bleibt erhalten, wird aber deaktiviert).", + "Your filter script {damage}, so only part of it arrived. Adding a rule would write that part back over the whole thing. Reload the page and try again.": "Ihr Filterskript {damage}, daher ist nur ein Teil angekommen. Eine Regel hinzuzufügen würde diesen Teil über das Ganze schreiben. Laden Sie die Seite neu und versuchen Sie es erneut.", + "Your filter script couldn't be read just now, so adding a rule would risk overwriting it. Reload the page and try again.": "Ihr Filterskript konnte gerade nicht gelesen werden; eine Regel hinzuzufügen würde riskieren, es zu überschreiben. Laden Sie die Seite neu und versuchen Sie es erneut.", + "Your active Sieve script was written by hand, so rules can't be added automatically. Open {where} to edit the script or switch to managed rules.": "Ihr aktives Sieve-Skript wurde von Hand geschrieben, daher können Regeln nicht automatisch hinzugefügt werden. Öffnen Sie {where}, um das Skript zu bearbeiten oder zu verwalteten Regeln zu wechseln.", + "Only languages ihasmail has been translated into appear here, so this list grows as translations land rather than ahead of them — a language offered without strings behind it would leave the page claiming to be in a language it is not.": "Hier erscheinen nur Sprachen, in die ihasmail übersetzt wurde; die Liste wächst also mit den Übersetzungen und nicht vorab — eine Sprache ohne hinterlegte Texte würde die Seite behaupten lassen, sie sei in einer Sprache, in der sie nicht ist.", + + // ── Labels defined as constants, translated where they render ────── + // The catalogue checker cannot see these: they reach t() as a variable, + // so there is no string literal for it to find. Listed here deliberately. + "About": "Über", + "Add": "Hinzufügen", + "Add files": "Dateien hinzufügen", + "Automatic": "Automatisch", + "Create subfolders": "Unterordner erstellen", + "Dark": "Dunkel", + "Light": "Hell", + "Match system": "System folgen", + "Day.Month.Year": "Tag.Monat.Jahr", + "Day/Month/Year": "Tag/Monat/Jahr", + "Month/Day/Year": "Monat/Tag/Jahr", + "Year-Month-Day (ISO 8601)": "Jahr-Monat-Tag (ISO 8601)", + "Edit all": "Alles bearbeiten", + "Edit contents": "Inhalte bearbeiten", + "Edit own": "Eigene bearbeiten", + "Flag": "Markieren", + "Mark read": "Als gelesen markieren", + "Private props": "Private Eigenschaften", + "Read": "Lesen", + "Read events": "Termine lesen", + "RSVP": "Zusagen", + "See free/busy": "Frei/Gebucht sehen", + "Share": "Freigeben", + "Write": "Schreiben", + "Live updates connected": "Live-Aktualisierungen verbunden", + "Live updates reconnecting…": "Live-Aktualisierungen werden wieder verbunden…", + "Live updates off — checking periodically instead": "Live-Aktualisierungen aus — es wird stattdessen regelmäßig geprüft", + "Mark as read / unread": "Als gelesen / ungelesen markieren", + "Star / unstar": "Markieren / Markierung entfernen", + "Report spam / not spam": "Als Spam / Nicht-Spam melden", + "Report spam": "Als Spam melden", + "Not spam": "Kein Spam", + "Nothing": "Nichts", + + "No conversation selected": "Keine Konversation ausgewählt", + "Drop here for the top level": "Hierher ziehen für die oberste Ebene", + + // ── Remaining prose ──────────────────────────────────────────────── + "{name} is the palette from {site}, and what a new account starts on. It is a dark theme, so it counts as dark wherever that matters, and the accent colour below still applies on top of it.": "{name} ist die Farbpalette von {site} und das, womit ein neues Konto startet. Es ist ein dunkles Design und zählt daher überall dort als dunkel, wo das eine Rolle spielt; die Akzentfarbe unten wirkt weiterhin darauf.", + "ihasmail's own version is the date of the commit it was built from, followed by where that commit came from: {example} was built from a commit dated the 30th of August 2026 that arrived through pull request 129. A commit that did not come through one carries its short SHA instead — {sha}. The version deliberately says nothing about Stalwart; what this build needs from the server is the line above.": "Die Version von ihasmail ist das Datum des Commits, aus dem es gebaut wurde, gefolgt davon, woher dieser Commit stammt: {example} wurde aus einem Commit vom 30. August 2026 gebaut, der über Pull Request 129 kam. Ein Commit, der nicht über einen solchen kam, trägt stattdessen seinen kurzen SHA — {sha}. Die Version sagt bewusst nichts über Stalwart aus; was dieser Build vom Server benötigt, steht in der Zeile darüber.", + + // ── Weekdays, schedule presets, rule operators ───────────────────── + // Header names (List-Id, X-Spam-Status) stay English: they are the actual + // field names in the message, not words. + "Tuesday": "Dienstag", + "Wednesday": "Mittwoch", + "Thursday": "Donnerstag", + "Friday": "Freitag", + "Later today": "Später heute", + "Tomorrow morning": "Morgen früh", + "Tomorrow afternoon": "Morgen Nachmittag", + "Monday morning": "Montagmorgen", + "Open draft": "Entwurf öffnen", + "Undo": "Rückgängig", + "Deleted Items": "Papierkorb", + "Other header…": "Andere Kopfzeile…", + "is": "ist", + "is not": "ist nicht", + "matches (wildcards * ?)": "entspricht (Platzhalter * ?)", + "does not match": "entspricht nicht", + "matches regex": "entspricht regulärem Ausdruck", + "does not match regex": "entspricht regulärem Ausdruck nicht", + "exists": "ist vorhanden", + "does not exist": "ist nicht vorhanden", + + // ── Folder names shown for a JMAP role (see lib/mailboxName.ts) ──── + // Not what the server calls them: Stalwart names these once at account + // creation and cannot rename them afterwards. Custom folders keep the + // reader's own words and are never translated. + // Keyed with the "folder" context (see tc() in lib/i18n.ts), because two + // of these are also something else in English: "Archive" is the button + // that archives a message, and "Important" is a priority tag. German wants + // a different word for each, and one key cannot hold both. + "folder\u0004Inbox": "Posteingang", + "folder\u0004Archive": "Archiv", + "folder\u0004Drafts": "Entwürfe", + "folder\u0004Sent": "Gesendet", + "folder\u0004Deleted Items": "Papierkorb", + "folder\u0004Junk Mail": "Spam", + "folder\u0004Important": "Wichtig", + "folder\u0004All mail": "Alle Nachrichten", + "folder": "Ordner", + "“{name}” moved into “{parent}”": "„{name}“ wurde nach „{parent}“ verschoben", + "“{name}” moved to the top level": "„{name}“ wurde auf die oberste Ebene verschoben", + "Could not move “{name}”: {reason}": "„{name}“ konnte nicht verschoben werden: {reason}", + "Delete “{name}”?": "„{name}“ löschen?", + "Rename folder": "Ordner umbenennen", + "Search: {query}": "Suche: {query}", + }, + plurals: { + "{n} messages": { one: "{n} Nachricht", other: "{n} Nachrichten" }, + "{n} selected": { one: "{n} ausgewählt", other: "{n} ausgewählt" }, + "{n} conversations": { one: "{n} Konversation", other: "{n} Konversationen" }, + }, +}; diff --git a/web/src/store/__tests__/lang-attribute.test.ts b/web/src/store/__tests__/lang-attribute.test.ts index 272c118..b3a1c90 100644 --- a/web/src/store/__tests__/lang-attribute.test.ts +++ b/web/src/store/__tests__/lang-attribute.test.ts @@ -34,8 +34,15 @@ describe("applyLang", () => { expect(document.documentElement.lang).toBe("en"); }); - it("falls back to English rather than claiming a language it cannot render", () => { + it("serves a language whose catalogue is shipped", () => { applyLang({ ...DEFAULT_SETTINGS, uiLanguage: "de" }); + expect(document.documentElement.lang).toBe("de"); + }); + + it("falls back to English rather than claiming a language it cannot render", () => { + // A tag no catalogue exists for -- an account carrying a preference from a + // build that shipped more languages than this one. + applyLang({ ...DEFAULT_SETTINGS, uiLanguage: "fr" }); expect(document.documentElement.lang).toBe("en"); }); diff --git a/web/src/store/mail.ts b/web/src/store/mail.ts index fddc9a9..6b2c4fa 100644 --- a/web/src/store/mail.ts +++ b/web/src/store/mail.ts @@ -21,6 +21,8 @@ import type { import { toast } from "@/ui/toast"; import { settings, useSettings } from "./settings"; import { useSession } from "./session"; +import { mailboxDisplayName } from "@/lib/mailboxName"; +import { t } from "@/lib/i18n"; /* * Named explicitly so `shareWith` comes back, which it does not otherwise -- @@ -478,7 +480,9 @@ export const useMail = create((set, get) => ({ // moved to "Trash" or "Spam" on a server whose folders are called // "Deleted Items" and "Junk Mail" -- naming somewhere that does not // exist, in the one message whose job is saying where it went. - const name = mailboxes[toMailboxId]?.name ?? opts.label ?? "folder"; + // Through the display name, so the message names the folder the reader + // is looking at in the sidebar rather than the server's own word for it. + const name = mailboxDisplayName(mailboxes[toMailboxId]) || opts.label || t("folder"); toast.show(`${ids.length === 1 ? "Conversation" : `${ids.length} conversations`} moved to ${name}`, { action: { label: "Undo", diff --git a/web/src/store/settings.ts b/web/src/store/settings.ts index 8c767bb..80975f8 100644 --- a/web/src/store/settings.ts +++ b/web/src/store/settings.ts @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import { create } from "zustand"; import { loadJson, saveJson } from "@/lib/storage"; import { queueSettingsPush } from "@/lib/settingsSync"; -import { setDateTimePrefs, type DateFormat, type TimeFormat } from "@/lib/datetime"; +import { setDateTimePrefs, setUiLanguageForFormatting, type DateFormat, type TimeFormat } from "@/lib/datetime"; import type { SwipeAction } from "@/lib/swipe"; import { resolveUiLanguage } from "@/lib/languages"; import { loadLanguage } from "@/lib/i18n"; @@ -344,6 +344,9 @@ export const useSettings = create((set, get) => ({ })); function applyDateTimePrefs(s: Settings): void { + // The interface language feeds the automatic locale, so month and weekday + // names follow the language somebody chose rather than staying English. + setUiLanguageForFormatting(resolveUiLanguage(s.uiLanguage)); setDateTimePrefs({ locale: s.locale, dateFormat: s.dateFormat, timeFormat: s.timeFormat }); } diff --git a/web/src/views/AppShell.tsx b/web/src/views/AppShell.tsx index 3a0eb7b..d2c709b 100644 --- a/web/src/views/AppShell.tsx +++ b/web/src/views/AppShell.tsx @@ -81,7 +81,7 @@ export function AppShell({ children }: { children: ReactNode }) {
- +
{(section === "mail" || section === "search") && } diff --git a/web/src/views/mail/FilterFromMessage.tsx b/web/src/views/mail/FilterFromMessage.tsx index de92438..d22d2e4 100644 --- a/web/src/views/mail/FilterFromMessage.tsx +++ b/web/src/views/mail/FilterFromMessage.tsx @@ -8,7 +8,7 @@ import { RuleDialog } from "../settings/RuleDialog"; import { toast } from "@/ui/toast"; import { Spinner } from "@/ui/misc"; import { Dialog } from "@/ui/dialog"; -import { t } from "@/lib/i18n"; +import { t, tNode } from "@/lib/i18n"; /** "Filter messages like this…" — creates a Sieve rule seeded from a message, optionally applying it to the current folder. */ export function FilterFromMessageDialog({ email, mailboxId, onClose }: { email: Email; mailboxId: Id | null; onClose: () => void }) { @@ -47,7 +47,7 @@ export function FilterFromMessageDialog({ email, mailboxId, onClose }: { email: {damage ? (

{t("Your filter script {damage}, so only part of it arrived. Adding a rule would write that part back over the whole thing. Reload the page and try again.", { damage })}

) : loaded ? ( -

{t("Your active Sieve script was written by hand, so rules can't be added automatically. Open")} {t("Settings → Filters & rules")} {t("to edit the script or switch to managed rules.")}

+

{tNode("Your active Sieve script was written by hand, so rules can't be added automatically. Open {where} to edit the script or switch to managed rules.", { where: {t("Settings → Filters & rules")} })}

) : (

{t("Your filter script couldn't be read just now, so adding a rule would risk overwriting it. Reload the page and try again.")}

)} diff --git a/web/src/views/mail/MailView.tsx b/web/src/views/mail/MailView.tsx index d00d75f..4f84828 100644 --- a/web/src/views/mail/MailView.tsx +++ b/web/src/views/mail/MailView.tsx @@ -16,7 +16,8 @@ import { confirmDialog } from "@/ui/dialog"; import { toast } from "@/ui/toast"; import { isUnknownMailbox } from "@/lib/mailboxRoute"; import { scheduledMailboxIdFrom, useScheduled } from "@/store/scheduled"; -import { t as translate } from "@/lib/i18n"; +import { plural, t as translate, tNode } from "@/lib/i18n"; +import { mailboxDisplayName } from "@/lib/mailboxName"; export function MailView({ mailboxId, threadId, search }: { mailboxId?: string; threadId?: string; search?: boolean }) { const [, navigate] = useLocation(); @@ -306,7 +307,7 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string; [emails, mailboxId, mailboxes, openThread, openDraft], ); - const title = search ? `Search: ${listQuery?.label ?? q}` : (mailboxId && mailboxes[mailboxId]?.name) || "Mail"; + const title = search ? translate("Search: {query}", { query: listQuery?.label ?? q }) : (mailboxId && mailboxDisplayName(mailboxes[mailboxId])) || translate("Mail"); const reading = Boolean(threadId); const paneClass = settings.readingPane === "bottom" ? "pane-bottom" : settings.readingPane === "off" ? "pane-off" : "pane-right"; const showList = !(settings.readingPane === "off" && reading) && !(narrow && reading); @@ -353,8 +354,8 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string; ) : (
-
{list?.total ? `${list.total} conversation${list.total === 1 ? "" : "s"}` : "No conversation selected"}
-
{translate("Select a conversation to read it here · Press")} ? {translate("for shortcuts")}
+
{list?.total ? plural(list.total, { one: "{n} conversation", other: "{n} conversations" }) : translate("No conversation selected")}
+
{tNode("Select a conversation to read it here · Press {key} for shortcuts", { key: ? })}
)}
diff --git a/web/src/views/mail/MailboxPicker.tsx b/web/src/views/mail/MailboxPicker.tsx index 2d92bd0..8bd9630 100644 --- a/web/src/views/mail/MailboxPicker.tsx +++ b/web/src/views/mail/MailboxPicker.tsx @@ -4,6 +4,7 @@ import { useMail } from "@/store/mail"; import { Dialog } from "@/ui/dialog"; import type { Id, Mailbox } from "@/jmap/types"; import { t } from "@/lib/i18n"; +import { mailboxDisplayPath } from "@/lib/mailboxName"; export function MailboxPicker({ title, onClose, onPick, exclude }: { title: string; onClose: () => void; onPick: (id: Id) => void; exclude?: Id[] }) { const mailboxes = useMail((s) => s.mailboxes); @@ -13,7 +14,7 @@ export function MailboxPicker({ title, onClose, onPick, exclude }: { title: stri const list = useMemo(() => { const all = Object.values(mailboxes) .filter((m) => !exclude?.includes(m.id) && m.myRights.mayAddItems) - .map((m) => ({ m, path: mailboxPath(m.id) })) + .map((m) => ({ m, path: mailboxDisplayPath(m, mailboxes) })) .sort((a, b) => (a.m.role === "inbox" ? -1 : b.m.role === "inbox" ? 1 : a.path.localeCompare(b.path))); const ql = q.trim().toLowerCase(); return ql ? all.filter((x) => x.path.toLowerCase().includes(ql)) : all; diff --git a/web/src/views/mail/MailboxTree.tsx b/web/src/views/mail/MailboxTree.tsx index a0edbd2..d2b3de9 100644 --- a/web/src/views/mail/MailboxTree.tsx +++ b/web/src/views/mail/MailboxTree.tsx @@ -15,6 +15,7 @@ import { loadRaw, saveJson } from "@/lib/storage"; import { canDropFolder, folderColor, movable } from "@/lib/folderMove"; import { haptic, useTouchRow } from "@/lib/touch"; import { t } from "@/lib/i18n"; +import { mailboxDisplayName } from "@/lib/mailboxName"; const ROLE_ICONS: Record = { inbox: , @@ -63,9 +64,9 @@ export function MailboxTree() { setExpanded(next); saveJson("mbx-expanded", next); } - toast.success(parentId ? `“${m?.name}” moved into “${mailboxes[parentId]?.name}”` : `“${m?.name}” moved to the top level`); + toast.success(parentId ? t("“{name}” moved into “{parent}”", { name: mailboxDisplayName(m), parent: mailboxDisplayName(mailboxes[parentId]) }) : t("“{name}” moved to the top level", { name: mailboxDisplayName(m) })); } catch (err) { - toast.error(`Could not move “${m?.name}”: ${(err as Error).message}`); + toast.error(t("Could not move “{name}”: {reason}", { name: mailboxDisplayName(m), reason: (err as Error).message })); } }; @@ -143,7 +144,7 @@ export function MailboxTree() { if (id) void moveFolder(id, null); }} > - {draggingId && canDropOn(null) ? "Drop here for the top level" : "Folders"} + {draggingId && canDropOn(null) ? t("Drop here for the top level") : t("Folders")} @@ -152,7 +153,7 @@ export function MailboxTree() { { - const name = await promptDialog({ title: "Rename folder", defaultValue: m.name }); + const name = await // The server's own name, never the localised one: this box writes + // back whatever it is prefilled with. + promptDialog({ title: t("Rename folder"), defaultValue: m.name }); if (!name?.trim() || name.trim() === m.name) return; try { await useMail.getState().updateMailbox(m.id, { name: name.trim() }); @@ -341,7 +344,7 @@ function MailboxMenu({ mailbox: m, onClose, onCreateChild, onShare }: { mailbox: } }; const remove = async () => { - const ok = await confirmDialog({ title: `Delete “${m.name}”?`, message: `This permanently deletes the folder and its ${m.totalEmails} message(s).`, confirmLabel: "Delete", danger: true }); + const ok = await confirmDialog({ title: t("Delete “{name}”?", { name: mailboxDisplayName(m) }), message: `This permanently deletes the folder and its ${m.totalEmails} message(s).`, confirmLabel: "Delete", danger: true }); if (!ok) return; try { await useMail.getState().destroyMailbox(m.id, true); @@ -351,7 +354,7 @@ function MailboxMenu({ mailbox: m, onClose, onCreateChild, onShare }: { mailbox: toast.error((err as Error).message); } }; - const empty = () => confirmAndEmpty({ id: m.id, name: m.name, role: m.role, totalEmails: m.totalEmails }); + const empty = () => confirmAndEmpty({ id: m.id, name: mailboxDisplayName(m), role: m.role, totalEmails: m.totalEmails }); const isSpecial = Boolean(m.role) && m.role !== "subscribed"; const color = folderColor(colors, m.id); const setColor = (c: string | null) => { diff --git a/web/src/views/mail/MessageView.tsx b/web/src/views/mail/MessageView.tsx index 511d91a..d68e0e4 100644 --- a/web/src/views/mail/MessageView.tsx +++ b/web/src/views/mail/MessageView.tsx @@ -24,7 +24,7 @@ import { useScheduled } from "@/store/scheduled"; import { formatScheduleTime } from "@/lib/schedule"; import { mdnDecision, refusalText } from "@/lib/mdn"; import { sendReadReceipt } from "@/store/mdn"; -import { t as translate } from "@/lib/i18n"; +import { t as translate, tNode } from "@/lib/i18n"; interface Props { email: Email; @@ -226,7 +226,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn {translate("The sender asked for a read receipt.")} {receipt.redirected && ( - <> {translate("It would go to")} {receipt.to!.email}{translate(", which is not where the message came from.")} + <>{tNode("It would go to {address}, which is not where the message came from.", { address: {receipt.to!.email} })} )}
))}

- {translate("ihasmail")} {translate("is the palette from")} {translate("ihasmail.org")}{translate(", and what a new account starts on. It is a dark theme, so it counts as dark wherever that matters, and the accent colour below still applies on top of it.")} + {tNode("{name} is the palette from {site}, and what a new account starts on. It is a dark theme, so it counts as dark wherever that matters, and the accent colour below still applies on top of it.", { name: ihasmail, site: ihasmail.org })}

{translate("Interface language")} @@ -93,13 +95,27 @@ export function AppearanceSettings() { looks broken; a picker with one entry and a sentence explaining that more are coming is a roadmap. */} + {/* + Said plainly rather than buried. A machine translation presented as a + finished one is the version of this that does harm: a reader told it was + unchecked forgives an odd sentence and reports it, while a reader told + it was reviewed reasonably concludes the product is sloppy. The report + link is the entire review process, so it belongs one click from the + thing being complained about. + */} + {betaChosen && ( +

+ {tNode("This translation was generated by AI and has not been checked by a native speaker, so it is marked Beta until somebody who speaks it signs it off. Anything that reads wrongly is worth reporting — {report}.", { + report: {translate("tell us about it")}, + })} +

+ )}

- {translate("Only languages ihasmail has been translated into appear here, so this list grows as translations land rather than ahead of them — a language offered without strings behind it would leave the page claiming to be in a language it is not.")}

- {translate("This is separate from")} {translate("Language & region")} {translate("in General, which decides how dates, times and numbers are written. You can read an English interface with German dates, or the other way round.")} + {tNode("This is separate from {setting} in General, which decides how dates, times and numbers are written. You can read an English interface with German dates, or the other way round.", { setting: {translate("Language & region")} })}

{translate("Swiping")}

@@ -112,7 +128,7 @@ export function AppearanceSettings() { @@ -120,7 +136,7 @@ export function AppearanceSettings() { diff --git a/web/src/views/settings/FoldersSettings.tsx b/web/src/views/settings/FoldersSettings.tsx index bfa238a..4e1b0ee 100644 --- a/web/src/views/settings/FoldersSettings.tsx +++ b/web/src/views/settings/FoldersSettings.tsx @@ -7,6 +7,7 @@ import { formatSize } from "@/lib/format"; import { ShareDialog } from "./ShareDialog"; import type { Mailbox } from "@/jmap/types"; import { t } from "@/lib/i18n"; +import { mailboxDisplayPath } from "@/lib/mailboxName"; export function FoldersSettings() { const mailboxes = useMail((s) => s.mailboxes); @@ -42,12 +43,14 @@ export function FoldersSettings() { {list.map(({ m, path }) => ( -
{m.role === "inbox" ? : }{path}{!m.isSubscribed && {t("hidden")}}{m.role && m.role !== "subscribed" && ({m.role})}
+
{m.role === "inbox" ? : }{mailboxDisplayPath(m, mailboxes)}{!m.isSubscribed && {t("hidden")}}{m.role && m.role !== "subscribed" && ({m.role})}
{m.totalEmails.toLocaleString()} {m.unreadEmails.toLocaleString()}
- + {Object.keys(m.shareWith ?? {}).length > 0 && } diff --git a/web/src/views/settings/GeneralSettings.tsx b/web/src/views/settings/GeneralSettings.tsx index 1eda139..135ef52 100644 --- a/web/src/views/settings/GeneralSettings.tsx +++ b/web/src/views/settings/GeneralSettings.tsx @@ -159,7 +159,7 @@ export function GeneralSettings() { diff --git a/web/src/views/settings/LabelsSettings.tsx b/web/src/views/settings/LabelsSettings.tsx index a0574cf..f879bce 100644 --- a/web/src/views/settings/LabelsSettings.tsx +++ b/web/src/views/settings/LabelsSettings.tsx @@ -3,7 +3,7 @@ import { Plus, Trash2 } from "lucide-react"; import { useSettings } from "@/store/settings"; import { CALENDAR_COLORS, ColorSwatches } from "@/ui/misc"; import { promptDialog } from "@/ui/dialog"; -import { t } from "@/lib/i18n"; +import { t, tNode } from "@/lib/i18n"; export function LabelsSettings() { const labels = useSettings((s) => s.settings.labels); @@ -39,7 +39,7 @@ export function LabelsSettings() {
))} -

{t("Tip: press")} l {t("on a conversation to apply labels. Search with")} label:name.

+

{tNode("Tip: press {key} on a conversation to apply labels. Search with {operator}.", { key: l, operator: label:name })}

); } diff --git a/web/src/views/settings/SecuritySettings.tsx b/web/src/views/settings/SecuritySettings.tsx index 621322f..23368ea 100644 --- a/web/src/views/settings/SecuritySettings.tsx +++ b/web/src/views/settings/SecuritySettings.tsx @@ -5,7 +5,7 @@ import { useSession } from "@/store/session"; import { formatFullDate } from "@/lib/format"; import { toast } from "@/ui/toast"; import { confirmDialog, Dialog } from "@/ui/dialog"; -import { t } from "@/lib/i18n"; +import { t, tNode } from "@/lib/i18n"; interface SessionRow { id: string; @@ -59,7 +59,7 @@ export function SecuritySettings() { return (

{t("Security & sessions")}

-

{t("You're signed in as")} {session?.username}{t(". Your password is never stored in the browser; the server keeps it encrypted per-session for talking to Stalwart.")}

+

{tNode("You're signed in as {user}. Your password is never stored in the browser; the server keeps it encrypted per-session for talking to Stalwart.", { user: {session?.username} })}

{t("Password")}

{unsupported ? ( @@ -303,7 +303,7 @@ function AppPasswords({ state, reload }: { state: SecurityState | null; reload: footer={}> {issued && (
-

{t("Copy it into")} {issued.description} {t("now — it isn't shown again.")}

+

{tNode("Copy it into {name} now — it isn't shown again.", { name: {issued.description} })}

{t("Use your usual address as the username.")}

diff --git a/web/src/views/settings/SettingsView.tsx b/web/src/views/settings/SettingsView.tsx index bdd2a52..4878997 100644 --- a/web/src/views/settings/SettingsView.tsx +++ b/web/src/views/settings/SettingsView.tsx @@ -44,7 +44,7 @@ export function SettingsView({ section }: { section?: string }) { {SECTIONS.map((s) => ( {s.icon} - {s.label} + {t(s.label)} ))}
{t("Shortcuts")}
diff --git a/web/src/views/settings/ShareDialog.tsx b/web/src/views/settings/ShareDialog.tsx index 5b6b0ac..7ebfd26 100644 --- a/web/src/views/settings/ShareDialog.tsx +++ b/web/src/views/settings/ShareDialog.tsx @@ -142,7 +142,7 @@ export function ShareDialog({ kind, id, name, shareWith, onClose }: { kind: Kind {RIGHTS[kind].map((rt) => ( ))}
diff --git a/web/src/views/settings/ShortcutsSettings.tsx b/web/src/views/settings/ShortcutsSettings.tsx index a134644..b721b9f 100644 --- a/web/src/views/settings/ShortcutsSettings.tsx +++ b/web/src/views/settings/ShortcutsSettings.tsx @@ -1,7 +1,7 @@ import { useMemo } from "react"; import { keyboard } from "@/lib/keyboard"; import { Kbd } from "@/ui/misc"; -import { t } from "@/lib/i18n"; +import { t, tNode } from "@/lib/i18n"; export function ShortcutsSettings() { const list = useMemo(() => keyboard.list(), []); @@ -17,7 +17,7 @@ export function ShortcutsSettings() { return (

{t("Keyboard shortcuts")}

-

{t("Gmail-style shortcuts are always on. Press")} ? {t("anywhere to see this list.")}

+

{tNode("Gmail-style shortcuts are always on. Press {key} anywhere to see this list.", { key: ? })}

{groups.map(([group, items]) => (