Merge pull request #349 from Coffey-Labs/i18n/template-literal-attributes
Translate English built from template literals in attributes
This commit is contained in:
+1
-1
@@ -62,7 +62,7 @@ works the same way — and dropped where 0.15 was the whole subject. Support for
|
|||||||
|
|
||||||
- **All nine translations have never been read by anybody who speaks them.** They were produced by AI against standard dictionaries on 2026-08-31 — German, Spanish, French, Dutch, Portuguese (Brazil), Russian, Ukrainian, Simplified Chinese and Japanese, which with English makes ten languages in the picker — and every one of the nine is marked **Beta** in the picker, with that stated in Settings beside a link for reporting anything that reads wrongly. This is the entry that matters most on this page, because it is the one thing here that cannot be closed by testing: a translation can be complete, consistent, pass every check, and still read like a machine wrote it, and nobody on this project can tell which. What *is* verified is the machinery around them. A missing key renders its English source, so a bad line can simply be deleted; a stale key — one whose English no longer exists — is caught by `npm run i18n:check` rather than sitting in the file looking correct and never being looked up. Plurals are asked of `Intl.PluralRules` rather than assumed, which is why Russian and Ukrainian carry three forms and Japanese and Chinese carry one; supplying `one` for Japanese would have been filling in a distinction the language does not draw. Confirmed live on the deployed instance (2026-08-31) against a 6,289-message mailbox: role folders localise and the ~20 custom folders keep the names their owner gave them, dates and the calendar follow the language, and 6,289 renders as *6289 листувань* — the genitive plural a number ending in nine takes, which is the first time the plural machinery ran on anything but a hand-picked value.
|
- **All nine translations have never been read by anybody who speaks them.** They were produced by AI against standard dictionaries on 2026-08-31 — German, Spanish, French, Dutch, Portuguese (Brazil), Russian, Ukrainian, Simplified Chinese and Japanese, which with English makes ten languages in the picker — and every one of the nine is marked **Beta** in the picker, with that stated in Settings beside a link for reporting anything that reads wrongly. This is the entry that matters most on this page, because it is the one thing here that cannot be closed by testing: a translation can be complete, consistent, pass every check, and still read like a machine wrote it, and nobody on this project can tell which. What *is* verified is the machinery around them. A missing key renders its English source, so a bad line can simply be deleted; a stale key — one whose English no longer exists — is caught by `npm run i18n:check` rather than sitting in the file looking correct and never being looked up. Plurals are asked of `Intl.PluralRules` rather than assumed, which is why Russian and Ukrainian carry three forms and Japanese and Chinese carry one; supplying `one` for Japanese would have been filling in a distinction the language does not draw. Confirmed live on the deployed instance (2026-08-31) against a 6,289-message mailbox: role folders localise and the ~20 custom folders keep the names their owner gave them, dates and the calendar follow the language, and 6,289 renders as *6289 листувань* — the genitive plural a number ending in nine takes, which is the first time the plural machinery ran on anything but a hand-picked value.
|
||||||
|
|
||||||
- **`npm run i18n:coverage` reported 100% while about two hundred strings rendered English in every language.** It reads JSX text, and it was not wrong about what it measured — none of them were JSX text. They were `toast.error(...)` arguments, `confirmDialog({ title, confirmLabel })` props, `title=` and `aria-label=` attributes, and template literals: every one built from an expression a codemod cannot read. The calendar's own view switcher was the clearest case, spelling its labels `v[0].toUpperCase() + v.slice(1)` — correct English, untranslatable anywhere else, and galling because **Day**, **Week**, **Month** and **Agenda** were already in all nine catalogues and the buttons simply never asked for them. Reported from production, where the switcher stayed English in a Japanese interface. All of them are now wrapped, and `npm run i18n:check` grew a second half (`scripts/i18n-literals.mjs`) that accepts a string wrapped where it is written *or* present as a catalogue key — the constant-table convention, where `SECTIONS` holds `label: "About"` and the render site calls `t(s.label)` — and refuses one that is neither, because that is a string no catalogue can translate however many languages ship. It found twenty more than a hand sweep had. Worth recording as a general lesson rather than an i18n one: a coverage number measures the thing it can see, and the strings it cannot see are exactly the ones nobody is checking. **The check had the same blind spot one level down (2026-09-14).** It looked at `title=`, `aria-label=`, `placeholder=` and `alt=` on elements, but not at props passed to components, so `<MenuItem label={x ? "Collapse all" : "Expand all"}>` passed. It also accepted a JSX literal that was a catalogue key, although no component here runs its props through `t()`, so 19 strings with translations in every catalogue (Report spam, Mark as read, Add star, Save…) still rendered in English. And the script only exited non-zero with `--check`, which `npm run i18n:check` never passed, so it could print a finding without failing. Component props are checked now, a key no longer excuses a literal in an attribute, and both halves run with `--check`. That turned up 28 strings, all fixed: 19 wrapped, and 9 that needed new keys in all nine catalogues. Still not covered: English built with a template literal inside an attribute, such as ``aria-label={`Remove ${email}`}``. Such a string can't be a catalogue key as written, and a handful remain in the calendar and contacts sidebars, the recipient chips, the address menu, attachment titles and the contact editor's "Edit …" title.
|
- **`npm run i18n:coverage` reported 100% while about two hundred strings rendered English in every language.** It reads JSX text, and it was not wrong about what it measured — none of them were JSX text. They were `toast.error(...)` arguments, `confirmDialog({ title, confirmLabel })` props, `title=` and `aria-label=` attributes, and template literals: every one built from an expression a codemod cannot read. The calendar's own view switcher was the clearest case, spelling its labels `v[0].toUpperCase() + v.slice(1)` — correct English, untranslatable anywhere else, and galling because **Day**, **Week**, **Month** and **Agenda** were already in all nine catalogues and the buttons simply never asked for them. Reported from production, where the switcher stayed English in a Japanese interface. All of them are now wrapped, and `npm run i18n:check` grew a second half (`scripts/i18n-literals.mjs`) that accepts a string wrapped where it is written *or* present as a catalogue key — the constant-table convention, where `SECTIONS` holds `label: "About"` and the render site calls `t(s.label)` — and refuses one that is neither, because that is a string no catalogue can translate however many languages ship. It found twenty more than a hand sweep had. Worth recording as a general lesson rather than an i18n one: a coverage number measures the thing it can see, and the strings it cannot see are exactly the ones nobody is checking. **The check had the same blind spot one level down (2026-09-14).** It looked at `title=`, `aria-label=`, `placeholder=` and `alt=` on elements, but not at props passed to components, so `<MenuItem label={x ? "Collapse all" : "Expand all"}>` passed. It also accepted a JSX literal that was a catalogue key, although no component here runs its props through `t()`, so 19 strings with translations in every catalogue (Report spam, Mark as read, Add star, Save…) still rendered in English. And the script only exited non-zero with `--check`, which `npm run i18n:check` never passed, so it could print a finding without failing. Component props are checked now, a key no longer excuses a literal in an attribute, and both halves run with `--check`. That turned up 28 strings, all fixed: 19 wrapped, and 9 that needed new keys in all nine catalogues. English built with a template literal inside an attribute, such as ``aria-label={`Remove ${email}`}``, was the last gap. It can't be a catalogue key as written. Since 2026-09-14 the check flags any template literal in one of these positions that has words between its values, and the twelve that existed are now keys with placeholders. They were the quota bar, the address menu, a folder's unread count, the recipient chips, the contact editor's title, shared calendars and address books, the date and time fields, the attachment fallback name, and the free/busy bar. That bar showed the raw JMAP value (`confirmed`) in every language.
|
||||||
|
|
||||||
- **A compressing hop in front of Stalwart truncated every blob download, and nothing said so.** Node decompresses a gzip response before the code ever sees the body, but leaves the `content-length` header describing the *compressed* bytes. The blob proxy copied that header onto the longer body it forwarded, so the browser stopped reading exactly that many bytes in and called the download complete. Reported on [#76](https://github.com/Coffey-Labs/ihasmail/issues/76) against a Coolify deployment, where Traefik's compress middleware only engages above 1 KiB: filter rules one and two were fine and the third pushed the script past the threshold, after which it came back cut off mid-rule — 384 bytes of a 1.3 KB script. The size threshold is what made it look like a race. This is the *second* cause behind that issue, and the first fix did not touch it: a truncated script is neither unknown nor empty, so the "refuse to save from a baseline we could not read" guard never fired — the script parsed, just with rules missing, and the next save wrote the short version back over the real one. Every blob download shared the fault, not just Sieve: message source, vCards, signature HTML, attachments being forwarded, and the `settings.json` sync. Settings degraded honestly by luck rather than design — a truncated file fails `JSON.parse`, which is caught and leaves the local cache in charge — so it stopped syncing between devices instead of being overwritten. The proxy now asks upstream for `identity` and, for a hop that compresses anyway, forwards no length at all rather than one describing different bytes. The image proxy is unaffected: it uses `node:http` directly, sends no `accept-encoding`, and never decompresses. The save path no longer trusts the transport either: a script is now checked for completeness against the shape the generator emits — every `# rule:` comment parses, every enabled rule has an `if` and a closed body below it, every block ends with a blank line — and saving refuses on anything short, as does the rule editor, which reports the script as unreadable rather than showing the rules that happened to parse. The check is structural rather than a re-serialize-and-compare, so a script written by an older version with a different serializer is still editable; refusing over a changed byte would be the worse bug. It catches a cut at every offset except the end of a complete rule block, which is a legitimately shorter script and indistinguishable from one in the bytes alone — that residual is what the proxy fix covers.
|
- **A compressing hop in front of Stalwart truncated every blob download, and nothing said so.** Node decompresses a gzip response before the code ever sees the body, but leaves the `content-length` header describing the *compressed* bytes. The blob proxy copied that header onto the longer body it forwarded, so the browser stopped reading exactly that many bytes in and called the download complete. Reported on [#76](https://github.com/Coffey-Labs/ihasmail/issues/76) against a Coolify deployment, where Traefik's compress middleware only engages above 1 KiB: filter rules one and two were fine and the third pushed the script past the threshold, after which it came back cut off mid-rule — 384 bytes of a 1.3 KB script. The size threshold is what made it look like a race. This is the *second* cause behind that issue, and the first fix did not touch it: a truncated script is neither unknown nor empty, so the "refuse to save from a baseline we could not read" guard never fired — the script parsed, just with rules missing, and the next save wrote the short version back over the real one. Every blob download shared the fault, not just Sieve: message source, vCards, signature HTML, attachments being forwarded, and the `settings.json` sync. Settings degraded honestly by luck rather than design — a truncated file fails `JSON.parse`, which is caught and leaves the local cache in charge — so it stopped syncing between devices instead of being overwritten. The proxy now asks upstream for `identity` and, for a hop that compresses anyway, forwards no length at all rather than one describing different bytes. The image proxy is unaffected: it uses `node:http` directly, sends no `accept-encoding`, and never decompresses. The save path no longer trusts the transport either: a script is now checked for completeness against the shape the generator emits — every `# rule:` comment parses, every enabled rule has an `if` and a closed body below it, every block ends with a blank line — and saving refuses on anything short, as does the rule editor, which reports the script as unreadable rather than showing the rules that happened to parse. The check is structural rather than a re-serialize-and-compare, so a script written by an older version with a different serializer is still editable; refusing over a changed byte would be the worse bug. It catches a cut at every offset except the end of a complete rule block, which is a legitimately shorter script and indistinguishable from one in the bytes alone — that residual is what the proxy fix covers.
|
||||||
|
|
||||||
|
|||||||
@@ -116,13 +116,32 @@ for (const file of globSync("web/src/**/*.{ts,tsx}").filter((f) => !f.includes("
|
|||||||
mark(src);
|
mark(src);
|
||||||
const wrapped = exempt;
|
const wrapped = exempt;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* English assembled around values: `aria-label={`Remove ${email}`}`.
|
||||||
|
*
|
||||||
|
* The literal cannot be a catalogue key as written, so whether it is a key is
|
||||||
|
* not asked. Neither is looksLikeUi, which reads the opening of a sentence:
|
||||||
|
* `${name} — shared by ${owner}` opens with a value and its words come after.
|
||||||
|
* Any run of letters between the values counts. The only template literals
|
||||||
|
* that reach a UI attribute and are not prose are pure punctuation around
|
||||||
|
* values, like `${name} (${size})`, and those have none.
|
||||||
|
*/
|
||||||
|
const reportTemplate = (x) => {
|
||||||
|
const parts = ts.isNoSubstitutionTemplateLiteral(x) ? [x.text] : [x.head.text, ...x.templateSpans.map((s) => s.literal.text)];
|
||||||
|
if (!/[A-Za-z]{2,}/.test(parts.join(""))) return;
|
||||||
|
const { line } = src.getLineAndCharacterOfPosition(x.getStart(src));
|
||||||
|
found.push({ file, line: line + 1, text: parts.join("{}") });
|
||||||
|
};
|
||||||
|
const isTemplate = (x) => ts.isTemplateExpression(x) || ts.isNoSubstitutionTemplateLiteral(x);
|
||||||
|
|
||||||
const visit = (n) => {
|
const visit = (n) => {
|
||||||
if (ts.isPropertyAssignment(n) && ts.isStringLiteral(n.initializer) && !wrapped.has(n.initializer)
|
if (ts.isPropertyAssignment(n) && UI_PROPS.has(n.name.getText(src).replace(/['"]/g, ""))) {
|
||||||
&& UI_PROPS.has(n.name.getText(src).replace(/['"]/g, ""))) {
|
if (ts.isStringLiteral(n.initializer) && !wrapped.has(n.initializer)) report(n.initializer, n.initializer.text);
|
||||||
report(n.initializer, n.initializer.text);
|
if (isTemplate(n.initializer)) reportTemplate(n.initializer);
|
||||||
}
|
}
|
||||||
if (ts.isJsxAttribute(n) && n.initializer && UI_ATTRS.has(n.name.getText(src))) {
|
if (ts.isJsxAttribute(n) && n.initializer && UI_ATTRS.has(n.name.getText(src))) {
|
||||||
const walk = (x) => {
|
const walk = (x) => {
|
||||||
|
if (isTemplate(x)) reportTemplate(x);
|
||||||
if (ts.isStringLiteral(x) && !wrapped.has(x)) report(x, x.text, true);
|
if (ts.isStringLiteral(x) && !wrapped.has(x)) report(x, x.text, true);
|
||||||
if (!ts.isCallExpression(x)) ts.forEachChild(x, walk);
|
if (!ts.isCallExpression(x)) ts.forEachChild(x, walk);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1223,6 +1223,17 @@ export const catalog: Catalog = {
|
|||||||
"Send now instead": "Stattdessen jetzt senden",
|
"Send now instead": "Stattdessen jetzt senden",
|
||||||
"Switch to plain text": "Zu Nur-Text wechseln",
|
"Switch to plain text": "Zu Nur-Text wechseln",
|
||||||
"Switch to rich text": "Zu formatiertem Text wechseln",
|
"Switch to rich text": "Zu formatiertem Text wechseln",
|
||||||
|
"{used} of {total} used": "{used} von {total} belegt",
|
||||||
|
"Actions for {address}": "Aktionen für {address}",
|
||||||
|
"{here} here, {inSubfolders} in subfolders": "{here} hier, {inSubfolders} in Unterordnern",
|
||||||
|
"Edit {name}": "{name} bearbeiten",
|
||||||
|
"{name} — shared by {owner}": "{name} — freigegeben von {owner}",
|
||||||
|
"{name} — from {owner}": "{name} — von {owner}",
|
||||||
|
"{label} (date)": "{label} (Datum)",
|
||||||
|
"{label} (time)": "{label} (Uhrzeit)",
|
||||||
|
"{status}: {start} – {end}": "{status}: {start} – {end}",
|
||||||
|
"Unavailable": "Nicht verfügbar",
|
||||||
|
"Attachment": "Anhang",
|
||||||
"QR code": "QR-Code",
|
"QR code": "QR-Code",
|
||||||
"Draft discarded": "Entwurf verworfen",
|
"Draft discarded": "Entwurf verworfen",
|
||||||
"Draft saved": "Entwurf gespeichert",
|
"Draft saved": "Entwurf gespeichert",
|
||||||
|
|||||||
@@ -1196,6 +1196,17 @@ export const catalog: Catalog = {
|
|||||||
"Send now instead": "Enviar ahora, sin programar",
|
"Send now instead": "Enviar ahora, sin programar",
|
||||||
"Switch to plain text": "Cambiar a texto sin formato",
|
"Switch to plain text": "Cambiar a texto sin formato",
|
||||||
"Switch to rich text": "Cambiar a texto enriquecido",
|
"Switch to rich text": "Cambiar a texto enriquecido",
|
||||||
|
"{used} of {total} used": "{used} de {total} usados",
|
||||||
|
"Actions for {address}": "Acciones para {address}",
|
||||||
|
"{here} here, {inSubfolders} in subfolders": "{here} aquí, {inSubfolders} en subcarpetas",
|
||||||
|
"Edit {name}": "Editar {name}",
|
||||||
|
"{name} — shared by {owner}": "{name} — compartido por {owner}",
|
||||||
|
"{name} — from {owner}": "{name} — de {owner}",
|
||||||
|
"{label} (date)": "{label} (fecha)",
|
||||||
|
"{label} (time)": "{label} (hora)",
|
||||||
|
"{status}: {start} – {end}": "{status}: {start} – {end}",
|
||||||
|
"Unavailable": "No disponible",
|
||||||
|
"Attachment": "Archivo adjunto",
|
||||||
"QR code": "Código QR",
|
"QR code": "Código QR",
|
||||||
"Draft discarded": "Borrador descartado",
|
"Draft discarded": "Borrador descartado",
|
||||||
"Draft saved": "Borrador guardado",
|
"Draft saved": "Borrador guardado",
|
||||||
|
|||||||
@@ -1201,6 +1201,17 @@ export const catalog: Catalog = {
|
|||||||
"Send now instead": "Envoyer tout de suite",
|
"Send now instead": "Envoyer tout de suite",
|
||||||
"Switch to plain text": "Passer en texte brut",
|
"Switch to plain text": "Passer en texte brut",
|
||||||
"Switch to rich text": "Passer en texte enrichi",
|
"Switch to rich text": "Passer en texte enrichi",
|
||||||
|
"{used} of {total} used": "{used} sur {total} utilisés",
|
||||||
|
"Actions for {address}": "Actions pour {address}",
|
||||||
|
"{here} here, {inSubfolders} in subfolders": "{here} ici, {inSubfolders} dans les sous-dossiers",
|
||||||
|
"Edit {name}": "Modifier {name}",
|
||||||
|
"{name} — shared by {owner}": "{name} — partagé par {owner}",
|
||||||
|
"{name} — from {owner}": "{name} — de {owner}",
|
||||||
|
"{label} (date)": "{label} (date)",
|
||||||
|
"{label} (time)": "{label} (heure)",
|
||||||
|
"{status}: {start} – {end}": "{status} : {start} – {end}",
|
||||||
|
"Unavailable": "Indisponible",
|
||||||
|
"Attachment": "Pièce jointe",
|
||||||
"QR code": "QR code",
|
"QR code": "QR code",
|
||||||
"Draft discarded": "Brouillon abandonné",
|
"Draft discarded": "Brouillon abandonné",
|
||||||
"Draft saved": "Brouillon enregistré",
|
"Draft saved": "Brouillon enregistré",
|
||||||
|
|||||||
@@ -1204,6 +1204,17 @@ export const catalog: Catalog = {
|
|||||||
"Send now instead": "予約をやめて今すぐ送信",
|
"Send now instead": "予約をやめて今すぐ送信",
|
||||||
"Switch to plain text": "プレーンテキストに切り替え",
|
"Switch to plain text": "プレーンテキストに切り替え",
|
||||||
"Switch to rich text": "リッチテキストに切り替え",
|
"Switch to rich text": "リッチテキストに切り替え",
|
||||||
|
"{used} of {total} used": "{total} 中 {used} を使用",
|
||||||
|
"Actions for {address}": "{address} の操作",
|
||||||
|
"{here} here, {inSubfolders} in subfolders": "このフォルダー {here}、サブフォルダー {inSubfolders}",
|
||||||
|
"Edit {name}": "{name} を編集",
|
||||||
|
"{name} — shared by {owner}": "{name} — {owner} が共有",
|
||||||
|
"{name} — from {owner}": "{name} — {owner} から",
|
||||||
|
"{label} (date)": "{label}(日付)",
|
||||||
|
"{label} (time)": "{label}(時刻)",
|
||||||
|
"{status}: {start} – {end}": "{status}:{start}~{end}",
|
||||||
|
"Unavailable": "不在",
|
||||||
|
"Attachment": "添付ファイル",
|
||||||
"QR code": "QR コード",
|
"QR code": "QR コード",
|
||||||
"Draft discarded": "下書きを破棄しました",
|
"Draft discarded": "下書きを破棄しました",
|
||||||
"Draft saved": "下書きを保存しました",
|
"Draft saved": "下書きを保存しました",
|
||||||
|
|||||||
@@ -1192,6 +1192,17 @@ export const catalog: Catalog = {
|
|||||||
"Send now instead": "Toch nu verzenden",
|
"Send now instead": "Toch nu verzenden",
|
||||||
"Switch to plain text": "Overschakelen naar platte tekst",
|
"Switch to plain text": "Overschakelen naar platte tekst",
|
||||||
"Switch to rich text": "Overschakelen naar opgemaakte tekst",
|
"Switch to rich text": "Overschakelen naar opgemaakte tekst",
|
||||||
|
"{used} of {total} used": "{used} van {total} gebruikt",
|
||||||
|
"Actions for {address}": "Acties voor {address}",
|
||||||
|
"{here} here, {inSubfolders} in subfolders": "{here} hier, {inSubfolders} in submappen",
|
||||||
|
"Edit {name}": "{name} bewerken",
|
||||||
|
"{name} — shared by {owner}": "{name} — gedeeld door {owner}",
|
||||||
|
"{name} — from {owner}": "{name} — van {owner}",
|
||||||
|
"{label} (date)": "{label} (datum)",
|
||||||
|
"{label} (time)": "{label} (tijd)",
|
||||||
|
"{status}: {start} – {end}": "{status}: {start} – {end}",
|
||||||
|
"Unavailable": "Niet beschikbaar",
|
||||||
|
"Attachment": "Bijlage",
|
||||||
"QR code": "QR-code",
|
"QR code": "QR-code",
|
||||||
"Draft discarded": "Concept weggegooid",
|
"Draft discarded": "Concept weggegooid",
|
||||||
"Draft saved": "Concept opgeslagen",
|
"Draft saved": "Concept opgeslagen",
|
||||||
|
|||||||
@@ -1199,6 +1199,17 @@ export const catalog: Catalog = {
|
|||||||
"Send now instead": "Enviar agora mesmo",
|
"Send now instead": "Enviar agora mesmo",
|
||||||
"Switch to plain text": "Mudar para texto simples",
|
"Switch to plain text": "Mudar para texto simples",
|
||||||
"Switch to rich text": "Mudar para texto formatado",
|
"Switch to rich text": "Mudar para texto formatado",
|
||||||
|
"{used} of {total} used": "{used} de {total} usados",
|
||||||
|
"Actions for {address}": "Ações para {address}",
|
||||||
|
"{here} here, {inSubfolders} in subfolders": "{here} aqui, {inSubfolders} em subpastas",
|
||||||
|
"Edit {name}": "Editar {name}",
|
||||||
|
"{name} — shared by {owner}": "{name} — compartilhado por {owner}",
|
||||||
|
"{name} — from {owner}": "{name} — de {owner}",
|
||||||
|
"{label} (date)": "{label} (data)",
|
||||||
|
"{label} (time)": "{label} (hora)",
|
||||||
|
"{status}: {start} – {end}": "{status}: {start} – {end}",
|
||||||
|
"Unavailable": "Indisponível",
|
||||||
|
"Attachment": "Anexo",
|
||||||
"QR code": "Código QR",
|
"QR code": "Código QR",
|
||||||
"Draft discarded": "Rascunho descartado",
|
"Draft discarded": "Rascunho descartado",
|
||||||
"Draft saved": "Rascunho salvo",
|
"Draft saved": "Rascunho salvo",
|
||||||
|
|||||||
@@ -1198,6 +1198,17 @@ export const catalog: Catalog = {
|
|||||||
"Send now instead": "Отправить сейчас",
|
"Send now instead": "Отправить сейчас",
|
||||||
"Switch to plain text": "Переключиться на обычный текст",
|
"Switch to plain text": "Переключиться на обычный текст",
|
||||||
"Switch to rich text": "Переключиться на форматированный текст",
|
"Switch to rich text": "Переключиться на форматированный текст",
|
||||||
|
"{used} of {total} used": "Использовано {used} из {total}",
|
||||||
|
"Actions for {address}": "Действия для {address}",
|
||||||
|
"{here} here, {inSubfolders} in subfolders": "{here} здесь, {inSubfolders} во вложенных папках",
|
||||||
|
"Edit {name}": "Изменить: {name}",
|
||||||
|
"{name} — shared by {owner}": "{name} — общий доступ от {owner}",
|
||||||
|
"{name} — from {owner}": "{name} — от {owner}",
|
||||||
|
"{label} (date)": "{label} (дата)",
|
||||||
|
"{label} (time)": "{label} (время)",
|
||||||
|
"{status}: {start} – {end}": "{status}: {start} – {end}",
|
||||||
|
"Unavailable": "Недоступен",
|
||||||
|
"Attachment": "Вложение",
|
||||||
"QR code": "QR-код",
|
"QR code": "QR-код",
|
||||||
"Draft discarded": "Черновик удалён",
|
"Draft discarded": "Черновик удалён",
|
||||||
"Draft saved": "Черновик сохранён",
|
"Draft saved": "Черновик сохранён",
|
||||||
|
|||||||
@@ -1192,6 +1192,17 @@ export const catalog: Catalog = {
|
|||||||
"Send now instead": "Надіслати зараз",
|
"Send now instead": "Надіслати зараз",
|
||||||
"Switch to plain text": "Перейти на звичайний текст",
|
"Switch to plain text": "Перейти на звичайний текст",
|
||||||
"Switch to rich text": "Перейти на форматований текст",
|
"Switch to rich text": "Перейти на форматований текст",
|
||||||
|
"{used} of {total} used": "Використано {used} з {total}",
|
||||||
|
"Actions for {address}": "Дії для {address}",
|
||||||
|
"{here} here, {inSubfolders} in subfolders": "{here} тут, {inSubfolders} у вкладених теках",
|
||||||
|
"Edit {name}": "Змінити: {name}",
|
||||||
|
"{name} — shared by {owner}": "{name} — спільний доступ від {owner}",
|
||||||
|
"{name} — from {owner}": "{name} — від {owner}",
|
||||||
|
"{label} (date)": "{label} (дата)",
|
||||||
|
"{label} (time)": "{label} (час)",
|
||||||
|
"{status}: {start} – {end}": "{status}: {start} – {end}",
|
||||||
|
"Unavailable": "Недоступний",
|
||||||
|
"Attachment": "Вкладення",
|
||||||
"QR code": "QR-код",
|
"QR code": "QR-код",
|
||||||
"Draft discarded": "Чернетку видалено",
|
"Draft discarded": "Чернетку видалено",
|
||||||
"Draft saved": "Чернетку збережено",
|
"Draft saved": "Чернетку збережено",
|
||||||
|
|||||||
@@ -1203,6 +1203,17 @@ export const catalog: Catalog = {
|
|||||||
"Send now instead": "改为立即发送",
|
"Send now instead": "改为立即发送",
|
||||||
"Switch to plain text": "切换为纯文本",
|
"Switch to plain text": "切换为纯文本",
|
||||||
"Switch to rich text": "切换为富文本",
|
"Switch to rich text": "切换为富文本",
|
||||||
|
"{used} of {total} used": "已使用 {used},共 {total}",
|
||||||
|
"Actions for {address}": "{address} 的操作",
|
||||||
|
"{here} here, {inSubfolders} in subfolders": "此处 {here},子文件夹 {inSubfolders}",
|
||||||
|
"Edit {name}": "编辑 {name}",
|
||||||
|
"{name} — shared by {owner}": "{name} — {owner} 共享",
|
||||||
|
"{name} — from {owner}": "{name} — 来自 {owner}",
|
||||||
|
"{label} (date)": "{label}(日期)",
|
||||||
|
"{label} (time)": "{label}(时间)",
|
||||||
|
"{status}: {start} – {end}": "{status}:{start}–{end}",
|
||||||
|
"Unavailable": "不可用",
|
||||||
|
"Attachment": "附件",
|
||||||
"QR code": "二维码",
|
"QR code": "二维码",
|
||||||
"Draft discarded": "草稿已放弃",
|
"Draft discarded": "草稿已放弃",
|
||||||
"Draft saved": "草稿已保存",
|
"Draft saved": "草稿已保存",
|
||||||
|
|||||||
@@ -301,7 +301,7 @@ export function DateTimeField({ value, onChange, className, disabled, required,
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
required={required}
|
required={required}
|
||||||
placeholder={dateInputPlaceholder()}
|
placeholder={dateInputPlaceholder()}
|
||||||
aria-label={rest["aria-label"] ? `${rest["aria-label"]} (date)` : "Date"}
|
aria-label={rest["aria-label"] ? translate("{label} (date)", { label: rest["aria-label"] }) : translate("Date")}
|
||||||
aria-haspopup="dialog"
|
aria-haspopup="dialog"
|
||||||
aria-expanded={Boolean(anchor)}
|
aria-expanded={Boolean(anchor)}
|
||||||
value={dateField.text}
|
value={dateField.text}
|
||||||
@@ -326,7 +326,7 @@ export function DateTimeField({ value, onChange, className, disabled, required,
|
|||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
placeholder={timeInputPlaceholder()}
|
placeholder={timeInputPlaceholder()}
|
||||||
aria-label={rest["aria-label"] ? `${rest["aria-label"]} (time)` : "Time"}
|
aria-label={rest["aria-label"] ? translate("{label} (time)", { label: rest["aria-label"] }) : translate("Time")}
|
||||||
value={timeField.text}
|
value={timeField.text}
|
||||||
onChange={(e) => { timeField.setEditing(true); timeField.setText(e.target.value); }}
|
onChange={(e) => { timeField.setEditing(true); timeField.setText(e.target.value); }}
|
||||||
onBlur={timeField.onBlur}
|
onBlur={timeField.onBlur}
|
||||||
|
|||||||
@@ -365,7 +365,7 @@ function QuotaBar() {
|
|||||||
if (!q || !q.hardLimit) return null;
|
if (!q || !q.hardLimit) return null;
|
||||||
const pct = Math.min(100, Math.round((q.used / q.hardLimit) * 100));
|
const pct = Math.min(100, Math.round((q.used / q.hardLimit) * 100));
|
||||||
return (
|
return (
|
||||||
<div className="quota" title={`${formatSize(q.used)} of ${formatSize(q.hardLimit)} used`}>
|
<div className="quota" title={t("{used} of {total} used", { used: formatSize(q.used), total: formatSize(q.hardLimit) })}>
|
||||||
<div className="row" style={{ justifyContent: "space-between" }}>
|
<div className="row" style={{ justifyContent: "space-between" }}>
|
||||||
<span>
|
<span>
|
||||||
{t("{used} of {total}", { used: formatSize(q.used), total: formatSize(q.hardLimit) })}
|
{t("{used} of {total}", { used: formatSize(q.used), total: formatSize(q.hardLimit) })}
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ export function CalendarSidebar() {
|
|||||||
{sharedSubscribed.map(({ accountId, accountName, calendar: c }) => {
|
{sharedSubscribed.map(({ accountId, accountName, calendar: c }) => {
|
||||||
const key = `${accountId}:${c.id}`;
|
const key = `${accountId}:${c.id}`;
|
||||||
return (
|
return (
|
||||||
<div key={key} className={`cal-list-item ${cal.hidden[key] ? "hidden-cal" : ""}`} onClick={() => cal.toggleHidden(key)} title={`${c.name} — shared by ${accountName}`}>
|
<div key={key} className={`cal-list-item ${cal.hidden[key] ? "hidden-cal" : ""}`} onClick={() => cal.toggleHidden(key)} title={t("{name} — shared by {owner}", { name: c.name, owner: accountName })}>
|
||||||
<span className="cal-color" style={{ background: c.color ?? "var(--accent)", borderColor: c.color ?? "var(--accent)" }} />
|
<span className="cal-color" style={{ background: c.color ?? "var(--accent)", borderColor: c.color ?? "var(--accent)" }} />
|
||||||
<span className="cal-name">{c.name}</span>
|
<span className="cal-name">{c.name}</span>
|
||||||
<button
|
<button
|
||||||
@@ -212,7 +212,7 @@ export function CalendarSidebar() {
|
|||||||
<>
|
<>
|
||||||
<div className="nav-section"><span>{t("Available to add")}</span></div>
|
<div className="nav-section"><span>{t("Available to add")}</span></div>
|
||||||
{sharedAvailable.map(({ accountId, accountName, calendar: c }) => (
|
{sharedAvailable.map(({ accountId, accountName, calendar: c }) => (
|
||||||
<div key={`${accountId}:${c.id}`} className="cal-list-item" title={`${c.name} — from ${accountName}`}>
|
<div key={`${accountId}:${c.id}`} className="cal-list-item" title={t("{name} — from {owner}", { name: c.name, owner: accountName })}>
|
||||||
<span className="cal-color" style={{ background: "transparent", borderColor: c.color ?? "var(--border-strong)" }} />
|
<span className="cal-color" style={{ background: "transparent", borderColor: c.color ?? "var(--border-strong)" }} />
|
||||||
<span className="cal-name faint">{c.name}</span>
|
<span className="cal-name faint">{c.name}</span>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -34,6 +34,14 @@ export interface EditorInit {
|
|||||||
|
|
||||||
const ALERT_OPTIONS = [0, 5, 10, 15, 30, 60, 120, 1440, 2880, 10080];
|
const ALERT_OPTIONS = [0, 5, 10, 15, 30, 60, 120, 1440, 2880, 10080];
|
||||||
|
|
||||||
|
// A free/busy block's status names a JMAP value, not a word; the bar's tooltip
|
||||||
|
// showed "confirmed" in every language until it was given one.
|
||||||
|
const BUSY_LABEL: Record<"confirmed" | "tentative" | "unavailable", string> = {
|
||||||
|
confirmed: "Busy",
|
||||||
|
tentative: "Tentative",
|
||||||
|
unavailable: "Unavailable",
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fields this form always sends that a single occurrence will not take.
|
* Fields this form always sends that a single occurrence will not take.
|
||||||
*
|
*
|
||||||
@@ -482,7 +490,7 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle
|
|||||||
const bs = Math.max(new Date(b.utcStart).getTime(), fbWindow.start.getTime());
|
const bs = Math.max(new Date(b.utcStart).getTime(), fbWindow.start.getTime());
|
||||||
const be = Math.min(new Date(b.utcEnd).getTime(), fbWindow.end.getTime());
|
const be = Math.min(new Date(b.utcEnd).getTime(), fbWindow.end.getTime());
|
||||||
if (be <= bs) return null;
|
if (be <= bs) return null;
|
||||||
return <span key={i} className="fb-busy" style={pct(bs, be)} title={`${b.busyStatus}: ${formatClock(new Date(b.utcStart))} – ${formatClock(new Date(b.utcEnd))}`} />;
|
return <span key={i} className="fb-busy" style={pct(bs, be)} title={translate("{status}: {start} – {end}", { status: translate(BUSY_LABEL[b.busyStatus]), start: formatClock(new Date(b.utcStart)), end: formatClock(new Date(b.utcEnd)) })} />;
|
||||||
})}
|
})}
|
||||||
{fbHover && <span className="fb-guide" style={{ left: `${((fbHover.getTime() - fbWindow.start.getTime()) / fbWindow.span) * 100}%` }} />}
|
{fbHover && <span className="fb-guide" style={{ left: `${((fbHover.getTime() - fbWindow.start.getTime()) / fbWindow.span) * 100}%` }} />}
|
||||||
{!allDay && <span className="fb-window" style={pct(start.getTime(), end.getTime())} />}
|
{!allDay && <span className="fb-window" style={pct(start.getTime(), end.getTime())} />}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { EmailAddress } from "@/jmap/types";
|
|||||||
import { isValidEmail, parseAddressList, displayName } from "@/lib/address";
|
import { isValidEmail, parseAddressList, displayName } from "@/lib/address";
|
||||||
import { useContacts, type Suggestion } from "@/store/contacts";
|
import { useContacts, type Suggestion } from "@/store/contacts";
|
||||||
import { Avatar } from "@/ui/misc";
|
import { Avatar } from "@/ui/misc";
|
||||||
|
import { t as translate } from "@/lib/i18n";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
value: EmailAddress[];
|
value: EmailAddress[];
|
||||||
@@ -112,7 +113,7 @@ export function RecipientInput({ value, onChange, placeholder, autoFocus, id }:
|
|||||||
{value.map((a, i) => (
|
{value.map((a, i) => (
|
||||||
<span key={`${a.email}-${i}`} className={`chip ${isValidEmail(a.email) ? "" : "invalid"}`} title={a.email}>
|
<span key={`${a.email}-${i}`} className={`chip ${isValidEmail(a.email) ? "" : "invalid"}`} title={a.email}>
|
||||||
<span className="truncate" style={{ maxWidth: 220 }}>{a.name ? displayName(a) : a.email}</span>
|
<span className="truncate" style={{ maxWidth: 220 }}>{a.name ? displayName(a) : a.email}</span>
|
||||||
<button type="button" className="chip-x" aria-label={`Remove ${a.email}`} onClick={(e) => { e.stopPropagation(); onChange(value.filter((_, j) => j !== i)); }}>
|
<button type="button" className="chip-x" aria-label={translate("Remove {address}", { address: a.email })} onClick={(e) => { e.stopPropagation(); onChange(value.filter((_, j) => j !== i)); }}>
|
||||||
<X size={12} />
|
<X size={12} />
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ export function ContactEditor({ card, defaultBookId, onClose, onSaved }: Props)
|
|||||||
const photoSrc = photo?.dataUrl ?? (!removePhoto && existingPhoto ? (existingPhoto.uri?.startsWith("data:") ? existingPhoto.uri : existingPhoto.blobId ? client.downloadUrl(contacts.accountId!, existingPhoto.blobId, "photo", existingPhoto.mediaType ?? "image/jpeg", true) : null) : null);
|
const photoSrc = photo?.dataUrl ?? (!removePhoto && existingPhoto ? (existingPhoto.uri?.startsWith("data:") ? existingPhoto.uri : existingPhoto.blobId ? client.downloadUrl(contacts.accountId!, existingPhoto.blobId, "photo", existingPhoto.mediaType ?? "image/jpeg", true) : null) : null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open onClose={onClose} title={isNew ? t("New contact") : `Edit ${contactDisplayName(card as ContactCard)}`} 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></>}>
|
<Dialog open onClose={onClose} title={isNew ? t("New contact") : t("Edit {name}", { name: contactDisplayName(card as ContactCard) })} 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="contact-form">
|
<div className="contact-form">
|
||||||
<div className="row" style={{ gap: 16, marginBottom: 12 }}>
|
<div className="row" style={{ gap: 16, marginBottom: 12 }}>
|
||||||
<label className="avatar xl" style={{ background: "var(--bg-sunken)", color: "var(--fg-muted)", cursor: "pointer", position: "relative" }} title={t("Change photo")}>
|
<label className="avatar xl" style={{ background: "var(--bg-sunken)", color: "var(--fg-muted)", cursor: "pointer", position: "relative" }} title={t("Change photo")}>
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ export function ContactsSidebar() {
|
|||||||
key={`${accountId}:${book.id}`}
|
key={`${accountId}:${book.id}`}
|
||||||
className={`nav-item ${isOn(accountId, book.id) ? "active" : ""}`}
|
className={`nav-item ${isOn(accountId, book.id) ? "active" : ""}`}
|
||||||
onClick={() => contacts.select({ accountId, bookId: book.id })}
|
onClick={() => contacts.select({ accountId, bookId: book.id })}
|
||||||
title={`${book.name} — shared by ${accountName}`}
|
title={t("{name} — shared by {owner}", { name: book.name, owner: accountName })}
|
||||||
onContextMenu={(e) => openMenuAt(e, { kind: "shared", accountId, book })}
|
onContextMenu={(e) => openMenuAt(e, { kind: "shared", accountId, book })}
|
||||||
>
|
>
|
||||||
<BookOpen size={17} />
|
<BookOpen size={17} />
|
||||||
@@ -181,7 +181,7 @@ export function ContactsSidebar() {
|
|||||||
<>
|
<>
|
||||||
<div className="nav-section"><span>{t("Available to add")}</span></div>
|
<div className="nav-section"><span>{t("Available to add")}</span></div>
|
||||||
{available.map(({ accountId, accountName, book }) => (
|
{available.map(({ accountId, accountName, book }) => (
|
||||||
<div key={`${accountId}:${book.id}`} className="nav-item" title={`${book.name} — from ${accountName}`}>
|
<div key={`${accountId}:${book.id}`} className="nav-item" title={t("{name} — from {owner}", { name: book.name, owner: accountName })}>
|
||||||
<BookOpen size={17} className="faint" />
|
<BookOpen size={17} className="faint" />
|
||||||
<span className="grow truncate faint">{book.name}</span>
|
<span className="grow truncate faint">{book.name}</span>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export function useAddressMenu() {
|
|||||||
const node: ReactNode = (
|
const node: ReactNode = (
|
||||||
<>
|
<>
|
||||||
{menu && (
|
{menu && (
|
||||||
<Popover anchor={menu.anchor} onClose={close} width={230} ariaLabel={`Actions for ${menu.address.email}`}>
|
<Popover anchor={menu.anchor} onClose={close} width={230} ariaLabel={t("Actions for {address}", { address: menu.address.email })}>
|
||||||
<div className="menu-title truncate">{formatAddress(menu.address)}</div>
|
<div className="menu-title truncate">{formatAddress(menu.address)}</div>
|
||||||
{contacts.available && (
|
{contacts.available && (
|
||||||
known ? (
|
known ? (
|
||||||
|
|||||||
@@ -369,7 +369,7 @@ function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread,
|
|||||||
</span>
|
</span>
|
||||||
<span className="folder-icon" style={tint ? ({ "--folder-color": tint } as React.CSSProperties) : undefined}>{icon}</span>
|
<span className="folder-icon" style={tint ? ({ "--folder-color": tint } as React.CSSProperties) : undefined}>{icon}</span>
|
||||||
<span className="nav-label">{label}</span>
|
<span className="nav-label">{label}</span>
|
||||||
{count > 0 && <span className="nav-count" title={hiddenUnread ? `${own} here, ${hiddenUnread} in subfolders` : undefined}>{count > 9999 ? "9999+" : count}</span>}
|
{count > 0 && <span className="nav-count" title={hiddenUnread ? t("{here} here, {inSubfolders} in subfolders", { here: own, inSubfolders: hiddenUnread }) : undefined}>{count > 9999 ? "9999+" : count}</span>}
|
||||||
{count > 0 && <span className="nav-dot" />}
|
{count > 0 && <span className="nav-dot" />}
|
||||||
<button
|
<button
|
||||||
className="icon-btn nav-more"
|
className="icon-btn nav-more"
|
||||||
|
|||||||
@@ -888,7 +888,7 @@ function AttachmentList({ attachments, accountId, email }: { attachments: EmailB
|
|||||||
const url = a.blobId ? client.downloadUrl(accountId, a.blobId, a.name ?? "attachment", a.type) : "#";
|
const url = a.blobId ? client.downloadUrl(accountId, a.blobId, a.name ?? "attachment", a.type) : "#";
|
||||||
const inlineUrl = a.blobId ? client.downloadUrl(accountId, a.blobId, a.name ?? "attachment", a.type, true) : "#";
|
const inlineUrl = a.blobId ? client.downloadUrl(accountId, a.blobId, a.name ?? "attachment", a.type, true) : "#";
|
||||||
return (
|
return (
|
||||||
<a key={a.blobId ?? i} className="attachment" href={url} download={a.name ?? undefined} title={`${a.name ?? "attachment"} (${formatSize(a.size)})`} onClick={(ev) => { if (viewable(a)) { ev.preventDefault(); setPreview(a); } }}>
|
<a key={a.blobId ?? i} className="attachment" href={url} download={a.name ?? undefined} title={`${a.name ?? translate("Attachment")} (${formatSize(a.size)})`} onClick={(ev) => { if (viewable(a)) { ev.preventDefault(); setPreview(a); } }}>
|
||||||
<span className="att-icon">{a.type.startsWith("image/") && a.type !== "image/svg+xml" && a.blobId ? <img src={inlineUrl} alt="" loading="lazy" /> : attachmentIcon(a.type, a.name)}</span>
|
<span className="att-icon">{a.type.startsWith("image/") && a.type !== "image/svg+xml" && a.blobId ? <img src={inlineUrl} alt="" loading="lazy" /> : attachmentIcon(a.type, a.name)}</span>
|
||||||
<span className="att-text">
|
<span className="att-text">
|
||||||
<span className="att-name">{a.name ?? "(unnamed)"}</span>
|
<span className="att-name">{a.name ?? "(unnamed)"}</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user