Merge pull request #150 from Coffey-Labs/i18n-folder-names
Folder names follow the language, and three bugs that found
This commit is contained in:
+2
-1
@@ -22,7 +22,8 @@
|
|||||||
"mock": "npm run mock -w server",
|
"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": "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\"",
|
"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": {
|
"devDependencies": {
|
||||||
"concurrently": "^9.1.2",
|
"concurrently": "^9.1.2",
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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`);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
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 = {
|
const de: Catalog = {
|
||||||
strings: {
|
strings: {
|
||||||
@@ -109,3 +109,44 @@ describe("tNode", () => {
|
|||||||
expect(render(tNode("{count} of {scheme}", { scheme: <b>x</b> }, { count: 3 }))).toBe("3 of <b>x</b>");
|
expect(render(tNode("{count} of {scheme}", { scheme: <b>x</b> }, { count: 3 }))).toBe("3 of <b>x</b>");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("setCatalog", () => {
|
||||||
|
it("does not announce a change that did not happen", () => {
|
||||||
|
/*
|
||||||
|
* The root keys its tree on the language version, so every publish
|
||||||
|
* remounts the app -- which re-runs the effect that loads the account's
|
||||||
|
* settings, which calls applyLang, which lands back in setCatalog with the
|
||||||
|
* same language. Publishing that non-change looped for ever, and from the
|
||||||
|
* outside it looked like the message list refreshing without end.
|
||||||
|
*/
|
||||||
|
const seen: number[] = [];
|
||||||
|
const stop = subscribeForTest(() => seen.push(1));
|
||||||
|
const cat: Catalog = { strings: { Archive: "Archivieren" }, plurals: {} };
|
||||||
|
setCatalog("de", cat);
|
||||||
|
setCatalog("de", cat);
|
||||||
|
setCatalog("de", cat);
|
||||||
|
expect(seen.length).toBe(1);
|
||||||
|
setCatalog("en", { strings: {}, plurals: {} });
|
||||||
|
expect(seen.length).toBe(2);
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("tc", () => {
|
||||||
|
it("tells apart an English word doing two jobs", () => {
|
||||||
|
// "Archive" is the button and the folder; German wants a different word
|
||||||
|
// for each, and one key cannot hold both.
|
||||||
|
setCatalog("de", {
|
||||||
|
strings: { "Archive": "Archivieren", [`folder${CONTEXT_SEPARATOR}Archive`]: "Archiv" },
|
||||||
|
plurals: {},
|
||||||
|
});
|
||||||
|
expect(t("Archive")).toBe("Archivieren");
|
||||||
|
expect(tc("folder", "Archive")).toBe("Archiv");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the plain translation, then to English", () => {
|
||||||
|
setCatalog("de", { strings: { "Drafts": "Entwürfe" }, plurals: {} });
|
||||||
|
expect(tc("folder", "Drafts")).toBe("Entwürfe"); // no context entry yet
|
||||||
|
expect(tc("folder", "Sent")).toBe("Sent"); // nothing at all
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -20,12 +20,20 @@ describe("resolveUiLanguage", () => {
|
|||||||
|
|
||||||
it("refuses a language whose strings are not shipped", () => {
|
it("refuses a language whose strings are not shipped", () => {
|
||||||
// The account travels between machines and can outlive a catalogue. A
|
// 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.
|
// 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");
|
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", () => {
|
it("honours one that is", () => {
|
||||||
for (const l of UI_LANGUAGES) expect(resolveUiLanguage(l.tag)).toBe(l.tag);
|
for (const l of UI_LANGUAGES) expect(resolveUiLanguage(l.tag)).toBe(l.tag);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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<string, Mailbox> = { a: mb("a", "A", null, "b"), b: mb("b", "B", null, "a") };
|
||||||
|
expect(mailboxDisplayPath(all.a!, all)).toBe("B / A");
|
||||||
|
});
|
||||||
|
});
|
||||||
+25
-3
@@ -24,6 +24,28 @@ export interface DateTimePrefs {
|
|||||||
|
|
||||||
const DEFAULT_PREFS: DateTimePrefs = { locale: "", dateFormat: "auto", timeFormat: "auto" };
|
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 prefs: DateTimePrefs = DEFAULT_PREFS;
|
||||||
let serverLocale: string | null = null;
|
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 {
|
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. */
|
/** 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).
|
* outside the generated list is still selectable).
|
||||||
*/
|
*/
|
||||||
export function localeOptions(): LocaleOption[] {
|
export function localeOptions(): LocaleOption[] {
|
||||||
const extras = `${serverLocale ?? ""}|${prefs.locale}`;
|
const extras = `${serverLocale ?? ""}|${prefs.locale}|${uiLanguage ?? ""}`;
|
||||||
if (optionsCache && optionsExtras === extras) return optionsCache;
|
if (optionsCache && optionsExtras === extras) return optionsCache;
|
||||||
const tags = new Set<string>(LOCALE_TAGS);
|
const tags = new Set<string>(LOCALE_TAGS);
|
||||||
if (serverLocale) tags.add(serverLocale);
|
if (serverLocale) tags.add(serverLocale);
|
||||||
|
|||||||
@@ -61,6 +61,30 @@ export function t(source: string, vars?: Vars): string {
|
|||||||
return interpolate(current.strings[source] ?? source, vars);
|
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.
|
* Translate a counted thing.
|
||||||
*
|
*
|
||||||
@@ -119,6 +143,12 @@ export function tNode(source: string, parts: Record<string, ReactNode>, vars?: V
|
|||||||
return out;
|
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. */
|
/** The language in force, for anything that needs the tag itself. */
|
||||||
export function currentLanguage(): string {
|
export function currentLanguage(): string {
|
||||||
return currentTag;
|
return currentTag;
|
||||||
@@ -132,6 +162,23 @@ export function currentLanguage(): string {
|
|||||||
* language's rules against another's forms.
|
* language's rules against another's forms.
|
||||||
*/
|
*/
|
||||||
export function setCatalog(tag: string, catalog: Catalog): void {
|
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;
|
currentTag = tag;
|
||||||
current = catalog;
|
current = catalog;
|
||||||
publish();
|
publish();
|
||||||
|
|||||||
@@ -23,12 +23,26 @@ export interface UiLanguage {
|
|||||||
tag: string;
|
tag: string;
|
||||||
/** The language's name in that language, which is how a picker should read. */
|
/** The language's name in that language, which is how a picker should read. */
|
||||||
name: string;
|
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[] = [
|
export const UI_LANGUAGES: readonly UiLanguage[] = [
|
||||||
{ tag: "en", name: "English" },
|
{ 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";
|
export const DEFAULT_UI_LANGUAGE = "en";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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, () => 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, Mailbox>): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
let cur: Mailbox | undefined = mailbox;
|
||||||
|
const seen = new Set<string>();
|
||||||
|
while (cur && !seen.has(cur.id)) {
|
||||||
|
seen.add(cur.id);
|
||||||
|
parts.unshift(mailboxDisplayName(cur));
|
||||||
|
cur = cur.parentId ? all[cur.parentId] : undefined;
|
||||||
|
}
|
||||||
|
return parts.join(" / ");
|
||||||
|
}
|
||||||
@@ -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" },
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -34,8 +34,15 @@ describe("applyLang", () => {
|
|||||||
expect(document.documentElement.lang).toBe("en");
|
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" });
|
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");
|
expect(document.documentElement.lang).toBe("en");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import type {
|
|||||||
import { toast } from "@/ui/toast";
|
import { toast } from "@/ui/toast";
|
||||||
import { settings, useSettings } from "./settings";
|
import { settings, useSettings } from "./settings";
|
||||||
import { useSession } from "./session";
|
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 --
|
* Named explicitly so `shareWith` comes back, which it does not otherwise --
|
||||||
@@ -478,7 +480,9 @@ export const useMail = create<MailState>((set, get) => ({
|
|||||||
// moved to "Trash" or "Spam" on a server whose folders are called
|
// moved to "Trash" or "Spam" on a server whose folders are called
|
||||||
// "Deleted Items" and "Junk Mail" -- naming somewhere that does not
|
// "Deleted Items" and "Junk Mail" -- naming somewhere that does not
|
||||||
// exist, in the one message whose job is saying where it went.
|
// 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}`, {
|
toast.show(`${ids.length === 1 ? "Conversation" : `${ids.length} conversations`} moved to ${name}`, {
|
||||||
action: {
|
action: {
|
||||||
label: "Undo",
|
label: "Undo",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { loadJson, saveJson } from "@/lib/storage";
|
import { loadJson, saveJson } from "@/lib/storage";
|
||||||
import { queueSettingsPush } from "@/lib/settingsSync";
|
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 type { SwipeAction } from "@/lib/swipe";
|
||||||
import { resolveUiLanguage } from "@/lib/languages";
|
import { resolveUiLanguage } from "@/lib/languages";
|
||||||
import { loadLanguage } from "@/lib/i18n";
|
import { loadLanguage } from "@/lib/i18n";
|
||||||
@@ -344,6 +344,9 @@ export const useSettings = create<SettingsState>((set, get) => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
function applyDateTimePrefs(s: Settings): void {
|
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 });
|
setDateTimePrefs({ locale: s.locale, dateFormat: s.dateFormat, timeFormat: s.timeFormat });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
</Link>
|
</Link>
|
||||||
<SearchBar />
|
<SearchBar />
|
||||||
<div className="topbar-actions">
|
<div className="topbar-actions">
|
||||||
<span className="push-status hide-mobile" role="img" aria-label={PUSH_LABEL[pushState]} title={PUSH_LABEL[pushState]}>
|
<span className="push-status hide-mobile" role="img" aria-label={t(PUSH_LABEL[pushState])} title={t(PUSH_LABEL[pushState])}>
|
||||||
<span className={`push-dot ${pushState}`} />
|
<span className={`push-dot ${pushState}`} />
|
||||||
</span>
|
</span>
|
||||||
<button className="icon-btn hide-mobile" aria-label={t("Keyboard shortcuts")} title={t("Keyboard shortcuts (?)")} onClick={() => setHelpOpen(true)}>
|
<button className="icon-btn hide-mobile" aria-label={t("Keyboard shortcuts")} title={t("Keyboard shortcuts (?)")} onClick={() => setHelpOpen(true)}>
|
||||||
@@ -132,7 +132,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{section === "files" ? <Upload size={22} /> : section === "calendar" || section === "contacts" ? <Plus size={22} /> : <PenSquare size={22} />}
|
{section === "files" ? <Upload size={22} /> : section === "calendar" || section === "contacts" ? <Plus size={22} /> : <PenSquare size={22} />}
|
||||||
<span>{section === "calendar" ? "New event" : section === "contacts" ? "New contact" : section === "files" ? "Upload" : "Compose"}</span>
|
<span>{section === "calendar" ? t("New event") : section === "contacts" ? t("New contact") : section === "files" ? t("Upload") : t("Compose")}</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="sidebar-scroll">
|
<div className="sidebar-scroll">
|
||||||
{(section === "mail" || section === "search") && <MailboxTree />}
|
{(section === "mail" || section === "search") && <MailboxTree />}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { RuleDialog } from "../settings/RuleDialog";
|
|||||||
import { toast } from "@/ui/toast";
|
import { toast } from "@/ui/toast";
|
||||||
import { Spinner } from "@/ui/misc";
|
import { Spinner } from "@/ui/misc";
|
||||||
import { Dialog } from "@/ui/dialog";
|
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. */
|
/** "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 }) {
|
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 ? (
|
{damage ? (
|
||||||
<p>{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 })}</p>
|
<p>{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 })}</p>
|
||||||
) : loaded ? (
|
) : loaded ? (
|
||||||
<p>{t("Your active Sieve script was written by hand, so rules can't be added automatically. Open")} <b>{t("Settings → Filters & rules")}</b> {t("to edit the script or switch to managed rules.")}</p>
|
<p>{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: <b>{t("Settings → Filters & rules")}</b> })}</p>
|
||||||
) : (
|
) : (
|
||||||
<p>{t("Your filter script couldn't be read just now, so adding a rule would risk overwriting it. Reload the page and try again.")}</p>
|
<p>{t("Your filter script couldn't be read just now, so adding a rule would risk overwriting it. Reload the page and try again.")}</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ import { confirmDialog } from "@/ui/dialog";
|
|||||||
import { toast } from "@/ui/toast";
|
import { toast } from "@/ui/toast";
|
||||||
import { isUnknownMailbox } from "@/lib/mailboxRoute";
|
import { isUnknownMailbox } from "@/lib/mailboxRoute";
|
||||||
import { scheduledMailboxIdFrom, useScheduled } from "@/store/scheduled";
|
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 }) {
|
export function MailView({ mailboxId, threadId, search }: { mailboxId?: string; threadId?: string; search?: boolean }) {
|
||||||
const [, navigate] = useLocation();
|
const [, navigate] = useLocation();
|
||||||
@@ -306,7 +307,7 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string;
|
|||||||
[emails, mailboxId, mailboxes, openThread, openDraft],
|
[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 reading = Boolean(threadId);
|
||||||
const paneClass = settings.readingPane === "bottom" ? "pane-bottom" : settings.readingPane === "off" ? "pane-off" : "pane-right";
|
const paneClass = settings.readingPane === "bottom" ? "pane-bottom" : settings.readingPane === "off" ? "pane-off" : "pane-right";
|
||||||
const showList = !(settings.readingPane === "off" && reading) && !(narrow && reading);
|
const showList = !(settings.readingPane === "off" && reading) && !(narrow && reading);
|
||||||
@@ -353,8 +354,8 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string;
|
|||||||
) : (
|
) : (
|
||||||
<div className="no-thread">
|
<div className="no-thread">
|
||||||
<img src="/img/logo.png" alt="" />
|
<img src="/img/logo.png" alt="" />
|
||||||
<div>{list?.total ? `${list.total} conversation${list.total === 1 ? "" : "s"}` : "No conversation selected"}</div>
|
<div>{list?.total ? plural(list.total, { one: "{n} conversation", other: "{n} conversations" }) : translate("No conversation selected")}</div>
|
||||||
<div className="hint">{translate("Select a conversation to read it here · Press")} <kbd className="kbd">?</kbd> {translate("for shortcuts")}</div>
|
<div className="hint">{tNode("Select a conversation to read it here · Press {key} for shortcuts", { key: <kbd className="kbd">?</kbd> })}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useMail } from "@/store/mail";
|
|||||||
import { Dialog } from "@/ui/dialog";
|
import { Dialog } from "@/ui/dialog";
|
||||||
import type { Id, Mailbox } from "@/jmap/types";
|
import type { Id, Mailbox } from "@/jmap/types";
|
||||||
import { t } from "@/lib/i18n";
|
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[] }) {
|
export function MailboxPicker({ title, onClose, onPick, exclude }: { title: string; onClose: () => void; onPick: (id: Id) => void; exclude?: Id[] }) {
|
||||||
const mailboxes = useMail((s) => s.mailboxes);
|
const mailboxes = useMail((s) => s.mailboxes);
|
||||||
@@ -13,7 +14,7 @@ export function MailboxPicker({ title, onClose, onPick, exclude }: { title: stri
|
|||||||
const list = useMemo(() => {
|
const list = useMemo(() => {
|
||||||
const all = Object.values(mailboxes)
|
const all = Object.values(mailboxes)
|
||||||
.filter((m) => !exclude?.includes(m.id) && m.myRights.mayAddItems)
|
.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)));
|
.sort((a, b) => (a.m.role === "inbox" ? -1 : b.m.role === "inbox" ? 1 : a.path.localeCompare(b.path)));
|
||||||
const ql = q.trim().toLowerCase();
|
const ql = q.trim().toLowerCase();
|
||||||
return ql ? all.filter((x) => x.path.toLowerCase().includes(ql)) : all;
|
return ql ? all.filter((x) => x.path.toLowerCase().includes(ql)) : all;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { loadRaw, saveJson } from "@/lib/storage";
|
|||||||
import { canDropFolder, folderColor, movable } from "@/lib/folderMove";
|
import { canDropFolder, folderColor, movable } from "@/lib/folderMove";
|
||||||
import { haptic, useTouchRow } from "@/lib/touch";
|
import { haptic, useTouchRow } from "@/lib/touch";
|
||||||
import { t } from "@/lib/i18n";
|
import { t } from "@/lib/i18n";
|
||||||
|
import { mailboxDisplayName } from "@/lib/mailboxName";
|
||||||
|
|
||||||
const ROLE_ICONS: Record<string, ReactNode> = {
|
const ROLE_ICONS: Record<string, ReactNode> = {
|
||||||
inbox: <Inbox size={20} />,
|
inbox: <Inbox size={20} />,
|
||||||
@@ -63,9 +64,9 @@ export function MailboxTree() {
|
|||||||
setExpanded(next);
|
setExpanded(next);
|
||||||
saveJson("mbx-expanded", 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) {
|
} 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);
|
if (id) void moveFolder(id, null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>{draggingId && canDropOn(null) ? "Drop here for the top level" : "Folders"}</span>
|
<span>{draggingId && canDropOn(null) ? t("Drop here for the top level") : t("Folders")}</span>
|
||||||
<button className="icon-btn" title={t("New folder")} aria-label={t("New folder")} onClick={() => void createFolder(null)}>
|
<button className="icon-btn" title={t("New folder")} aria-label={t("New folder")} onClick={() => void createFolder(null)}>
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
</button>
|
</button>
|
||||||
@@ -152,7 +153,7 @@ export function MailboxTree() {
|
|||||||
<FolderRow
|
<FolderRow
|
||||||
key={m.id}
|
key={m.id}
|
||||||
mailbox={m}
|
mailbox={m}
|
||||||
label={m.name}
|
label={mailboxDisplayName(m)}
|
||||||
depth={depth}
|
depth={depth}
|
||||||
hasChildren={hasChildren}
|
hasChildren={hasChildren}
|
||||||
open={open}
|
open={open}
|
||||||
@@ -332,7 +333,9 @@ function MailboxMenu({ mailbox: m, onClose, onCreateChild, onShare }: { mailbox:
|
|||||||
return n;
|
return n;
|
||||||
});
|
});
|
||||||
const rename = async () => {
|
const rename = async () => {
|
||||||
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;
|
if (!name?.trim() || name.trim() === m.name) return;
|
||||||
try {
|
try {
|
||||||
await useMail.getState().updateMailbox(m.id, { name: name.trim() });
|
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 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;
|
if (!ok) return;
|
||||||
try {
|
try {
|
||||||
await useMail.getState().destroyMailbox(m.id, true);
|
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);
|
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 isSpecial = Boolean(m.role) && m.role !== "subscribed";
|
||||||
const color = folderColor(colors, m.id);
|
const color = folderColor(colors, m.id);
|
||||||
const setColor = (c: string | null) => {
|
const setColor = (c: string | null) => {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import { useScheduled } from "@/store/scheduled";
|
|||||||
import { formatScheduleTime } from "@/lib/schedule";
|
import { formatScheduleTime } from "@/lib/schedule";
|
||||||
import { mdnDecision, refusalText } from "@/lib/mdn";
|
import { mdnDecision, refusalText } from "@/lib/mdn";
|
||||||
import { sendReadReceipt } from "@/store/mdn";
|
import { sendReadReceipt } from "@/store/mdn";
|
||||||
import { t as translate } from "@/lib/i18n";
|
import { t as translate, tNode } from "@/lib/i18n";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
email: Email;
|
email: Email;
|
||||||
@@ -226,7 +226,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
|
|||||||
|
|
||||||
{translate("The sender asked for a read receipt.")}
|
{translate("The sender asked for a read receipt.")}
|
||||||
{receipt.redirected && (
|
{receipt.redirected && (
|
||||||
<> {translate("It would go to")} <strong>{receipt.to!.email}</strong>{translate(", which is not where the message came from.")}</>
|
<>{tNode("It would go to {address}, which is not where the message came from.", { address: <strong className="notranslate" translate="no">{receipt.to!.email}</strong> })}</>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useSettings } from "@/store/settings";
|
import { useSettings } from "@/store/settings";
|
||||||
import { Switch, useIsTouch } from "@/ui/misc";
|
import { Switch, useIsTouch } from "@/ui/misc";
|
||||||
import { SWIPE_CHOICES, type SwipeAction } from "@/lib/swipe";
|
import { SWIPE_CHOICES, type SwipeAction } from "@/lib/swipe";
|
||||||
import { UI_LANGUAGES } from "@/lib/languages";
|
import { TRANSLATION_ISSUE_URL, UI_LANGUAGES } from "@/lib/languages";
|
||||||
import { t as translate } from "@/lib/i18n";
|
import { t as translate, tNode } from "@/lib/i18n";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The theme cards, each previewing the background it actually paints. Kept as
|
* The theme cards, each previewing the background it actually paints. Kept as
|
||||||
@@ -31,6 +31,8 @@ export function AppearanceSettings() {
|
|||||||
const s = useSettings((st) => st.settings);
|
const s = useSettings((st) => st.settings);
|
||||||
const update = useSettings((st) => st.update);
|
const update = useSettings((st) => st.update);
|
||||||
const isTouch = useIsTouch();
|
const isTouch = useIsTouch();
|
||||||
|
const chosen = UI_LANGUAGES.find((l) => l.tag === s.uiLanguage);
|
||||||
|
const betaChosen = Boolean(chosen?.beta);
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1>{translate("Appearance")}</h1>
|
<h1>{translate("Appearance")}</h1>
|
||||||
@@ -40,12 +42,12 @@ export function AppearanceSettings() {
|
|||||||
{THEMES.map((t) => (
|
{THEMES.map((t) => (
|
||||||
<button key={t.id} className={`theme-card ${s.theme === t.id ? "active" : ""}`} onClick={() => update({ theme: t.id })}>
|
<button key={t.id} className={`theme-card ${s.theme === t.id ? "active" : ""}`} onClick={() => update({ theme: t.id })}>
|
||||||
<div className="preview" style={{ background: t.preview }} />
|
<div className="preview" style={{ background: t.preview }} />
|
||||||
{t.label}
|
{translate(t.label)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className="hint" style={{ marginTop: 10 }}>
|
<p className="hint" style={{ marginTop: 10 }}>
|
||||||
<strong>{translate("ihasmail")}</strong> {translate("is the palette from")} <a href="https://ihasmail.org" target="_blank" rel="noopener noreferrer">{translate("ihasmail.org")}</a>{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: <strong className="notranslate" translate="no">ihasmail</strong>, site: <a href="https://ihasmail.org" target="_blank" rel="noopener noreferrer">ihasmail.org</a> })}
|
||||||
</p>
|
</p>
|
||||||
<Switch
|
<Switch
|
||||||
checked={s.themeMessageBody}
|
checked={s.themeMessageBody}
|
||||||
@@ -84,7 +86,7 @@ export function AppearanceSettings() {
|
|||||||
<label htmlFor="ui-language">{translate("Interface language")}</label>
|
<label htmlFor="ui-language">{translate("Interface language")}</label>
|
||||||
<select id="ui-language" className="select" value={s.uiLanguage} onChange={(e) => update({ uiLanguage: e.target.value })}>
|
<select id="ui-language" className="select" value={s.uiLanguage} onChange={(e) => update({ uiLanguage: e.target.value })}>
|
||||||
{UI_LANGUAGES.map((l) => (
|
{UI_LANGUAGES.map((l) => (
|
||||||
<option key={l.tag} value={l.tag}>{l.name}</option>
|
<option key={l.tag} value={l.tag}>{l.beta ? `${l.name} (Beta)` : l.name}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -93,13 +95,27 @@ export function AppearanceSettings() {
|
|||||||
looks broken; a picker with one entry and a sentence explaining that
|
looks broken; a picker with one entry and a sentence explaining that
|
||||||
more are coming is a roadmap.
|
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 && (
|
||||||
|
<p className="hint">
|
||||||
|
{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: <a href={`${TRANSLATION_ISSUE_URL}${encodeURIComponent(chosen?.name ?? "")}`} target="_blank" rel="noopener noreferrer">{translate("tell us about it")}</a>,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<p className="hint">
|
<p className="hint">
|
||||||
|
|
||||||
{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("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.")}
|
||||||
</p>
|
</p>
|
||||||
<p className="hint">
|
<p className="hint">
|
||||||
|
|
||||||
{translate("This is separate from")} <strong>{translate("Language & region")}</strong> {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: <strong>{translate("Language & region")}</strong> })}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>{translate("Swiping")}</h2>
|
<h2>{translate("Swiping")}</h2>
|
||||||
@@ -112,7 +128,7 @@ export function AppearanceSettings() {
|
|||||||
<label htmlFor="swipe-right">{translate("Swipe right")}</label>
|
<label htmlFor="swipe-right">{translate("Swipe right")}</label>
|
||||||
<select id="swipe-right" className="select" value={s.swipeRight} onChange={(e) => update({ swipeRight: e.target.value as SwipeAction })}>
|
<select id="swipe-right" className="select" value={s.swipeRight} onChange={(e) => update({ swipeRight: e.target.value as SwipeAction })}>
|
||||||
{SWIPE_CHOICES.map((c) => (
|
{SWIPE_CHOICES.map((c) => (
|
||||||
<option key={c.value} value={c.value}>{c.label}</option>
|
<option key={c.value} value={c.value}>{translate(c.label)}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -120,7 +136,7 @@ export function AppearanceSettings() {
|
|||||||
<label htmlFor="swipe-left">{translate("Swipe left")}</label>
|
<label htmlFor="swipe-left">{translate("Swipe left")}</label>
|
||||||
<select id="swipe-left" className="select" value={s.swipeLeft} onChange={(e) => update({ swipeLeft: e.target.value as SwipeAction })}>
|
<select id="swipe-left" className="select" value={s.swipeLeft} onChange={(e) => update({ swipeLeft: e.target.value as SwipeAction })}>
|
||||||
{SWIPE_CHOICES.map((c) => (
|
{SWIPE_CHOICES.map((c) => (
|
||||||
<option key={c.value} value={c.value}>{c.label}</option>
|
<option key={c.value} value={c.value}>{translate(c.label)}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { formatSize } from "@/lib/format";
|
|||||||
import { ShareDialog } from "./ShareDialog";
|
import { ShareDialog } from "./ShareDialog";
|
||||||
import type { Mailbox } from "@/jmap/types";
|
import type { Mailbox } from "@/jmap/types";
|
||||||
import { t } from "@/lib/i18n";
|
import { t } from "@/lib/i18n";
|
||||||
|
import { mailboxDisplayPath } from "@/lib/mailboxName";
|
||||||
|
|
||||||
export function FoldersSettings() {
|
export function FoldersSettings() {
|
||||||
const mailboxes = useMail((s) => s.mailboxes);
|
const mailboxes = useMail((s) => s.mailboxes);
|
||||||
@@ -42,12 +43,14 @@ export function FoldersSettings() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{list.map(({ m, path }) => (
|
{list.map(({ m, path }) => (
|
||||||
<tr key={m.id}>
|
<tr key={m.id}>
|
||||||
<td><div className="row gap-8">{m.role === "inbox" ? <Inbox size={16} /> : <Folder size={16} />}<span>{path}</span>{!m.isSubscribed && <span className="badge muted">{t("hidden")}</span>}{m.role && m.role !== "subscribed" && <span className="hint">({m.role})</span>}</div></td>
|
<td><div className="row gap-8">{m.role === "inbox" ? <Inbox size={16} /> : <Folder size={16} />}<span>{mailboxDisplayPath(m, mailboxes)}</span>{!m.isSubscribed && <span className="badge muted">{t("hidden")}</span>}{m.role && m.role !== "subscribed" && <span className="hint">({m.role})</span>}</div></td>
|
||||||
<td>{m.totalEmails.toLocaleString()}</td>
|
<td>{m.totalEmails.toLocaleString()}</td>
|
||||||
<td>{m.unreadEmails.toLocaleString()}</td>
|
<td>{m.unreadEmails.toLocaleString()}</td>
|
||||||
<td>
|
<td>
|
||||||
<div className="row" style={{ justifyContent: "flex-end", gap: 0 }}>
|
<div className="row" style={{ justifyContent: "flex-end", gap: 0 }}>
|
||||||
<button className="icon-btn sm" title={t("Rename")} disabled={Boolean(m.role) && m.role !== "subscribed"} onClick={async () => { const n = await promptDialog({ title: "Rename folder", defaultValue: m.name }); if (n?.trim() && n !== m.name) { try { await useMail.getState().updateMailbox(m.id, { name: n.trim() }); } catch (err) { toast.error((err as Error).message); } } }}><Pencil size={16} /></button>
|
<button className="icon-btn sm" title={t("Rename")} disabled={Boolean(m.role) && m.role !== "subscribed"} onClick={async () => { const n = 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 (n?.trim() && n !== m.name) { try { await useMail.getState().updateMailbox(m.id, { name: n.trim() }); } catch (err) { toast.error((err as Error).message); } } }}><Pencil size={16} /></button>
|
||||||
<button className="icon-btn sm" title={m.isSubscribed ? "Hide" : "Show"} disabled={m.role === "inbox"} onClick={() => void useMail.getState().updateMailbox(m.id, { isSubscribed: !m.isSubscribed })}>{m.isSubscribed ? <EyeOff size={16} /> : <Eye size={16} />}</button>
|
<button className="icon-btn sm" title={m.isSubscribed ? "Hide" : "Show"} disabled={m.role === "inbox"} onClick={() => void useMail.getState().updateMailbox(m.id, { isSubscribed: !m.isSubscribed })}>{m.isSubscribed ? <EyeOff size={16} /> : <Eye size={16} />}</button>
|
||||||
{Object.keys(m.shareWith ?? {}).length > 0 && <button className="icon-btn sm" title={t("Stop sharing")} onClick={() => setShare(m)}><Share2 size={16} /></button>}
|
{Object.keys(m.shareWith ?? {}).length > 0 && <button className="icon-btn sm" title={t("Stop sharing")} onClick={() => setShare(m)}><Share2 size={16} /></button>}
|
||||||
<button className="icon-btn sm danger" title={t("Delete")} disabled={Boolean(m.role) && m.role !== "subscribed"} onClick={async () => { if (await confirmDialog({ title: `Delete “${m.name}”?`, message: `${m.totalEmails} message(s) will be permanently deleted.`, confirmLabel: "Delete", danger: true })) { try { await useMail.getState().destroyMailbox(m.id, true); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={16} /></button>
|
<button className="icon-btn sm danger" title={t("Delete")} disabled={Boolean(m.role) && m.role !== "subscribed"} onClick={async () => { if (await confirmDialog({ title: `Delete “${m.name}”?`, message: `${m.totalEmails} message(s) will be permanently deleted.`, confirmLabel: "Delete", danger: true })) { try { await useMail.getState().destroyMailbox(m.id, true); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={16} /></button>
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ export function GeneralSettings() {
|
|||||||
<select className="select" value={s.dateFormat} onChange={(e) => update({ dateFormat: e.target.value as DateFormat })}>
|
<select className="select" value={s.dateFormat} onChange={(e) => update({ dateFormat: e.target.value as DateFormat })}>
|
||||||
{DATE_FORMATS.map((f) => (
|
{DATE_FORMATS.map((f) => (
|
||||||
<option key={f.value} value={f.value}>
|
<option key={f.value} value={f.value}>
|
||||||
{f.label} ({withPrefs({ locale: s.locale, dateFormat: f.value }, () => formatDate(SAMPLE))})
|
{t(f.label)} ({withPrefs({ locale: s.locale, dateFormat: f.value }, () => formatDate(SAMPLE))})
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Plus, Trash2 } from "lucide-react";
|
|||||||
import { useSettings } from "@/store/settings";
|
import { useSettings } from "@/store/settings";
|
||||||
import { CALENDAR_COLORS, ColorSwatches } from "@/ui/misc";
|
import { CALENDAR_COLORS, ColorSwatches } from "@/ui/misc";
|
||||||
import { promptDialog } from "@/ui/dialog";
|
import { promptDialog } from "@/ui/dialog";
|
||||||
import { t } from "@/lib/i18n";
|
import { t, tNode } from "@/lib/i18n";
|
||||||
|
|
||||||
export function LabelsSettings() {
|
export function LabelsSettings() {
|
||||||
const labels = useSettings((s) => s.settings.labels);
|
const labels = useSettings((s) => s.settings.labels);
|
||||||
@@ -39,7 +39,7 @@ export function LabelsSettings() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<button className="btn" onClick={() => void add()}><Plus size={16} /> {t("New label")}</button>
|
<button className="btn" onClick={() => void add()}><Plus size={16} /> {t("New label")}</button>
|
||||||
<p className="hint mt-8">{t("Tip: press")} <kbd className="kbd">l</kbd> {t("on a conversation to apply labels. Search with")} <code>label:name</code>.</p>
|
<p className="hint mt-8">{tNode("Tip: press {key} on a conversation to apply labels. Search with {operator}.", { key: <kbd className="kbd">l</kbd>, operator: <code>label:name</code> })}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useSession } from "@/store/session";
|
|||||||
import { formatFullDate } from "@/lib/format";
|
import { formatFullDate } from "@/lib/format";
|
||||||
import { toast } from "@/ui/toast";
|
import { toast } from "@/ui/toast";
|
||||||
import { confirmDialog, Dialog } from "@/ui/dialog";
|
import { confirmDialog, Dialog } from "@/ui/dialog";
|
||||||
import { t } from "@/lib/i18n";
|
import { t, tNode } from "@/lib/i18n";
|
||||||
|
|
||||||
interface SessionRow {
|
interface SessionRow {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -59,7 +59,7 @@ export function SecuritySettings() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1>{t("Security & sessions")}</h1>
|
<h1>{t("Security & sessions")}</h1>
|
||||||
<p className="lead">{t("You're signed in as")} <b>{session?.username}</b>{t(". Your password is never stored in the browser; the server keeps it encrypted per-session for talking to Stalwart.")}</p>
|
<p className="lead">{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: <b className="notranslate" translate="no">{session?.username}</b> })}</p>
|
||||||
|
|
||||||
<h2>{t("Password")}</h2>
|
<h2>{t("Password")}</h2>
|
||||||
{unsupported ? (
|
{unsupported ? (
|
||||||
@@ -303,7 +303,7 @@ function AppPasswords({ state, reload }: { state: SecurityState | null; reload:
|
|||||||
footer={<button className="btn btn-primary" onClick={() => setIssued(null)}>{t("Done")}</button>}>
|
footer={<button className="btn btn-primary" onClick={() => setIssued(null)}>{t("Done")}</button>}>
|
||||||
{issued && (
|
{issued && (
|
||||||
<div>
|
<div>
|
||||||
<p>{t("Copy it into")} <b>{issued.description}</b> {t("now — it isn't shown again.")}</p>
|
<p>{tNode("Copy it into {name} now — it isn't shown again.", { name: <b>{issued.description}</b> })}</p>
|
||||||
<CopyableSecret value={issued.secret} />
|
<CopyableSecret value={issued.secret} />
|
||||||
<p className="hint mt-8"><Smartphone size={13} style={{ verticalAlign: "-2px" }} /> {t("Use your usual address as the username.")}</p>
|
<p className="hint mt-8"><Smartphone size={13} style={{ verticalAlign: "-2px" }} /> {t("Use your usual address as the username.")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export function SettingsView({ section }: { section?: string }) {
|
|||||||
{SECTIONS.map((s) => (
|
{SECTIONS.map((s) => (
|
||||||
<Link key={s.id} href={`/settings/${s.id}`} className={`nav-item ${section === s.id ? "active" : ""}`}>
|
<Link key={s.id} href={`/settings/${s.id}`} className={`nav-item ${section === s.id ? "active" : ""}`}>
|
||||||
{s.icon}
|
{s.icon}
|
||||||
<span className="nav-label">{s.label}</span>
|
<span className="nav-label">{t(s.label)}</span>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
<div className="nav-section" style={{ paddingLeft: 8 }}><span>{t("Shortcuts")}</span></div>
|
<div className="nav-section" style={{ paddingLeft: 8 }}><span>{t("Shortcuts")}</span></div>
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ export function ShareDialog({ kind, id, name, shareWith, onClose }: { kind: Kind
|
|||||||
{RIGHTS[kind].map((rt) => (
|
{RIGHTS[kind].map((rt) => (
|
||||||
<label key={rt.key} className="check" style={{ padding: "2px 6px" }}>
|
<label key={rt.key} className="check" style={{ padding: "2px 6px" }}>
|
||||||
<input type="checkbox" checked={Boolean(r[rt.key])} onChange={(e) => setRights({ ...rights, [pid]: { ...r, [rt.key]: e.target.checked } })} />
|
<input type="checkbox" checked={Boolean(r[rt.key])} onChange={(e) => setRights({ ...rights, [pid]: { ...r, [rt.key]: e.target.checked } })} />
|
||||||
<span className="small">{rt.label}</span>
|
<span className="small">{t(rt.label)}</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { keyboard } from "@/lib/keyboard";
|
import { keyboard } from "@/lib/keyboard";
|
||||||
import { Kbd } from "@/ui/misc";
|
import { Kbd } from "@/ui/misc";
|
||||||
import { t } from "@/lib/i18n";
|
import { t, tNode } from "@/lib/i18n";
|
||||||
|
|
||||||
export function ShortcutsSettings() {
|
export function ShortcutsSettings() {
|
||||||
const list = useMemo(() => keyboard.list(), []);
|
const list = useMemo(() => keyboard.list(), []);
|
||||||
@@ -17,7 +17,7 @@ export function ShortcutsSettings() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1>{t("Keyboard shortcuts")}</h1>
|
<h1>{t("Keyboard shortcuts")}</h1>
|
||||||
<p className="lead">{t("Gmail-style shortcuts are always on. Press")} <kbd className="kbd">?</kbd> {t("anywhere to see this list.")}</p>
|
<p className="lead">{tNode("Gmail-style shortcuts are always on. Press {key} anywhere to see this list.", { key: <kbd className="kbd">?</kbd> })}</p>
|
||||||
<div className="shortcut-grid">
|
<div className="shortcut-grid">
|
||||||
{groups.map(([group, items]) => (
|
{groups.map(([group, items]) => (
|
||||||
<div key={group}>
|
<div key={group}>
|
||||||
|
|||||||
Reference in New Issue
Block a user