Translate English built from template literals in attributes
i18n-literals now flags a template literal in a UI attribute or prop when
there are words between its values: `Remove ${email}`, `${name} — shared by
${owner}`. It cannot be a catalogue key as written, so neither the key
exemption nor the sentence-shape test applies. A template that is only
punctuation around values, like `${name} (${size})`, is left alone.
The twelve it found are now keys with placeholders: the quota bar title,
the address menu's label, a folder's subfolder unread count, a recipient
chip's remove button, the contact editor's title, shared and available
calendars and address books, the date and time fields' labels, the
attachment title's fallback name, and the free/busy bar. The bar showed
the raw JMAP busyStatus ("confirmed") and now says Busy, Tentative or
Unavailable.
11 new keys in all nine catalogues. Remove {address}, Date, Time, Busy and
Tentative already existed.
This commit is contained in:
@@ -1223,6 +1223,17 @@ export const catalog: Catalog = {
|
||||
"Send now instead": "Stattdessen jetzt senden",
|
||||
"Switch to plain text": "Zu Nur-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",
|
||||
"Draft discarded": "Entwurf verworfen",
|
||||
"Draft saved": "Entwurf gespeichert",
|
||||
|
||||
@@ -1196,6 +1196,17 @@ export const catalog: Catalog = {
|
||||
"Send now instead": "Enviar ahora, sin programar",
|
||||
"Switch to plain text": "Cambiar a texto sin formato",
|
||||
"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",
|
||||
"Draft discarded": "Borrador descartado",
|
||||
"Draft saved": "Borrador guardado",
|
||||
|
||||
@@ -1201,6 +1201,17 @@ export const catalog: Catalog = {
|
||||
"Send now instead": "Envoyer tout de suite",
|
||||
"Switch to plain text": "Passer en texte brut",
|
||||
"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",
|
||||
"Draft discarded": "Brouillon abandonné",
|
||||
"Draft saved": "Brouillon enregistré",
|
||||
|
||||
@@ -1204,6 +1204,17 @@ export const catalog: Catalog = {
|
||||
"Send now instead": "予約をやめて今すぐ送信",
|
||||
"Switch to plain 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 コード",
|
||||
"Draft discarded": "下書きを破棄しました",
|
||||
"Draft saved": "下書きを保存しました",
|
||||
|
||||
@@ -1192,6 +1192,17 @@ export const catalog: Catalog = {
|
||||
"Send now instead": "Toch nu verzenden",
|
||||
"Switch to plain text": "Overschakelen naar platte 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",
|
||||
"Draft discarded": "Concept weggegooid",
|
||||
"Draft saved": "Concept opgeslagen",
|
||||
|
||||
@@ -1199,6 +1199,17 @@ export const catalog: Catalog = {
|
||||
"Send now instead": "Enviar agora mesmo",
|
||||
"Switch to plain text": "Mudar para texto simples",
|
||||
"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",
|
||||
"Draft discarded": "Rascunho descartado",
|
||||
"Draft saved": "Rascunho salvo",
|
||||
|
||||
@@ -1198,6 +1198,17 @@ export const catalog: Catalog = {
|
||||
"Send now instead": "Отправить сейчас",
|
||||
"Switch to plain 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-код",
|
||||
"Draft discarded": "Черновик удалён",
|
||||
"Draft saved": "Черновик сохранён",
|
||||
|
||||
@@ -1192,6 +1192,17 @@ export const catalog: Catalog = {
|
||||
"Send now instead": "Надіслати зараз",
|
||||
"Switch to plain 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-код",
|
||||
"Draft discarded": "Чернетку видалено",
|
||||
"Draft saved": "Чернетку збережено",
|
||||
|
||||
@@ -1203,6 +1203,17 @@ export const catalog: Catalog = {
|
||||
"Send now instead": "改为立即发送",
|
||||
"Switch to plain 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": "二维码",
|
||||
"Draft discarded": "草稿已放弃",
|
||||
"Draft saved": "草稿已保存",
|
||||
|
||||
@@ -301,7 +301,7 @@ export function DateTimeField({ value, onChange, className, disabled, required,
|
||||
disabled={disabled}
|
||||
required={required}
|
||||
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-expanded={Boolean(anchor)}
|
||||
value={dateField.text}
|
||||
@@ -326,7 +326,7 @@ export function DateTimeField({ value, onChange, className, disabled, required,
|
||||
spellCheck={false}
|
||||
disabled={disabled}
|
||||
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}
|
||||
onChange={(e) => { timeField.setEditing(true); timeField.setText(e.target.value); }}
|
||||
onBlur={timeField.onBlur}
|
||||
|
||||
@@ -365,7 +365,7 @@ function QuotaBar() {
|
||||
if (!q || !q.hardLimit) return null;
|
||||
const pct = Math.min(100, Math.round((q.used / q.hardLimit) * 100));
|
||||
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" }}>
|
||||
<span>
|
||||
{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 }) => {
|
||||
const key = `${accountId}:${c.id}`;
|
||||
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-name">{c.name}</span>
|
||||
<button
|
||||
@@ -212,7 +212,7 @@ export function CalendarSidebar() {
|
||||
<>
|
||||
<div className="nav-section"><span>{t("Available to add")}</span></div>
|
||||
{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-name faint">{c.name}</span>
|
||||
<button
|
||||
|
||||
@@ -34,6 +34,14 @@ export interface EditorInit {
|
||||
|
||||
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.
|
||||
*
|
||||
@@ -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 be = Math.min(new Date(b.utcEnd).getTime(), fbWindow.end.getTime());
|
||||
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}%` }} />}
|
||||
{!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 { useContacts, type Suggestion } from "@/store/contacts";
|
||||
import { Avatar } from "@/ui/misc";
|
||||
import { t as translate } from "@/lib/i18n";
|
||||
|
||||
interface Props {
|
||||
value: EmailAddress[];
|
||||
@@ -112,7 +113,7 @@ export function RecipientInput({ value, onChange, placeholder, autoFocus, id }:
|
||||
{value.map((a, i) => (
|
||||
<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>
|
||||
<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} />
|
||||
</button>
|
||||
</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);
|
||||
|
||||
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="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")}>
|
||||
|
||||
@@ -157,7 +157,7 @@ export function ContactsSidebar() {
|
||||
key={`${accountId}:${book.id}`}
|
||||
className={`nav-item ${isOn(accountId, book.id) ? "active" : ""}`}
|
||||
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 })}
|
||||
>
|
||||
<BookOpen size={17} />
|
||||
@@ -181,7 +181,7 @@ export function ContactsSidebar() {
|
||||
<>
|
||||
<div className="nav-section"><span>{t("Available to add")}</span></div>
|
||||
{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" />
|
||||
<span className="grow truncate faint">{book.name}</span>
|
||||
<button
|
||||
|
||||
@@ -40,7 +40,7 @@ export function useAddressMenu() {
|
||||
const node: ReactNode = (
|
||||
<>
|
||||
{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>
|
||||
{contacts.available && (
|
||||
known ? (
|
||||
|
||||
@@ -369,7 +369,7 @@ function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread,
|
||||
</span>
|
||||
<span className="folder-icon" style={tint ? ({ "--folder-color": tint } as React.CSSProperties) : undefined}>{icon}</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" />}
|
||||
<button
|
||||
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 inlineUrl = a.blobId ? client.downloadUrl(accountId, a.blobId, a.name ?? "attachment", a.type, true) : "#";
|
||||
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-text">
|
||||
<span className="att-name">{a.name ?? "(unnamed)"}</span>
|
||||
|
||||
Reference in New Issue
Block a user