From 3f4b33cb51a3816a0dd211877a1ee9e8f27ea9a8 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 31 Aug 2026 10:41:24 -0700 Subject: [PATCH] Finish extraction: 100%, and a coverage number worth believing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 143 the codemod refused turned out to be two different things, and only one of them needed a person. A third were phrases sitting next to an icon -- ` New rule`. The refusal rule was "has siblings", which is broader than the danger: what breaks a translation is a sibling that renders *text*, splitting a sentence into fragments no one can reorder. An element beside a phrase does not. Narrowing the rule to text-producing siblings let the codemod take 73 more. The rest were real sentences with values in the middle, rebuilt by hand as named placeholders -- "Your active script “{name}” was written by hand", "Waiting on the server — goes out {when}." Named rather than positional because a translator moves the parts around; counted things go through plural() so Russian and Ukrainian get their three forms rather than English's two. Sentences with an element inside them needed something new. `Open mailto: links in ihasmail` has two obvious treatments and both are wrong: splitting it into two t() calls hands over fragments that cannot be reordered, and dropping the keeps the sentence whole but loses the monospace that said "this is a literal". tNode() keeps the sentence whole and makes the element a named hole in it, so a translator sees one sentence and can put the hole where their language wants it. The German test asserts exactly that: the same call renders the code first when the catalogue says so. The coverage number was also lying, and it is worth saying how. It counted text inside and inside translate="no" as untranslated work, and placeholders like "123456" and "+1 555 0100" -- a one-time code and a phone format. None of those will ever be translated, so the report sat at 21 with 6 real items left. A number with an unreachable floor is something to argue with rather than act on, so the tool now applies the same rules the codemod does. 596 wrapped, nothing remaining. Verified in the browser across 15 views, which is where the last bulk pass hid a bug the tests could not see: no entities, no unfilled placeholders, no raw t( in rendered text, and the toggle switches that looked like emptied labels are text-free by design. --- scripts/i18n-coverage.mjs | 19 +++++++++- scripts/i18n-extract.mjs | 17 ++++++++- .../__tests__/{i18n.test.ts => i18n.test.tsx} | 36 +++++++++++++++++- web/src/lib/i18n.ts | 38 ++++++++++++++++++- web/src/views/AppShell.tsx | 14 ++++--- web/src/views/SearchBar.tsx | 4 +- .../views/calendar/CalendarContextMenu.tsx | 2 +- web/src/views/calendar/CalendarDialog.tsx | 2 +- web/src/views/calendar/CalendarView.tsx | 4 +- web/src/views/calendar/EventEditor.tsx | 14 +++---- web/src/views/calendar/EventPopover.tsx | 6 +-- web/src/views/compose/Composer.tsx | 2 +- web/src/views/compose/FilePicker.tsx | 2 +- web/src/views/compose/RecipientPicker.tsx | 2 +- web/src/views/compose/SchedulePicker.tsx | 3 +- web/src/views/contacts/ContactEditor.tsx | 8 ++-- web/src/views/contacts/ContactsSidebar.tsx | 4 +- web/src/views/contacts/ContactsView.tsx | 10 ++--- web/src/views/files/FilesView.tsx | 4 +- web/src/views/mail/FilterFromMessage.tsx | 4 +- web/src/views/mail/InviteCard.tsx | 4 +- web/src/views/mail/LabelPicker.tsx | 2 +- web/src/views/mail/MailView.tsx | 3 +- web/src/views/mail/MailboxTree.tsx | 2 +- web/src/views/mail/MessageList.tsx | 4 +- web/src/views/mail/MessageView.tsx | 22 ++++++----- web/src/views/mail/ThreadView.tsx | 10 ++--- web/src/views/settings/AboutSettings.tsx | 15 ++++---- web/src/views/settings/AppearanceSettings.tsx | 5 ++- web/src/views/settings/CalendarSettings.tsx | 2 +- web/src/views/settings/FiltersSettings.tsx | 22 +++++------ web/src/views/settings/FoldersSettings.tsx | 4 +- web/src/views/settings/GeneralSettings.tsx | 23 ++++++----- web/src/views/settings/IdentitiesSettings.tsx | 10 ++--- web/src/views/settings/LabelsSettings.tsx | 4 +- web/src/views/settings/RuleDialog.tsx | 10 ++--- web/src/views/settings/SecuritySettings.tsx | 6 +-- web/src/views/settings/SettingsView.tsx | 2 +- web/src/views/settings/ShortcutsSettings.tsx | 2 +- web/src/views/settings/TemplatesSettings.tsx | 4 +- 40 files changed, 228 insertions(+), 123 deletions(-) rename web/src/lib/__tests__/{i18n.test.ts => i18n.test.tsx} (65%) diff --git a/scripts/i18n-coverage.mjs b/scripts/i18n-coverage.mjs index fc289f4..efd496d 100755 --- a/scripts/i18n-coverage.mjs +++ b/scripts/i18n-coverage.mjs @@ -20,6 +20,19 @@ const ATTRS = new Set(["title", "aria-label", "placeholder", "alt", "label", "hi as dividers. Counting these as untranslated would put a floor under the number that no amount of work could reach. */ const NOT_PROSE = /^[\s·—–\-—:;,.()[\]{}/|+×✓~<>#*@0-9]*$/u; +/* + * Text that is deliberately not translated is not "remaining work". Counting + * it put a floor under the number that no amount of effort could reach -- the + * report sat at 21 with only 6 real items left, which makes the number + * something to argue with rather than act on. Same rule the codemod uses. + */ +const CODE_TAGS = new Set(["code", "kbd", "pre", "samp", "var"]); +const optedOut = (node, src) => { + const opening = ts.isJsxElement(node) ? node.openingElement : ts.isJsxSelfClosingElement(node) ? node : null; + return Boolean(opening?.attributes.properties.some((a) => + ts.isJsxAttribute(a) && a.name.getText(src) === "translate" && + a.initializer && ts.isStringLiteral(a.initializer) && a.initializer.text === "no")); +}; const files = globSync("web/src/**/*.tsx").filter((f) => !f.includes("__tests__")); const rows = []; @@ -31,11 +44,15 @@ for (const file of files) { let left = 0; const wrapped = (text.match(/\bt\(\s*["'`]/g) || []).length + (text.match(/\bplural\(/g) || []).length; const visit = (node) => { + if ((ts.isJsxElement(node) && CODE_TAGS.has(node.openingElement.tagName.getText(src).toLowerCase())) || optedOut(node, src)) return; if (ts.isJsxText(node) && node.text.trim().length > 1 && !NOT_PROSE.test(node.text.trim())) left++; if (ts.isJsxAttribute(node) && ATTRS.has(node.name.getText(src))) { const i = node.initializer; const lit = i && (ts.isStringLiteral(i) ? i : ts.isJsxExpression(i) && i.expression && ts.isStringLiteral(i.expression) ? i.expression : null); - if (lit && lit.text.trim().length > 1) left++; + // The same prose test the text nodes get. Without it, placeholders that + // are format examples -- "123456" for a one-time code, "+1 555 0100" for + // a phone -- counted as untranslated work forever. + if (lit && lit.text.trim().length > 1 && !NOT_PROSE.test(lit.text.trim())) left++; } ts.forEachChild(node, visit); }; diff --git a/scripts/i18n-extract.mjs b/scripts/i18n-extract.mjs index 5985ff4..9960137 100644 --- a/scripts/i18n-extract.mjs +++ b/scripts/i18n-extract.mjs @@ -76,8 +76,21 @@ for (const file of files) { const raw = c.text; const body = raw.trim(); if (body.length < 2 || NOT_PROSE.test(body)) continue; - // Split around an interpolation: the fragments are not sentences. - if (meaningful.length > 1) { + /* + * "Split around an interpolation" is the dangerous case, and it is + * narrower than "has siblings". ` New rule` is a phrase next + * to an icon: wrapping it alone is correct, and refusing it left a + * third of the remaining work to be done by hand for no reason. + * `Your script “{name}” was written by hand` is the real thing -- + * a sibling that renders text, so the fragments are not sentences. + */ + const textSibling = kids.some((k) => k !== c && ts.isJsxExpression(k) && k.expression && !(() => { + let jsx = false; + const w = (n) => { if (ts.isJsxElement(n) || ts.isJsxSelfClosingElement(n) || ts.isJsxFragment(n)) { jsx = true; return; } ts.forEachChild(n, w); }; + w(k.expression); + return jsx; + })()); + if (textSibling) { const { line } = src.getLineAndCharacterOfPosition(c.getStart(src)); skipped.push({ file, line: line + 1, why: "text split around an expression", text: body.slice(0, 52) }); continue; diff --git a/web/src/lib/__tests__/i18n.test.ts b/web/src/lib/__tests__/i18n.test.tsx similarity index 65% rename from web/src/lib/__tests__/i18n.test.ts rename to web/src/lib/__tests__/i18n.test.tsx index 7a68b5d..24fe203 100644 --- a/web/src/lib/__tests__/i18n.test.ts +++ b/web/src/lib/__tests__/i18n.test.tsx @@ -1,8 +1,15 @@ import { afterEach, describe, expect, it } from "vitest"; -import { currentLanguage, interpolate, plural, setCatalog, t, type Catalog } from "@/lib/i18n"; +import { renderToStaticMarkup } from "react-dom/server"; +import { currentLanguage, interpolate, plural, setCatalog, t, tNode, type Catalog } from "@/lib/i18n"; const de: Catalog = { - strings: { "Archive": "Archivieren", "Move {n} to {folder}": "{n} nach {folder} verschieben" }, + strings: { + "Archive": "Archivieren", + "Move {n} to {folder}": "{n} nach {folder} verschieben", + // German puts the parts in a different order, which is the whole reason + // the element is a named hole rather than a split sentence. + "Open {scheme} links here": "{scheme}-Links hier öffnen", + }, plurals: { "{n} messages": { one: "{n} Nachricht", other: "{n} Nachrichten" } }, }; /* Russian is the reason plural() does not take (one, other): it needs three @@ -77,3 +84,28 @@ describe("plural", () => { .toBe("2 messages in Inbox"); }); }); + +describe("tNode", () => { + const render = (node: React.ReactNode) => renderToStaticMarkup(<>{node}); + + it("keeps an element inside the sentence", () => { + expect(render(tNode("Open {scheme} links here", { scheme: mailto: }))) + .toBe("Open mailto: links here"); + }); + + it("lets a translator move the element", () => { + // Splitting the sentence into two t() calls could not do this: the + // fragments would render in the English order whatever the catalogue said. + setCatalog("de", de); + expect(render(tNode("Open {scheme} links here", { scheme: mailto: }))) + .toBe("mailto:-Links hier öffnen"); + }); + + it("leaves a placeholder alone when nothing is supplied for it", () => { + expect(render(tNode("Open {scheme} links here", {}))).toBe("Open {scheme} links here"); + }); + + it("takes plain variables alongside elements", () => { + expect(render(tNode("{count} of {scheme}", { scheme: x }, { count: 3 }))).toBe("3 of x"); + }); +}); diff --git a/web/src/lib/i18n.ts b/web/src/lib/i18n.ts index c33a899..51af7b9 100644 --- a/web/src/lib/i18n.ts +++ b/web/src/lib/i18n.ts @@ -1,4 +1,4 @@ -import { useSyncExternalStore } from "react"; +import { createElement, Fragment, useSyncExternalStore, type ReactNode } from "react"; import { DEFAULT_UI_LANGUAGE, resolveUiLanguage } from "@/lib/languages"; /** @@ -83,6 +83,42 @@ export function plural(n: number, forms: PluralForms, vars?: Vars): string { return interpolate(entry[category] ?? entry.other, { n, ...vars }); } +/** + * A translated sentence with elements inside it. + * + * Some sentences have a `` or a `` in the middle of them, and the + * two obvious approaches are both wrong. Splitting the sentence into two `t()` + * calls hands a translator "This browser cannot register apps for" and "links, + * in particular…", which are not sentences and cannot be reordered into a + * language that puts the verb somewhere else. Dropping the element and + * interpolating plain text keeps the sentence whole but loses the monospace + * that told the reader it was a literal. + * + * So the sentence stays whole and the elements are placeholders in it: + * + * tNode("Open {scheme} links in ihasmail.", { scheme: mailto: }) + * + * A translator sees one sentence with a named hole and can put the hole + * wherever their language wants it. + */ +export function tNode(source: string, parts: Record, vars?: Vars): ReactNode { + const translated = interpolate(current.strings[source] ?? source, vars); + const out: ReactNode[] = []; + let last = 0; + const re = /\{(\w+)\}/g; + let m: RegExpExecArray | null; + while ((m = re.exec(translated))) { + if (!Object.prototype.hasOwnProperty.call(parts, m[1]!)) continue; + if (m.index > last) out.push(translated.slice(last, m.index)); + // Keyed, because this is an array and React asks; the index is stable for + // a given rendering of a given sentence. + out.push(createElement(Fragment, { key: `${m[1]}-${m.index}` }, parts[m[1]!])); + last = m.index + m[0].length; + } + if (last < translated.length) out.push(translated.slice(last)); + return out; +} + /** The language in force, for anything that needs the tag itself. */ export function currentLanguage(): string { return currentTag; diff --git a/web/src/views/AppShell.tsx b/web/src/views/AppShell.tsx index 42c1243..3a0eb7b 100644 --- a/web/src/views/AppShell.tsx +++ b/web/src/views/AppShell.tsx @@ -168,19 +168,23 @@ export function AppShell({ children }: { children: ReactNode }) { @@ -209,7 +213,7 @@ function QuotaBar() {
- {formatSize(q.used)} of {formatSize(q.hardLimit)} + {t("{used} of {total}", { used: formatSize(q.used), total: formatSize(q.hardLimit) })}
diff --git a/web/src/views/SearchBar.tsx b/web/src/views/SearchBar.tsx index 4440f62..ac77141 100644 --- a/web/src/views/SearchBar.tsx +++ b/web/src/views/SearchBar.tsx @@ -85,8 +85,8 @@ export function SearchBar() {
- - + +
diff --git a/web/src/views/calendar/CalendarContextMenu.tsx b/web/src/views/calendar/CalendarContextMenu.tsx index 356f38c..85ce741 100644 --- a/web/src/views/calendar/CalendarContextMenu.tsx +++ b/web/src/views/calendar/CalendarContextMenu.tsx @@ -116,7 +116,7 @@ export function CalendarContextMenu({ ctx, onClose, onOpen, onEdit, onCreate }: {canEdit && ( <> - Category + {t("Category")} {categories.map((c) => ( {c.name}} checked={currentCat?.name === c.name} onClick={() => { onClose(); setCategory(currentCat?.name === c.name ? null : c); }} /> ))} diff --git a/web/src/views/calendar/CalendarDialog.tsx b/web/src/views/calendar/CalendarDialog.tsx index f22c83e..5a7e4f1 100644 --- a/web/src/views/calendar/CalendarDialog.tsx +++ b/web/src/views/calendar/CalendarDialog.tsx @@ -37,7 +37,7 @@ export function CalendarDialog({ calendar, onClose }: { calendar: Partial setDescription(e.target.value)} />
diff --git a/web/src/views/calendar/CalendarView.tsx b/web/src/views/calendar/CalendarView.tsx index 914a580..d48a50e 100644 --- a/web/src/views/calendar/CalendarView.tsx +++ b/web/src/views/calendar/CalendarView.tsx @@ -130,7 +130,7 @@ export function CalendarView({ view: viewParam, date }: { view?: string; date?: ))}
- {!isMobile && } + {!isMobile && } {cal.error &&
{cal.error}
} {effectiveView === "month" && go("day", d)} onEvent={onEvent} onEventContext={onEventContext} onSlotContext={onSlotContext} onCreate={(d) => openNew(new Date(d.getTime() + 9 * 3600_000))} />} @@ -169,7 +169,7 @@ function MonthView({ anchor, weekStart, onDay, onEvent, onEventContext, onSlotCo
onCreate(d)} onDoubleClick={() => onDay(d)} onContextMenu={(e) => onSlotContext(new Date(d.getTime() + 9 * 3600_000), new Date(d.getTime() + 10 * 3600_000), false, e)}> { e.stopPropagation(); onDay(d); }}>{d.getDate() === 1 ? formatDayMonth(d) : d.getDate()} {shown.map((i) => onEvent(i, el)} onContext={(e) => onEventContext(i, e)} />)} - {evs.length > maxPer && { e.stopPropagation(); onDay(d); }}>+{evs.length - maxPer} more} + {evs.length > maxPer && { e.stopPropagation(); onDay(d); }}>{translate("+{n} more", { n: evs.length - maxPer })}}
); })} diff --git a/web/src/views/calendar/EventEditor.tsx b/web/src/views/calendar/EventEditor.tsx index 5288b28..387b316 100644 --- a/web/src/views/calendar/EventEditor.tsx +++ b/web/src/views/calendar/EventEditor.tsx @@ -285,7 +285,7 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle )}
- + {!allDay && ( { const p = e.target.value as RecurrencePreset; setPreset(p); if (p === "custom") setRule(rule ?? { "@type": "RecurrenceRule", frequency: "weekly", byDay: [{ "@type": "NDay", day: WEEKDAYS[(start.getDay() + 6) % 7]!.key }] }); else setRule(ruleFromPreset(p, start)); }}> - + - + @@ -342,7 +342,7 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle
setVurl(e.target.value)} placeholder={translate("https://meet.example.com/…")} />
- +
@@ -351,7 +351,7 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle {Object.keys(fb).length > 0 && (
-
Availability on {formatNumericDate(start)}
+
{translate("Availability on {date}", { date: formatNumericDate(start) })}
{attendees.filter((a) => fb[a.email]).map((a) => (
{a.name ?? a.email} @@ -383,7 +383,7 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle
))} - +
@@ -400,7 +400,7 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle {categories.map((c) => )} -
{color && }
+
{color && }
)} diff --git a/web/src/views/calendar/EventPopover.tsx b/web/src/views/calendar/EventPopover.tsx index 970ad3f..9d23d05 100644 --- a/web/src/views/calendar/EventPopover.tsx +++ b/web/src/views/calendar/EventPopover.tsx @@ -99,9 +99,9 @@ export function EventPopover({ inst, anchor, onClose, onEdit }: { inst: EventIns {myKeys.length > 0 && !isOrganizer && (
{t("Going?")} - - - + + +
)} diff --git a/web/src/views/compose/Composer.tsx b/web/src/views/compose/Composer.tsx index 3c777fa..0aa84b9 100644 --- a/web/src/views/compose/Composer.tsx +++ b/web/src/views/compose/Composer.tsx @@ -246,7 +246,7 @@ export function Composer({ draft }: { draft: Draft }) {
diff --git a/web/src/views/compose/FilePicker.tsx b/web/src/views/compose/FilePicker.tsx index 2a754a1..663ce7f 100644 --- a/web/src/views/compose/FilePicker.tsx +++ b/web/src/views/compose/FilePicker.tsx @@ -73,7 +73,7 @@ export function FilePicker({ onPick, onClose }: { onPick: (files: AttachableFile {files.sharedAccounts.length > 0 && (
{files.sharedAccounts.map((a) => (
{!ownBooks.length && !subscribed.length && ( -

No address books yet.

+

{t("No address books yet.")}

)} ); diff --git a/web/src/views/compose/SchedulePicker.tsx b/web/src/views/compose/SchedulePicker.tsx index 6a7f1f4..8f88f6d 100644 --- a/web/src/views/compose/SchedulePicker.tsx +++ b/web/src/views/compose/SchedulePicker.tsx @@ -58,8 +58,7 @@ export function ScheduleDialog({ open, maxMs, initial, onClose, onPick }: {

{error}

) : (

- The message waits on the server, so it goes out whether or not ihasmail is open. - {maxMs > 0 && ` This server holds a message for up to ${describeSpan(maxMs)}.`} + {`${t("The message waits on the server, so it goes out whether or not ihasmail is open.")}${maxMs > 0 ? ` ${t("This server holds a message for up to {span}.", { span: describeSpan(maxMs) })}` : ""}`}

)} diff --git a/web/src/views/contacts/ContactEditor.tsx b/web/src/views/contacts/ContactEditor.tsx index 90c621a..43e0080 100644 --- a/web/src/views/contacts/ContactEditor.tsx +++ b/web/src/views/contacts/ContactEditor.tsx @@ -163,7 +163,7 @@ export function ContactEditor({ card, defaultBookId, onClose, onSaved }: Props) {photoSrc ? : } { const f = e.target.files?.[0]; if (f) onPhoto(f); e.target.value = ""; }} /> - {photoSrc && } + {photoSrc && }
@@ -234,7 +234,7 @@ export function ContactEditor({ card, defaultBookId, onClose, onSaved }: Props)
))} - +
@@ -247,7 +247,7 @@ export function ContactEditor({ card, defaultBookId, onClose, onSaved }: Props)
))} - +
@@ -269,7 +269,7 @@ export function ContactEditor({ card, defaultBookId, onClose, onSaved }: Props)
))} - +
diff --git a/web/src/views/contacts/ContactsSidebar.tsx b/web/src/views/contacts/ContactsSidebar.tsx index fd9c3ca..f922962 100644 --- a/web/src/views/contacts/ContactsSidebar.tsx +++ b/web/src/views/contacts/ContactsSidebar.tsx @@ -170,10 +170,10 @@ export function ContactsSidebar() { {/* Import and export lived in the pane this replaced. */}
- +
diff --git a/web/src/views/contacts/ContactsView.tsx b/web/src/views/contacts/ContactsView.tsx index 377fb64..62731b9 100644 --- a/web/src/views/contacts/ContactsView.tsx +++ b/web/src/views/contacts/ContactsView.tsx @@ -166,8 +166,8 @@ function ContactDetail({ card: c, onBack, onEdit, narrow, onEmail }: { card: Con
{narrow && } - - + +
@@ -223,13 +223,13 @@ function ContactDetail({ card: c, onBack, onEdit, narrow, onEmail }: { card: Con
)} {c.kind === "group" && ( -

Members ({Object.keys(c.members ?? {}).length})

+

{translate("Members ({count})", { count: Object.keys(c.members ?? {}).length })}

{members.map((m) => )} - {members.length > 0 && } + {members.length > 0 && }
)} {c.keywords && Object.keys(c.keywords).length > 0 &&
{Object.keys(c.keywords).map((k) => {k})}
} - {c.updated &&

Updated {formatDate(new Date(c.updated))}

} + {c.updated &&

{translate("Updated {date}", { date: formatDate(new Date(c.updated)) })}

}
); } diff --git a/web/src/views/files/FilesView.tsx b/web/src/views/files/FilesView.tsx index 45bc36a..8a1273a 100644 --- a/web/src/views/files/FilesView.tsx +++ b/web/src/views/files/FilesView.tsx @@ -120,9 +120,9 @@ export function FilesView({ nodeId }: { nodeId?: string }) { ))}
- + { const l = Array.from(e.target.files ?? []); if (l.length) void files.upload(parentId, l); e.target.value = ""; }} /> - + {files.uploads.length > 0 && (
diff --git a/web/src/views/mail/FilterFromMessage.tsx b/web/src/views/mail/FilterFromMessage.tsx index df654ac..de92438 100644 --- a/web/src/views/mail/FilterFromMessage.tsx +++ b/web/src/views/mail/FilterFromMessage.tsx @@ -45,9 +45,9 @@ export function FilterFromMessageDialog({ email, mailboxId, onClose }: { email: looking for a problem they do not have. */} {damage ? ( -

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

+

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

) : loaded ? ( -

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

+

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

) : (

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

)} diff --git a/web/src/views/mail/InviteCard.tsx b/web/src/views/mail/InviteCard.tsx index b49bdc9..16c3ebc 100644 --- a/web/src/views/mail/InviteCard.tsx +++ b/web/src/views/mail/InviteCard.tsx @@ -91,7 +91,7 @@ export function InviteCard({ email, part }: { email: Email; part: EmailBodyPart

{ev.title || "(untitled event)"}

{inst &&
{`${formatTimeRange(inst.start, inst.end, inst.allDay)}${ev.timeZone ? ` (${ev.timeZone})` : ""}`}
} {location &&
{location}
} - {organizer &&
Organizer: {organizer.name || participantEmail(organizer)}
} + {organizer &&
{t("Organizer: {name}", { name: organizer.name || participantEmail(organizer) })}
} {attendees.length > 0 &&
{`${attendees.length} attendee${attendees.length === 1 ? "" : "s"}`}
} {method === "REPLY" && (
@@ -109,7 +109,7 @@ export function InviteCard({ email, part }: { email: Email; part: EmailBodyPart ) : ( - !existing && + !existing && )} {existing && inst && }
diff --git a/web/src/views/mail/LabelPicker.tsx b/web/src/views/mail/LabelPicker.tsx index a738686..af23d74 100644 --- a/web/src/views/mail/LabelPicker.tsx +++ b/web/src/views/mail/LabelPicker.tsx @@ -75,7 +75,7 @@ export function LabelPicker({ ids, anchor, onClose, onApplied }: { ids: Id[]; an {q.trim() && !labels.some((l) => l.name.toLowerCase() === q.trim().toLowerCase()) && ( )} {!labels.length && !q &&
{t("Type a name to create your first label.")}
} diff --git a/web/src/views/mail/MailView.tsx b/web/src/views/mail/MailView.tsx index 62a43de..d00d75f 100644 --- a/web/src/views/mail/MailView.tsx +++ b/web/src/views/mail/MailView.tsx @@ -16,6 +16,7 @@ import { confirmDialog } from "@/ui/dialog"; import { toast } from "@/ui/toast"; import { isUnknownMailbox } from "@/lib/mailboxRoute"; import { scheduledMailboxIdFrom, useScheduled } from "@/store/scheduled"; +import { t as translate } from "@/lib/i18n"; export function MailView({ mailboxId, threadId, search }: { mailboxId?: string; threadId?: string; search?: boolean }) { const [, navigate] = useLocation(); @@ -353,7 +354,7 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string;
{list?.total ? `${list.total} conversation${list.total === 1 ? "" : "s"}` : "No conversation selected"}
-
Select a conversation to read it here · Press ? for shortcuts
+
{translate("Select a conversation to read it here · Press")} ? {translate("for shortcuts")}
)}
diff --git a/web/src/views/mail/MailboxTree.tsx b/web/src/views/mail/MailboxTree.tsx index 9c38b18..a0edbd2 100644 --- a/web/src/views/mail/MailboxTree.tsx +++ b/web/src/views/mail/MailboxTree.tsx @@ -384,7 +384,7 @@ function MailboxMenu({ mailbox: m, onClose, onCreateChild, onShare }: { mailbox: reason this entry survives at all. */} {shared && } label={t("Stop sharing")} onClick={onShare} />} - Colour + {t("Colour")}
{CALENDAR_COLORS.map((c) => ( diff --git a/web/src/views/mail/MessageView.tsx b/web/src/views/mail/MessageView.tsx index cbe52bf..511d91a 100644 --- a/web/src/views/mail/MessageView.tsx +++ b/web/src/views/mail/MessageView.tsx @@ -150,13 +150,14 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
from && addrMenu.open(ev, from)}> {displayName(from)} - {expanded && from && <{from.email}>} + {/* An address, not a sentence. */} + {expanded && from && <{from.email}>} {isHighPriority && {translate("Important")}} - {authFailed && Unverified} + {authFailed && {translate("Unverified")}}
{expanded ? (
- to {summarizeRecipients(e)} + {translate("to {recipients}", { recipients: summarizeRecipients(e) })} @@ -222,9 +223,10 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
- The sender asked for a read receipt. + + {translate("The sender asked for a read receipt.")} {receipt.redirected && ( - <> It would go to {receipt.to!.email}, which is not where the message came from. + <> {translate("It would go to")} {receipt.to!.email}{translate(", which is not where the message came from.")} )} - {from && } + {from && }
)} {icsPart && } @@ -543,15 +545,15 @@ function AttachmentList({ attachments, accountId, email }: { attachments: EmailB })} {attachments.length > 1 && ( )}
- setPreview(null)} title={preview?.name ?? "Preview"} size="xl" footer={preview && Download}> + setPreview(null)} title={preview?.name ?? "Preview"} size="xl" footer={preview && {translate("Download")}}> {preview?.type.startsWith("image/") && {preview.name} {preview?.type === "application/pdf" &&