Second extraction pass: the strings the codemod could not see

`i18n:coverage` reported 100% while a hundred-odd strings rendered
English in every language. It was not wrong about what it measured: it
reads JSX text, and none of these were JSX text. They were toast
arguments, `confirmDialog({ title, confirmLabel })` props, `title=` and
`aria-label=` attributes, and template literals — every one built from an
expression the codemod cannot read.

176 source strings and 15 plural sets now go through t() and plural(),
translated into all nine languages. Where English put a word in a slot,
the sentence is spelled out per branch instead: `Filter ${verb}` became
"Filter saved" and "Filter created", because which word agrees with what,
and where it sits, is not a property English gets to decide for everyone.
Counts that were `${n} message${n === 1 ? "" : "s"}` are plural() calls,
so Russian and Ukrainian get three forms and Japanese and Chinese get the
one they actually have.

Two of the catalogue's own conventions were worth learning the hard way.
Plural entries are keyed on the English *other* form, not `one` — `one`
is a form English happens to have and Japanese does not. And a constant
table holding English that is translated at the render site is fine: the
literal is a key, not a leak.

Which is what the new check encodes. `scripts/i18n-literals.mjs` accepts
a string that is wrapped where it is written or is a catalogue key
somewhere, and refuses one that is neither — a string no catalogue can
translate, however many languages ship. It found twenty more than my own
sweep had, including the stale-folder toast seen in production. It runs
as part of `npm run i18n:check`.

Also fixed: the catalogue is now awaited before the first paint. The
tree is rebuilt when a catalogue lands, so components recover on their
own, but a string computed in an effect does not — a toast fired in that
gap is emitted in English and stays English. The wait costs nothing
visible, since the session bootstrap already shows a spinner and English
resolves immediately.

And the Japanese agenda title loses a space Japanese does not use:
"{date} からの予定" was written with the English habit of spacing around
a placeholder.
This commit is contained in:
2026-08-31 14:14:30 -07:00
parent a4f7d386a6
commit a6863e98cc
52 changed files with 2101 additions and 191 deletions
+1 -1
View File
@@ -23,7 +23,7 @@
"dev:mock": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock -w server\" \"STALWART_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\"",
"dev:mock:no-future-release": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock:no-future-release -w server\" \"STALWART_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\"",
"i18n:coverage": "node scripts/i18n-coverage.mjs",
"i18n:check": "node scripts/i18n-catalog-check.mjs"
"i18n:check": "node scripts/i18n-catalog-check.mjs && node scripts/i18n-literals.mjs"
},
"devDependencies": {
"concurrently": "^9.1.2",
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env node
/*
* User-visible English the extraction pass cannot see.
*
* `i18n:coverage` reads JSX text, and reported 100% while the calendar's
* Day/Week/Month/Agenda buttons rendered English in all nine languages. It was
* not wrong about what it measured -- those labels were never JSX text. They
* were built from an expression, and so was every toast argument, every
* `confirmDialog({ title })`, and every `Could not save: ${err}`.
*
* A string reaches a reader translated if either is true:
*
* 1. it is wrapped where it is written -- t(), tc(), tNode(), plural()
* 2. it is a catalogue key, translated somewhere else
*
* The second case is a real convention here, not a loophole: constant tables
* hold English and the render site calls `t(s.label)`. What this refuses is a
* string that is neither -- one no catalogue has a key for, which therefore
* cannot be translated at all, however many languages ship.
*/
import ts from "typescript";
import { readFileSync, globSync } from "node:fs";
/* Where a string literal in this position is shown to somebody. */
const UI_PROPS = new Set([
"title", "message", "label", "confirmLabel", "cancelLabel", "ariaLabel",
"placeholder", "hint", "occurrenceLabel", "occurrenceHint", "seriesLabel", "seriesHint",
]);
const UI_ATTRS = new Set(["title", "aria-label", "placeholder", "alt"]);
const TOASTS = new Set(["error", "success", "info", "show"]);
const WRAPPERS = ["t", "tc", "tNode", "translate", "plural"];
const EQUALITY = new Set([
ts.SyntaxKind.EqualsEqualsEqualsToken, ts.SyntaxKind.ExclamationEqualsEqualsToken,
ts.SyntaxKind.EqualsEqualsToken, ts.SyntaxKind.ExclamationEqualsToken,
]);
/*
* Product names, example addresses and URL scaffolding. These reach t() and
* are deliberately absent from every catalogue -- translating "ihasmail" or
* "[email protected]" would be a bug, not a feature -- so they would otherwise
* be reported for ever.
*/
const NEVER_TRANSLATED = new Set([
"ihasmail", "ihasmail.org", "ihasmail test", "Stalwart", "Stalwart Mail Server",
"AGPL-3.0-or-later · {source}", "•••", "https://", "https://…",
"https://meet.example.com/…", "[email protected]", "[email protected]",
"[email protected]", "List-Id", "X-Spam-Status",
]);
/* Prose, not an identifier: opens like a sentence, and has lower-case letters. */
const looksLikeUi = (s) =>
/[a-z]/.test(s) && /^[A-Z(“]/.test(s) && (/\s/.test(s) || /[.?!…]$/.test(s));
const keys = new Set();
{
const src = readFileSync("web/src/locales/de.ts", "utf8");
for (const m of src.matchAll(/^\s{4}"((?:[^"\\]|\\.)*)":/gm)) keys.add(m[1].replace("\\u0004", ""));
}
const found = [];
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 report = (node, text) => {
if (!looksLikeUi(text) || keys.has(text) || NEVER_TRANSLATED.has(text)) return;
const { line } = src.getLineAndCharacterOfPosition(node.getStart(src));
found.push({ file, line: line + 1, text });
};
/*
* Literals that are not text on their way to a reader.
*
* Two kinds. One is already inside t("...") -- walking into the call would
* report the very string that proves it is handled. The other is an operand
* of an equality test: `rule.name === "New filter"` compares against a
* sentinel stored in the Sieve script, and translating it would not change
* what a reader sees, it would break the comparison.
*/
const exempt = new Set();
const mark = (n) => {
if (ts.isCallExpression(n) && ts.isIdentifier(n.expression) && WRAPPERS.includes(n.expression.text)) {
const walk = (x) => { if (ts.isStringLiteral(x)) exempt.add(x); ts.forEachChild(x, walk); };
for (const a of n.arguments) walk(a);
}
if (ts.isBinaryExpression(n) && EQUALITY.has(n.operatorToken.kind)) {
for (const side of [n.left, n.right]) if (ts.isStringLiteral(side)) exempt.add(side);
}
ts.forEachChild(n, mark);
};
mark(src);
const wrapped = exempt;
const visit = (n) => {
if (ts.isPropertyAssignment(n) && ts.isStringLiteral(n.initializer) && !wrapped.has(n.initializer)
&& UI_PROPS.has(n.name.getText(src).replace(/['"]/g, ""))) {
report(n.initializer, n.initializer.text);
}
if (ts.isJsxAttribute(n) && n.initializer && UI_ATTRS.has(n.name.getText(src))) {
const walk = (x) => {
if (ts.isStringLiteral(x) && !wrapped.has(x)) report(x, x.text);
if (!ts.isCallExpression(x)) ts.forEachChild(x, walk);
};
walk(n.initializer);
}
if (ts.isCallExpression(n) && ts.isPropertyAccessExpression(n.expression)
&& n.expression.expression.getText(src) === "toast" && TOASTS.has(n.expression.name.text)) {
const a0 = n.arguments[0];
if (a0 && ts.isStringLiteral(a0) && !wrapped.has(a0)) report(a0, a0.text);
/* A template literal cannot be a catalogue key at all, so it is always a find. */
if (a0 && ts.isTemplateExpression(a0)) report(a0, a0.head.text + "{}");
}
ts.forEachChild(n, visit);
};
visit(src);
}
if (!found.length) {
console.log("i18n literals: none -- every user-visible string is wrapped or has a catalogue key");
process.exit(0);
}
console.log(`${found.length} user-visible string(s) the extractor cannot see and no catalogue can translate:\n`);
for (const f of found) console.log(` ${f.file}:${f.line}\n ${JSON.stringify(f.text)}`);
console.log("\nWrap them in t() / plural(), or -- for a label held in a constant and");
console.log("translated where it renders -- make sure the English is a catalogue key.");
process.exit(process.argv.includes("--check") ? 1 : 0);
+19 -3
View File
@@ -1,4 +1,4 @@
import { Fragment, lazy, Suspense, useEffect } from "react";
import { Fragment, lazy, Suspense, useEffect, useState } from "react";
import { Route, Switch, Redirect, useLocation } from "wouter";
import { useSession } from "@/store/session";
import { useMail } from "@/store/mail";
@@ -20,7 +20,7 @@ import { setUnreadBadge } from "@/lib/notify";
import { useSettings, syncedPart } from "@/store/settings";
import { armSettingsSync, loadRemoteSettings, queueSettingsPush, settingsSyncAvailable } from "@/lib/settingsSync";
import { listenForVerification, renewWebPush } from "@/lib/webpushEnable";
import { useLanguageVersion } from "@/lib/i18n";
import { useLanguageVersion, whenLanguageReady } from "@/lib/i18n";
const ContactsView = lazy(() => import("@/views/contacts/ContactsView").then((m) => ({ default: m.ContactsView })));
const CalendarView = lazy(() => import("@/views/calendar/CalendarView").then((m) => ({ default: m.CalendarView })));
@@ -46,7 +46,23 @@ export function App() {
void bootstrap();
}, [bootstrap]);
if (status === "loading") {
/*
* Wait for the catalogue before the first paint.
*
* The tree is rebuilt when a catalogue lands, so components recover on
* their own -- but a string computed in an effect does not. A toast fired
* in the gap is emitted in English and stays English, in an interface that
* is otherwise not. The wait costs nothing visible: the session bootstrap
* is already showing a spinner, and English resolves immediately.
*/
const [languageReady, setLanguageReady] = useState(false);
useEffect(() => {
let live = true;
void whenLanguageReady().finally(() => live && setLanguageReady(true));
return () => { live = false; };
}, []);
if (status === "loading" || !languageReady) {
return (
<div className="center" style={{ height: "100%" }}>
<Spinner size="lg" />
+21
View File
@@ -193,7 +193,28 @@ export function setCatalog(tag: string, catalog: Catalog): void {
* catalogue -- which matters, because the main bundle is already large enough
* to warn about.
*/
/**
* The catalogue load that is in flight, so the first paint can wait for it.
*
* Without this, a cold load paints before the catalogue lands. Components
* recover -- the tree is rebuilt when the catalogue arrives -- but a string
* computed in an effect does not: a toast fired in that window is emitted in
* English and stays English, in an interface that is otherwise German.
* Reported as a stale-folder toast that ignored the language setting.
*/
let inFlight: Promise<void> = Promise.resolve();
/** Resolves once the chosen language is in force. English resolves at once. */
export function whenLanguageReady(): Promise<void> {
return inFlight;
}
export async function loadLanguage(tag: string): Promise<void> {
inFlight = loadLanguageNow(tag);
return inFlight;
}
async function loadLanguageNow(tag: string): Promise<void> {
const resolved = resolveUiLanguage(tag);
if (resolved === DEFAULT_UI_LANGUAGE) {
setCatalog(DEFAULT_UI_LANGUAGE, EMPTY);
+4 -3
View File
@@ -10,16 +10,17 @@ import { fileCreate } from "@/lib/filenode";
import { ensureFolder, nodeBlobId } from "@/lib/appFolder";
import { useSession } from "@/store/session";
import { toast } from "@/ui/toast";
import { t } from "@/lib/i18n";
/** Upload an image for use in a signature; returns a same-origin blob URL. */
export async function uploadSignatureImage(file: File): Promise<string> {
const accountId = useSession.getState().ownAccountFor(CAP.filenode);
if (!accountId || !client.hasCapability(CAP.filenode)) {
toast.error("Images in signatures need the Files feature, which this account doesn't have.");
toast.error(t("Images in signatures need the Files feature, which this account doesn't have."));
throw new Error("filenode unavailable");
}
if (file.size > 512 * 1024) {
toast.error("Please use an image under 512 KB for signatures.");
toast.error(t("Please use an image under 512 KB for signatures."));
throw new Error("too large");
}
try {
@@ -35,7 +36,7 @@ export async function uploadSignatureImage(file: File): Promise<string> {
const blobId = created?.blobId ?? (await nodeBlobId(accountId, created?.id)) ?? up.blobId;
return client.downloadUrl(accountId, blobId, name, type, true);
} catch (err) {
toast.error(`Could not store image: ${(err as Error).message}`);
toast.error(t("Could not store image: {error}", { error: (err as Error).message }));
throw err;
}
}
+192
View File
@@ -884,10 +884,202 @@ export const catalog: Catalog = {
"Saved {when}": "Gespeichert {when}",
"Unsaved": "Nicht gespeichert",
"Agenda from {date}": "Agenda ab {date}",
// ── Toasts, dialogs and computed labels (second extraction pass) ───
"(no subject)": "(kein Betreff)",
"(skipped: {actions})": "(übersprungen: {actions})",
"Activate": "Aktivieren",
"Add a contact or import a vCard file.": "Fügen Sie einen Kontakt hinzu oder importieren Sie eine vCard-Datei.",
"Added to your calendar": "Zu Ihrem Kalender hinzugefügt",
"All events in this calendar will be deleted.": "Alle Termine in diesem Kalender werden gelöscht.",
"All occurrences": "Alle Termine der Serie",
"An occurrence cannot be moved to another calendar on its own": "Ein einzelner Termin einer Serie kann nicht allein in einen anderen Kalender verschoben werden",
"Anything signed in with this password stops working immediately.": "Alles, was mit diesem Passwort angemeldet ist, funktioniert sofort nicht mehr.",
"App password revoked": "App-Passwort widerrufen",
"Applies to every date in the series.": "Gilt für alle Termine der Serie.",
"Applies to this date only.": "Gilt nur für diesen Termin.",
"Apply to series": "Auf die Serie anwenden",
"Applying filter to existing messages…": "Filter wird auf vorhandene Nachrichten angewendet…",
"Attachments are still uploading": "Anhänge werden noch hochgeladen",
"Calendar saved": "Kalender gespeichert",
"Categorised as {name}": "Kategorie „{name}“ zugewiesen",
"Category cleared": "Kategorie entfernt",
"Change this event?": "Diesen Termin ändern?",
"Choose a calendar": "Wählen Sie einen Kalender",
"Choose an address book": "Wählen Sie ein Adressbuch",
"Colour updated": "Farbe geändert",
"Contact created": "Kontakt erstellt",
"Contact deleted": "Kontakt gelöscht",
"Contact saved": "Kontakt gespeichert",
"Copied": "Kopiert",
"Copy of {title}": "Kopie von {title}",
"Could not cancel: {error}": "Abbrechen fehlgeschlagen: {error}",
"Could not copy": "Kopieren fehlgeschlagen",
"Could not copy the address": "Die Adresse konnte nicht kopiert werden",
"Could not empty folder: {error}": "Ordner konnte nicht geleert werden: {error}",
"Could not load source: {error}": "Quelltext konnte nicht geladen werden: {error}",
"Could not mark as read: {error}": "Konnte nicht als gelesen markiert werden: {error}",
"Could not save draft: {error}": "Entwurf konnte nicht gespeichert werden: {error}",
"Could not save filter: {error}": "Filter konnte nicht gespeichert werden: {error}",
"Could not save filters: {error}": "Filter konnten nicht gespeichert werden: {error}",
"Could not send the receipt: {error}": "Die Lesebestätigung konnte nicht gesendet werden: {error}",
"Could not sign in.": "Anmeldung fehlgeschlagen.",
"Could not store image: {error}": "Bild konnte nicht gespeichert werden: {error}",
"Could not update labels: {error}": "Labels konnten nicht aktualisiert werden: {error}",
"Could not update the Scheduled folder: {error}": "Der Ordner „Geplant“ konnte nicht aktualisiert werden: {error}",
"Could not update: {error}": "Aktualisierung fehlgeschlagen: {error}",
"Create": "Erstellen",
"Create an address book first": "Legen Sie zuerst ein Adressbuch an",
"Custom colour removed": "Eigene Farbe entfernt",
"Deactivate": "Deaktivieren",
"Delete failed: {error}": "Löschen fehlgeschlagen: {error}",
"Delete forever": "Endgültig löschen",
"Delete script “{name}”?": "Skript „{name}“ löschen?",
"Delete this event?": "Diesen Termin löschen?",
"Delete this identity?": "Diese Absenderidentität löschen?",
"Delete {name}?": "{name} löschen?",
"Deleted": "Gelöscht",
"Deletes the whole series. This cannot be undone.": "Löscht die gesamte Serie. Das lässt sich nicht rückgängig machen.",
"Did you forget the attachment?": "Haben Sie den Anhang vergessen?",
"Discard": "Verwerfen",
"Discard this draft?": "Diesen Entwurf verwerfen?",
"Edit calendar": "Kalender bearbeiten",
"Edit event": "Termin bearbeiten",
"Edit identity": "Absenderidentität bearbeiten",
"Edit template": "Vorlage bearbeiten",
"End must be after start": "Das Ende muss nach dem Beginn liegen",
"Event duplicated": "Termin dupliziert",
"Everything inside it goes too.": "Alles darin wird ebenfalls gelöscht.",
"Filter created": "Filter erstellt",
"Filter created — it will run on new mail": "Filter erstellt er läuft ab jetzt für neue Nachrichten",
"Filter saved": "Filter gespeichert",
"Filter saved — it will run on new mail": "Filter gespeichert er läuft ab jetzt für neue Nachrichten",
"Filter saved, but applying it failed: {error}": "Filter gespeichert, aber das Anwenden ist fehlgeschlagen: {error}",
"Filters saved": "Filter gespeichert",
"Folder changed, but its filter rules could not be updated: {error}": "Ordner geändert, aber seine Filterregeln konnten nicht aktualisiert werden: {error}",
"Folder created": "Ordner erstellt",
"Folder deleted": "Ordner gelöscht",
"Folder name": "Ordnername",
"Folder name (use / for a subfolder, e.g. Work/Invoices)": "Ordnername (/ für einen Unterordner, z. B. Arbeit/Rechnungen)",
"Folder name (use / for subfolders, e.g. Work/Invoices)": "Ordnername (/ für Unterordner, z. B. Arbeit/Rechnungen)",
"Folder “{name}” created": "Ordner „{name}“ erstellt",
"Forwarded": "Weitergeleitet",
"Hand to the server, held until {when} (Ctrl+Enter)": "An den Server übergeben, zurückgehalten bis {when} (Strg+Enter)",
"Hide": "Ausblenden",
"Hide quoted text": "Zitierten Text ausblenden",
"Hide this from the compose picker": "Beim Verfassen nicht anbieten",
"Identity saved": "Absenderidentität gespeichert",
"Images in signatures need the Files feature, which this account doesn't have.": "Bilder in Signaturen brauchen die Dateien-Funktion, die dieses Konto nicht hat.",
"Invalid address: {address}": "Ungültige Adresse: {address}",
"Invalid username or password.": "Benutzername oder Passwort ist falsch.",
"It belongs to a change that was applied to this and all later occurrences, which the server will only edit as a whole. Apply to the entire series instead?": "Er gehört zu einer Änderung, die auf diesen und alle späteren Termine angewendet wurde und die der Server nur als Ganzes bearbeitet. Stattdessen auf die gesamte Serie anwenden?",
"Label name": "Labelname",
"Larger than {size} MB limit": "Größer als das Limit von {size} MB",
"Message sent": "Nachricht gesendet",
"Move failed: {error}": "Verschieben fehlgeschlagen: {error}",
"Move “{name}”": "„{name}“ verschieben",
"Moved": "Verschoben",
"Network error. Please check your connection.": "Netzwerkfehler. Bitte prüfen Sie Ihre Verbindung.",
"New all-day event on {date}": "Neuer ganztägiger Termin am {date}",
"New event at {time}": "Neuer Termin um {time}",
"New identity": "Neue Absenderidentität",
"No Archive folder found. Create one named “Archive” first.": "Kein Archivordner gefunden. Legen Sie zuerst einen mit dem Namen „Archive“ an.",
"No contacts yet": "Noch keine Kontakte",
"No longer shared": "Nicht mehr freigegeben",
"No matches": "Keine Treffer",
"No new mail in your inbox.": "Keine neuen Nachrichten im Posteingang.",
"No results": "Keine Ergebnisse",
"Nothing here": "Nichts vorhanden",
"Only Deleted Items and Junk Mail can be emptied.": "Nur Papierkorb und Spam lassen sich leeren.",
"Please add at least one recipient": "Bitte geben Sie mindestens einen Empfänger an",
"Please use an image under 512 KB for signatures.": "Bitte verwenden Sie für Signaturen ein Bild unter 512 KB.",
"Preview": "Vorschau",
"Read receipt sent": "Lesebestätigung gesendet",
"Remove attachments that failed to upload first": "Entfernen Sie zuerst die Anhänge, deren Upload fehlgeschlagen ist",
"Removed from calendar": "Aus dem Kalender entfernt",
"Removes this date and leaves the rest of the series.": "Entfernt diesen Termin und lässt die übrige Serie bestehen.",
"Rename address book": "Adressbuch umbenennen",
"Rename category": "Kategorie umbenennen",
"Replied": "Beantwortet",
"Report spam (!)": "Als Spam melden (!)",
"Response sent": "Antwort gesendet",
"Revoke “{name}”?": "„{name}“ widerrufen?",
"Script has errors": "Das Skript enthält Fehler",
"Script is valid": "Das Skript ist gültig",
"Script name is required": "Ein Skriptname ist erforderlich",
"Script saved": "Skript gespeichert",
"Send (Ctrl+Enter)": "Senden (Strg+Enter)",
"Send anyway": "Trotzdem senden",
"Send cancelled — the message is back in Drafts": "Senden abgebrochen die Nachricht liegt wieder in den Entwürfen",
"Send failed: {error}": "Senden fehlgeschlagen: {error}",
"Send invites": "Einladungen senden",
"Send scheduled for {when}": "Senden geplant für {when}",
"Send without a subject?": "Ohne Betreff senden?",
"Share “{name}”": "„{name}“ freigeben",
"Sharing updated": "Freigabe aktualisiert",
"Show": "Einblenden",
"Show quoted text": "Zitierten Text einblenden",
"Show this in the compose picker": "Beim Verfassen anbieten",
"Sign out other sessions?": "Andere Sitzungen abmelden?",
"Sign out others": "Andere abmelden",
"Stop sharing “{name}”?": "Freigabe von „{name}“ aufheben?",
"Switch to {theme}": "Zu {theme} wechseln",
"Template": "Vorlage",
"Template name": "Vorlagenname",
"The contacts in it go too.": "Die enthaltenen Kontakte werden ebenfalls gelöscht.",
"The default identity is always offered when composing": "Die Standardidentität wird beim Verfassen immer angeboten",
"The new passwords don't match": "Die neuen Passwörter stimmen nicht überein",
"The server scheduled this for {when}, not the time requested.": "Der Server hat dies für {when} geplant, nicht für die gewünschte Zeit.",
"This date cannot be changed on its own": "Dieser Termin lässt sich nicht einzeln ändern",
"This occurrence": "Nur dieser Termin",
"Too many attempts. Please wait a few minutes and try again.": "Zu viele Versuche. Bitte warten Sie einige Minuten und versuchen Sie es erneut.",
"Try another search.": "Versuchen Sie eine andere Suche.",
"Try different keywords or filters.": "Versuchen Sie andere Suchbegriffe oder Filter.",
"Two-factor authentication is off": "Zwei-Faktor-Authentifizierung ist aus",
"Undo window: {seconds}s": "Rückgängig-Zeitfenster: {seconds}s",
"You're all caught up": "Sie sind auf dem neuesten Stand",
"Your browser refused the request: {error}": "Ihr Browser hat die Anfrage abgelehnt: {error}",
"Your browser will ask whether to open mail links in ihasmail": "Ihr Browser wird fragen, ob Mail-Links in ihasmail geöffnet werden sollen",
"Your message mentions an attachment, but nothing is attached.": "Ihre Nachricht erwähnt einen Anhang, aber es ist nichts angehängt.",
"event": "Termin",
"Hide password": "Passwort verbergen",
"Show password": "Passwort anzeigen",
"Settings reset to defaults": "Einstellungen auf Standard zurückgesetzt",
"Removed. Mail links will open in whatever your browser falls back to.": "Entfernt. Mail-Links öffnen sich künftig in dem, worauf Ihr Browser zurückfällt.",
"Edit rule": "Regel bearbeiten",
"Address copied": "Adresse kopiert",
"Search or create label": "Label suchen oder anlegen",
"New label name": "Name des neuen Labels",
"That folder no longer exists. Showing your inbox instead.": "Diesen Ordner gibt es nicht mehr. Stattdessen wird der Posteingang angezeigt.",
"Labels updated": "Labels aktualisiert",
"Unsubscribe message prepared — just hit Send": "Abmeldenachricht vorbereitet klicken Sie einfach auf Senden",
"Maximize": "Maximieren",
"Full screen": "Vollbild",
"Resize panes": "Bereichsgröße ändern",
"QR code": "QR-Code",
"Draft discarded": "Entwurf verworfen",
"Draft saved": "Entwurf gespeichert",
"Emptying folder…": "Ordner wird geleert…",
"Nothing unread here": "Hier ist nichts ungelesen",
},
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" },
"{n} messages will be permanently deleted.": { one: "{n} Nachricht wird endgültig gelöscht.", other: "{n} Nachrichten werden endgültig gelöscht." },
"Signed out {n} other sessions": { one: "{n} andere Sitzung abgemeldet", other: "{n} andere Sitzungen abgemeldet" },
"applied to {matched} of {n} messages": { one: "auf {matched} von {n} Nachricht angewendet", other: "auf {matched} von {n} Nachrichten angewendet" },
"Move {n} messages to…": { one: "{n} Nachricht verschieben nach…", other: "{n} Nachrichten verschieben nach…" },
"This permanently deletes the folder and its {n} messages.": { one: "Das löscht den Ordner und seine {n} Nachricht endgültig.", other: "Das löscht den Ordner und seine {n} Nachrichten endgültig." },
"Added {n} contacts": { one: "{n} Kontakt hinzugefügt", other: "{n} Kontakte hinzugefügt" },
"{n} people will lose access. The contacts in it are not affected.": { one: "{n} Person verliert den Zugriff. Die enthaltenen Kontakte sind nicht betroffen.", other: "{n} Personen verlieren den Zugriff. Die enthaltenen Kontakte sind nicht betroffen." },
"Imported {n} contacts": { one: "{n} Kontakt importiert", other: "{n} Kontakte importiert" },
"Saved for this date. {names} apply to the whole series and were left unchanged.": { one: "Für diesen Termin gespeichert. {names} gilt für die gesamte Serie und blieb unverändert.", other: "Für diesen Termin gespeichert. {names} gelten für die gesamte Serie und blieben unverändert." },
"{n} people will lose access. Events in it are not affected.": { one: "{n} Person verliert den Zugriff. Die enthaltenen Termine sind nicht betroffen.", other: "{n} Personen verlieren den Zugriff. Die enthaltenen Termine sind nicht betroffen." },
"{n} messages could not be deleted": { one: "{n} Nachricht konnte nicht gelöscht werden", other: "{n} Nachrichten konnten nicht gelöscht werden" },
"({n} deleted first)": { one: "({n} zuvor gelöscht)", other: "({n} zuvor gelöscht)" },
"Marked {n} messages as read": { one: "{n} Nachricht als gelesen markiert", other: "{n} Nachrichten als gelesen markiert" },
"in {n} folders": { one: "in {n} Ordner", other: "in {n} Ordnern" },
"Deleted {n} messages": { one: "{n} Nachricht gelöscht", other: "{n} Nachrichten gelöscht" },
},
};
+192
View File
@@ -857,10 +857,202 @@ export const catalog: Catalog = {
"Saved {when}": "Guardado {when}",
"Unsaved": "Sin guardar",
"Agenda from {date}": "Agenda desde {date}",
// ── Toasts, dialogs and computed labels (second extraction pass) ───
"(no subject)": "(sin asunto)",
"(skipped: {actions})": "(omitido: {actions})",
"Activate": "Activar",
"Add a contact or import a vCard file.": "Añada un contacto o importe un archivo vCard.",
"Added to your calendar": "Añadido a su calendario",
"All events in this calendar will be deleted.": "Se eliminarán todos los eventos de este calendario.",
"All occurrences": "Todas las repeticiones",
"An occurrence cannot be moved to another calendar on its own": "Una repetición no puede moverse a otro calendario por sí sola",
"Anything signed in with this password stops working immediately.": "Todo lo que haya iniciado sesión con esta contraseña dejará de funcionar de inmediato.",
"App password revoked": "Contraseña de aplicación revocada",
"Applies to every date in the series.": "Se aplica a todas las fechas de la serie.",
"Applies to this date only.": "Se aplica solo a esta fecha.",
"Apply to series": "Aplicar a la serie",
"Applying filter to existing messages…": "Aplicando el filtro a los mensajes existentes…",
"Attachments are still uploading": "Los archivos adjuntos aún se están subiendo",
"Calendar saved": "Calendario guardado",
"Categorised as {name}": "Categoría «{name}» asignada",
"Category cleared": "Categoría quitada",
"Change this event?": "¿Cambiar este evento?",
"Choose a calendar": "Elija un calendario",
"Choose an address book": "Elija una libreta de direcciones",
"Colour updated": "Color actualizado",
"Contact created": "Contacto creado",
"Contact deleted": "Contacto eliminado",
"Contact saved": "Contacto guardado",
"Copied": "Copiado",
"Copy of {title}": "Copia de {title}",
"Could not cancel: {error}": "No se pudo cancelar: {error}",
"Could not copy": "No se pudo copiar",
"Could not copy the address": "No se pudo copiar la dirección",
"Could not empty folder: {error}": "No se pudo vaciar la carpeta: {error}",
"Could not load source: {error}": "No se pudo cargar el código fuente: {error}",
"Could not mark as read: {error}": "No se pudo marcar como leído: {error}",
"Could not save draft: {error}": "No se pudo guardar el borrador: {error}",
"Could not save filter: {error}": "No se pudo guardar el filtro: {error}",
"Could not save filters: {error}": "No se pudieron guardar los filtros: {error}",
"Could not send the receipt: {error}": "No se pudo enviar la confirmación de lectura: {error}",
"Could not sign in.": "No se pudo iniciar sesión.",
"Could not store image: {error}": "No se pudo guardar la imagen: {error}",
"Could not update labels: {error}": "No se pudieron actualizar las etiquetas: {error}",
"Could not update the Scheduled folder: {error}": "No se pudo actualizar la carpeta Programados: {error}",
"Could not update: {error}": "No se pudo actualizar: {error}",
"Create": "Crear",
"Create an address book first": "Cree primero una libreta de direcciones",
"Custom colour removed": "Color personalizado quitado",
"Deactivate": "Desactivar",
"Delete failed: {error}": "No se pudo eliminar: {error}",
"Delete forever": "Eliminar definitivamente",
"Delete script “{name}”?": "¿Eliminar el script «{name}»?",
"Delete this event?": "¿Eliminar este evento?",
"Delete this identity?": "¿Eliminar esta identidad?",
"Delete {name}?": "¿Eliminar {name}?",
"Deleted": "Eliminado",
"Deletes the whole series. This cannot be undone.": "Elimina la serie completa. No se puede deshacer.",
"Did you forget the attachment?": "¿Ha olvidado el archivo adjunto?",
"Discard": "Descartar",
"Discard this draft?": "¿Descartar este borrador?",
"Edit calendar": "Editar calendario",
"Edit event": "Editar evento",
"Edit identity": "Editar identidad",
"Edit template": "Editar plantilla",
"End must be after start": "El fin debe ser posterior al inicio",
"Event duplicated": "Evento duplicado",
"Everything inside it goes too.": "También se eliminará todo su contenido.",
"Filter created": "Filtro creado",
"Filter created — it will run on new mail": "Filtro creado: se aplicará al correo nuevo",
"Filter saved": "Filtro guardado",
"Filter saved — it will run on new mail": "Filtro guardado: se aplicará al correo nuevo",
"Filter saved, but applying it failed: {error}": "Filtro guardado, pero no se pudo aplicar: {error}",
"Filters saved": "Filtros guardados",
"Folder changed, but its filter rules could not be updated: {error}": "Carpeta modificada, pero no se pudieron actualizar sus reglas de filtrado: {error}",
"Folder created": "Carpeta creada",
"Folder deleted": "Carpeta eliminada",
"Folder name": "Nombre de la carpeta",
"Folder name (use / for a subfolder, e.g. Work/Invoices)": "Nombre de la carpeta (use / para una subcarpeta, p. ej. Trabajo/Facturas)",
"Folder name (use / for subfolders, e.g. Work/Invoices)": "Nombre de la carpeta (use / para subcarpetas, p. ej. Trabajo/Facturas)",
"Folder “{name}” created": "Carpeta «{name}» creada",
"Forwarded": "Reenviado",
"Hand to the server, held until {when} (Ctrl+Enter)": "Se entrega al servidor y se retiene hasta {when} (Ctrl+Intro)",
"Hide": "Ocultar",
"Hide quoted text": "Ocultar el texto citado",
"Hide this from the compose picker": "No ofrecer al redactar",
"Identity saved": "Identidad guardada",
"Images in signatures need the Files feature, which this account doesn't have.": "Las imágenes en las firmas necesitan la función Archivos, de la que esta cuenta no dispone.",
"Invalid address: {address}": "Dirección no válida: {address}",
"Invalid username or password.": "Usuario o contraseña incorrectos.",
"It belongs to a change that was applied to this and all later occurrences, which the server will only edit as a whole. Apply to the entire series instead?": "Pertenece a un cambio aplicado a esta y a todas las repeticiones posteriores, que el servidor solo edita en conjunto. ¿Aplicarlo a la serie completa?",
"Label name": "Nombre de la etiqueta",
"Larger than {size} MB limit": "Supera el límite de {size} MB",
"Message sent": "Mensaje enviado",
"Move failed: {error}": "No se pudo mover: {error}",
"Move “{name}”": "Mover «{name}»",
"Moved": "Movido",
"Network error. Please check your connection.": "Error de red. Compruebe su conexión.",
"New all-day event on {date}": "Nuevo evento de todo el día el {date}",
"New event at {time}": "Nuevo evento a las {time}",
"New identity": "Nueva identidad",
"No Archive folder found. Create one named “Archive” first.": "No se encontró una carpeta de archivo. Cree primero una llamada «Archive».",
"No contacts yet": "Aún no hay contactos",
"No longer shared": "Ya no está compartido",
"No matches": "Sin coincidencias",
"No new mail in your inbox.": "No hay correo nuevo en su bandeja de entrada.",
"No results": "Sin resultados",
"Nothing here": "Aquí no hay nada",
"Only Deleted Items and Junk Mail can be emptied.": "Solo se pueden vaciar la papelera y el correo no deseado.",
"Please add at least one recipient": "Añada al menos un destinatario",
"Please use an image under 512 KB for signatures.": "Use una imagen de menos de 512 KB para las firmas.",
"Preview": "Vista previa",
"Read receipt sent": "Confirmación de lectura enviada",
"Remove attachments that failed to upload first": "Quite primero los adjuntos que no se pudieron subir",
"Removed from calendar": "Quitado del calendario",
"Removes this date and leaves the rest of the series.": "Quita esta fecha y deja el resto de la serie.",
"Rename address book": "Cambiar el nombre de la libreta",
"Rename category": "Cambiar el nombre de la categoría",
"Replied": "Respondido",
"Report spam (!)": "Marcar como spam (!)",
"Response sent": "Respuesta enviada",
"Revoke “{name}”?": "¿Revocar «{name}»?",
"Script has errors": "El script tiene errores",
"Script is valid": "El script es válido",
"Script name is required": "Se necesita un nombre de script",
"Script saved": "Script guardado",
"Send (Ctrl+Enter)": "Enviar (Ctrl+Intro)",
"Send anyway": "Enviar de todos modos",
"Send cancelled — the message is back in Drafts": "Envío cancelado: el mensaje ha vuelto a Borradores",
"Send failed: {error}": "No se pudo enviar: {error}",
"Send invites": "Enviar invitaciones",
"Send scheduled for {when}": "Envío programado para {when}",
"Send without a subject?": "¿Enviar sin asunto?",
"Share “{name}”": "Compartir «{name}»",
"Sharing updated": "Uso compartido actualizado",
"Show": "Mostrar",
"Show quoted text": "Mostrar el texto citado",
"Show this in the compose picker": "Ofrecer al redactar",
"Sign out other sessions?": "¿Cerrar las demás sesiones?",
"Sign out others": "Cerrar las demás",
"Stop sharing “{name}”?": "¿Dejar de compartir «{name}»?",
"Switch to {theme}": "Cambiar a {theme}",
"Template": "Plantilla",
"Template name": "Nombre de la plantilla",
"The contacts in it go too.": "También se eliminarán los contactos que contiene.",
"The default identity is always offered when composing": "La identidad predeterminada siempre se ofrece al redactar",
"The new passwords don't match": "Las contraseñas nuevas no coinciden",
"The server scheduled this for {when}, not the time requested.": "El servidor lo programó para {when}, no para la hora solicitada.",
"This date cannot be changed on its own": "Esta fecha no se puede cambiar por sí sola",
"This occurrence": "Solo esta repetición",
"Too many attempts. Please wait a few minutes and try again.": "Demasiados intentos. Espere unos minutos y vuelva a intentarlo.",
"Try another search.": "Pruebe con otra búsqueda.",
"Try different keywords or filters.": "Pruebe con otras palabras o filtros.",
"Two-factor authentication is off": "La verificación en dos pasos está desactivada",
"Undo window: {seconds}s": "Margen para deshacer: {seconds} s",
"You're all caught up": "Está todo al día",
"Your browser refused the request: {error}": "Su navegador rechazó la solicitud: {error}",
"Your browser will ask whether to open mail links in ihasmail": "Su navegador le preguntará si quiere abrir los enlaces de correo en ihasmail",
"Your message mentions an attachment, but nothing is attached.": "Su mensaje menciona un archivo adjunto, pero no hay ninguno.",
"event": "evento",
"Hide password": "Ocultar la contraseña",
"Show password": "Mostrar la contraseña",
"Settings reset to defaults": "Ajustes restablecidos a los valores predeterminados",
"Removed. Mail links will open in whatever your browser falls back to.": "Quitado. Los enlaces de correo se abrirán en lo que use su navegador por defecto.",
"Edit rule": "Editar regla",
"Address copied": "Dirección copiada",
"Search or create label": "Busque o cree una etiqueta",
"New label name": "Nombre de la nueva etiqueta",
"That folder no longer exists. Showing your inbox instead.": "Esa carpeta ya no existe. Se muestra su bandeja de entrada.",
"Labels updated": "Etiquetas actualizadas",
"Unsubscribe message prepared — just hit Send": "Mensaje de baja preparado: solo tiene que pulsar Enviar",
"Maximize": "Maximizar",
"Full screen": "Pantalla completa",
"Resize panes": "Cambiar el tamaño de los paneles",
"QR code": "Código QR",
"Draft discarded": "Borrador descartado",
"Draft saved": "Borrador guardado",
"Emptying folder…": "Vaciando la carpeta…",
"Nothing unread here": "Aquí no hay nada sin leer",
},
plurals: {
"{n} messages": { one: "{n} mensaje", other: "{n} mensajes" },
"{n} selected": { one: "{n} seleccionado", other: "{n} seleccionados" },
"{n} conversations": { one: "{n} conversación", other: "{n} conversaciones" },
"{n} messages will be permanently deleted.": { one: "Se eliminará {n} mensaje definitivamente.", other: "Se eliminarán {n} mensajes definitivamente." },
"Signed out {n} other sessions": { one: "Se ha cerrado {n} sesión más", other: "Se han cerrado {n} sesiones más" },
"applied to {matched} of {n} messages": { one: "aplicado a {matched} de {n} mensaje", other: "aplicado a {matched} de {n} mensajes" },
"Move {n} messages to…": { one: "Mover {n} mensaje a…", other: "Mover {n} mensajes a…" },
"This permanently deletes the folder and its {n} messages.": { one: "Esto elimina definitivamente la carpeta y su {n} mensaje.", other: "Esto elimina definitivamente la carpeta y sus {n} mensajes." },
"Added {n} contacts": { one: "Se ha añadido {n} contacto", other: "Se han añadido {n} contactos" },
"{n} people will lose access. The contacts in it are not affected.": { one: "{n} persona perderá el acceso. Los contactos que contiene no se ven afectados.", other: "{n} personas perderán el acceso. Los contactos que contiene no se ven afectados." },
"Imported {n} contacts": { one: "Se ha importado {n} contacto", other: "Se han importado {n} contactos" },
"Saved for this date. {names} apply to the whole series and were left unchanged.": { one: "Guardado para esta fecha. {names} se aplica a toda la serie y no se ha modificado.", other: "Guardado para esta fecha. {names} se aplican a toda la serie y no se han modificado." },
"{n} people will lose access. Events in it are not affected.": { one: "{n} persona perderá el acceso. Los eventos que contiene no se ven afectados.", other: "{n} personas perderán el acceso. Los eventos que contiene no se ven afectados." },
"{n} messages could not be deleted": { one: "No se pudo eliminar {n} mensaje", other: "No se pudieron eliminar {n} mensajes" },
"({n} deleted first)": { one: "({n} eliminado antes)", other: "({n} eliminados antes)" },
"Marked {n} messages as read": { one: "{n} mensaje marcado como leído", other: "{n} mensajes marcados como leídos" },
"in {n} folders": { one: "en {n} carpeta", other: "en {n} carpetas" },
"Deleted {n} messages": { one: "{n} mensaje eliminado", other: "{n} mensajes eliminados" },
},
};
+192
View File
@@ -862,10 +862,202 @@ export const catalog: Catalog = {
"Saved {when}": "Enregistré {when}",
"Unsaved": "Non enregistré",
"Agenda from {date}": "Agenda à partir du {date}",
// ── Toasts, dialogs and computed labels (second extraction pass) ───
"(no subject)": "(sans objet)",
"(skipped: {actions})": "(ignoré : {actions})",
"Activate": "Activer",
"Add a contact or import a vCard file.": "Ajoutez un contact ou importez un fichier vCard.",
"Added to your calendar": "Ajouté à votre agenda",
"All events in this calendar will be deleted.": "Tous les événements de cet agenda seront supprimés.",
"All occurrences": "Toutes les occurrences",
"An occurrence cannot be moved to another calendar on its own": "Une occurrence ne peut pas être déplacée seule vers un autre agenda",
"Anything signed in with this password stops working immediately.": "Tout ce qui est connecté avec ce mot de passe cesse de fonctionner immédiatement.",
"App password revoked": "Mot de passe dapplication révoqué",
"Applies to every date in the series.": "Sapplique à toutes les dates de la série.",
"Applies to this date only.": "Sapplique uniquement à cette date.",
"Apply to series": "Appliquer à la série",
"Applying filter to existing messages…": "Application du filtre aux messages existants…",
"Attachments are still uploading": "Les pièces jointes sont encore en cours denvoi",
"Calendar saved": "Agenda enregistré",
"Categorised as {name}": "Catégorie « {name} » attribuée",
"Category cleared": "Catégorie retirée",
"Change this event?": "Modifier cet événement ?",
"Choose a calendar": "Choisissez un agenda",
"Choose an address book": "Choisissez un carnet dadresses",
"Colour updated": "Couleur modifiée",
"Contact created": "Contact créé",
"Contact deleted": "Contact supprimé",
"Contact saved": "Contact enregistré",
"Copied": "Copié",
"Copy of {title}": "Copie de {title}",
"Could not cancel: {error}": "Impossible dannuler : {error}",
"Could not copy": "Impossible de copier",
"Could not copy the address": "Impossible de copier ladresse",
"Could not empty folder: {error}": "Impossible de vider le dossier : {error}",
"Could not load source: {error}": "Impossible de charger la source : {error}",
"Could not mark as read: {error}": "Impossible de marquer comme lu : {error}",
"Could not save draft: {error}": "Impossible denregistrer le brouillon : {error}",
"Could not save filter: {error}": "Impossible denregistrer le filtre : {error}",
"Could not save filters: {error}": "Impossible denregistrer les filtres : {error}",
"Could not send the receipt: {error}": "Impossible denvoyer laccusé de lecture : {error}",
"Could not sign in.": "Connexion impossible.",
"Could not store image: {error}": "Impossible denregistrer limage : {error}",
"Could not update labels: {error}": "Impossible de mettre à jour les libellés : {error}",
"Could not update the Scheduled folder: {error}": "Impossible de mettre à jour le dossier Programmés : {error}",
"Could not update: {error}": "Impossible de mettre à jour : {error}",
"Create": "Créer",
"Create an address book first": "Créez dabord un carnet dadresses",
"Custom colour removed": "Couleur personnalisée retirée",
"Deactivate": "Désactiver",
"Delete failed: {error}": "Échec de la suppression : {error}",
"Delete forever": "Supprimer définitivement",
"Delete script “{name}”?": "Supprimer le script « {name} » ?",
"Delete this event?": "Supprimer cet événement ?",
"Delete this identity?": "Supprimer cette identité ?",
"Delete {name}?": "Supprimer {name} ?",
"Deleted": "Supprimé",
"Deletes the whole series. This cannot be undone.": "Supprime toute la série. Cette action est irréversible.",
"Did you forget the attachment?": "Avez-vous oublié la pièce jointe ?",
"Discard": "Abandonner",
"Discard this draft?": "Abandonner ce brouillon ?",
"Edit calendar": "Modifier lagenda",
"Edit event": "Modifier l’événement",
"Edit identity": "Modifier lidentité",
"Edit template": "Modifier le modèle",
"End must be after start": "La fin doit être postérieure au début",
"Event duplicated": "Événement dupliqué",
"Everything inside it goes too.": "Tout ce quil contient sera également supprimé.",
"Filter created": "Filtre créé",
"Filter created — it will run on new mail": "Filtre créé : il sappliquera au courrier entrant",
"Filter saved": "Filtre enregistré",
"Filter saved — it will run on new mail": "Filtre enregistré : il sappliquera au courrier entrant",
"Filter saved, but applying it failed: {error}": "Filtre enregistré, mais son application a échoué : {error}",
"Filters saved": "Filtres enregistrés",
"Folder changed, but its filter rules could not be updated: {error}": "Dossier modifié, mais ses règles de filtrage nont pas pu être mises à jour : {error}",
"Folder created": "Dossier créé",
"Folder deleted": "Dossier supprimé",
"Folder name": "Nom du dossier",
"Folder name (use / for a subfolder, e.g. Work/Invoices)": "Nom du dossier (utilisez / pour un sous-dossier, par ex. Travail/Factures)",
"Folder name (use / for subfolders, e.g. Work/Invoices)": "Nom du dossier (utilisez / pour des sous-dossiers, par ex. Travail/Factures)",
"Folder “{name}” created": "Dossier « {name} » créé",
"Forwarded": "Transféré",
"Hand to the server, held until {when} (Ctrl+Enter)": "Confié au serveur, retenu jusqu’à {when} (Ctrl+Entrée)",
"Hide": "Masquer",
"Hide quoted text": "Masquer le texte cité",
"Hide this from the compose picker": "Ne pas proposer à la rédaction",
"Identity saved": "Identité enregistrée",
"Images in signatures need the Files feature, which this account doesn't have.": "Les images dans les signatures nécessitent la fonction Fichiers, dont ce compte ne dispose pas.",
"Invalid address: {address}": "Adresse invalide : {address}",
"Invalid username or password.": "Nom dutilisateur ou mot de passe incorrect.",
"It belongs to a change that was applied to this and all later occurrences, which the server will only edit as a whole. Apply to the entire series instead?": "Elle relève dune modification appliquée à cette occurrence et à toutes les suivantes, que le serveur ne modifie que dun bloc. Lappliquer à toute la série ?",
"Label name": "Nom du libellé",
"Larger than {size} MB limit": "Dépasse la limite de {size} Mo",
"Message sent": "Message envoyé",
"Move failed: {error}": "Échec du déplacement : {error}",
"Move “{name}”": "Déplacer « {name} »",
"Moved": "Déplacé",
"Network error. Please check your connection.": "Erreur réseau. Vérifiez votre connexion.",
"New all-day event on {date}": "Nouvel événement sur la journée du {date}",
"New event at {time}": "Nouvel événement à {time}",
"New identity": "Nouvelle identité",
"No Archive folder found. Create one named “Archive” first.": "Aucun dossier darchive trouvé. Créez-en dabord un nommé « Archive ».",
"No contacts yet": "Pas encore de contacts",
"No longer shared": "Nest plus partagé",
"No matches": "Aucune correspondance",
"No new mail in your inbox.": "Aucun nouveau message dans votre boîte de réception.",
"No results": "Aucun résultat",
"Nothing here": "Rien ici",
"Only Deleted Items and Junk Mail can be emptied.": "Seuls la corbeille et les indésirables peuvent être vidés.",
"Please add at least one recipient": "Ajoutez au moins un destinataire",
"Please use an image under 512 KB for signatures.": "Utilisez une image de moins de 512 Ko pour les signatures.",
"Preview": "Aperçu",
"Read receipt sent": "Accusé de lecture envoyé",
"Remove attachments that failed to upload first": "Retirez dabord les pièces jointes dont lenvoi a échoué",
"Removed from calendar": "Retiré de lagenda",
"Removes this date and leaves the rest of the series.": "Retire cette date et laisse le reste de la série.",
"Rename address book": "Renommer le carnet dadresses",
"Rename category": "Renommer la catégorie",
"Replied": "Répondu",
"Report spam (!)": "Signaler comme spam (!)",
"Response sent": "Réponse envoyée",
"Revoke “{name}”?": "Révoquer « {name} » ?",
"Script has errors": "Le script contient des erreurs",
"Script is valid": "Le script est valide",
"Script name is required": "Un nom de script est requis",
"Script saved": "Script enregistré",
"Send (Ctrl+Enter)": "Envoyer (Ctrl+Entrée)",
"Send anyway": "Envoyer quand même",
"Send cancelled — the message is back in Drafts": "Envoi annulé : le message est de retour dans les brouillons",
"Send failed: {error}": "Échec de lenvoi : {error}",
"Send invites": "Envoyer les invitations",
"Send scheduled for {when}": "Envoi programmé pour {when}",
"Send without a subject?": "Envoyer sans objet ?",
"Share “{name}”": "Partager « {name} »",
"Sharing updated": "Partage mis à jour",
"Show": "Afficher",
"Show quoted text": "Afficher le texte cité",
"Show this in the compose picker": "Proposer à la rédaction",
"Sign out other sessions?": "Déconnecter les autres sessions ?",
"Sign out others": "Déconnecter les autres",
"Stop sharing “{name}”?": "Arrêter le partage de « {name} » ?",
"Switch to {theme}": "Passer à {theme}",
"Template": "Modèle",
"Template name": "Nom du modèle",
"The contacts in it go too.": "Les contacts quil contient seront également supprimés.",
"The default identity is always offered when composing": "Lidentité par défaut est toujours proposée à la rédaction",
"The new passwords don't match": "Les nouveaux mots de passe ne correspondent pas",
"The server scheduled this for {when}, not the time requested.": "Le serveur la programmé pour {when}, et non pour lheure demandée.",
"This date cannot be changed on its own": "Cette date ne peut pas être modifiée seule",
"This occurrence": "Cette occurrence",
"Too many attempts. Please wait a few minutes and try again.": "Trop de tentatives. Attendez quelques minutes et réessayez.",
"Try another search.": "Essayez une autre recherche.",
"Try different keywords or filters.": "Essayez dautres mots-clés ou filtres.",
"Two-factor authentication is off": "Lauthentification à deux facteurs est désactivée",
"Undo window: {seconds}s": "Délai dannulation : {seconds} s",
"You're all caught up": "Vous êtes à jour",
"Your browser refused the request: {error}": "Votre navigateur a refusé la demande : {error}",
"Your browser will ask whether to open mail links in ihasmail": "Votre navigateur vous demandera sil faut ouvrir les liens de courrier dans ihasmail",
"Your message mentions an attachment, but nothing is attached.": "Votre message mentionne une pièce jointe, mais rien nest joint.",
"event": "événement",
"Hide password": "Masquer le mot de passe",
"Show password": "Afficher le mot de passe",
"Settings reset to defaults": "Paramètres réinitialisés",
"Removed. Mail links will open in whatever your browser falls back to.": "Retiré. Les liens de courrier souvriront dans ce que votre navigateur utilise par défaut.",
"Edit rule": "Modifier la règle",
"Address copied": "Adresse copiée",
"Search or create label": "Rechercher ou créer un libellé",
"New label name": "Nom du nouveau libellé",
"That folder no longer exists. Showing your inbox instead.": "Ce dossier nexiste plus. Votre boîte de réception est affichée à la place.",
"Labels updated": "Libellés mis à jour",
"Unsubscribe message prepared — just hit Send": "Message de désabonnement préparé : il ne reste qu’à envoyer",
"Maximize": "Agrandir",
"Full screen": "Plein écran",
"Resize panes": "Redimensionner les volets",
"QR code": "QR code",
"Draft discarded": "Brouillon abandonné",
"Draft saved": "Brouillon enregistré",
"Emptying folder…": "Vidage du dossier…",
"Nothing unread here": "Rien de non lu ici",
},
plurals: {
"{n} messages": { one: "{n} message", other: "{n} messages" },
"{n} selected": { one: "{n} sélectionné", other: "{n} sélectionnés" },
"{n} conversations": { one: "{n} conversation", other: "{n} conversations" },
"{n} messages will be permanently deleted.": { one: "{n} message sera définitivement supprimé.", other: "{n} messages seront définitivement supprimés." },
"Signed out {n} other sessions": { one: "{n} autre session déconnectée", other: "{n} autres sessions déconnectées" },
"applied to {matched} of {n} messages": { one: "appliqué à {matched} message sur {n}", other: "appliqué à {matched} messages sur {n}" },
"Move {n} messages to…": { one: "Déplacer {n} message vers…", other: "Déplacer {n} messages vers…" },
"This permanently deletes the folder and its {n} messages.": { one: "Cela supprime définitivement le dossier et son {n} message.", other: "Cela supprime définitivement le dossier et ses {n} messages." },
"Added {n} contacts": { one: "{n} contact ajouté", other: "{n} contacts ajoutés" },
"{n} people will lose access. The contacts in it are not affected.": { one: "{n} personne perdra laccès. Les contacts quil contient ne sont pas affectés.", other: "{n} personnes perdront laccès. Les contacts quil contient ne sont pas affectés." },
"Imported {n} contacts": { one: "{n} contact importé", other: "{n} contacts importés" },
"Saved for this date. {names} apply to the whole series and were left unchanged.": { one: "Enregistré pour cette date. {names} sapplique à toute la série et na pas été modifié.", other: "Enregistré pour cette date. {names} sappliquent à toute la série et nont pas été modifiés." },
"{n} people will lose access. Events in it are not affected.": { one: "{n} personne perdra laccès. Les événements quil contient ne sont pas affectés.", other: "{n} personnes perdront laccès. Les événements quil contient ne sont pas affectés." },
"{n} messages could not be deleted": { one: "{n} message na pas pu être supprimé", other: "{n} messages nont pas pu être supprimés" },
"({n} deleted first)": { one: "({n} supprimé auparavant)", other: "({n} supprimés auparavant)" },
"Marked {n} messages as read": { one: "{n} message marqué comme lu", other: "{n} messages marqués comme lus" },
"in {n} folders": { one: "dans {n} dossier", other: "dans {n} dossiers" },
"Deleted {n} messages": { one: "{n} message supprimé", other: "{n} messages supprimés" },
},
};
+193 -1
View File
@@ -864,7 +864,184 @@ export const catalog: Catalog = {
"Error": "エラー",
"Saved {when}": "{when} に保存",
"Unsaved": "未保存",
"Agenda from {date}": "{date} からの予定",
"Agenda from {date}": "{date}からの予定",
// ── Toasts, dialogs and computed labels (second extraction pass) ───
"(no subject)": "(件名なし)",
"(skipped: {actions})": "(スキップ: {actions}",
"Activate": "有効にする",
"Add a contact or import a vCard file.": "連絡先を追加するか、vCard ファイルをインポートしてください。",
"Added to your calendar": "カレンダーに追加しました",
"All events in this calendar will be deleted.": "このカレンダーの予定はすべて削除されます。",
"All occurrences": "すべての繰り返し",
"An occurrence cannot be moved to another calendar on its own": "繰り返しの 1 件だけを別のカレンダーへ移動することはできません",
"Anything signed in with this password stops working immediately.": "このパスワードでサインインしているものは、ただちに使えなくなります。",
"App password revoked": "アプリパスワードを無効化しました",
"Applies to every date in the series.": "この繰り返しのすべての日付に適用されます。",
"Applies to this date only.": "この日付にのみ適用されます。",
"Apply to series": "繰り返し全体に適用",
"Applying filter to existing messages…": "既存のメールにフィルターを適用しています…",
"Attachments are still uploading": "添付ファイルをアップロード中です",
"Calendar saved": "カレンダーを保存しました",
"Categorised as {name}": "分類「{name}」を設定しました",
"Category cleared": "分類を解除しました",
"Change this event?": "この予定を変更しますか?",
"Choose a calendar": "カレンダーを選んでください",
"Choose an address book": "アドレス帳を選んでください",
"Colour updated": "色を変更しました",
"Contact created": "連絡先を作成しました",
"Contact deleted": "連絡先を削除しました",
"Contact saved": "連絡先を保存しました",
"Copied": "コピーしました",
"Copy of {title}": "{title} のコピー",
"Could not cancel: {error}": "取り消せませんでした: {error}",
"Could not copy": "コピーできませんでした",
"Could not copy the address": "アドレスをコピーできませんでした",
"Could not empty folder: {error}": "フォルダーを空にできませんでした: {error}",
"Could not load source: {error}": "ソースを読み込めませんでした: {error}",
"Could not mark as read: {error}": "既読にできませんでした: {error}",
"Could not save draft: {error}": "下書きを保存できませんでした: {error}",
"Could not save filter: {error}": "フィルターを保存できませんでした: {error}",
"Could not save filters: {error}": "フィルターを保存できませんでした: {error}",
"Could not send the receipt: {error}": "開封確認を送信できませんでした: {error}",
"Could not sign in.": "サインインできませんでした。",
"Could not store image: {error}": "画像を保存できませんでした: {error}",
"Could not update labels: {error}": "ラベルを更新できませんでした: {error}",
"Could not update the Scheduled folder: {error}": "「送信予約」フォルダーを更新できませんでした: {error}",
"Could not update: {error}": "更新できませんでした: {error}",
"Create": "作成",
"Create an address book first": "先にアドレス帳を作成してください",
"Custom colour removed": "カスタム色を解除しました",
"Deactivate": "無効にする",
"Delete failed: {error}": "削除できませんでした: {error}",
"Delete forever": "完全に削除",
"Delete script “{name}”?": "スクリプト「{name}」を削除しますか?",
"Delete this event?": "この予定を削除しますか?",
"Delete this identity?": "この差出人を削除しますか?",
"Delete {name}?": "{name} を削除しますか?",
"Deleted": "削除しました",
"Deletes the whole series. This cannot be undone.": "繰り返し全体を削除します。元に戻せません。",
"Did you forget the attachment?": "添付ファイルを忘れていませんか?",
"Discard": "破棄",
"Discard this draft?": "この下書きを破棄しますか?",
"Edit calendar": "カレンダーを編集",
"Edit event": "予定を編集",
"Edit identity": "差出人を編集",
"Edit template": "テンプレートを編集",
"End must be after start": "終了は開始より後にしてください",
"Event duplicated": "予定を複製しました",
"Everything inside it goes too.": "中身もすべて削除されます。",
"Filter created": "フィルターを作成しました",
"Filter created — it will run on new mail": "フィルターを作成しました。新着メールに適用されます",
"Filter saved": "フィルターを保存しました",
"Filter saved — it will run on new mail": "フィルターを保存しました。新着メールに適用されます",
"Filter saved, but applying it failed: {error}": "フィルターは保存しましたが、適用できませんでした: {error}",
"Filters saved": "フィルターを保存しました",
"Folder changed, but its filter rules could not be updated: {error}": "フォルダーは変更しましたが、そのフィルタールールを更新できませんでした: {error}",
"Folder created": "フォルダーを作成しました",
"Folder deleted": "フォルダーを削除しました",
"Folder name": "フォルダー名",
"Folder name (use / for a subfolder, e.g. Work/Invoices)": "フォルダー名(/ でサブフォルダー。例: 仕事/請求書)",
"Folder name (use / for subfolders, e.g. Work/Invoices)": "フォルダー名(/ でサブフォルダー。例: 仕事/請求書)",
"Folder “{name}” created": "フォルダー「{name}」を作成しました",
"Forwarded": "転送済み",
"Hand to the server, held until {when} (Ctrl+Enter)": "サーバーに渡し、{when} まで保留します (Ctrl+Enter)",
"Hide": "非表示",
"Hide quoted text": "引用文を隠す",
"Hide this from the compose picker": "作成時に表示しない",
"Identity saved": "差出人を保存しました",
"Images in signatures need the Files feature, which this account doesn't have.": "署名に画像を使うには「ファイル」機能が必要ですが、このアカウントにはありません。",
"Invalid address: {address}": "アドレスが正しくありません: {address}",
"Invalid username or password.": "ユーザー名またはパスワードが正しくありません。",
"It belongs to a change that was applied to this and all later occurrences, which the server will only edit as a whole. Apply to the entire series instead?": "これは、この回以降のすべての繰り返しに適用された変更の一部です。サーバーはその変更をまとめてしか編集できません。繰り返し全体に適用しますか?",
"Label name": "ラベル名",
"Larger than {size} MB limit": "上限の {size} MB を超えています",
"Message sent": "メールを送信しました",
"Move failed: {error}": "移動できませんでした: {error}",
"Move “{name}”": "「{name}」を移動",
"Moved": "移動しました",
"Network error. Please check your connection.": "ネットワークエラーです。接続を確認してください。",
"New all-day event on {date}": "{date} に終日の予定を作成",
"New event at {time}": "{time} に予定を作成",
"New identity": "新しい差出人",
"No Archive folder found. Create one named “Archive” first.": "アーカイブフォルダーが見つかりません。先に「Archive」という名前で作成してください。",
"No contacts yet": "連絡先がまだありません",
"No longer shared": "共有を解除しました",
"No matches": "一致するものがありません",
"No new mail in your inbox.": "受信トレイに新着メールはありません。",
"No results": "結果がありません",
"Nothing here": "ここには何もありません",
"Only Deleted Items and Junk Mail can be emptied.": "空にできるのはゴミ箱と迷惑メールだけです。",
"Please add at least one recipient": "宛先を 1 件以上入力してください",
"Please use an image under 512 KB for signatures.": "署名には 512 KB 未満の画像をお使いください。",
"Preview": "プレビュー",
"Read receipt sent": "開封確認を送信しました",
"Remove attachments that failed to upload first": "アップロードに失敗した添付ファイルを先に削除してください",
"Removed from calendar": "カレンダーから削除しました",
"Removes this date and leaves the rest of the series.": "この日付だけを削除し、繰り返しの残りは残します。",
"Rename address book": "アドレス帳の名前を変更",
"Rename category": "分類の名前を変更",
"Replied": "返信済み",
"Report spam (!)": "迷惑メールとして報告 (!)",
"Response sent": "回答を送信しました",
"Revoke “{name}”?": "「{name}」を無効化しますか?",
"Script has errors": "スクリプトにエラーがあります",
"Script is valid": "スクリプトは正しい形式です",
"Script name is required": "スクリプト名を入力してください",
"Script saved": "スクリプトを保存しました",
"Send (Ctrl+Enter)": "送信 (Ctrl+Enter)",
"Send anyway": "このまま送信",
"Send cancelled — the message is back in Drafts": "送信を取り消しました。メールは下書きに戻っています",
"Send failed: {error}": "送信できませんでした: {error}",
"Send invites": "招待を送信",
"Send scheduled for {when}": "{when} に送信を予約しました",
"Send without a subject?": "件名なしで送信しますか?",
"Share “{name}”": "「{name}」を共有",
"Sharing updated": "共有設定を更新しました",
"Show": "表示",
"Show quoted text": "引用文を表示",
"Show this in the compose picker": "作成時に表示する",
"Sign out other sessions?": "他のセッションをサインアウトしますか?",
"Sign out others": "他をサインアウト",
"Stop sharing “{name}”?": "「{name}」の共有を解除しますか?",
"Switch to {theme}": "{theme}に切り替え",
"Template": "テンプレート",
"Template name": "テンプレート名",
"The contacts in it go too.": "中の連絡先も削除されます。",
"The default identity is always offered when composing": "既定の差出人は作成時につねに表示されます",
"The new passwords don't match": "新しいパスワードが一致しません",
"The server scheduled this for {when}, not the time requested.": "サーバーは要求した時刻ではなく {when} に予約しました。",
"This date cannot be changed on its own": "この日付だけを変更することはできません",
"This occurrence": "この回のみ",
"Too many attempts. Please wait a few minutes and try again.": "試行回数が多すぎます。数分待ってからもう一度お試しください。",
"Try another search.": "別のことばで検索してみてください。",
"Try different keywords or filters.": "別のキーワードや絞り込みをお試しください。",
"Two-factor authentication is off": "2 段階認証はオフです",
"Undo window: {seconds}s": "取り消せる時間: {seconds} 秒",
"You're all caught up": "未読はありません",
"Your browser refused the request: {error}": "ブラウザーが要求を拒否しました: {error}",
"Your browser will ask whether to open mail links in ihasmail": "メールのリンクを ihasmail で開くかどうか、ブラウザーが確認します",
"Your message mentions an attachment, but nothing is attached.": "本文で添付ファイルに触れていますが、何も添付されていません。",
"event": "予定",
"Hide password": "パスワードを隠す",
"Show password": "パスワードを表示",
"Settings reset to defaults": "設定を既定に戻しました",
"Removed. Mail links will open in whatever your browser falls back to.": "解除しました。メールのリンクは、ブラウザーが代わりに使うアプリで開きます。",
"Edit rule": "ルールを編集",
"Address copied": "アドレスをコピーしました",
"Search or create label": "ラベルを検索または作成",
"New label name": "新しいラベルの名前",
"That folder no longer exists. Showing your inbox instead.": "そのフォルダーはもうありません。代わりに受信トレイを表示しています。",
"Labels updated": "ラベルを更新しました",
"Unsubscribe message prepared — just hit Send": "配信停止のメールを用意しました。あとは送信するだけです",
"Maximize": "最大化",
"Full screen": "全画面",
"Resize panes": "ペインの大きさを変更",
"QR code": "QR コード",
"Draft discarded": "下書きを破棄しました",
"Draft saved": "下書きを保存しました",
"Emptying folder…": "フォルダーを空にしています…",
"Nothing unread here": "ここに未読はありません",
},
plurals: {
/*
@@ -875,5 +1052,20 @@ export const catalog: Catalog = {
"{n} messages": { other: "{n} 通のメール" },
"{n} conversations": { other: "{n} 件のスレッド" },
"{n} selected": { other: "{n} 件を選択中" },
"{n} messages will be permanently deleted.": { other: "{n} 通のメールが完全に削除されます。" },
"Signed out {n} other sessions": { other: "他の {n} 件のセッションをサインアウトしました" },
"applied to {matched} of {n} messages": { other: "{n} 通中 {matched} 通に適用" },
"Move {n} messages to…": { other: "{n} 通のメールを移動…" },
"This permanently deletes the folder and its {n} messages.": { other: "フォルダーと、その中の {n} 通のメールを完全に削除します。" },
"Added {n} contacts": { other: "{n} 件の連絡先を追加しました" },
"{n} people will lose access. The contacts in it are not affected.": { other: "{n} 人がアクセスできなくなります。中の連絡先には影響しません。" },
"Imported {n} contacts": { other: "{n} 件の連絡先をインポートしました" },
"Saved for this date. {names} apply to the whole series and were left unchanged.": { other: "この日付に保存しました。{names} は繰り返し全体に適用されるため、変更していません。" },
"{n} people will lose access. Events in it are not affected.": { other: "{n} 人がアクセスできなくなります。中の予定には影響しません。" },
"{n} messages could not be deleted": { other: "{n} 通のメールを削除できませんでした" },
"({n} deleted first)": { other: "(先に {n} 通を削除)" },
"Marked {n} messages as read": { other: "{n} 通のメールを既読にしました" },
"in {n} folders": { other: "{n} 個のフォルダーで" },
"Deleted {n} messages": { other: "{n} 通のメールを削除しました" },
},
};
+192
View File
@@ -853,10 +853,202 @@ export const catalog: Catalog = {
"Saved {when}": "Opgeslagen {when}",
"Unsaved": "Niet opgeslagen",
"Agenda from {date}": "Agenda vanaf {date}",
// ── Toasts, dialogs and computed labels (second extraction pass) ───
"(no subject)": "(geen onderwerp)",
"(skipped: {actions})": "(overgeslagen: {actions})",
"Activate": "Activeren",
"Add a contact or import a vCard file.": "Voeg een contact toe of importeer een vCard-bestand.",
"Added to your calendar": "Toegevoegd aan uw agenda",
"All events in this calendar will be deleted.": "Alle afspraken in deze agenda worden verwijderd.",
"All occurrences": "Alle herhalingen",
"An occurrence cannot be moved to another calendar on its own": "Eén herhaling kan niet los naar een andere agenda worden verplaatst",
"Anything signed in with this password stops working immediately.": "Alles wat met dit wachtwoord is aangemeld, werkt meteen niet meer.",
"App password revoked": "App-wachtwoord ingetrokken",
"Applies to every date in the series.": "Geldt voor elke datum in de reeks.",
"Applies to this date only.": "Geldt alleen voor deze datum.",
"Apply to series": "Op de reeks toepassen",
"Applying filter to existing messages…": "Filter wordt toegepast op bestaande berichten…",
"Attachments are still uploading": "Bijlagen worden nog geüpload",
"Calendar saved": "Agenda opgeslagen",
"Categorised as {name}": "Categorie {name} toegekend",
"Category cleared": "Categorie verwijderd",
"Change this event?": "Deze afspraak wijzigen?",
"Choose a calendar": "Kies een agenda",
"Choose an address book": "Kies een adresboek",
"Colour updated": "Kleur gewijzigd",
"Contact created": "Contact aangemaakt",
"Contact deleted": "Contact verwijderd",
"Contact saved": "Contact opgeslagen",
"Copied": "Gekopieerd",
"Copy of {title}": "Kopie van {title}",
"Could not cancel: {error}": "Annuleren mislukt: {error}",
"Could not copy": "Kopiëren mislukt",
"Could not copy the address": "Het adres kon niet worden gekopieerd",
"Could not empty folder: {error}": "Map legen mislukt: {error}",
"Could not load source: {error}": "De bron kon niet worden geladen: {error}",
"Could not mark as read: {error}": "Markeren als gelezen mislukt: {error}",
"Could not save draft: {error}": "Concept opslaan mislukt: {error}",
"Could not save filter: {error}": "Filter opslaan mislukt: {error}",
"Could not save filters: {error}": "Filters opslaan mislukt: {error}",
"Could not send the receipt: {error}": "De leesbevestiging kon niet worden verzonden: {error}",
"Could not sign in.": "Aanmelden mislukt.",
"Could not store image: {error}": "De afbeelding kon niet worden opgeslagen: {error}",
"Could not update labels: {error}": "Labels bijwerken mislukt: {error}",
"Could not update the Scheduled folder: {error}": "De map Gepland kon niet worden bijgewerkt: {error}",
"Could not update: {error}": "Bijwerken mislukt: {error}",
"Create": "Aanmaken",
"Create an address book first": "Maak eerst een adresboek aan",
"Custom colour removed": "Eigen kleur verwijderd",
"Deactivate": "Deactiveren",
"Delete failed: {error}": "Verwijderen mislukt: {error}",
"Delete forever": "Definitief verwijderen",
"Delete script “{name}”?": "Script {name} verwijderen?",
"Delete this event?": "Deze afspraak verwijderen?",
"Delete this identity?": "Deze afzender verwijderen?",
"Delete {name}?": "{name} verwijderen?",
"Deleted": "Verwijderd",
"Deletes the whole series. This cannot be undone.": "Verwijdert de hele reeks. Dit kan niet ongedaan worden gemaakt.",
"Did you forget the attachment?": "Bent u de bijlage vergeten?",
"Discard": "Weggooien",
"Discard this draft?": "Dit concept weggooien?",
"Edit calendar": "Agenda bewerken",
"Edit event": "Afspraak bewerken",
"Edit identity": "Afzender bewerken",
"Edit template": "Sjabloon bewerken",
"End must be after start": "Het einde moet na het begin liggen",
"Event duplicated": "Afspraak gedupliceerd",
"Everything inside it goes too.": "Alles wat erin zit gaat mee.",
"Filter created": "Filter aangemaakt",
"Filter created — it will run on new mail": "Filter aangemaakt — het draait op nieuwe berichten",
"Filter saved": "Filter opgeslagen",
"Filter saved — it will run on new mail": "Filter opgeslagen — het draait op nieuwe berichten",
"Filter saved, but applying it failed: {error}": "Filter opgeslagen, maar toepassen is mislukt: {error}",
"Filters saved": "Filters opgeslagen",
"Folder changed, but its filter rules could not be updated: {error}": "Map gewijzigd, maar de filterregels konden niet worden bijgewerkt: {error}",
"Folder created": "Map aangemaakt",
"Folder deleted": "Map verwijderd",
"Folder name": "Mapnaam",
"Folder name (use / for a subfolder, e.g. Work/Invoices)": "Mapnaam (gebruik / voor een submap, bijv. Werk/Facturen)",
"Folder name (use / for subfolders, e.g. Work/Invoices)": "Mapnaam (gebruik / voor submappen, bijv. Werk/Facturen)",
"Folder “{name}” created": "Map {name} aangemaakt",
"Forwarded": "Doorgestuurd",
"Hand to the server, held until {when} (Ctrl+Enter)": "Naar de server gestuurd en vastgehouden tot {when} (Ctrl+Enter)",
"Hide": "Verbergen",
"Hide quoted text": "Geciteerde tekst verbergen",
"Hide this from the compose picker": "Niet aanbieden bij het opstellen",
"Identity saved": "Afzender opgeslagen",
"Images in signatures need the Files feature, which this account doesn't have.": "Afbeeldingen in handtekeningen vereisen de functie Bestanden, die dit account niet heeft.",
"Invalid address: {address}": "Ongeldig adres: {address}",
"Invalid username or password.": "Gebruikersnaam of wachtwoord is onjuist.",
"It belongs to a change that was applied to this and all later occurrences, which the server will only edit as a whole. Apply to the entire series instead?": "Het hoort bij een wijziging die op deze en alle latere herhalingen is toegepast, en die de server alleen in zijn geheel bewerkt. In plaats daarvan op de hele reeks toepassen?",
"Label name": "Labelnaam",
"Larger than {size} MB limit": "Groter dan de limiet van {size} MB",
"Message sent": "Bericht verzonden",
"Move failed: {error}": "Verplaatsen mislukt: {error}",
"Move “{name}”": "{name} verplaatsen",
"Moved": "Verplaatst",
"Network error. Please check your connection.": "Netwerkfout. Controleer uw verbinding.",
"New all-day event on {date}": "Nieuwe hele dag durende afspraak op {date}",
"New event at {time}": "Nieuwe afspraak om {time}",
"New identity": "Nieuwe afzender",
"No Archive folder found. Create one named “Archive” first.": "Geen archiefmap gevonden. Maak er eerst een met de naam Archive.",
"No contacts yet": "Nog geen contacten",
"No longer shared": "Niet langer gedeeld",
"No matches": "Geen overeenkomsten",
"No new mail in your inbox.": "Geen nieuwe berichten in uw postvak IN.",
"No results": "Geen resultaten",
"Nothing here": "Hier is niets",
"Only Deleted Items and Junk Mail can be emptied.": "Alleen de prullenbak en ongewenste e-mail kunnen worden geleegd.",
"Please add at least one recipient": "Voeg ten minste één ontvanger toe",
"Please use an image under 512 KB for signatures.": "Gebruik voor handtekeningen een afbeelding onder 512 KB.",
"Preview": "Voorbeeld",
"Read receipt sent": "Leesbevestiging verzonden",
"Remove attachments that failed to upload first": "Verwijder eerst de bijlagen die niet geüpload konden worden",
"Removed from calendar": "Uit de agenda verwijderd",
"Removes this date and leaves the rest of the series.": "Verwijdert deze datum en laat de rest van de reeks staan.",
"Rename address book": "Adresboek hernoemen",
"Rename category": "Categorie hernoemen",
"Replied": "Beantwoord",
"Report spam (!)": "Als spam melden (!)",
"Response sent": "Antwoord verzonden",
"Revoke “{name}”?": "{name} intrekken?",
"Script has errors": "Het script bevat fouten",
"Script is valid": "Het script is geldig",
"Script name is required": "Een scriptnaam is verplicht",
"Script saved": "Script opgeslagen",
"Send (Ctrl+Enter)": "Verzenden (Ctrl+Enter)",
"Send anyway": "Toch verzenden",
"Send cancelled — the message is back in Drafts": "Verzenden geannuleerd — het bericht staat weer bij Concepten",
"Send failed: {error}": "Verzenden mislukt: {error}",
"Send invites": "Uitnodigingen verzenden",
"Send scheduled for {when}": "Verzenden gepland voor {when}",
"Send without a subject?": "Verzenden zonder onderwerp?",
"Share “{name}”": "{name} delen",
"Sharing updated": "Delen bijgewerkt",
"Show": "Tonen",
"Show quoted text": "Geciteerde tekst tonen",
"Show this in the compose picker": "Aanbieden bij het opstellen",
"Sign out other sessions?": "Andere sessies afmelden?",
"Sign out others": "Andere afmelden",
"Stop sharing “{name}”?": "Delen van {name} stoppen?",
"Switch to {theme}": "Overschakelen naar {theme}",
"Template": "Sjabloon",
"Template name": "Sjabloonnaam",
"The contacts in it go too.": "De contacten erin gaan mee.",
"The default identity is always offered when composing": "De standaardafzender wordt altijd aangeboden bij het opstellen",
"The new passwords don't match": "De nieuwe wachtwoorden komen niet overeen",
"The server scheduled this for {when}, not the time requested.": "De server heeft dit gepland voor {when}, niet voor de gevraagde tijd.",
"This date cannot be changed on its own": "Deze datum kan niet los worden gewijzigd",
"This occurrence": "Deze herhaling",
"Too many attempts. Please wait a few minutes and try again.": "Te veel pogingen. Wacht een paar minuten en probeer het opnieuw.",
"Try another search.": "Probeer een andere zoekopdracht.",
"Try different keywords or filters.": "Probeer andere zoekwoorden of filters.",
"Two-factor authentication is off": "Tweefactorauthenticatie staat uit",
"Undo window: {seconds}s": "Tijd om ongedaan te maken: {seconds} s",
"You're all caught up": "U bent helemaal bij",
"Your browser refused the request: {error}": "Uw browser heeft het verzoek geweigerd: {error}",
"Your browser will ask whether to open mail links in ihasmail": "Uw browser vraagt of e-maillinks in ihasmail moeten worden geopend",
"Your message mentions an attachment, but nothing is attached.": "Uw bericht noemt een bijlage, maar er is niets bijgevoegd.",
"event": "afspraak",
"Hide password": "Wachtwoord verbergen",
"Show password": "Wachtwoord tonen",
"Settings reset to defaults": "Instellingen teruggezet naar de standaardwaarden",
"Removed. Mail links will open in whatever your browser falls back to.": "Verwijderd. E-maillinks worden geopend in waar uw browser op terugvalt.",
"Edit rule": "Regel bewerken",
"Address copied": "Adres gekopieerd",
"Search or create label": "Label zoeken of aanmaken",
"New label name": "Naam van het nieuwe label",
"That folder no longer exists. Showing your inbox instead.": "Die map bestaat niet meer. Uw postvak IN wordt getoond.",
"Labels updated": "Labels bijgewerkt",
"Unsubscribe message prepared — just hit Send": "Afmeldbericht klaargezet — u hoeft alleen op Verzenden te klikken",
"Maximize": "Maximaliseren",
"Full screen": "Volledig scherm",
"Resize panes": "Vensterdelen verslepen",
"QR code": "QR-code",
"Draft discarded": "Concept weggegooid",
"Draft saved": "Concept opgeslagen",
"Emptying folder…": "Map wordt geleegd…",
"Nothing unread here": "Hier is niets ongelezen",
},
plurals: {
"{n} messages": { one: "{n} bericht", other: "{n} berichten" },
"{n} selected": { one: "{n} geselecteerd", other: "{n} geselecteerd" },
"{n} conversations": { one: "{n} gesprek", other: "{n} gesprekken" },
"{n} messages will be permanently deleted.": { one: "{n} bericht wordt definitief verwijderd.", other: "{n} berichten worden definitief verwijderd." },
"Signed out {n} other sessions": { one: "{n} andere sessie afgemeld", other: "{n} andere sessies afgemeld" },
"applied to {matched} of {n} messages": { one: "toegepast op {matched} van {n} bericht", other: "toegepast op {matched} van {n} berichten" },
"Move {n} messages to…": { one: "{n} bericht verplaatsen naar…", other: "{n} berichten verplaatsen naar…" },
"This permanently deletes the folder and its {n} messages.": { one: "Dit verwijdert de map en het {n} bericht erin definitief.", other: "Dit verwijdert de map en de {n} berichten erin definitief." },
"Added {n} contacts": { one: "{n} contact toegevoegd", other: "{n} contacten toegevoegd" },
"{n} people will lose access. The contacts in it are not affected.": { one: "{n} persoon verliest toegang. De contacten erin blijven ongemoeid.", other: "{n} personen verliezen toegang. De contacten erin blijven ongemoeid." },
"Imported {n} contacts": { one: "{n} contact geïmporteerd", other: "{n} contacten geïmporteerd" },
"Saved for this date. {names} apply to the whole series and were left unchanged.": { one: "Opgeslagen voor deze datum. {names} geldt voor de hele reeks en is ongewijzigd gelaten.", other: "Opgeslagen voor deze datum. {names} gelden voor de hele reeks en zijn ongewijzigd gelaten." },
"{n} people will lose access. Events in it are not affected.": { one: "{n} persoon verliest toegang. De afspraken erin blijven ongemoeid.", other: "{n} personen verliezen toegang. De afspraken erin blijven ongemoeid." },
"{n} messages could not be deleted": { one: "{n} bericht kon niet worden verwijderd", other: "{n} berichten konden niet worden verwijderd" },
"({n} deleted first)": { one: "({n} eerst verwijderd)", other: "({n} eerst verwijderd)" },
"Marked {n} messages as read": { one: "{n} bericht als gelezen gemarkeerd", other: "{n} berichten als gelezen gemarkeerd" },
"in {n} folders": { one: "in {n} map", other: "in {n} mappen" },
"Deleted {n} messages": { one: "{n} bericht verwijderd", other: "{n} berichten verwijderd" },
},
};
+192
View File
@@ -860,10 +860,202 @@ export const catalog: Catalog = {
"Saved {when}": "Salvo {when}",
"Unsaved": "Não salvo",
"Agenda from {date}": "Agenda a partir de {date}",
// ── Toasts, dialogs and computed labels (second extraction pass) ───
"(no subject)": "(sem assunto)",
"(skipped: {actions})": "(ignorado: {actions})",
"Activate": "Ativar",
"Add a contact or import a vCard file.": "Adicione um contato ou importe um arquivo vCard.",
"Added to your calendar": "Adicionado à sua agenda",
"All events in this calendar will be deleted.": "Todos os eventos desta agenda serão excluídos.",
"All occurrences": "Todas as ocorrências",
"An occurrence cannot be moved to another calendar on its own": "Uma ocorrência não pode ser movida sozinha para outra agenda",
"Anything signed in with this password stops working immediately.": "Tudo o que estiver conectado com esta senha para de funcionar imediatamente.",
"App password revoked": "Senha de aplicativo revogada",
"Applies to every date in the series.": "Vale para todas as datas da série.",
"Applies to this date only.": "Vale apenas para esta data.",
"Apply to series": "Aplicar à série",
"Applying filter to existing messages…": "Aplicando o filtro às mensagens existentes…",
"Attachments are still uploading": "Os anexos ainda estão sendo enviados",
"Calendar saved": "Agenda salva",
"Categorised as {name}": "Categoria “{name}” atribuída",
"Category cleared": "Categoria removida",
"Change this event?": "Alterar este evento?",
"Choose a calendar": "Escolha uma agenda",
"Choose an address book": "Escolha um catálogo de endereços",
"Colour updated": "Cor atualizada",
"Contact created": "Contato criado",
"Contact deleted": "Contato excluído",
"Contact saved": "Contato salvo",
"Copied": "Copiado",
"Copy of {title}": "Cópia de {title}",
"Could not cancel: {error}": "Não foi possível cancelar: {error}",
"Could not copy": "Não foi possível copiar",
"Could not copy the address": "Não foi possível copiar o endereço",
"Could not empty folder: {error}": "Não foi possível esvaziar a pasta: {error}",
"Could not load source: {error}": "Não foi possível carregar o código-fonte: {error}",
"Could not mark as read: {error}": "Não foi possível marcar como lida: {error}",
"Could not save draft: {error}": "Não foi possível salvar o rascunho: {error}",
"Could not save filter: {error}": "Não foi possível salvar o filtro: {error}",
"Could not save filters: {error}": "Não foi possível salvar os filtros: {error}",
"Could not send the receipt: {error}": "Não foi possível enviar a confirmação de leitura: {error}",
"Could not sign in.": "Não foi possível entrar.",
"Could not store image: {error}": "Não foi possível armazenar a imagem: {error}",
"Could not update labels: {error}": "Não foi possível atualizar os marcadores: {error}",
"Could not update the Scheduled folder: {error}": "Não foi possível atualizar a pasta Agendados: {error}",
"Could not update: {error}": "Não foi possível atualizar: {error}",
"Create": "Criar",
"Create an address book first": "Crie um catálogo de endereços primeiro",
"Custom colour removed": "Cor personalizada removida",
"Deactivate": "Desativar",
"Delete failed: {error}": "Falha ao excluir: {error}",
"Delete forever": "Excluir definitivamente",
"Delete script “{name}”?": "Excluir o script “{name}”?",
"Delete this event?": "Excluir este evento?",
"Delete this identity?": "Excluir esta identidade?",
"Delete {name}?": "Excluir {name}?",
"Deleted": "Excluído",
"Deletes the whole series. This cannot be undone.": "Exclui a série inteira. Isso não pode ser desfeito.",
"Did you forget the attachment?": "Você esqueceu o anexo?",
"Discard": "Descartar",
"Discard this draft?": "Descartar este rascunho?",
"Edit calendar": "Editar agenda",
"Edit event": "Editar evento",
"Edit identity": "Editar identidade",
"Edit template": "Editar modelo",
"End must be after start": "O término deve ser depois do início",
"Event duplicated": "Evento duplicado",
"Everything inside it goes too.": "Tudo o que está dentro também será excluído.",
"Filter created": "Filtro criado",
"Filter created — it will run on new mail": "Filtro criado — ele vai valer para as mensagens novas",
"Filter saved": "Filtro salvo",
"Filter saved — it will run on new mail": "Filtro salvo — ele vai valer para as mensagens novas",
"Filter saved, but applying it failed: {error}": "Filtro salvo, mas não foi possível aplicá-lo: {error}",
"Filters saved": "Filtros salvos",
"Folder changed, but its filter rules could not be updated: {error}": "Pasta alterada, mas não foi possível atualizar as regras de filtro dela: {error}",
"Folder created": "Pasta criada",
"Folder deleted": "Pasta excluída",
"Folder name": "Nome da pasta",
"Folder name (use / for a subfolder, e.g. Work/Invoices)": "Nome da pasta (use / para uma subpasta, ex.: Trabalho/Faturas)",
"Folder name (use / for subfolders, e.g. Work/Invoices)": "Nome da pasta (use / para subpastas, ex.: Trabalho/Faturas)",
"Folder “{name}” created": "Pasta “{name}” criada",
"Forwarded": "Encaminhada",
"Hand to the server, held until {when} (Ctrl+Enter)": "Entregue ao servidor e retida até {when} (Ctrl+Enter)",
"Hide": "Ocultar",
"Hide quoted text": "Ocultar o texto citado",
"Hide this from the compose picker": "Não oferecer ao escrever",
"Identity saved": "Identidade salva",
"Images in signatures need the Files feature, which this account doesn't have.": "Imagens em assinaturas exigem o recurso Arquivos, que esta conta não tem.",
"Invalid address: {address}": "Endereço inválido: {address}",
"Invalid username or password.": "Usuário ou senha incorretos.",
"It belongs to a change that was applied to this and all later occurrences, which the server will only edit as a whole. Apply to the entire series instead?": "Ela faz parte de uma alteração aplicada a esta e a todas as ocorrências seguintes, que o servidor só edita por inteiro. Aplicar à série inteira?",
"Label name": "Nome do marcador",
"Larger than {size} MB limit": "Acima do limite de {size} MB",
"Message sent": "Mensagem enviada",
"Move failed: {error}": "Falha ao mover: {error}",
"Move “{name}”": "Mover “{name}”",
"Moved": "Movido",
"Network error. Please check your connection.": "Erro de rede. Verifique sua conexão.",
"New all-day event on {date}": "Novo evento de dia inteiro em {date}",
"New event at {time}": "Novo evento às {time}",
"New identity": "Nova identidade",
"No Archive folder found. Create one named “Archive” first.": "Nenhuma pasta de arquivamento encontrada. Crie uma chamada “Archive” primeiro.",
"No contacts yet": "Ainda não há contatos",
"No longer shared": "Não está mais compartilhado",
"No matches": "Nenhuma correspondência",
"No new mail in your inbox.": "Nenhuma mensagem nova na sua caixa de entrada.",
"No results": "Nenhum resultado",
"Nothing here": "Nada aqui",
"Only Deleted Items and Junk Mail can be emptied.": "Só a lixeira e o spam podem ser esvaziados.",
"Please add at least one recipient": "Adicione pelo menos um destinatário",
"Please use an image under 512 KB for signatures.": "Use uma imagem com menos de 512 KB para assinaturas.",
"Preview": "Visualização",
"Read receipt sent": "Confirmação de leitura enviada",
"Remove attachments that failed to upload first": "Remova primeiro os anexos que falharam ao enviar",
"Removed from calendar": "Removido da agenda",
"Removes this date and leaves the rest of the series.": "Remove esta data e mantém o resto da série.",
"Rename address book": "Renomear catálogo de endereços",
"Rename category": "Renomear categoria",
"Replied": "Respondida",
"Report spam (!)": "Denunciar spam (!)",
"Response sent": "Resposta enviada",
"Revoke “{name}”?": "Revogar “{name}”?",
"Script has errors": "O script tem erros",
"Script is valid": "O script é válido",
"Script name is required": "É preciso informar um nome de script",
"Script saved": "Script salvo",
"Send (Ctrl+Enter)": "Enviar (Ctrl+Enter)",
"Send anyway": "Enviar mesmo assim",
"Send cancelled — the message is back in Drafts": "Envio cancelado — a mensagem voltou para Rascunhos",
"Send failed: {error}": "Falha ao enviar: {error}",
"Send invites": "Enviar convites",
"Send scheduled for {when}": "Envio agendado para {when}",
"Send without a subject?": "Enviar sem assunto?",
"Share “{name}”": "Compartilhar “{name}”",
"Sharing updated": "Compartilhamento atualizado",
"Show": "Mostrar",
"Show quoted text": "Mostrar o texto citado",
"Show this in the compose picker": "Oferecer ao escrever",
"Sign out other sessions?": "Encerrar as outras sessões?",
"Sign out others": "Encerrar as outras",
"Stop sharing “{name}”?": "Parar de compartilhar “{name}”?",
"Switch to {theme}": "Mudar para {theme}",
"Template": "Modelo",
"Template name": "Nome do modelo",
"The contacts in it go too.": "Os contatos dentro dele também serão excluídos.",
"The default identity is always offered when composing": "A identidade padrão é sempre oferecida ao escrever",
"The new passwords don't match": "As novas senhas não coincidem",
"The server scheduled this for {when}, not the time requested.": "O servidor agendou isto para {when}, e não para o horário solicitado.",
"This date cannot be changed on its own": "Esta data não pode ser alterada sozinha",
"This occurrence": "Esta ocorrência",
"Too many attempts. Please wait a few minutes and try again.": "Tentativas demais. Espere alguns minutos e tente de novo.",
"Try another search.": "Tente outra busca.",
"Try different keywords or filters.": "Tente outras palavras ou filtros.",
"Two-factor authentication is off": "A verificação em duas etapas está desativada",
"Undo window: {seconds}s": "Tempo para desfazer: {seconds} s",
"You're all caught up": "Você está em dia",
"Your browser refused the request: {error}": "Seu navegador recusou a solicitação: {error}",
"Your browser will ask whether to open mail links in ihasmail": "Seu navegador vai perguntar se os links de e-mail devem abrir no ihasmail",
"Your message mentions an attachment, but nothing is attached.": "Sua mensagem menciona um anexo, mas nada foi anexado.",
"event": "evento",
"Hide password": "Ocultar a senha",
"Show password": "Mostrar a senha",
"Settings reset to defaults": "Configurações restauradas para o padrão",
"Removed. Mail links will open in whatever your browser falls back to.": "Removido. Os links de e-mail vão abrir no que o seu navegador usar por padrão.",
"Edit rule": "Editar regra",
"Address copied": "Endereço copiado",
"Search or create label": "Busque ou crie um marcador",
"New label name": "Nome do novo marcador",
"That folder no longer exists. Showing your inbox instead.": "Essa pasta não existe mais. Mostrando a sua caixa de entrada.",
"Labels updated": "Marcadores atualizados",
"Unsubscribe message prepared — just hit Send": "Mensagem de cancelamento pronta — é só clicar em Enviar",
"Maximize": "Maximizar",
"Full screen": "Tela cheia",
"Resize panes": "Redimensionar os painéis",
"QR code": "Código QR",
"Draft discarded": "Rascunho descartado",
"Draft saved": "Rascunho salvo",
"Emptying folder…": "Esvaziando a pasta…",
"Nothing unread here": "Não há nada não lido aqui",
},
plurals: {
"{n} messages": { one: "{n} mensagem", other: "{n} mensagens" },
"{n} selected": { one: "{n} selecionada", other: "{n} selecionadas" },
"{n} conversations": { one: "{n} conversa", other: "{n} conversas" },
"{n} messages will be permanently deleted.": { one: "{n} mensagem será excluída definitivamente.", other: "{n} mensagens serão excluídas definitivamente." },
"Signed out {n} other sessions": { one: "{n} outra sessão encerrada", other: "{n} outras sessões encerradas" },
"applied to {matched} of {n} messages": { one: "aplicado a {matched} de {n} mensagem", other: "aplicado a {matched} de {n} mensagens" },
"Move {n} messages to…": { one: "Mover {n} mensagem para…", other: "Mover {n} mensagens para…" },
"This permanently deletes the folder and its {n} messages.": { one: "Isso exclui definitivamente a pasta e a {n} mensagem dela.", other: "Isso exclui definitivamente a pasta e as {n} mensagens dela." },
"Added {n} contacts": { one: "{n} contato adicionado", other: "{n} contatos adicionados" },
"{n} people will lose access. The contacts in it are not affected.": { one: "{n} pessoa perderá o acesso. Os contatos dentro dele não são afetados.", other: "{n} pessoas perderão o acesso. Os contatos dentro dele não são afetados." },
"Imported {n} contacts": { one: "{n} contato importado", other: "{n} contatos importados" },
"Saved for this date. {names} apply to the whole series and were left unchanged.": { one: "Salvo para esta data. {names} vale para a série inteira e não foi alterado.", other: "Salvo para esta data. {names} valem para a série inteira e não foram alterados." },
"{n} people will lose access. Events in it are not affected.": { one: "{n} pessoa perderá o acesso. Os eventos dentro dela não são afetados.", other: "{n} pessoas perderão o acesso. Os eventos dentro dela não são afetados." },
"{n} messages could not be deleted": { one: "{n} mensagem não pôde ser excluída", other: "{n} mensagens não puderam ser excluídas" },
"({n} deleted first)": { one: "({n} excluída antes)", other: "({n} excluídas antes)" },
"Marked {n} messages as read": { one: "{n} mensagem marcada como lida", other: "{n} mensagens marcadas como lidas" },
"in {n} folders": { one: "em {n} pasta", other: "em {n} pastas" },
"Deleted {n} messages": { one: "{n} mensagem excluída", other: "{n} mensagens excluídas" },
},
};
+192
View File
@@ -859,6 +859,183 @@ export const catalog: Catalog = {
"Saved {when}": "Сохранено {when}",
"Unsaved": "Не сохранено",
"Agenda from {date}": "Расписание с {date}",
// ── Toasts, dialogs and computed labels (second extraction pass) ───
"(no subject)": "(без темы)",
"(skipped: {actions})": "(пропущено: {actions})",
"Activate": "Включить",
"Add a contact or import a vCard file.": "Добавьте контакт или импортируйте файл vCard.",
"Added to your calendar": "Добавлено в ваш календарь",
"All events in this calendar will be deleted.": "Все события этого календаря будут удалены.",
"All occurrences": "Все повторения",
"An occurrence cannot be moved to another calendar on its own": "Отдельное повторение нельзя перенести в другой календарь",
"Anything signed in with this password stops working immediately.": "Всё, что вошло с этим паролем, сразу перестанет работать.",
"App password revoked": "Пароль приложения отозван",
"Applies to every date in the series.": "Применяется ко всем датам серии.",
"Applies to this date only.": "Применяется только к этой дате.",
"Apply to series": "Применить ко всей серии",
"Applying filter to existing messages…": "Фильтр применяется к существующим письмам…",
"Attachments are still uploading": "Вложения ещё загружаются",
"Calendar saved": "Календарь сохранён",
"Categorised as {name}": "Назначена категория «{name}»",
"Category cleared": "Категория снята",
"Change this event?": "Изменить это событие?",
"Choose a calendar": "Выберите календарь",
"Choose an address book": "Выберите адресную книгу",
"Colour updated": "Цвет изменён",
"Contact created": "Контакт создан",
"Contact deleted": "Контакт удалён",
"Contact saved": "Контакт сохранён",
"Copied": "Скопировано",
"Copy of {title}": "Копия: {title}",
"Could not cancel: {error}": "Не удалось отменить: {error}",
"Could not copy": "Не удалось скопировать",
"Could not copy the address": "Не удалось скопировать адрес",
"Could not empty folder: {error}": "Не удалось очистить папку: {error}",
"Could not load source: {error}": "Не удалось загрузить исходный текст: {error}",
"Could not mark as read: {error}": "Не удалось отметить как прочитанное: {error}",
"Could not save draft: {error}": "Не удалось сохранить черновик: {error}",
"Could not save filter: {error}": "Не удалось сохранить фильтр: {error}",
"Could not save filters: {error}": "Не удалось сохранить фильтры: {error}",
"Could not send the receipt: {error}": "Не удалось отправить уведомление о прочтении: {error}",
"Could not sign in.": "Не удалось войти.",
"Could not store image: {error}": "Не удалось сохранить изображение: {error}",
"Could not update labels: {error}": "Не удалось обновить метки: {error}",
"Could not update the Scheduled folder: {error}": "Не удалось обновить папку «Отложенные»: {error}",
"Could not update: {error}": "Не удалось обновить: {error}",
"Create": "Создать",
"Create an address book first": "Сначала создайте адресную книгу",
"Custom colour removed": "Свой цвет снят",
"Deactivate": "Выключить",
"Delete failed: {error}": "Не удалось удалить: {error}",
"Delete forever": "Удалить безвозвратно",
"Delete script “{name}”?": "Удалить сценарий «{name}»?",
"Delete this event?": "Удалить это событие?",
"Delete this identity?": "Удалить этот профиль отправителя?",
"Delete {name}?": "Удалить {name}?",
"Deleted": "Удалено",
"Deletes the whole series. This cannot be undone.": "Удаляет всю серию. Отменить это будет нельзя.",
"Did you forget the attachment?": "Не забыли вложение?",
"Discard": "Не сохранять",
"Discard this draft?": "Удалить этот черновик?",
"Edit calendar": "Изменить календарь",
"Edit event": "Изменить событие",
"Edit identity": "Изменить профиль отправителя",
"Edit template": "Изменить шаблон",
"End must be after start": "Окончание должно быть позже начала",
"Event duplicated": "Событие продублировано",
"Everything inside it goes too.": "Всё, что внутри, тоже будет удалено.",
"Filter created": "Фильтр создан",
"Filter created — it will run on new mail": "Фильтр создан — он будет работать для новых писем",
"Filter saved": "Фильтр сохранён",
"Filter saved — it will run on new mail": "Фильтр сохранён — он будет работать для новых писем",
"Filter saved, but applying it failed: {error}": "Фильтр сохранён, но применить его не удалось: {error}",
"Filters saved": "Фильтры сохранены",
"Folder changed, but its filter rules could not be updated: {error}": "Папка изменена, но её правила фильтрации обновить не удалось: {error}",
"Folder created": "Папка создана",
"Folder deleted": "Папка удалена",
"Folder name": "Имя папки",
"Folder name (use / for a subfolder, e.g. Work/Invoices)": "Имя папки (/ создаёт вложенную папку, например Работа/Счета)",
"Folder name (use / for subfolders, e.g. Work/Invoices)": "Имя папки (/ создаёт вложенные папки, например Работа/Счета)",
"Folder “{name}” created": "Папка «{name}» создана",
"Forwarded": "Переслано",
"Hand to the server, held until {when} (Ctrl+Enter)": "Передать серверу и удержать до {when} (Ctrl+Enter)",
"Hide": "Скрыть",
"Hide quoted text": "Скрыть цитируемый текст",
"Hide this from the compose picker": "Не предлагать при написании",
"Identity saved": "Профиль отправителя сохранён",
"Images in signatures need the Files feature, which this account doesn't have.": "Для изображений в подписи нужны «Файлы», которых у этой учётной записи нет.",
"Invalid address: {address}": "Неверный адрес: {address}",
"Invalid username or password.": "Неверное имя пользователя или пароль.",
"It belongs to a change that was applied to this and all later occurrences, which the server will only edit as a whole. Apply to the entire series instead?": "Она относится к изменению, применённому к этому и всем последующим повторениям, а сервер правит такое изменение только целиком. Применить ко всей серии?",
"Label name": "Название метки",
"Larger than {size} MB limit": "Больше предела в {size} МБ",
"Message sent": "Письмо отправлено",
"Move failed: {error}": "Не удалось переместить: {error}",
"Move “{name}”": "Переместить «{name}»",
"Moved": "Перемещено",
"Network error. Please check your connection.": "Ошибка сети. Проверьте подключение.",
"New all-day event on {date}": "Новое событие на весь день {date}",
"New event at {time}": "Новое событие в {time}",
"New identity": "Новый профиль отправителя",
"No Archive folder found. Create one named “Archive” first.": "Папка архива не найдена. Сначала создайте папку с именем «Archive».",
"No contacts yet": "Контактов пока нет",
"No longer shared": "Общий доступ прекращён",
"No matches": "Совпадений нет",
"No new mail in your inbox.": "Новых писем во входящих нет.",
"No results": "Ничего не найдено",
"Nothing here": "Здесь пусто",
"Only Deleted Items and Junk Mail can be emptied.": "Очистить можно только корзину и спам.",
"Please add at least one recipient": "Добавьте хотя бы одного получателя",
"Please use an image under 512 KB for signatures.": "Для подписи используйте изображение меньше 512 КБ.",
"Preview": "Просмотр",
"Read receipt sent": "Уведомление о прочтении отправлено",
"Remove attachments that failed to upload first": "Сначала удалите вложения, которые не загрузились",
"Removed from calendar": "Удалено из календаря",
"Removes this date and leaves the rest of the series.": "Удаляет эту дату и оставляет остальную серию.",
"Rename address book": "Переименовать адресную книгу",
"Rename category": "Переименовать категорию",
"Replied": "Есть ответ",
"Report spam (!)": "Пожаловаться на спам (!)",
"Response sent": "Ответ отправлен",
"Revoke “{name}”?": "Отозвать «{name}»?",
"Script has errors": "В сценарии есть ошибки",
"Script is valid": "Сценарий корректен",
"Script name is required": "Нужно указать имя сценария",
"Script saved": "Сценарий сохранён",
"Send (Ctrl+Enter)": "Отправить (Ctrl+Enter)",
"Send anyway": "Всё равно отправить",
"Send cancelled — the message is back in Drafts": "Отправка отменена — письмо вернулось в черновики",
"Send failed: {error}": "Не удалось отправить: {error}",
"Send invites": "Отправить приглашения",
"Send scheduled for {when}": "Отправка запланирована на {when}",
"Send without a subject?": "Отправить без темы?",
"Share “{name}”": "Открыть доступ к «{name}»",
"Sharing updated": "Общий доступ обновлён",
"Show": "Показать",
"Show quoted text": "Показать цитируемый текст",
"Show this in the compose picker": "Предлагать при написании",
"Sign out other sessions?": "Завершить другие сеансы?",
"Sign out others": "Завершить другие",
"Stop sharing “{name}”?": "Закрыть общий доступ к «{name}»?",
"Switch to {theme}": "Переключить на {theme}",
"Template": "Шаблон",
"Template name": "Название шаблона",
"The contacts in it go too.": "Контакты внутри тоже будут удалены.",
"The default identity is always offered when composing": "Профиль по умолчанию всегда предлагается при написании",
"The new passwords don't match": "Новые пароли не совпадают",
"The server scheduled this for {when}, not the time requested.": "Сервер запланировал это на {when}, а не на запрошенное время.",
"This date cannot be changed on its own": "Эту дату нельзя изменить отдельно",
"This occurrence": "Только это повторение",
"Too many attempts. Please wait a few minutes and try again.": "Слишком много попыток. Подождите несколько минут и попробуйте снова.",
"Try another search.": "Попробуйте другой запрос.",
"Try different keywords or filters.": "Попробуйте другие слова или фильтры.",
"Two-factor authentication is off": "Двухфакторная аутентификация выключена",
"Undo window: {seconds}s": "Время на отмену: {seconds} с",
"You're all caught up": "Всё прочитано",
"Your browser refused the request: {error}": "Браузер отклонил запрос: {error}",
"Your browser will ask whether to open mail links in ihasmail": "Браузер спросит, открывать ли почтовые ссылки в ihasmail",
"Your message mentions an attachment, but nothing is attached.": "В письме упомянуто вложение, но ничего не приложено.",
"event": "событие",
"Hide password": "Скрыть пароль",
"Show password": "Показать пароль",
"Settings reset to defaults": "Настройки сброшены к значениям по умолчанию",
"Removed. Mail links will open in whatever your browser falls back to.": "Удалено. Почтовые ссылки будут открываться там, куда переключится браузер.",
"Edit rule": "Изменить правило",
"Address copied": "Адрес скопирован",
"Search or create label": "Найдите или создайте метку",
"New label name": "Название новой метки",
"That folder no longer exists. Showing your inbox instead.": "Этой папки больше нет. Показаны входящие.",
"Labels updated": "Метки обновлены",
"Unsubscribe message prepared — just hit Send": "Письмо об отписке готово — осталось нажать «Отправить»",
"Maximize": "Развернуть",
"Full screen": "Во весь экран",
"Resize panes": "Изменить размер панелей",
"QR code": "QR-код",
"Draft discarded": "Черновик удалён",
"Draft saved": "Черновик сохранён",
"Emptying folder…": "Папка очищается…",
"Nothing unread here": "Здесь нет непрочитанного",
},
plurals: {
/*
@@ -872,5 +1049,20 @@ export const catalog: Catalog = {
// Impersonal, so the count needs no agreement at all — which is what
// Russian interfaces actually do here.
"{n} selected": { one: "Выбрано: {n}", few: "Выбрано: {n}", many: "Выбрано: {n}", other: "Выбрано: {n}" },
"{n} messages will be permanently deleted.": { one: "{n} письмо будет удалено безвозвратно.", few: "{n} письма будут удалены безвозвратно.", many: "{n} писем будут удалены безвозвратно.", other: "{n} письма будут удалены безвозвратно." },
"Signed out {n} other sessions": { one: "Завершён {n} другой сеанс", few: "Завершено {n} других сеанса", many: "Завершено {n} других сеансов", other: "Завершено {n} других сеанса" },
"applied to {matched} of {n} messages": { one: "применён к {matched} из {n} письма", few: "применён к {matched} из {n} писем", many: "применён к {matched} из {n} писем", other: "применён к {matched} из {n} писем" },
"Move {n} messages to…": { one: "Переместить {n} письмо в…", few: "Переместить {n} письма в…", many: "Переместить {n} писем в…", other: "Переместить {n} письма в…" },
"This permanently deletes the folder and its {n} messages.": { one: "Папка и {n} письмо в ней будут удалены безвозвратно.", few: "Папка и {n} письма в ней будут удалены безвозвратно.", many: "Папка и {n} писем в ней будут удалены безвозвратно.", other: "Папка и {n} письма в ней будут удалены безвозвратно." },
"Added {n} contacts": { one: "Добавлен {n} контакт", few: "Добавлено {n} контакта", many: "Добавлено {n} контактов", other: "Добавлено {n} контакта" },
"{n} people will lose access. The contacts in it are not affected.": { one: "{n} человек потеряет доступ. Контакты внутри не затрагиваются.", few: "{n} человека потеряют доступ. Контакты внутри не затрагиваются.", many: "{n} человек потеряют доступ. Контакты внутри не затрагиваются.", other: "{n} человека потеряют доступ. Контакты внутри не затрагиваются." },
"Imported {n} contacts": { one: "Импортирован {n} контакт", few: "Импортировано {n} контакта", many: "Импортировано {n} контактов", other: "Импортировано {n} контакта" },
"Saved for this date. {names} apply to the whole series and were left unchanged.": { one: "Сохранено для этой даты. {names} относится ко всей серии и осталось без изменений.", few: "Сохранено для этой даты. {names} относятся ко всей серии и остались без изменений.", many: "Сохранено для этой даты. {names} относятся ко всей серии и остались без изменений.", other: "Сохранено для этой даты. {names} относятся ко всей серии и остались без изменений." },
"{n} people will lose access. Events in it are not affected.": { one: "{n} человек потеряет доступ. События внутри не затрагиваются.", few: "{n} человека потеряют доступ. События внутри не затрагиваются.", many: "{n} человек потеряют доступ. События внутри не затрагиваются.", other: "{n} человека потеряют доступ. События внутри не затрагиваются." },
"{n} messages could not be deleted": { one: "Не удалось удалить {n} письмо", few: "Не удалось удалить {n} письма", many: "Не удалось удалить {n} писем", other: "Не удалось удалить {n} письма" },
"({n} deleted first)": { one: "(перед этим удалено: {n})", few: "(перед этим удалено: {n})", many: "(перед этим удалено: {n})", other: "(перед этим удалено: {n})" },
"Marked {n} messages as read": { one: "{n} письмо отмечено как прочитанное", few: "{n} письма отмечены как прочитанные", many: "{n} писем отмечены как прочитанные", other: "{n} письма отмечены как прочитанные" },
"in {n} folders": { one: "в {n} папке", few: "в {n} папках", many: "в {n} папках", other: "в {n} папках" },
"Deleted {n} messages": { one: "Удалено {n} письмо", few: "Удалено {n} письма", many: "Удалено {n} писем", other: "Удалено {n} письма" },
},
};
+192
View File
@@ -853,6 +853,183 @@ export const catalog: Catalog = {
"Saved {when}": "Збережено {when}",
"Unsaved": "Не збережено",
"Agenda from {date}": "Розклад від {date}",
// ── Toasts, dialogs and computed labels (second extraction pass) ───
"(no subject)": "(без теми)",
"(skipped: {actions})": "(пропущено: {actions})",
"Activate": "Увімкнути",
"Add a contact or import a vCard file.": "Додайте контакт або імпортуйте файл vCard.",
"Added to your calendar": "Додано до вашого календаря",
"All events in this calendar will be deleted.": "Усі події цього календаря буде видалено.",
"All occurrences": "Усі повторення",
"An occurrence cannot be moved to another calendar on its own": "Окреме повторення не можна перенести до іншого календаря",
"Anything signed in with this password stops working immediately.": "Усе, що ввійшло з цим паролем, одразу перестане працювати.",
"App password revoked": "Пароль застосунку відкликано",
"Applies to every date in the series.": "Застосовується до всіх дат серії.",
"Applies to this date only.": "Застосовується лише до цієї дати.",
"Apply to series": "Застосувати до всієї серії",
"Applying filter to existing messages…": "Фільтр застосовується до наявних листів…",
"Attachments are still uploading": "Вкладення ще завантажуються",
"Calendar saved": "Календар збережено",
"Categorised as {name}": "Призначено категорію «{name}»",
"Category cleared": "Категорію знято",
"Change this event?": "Змінити цю подію?",
"Choose a calendar": "Виберіть календар",
"Choose an address book": "Виберіть адресну книгу",
"Colour updated": "Колір змінено",
"Contact created": "Контакт створено",
"Contact deleted": "Контакт видалено",
"Contact saved": "Контакт збережено",
"Copied": "Скопійовано",
"Copy of {title}": "Копія: {title}",
"Could not cancel: {error}": "Не вдалося скасувати: {error}",
"Could not copy": "Не вдалося скопіювати",
"Could not copy the address": "Не вдалося скопіювати адресу",
"Could not empty folder: {error}": "Не вдалося очистити теку: {error}",
"Could not load source: {error}": "Не вдалося завантажити вихідний текст: {error}",
"Could not mark as read: {error}": "Не вдалося позначити як прочитане: {error}",
"Could not save draft: {error}": "Не вдалося зберегти чернетку: {error}",
"Could not save filter: {error}": "Не вдалося зберегти фільтр: {error}",
"Could not save filters: {error}": "Не вдалося зберегти фільтри: {error}",
"Could not send the receipt: {error}": "Не вдалося надіслати сповіщення про прочитання: {error}",
"Could not sign in.": "Не вдалося увійти.",
"Could not store image: {error}": "Не вдалося зберегти зображення: {error}",
"Could not update labels: {error}": "Не вдалося оновити мітки: {error}",
"Could not update the Scheduled folder: {error}": "Не вдалося оновити теку «Заплановані»: {error}",
"Could not update: {error}": "Не вдалося оновити: {error}",
"Create": "Створити",
"Create an address book first": "Спершу створіть адресну книгу",
"Custom colour removed": "Власний колір знято",
"Deactivate": "Вимкнути",
"Delete failed: {error}": "Не вдалося видалити: {error}",
"Delete forever": "Видалити назавжди",
"Delete script “{name}”?": "Видалити сценарій «{name}»?",
"Delete this event?": "Видалити цю подію?",
"Delete this identity?": "Видалити цей профіль відправника?",
"Delete {name}?": "Видалити {name}?",
"Deleted": "Видалено",
"Deletes the whole series. This cannot be undone.": "Видаляє всю серію. Це не можна буде скасувати.",
"Did you forget the attachment?": "Не забули вкладення?",
"Discard": "Не зберігати",
"Discard this draft?": "Видалити цю чернетку?",
"Edit calendar": "Змінити календар",
"Edit event": "Змінити подію",
"Edit identity": "Змінити профіль відправника",
"Edit template": "Змінити шаблон",
"End must be after start": "Завершення має бути пізніше за початок",
"Event duplicated": "Подію продубльовано",
"Everything inside it goes too.": "Усе, що всередині, теж буде видалено.",
"Filter created": "Фільтр створено",
"Filter created — it will run on new mail": "Фільтр створено — він працюватиме для нових листів",
"Filter saved": "Фільтр збережено",
"Filter saved — it will run on new mail": "Фільтр збережено — він працюватиме для нових листів",
"Filter saved, but applying it failed: {error}": "Фільтр збережено, але застосувати його не вдалося: {error}",
"Filters saved": "Фільтри збережено",
"Folder changed, but its filter rules could not be updated: {error}": "Теку змінено, але оновити її правила фільтрування не вдалося: {error}",
"Folder created": "Теку створено",
"Folder deleted": "Теку видалено",
"Folder name": "Назва теки",
"Folder name (use / for a subfolder, e.g. Work/Invoices)": "Назва теки (/ створює вкладену теку, наприклад Робота/Рахунки)",
"Folder name (use / for subfolders, e.g. Work/Invoices)": "Назва теки (/ створює вкладені теки, наприклад Робота/Рахунки)",
"Folder “{name}” created": "Теку «{name}» створено",
"Forwarded": "Переслано",
"Hand to the server, held until {when} (Ctrl+Enter)": "Передати серверу й утримати до {when} (Ctrl+Enter)",
"Hide": "Сховати",
"Hide quoted text": "Сховати цитований текст",
"Hide this from the compose picker": "Не пропонувати під час написання",
"Identity saved": "Профіль відправника збережено",
"Images in signatures need the Files feature, which this account doesn't have.": "Для зображень у підписі потрібні «Файли», яких цей обліковий запис не має.",
"Invalid address: {address}": "Неправильна адреса: {address}",
"Invalid username or password.": "Неправильне ім’я користувача або пароль.",
"It belongs to a change that was applied to this and all later occurrences, which the server will only edit as a whole. Apply to the entire series instead?": "Вона належить до зміни, застосованої до цього й усіх наступних повторень, а сервер редагує таку зміну лише повністю. Застосувати до всієї серії?",
"Label name": "Назва мітки",
"Larger than {size} MB limit": "Більше за обмеження в {size} МБ",
"Message sent": "Лист надіслано",
"Move failed: {error}": "Не вдалося перемістити: {error}",
"Move “{name}”": "Перемістити «{name}»",
"Moved": "Переміщено",
"Network error. Please check your connection.": "Помилка мережі. Перевірте з’єднання.",
"New all-day event on {date}": "Нова подія на весь день {date}",
"New event at {time}": "Нова подія о {time}",
"New identity": "Новий профіль відправника",
"No Archive folder found. Create one named “Archive” first.": "Теку архіву не знайдено. Спершу створіть теку з назвою «Archive».",
"No contacts yet": "Контактів ще немає",
"No longer shared": "Спільний доступ припинено",
"No matches": "Збігів немає",
"No new mail in your inbox.": "Нових листів у вхідних немає.",
"No results": "Нічого не знайдено",
"Nothing here": "Тут порожньо",
"Only Deleted Items and Junk Mail can be emptied.": "Очистити можна лише кошик і спам.",
"Please add at least one recipient": "Додайте принаймні одного одержувача",
"Please use an image under 512 KB for signatures.": "Для підпису використайте зображення менше за 512 КБ.",
"Preview": "Перегляд",
"Read receipt sent": "Сповіщення про прочитання надіслано",
"Remove attachments that failed to upload first": "Спершу приберіть вкладення, які не завантажилися",
"Removed from calendar": "Видалено з календаря",
"Removes this date and leaves the rest of the series.": "Видаляє цю дату й залишає решту серії.",
"Rename address book": "Перейменувати адресну книгу",
"Rename category": "Перейменувати категорію",
"Replied": "Є відповідь",
"Report spam (!)": "Поскаржитися на спам (!)",
"Response sent": "Відповідь надіслано",
"Revoke “{name}”?": "Відкликати «{name}»?",
"Script has errors": "У сценарії є помилки",
"Script is valid": "Сценарій коректний",
"Script name is required": "Потрібно вказати назву сценарію",
"Script saved": "Сценарій збережено",
"Send (Ctrl+Enter)": "Надіслати (Ctrl+Enter)",
"Send anyway": "Усе одно надіслати",
"Send cancelled — the message is back in Drafts": "Надсилання скасовано — лист повернувся до чернеток",
"Send failed: {error}": "Не вдалося надіслати: {error}",
"Send invites": "Надіслати запрошення",
"Send scheduled for {when}": "Надсилання заплановано на {when}",
"Send without a subject?": "Надіслати без теми?",
"Share “{name}”": "Надати доступ до «{name}»",
"Sharing updated": "Спільний доступ оновлено",
"Show": "Показати",
"Show quoted text": "Показати цитований текст",
"Show this in the compose picker": "Пропонувати під час написання",
"Sign out other sessions?": "Завершити інші сеанси?",
"Sign out others": "Завершити інші",
"Stop sharing “{name}”?": "Припинити спільний доступ до «{name}»?",
"Switch to {theme}": "Перемкнути на {theme}",
"Template": "Шаблон",
"Template name": "Назва шаблону",
"The contacts in it go too.": "Контакти всередині теж буде видалено.",
"The default identity is always offered when composing": "Профіль за умовчанням завжди пропонується під час написання",
"The new passwords don't match": "Нові паролі не збігаються",
"The server scheduled this for {when}, not the time requested.": "Сервер запланував це на {when}, а не на запитаний час.",
"This date cannot be changed on its own": "Цю дату не можна змінити окремо",
"This occurrence": "Лише це повторення",
"Too many attempts. Please wait a few minutes and try again.": "Забагато спроб. Зачекайте кілька хвилин і спробуйте ще раз.",
"Try another search.": "Спробуйте інший запит.",
"Try different keywords or filters.": "Спробуйте інші слова або фільтри.",
"Two-factor authentication is off": "Двофакторну автентифікацію вимкнено",
"Undo window: {seconds}s": "Час на скасування: {seconds} с",
"You're all caught up": "Усе прочитано",
"Your browser refused the request: {error}": "Браузер відхилив запит: {error}",
"Your browser will ask whether to open mail links in ihasmail": "Браузер запитає, чи відкривати поштові посилання в ihasmail",
"Your message mentions an attachment, but nothing is attached.": "У листі згадано вкладення, але нічого не долучено.",
"event": "подія",
"Hide password": "Сховати пароль",
"Show password": "Показати пароль",
"Settings reset to defaults": "Налаштування скинуто до типових",
"Removed. Mail links will open in whatever your browser falls back to.": "Вилучено. Поштові посилання відкриватимуться там, куди перемкнеться браузер.",
"Edit rule": "Змінити правило",
"Address copied": "Адресу скопійовано",
"Search or create label": "Знайдіть або створіть мітку",
"New label name": "Назва нової мітки",
"That folder no longer exists. Showing your inbox instead.": "Цієї теки більше немає. Показано вхідні.",
"Labels updated": "Мітки оновлено",
"Unsubscribe message prepared — just hit Send": "Лист про відписку готовий — лишилося натиснути «Надіслати»",
"Maximize": "Розгорнути",
"Full screen": "На весь екран",
"Resize panes": "Змінити розмір панелей",
"QR code": "QR-код",
"Draft discarded": "Чернетку видалено",
"Draft saved": "Чернетку збережено",
"Emptying folder…": "Тека очищується…",
"Nothing unread here": "Тут немає непрочитаного",
},
plurals: {
/*
@@ -864,5 +1041,20 @@ export const catalog: Catalog = {
"{n} messages": { one: "{n} лист", few: "{n} листи", many: "{n} листів", other: "{n} листа" },
"{n} conversations": { one: "{n} листування", few: "{n} листування", many: "{n} листувань", other: "{n} листування" },
"{n} selected": { one: "Вибрано: {n}", few: "Вибрано: {n}", many: "Вибрано: {n}", other: "Вибрано: {n}" },
"{n} messages will be permanently deleted.": { one: "{n} лист буде видалено назавжди.", few: "{n} листи буде видалено назавжди.", many: "{n} листів буде видалено назавжди.", other: "{n} листа буде видалено назавжди." },
"Signed out {n} other sessions": { one: "Завершено {n} інший сеанс", few: "Завершено {n} інші сеанси", many: "Завершено {n} інших сеансів", other: "Завершено {n} інших сеансів" },
"applied to {matched} of {n} messages": { one: "застосовано до {matched} з {n} листа", few: "застосовано до {matched} з {n} листів", many: "застосовано до {matched} з {n} листів", other: "застосовано до {matched} з {n} листів" },
"Move {n} messages to…": { one: "Перемістити {n} лист до…", few: "Перемістити {n} листи до…", many: "Перемістити {n} листів до…", other: "Перемістити {n} листа до…" },
"This permanently deletes the folder and its {n} messages.": { one: "Теку та {n} лист у ній буде видалено назавжди.", few: "Теку та {n} листи в ній буде видалено назавжди.", many: "Теку та {n} листів у ній буде видалено назавжди.", other: "Теку та {n} листа в ній буде видалено назавжди." },
"Added {n} contacts": { one: "Додано {n} контакт", few: "Додано {n} контакти", many: "Додано {n} контактів", other: "Додано {n} контакта" },
"{n} people will lose access. The contacts in it are not affected.": { one: "{n} особа втратить доступ. Контакти всередині не змінюються.", few: "{n} особи втратять доступ. Контакти всередині не змінюються.", many: "{n} осіб втратять доступ. Контакти всередині не змінюються.", other: "{n} особи втратять доступ. Контакти всередині не змінюються." },
"Imported {n} contacts": { one: "Імпортовано {n} контакт", few: "Імпортовано {n} контакти", many: "Імпортовано {n} контактів", other: "Імпортовано {n} контакта" },
"Saved for this date. {names} apply to the whole series and were left unchanged.": { one: "Збережено для цієї дати. {names} стосується всієї серії й залишилося без змін.", few: "Збережено для цієї дати. {names} стосуються всієї серії й залишилися без змін.", many: "Збережено для цієї дати. {names} стосуються всієї серії й залишилися без змін.", other: "Збережено для цієї дати. {names} стосуються всієї серії й залишилися без змін." },
"{n} people will lose access. Events in it are not affected.": { one: "{n} особа втратить доступ. Події всередині не змінюються.", few: "{n} особи втратять доступ. Події всередині не змінюються.", many: "{n} осіб втратять доступ. Події всередині не змінюються.", other: "{n} особи втратять доступ. Події всередині не змінюються." },
"{n} messages could not be deleted": { one: "Не вдалося видалити {n} лист", few: "Не вдалося видалити {n} листи", many: "Не вдалося видалити {n} листів", other: "Не вдалося видалити {n} листа" },
"({n} deleted first)": { one: "(перед цим видалено: {n})", few: "(перед цим видалено: {n})", many: "(перед цим видалено: {n})", other: "(перед цим видалено: {n})" },
"Marked {n} messages as read": { one: "{n} лист позначено як прочитаний", few: "{n} листи позначено як прочитані", many: "{n} листів позначено як прочитані", other: "{n} листа позначено як прочитані" },
"in {n} folders": { one: "у {n} теці", few: "у {n} теках", many: "у {n} теках", other: "у {n} теках" },
"Deleted {n} messages": { one: "Видалено {n} лист", few: "Видалено {n} листи", many: "Видалено {n} листів", other: "Видалено {n} листа" },
},
};
+192
View File
@@ -864,6 +864,183 @@ export const catalog: Catalog = {
"Saved {when}": "已保存 {when}",
"Unsaved": "未保存",
"Agenda from {date}": "从 {date} 起的日程",
// ── Toasts, dialogs and computed labels (second extraction pass) ───
"(no subject)": "(无主题)",
"(skipped: {actions})": "(已跳过:{actions}",
"Activate": "启用",
"Add a contact or import a vCard file.": "添加联系人,或导入 vCard 文件。",
"Added to your calendar": "已添加到您的日历",
"All events in this calendar will be deleted.": "此日历中的所有日程都将被删除。",
"All occurrences": "所有重复日程",
"An occurrence cannot be moved to another calendar on its own": "单个重复日程无法单独移动到其他日历",
"Anything signed in with this password stops working immediately.": "使用此密码登录的一切将立即停止工作。",
"App password revoked": "应用专用密码已吊销",
"Applies to every date in the series.": "对该系列的所有日期生效。",
"Applies to this date only.": "仅对此日期生效。",
"Apply to series": "应用于整个系列",
"Applying filter to existing messages…": "正在对现有邮件应用过滤器…",
"Attachments are still uploading": "附件仍在上传中",
"Calendar saved": "日历已保存",
"Categorised as {name}": "已归入分类「{name}」",
"Category cleared": "已清除分类",
"Change this event?": "要修改此日程吗?",
"Choose a calendar": "请选择日历",
"Choose an address book": "请选择通讯录",
"Colour updated": "颜色已更新",
"Contact created": "联系人已创建",
"Contact deleted": "联系人已删除",
"Contact saved": "联系人已保存",
"Copied": "已复制",
"Copy of {title}": "{title} 的副本",
"Could not cancel: {error}": "无法取消:{error}",
"Could not copy": "无法复制",
"Could not copy the address": "无法复制该地址",
"Could not empty folder: {error}": "无法清空文件夹:{error}",
"Could not load source: {error}": "无法加载原文:{error}",
"Could not mark as read: {error}": "无法标为已读:{error}",
"Could not save draft: {error}": "无法保存草稿:{error}",
"Could not save filter: {error}": "无法保存过滤器:{error}",
"Could not save filters: {error}": "无法保存过滤器:{error}",
"Could not send the receipt: {error}": "无法发送已读回执:{error}",
"Could not sign in.": "无法登录。",
"Could not store image: {error}": "无法保存图片:{error}",
"Could not update labels: {error}": "无法更新标签:{error}",
"Could not update the Scheduled folder: {error}": "无法更新「定时发送」文件夹:{error}",
"Could not update: {error}": "无法更新:{error}",
"Create": "创建",
"Create an address book first": "请先创建通讯录",
"Custom colour removed": "已清除自定义颜色",
"Deactivate": "停用",
"Delete failed: {error}": "删除失败:{error}",
"Delete forever": "永久删除",
"Delete script “{name}”?": "删除脚本「{name}」?",
"Delete this event?": "删除此日程?",
"Delete this identity?": "删除此发件身份?",
"Delete {name}?": "删除 {name}",
"Deleted": "已删除",
"Deletes the whole series. This cannot be undone.": "删除整个系列。此操作无法撤销。",
"Did you forget the attachment?": "您是不是忘了添加附件?",
"Discard": "放弃",
"Discard this draft?": "放弃此草稿?",
"Edit calendar": "编辑日历",
"Edit event": "编辑日程",
"Edit identity": "编辑发件身份",
"Edit template": "编辑模板",
"End must be after start": "结束时间必须晚于开始时间",
"Event duplicated": "日程已创建副本",
"Everything inside it goes too.": "其中的所有内容也会一并删除。",
"Filter created": "过滤器已创建",
"Filter created — it will run on new mail": "过滤器已创建——它将对新邮件生效",
"Filter saved": "过滤器已保存",
"Filter saved — it will run on new mail": "过滤器已保存——它将对新邮件生效",
"Filter saved, but applying it failed: {error}": "过滤器已保存,但应用失败:{error}",
"Filters saved": "过滤器已保存",
"Folder changed, but its filter rules could not be updated: {error}": "文件夹已修改,但其过滤规则无法更新:{error}",
"Folder created": "文件夹已创建",
"Folder deleted": "文件夹已删除",
"Folder name": "文件夹名称",
"Folder name (use / for a subfolder, e.g. Work/Invoices)": "文件夹名称(用 / 创建子文件夹,例如 工作/发票)",
"Folder name (use / for subfolders, e.g. Work/Invoices)": "文件夹名称(用 / 创建子文件夹,例如 工作/发票)",
"Folder “{name}” created": "文件夹「{name}」已创建",
"Forwarded": "已转发",
"Hand to the server, held until {when} (Ctrl+Enter)": "交给服务器保留至 {when} (Ctrl+Enter)",
"Hide": "隐藏",
"Hide quoted text": "隐藏引用文本",
"Hide this from the compose picker": "写邮件时不提供",
"Identity saved": "发件身份已保存",
"Images in signatures need the Files feature, which this account doesn't have.": "签名中的图片需要「文件」功能,而此账户没有该功能。",
"Invalid address: {address}": "地址无效:{address}",
"Invalid username or password.": "用户名或密码不正确。",
"It belongs to a change that was applied to this and all later occurrences, which the server will only edit as a whole. Apply to the entire series instead?": "它属于一项已应用于此日程及其后所有重复日程的修改,而服务器只能整体编辑这项修改。改为应用于整个系列吗?",
"Label name": "标签名称",
"Larger than {size} MB limit": "超过 {size} MB 的上限",
"Message sent": "邮件已发送",
"Move failed: {error}": "移动失败:{error}",
"Move “{name}”": "移动「{name}」",
"Moved": "已移动",
"Network error. Please check your connection.": "网络错误。请检查您的网络连接。",
"New all-day event on {date}": "在 {date} 新建全天日程",
"New event at {time}": "在 {time} 新建日程",
"New identity": "新建发件身份",
"No Archive folder found. Create one named “Archive” first.": "未找到归档文件夹。请先创建一个名为「Archive」的文件夹。",
"No contacts yet": "还没有联系人",
"No longer shared": "已停止共享",
"No matches": "没有匹配项",
"No new mail in your inbox.": "收件箱中没有新邮件。",
"No results": "没有结果",
"Nothing here": "这里什么都没有",
"Only Deleted Items and Junk Mail can be emptied.": "只有已删除邮件和垃圾邮件可以清空。",
"Please add at least one recipient": "请至少添加一位收件人",
"Please use an image under 512 KB for signatures.": "签名请使用小于 512 KB 的图片。",
"Preview": "预览",
"Read receipt sent": "已读回执已发送",
"Remove attachments that failed to upload first": "请先移除上传失败的附件",
"Removed from calendar": "已从日历中移除",
"Removes this date and leaves the rest of the series.": "删除此日期,保留系列中的其余日程。",
"Rename address book": "重命名通讯录",
"Rename category": "重命名分类",
"Replied": "已回复",
"Report spam (!)": "举报垃圾邮件 (!)",
"Response sent": "回复已发送",
"Revoke “{name}”?": "吊销「{name}」?",
"Script has errors": "脚本存在错误",
"Script is valid": "脚本有效",
"Script name is required": "必须填写脚本名称",
"Script saved": "脚本已保存",
"Send (Ctrl+Enter)": "发送 (Ctrl+Enter)",
"Send anyway": "仍然发送",
"Send cancelled — the message is back in Drafts": "已取消发送——邮件已回到草稿箱",
"Send failed: {error}": "发送失败:{error}",
"Send invites": "发送邀请",
"Send scheduled for {when}": "已定时于 {when} 发送",
"Send without a subject?": "不填主题就发送吗?",
"Share “{name}”": "共享「{name}」",
"Sharing updated": "共享设置已更新",
"Show": "显示",
"Show quoted text": "显示引用文本",
"Show this in the compose picker": "写邮件时提供",
"Sign out other sessions?": "退出其他会话?",
"Sign out others": "退出其他",
"Stop sharing “{name}”?": "停止共享「{name}」?",
"Switch to {theme}": "切换到{theme}",
"Template": "模板",
"Template name": "模板名称",
"The contacts in it go too.": "其中的联系人也会一并删除。",
"The default identity is always offered when composing": "默认发件身份在写邮件时始终提供",
"The new passwords don't match": "两次输入的新密码不一致",
"The server scheduled this for {when}, not the time requested.": "服务器把发送时间定在了 {when},而不是您请求的时间。",
"This date cannot be changed on its own": "此日期无法单独修改",
"This occurrence": "仅此重复日程",
"Too many attempts. Please wait a few minutes and try again.": "尝试次数过多。请等待几分钟后再试。",
"Try another search.": "换个搜索词试试。",
"Try different keywords or filters.": "换些关键词或过滤条件试试。",
"Two-factor authentication is off": "两步验证已关闭",
"Undo window: {seconds}s": "撤销时限:{seconds} 秒",
"You're all caught up": "您已看完全部邮件",
"Your browser refused the request: {error}": "您的浏览器拒绝了该请求:{error}",
"Your browser will ask whether to open mail links in ihasmail": "浏览器会询问是否用 ihasmail 打开邮件链接",
"Your message mentions an attachment, but nothing is attached.": "您的邮件提到了附件,但没有添加任何附件。",
"event": "日程",
"Hide password": "隐藏密码",
"Show password": "显示密码",
"Settings reset to defaults": "设置已恢复为默认值",
"Removed. Mail links will open in whatever your browser falls back to.": "已移除。邮件链接将改由浏览器的后备程序打开。",
"Edit rule": "编辑规则",
"Address copied": "地址已复制",
"Search or create label": "搜索或创建标签",
"New label name": "新标签名称",
"That folder no longer exists. Showing your inbox instead.": "该文件夹已不存在,改为显示收件箱。",
"Labels updated": "标签已更新",
"Unsubscribe message prepared — just hit Send": "退订邮件已准备好——点击「发送」即可",
"Maximize": "最大化",
"Full screen": "全屏",
"Resize panes": "调整窗格大小",
"QR code": "二维码",
"Draft discarded": "草稿已放弃",
"Draft saved": "草稿已保存",
"Emptying folder…": "正在清空文件夹…",
"Nothing unread here": "这里没有未读邮件",
},
plurals: {
/*
@@ -874,5 +1051,20 @@ export const catalog: Catalog = {
"{n} messages": { other: "{n} 封邮件" },
"{n} conversations": { other: "{n} 个会话" },
"{n} selected": { other: "已选择 {n} 项" },
"{n} messages will be permanently deleted.": { other: "将永久删除 {n} 封邮件。" },
"Signed out {n} other sessions": { other: "已退出 {n} 个其他会话" },
"applied to {matched} of {n} messages": { other: "已对 {n} 封邮件中的 {matched} 封生效" },
"Move {n} messages to…": { other: "将 {n} 封邮件移动到…" },
"This permanently deletes the folder and its {n} messages.": { other: "这将永久删除该文件夹及其中的 {n} 封邮件。" },
"Added {n} contacts": { other: "已添加 {n} 位联系人" },
"{n} people will lose access. The contacts in it are not affected.": { other: "{n} 人将失去访问权限。其中的联系人不受影响。" },
"Imported {n} contacts": { other: "已导入 {n} 位联系人" },
"Saved for this date. {names} apply to the whole series and were left unchanged.": { other: "已为此日期保存。{names} 适用于整个系列,未作更改。" },
"{n} people will lose access. Events in it are not affected.": { other: "{n} 人将失去访问权限。其中的日程不受影响。" },
"{n} messages could not be deleted": { other: "有 {n} 封邮件无法删除" },
"({n} deleted first)": { other: "(已先删除 {n} 封)" },
"Marked {n} messages as read": { other: "已将 {n} 封邮件标为已读" },
"in {n} folders": { other: "在 {n} 个文件夹中" },
"Deleted {n} messages": { other: "已删除 {n} 封邮件" },
},
};
+10 -9
View File
@@ -9,6 +9,7 @@ import { toast } from "@/ui/toast";
import { useMail, FULL_PROPS, BODY_PROPS } from "./mail";
import { ensureScheduledMailbox, useScheduled } from "./scheduled";
import { formatScheduleTime, holdUntil } from "@/lib/schedule";
import { t as translate } from "@/lib/i18n";
import { settings } from "./settings";
export interface ComposeAttachment {
@@ -334,15 +335,15 @@ export const useCompose = create<ComposeState>((set, get) => ({
/* ignore */
}
}
toast.show("Draft discarded");
toast.show(translate("Draft discarded"));
return;
}
if (d.dirty && (d.to.length || d.subject || hasContent(d))) {
try {
await saveDraftInternal(d, get, set, { silent: true, final: true });
toast.show("Draft saved");
toast.show(translate("Draft saved"));
} catch (err) {
toast.error(`Could not save draft: ${(err as Error).message}`);
toast.error(translate("Could not save draft: {error}", { error: (err as Error).message }));
}
}
},
@@ -355,7 +356,7 @@ export const useCompose = create<ComposeState>((set, get) => ({
const accountId = useMail.getState().accountId;
if (!accountId) return;
const max = client.maxSizeUpload;
const atts: ComposeAttachment[] = files.map((f) => ({ id: uid("a"), name: f.name, type: f.type || "application/octet-stream", size: f.size, blobId: null, progress: 0, error: f.size > max ? `Larger than ${Math.round(max / 1048576)} MB limit` : null, file: f }));
const atts: ComposeAttachment[] = files.map((f) => ({ id: uid("a"), name: f.name, type: f.type || "application/octet-stream", size: f.size, blobId: null, progress: 0, error: f.size > max ? translate("Larger than {size} MB limit", { size: Math.round(max / 1048576) }) : null, file: f }));
get().update(key, { attachments: [...(get().drafts.find((d) => d.key === key)?.attachments ?? []), ...atts] });
for (const a of atts) {
if (a.error || !a.file) continue;
@@ -396,7 +397,7 @@ export const useCompose = create<ComposeState>((set, get) => ({
size: n.size ?? 0,
blobId: n.accountId === accountId ? n.blobId : null,
progress: n.accountId === accountId ? 100 : 0,
error: (n.size ?? 0) > max ? `Larger than ${Math.round(max / 1048576)} MB limit` : null,
error: (n.size ?? 0) > max ? translate("Larger than {size} MB limit", { size: Math.round(max / 1048576) }) : null,
}));
get().update(key, { attachments: [...(get().drafts.find((d) => d.key === key)?.attachments ?? []), ...atts] });
@@ -426,7 +427,7 @@ export const useCompose = create<ComposeState>((set, get) => ({
try {
return await saveDraftInternal(d, get, set, { silent: opts.silent ?? false });
} catch (err) {
if (!opts.silent) toast.error(`Could not save draft: ${(err as Error).message}`);
if (!opts.silent) toast.error(translate("Could not save draft: {error}", { error: (err as Error).message }));
return null;
}
},
@@ -449,9 +450,9 @@ export const useCompose = create<ComposeState>((set, get) => ({
});
try {
await sendInternal(d, get);
toast.success(scheduling ? `Send scheduled for ${formatScheduleTime(new Date(d.sendAt!))}` : "Message sent");
toast.success(scheduling ? translate("Send scheduled for {when}", { when: formatScheduleTime(new Date(d.sendAt!)) }) : translate("Message sent"));
} catch (err) {
toast.error(`Send failed: ${(err as Error).message}`, {
toast.error(translate("Send failed: {error}", { error: (err as Error).message }), {
action: { label: "Open draft", onClick: () => set((s) => ({ drafts: [...s.drafts, { ...d, sending: false, error: (err as Error).message }], activeKey: d.key })) },
duration: 15000,
});
@@ -783,7 +784,7 @@ async function sendInternal(d: Draft, _get: () => ComposeState): Promise<void> {
const created = (s.created?.s ?? {}) as { id?: Id; sendAt?: string; undoStatus?: string };
const settled = created.sendAt ? Date.parse(created.sendAt) : NaN;
if (!Number.isNaN(settled) && Math.abs(settled - d.sendAt!) > 60_000) {
toast.error(`The server scheduled this for ${formatScheduleTime(new Date(settled))}, not the time requested.`);
toast.error(translate("The server scheduled this for {when}, not the time requested.", { when: formatScheduleTime(new Date(settled)) }));
}
await useScheduled.getState().load();
}
+19 -15
View File
@@ -22,7 +22,7 @@ import { toast } from "@/ui/toast";
import { settings, useSettings } from "./settings";
import { useSession } from "./session";
import { mailboxDisplayName } from "@/lib/mailboxName";
import { t } from "@/lib/i18n";
import { plural, t } from "@/lib/i18n";
/*
* Named explicitly so `shareWith` comes back, which it does not otherwise --
@@ -441,7 +441,7 @@ export const useMail = create<MailState>((set, get) => ({
try {
await setEmails(accountId, update);
} catch (err) {
toast.error(`Could not update: ${(err as Error).message}`);
toast.error(t("Could not update: {error}", { error: (err as Error).message }));
void get().getEmails(ids);
}
},
@@ -503,7 +503,7 @@ export const useMail = create<MailState>((set, get) => ({
}
void get().loadMailboxes();
} catch (err) {
toast.error(`Move failed: ${(err as Error).message}`);
toast.error(t("Move failed: {error}", { error: (err as Error).message }));
void get().getEmails(ids);
void get().refreshList();
}
@@ -530,7 +530,7 @@ export const useMail = create<MailState>((set, get) => ({
await setEmails(accountId, update);
void get().loadMailboxes();
} catch (err) {
toast.error(`Could not update labels: ${(err as Error).message}`);
toast.error(t("Could not update labels: {error}", { error: (err as Error).message }));
void get().getEmails(ids);
}
},
@@ -557,11 +557,11 @@ export const useMail = create<MailState>((set, get) => ({
try {
const { notDestroyed } = await destroyEmails(accountId, ids);
const failed = Object.keys(notDestroyed);
if (failed.length) toast.error(`${failed.length} message(s) could not be deleted`);
if (failed.length) toast.error(plural(failed.length, { one: "{n} message could not be deleted", other: "{n} messages could not be deleted" }));
else toast.show(`${ids.length === 1 ? "Message" : `${ids.length} messages`} deleted forever`);
void get().loadMailboxes();
} catch (err) {
toast.error(`Delete failed: ${(err as Error).message}`);
toast.error(t("Delete failed: {error}", { error: (err as Error).message }));
void get().refreshList();
}
},
@@ -569,7 +569,7 @@ export const useMail = create<MailState>((set, get) => ({
async archive(ids) {
const archiveId = get().roleId("archive") ?? get().roleId("all");
if (!archiveId) {
toast.error("No Archive folder found. Create one named “Archive” first.");
toast.error(t("No Archive folder found. Create one named “Archive” first."));
return;
}
await get().move(ids, archiveId, { label: "Archive" });
@@ -602,7 +602,7 @@ export const useMail = create<MailState>((set, get) => ({
// there is no point routing spam through the bin on its way out, and it is
// what "delete all spam" means everywhere else. The dialogs say so.
if (mailboxId !== get().roleId("trash") && mailboxId !== get().roleId("junk")) {
toast.error("Only Deleted Items and Junk Mail can be emptied.");
toast.error(t("Only Deleted Items and Junk Mail can be emptied."));
return;
}
// A folder can hold far more messages than the server will destroy in one
@@ -617,7 +617,7 @@ export const useMail = create<MailState>((set, get) => ({
const q = await client.call<QueryResponse>("Email/query", { accountId, filter: { inMailbox: mailboxId }, limit: page });
if (!q.ids.length) break;
if (progress === null && (q.total ?? q.ids.length) > page) {
progress = toast.show("Emptying folder…", { duration: 0 });
progress = toast.show(t("Emptying folder…"), { duration: 0 });
}
const { destroyed, notDestroyed } = await destroyEmails(accountId, q.ids);
deleted += destroyed.length;
@@ -628,10 +628,11 @@ export const useMail = create<MailState>((set, get) => ({
throw new Error(err ? setErrorMessage(err) : "the server refused to delete these messages");
}
}
toast.show(`Deleted ${deleted} message${deleted === 1 ? "" : "s"}`);
toast.show(plural(deleted, { one: "Deleted {n} message", other: "Deleted {n} messages" }));
set({ list: get().list ? { ...get().list!, ids: get().list!.mailboxId === mailboxId ? [] : get().list!.ids, total: 0 } : null });
} catch (err) {
toast.error(`Could not empty folder: ${(err as Error).message}${deleted ? ` (${deleted} deleted first)` : ""}`);
toast.error(t("Could not empty folder: {error}", { error: (err as Error).message })
+ (deleted ? " " + plural(deleted, { one: "({n} deleted first)", other: "({n} deleted first)" }) : ""));
} finally {
if (progress !== null) toast.dismiss(progress);
void get().loadMailboxes();
@@ -688,13 +689,16 @@ export const useMail = create<MailState>((set, get) => ({
marked += ids.length;
}
if (!marked) {
toast.show("Nothing unread here");
toast.show(t("Nothing unread here"));
return;
}
toast.success(`Marked ${marked} message${marked === 1 ? "" : "s"} as read${includeChildren && boxes.length > 1 ? ` in ${boxes.length} folders` : ""}`);
toast.success(
plural(marked, { one: "Marked {n} message as read", other: "Marked {n} messages as read" })
+ (includeChildren && boxes.length > 1 ? " " + plural(boxes.length, { one: "in {n} folder", other: "in {n} folders" }) : ""),
);
void get().loadMailboxes();
} catch (err) {
toast.error(`Could not mark as read: ${(err as Error).message}`);
toast.error(t("Could not mark as read: {error}", { error: (err as Error).message }));
}
},
@@ -1141,6 +1145,6 @@ async function followFolders(before: FolderRef[]): Promise<void> {
toast.show(said.join(" · "), { duration: 8000 });
} catch (err) {
const { toast } = await import("@/ui/toast");
toast.error(`Folder changed, but its filter rules could not be updated: ${(err as Error).message}`);
toast.error(t("Folder changed, but its filter rules could not be updated: {error}", { error: (err as Error).message }));
}
}
+2 -1
View File
@@ -4,6 +4,7 @@ import type { EmailSubmission, GetResponse, Id, Mailbox, QueryResponse, SetRespo
import { toast } from "@/ui/toast";
import { useMail } from "./mail";
import { canScheduleSend, maxDelayMs, SUBMISSION_CAP, type SubmissionCapability } from "@/lib/schedule";
import { t } from "@/lib/i18n";
/**
* A held message lives in a folder of its own, the way Gmail's does, because
@@ -232,7 +233,7 @@ export const useScheduled = create<ScheduledState>((set, get) => ({
void mail.refreshList();
}
} catch (err) {
toast.error(`Could not update the Scheduled folder: ${(err as Error).message}`);
toast.error(t("Could not update the Scheduled folder: {error}", { error: (err as Error).message }));
}
},
}));
+2 -1
View File
@@ -1,4 +1,5 @@
import { useRef } from "react";
import { t } from "@/lib/i18n";
interface Props {
direction: "vertical" | "horizontal"; // vertical = a vertical bar that resizes width
@@ -17,7 +18,7 @@ export function Splitter({ direction, onResize, onEnd, onReset, ariaLabel }: Pro
className={`splitter ${direction}`}
role="separator"
aria-orientation={direction === "vertical" ? "vertical" : "horizontal"}
aria-label={ariaLabel ?? "Resize panes"}
aria-label={ariaLabel ?? t("Resize panes")}
tabIndex={0}
onDoubleClick={onReset}
onPointerDown={(e) => {
+2 -1
View File
@@ -1,5 +1,6 @@
import { useMemo } from "react";
import qrcode from "qrcode-generator";
import { t } from "@/lib/i18n";
/**
* A QR code as inline SVG.
@@ -30,7 +31,7 @@ export function QrCode({ value, size = 200, title }: { value: string; size?: num
height={size}
viewBox={`-2 -2 ${count + 4} ${count + 4}`}
role="img"
aria-label={title ?? "QR code"}
aria-label={title ?? t("QR code")}
shapeRendering="crispEdges"
style={{ background: "#fff", borderRadius: 8, display: "block" }}
>
+2 -2
View File
@@ -248,8 +248,8 @@ function ThemeToggle() {
return (
<button
className="icon-btn"
aria-label={`Switch to ${label}`}
title={`Switch to ${label}`}
aria-label={t("Switch to {theme}", { theme: label })}
title={t("Switch to {theme}", { theme: label })}
onClick={() => update({ theme: next })}
>
{effective === "dark" ? <Sun size={21} /> : <Moon size={21} />}
+5 -5
View File
@@ -40,10 +40,10 @@ export function LoginPage() {
} catch (err) {
if (err instanceof ApiError) {
if (err.code === "invalid_credentials") {
setError("Invalid username or password.");
} else if (err.code === "rate_limited") setError("Too many attempts. Please wait a few minutes and try again.");
else setError(err.message || "Could not sign in.");
} else setError("Network error. Please check your connection.");
setError(t("Invalid username or password."));
} else if (err.code === "rate_limited") setError(t("Too many attempts. Please wait a few minutes and try again."));
else setError(err.message || t("Could not sign in."));
} else setError(t("Network error. Please check your connection."));
} finally {
setBusy(false);
}
@@ -70,7 +70,7 @@ export function LoginPage() {
<label htmlFor="p">{t("Password")}</label>
<div className="pw-wrap">
<input id="p" className="input" type={showPw ? "text" : "password"} autoComplete="current-password" value={password} onChange={(e) => setPassword(e.target.value)} autoFocus={Boolean(username)} required style={{ paddingRight: 40 }} />
<button type="button" className="icon-btn" onClick={() => setShowPw((v) => !v)} aria-label={showPw ? "Hide password" : "Show password"} tabIndex={-1}>
<button type="button" className="icon-btn" onClick={() => setShowPw((v) => !v)} aria-label={showPw ? t("Hide password") : t("Show password")} tabIndex={-1}>
{showPw ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
@@ -50,7 +50,7 @@ export function CalendarContextMenu({ ctx, onClose, onOpen, onEdit, onCreate }:
const { start, end, allDay } = ctx;
return (
<Popover anchor={ctx.anchor} onClose={onClose} width={240}>
<MenuItem icon={<Plus size={16} />} label={allDay ? `New all-day event on ${formatDayMonth(start)}` : `New event at ${formatTime(start)}`} onClick={() => onCreate(start, end, allDay)} />
<MenuItem icon={<Plus size={16} />} label={allDay ? t("New all-day event on {date}", { date: formatDayMonth(start) }) : t("New event at {time}", { time: formatTime(start) })} onClick={() => onCreate(start, end, allDay)} />
{!allDay && <MenuItem icon={<CalendarDays size={16} />} label={t("New all-day event")} onClick={() => { const d = new Date(start); d.setHours(0, 0, 0, 0); onCreate(d, new Date(d.getTime() + 86400000), true); }} />}
<MenuSep />
<MenuItem icon={<CalIcon size={16} />} label={t("Go to day")} onClick={() => navigate(`/calendar/day/${toLocalDateOnly(start)}`)} />
@@ -77,16 +77,16 @@ export function CalendarContextMenu({ ctx, onClose, onOpen, onEdit, onCreate }:
toast.error((err as Error).message);
}
};
const setColor = (color: string | null) => void patch({ color }, color ? "Colour updated" : "Custom colour removed");
const setColor = (color: string | null) => void patch({ color }, color ? t("Colour updated") : t("Custom colour removed"));
const setCategory = (cat: { name: string; color: string } | null) => {
const categoriesPatch = cat ? { [cat.name]: true } : null;
void patch({ categories: categoriesPatch, color: cat ? cat.color : null }, cat ? `Categorised as ${cat.name}` : "Category cleared");
void patch({ categories: categoriesPatch, color: cat ? cat.color : null }, cat ? t("Categorised as {name}", { name: cat.name }) : t("Category cleared"));
};
const duplicate = async () => {
const { id: _i, baseEventId: _b, uid: _u, utcStart: _s, utcEnd: _e, isOrigin: _o, calendarIds, created: _c, updated: _up, sequence: _sq, recurrenceId: _ri, recurrenceIdTimeZone: _rt, ...rest } = ev as CalendarEvent & Record<string, unknown>;
try {
await cal.createEvent({ ...rest, title: `Copy of ${ev.title ?? "event"}`, participants: undefined, replyTo: undefined, organizerCalendarAddress: undefined } as Partial<CalendarEvent>, Object.keys(calendarIds)[0] ?? Object.keys(cal.calendars)[0]!, false);
toast.success("Event duplicated");
await cal.createEvent({ ...rest, title: t("Copy of {title}", { title: ev.title ?? t("event") }), participants: undefined, replyTo: undefined, organizerCalendarAddress: undefined } as Partial<CalendarEvent>, Object.keys(calendarIds)[0] ?? Object.keys(cal.calendars)[0]!, false);
toast.success(t("Event duplicated"));
} catch (err) {
toast.error((err as Error).message);
}
@@ -96,7 +96,7 @@ export function CalendarContextMenu({ ctx, onClose, onOpen, onEdit, onCreate }:
let scope: EventScope | null = "series";
if (isRecurring(ev) && isOccurrence(ev)) {
scope = await askDeleteScope(ev);
} else if (!(await confirmDialog({ title: "Delete this event?", confirmLabel: "Delete", danger: true }))) {
} else if (!(await confirmDialog({ title: t("Delete this event?"), confirmLabel: t("Delete"), danger: true }))) {
scope = null;
}
if (!scope) return;
+2 -2
View File
@@ -22,7 +22,7 @@ export function CalendarDialog({ calendar, onClose }: { calendar: Partial<Calend
const data: Partial<Calendar> = { name: name.trim(), color, description: description || null, timeZone: tz || null, includeInAvailability: avail };
if (calendar.id) await cal.updateCalendar(calendar.id, data);
else await cal.createCalendar(data);
toast.success("Calendar saved");
toast.success(translate("Calendar saved"));
onClose();
} catch (err) {
toast.error((err as Error).message);
@@ -31,7 +31,7 @@ export function CalendarDialog({ calendar, onClose }: { calendar: Partial<Calend
}
};
return (
<Dialog open onClose={onClose} title={calendar.id ? "Edit calendar" : "New calendar"} size="sm" footer={<><button className="btn" onClick={onClose}>{translate("Cancel")}</button><button className="btn btn-primary" disabled={busy || !name.trim()} onClick={() => void save()}>{translate("Save")}</button></>}>
<Dialog open onClose={onClose} title={calendar.id ? translate("Edit calendar") : translate("New calendar")} size="sm" footer={<><button className="btn" onClick={onClose}>{translate("Cancel")}</button><button className="btn btn-primary" disabled={busy || !name.trim()} onClick={() => void save()}>{translate("Save")}</button></>}>
<div className="field"><label>{translate("Name")}</label><input className="input" autoFocus value={name} onChange={(e) => setName(e.target.value)} /></div>
<div className="field"><label>{translate("Color")}</label><ColorSwatches value={color} onChange={setColor} /></div>
<div className="field"><label>{translate("Description")}</label><input className="input" value={description} onChange={(e) => setDescription(e.target.value)} /></div>
+6 -6
View File
@@ -12,7 +12,7 @@ import { toast } from "@/ui/toast";
import type { Calendar } from "@/jmap/types";
import { CalendarDialog } from "./CalendarDialog";
import { ShareDialog } from "../settings/ShareDialog";
import { t } from "@/lib/i18n";
import { plural, t } from "@/lib/i18n";
export function CalendarSidebar() {
const [location, navigate] = useLocation();
@@ -136,14 +136,14 @@ export function CalendarSidebar() {
onClick={async () => {
const who = Object.keys(menuCal.shareWith ?? {}).length;
if (!(await confirmDialog({
title: `Stop sharing “${menuCal.name}”?`,
message: `${who === 1 ? "One person" : `${who} people`} will lose access. Events in it are not affected.`,
confirmLabel: "Stop sharing",
title: t("Stop sharing “{name}”?", { name: menuCal.name }),
message: plural(who, { one: "{n} person will lose access. Events in it are not affected.", other: "{n} people will lose access. Events in it are not affected." }),
confirmLabel: t("Stop sharing"),
danger: true,
}))) return;
try {
await cal.updateCalendar(menuCal.id, { shareWith: null });
toast.success("No longer shared");
toast.success(t("No longer shared"));
} catch (err) {
toast.error((err as Error).message);
}
@@ -152,7 +152,7 @@ export function CalendarSidebar() {
)}
<MenuItem icon={<Star size={16} />} label={t("Make default")} disabled={menuCal.isDefault} onClick={() => void cal.updateCalendar(menuCal.id, { isDefault: true } as Partial<Calendar>).catch((err) => toast.error((err as Error).message))} />
<MenuSep />
<MenuItem danger icon={<Trash2 size={16} />} label={t("Delete")} disabled={!menuCal.myRights.mayDelete} onClick={async () => { if (await confirmDialog({ title: `Delete “${menuCal.name}”?`, message: "All events in this calendar will be deleted.", confirmLabel: "Delete", danger: true })) void cal.destroyCalendar(menuCal.id).catch((err) => toast.error((err as Error).message)); }} />
<MenuItem danger icon={<Trash2 size={16} />} label={t("Delete")} disabled={!menuCal.myRights.mayDelete} onClick={async () => { if (await confirmDialog({ title: t("Delete “{name}”?", { name: menuCal.name }), message: t("All events in this calendar will be deleted."), confirmLabel: t("Delete"), danger: true })) void cal.destroyCalendar(menuCal.id).catch((err) => toast.error((err as Error).message)); }} />
</>
)}
</Popover>
+4 -4
View File
@@ -173,11 +173,11 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle
const save = async () => {
if (!calendarId) {
toast.error("Choose a calendar");
toast.error(translate("Choose a calendar"));
return;
}
if (end <= start) {
toast.error("End must be after start");
toast.error(translate("End must be after start"));
return;
}
setBusy(true);
@@ -259,7 +259,7 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle
}, [start]);
return (
<Dialog open onClose={onClose} title={editing ? "Edit event" : "New event"} size="lg" footer={<><button className="btn" onClick={onClose}>{translate("Cancel")}</button><button className="btn btn-primary" disabled={busy} onClick={() => void save()}>{busy ? "Saving…" : editing ? "Save" : attendees.length && sendInvites ? "Send invites" : "Create"}</button></>}>
<Dialog open onClose={onClose} title={editing ? translate("Edit event") : translate("New event")} size="lg" footer={<><button className="btn" onClick={onClose}>{translate("Cancel")}</button><button className="btn btn-primary" disabled={busy} onClick={() => void save()}>{busy ? translate("Saving…") : editing ? translate("Save") : attendees.length && sendInvites ? translate("Send invites") : translate("Create")}</button></>}>
<div className="event-form">
{ev && isRecurring(ev) && (
<div className="info-box mb-16">
@@ -334,7 +334,7 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle
)}
<div className="field-row">
<div className="field"><label>{translate("Calendar")}</label>
<select className="select" value={calendarId} disabled={oneDate} title={oneDate ? "An occurrence cannot be moved to another calendar on its own" : undefined} onChange={(e) => setCalendarId(e.target.value)}>
<select className="select" value={calendarId} disabled={oneDate} title={oneDate ? translate("An occurrence cannot be moved to another calendar on its own") : undefined} onChange={(e) => setCalendarId(e.target.value)}>
{calendars.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
+2 -2
View File
@@ -36,7 +36,7 @@ export function EventPopover({ inst, anchor, onClose, onEdit }: { inst: EventIns
if (isRecurring(ev) && isOccurrence(ev)) {
scope = await askDeleteScope(ev);
} else {
const ok = await confirmDialog({ title: "Delete this event?", confirmLabel: "Delete", danger: true });
const ok = await confirmDialog({ title: t("Delete this event?"), confirmLabel: t("Delete"), danger: true });
if (!ok) scope = null;
}
if (!scope) return;
@@ -56,7 +56,7 @@ export function EventPopover({ inst, anchor, onClose, onEdit }: { inst: EventIns
setBusy(true);
try {
await cal.rsvp(ev, status);
toast.success("Response sent");
toast.success(t("Response sent"));
onClose();
} catch (err) {
toast.error((err as Error).message);
+20 -14
View File
@@ -1,6 +1,7 @@
import { choiceDialog, confirmDialog } from "@/ui/dialog";
import { isOccurrence, isRecurring, isThisAndFutureRefusal, type EventScope } from "@/store/calendar";
import type { CalendarEvent } from "@/jmap/types";
import { plural, t } from "@/lib/i18n";
/**
* Ask which of a series a change is meant for, when there is a choice.
@@ -32,22 +33,22 @@ export async function askScope(
/** The scope question for deleting. */
export const askDeleteScope = (event: CalendarEvent): Promise<EventScope | null> =>
askScope(event, {
title: "Delete this event?",
occurrenceLabel: "This occurrence",
occurrenceHint: "Removes this date and leaves the rest of the series.",
seriesLabel: "All occurrences",
seriesHint: "Deletes the whole series. This cannot be undone.",
title: t("Delete this event?"),
occurrenceLabel: t("This occurrence"),
occurrenceHint: t("Removes this date and leaves the rest of the series."),
seriesLabel: t("All occurrences"),
seriesHint: t("Deletes the whole series. This cannot be undone."),
danger: true,
});
/** The scope question for editing. */
export const askEditScope = (event: CalendarEvent): Promise<EventScope | null> =>
askScope(event, {
title: "Change this event?",
occurrenceLabel: "This occurrence",
occurrenceHint: "Applies to this date only.",
seriesLabel: "All occurrences",
seriesHint: "Applies to every date in the series.",
title: t("Change this event?"),
occurrenceLabel: t("This occurrence"),
occurrenceHint: t("Applies to this date only."),
seriesLabel: t("All occurrences"),
seriesHint: t("Applies to every date in the series."),
});
/**
@@ -57,7 +58,12 @@ export const askEditScope = (event: CalendarEvent): Promise<EventScope | null> =
export function droppedMessage(dropped: string[]): string | null {
if (!dropped.length) return null;
const names = dropped.map((d) => d.replace(/^@/, "")).join(", ");
return `Saved for this date. ${names} ${dropped.length === 1 ? "applies" : "apply"} to the whole series and was left unchanged.`;
// One sentence per branch rather than a verb slot: which words agree with
// the count, and where they sit, is not the same in every language.
return plural(dropped.length, {
one: "Saved for this date. {names} applies to the whole series and was left unchanged.",
other: "Saved for this date. {names} apply to the whole series and were left unchanged.",
}, { names });
}
@@ -80,9 +86,9 @@ export async function runScoped<T>(scope: EventScope, run: (scope: EventScope) =
} catch (err) {
if (scope !== "occurrence" || !isThisAndFutureRefusal(err)) throw err;
const ok = await confirmDialog({
title: "This date cannot be changed on its own",
message: "It belongs to a change that was applied to this and all later occurrences, which the server will only edit as a whole. Apply to the entire series instead?",
confirmLabel: "Apply to series",
title: t("This date cannot be changed on its own"),
message: t("It belongs to a change that was applied to this and all later occurrences, which the server will only edit as a whole. Apply to the entire series instead?"),
confirmLabel: t("Apply to series"),
});
return ok ? await run("series") : null;
}
+11 -11
View File
@@ -82,28 +82,28 @@ export function Composer({ draft }: { draft: Draft }) {
const doSend = async () => {
const all = [...d.to, ...d.cc, ...d.bcc];
if (!all.length) {
toast.error("Please add at least one recipient");
toast.error(translate("Please add at least one recipient"));
return;
}
const bad = all.filter((a) => !isValidEmail(a.email));
if (bad.length) {
toast.error(`Invalid address: ${bad[0]!.email}`);
toast.error(translate("Invalid address: {address}", { address: bad[0]!.email }));
return;
}
if (d.attachments.some((a) => a.error)) {
toast.error("Remove attachments that failed to upload first");
toast.error(translate("Remove attachments that failed to upload first"));
return;
}
if (d.attachments.some((a) => !a.blobId)) {
toast.error("Attachments are still uploading");
toast.error(translate("Attachments are still uploading"));
return;
}
if (!d.subject.trim()) {
const ok = await confirmDialog({ title: "Send without a subject?", confirmLabel: "Send anyway" });
const ok = await confirmDialog({ title: translate("Send without a subject?"), confirmLabel: translate("Send anyway") });
if (!ok) return;
}
if (settings.attachmentReminder && !d.attachments.length && /\b(attach(ed|ment|ing)?|enclosed|anbei|ci-joint|adjunto)\b/i.test(bodyText) ) {
const ok = await confirmDialog({ title: "Did you forget the attachment?", message: "Your message mentions an attachment, but nothing is attached.", confirmLabel: "Send anyway" });
const ok = await confirmDialog({ title: translate("Did you forget the attachment?"), message: translate("Your message mentions an attachment, but nothing is attached."), confirmLabel: translate("Send anyway") });
if (!ok) return;
}
await send(key);
@@ -169,7 +169,7 @@ export function Composer({ draft }: { draft: Draft }) {
<span className="title">{title}</span>
<span className="status">{status}</span>
{!isMobile && <button className="icon-btn sm" aria-label={translate("Minimize")} title={translate("Minimize")} onClick={() => patch({ minimized: true })}><Minus size={16} /></button>}
{!isMobile && <button className="icon-btn sm" aria-label={d.maximized ? "Restore" : "Maximize"} title={d.maximized ? "Restore" : "Full screen"} onClick={() => patch({ maximized: !d.maximized })}>{d.maximized ? <Minimize2 size={16} /> : <Maximize2 size={16} />}</button>}
{!isMobile && <button className="icon-btn sm" aria-label={d.maximized ? translate("Restore") : translate("Maximize")} title={d.maximized ? translate("Restore") : translate("Full screen")} onClick={() => patch({ maximized: !d.maximized })}>{d.maximized ? <Minimize2 size={16} /> : <Maximize2 size={16} />}</button>}
<button className="icon-btn sm" aria-label={translate("Close")} title={translate("Save & close (Esc)")} onClick={() => void close(key)}><X size={18} /></button>
</div>
<div className="composer-body">
@@ -251,14 +251,14 @@ export function Composer({ draft }: { draft: Draft }) {
)}
<div className="composer-foot">
<span className="send-group">
<button className="btn btn-primary" onClick={() => void doSend()} disabled={d.sending} title={d.sendAt !== null ? `Hand to the server, held until ${formatScheduleTime(new Date(d.sendAt))} (Ctrl+Enter)` : "Send (Ctrl+Enter)"}>
<button className="btn btn-primary" onClick={() => void doSend()} disabled={d.sending} title={d.sendAt !== null ? translate("Hand to the server, held until {when} (Ctrl+Enter)", { when: formatScheduleTime(new Date(d.sendAt)) }) : translate("Send (Ctrl+Enter)")}>
{d.sendAt !== null ? <><Clock size={16} /> {translate("Schedule send")}</> : <><Send size={16} /> {translate("Send")}</>}
</button>
<button className="btn btn-primary" onClick={sendMenu.open} aria-label={translate("Send options")}><ChevronDown size={16} /></button>
</span>
<Popover anchor={sendMenu.anchor} onClose={sendMenu.close} side="top" width={280}>
<MenuItem icon={<Send size={16} />} label={d.sendAt !== null ? "Send now instead" : "Send"} kbd={d.sendAt !== null ? undefined : "Ctrl+↵"} onClick={() => { if (d.sendAt !== null) patch({ sendAt: null }); sendMenu.close(); void doSend(); }} />
<MenuItem icon={<Clock size={16} />} label={`Undo window: ${settings.undoSendSeconds}s`} onClick={() => updateSettings({ undoSendSeconds: settings.undoSendSeconds >= 30 ? 0 : settings.undoSendSeconds + 5 })} />
<MenuItem icon={<Clock size={16} />} label={translate("Undo window: {seconds}s", { seconds: settings.undoSendSeconds })} onClick={() => updateSettings({ undoSendSeconds: settings.undoSendSeconds >= 30 ? 0 : settings.undoSendSeconds + 5 })} />
{canSchedule && <ScheduleMenuItems maxMs={scheduleMax} onPick={scheduleFor} onCustom={() => { sendMenu.close(); setScheduleOpen(true); }} />}
</Popover>
{addressBookOpen && (
@@ -299,13 +299,13 @@ export function Composer({ draft }: { draft: Draft }) {
<MenuItem label={translate("Normal")} checked={d.priority === "normal"} onClick={() => patch({ priority: "normal" })} />
<MenuItem label={translate("Low")} checked={d.priority === "low"} onClick={() => patch({ priority: "low" })} />
<MenuSep />
<MenuItem icon={<ChevronsDown size={16} />} label={translate("Save as template")} onClick={async () => { const name = await promptDialog({ title: "Save as template", defaultValue: d.subject || "Template", placeholder: "Template name" }); if (name) updateSettings({ templates: [...useSettings.getState().settings.templates, { id: `t${Date.now()}`, name, subject: d.subject, html: d.format === "html" ? d.html : textToHtml(d.text) }] }); }} />
<MenuItem icon={<ChevronsDown size={16} />} label={translate("Save as template")} onClick={async () => { const name = await promptDialog({ title: translate("Save as template"), defaultValue: d.subject || translate("Template"), placeholder: translate("Template name") }); if (name) updateSettings({ templates: [...useSettings.getState().settings.templates, { id: `t${Date.now()}`, name, subject: d.subject, html: d.format === "html" ? d.html : textToHtml(d.text) }] }); }} />
<MenuItem icon={<FileText size={16} />} label={translate("Save draft now")} onClick={() => void saveDraft(key)} />
</Popover>
</span>
<span className="spacer" />
{totalSize > 20 * 1024 * 1024 && <span className="hint row gap-4" title={translate("Large attachments may be rejected by some servers")}><AlertTriangle size={14} /> {formatSize(totalSize)}</span>}
<button className="icon-btn danger" title={translate("Discard draft")} aria-label={translate("Discard draft")} onClick={async () => { if (!d.dirty && !d.draftId) { void close(key, { discard: true }); return; } if (await confirmDialog({ title: "Discard this draft?", confirmLabel: "Discard", danger: true })) void close(key, { discard: true }); }}><Trash2 size={18} /></button>
<button className="icon-btn danger" title={translate("Discard draft")} aria-label={translate("Discard draft")} onClick={async () => { if (!d.dirty && !d.draftId) { void close(key, { discard: true }); return; } if (await confirmDialog({ title: translate("Discard this draft?"), confirmLabel: translate("Discard"), danger: true })) void close(key, { discard: true }); }}><Trash2 size={18} /></button>
</div>
</div>
</div>
+3 -3
View File
@@ -66,7 +66,7 @@ export function ContactEditor({ card, defaultBookId, onClose, onSaved }: Props)
const save = async () => {
if (!bookId) {
toast.error("Choose an address book");
toast.error(t("Choose an address book"));
return;
}
setBusy(true);
@@ -117,7 +117,7 @@ export function ContactEditor({ card, defaultBookId, onClose, onSaved }: Props)
} else if (removePhoto) obj.media = null;
if (isNew) {
const id = await contacts.createCard(obj as Partial<ContactCard>, bookId);
toast.success("Contact created");
toast.success(t("Contact created"));
onSaved(id);
} else {
const patch: Record<string, unknown> = { ...obj };
@@ -125,7 +125,7 @@ export function ContactEditor({ card, defaultBookId, onClose, onSaved }: Props)
if (curBook !== bookId) patch.addressBookIds = { [bookId]: true };
if (!photo && !removePhoto) delete patch.media;
await contacts.updateCard(card.id!, patch);
toast.success("Contact saved");
toast.success(t("Contact saved"));
onSaved(card.id!);
}
} catch (err) {
+7 -7
View File
@@ -8,7 +8,7 @@ import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
import { confirmDialog, promptDialog } from "@/ui/dialog";
import { toast } from "@/ui/toast";
import { ShareDialog } from "../settings/ShareDialog";
import { t } from "@/lib/i18n";
import { plural, t } from "@/lib/i18n";
/**
* Re-read the session so newly shared books appear without a sign-in.
@@ -183,7 +183,7 @@ export function ContactsSidebar() {
icon={<Pencil size={16} />}
label={t("Rename")}
onClick={async () => {
const name = await promptDialog({ title: "Rename address book", defaultValue: menuBook.name });
const name = await promptDialog({ title: t("Rename address book"), defaultValue: menuBook.name });
if (!name?.trim() || name === menuBook.name) return;
try {
await contacts.updateBook(menuBook.id, { name: name.trim() });
@@ -203,14 +203,14 @@ export function ContactsSidebar() {
onClick={async () => {
const who = Object.keys(menuBook.shareWith ?? {}).length;
if (!(await confirmDialog({
title: `Stop sharing “${menuBook.name}”?`,
message: `${who === 1 ? "One person" : `${who} people`} will lose access. The contacts in it are not affected.`,
confirmLabel: "Stop sharing",
title: t("Stop sharing “{name}”?", { name: menuBook.name }),
message: plural(who, { one: "{n} person will lose access. The contacts in it are not affected.", other: "{n} people will lose access. The contacts in it are not affected." }),
confirmLabel: t("Stop sharing"),
danger: true,
}))) return;
try {
await contacts.updateBook(menuBook.id, { shareWith: null });
toast.success("No longer shared");
toast.success(t("No longer shared"));
} catch (err) {
toast.error((err as Error).message);
}
@@ -224,7 +224,7 @@ export function ContactsSidebar() {
label={t("Delete")}
disabled={menuBook.isDefault}
onClick={async () => {
if (!(await confirmDialog({ title: `Delete “${menuBook.name}”?`, message: "The contacts in it go too.", confirmLabel: "Delete", danger: true }))) return;
if (!(await confirmDialog({ title: t("Delete “{name}”?", { name: menuBook.name }), message: t("The contacts in it go too."), confirmLabel: t("Delete"), danger: true }))) return;
try {
await contacts.destroyBook(menuBook.id);
if (sel.bookId === menuBook.id) contacts.select({ accountId: null, bookId: "all" });
+5 -5
View File
@@ -11,7 +11,7 @@ import { confirmDialog } from "@/ui/dialog";
import { toast } from "@/ui/toast";
import { ContactEditor } from "./ContactEditor";
import { avatarColor } from "@/lib/address";
import { t as translate } from "@/lib/i18n";
import { plural, t as translate } from "@/lib/i18n";
export function ContactsView({ id }: { id?: string }) {
const [, navigate] = useLocation();
@@ -92,12 +92,12 @@ export function ContactsView({ id }: { id?: string }) {
const importFile = async (f: File) => {
const book = bookId !== "all" ? contacts.books[bookId] : (books.find((b) => b.isDefault) ?? books[0]);
if (!book) {
toast.error("Create an address book first");
toast.error(translate("Create an address book first"));
return;
}
try {
const n = await contacts.importVCard(await f.text(), book.id);
toast.success(`Imported ${n} contact${n === 1 ? "" : "s"}`);
toast.success(plural(n, { one: "Imported {n} contact", other: "Imported {n} contacts" }));
} catch (err) {
toast.error((err as Error).message);
}
@@ -116,7 +116,7 @@ export function ContactsView({ id }: { id?: string }) {
</div>
<div className="contacts-scroll">
{contacts.loading && !contacts.loaded ? <Spinner label={translate("Loading contacts…")} /> : !list.length ? (
<Empty icon={<Users size={36} />} title={q ? "No matches" : "No contacts yet"}>{q ? "Try another search." : "Add a contact or import a vCard file."}</Empty>
<Empty icon={<Users size={36} />} title={q ? translate("No matches") : translate("No contacts yet")}>{q ? translate("Try another search.") : translate("Add a contact or import a vCard file.")}</Empty>
) : groups.map((g) => (
<div key={g.letter}>
<div className="contact-letter">{g.letter}</div>
@@ -168,7 +168,7 @@ function ContactDetail({ card: c, onBack, onEdit, narrow, onEmail }: { card: Con
<span className="spacer" />
<button className="btn btn-sm" onClick={onEdit}><Pencil size={14} /> {translate("Edit")}</button>
<button className="btn btn-sm" onClick={() => { const a = document.createElement("a"); a.href = URL.createObjectURL(new Blob([toVCard(c)], { type: "text/vcard" })); a.download = `${name.replace(/[^\w.-]+/g, "_")}.vcf`; a.click(); }}><Download size={14} /> {translate("vCard")}</button>
<button className="btn btn-sm btn-ghost" style={{ color: "var(--danger)" }} onClick={async () => { if (await confirmDialog({ title: `Delete ${name}?`, confirmLabel: "Delete", danger: true })) { try { await contacts.destroyCards([c.id]); toast.success("Contact deleted"); navigate("/contacts"); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={14} /></button>
<button className="btn btn-sm btn-ghost" style={{ color: "var(--danger)" }} onClick={async () => { if (await confirmDialog({ title: translate("Delete {name}?", { name }), confirmLabel: translate("Delete"), danger: true })) { try { await contacts.destroyCards([c.id]); toast.success(translate("Contact deleted")); navigate("/contacts"); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={14} /></button>
</div>
<div className="contact-hero">
<span className="avatar xl" style={{ background: photo ? "transparent" : avatarColor(contactEmails(c)[0]?.email ?? name) }}>{photo ? <img src={photo} alt="" /> : c.kind === "group" ? <Users size={36} /> : name.slice(0, 1).toUpperCase()}</span>
+3 -3
View File
@@ -242,7 +242,7 @@ export function FilesTree() {
icon={<FolderPlus size={16} />}
label={t("New folder")}
onClick={async () => {
const name = await promptDialog({ title: "New folder", placeholder: "Folder name" });
const name = await promptDialog({ title: t("New folder"), placeholder: t("Folder name") });
if (!name?.trim()) return;
try {
await useFiles.getState().mkdir(menuNode?.id ?? null, name.trim());
@@ -276,11 +276,11 @@ export function FilesTree() {
label={t("Delete")}
disabled={!menuNode.myRights?.mayDelete}
onClick={async () => {
if (!(await confirmDialog({ title: `Delete “${menuNode.name}”?`, message: "Everything inside it goes too.", confirmLabel: "Delete", danger: true }))) return;
if (!(await confirmDialog({ title: t("Delete “{name}”?", { name: menuNode.name }), message: t("Everything inside it goes too."), confirmLabel: t("Delete"), danger: true }))) return;
try {
await useFiles.getState().destroy([menuNode.id]);
if (currentId === menuNode.id) navigate("/files");
toast.success("Deleted");
toast.success(t("Deleted"));
} catch (err) {
toast.error((err as Error).message);
}
+5 -5
View File
@@ -122,7 +122,7 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
</div>
<button className="btn btn-sm" onClick={() => inputRef.current?.click()}><Upload size={16} /> {t("Upload")}</button>
<input ref={inputRef} type="file" multiple hidden onChange={(e) => { const l = Array.from(e.target.files ?? []); if (l.length) void files.upload(parentId, l); e.target.value = ""; }} />
<button className="btn btn-sm" onClick={async () => { const n = await promptDialog({ title: "New folder", placeholder: "Folder name" }); if (n?.trim()) { try { await files.mkdir(parentId, n.trim()); } catch (err) { toast.error((err as Error).message); } } }}><FolderPlus size={16} /> {t("New folder")}</button>
<button className="btn btn-sm" onClick={async () => { const n = await promptDialog({ title: t("New folder"), placeholder: t("Folder name") }); if (n?.trim()) { try { await files.mkdir(parentId, n.trim()); } catch (err) { toast.error((err as Error).message); } } }}><FolderPlus size={16} /> {t("New folder")}</button>
</div>
{files.uploads.length > 0 && (
<div className="list-hint" style={{ flexDirection: "column", alignItems: "stretch", gap: 4 }}>
@@ -177,17 +177,17 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
{!menuNode && (
<>
<MenuItem icon={<Upload size={16} />} label={t("Upload files…")} onClick={() => inputRef.current?.click()} />
<MenuItem icon={<FolderPlus size={16} />} label={t("New folder")} onClick={async () => { const n = await promptDialog({ title: "New folder", placeholder: "Folder name" }); if (n?.trim()) { try { await files.mkdir(parentId, n.trim()); } catch (err) { toast.error((err as Error).message); } } }} />
<MenuItem icon={<FolderPlus size={16} />} label={t("New folder")} onClick={async () => { const n = await promptDialog({ title: t("New folder"), placeholder: t("Folder name") }); if (n?.trim()) { try { await files.mkdir(parentId, n.trim()); } catch (err) { toast.error((err as Error).message); } } }} />
</>
)}
{menuNode && (
<>
{menuNode.nodeType === "directory" ? <MenuItem icon={<FolderOpen size={16} />} label={t("Open")} onClick={() => navigate(`/files/${menuNode.id}`)} /> : <MenuItem icon={<Download size={16} />} label={t("Download")} onClick={() => download(menuNode)} />}
<MenuItem icon={<Pencil size={16} />} label={t("Rename")} disabled={!menuNode.myRights?.mayRename} onClick={async () => { const n = await promptDialog({ title: "Rename", defaultValue: menuNode.name }); if (n?.trim() && n !== menuNode.name) { try { await files.rename(menuNode.id, n.trim()); } catch (err) { toast.error((err as Error).message); } } }} />
<MenuItem icon={<Pencil size={16} />} label={t("Rename")} disabled={!menuNode.myRights?.mayRename} onClick={async () => { const n = await promptDialog({ title: t("Rename"), defaultValue: menuNode.name }); if (n?.trim() && n !== menuNode.name) { try { await files.rename(menuNode.id, n.trim()); } catch (err) { toast.error((err as Error).message); } } }} />
<MenuItem icon={<FolderInput size={16} />} label={t("Move to…")} onClick={() => setMoveNode(menuNode)} />
<MenuItem icon={<Share2 size={16} />} label={t("Share…")} disabled={!menuNode.myRights?.mayShare} onClick={() => setShareNode(menuNode)} />
<MenuSep />
<MenuItem danger icon={<Trash2 size={16} />} label={t("Delete")} disabled={!menuNode.myRights?.mayDelete} onClick={async () => { if (await confirmDialog({ title: `Delete “${menuNode.name}”?`, confirmLabel: "Delete", danger: true })) { try { await files.destroy([menuNode.id]); toast.success("Deleted"); } catch (err) { toast.error((err as Error).message); } } }} />
<MenuItem danger icon={<Trash2 size={16} />} label={t("Delete")} disabled={!menuNode.myRights?.mayDelete} onClick={async () => { if (await confirmDialog({ title: t("Delete “{name}”?", { name: menuNode.name }), confirmLabel: t("Delete"), danger: true })) { try { await files.destroy([menuNode.id]); toast.success(t("Deleted")); } catch (err) { toast.error((err as Error).message); } } }} />
</>
)}
</Popover>
@@ -207,7 +207,7 @@ function MoveDialog({ node, onClose }: { node: FileNode; onClose: () => void })
const dirs = (files.children[cur ?? "root"] ?? []).map((id) => files.nodes[id]).filter((n): n is FileNode => Boolean(n && n.nodeType === "directory" && n.id !== node.id));
const path = files.pathTo(cur);
return (
<Dialog open onClose={onClose} title={`Move “${node.name}`} size="sm" footer={<><button className="btn" onClick={onClose}>{t("Cancel")}</button><button className="btn btn-primary" disabled={cur === (node.parentId ?? null)} onClick={async () => { try { await files.move(node.id, cur); toast.success("Moved"); onClose(); } catch (err) { toast.error((err as Error).message); } }}>{t("Move here")}</button></>}>
<Dialog open onClose={onClose} title={t("Move “{name}”", { name: node.name })} size="sm" footer={<><button className="btn" onClick={onClose}>{t("Cancel")}</button><button className="btn btn-primary" disabled={cur === (node.parentId ?? null)} onClick={async () => { try { await files.move(node.id, cur); toast.success(t("Moved")); onClose(); } catch (err) { toast.error((err as Error).message); } }}>{t("Move here")}</button></>}>
<div className="breadcrumb mb-8">
<button onClick={() => setCur(null)}><Home size={14} /></button>
{path.map((n) => <span key={n.id} className="row gap-4"><ChevronRight size={12} /><button onClick={() => setCur(n.id)}>{n.name}</button></span>)}
+2 -2
View File
@@ -56,8 +56,8 @@ export function useAddressMenu() {
label={t("Copy email address")}
onClick={() => {
void navigator.clipboard?.writeText(menu.address.email).then(
() => toast.show("Address copied"),
() => toast.error("Could not copy the address"),
() => toast.show(t("Address copied")),
() => toast.error(t("Could not copy the address")),
);
close();
}}
+13 -7
View File
@@ -8,7 +8,7 @@ import { RuleDialog } from "../settings/RuleDialog";
import { toast } from "@/ui/toast";
import { Spinner } from "@/ui/misc";
import { Dialog } from "@/ui/dialog";
import { t, tNode } from "@/lib/i18n";
import { plural, t, tNode } from "@/lib/i18n";
/** "Filter messages like this…" — creates a Sieve rule seeded from a message, optionally applying it to the current folder. */
export function FilterFromMessageDialog({ email, mailboxId, onClose }: { email: Email; mailboxId: Id | null; onClose: () => void }) {
@@ -79,24 +79,30 @@ export function FilterFromMessageDialog({ email, mailboxId, onClose }: { email:
export async function saveAndApply(r: SieveRule, existing: SieveRule[], applyMailboxId: Id | null) {
const sieve = useSieve.getState();
const created = !existing.some((x) => x.id === r.id);
const verb = created ? "created" : "saved";
const saved = !created;
try {
await sieve.saveRules(upsertRule(existing, r));
} catch (err) {
toast.error(`Could not save filter: ${(err as Error).message}`);
toast.error(t("Could not save filter: {error}", { error: (err as Error).message }));
return;
}
if (!applyMailboxId) {
toast.success(`Filter ${verb} — it will run on new mail`);
toast.success(saved ? t("Filter saved — it will run on new mail") : t("Filter created — it will run on new mail"));
return;
}
const tid = toast.show("Applying filter to existing messages…", { duration: 0 });
const tid = toast.show(t("Applying filter to existing messages…"), { duration: 0 });
try {
const res = await applyRuleToMailbox(r, applyMailboxId);
toast.dismiss(tid);
toast.success(`Filter ${verb} · applied to ${res.matched} of ${res.scanned} message${res.scanned === 1 ? "" : "s"}${res.skippedActions.length ? ` (skipped: ${res.skippedActions.join("; ")})` : ""}`, { duration: 8000 });
toast.success(
(saved ? t("Filter saved") : t("Filter created"))
+ " · "
+ plural(res.scanned, { one: "applied to {matched} of {n} message", other: "applied to {matched} of {n} messages" }, { matched: res.matched })
+ (res.skippedActions.length ? " " + t("(skipped: {actions})", { actions: res.skippedActions.join("; ") }) : ""),
{ duration: 8000 },
);
} catch (err) {
toast.dismiss(tid);
toast.error(`Filter saved, but applying it failed: ${(err as Error).message}`);
toast.error(t("Filter saved, but applying it failed: {error}", { error: (err as Error).message }));
}
}
+2 -2
View File
@@ -72,7 +72,7 @@ export function InviteCard({ email, part }: { email: Email; part: EmailBodyPart
if (!calId) throw new Error("No calendar available");
const id = await cal.importEvent(ev, calId);
setExisting(await cal.getEvent(id));
toast.success("Added to your calendar");
toast.success(t("Added to your calendar"));
} catch (err) {
toast.error((err as Error).message);
} finally {
@@ -116,7 +116,7 @@ export function InviteCard({ email, part }: { email: Email; part: EmailBodyPart
)}
{method === "CANCEL" && existing && (
<div className="rsvp">
<button className="btn btn-sm btn-danger" disabled={Boolean(busy)} onClick={async () => { try { await cal.destroyEvent(existing, false, "series"); setExisting(null); toast.success("Removed from calendar"); } catch (err) { toast.error((err as Error).message); } }}>{t("Remove from calendar")}</button>
<button className="btn btn-sm btn-danger" disabled={Boolean(busy)} onClick={async () => { try { await cal.destroyEvent(existing, false, "series"); setExisting(null); toast.success(t("Removed from calendar")); } catch (err) { toast.error((err as Error).message); } }}>{t("Remove from calendar")}</button>
</div>
)}
<span className="sr-only">{email.id}</span>
+1 -1
View File
@@ -39,7 +39,7 @@ export function LabelPicker({ ids, anchor, onClose, onApplied }: { ids: Id[]; an
<input
className="input sm"
autoFocus
placeholder={labels.length ? "Search or create label" : "New label name"}
placeholder={labels.length ? t("Search or create label") : t("New label name")}
value={q}
onChange={(e) => setQ(e.target.value)}
onKeyDown={(e) => {
+3 -3
View File
@@ -58,7 +58,7 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string;
*/
useEffect(() => {
if (!isUnknownMailbox({ mailboxId, mailboxes, loaded: mailboxesLoaded, search }) || !inboxId) return;
toast.show("That folder no longer exists. Showing your inbox instead.");
toast.show(translate("That folder no longer exists. Showing your inbox instead."));
navigate(`/mail/${inboxId}`, { replace: true });
}, [search, mailboxId, mailboxesLoaded, mailboxes, inboxId, navigate]);
@@ -362,7 +362,7 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string;
)}
{movePicker && (
<MailboxPicker
title={`Move ${movePicker.ids.length} message${movePicker.ids.length === 1 ? "" : "s"} to…`}
title={plural(movePicker.ids.length, { one: "Move {n} message to…", other: "Move {n} messages to…" })}
onClose={() => setMovePicker(null)}
onPick={(mbId) => {
setMovePicker(null);
@@ -375,7 +375,7 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string;
ids={labelPicker.ids}
anchor={labelPicker.anchor}
onClose={() => setLabelPicker(null)}
onApplied={() => toast.show("Labels updated")}
onApplied={() => toast.show(translate("Labels updated"))}
/>
)}
</div>
+5 -5
View File
@@ -14,7 +14,7 @@ import { ShareDialog } from "../settings/ShareDialog";
import { loadRaw, saveJson } from "@/lib/storage";
import { canDropFolder, folderColor, movable } from "@/lib/folderMove";
import { haptic, useTouchRow } from "@/lib/touch";
import { t } from "@/lib/i18n";
import { plural, t } from "@/lib/i18n";
import { mailboxDisplayName } from "@/lib/mailboxName";
const ROLE_ICONS: Record<string, ReactNode> = {
@@ -105,11 +105,11 @@ export function MailboxTree() {
}, [mailboxes, showHidden, expanded]);
const createFolder = async (parentId: Id | null) => {
const name = await promptDialog({ title: parentId ? "New subfolder" : "New folder", placeholder: "Folder name" });
const name = await promptDialog({ title: parentId ? t("New subfolder") : t("New folder"), placeholder: t("Folder name") });
if (!name?.trim()) return;
try {
await useMail.getState().createMailbox(name.trim(), parentId);
toast.success(`Folder “${name.trim()}” created`);
toast.success(t("Folder “{name}” created", { name: name.trim() }));
} catch (err) {
toast.error((err as Error).message);
}
@@ -344,11 +344,11 @@ function MailboxMenu({ mailbox: m, onClose, onCreateChild, onShare }: { mailbox:
}
};
const remove = async () => {
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 });
const ok = await confirmDialog({ title: t("Delete “{name}”?", { name: mailboxDisplayName(m) }), message: plural(m.totalEmails, { one: "This permanently deletes the folder and its {n} message.", other: "This permanently deletes the folder and its {n} messages." }), confirmLabel: t("Delete"), danger: true });
if (!ok) return;
try {
await useMail.getState().destroyMailbox(m.id, true);
toast.success("Folder deleted");
toast.success(t("Folder deleted"));
navigate(`/mail/${useMail.getState().roleId("inbox") ?? ""}`);
} catch (err) {
toast.error((err as Error).message);
+10 -10
View File
@@ -251,8 +251,8 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on
<span className="tb-count">{plural(selCount, { one: "{n} selected", other: "{n} selected" })}</span>
<span className="tb-sep" />
<button className="icon-btn" title={t("Archive (e)")} onClick={() => void actions.archive()}><Archive size={19} /></button>
<button className="icon-btn" title={isTrashOrJunk ? "Delete forever" : "Delete (#)"} onClick={() => void actions.trash()}><Trash2 size={19} /></button>
<button className="icon-btn hide-mobile" title={mailbox?.role === "junk" ? "Not spam" : "Report spam (!)"} onClick={() => void actions.spam()}>{mailbox?.role === "junk" ? <ShieldCheck size={19} /> : <AlertOctagon size={19} />}</button>
<button className="icon-btn" title={isTrashOrJunk ? t("Delete forever") : t("Delete (#)")} onClick={() => void actions.trash()}><Trash2 size={19} /></button>
<button className="icon-btn hide-mobile" title={mailbox?.role === "junk" ? t("Not spam") : t("Report spam (!)")} onClick={() => void actions.spam()}>{mailbox?.role === "junk" ? <ShieldCheck size={19} /> : <AlertOctagon size={19} />}</button>
<span className="tb-sep" />
<button className="icon-btn" title={t("Mark as read (Shift+I)")} onClick={() => void actions.read(true)}><MailOpen size={19} /></button>
<button className="icon-btn hide-mobile" title={t("Mark as unread (Shift+U)")} onClick={() => void actions.read(false)}><Mail size={19} /></button>
@@ -383,8 +383,8 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on
))}
</div>
) : ids.length === 0 && list && !list.loading ? (
<Empty icon={isSearch ? <Search size={40} /> : <Inbox size={40} />} title={isSearch ? "No results" : mailbox?.role === "inbox" ? "You're all caught up" : "Nothing here"}>
{isSearch ? "Try different keywords or filters." : mailbox?.role === "inbox" ? "No new mail in your inbox." : "This folder is empty."}
<Empty icon={isSearch ? <Search size={40} /> : <Inbox size={40} />} title={isSearch ? t("No results") : mailbox?.role === "inbox" ? t("You're all caught up") : t("Nothing here")}>
{isSearch ? t("Try different keywords or filters.") : mailbox?.role === "inbox" ? t("No new mail in your inbox.") : t("This folder is empty.")}
</Empty>
) : (
<div className="mail-list-inner" style={{ height: virtualizer.getTotalSize() }}>
@@ -417,7 +417,7 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on
>
<span className="msg-swipe-act">
{SWIPE_ICON[strip.desc.icon]}
<span>{strip.desc.label}</span>
<span>{t(strip.desc.label)}</span>
</span>
</div>
)}
@@ -613,7 +613,7 @@ const Row = memo(function Row({ email: e, threadEmails, top, height, selected, f
ev.dataTransfer.effectAllowed = "move";
const ghost = document.createElement("div");
ghost.className = "drag-ghost";
ghost.textContent = `${ids.length > 1 ? `${ids.length} conversations` : e.subject || "(no subject)"}`;
ghost.textContent = ids.length > 1 ? plural(ids.length, { one: "{n} conversation", other: "{n} conversations" }) : e.subject || t("(no subject)");
document.body.appendChild(ghost);
ev.dataTransfer.setDragImage(ghost, 10, 10);
setTimeout(() => ghost.remove(), 0);
@@ -659,7 +659,7 @@ const Row = memo(function Row({ email: e, threadEmails, top, height, selected, f
</div>
<div className="msg-main">
{isDrafts && <span style={{ color: "var(--danger)" }}>{t("Draft")}</span>}
<span className="msg-subject">{e.subject || "(no subject)"}</span>
<span className="msg-subject">{e.subject || t("(no subject)")}</span>
{showPreview && <span className="msg-preview">{latest.preview}</span>}
<button className={`msg-star ${starred ? "on" : ""}`} style={{ marginLeft: "auto" }} onClick={(ev) => { ev.stopPropagation(); onStar(e.id, !starred); }} aria-label={t("Star")}>
<Star size={16} fill={starred ? "currentColor" : "none"} />
@@ -676,17 +676,17 @@ const Row = memo(function Row({ email: e, threadEmails, top, height, selected, f
<span className="msg-main">
{isDrafts && <span style={{ color: "var(--danger)", flex: "0 0 auto" }}>{t("Draft")}</span>}
{rowLabels.length > 0 && <span className="msg-labels">{rowLabels.map((l) => <span key={l.keyword} className="tag" style={{ background: l.color }}>{l.name}</span>)}</span>}
<span className="msg-subject">{e.subject || "(no subject)"}</span>
<span className="msg-subject">{e.subject || t("(no subject)")}</span>
{showPreview && <span className="msg-preview">{latest.preview}</span>}
</span>
<span className="msg-meta">
{(answered || forwarded) && <span className="msg-answered" title={answered ? "Replied" : "Forwarded"}>{answered ? <Reply size={14} /> : <Forward size={14} />}</span>}
{(answered || forwarded) && <span className="msg-answered" title={answered ? t("Replied") : t("Forwarded")}>{answered ? <Reply size={14} /> : <Forward size={14} />}</span>}
{hasAtt && <Paperclip size={14} className="msg-attach" />}
<span className="msg-date">{formatListDate(latest.receivedAt)}</span>
<span className="msg-actions">
<button className="icon-btn sm" title={t("Archive")} onClick={(ev) => { ev.stopPropagation(); onArchive(e.id); }}><Archive size={16} /></button>
<button className="icon-btn sm" title={t("Delete")} onClick={(ev) => { ev.stopPropagation(); onTrash(e.id); }}><Trash2 size={16} /></button>
<button className="icon-btn sm" title={unread ? "Mark as read" : "Mark as unread"} onClick={(ev) => { ev.stopPropagation(); onRead(e.id, unread); }}>{unread ? <MailOpen size={16} /> : <Mail size={16} />}</button>
<button className="icon-btn sm" title={unread ? t("Mark as read") : t("Mark as unread")} onClick={(ev) => { ev.stopPropagation(); onRead(e.id, unread); }}>{unread ? <MailOpen size={16} /> : <Mail size={16} />}</button>
</span>
</span>
</>
+11 -11
View File
@@ -110,7 +110,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
try {
setSource(await client.fetchBlobText(accountId, e.blobId, "message/rfc822"));
} catch (err) {
setSource(`Could not load source: ${(err as Error).message}`);
setSource(translate("Could not load source: {error}", { error: (err as Error).message }));
}
}
};
@@ -130,7 +130,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
if (mailto) {
const fields = draftFromMailto(mailto);
useCompose.getState().open({ ...fields, subject: fields.subject || "unsubscribe", html: fields.html ?? "<div>unsubscribe</div>", text: fields.text ?? "unsubscribe" });
toast.show("Unsubscribe message prepared — just hit Send");
toast.show(translate("Unsubscribe message prepared — just hit Send"));
} else if (http) {
window.open(http, "_blank", "noopener,noreferrer");
}
@@ -212,7 +212,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
{e.cc?.length ? <><dt>{translate("Cc")}</dt><dd><AddressList list={e.cc} onContext={addrMenu.open} /></dd></> : null}
{e.bcc?.length ? <><dt>{translate("Bcc")}</dt><dd><AddressList list={e.bcc} onContext={addrMenu.open} /></dd></> : null}
<dt>{translate("Date")}</dt><dd>{formatFullDate(e.sentAt ?? e.receivedAt)}</dd>
<dt>{translate("Subject")}</dt><dd>{e.subject || "(no subject)"}</dd>
<dt>{translate("Subject")}</dt><dd>{e.subject || translate("(no subject)")}</dd>
{e.messageId?.[0] && <><dt>{translate("Message-ID")}</dt><dd className="mono small">{e.messageId[0]}</dd></>}
{e["header:List-Id:asText"] && <><dt>{translate("List")}</dt><dd>{e["header:List-Id:asText"]}</dd></>}
<dt>{translate("Size")}</dt><dd>{formatSize(e.size)}</dd>
@@ -235,10 +235,10 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
setReceiptDone("sending");
try {
await sendReadReceipt(e);
toast.success("Read receipt sent");
toast.success(translate("Read receipt sent"));
} catch (err) {
setReceiptDone(null);
toast.error(`Could not send the receipt: ${(err as Error).message}`);
toast.error(translate("Could not send the receipt: {error}", { error: (err as Error).message }));
}
}}
>
@@ -255,9 +255,9 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
onClick={async () => {
try {
await cancelScheduled(e.id);
toast.success("Send cancelled — the message is back in Drafts");
toast.success(translate("Send cancelled — the message is back in Drafts"));
} catch (err) {
toast.error(`Could not cancel: ${(err as Error).message}`);
toast.error(translate("Could not cancel: {error}", { error: (err as Error).message }));
}
}}
>
@@ -452,9 +452,9 @@ function HtmlBody({ html, bodyStyle, themed, onShowImages }: { html: string; bod
rewrite what someone actually wrote. */}
<div ref={hostRef} className="body-host notranslate" translate="no" />
{hasQuote && (
<button className="quote-toggle" onClick={() => setQuoteOpen((v) => !v)} title={quoteOpen ? "Hide quoted text" : "Show quoted text"}>
<button className="quote-toggle" onClick={() => setQuoteOpen((v) => !v)} title={quoteOpen ? translate("Hide quoted text") : translate("Show quoted text")}>
{quoteOpen ? <ChevronUp size={12} /> : <span style={{ letterSpacing: 2 }}>{translate("•••")}</span>}
{quoteOpen ? "Hide quoted text" : ""}
{quoteOpen ? translate("Hide quoted text") : ""}
</button>
)}
</>
@@ -496,7 +496,7 @@ function TextBody({ text }: { text: string }) {
{quoted && (
<button className="quote-toggle" onClick={() => setQuoteOpen((v) => !v)}>
{quoteOpen ? <ChevronUp size={12} /> : <span style={{ letterSpacing: 2 }}>{translate("•••")}</span>}
{quoteOpen ? "Hide quoted text" : ""}
{quoteOpen ? translate("Hide quoted text") : ""}
</button>
)}
</>
@@ -549,7 +549,7 @@ function AttachmentList({ attachments, accountId, email }: { attachments: EmailB
</button>
)}
</div>
<Dialog open={Boolean(preview)} onClose={() => setPreview(null)} title={preview?.name ?? "Preview"} size="xl" footer={preview && <a className="btn" href={client.downloadUrl(accountId, preview.blobId!, preview.name ?? "file", preview.type)} download><Download size={16} /> {translate("Download")}</a>}>
<Dialog open={Boolean(preview)} onClose={() => setPreview(null)} title={preview?.name ?? translate("Preview")} size="xl" footer={preview && <a className="btn" href={client.downloadUrl(accountId, preview.blobId!, preview.name ?? "file", preview.type)} download><Download size={16} /> {translate("Download")}</a>}>
{preview?.type.startsWith("image/") && <img src={client.downloadUrl(accountId, preview.blobId!, preview.name ?? "image", preview.type, true)} alt={preview.name ?? ""} style={{ maxHeight: "70vh", display: "block", margin: "0 auto" }} />}
{preview?.type === "application/pdf" && <iframe title={translate("PDF")} src={client.downloadUrl(accountId, preview.blobId!, preview.name ?? "file.pdf", preview.type, true)} style={{ width: "100%", height: "70vh", border: 0 }} />}
{preview?.type === "text/plain" && <TextAttachment url={client.downloadUrl(accountId, preview.blobId!, preview.name ?? "file.txt", preview.type, true)} />}
+2 -2
View File
@@ -4,7 +4,7 @@ import type { EmailBodyPart, Id } from "@/jmap/types";
import { useContacts } from "@/store/contacts";
import { client } from "@/jmap/client";
import { toast } from "@/ui/toast";
import { t } from "@/lib/i18n";
import { plural, t } from "@/lib/i18n";
export function VCardCard({ part, accountId }: { part: EmailBodyPart; accountId: Id }) {
const contacts = useContacts();
@@ -19,7 +19,7 @@ export function VCardCard({ part, accountId }: { part: EmailBodyPart; accountId:
if (!book) throw new Error("No address book available");
const n = await contacts.importVCard(text, book.id);
setDone(true);
toast.success(`Added ${n} contact${n === 1 ? "" : "s"}`);
toast.success(plural(n, { one: "Added {n} contact", other: "Added {n} contacts" }));
} catch (err) {
toast.error((err as Error).message);
} finally {
+1 -1
View File
@@ -53,7 +53,7 @@ export function CalendarSettings() {
<div className="card-head">
<span className="label-dot" style={{ background: c.color, width: 14, height: 14 }} />
<h3>{c.name}</h3>
<button className="icon-btn sm" title={t("Rename")} onClick={async () => { const n = await promptDialog({ title: "Rename category", defaultValue: c.name }); if (n?.trim()) update({ eventCategories: s.eventCategories.map((x, j) => (j === i ? { ...x, name: n.trim() } : x)) }); }}></button>
<button className="icon-btn sm" title={t("Rename")} onClick={async () => { const n = await promptDialog({ title: t("Rename category"), defaultValue: c.name }); if (n?.trim()) update({ eventCategories: s.eventCategories.map((x, j) => (j === i ? { ...x, name: n.trim() } : x)) }); }}></button>
<button className="icon-btn sm danger" aria-label={t("Delete category")} onClick={() => update({ eventCategories: s.eventCategories.filter((_, j) => j !== i) })}><Trash2 size={16} /></button>
</div>
<div style={{ marginTop: 8 }}><ColorSwatches value={c.color} onChange={(col) => update({ eventCategories: s.eventCategories.map((x, j) => (j === i ? { ...x, color: col } : x)) })} /></div>
+8 -8
View File
@@ -73,9 +73,9 @@ function RulesEditor() {
try {
await sieve.saveRules(next);
setLocal(null);
toast.success("Filters saved");
toast.success(t("Filters saved"));
} catch (err) {
toast.error(`Could not save filters: ${(err as Error).message}`);
toast.error(t("Could not save filters: {error}", { error: (err as Error).message }));
} finally {
setSaving(false);
}
@@ -203,7 +203,7 @@ function ScriptsEditor() {
const save = async (activate: boolean) => {
if (!name.trim()) {
toast.error("Script name is required");
toast.error(t("Script name is required"));
return;
}
setBusy(true);
@@ -211,11 +211,11 @@ function ScriptsEditor() {
const err = await sieve.validate(content);
setValidation(err);
if (err) {
toast.error("Script has errors");
toast.error(t("Script has errors"));
return;
}
await sieve.saveScript(sel?.id ?? null, name.trim(), content, activate);
toast.success("Script saved");
toast.success(t("Script saved"));
setSel(null);
} catch (err) {
toast.error((err as Error).message);
@@ -236,7 +236,7 @@ function ScriptsEditor() {
{validation && <div className="error-box mb-16">{validation}</div>}
<div className="row">
<button className="btn btn-ghost" onClick={() => { setSel(null); setName(""); setContent(""); }}>{t("Cancel")}</button>
<button className="btn" disabled={busy} onClick={async () => { setBusy(true); const err = await sieve.validate(content); setValidation(err); setBusy(false); if (!err) toast.success("Script is valid"); }}><Play size={14} /> {t("Validate")}</button>
<button className="btn" disabled={busy} onClick={async () => { setBusy(true); const err = await sieve.validate(content); setValidation(err); setBusy(false); if (!err) toast.success(t("Script is valid")); }}><Play size={14} /> {t("Validate")}</button>
<span className="spacer" />
<button className="btn" disabled={busy} onClick={() => void save(false)}>{t("Save")}</button>
<button className="btn btn-primary" disabled={busy} onClick={() => void save(true)}>{t("Save & activate")}</button>
@@ -254,8 +254,8 @@ function ScriptsEditor() {
<div className="card-head">
<h3><span>{s.name} </span>{s.isActive && <span className="tag" style={{ background: "var(--success)" }}>{t("active")}</span>}</h3>
<button className="btn btn-sm" onClick={() => void open(s)}>{t("Edit")}</button>
<button className="btn btn-sm" onClick={async () => { try { await sieve.activate(s.isActive ? null : s.id); } catch (err) { toast.error((err as Error).message); } }}><Power size={14} /> {s.isActive ? "Deactivate" : "Activate"}</button>
<button className="icon-btn sm danger" aria-label={t("Delete script")} onClick={async () => { if (await confirmDialog({ title: `Delete script “${s.name}”?`, confirmLabel: "Delete", danger: true })) { try { await sieve.destroy(s.id); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={16} /></button>
<button className="btn btn-sm" onClick={async () => { try { await sieve.activate(s.isActive ? null : s.id); } catch (err) { toast.error((err as Error).message); } }}><Power size={14} /> {s.isActive ? t("Deactivate") : t("Activate")}</button>
<button className="icon-btn sm danger" aria-label={t("Delete script")} onClick={async () => { if (await confirmDialog({ title: t("Delete script “{name}”?", { name: s.name }), confirmLabel: t("Delete"), danger: true })) { try { await sieve.destroy(s.id); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={16} /></button>
</div>
</div>
))}
+5 -5
View File
@@ -6,7 +6,7 @@ import { toast } from "@/ui/toast";
import { formatSize } from "@/lib/format";
import { ShareDialog } from "./ShareDialog";
import type { Mailbox } from "@/jmap/types";
import { t } from "@/lib/i18n";
import { plural, t } from "@/lib/i18n";
import { mailboxDisplayPath } from "@/lib/mailboxName";
export function FoldersSettings() {
@@ -18,7 +18,7 @@ export function FoldersSettings() {
const q = quotas.find((x) => x.resourceType === "octets");
const create = async () => {
const name = await promptDialog({ title: "New folder", placeholder: "Folder name (use / for subfolders, e.g. Work/Invoices)" });
const name = await promptDialog({ title: t("New folder"), placeholder: t("Folder name (use / for subfolders, e.g. Work/Invoices)") });
if (!name?.trim()) return;
try {
const parts = name.split("/").map((p) => p.trim()).filter(Boolean);
@@ -27,7 +27,7 @@ export function FoldersSettings() {
const existing = Object.values(useMail.getState().mailboxes).find((m) => (m.parentId ?? null) === parentId && m.name.toLowerCase() === part.toLowerCase());
parentId = existing ? existing.id : await useMail.getState().createMailbox(part, parentId);
}
toast.success("Folder created");
toast.success(t("Folder created"));
} catch (err) {
toast.error((err as Error).message);
}
@@ -51,9 +51,9 @@ export function FoldersSettings() {
<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 ? t("Hide") : t("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>}
<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: t("Delete “{name}”?", { name: m.name }), message: plural(m.totalEmails, { one: "{n} message will be permanently deleted.", other: "{n} messages will be permanently deleted." }), confirmLabel: t("Delete"), danger: true })) { try { await useMail.getState().destroyMailbox(m.id, true); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={16} /></button>
</div>
</td>
</tr>
+4 -4
View File
@@ -185,7 +185,7 @@ export function GeneralSettings() {
{t("Import settings")}
<input type="file" accept="application/json" hidden onChange={async (e) => { const f = e.target.files?.[0]; if (!f) return; const ok = importJson(await f.text()); toast[ok ? "success" : "error"](ok ? t("Settings imported") : t("Invalid settings file")); e.target.value = ""; }} />
</label>
<button className="btn btn-ghost" onClick={() => { reset(); toast.show("Settings reset to defaults"); }}>{t("Reset to defaults")}</button>
<button className="btn btn-ghost" onClick={() => { reset(); toast.show(t("Settings reset to defaults")); }}>{t("Reset to defaults")}</button>
</div>
</div>
);
@@ -204,16 +204,16 @@ function MailHandlerSettings() {
try {
registerMailtoHandler();
setRequested(true);
toast.success("Your browser will ask whether to open mail links in ihasmail");
toast.success(t("Your browser will ask whether to open mail links in ihasmail"));
} catch (err) {
toast.error(`Your browser refused the request: ${(err as Error).message}`);
toast.error(t("Your browser refused the request: {error}", { error: (err as Error).message }));
}
};
const remove = () => {
unregisterMailtoHandler();
setRequested(false);
toast.show("Removed. Mail links will open in whatever your browser falls back to.");
toast.show(t("Removed. Mail links will open in whatever your browser falls back to."));
};
if (support === "unsupported") {
@@ -48,13 +48,13 @@ export function IdentitiesSettings() {
<button
className="btn btn-sm btn-ghost"
disabled={isAlwaysVisible(i.id, [defaultId])}
title={isAlwaysVisible(i.id, [defaultId]) ? "The default identity is always offered when composing" : hidden.includes(i.id) ? "Show this in the compose picker" : "Hide this from the compose picker"}
title={isAlwaysVisible(i.id, [defaultId]) ? t("The default identity is always offered when composing") : hidden.includes(i.id) ? t("Show this in the compose picker") : t("Hide this from the compose picker")}
onClick={(e) => { e.stopPropagation(); toggleHidden(i.id); }}
>
{hidden.includes(i.id) ? <><Eye size={14} /> {t("Show when composing")}</> : <><EyeOff size={14} /> {t("Hide when composing")}</>}
</button>
{i.mayDelete && (
<button className="icon-btn sm danger" aria-label={t("Delete identity")} onClick={async (e) => { e.stopPropagation(); if (await confirmDialog({ title: "Delete this identity?", confirmLabel: "Delete", danger: true })) { try { await useMail.getState().destroyIdentity(i.id); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={16} /></button>
<button className="icon-btn sm danger" aria-label={t("Delete identity")} onClick={async (e) => { e.stopPropagation(); if (await confirmDialog({ title: t("Delete this identity?"), confirmLabel: t("Delete"), danger: true })) { try { await useMail.getState().destroyIdentity(i.id); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={16} /></button>
)}
</div>
{hidden.includes(i.id) && <div className="hint" style={{ marginTop: 4 }}>{t("Not offered when composing. It still receives mail, and you can still send from it by showing it again.")}</div>}
@@ -105,7 +105,7 @@ function IdentityDialog({ identity, onClose }: { identity: Partial<Identity>; on
};
if (!identity.id) patch.email = email.trim();
await useMail.getState().saveIdentity(identity.id ?? null, patch);
toast.success("Identity saved");
toast.success(t("Identity saved"));
onClose();
} catch (err) {
toast.error((err as Error).message);
@@ -114,7 +114,7 @@ function IdentityDialog({ identity, onClose }: { identity: Partial<Identity>; on
}
};
return (
<Dialog open onClose={onClose} title={identity.id ? "Edit identity" : "New identity"} size="lg" footer={<><button className="btn" onClick={onClose}>{t("Cancel")}</button><button className="btn btn-primary" disabled={busy} onClick={() => void save()}>{busy ? "Saving…" : "Save"}</button></>}>
<Dialog open onClose={onClose} title={identity.id ? t("Edit identity") : t("New identity")} size="lg" footer={<><button className="btn" onClick={onClose}>{t("Cancel")}</button><button className="btn btn-primary" disabled={busy} onClick={() => void save()}>{busy ? t("Saving…") : t("Save")}</button></>}>
<div className="field-row">
<div className="field"><label>{t("Display name")}</label><input className="input" value={name} onChange={(e) => setName(e.target.value)} /></div>
<div className="field"><label>{t("Email address")}</label><input className="input" type="email" value={email} disabled={Boolean(identity.id)} onChange={(e) => setEmail(e.target.value)} /></div>
+1 -1
View File
@@ -11,7 +11,7 @@ export function LabelsSettings() {
const [editing, setEditing] = useState<string | null>(null);
const add = async () => {
const name = await promptDialog({ title: "New label", placeholder: "Label name" });
const name = await promptDialog({ title: t("New label"), placeholder: t("Label name") });
if (!name?.trim()) return;
const keyword = name.trim().toLowerCase().replace(/[^a-z0-9_.-]+/g, "_").replace(/^_+|_+$/g, "") || `label${Date.now()}`;
if (labels.some((l) => l.keyword === keyword)) return;
+3 -3
View File
@@ -30,7 +30,7 @@ export function RuleDialog({ rule, onClose, onSave, applyMailbox, applyByDefault
const setAction = (i: number, a: SieveAction) => setR({ ...r, actions: r.actions.map((x, j) => (j === i ? a : x)) });
return (
<Dialog open onClose={onClose} title={title ?? (rule.name === "New filter" ? "New rule" : "Edit rule")} size="lg" footer={<>
<Dialog open onClose={onClose} title={title ?? (rule.name === "New filter" ? translate("New rule") : translate("Edit rule"))} size="lg" footer={<>
{applyMailbox && (
<label className="check left" style={{ marginRight: "auto" }}>
<input type="checkbox" checked={applyNow} onChange={(e) => setApplyNow(e.target.checked)} />
@@ -116,7 +116,7 @@ export function RuleDialog({ rule, onClose, onSave, applyMailbox, applyByDefault
const v = e.target.value;
if (v === "__new__") {
// Create a folder on the fly ("Parent/Child" creates nested folders).
const name = await promptDialog({ title: "New folder", placeholder: "Folder name (use / for a subfolder, e.g. Work/Invoices)" });
const name = await promptDialog({ title: translate("New folder"), placeholder: translate("Folder name (use / for a subfolder, e.g. Work/Invoices)") });
if (!name?.trim()) return;
try {
const mail = useMail.getState();
@@ -128,7 +128,7 @@ export function RuleDialog({ rule, onClose, onSave, applyMailbox, applyByDefault
}
const path = useMail.getState().mailboxPath(parentId!);
setAction(i, { ...a, mailbox: path, mailboxId: parentId! });
toast.success(`Folder “${path}” created`);
toast.success(translate("Folder “{name}” created", { name: path }));
} catch (err) {
toast.error((err as Error).message);
}
+9 -9
View File
@@ -5,7 +5,7 @@ import { useSession } from "@/store/session";
import { formatFullDate } from "@/lib/format";
import { toast } from "@/ui/toast";
import { confirmDialog, Dialog } from "@/ui/dialog";
import { t, tNode } from "@/lib/i18n";
import { plural, t, tNode } from "@/lib/i18n";
interface SessionRow {
id: string;
@@ -100,7 +100,7 @@ export function SecuritySettings() {
</table>
)}
<div className="row mt-16">
<button className="btn" onClick={async () => { if (await confirmDialog({ title: "Sign out other sessions?", confirmLabel: "Sign out others" })) { const r = await apiFetch<{ revoked: number }>("/api/auth/sessions/revoke-others", { method: "POST" }); toast.success(`Signed out ${r.revoked} other session(s)`); void load(); } }}>{t("Sign out all other sessions")}</button>
<button className="btn" onClick={async () => { if (await confirmDialog({ title: t("Sign out other sessions?"), confirmLabel: t("Sign out others") })) { const r = await apiFetch<{ revoked: number }>("/api/auth/sessions/revoke-others", { method: "POST" }); toast.success(plural(r.revoked, { one: "Signed out {n} other session", other: "Signed out {n} other sessions" })); void load(); } }}>{t("Sign out all other sessions")}</button>
<button className="btn btn-ghost" onClick={() => void logout()}>{t("Sign out here")}</button>
</div>
</div>
@@ -119,7 +119,7 @@ function PasswordForm({ otpEnabled, onChanged }: { otpEnabled: boolean; onChange
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (next !== confirm) {
toast.error("The new passwords don't match");
toast.error(t("The new passwords don't match"));
return;
}
setBusy(true);
@@ -188,7 +188,7 @@ function TwoFactorOff({ reload }: { reload: () => Promise<void> }) {
await apiFetch("/api/account/2fa/disable", { method: "POST", body: JSON.stringify({ current: password, code }) });
setDisabling(false);
await reload();
toast.success("Two-factor authentication is off");
toast.success(t("Two-factor authentication is off"));
} catch (err) {
toast.error((err as Error).message);
} finally {
@@ -256,16 +256,16 @@ function AppPasswords({ state, reload }: { state: SecurityState | null; reload:
const revoke = async (row: AppPasswordRow) => {
const ok = await confirmDialog({
title: `Revoke "${row.description}"?`,
message: "Anything signed in with this password stops working immediately.",
confirmLabel: "Revoke",
title: t("Revoke “{name}”?", { name: row.description }),
message: t("Anything signed in with this password stops working immediately."),
confirmLabel: t("Revoke"),
danger: true,
});
if (!ok) return;
try {
await apiFetch("/api/account/app-passwords/revoke", { method: "POST", body: JSON.stringify({ id: row.id }) });
await reload();
toast.success("App password revoked");
toast.success(t("App password revoked"));
} catch (err) {
toast.error((err as Error).message);
}
@@ -321,7 +321,7 @@ function CopyableSecret({ value }: { value: string }) {
type="button"
className="btn btn-sm btn-ghost"
title={t("Copy")}
onClick={() => void navigator.clipboard?.writeText(value).then(() => toast.success("Copied"), () => toast.error("Could not copy"))}
onClick={() => void navigator.clipboard?.writeText(value).then(() => toast.success(t("Copied")), () => toast.error(t("Could not copy")))}
>
<Copy size={14} />
</button>
+2 -2
View File
@@ -90,7 +90,7 @@ export function ShareDialog({ kind, id, name, shareWith, onClose }: { kind: Kind
const res = await client.call<{ notUpdated?: Record<string, { type: string; description?: string }> }>(`${kind}/set`, { accountId, update: { [id]: { shareWith: Object.keys(rights).length ? rights : null } } });
const err = res.notUpdated?.[id];
if (err) throw new Error(setErrorMessage(err));
toast.success("Sharing updated");
toast.success(t("Sharing updated"));
if (kind === "Mailbox") void useMail.getState().loadMailboxes();
if (kind === "Calendar") void useCalendar.getState().loadCalendars();
if (kind === "AddressBook") void useContacts.getState().loadBooks();
@@ -104,7 +104,7 @@ export function ShareDialog({ kind, id, name, shareWith, onClose }: { kind: Kind
};
return (
<Dialog open onClose={onClose} title={`Share “${name}`} size="lg" footer={<><button className="btn" onClick={onClose}>{t("Cancel")}</button><button className="btn btn-primary" disabled={busy} onClick={() => void save()}>{t("Save")}</button></>}>
<Dialog open onClose={onClose} title={t("Share “{name}”", { name })} size="lg" footer={<><button className="btn" onClick={onClose}>{t("Cancel")}</button><button className="btn btn-primary" disabled={busy} onClick={() => void save()}>{t("Save")}</button></>}>
{/* The list of who it is shared with is rendered whether or not anybody
can be *added*. It used to sit inside the branch below, so a server
with directory queries switched off -- which is the default, and which
+1 -1
View File
@@ -26,7 +26,7 @@ export function TemplatesSettings() {
))}
<button className="btn" onClick={() => setEditing({ id: `t${Date.now()}`, name: "", subject: "", html: "" })}><Plus size={16} /> {translate("New template")}</button>
{editing && (
<Dialog open onClose={() => setEditing(null)} title={templates.some((t) => t.id === editing.id) ? "Edit template" : "New template"} size="lg" footer={<><button className="btn" onClick={() => setEditing(null)}>{translate("Cancel")}</button><button className="btn btn-primary" disabled={!editing.name.trim()} onClick={() => { const exists = templates.some((t) => t.id === editing.id); update({ templates: exists ? templates.map((t) => (t.id === editing.id ? editing : t)) : [...templates, editing] }); setEditing(null); }}>{translate("Save")}</button></>}>
<Dialog open onClose={() => setEditing(null)} title={templates.some((t) => t.id === editing.id) ? translate("Edit template") : translate("New template")} size="lg" footer={<><button className="btn" onClick={() => setEditing(null)}>{translate("Cancel")}</button><button className="btn btn-primary" disabled={!editing.name.trim()} onClick={() => { const exists = templates.some((t) => t.id === editing.id); update({ templates: exists ? templates.map((t) => (t.id === editing.id ? editing : t)) : [...templates, editing] }); setEditing(null); }}>{translate("Save")}</button></>}>
<div className="field-row">
<div className="field"><label>{translate("Name")}</label><input className="input" value={editing.name} onChange={(e) => setEditing({ ...editing, name: e.target.value })} autoFocus /></div>
<div className="field"><label>{translate("Subject (optional)")}</label><input className="input" value={editing.subject} onChange={(e) => setEditing({ ...editing, subject: e.target.value })} /></div>