Check component props for untranslated literals, and fail on a finding

i18n-literals checked title, aria-label, placeholder and alt on elements,
but not the same English passed to a component, so a MenuItem label or a
Popover ariaLabel written as a literal went through. It also excused a
literal that happened to be a catalogue key. That exemption is meant for
English held in a constant and translated where it renders, and a literal
written straight into a JSX attribute has no such render site. No
component passes its props through t(). And neither half of i18n:check
was run with --check, so a finding printed and the script still exited 0.

Component props are checked now, a key no longer excuses a literal in an
attribute, and both scripts run with --check. That found 28 strings
rendering English in every language: 19 already had keys and are wrapped,
and 9 are new keys in all nine catalogues. The contact editor's Save and
Saving… buttons are wrapped as well, on the same line.
This commit is contained in:
2026-09-14 08:03:12 -07:00
parent af092b3942
commit ac4c4bd267
20 changed files with 117 additions and 23 deletions
+9
View File
@@ -1214,6 +1214,15 @@ export const catalog: Catalog = {
"Resize panes": "Bereichsgröße ändern",
"Resize message list": "Größe der Nachrichtenliste ändern",
"Resize sidebar": "Größe der Seitenleiste ändern",
"Hide from list": "Aus der Liste ausblenden",
"Show in list": "In der Liste einblenden",
"Stop trusting sender images": "Bildern des Absenders nicht mehr vertrauen",
"Always show images from sender": "Bilder dieses Absenders immer anzeigen",
"Collapse all": "Alle einklappen",
"Expand all": "Alle ausklappen",
"Send now instead": "Stattdessen jetzt senden",
"Switch to plain text": "Zu Nur-Text wechseln",
"Switch to rich text": "Zu formatiertem Text wechseln",
"QR code": "QR-Code",
"Draft discarded": "Entwurf verworfen",
"Draft saved": "Entwurf gespeichert",
+9
View File
@@ -1187,6 +1187,15 @@ export const catalog: Catalog = {
"Resize panes": "Cambiar el tamaño de los paneles",
"Resize message list": "Cambiar el tamaño de la lista de mensajes",
"Resize sidebar": "Cambiar el tamaño de la barra lateral",
"Hide from list": "Ocultar de la lista",
"Show in list": "Mostrar en la lista",
"Stop trusting sender images": "Dejar de confiar en las imágenes del remitente",
"Always show images from sender": "Mostrar siempre las imágenes del remitente",
"Collapse all": "Contraer todo",
"Expand all": "Expandir todo",
"Send now instead": "Enviar ahora, sin programar",
"Switch to plain text": "Cambiar a texto sin formato",
"Switch to rich text": "Cambiar a texto enriquecido",
"QR code": "Código QR",
"Draft discarded": "Borrador descartado",
"Draft saved": "Borrador guardado",
+9
View File
@@ -1192,6 +1192,15 @@ export const catalog: Catalog = {
"Resize panes": "Redimensionner les volets",
"Resize message list": "Redimensionner la liste des messages",
"Resize sidebar": "Redimensionner la barre latérale",
"Hide from list": "Masquer de la liste",
"Show in list": "Afficher dans la liste",
"Stop trusting sender images": "Ne plus faire confiance aux images de l'expéditeur",
"Always show images from sender": "Toujours afficher les images de l'expéditeur",
"Collapse all": "Tout réduire",
"Expand all": "Tout développer",
"Send now instead": "Envoyer tout de suite",
"Switch to plain text": "Passer en texte brut",
"Switch to rich text": "Passer en texte enrichi",
"QR code": "QR code",
"Draft discarded": "Brouillon abandonné",
"Draft saved": "Brouillon enregistré",
+9
View File
@@ -1195,6 +1195,15 @@ export const catalog: Catalog = {
"Resize panes": "ペインの大きさを変更",
"Resize message list": "メール一覧の大きさを変更",
"Resize sidebar": "サイドバーの大きさを変更",
"Hide from list": "一覧で非表示にする",
"Show in list": "一覧に表示する",
"Stop trusting sender images": "差出人の画像を信頼しない",
"Always show images from sender": "差出人の画像を常に表示",
"Collapse all": "すべて折りたたむ",
"Expand all": "すべて展開",
"Send now instead": "予約をやめて今すぐ送信",
"Switch to plain text": "プレーンテキストに切り替え",
"Switch to rich text": "リッチテキストに切り替え",
"QR code": "QR コード",
"Draft discarded": "下書きを破棄しました",
"Draft saved": "下書きを保存しました",
+9
View File
@@ -1183,6 +1183,15 @@ export const catalog: Catalog = {
"Resize panes": "Vensterdelen verslepen",
"Resize message list": "Grootte van de berichtenlijst wijzigen",
"Resize sidebar": "Grootte van de zijbalk wijzigen",
"Hide from list": "Verbergen in de lijst",
"Show in list": "Tonen in de lijst",
"Stop trusting sender images": "Afbeeldingen van afzender niet meer vertrouwen",
"Always show images from sender": "Afbeeldingen van afzender altijd tonen",
"Collapse all": "Alles samenvouwen",
"Expand all": "Alles uitvouwen",
"Send now instead": "Toch nu verzenden",
"Switch to plain text": "Overschakelen naar platte tekst",
"Switch to rich text": "Overschakelen naar opgemaakte tekst",
"QR code": "QR-code",
"Draft discarded": "Concept weggegooid",
"Draft saved": "Concept opgeslagen",
+9
View File
@@ -1190,6 +1190,15 @@ export const catalog: Catalog = {
"Resize panes": "Redimensionar os painéis",
"Resize message list": "Redimensionar a lista de mensagens",
"Resize sidebar": "Redimensionar a barra lateral",
"Hide from list": "Ocultar da lista",
"Show in list": "Mostrar na lista",
"Stop trusting sender images": "Deixar de confiar nas imagens do remetente",
"Always show images from sender": "Sempre mostrar as imagens do remetente",
"Collapse all": "Recolher tudo",
"Expand all": "Expandir tudo",
"Send now instead": "Enviar agora mesmo",
"Switch to plain text": "Mudar para texto simples",
"Switch to rich text": "Mudar para texto formatado",
"QR code": "Código QR",
"Draft discarded": "Rascunho descartado",
"Draft saved": "Rascunho salvo",
+9
View File
@@ -1189,6 +1189,15 @@ export const catalog: Catalog = {
"Resize panes": "Изменить размер панелей",
"Resize message list": "Изменить размер списка писем",
"Resize sidebar": "Изменить размер боковой панели",
"Hide from list": "Скрыть из списка",
"Show in list": "Показать в списке",
"Stop trusting sender images": "Больше не доверять изображениям отправителя",
"Always show images from sender": "Всегда показывать изображения отправителя",
"Collapse all": "Свернуть все",
"Expand all": "Развернуть все",
"Send now instead": "Отправить сейчас",
"Switch to plain text": "Переключиться на обычный текст",
"Switch to rich text": "Переключиться на форматированный текст",
"QR code": "QR-код",
"Draft discarded": "Черновик удалён",
"Draft saved": "Черновик сохранён",
+9
View File
@@ -1183,6 +1183,15 @@ export const catalog: Catalog = {
"Resize panes": "Змінити розмір панелей",
"Resize message list": "Змінити розмір списку листів",
"Resize sidebar": "Змінити розмір бічної панелі",
"Hide from list": "Сховати зі списку",
"Show in list": "Показати в списку",
"Stop trusting sender images": "Більше не довіряти зображенням відправника",
"Always show images from sender": "Завжди показувати зображення відправника",
"Collapse all": "Згорнути все",
"Expand all": "Розгорнути все",
"Send now instead": "Надіслати зараз",
"Switch to plain text": "Перейти на звичайний текст",
"Switch to rich text": "Перейти на форматований текст",
"QR code": "QR-код",
"Draft discarded": "Чернетку видалено",
"Draft saved": "Чернетку збережено",
+9
View File
@@ -1194,6 +1194,15 @@ export const catalog: Catalog = {
"Resize panes": "调整窗格大小",
"Resize message list": "调整邮件列表大小",
"Resize sidebar": "调整侧边栏大小",
"Hide from list": "从列表中隐藏",
"Show in list": "在列表中显示",
"Stop trusting sender images": "不再信任发件人的图片",
"Always show images from sender": "始终显示该发件人的图片",
"Collapse all": "全部折叠",
"Expand all": "全部展开",
"Send now instead": "改为立即发送",
"Switch to plain text": "切换为纯文本",
"Switch to rich text": "切换为富文本",
"QR code": "二维码",
"Draft discarded": "草稿已放弃",
"Draft saved": "草稿已保存",
+2 -2
View File
@@ -239,7 +239,7 @@ export function DateField({ value, onChange, className, disabled, required, id,
<CalIcon size={15} />
</button>
{anchor && (
<Popover anchor={anchor} onClose={() => { setAnchor(null); inputRef.current?.focus(); }} role="dialog" className="dp-pop" closeOnClick={false} ariaLabel="Choose a date">
<Popover anchor={anchor} onClose={() => { setAnchor(null); inputRef.current?.focus(); }} role="dialog" className="dp-pop" closeOnClick={false} ariaLabel={translate("Choose a date")}>
<CalendarGrid
selected={selected}
onClose={() => { setAnchor(null); inputRef.current?.focus(); }}
@@ -335,7 +335,7 @@ export function DateTimeField({ value, onChange, className, disabled, required,
<span className="dp-open" aria-hidden="true"><Clock size={15} /></span>
</span>
{anchor && (
<Popover anchor={anchor} onClose={close} role="dialog" className="dp-pop dp-pop-wide" closeOnClick={false} ariaLabel="Choose a date and time">
<Popover anchor={anchor} onClose={close} role="dialog" className="dp-pop dp-pop-wide" closeOnClick={false} ariaLabel={translate("Choose a date and time")}>
<div className="dp-split">
<CalendarGrid
selected={valid}
+2 -2
View File
@@ -289,7 +289,7 @@ export function Composer({ draft }: { draft: Draft }) {
<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={<Send size={16} />} label={d.sendAt !== null ? translate("Send now instead") : translate("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={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>
@@ -323,7 +323,7 @@ export function Composer({ draft }: { draft: Draft }) {
</Popover>
<button className="icon-btn" onClick={moreMenu.open} aria-label={translate("More options")}><MoreVertical size={18} /></button>
<Popover anchor={moreMenu.anchor} onClose={moreMenu.close} side="top" width={260}>
<MenuItem icon={<Type size={16} />} label={d.format === "html" ? "Switch to plain text" : "Switch to rich text"} onClick={toggleFormat} />
<MenuItem icon={<Type size={16} />} label={d.format === "html" ? translate("Switch to plain text") : translate("Switch to rich text")} onClick={toggleFormat} />
<MenuItem icon={<CheckCheck size={16} />} label={translate("Request read receipt")} checked={d.requestReceipt} onClick={() => patch({ requestReceipt: !d.requestReceipt })} />
<MenuSep />
<MenuTitle>{translate("Priority")}</MenuTitle>
+1 -1
View File
@@ -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 ? "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 ? "Saving…" : "Save"}</button></>}>
<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></>}>
<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")}>
+1 -1
View File
@@ -471,7 +471,7 @@ function MailboxMenu({ mailbox: m, onClose, onCreateChild, onShare }: { mailbox:
)}
<MenuItem icon={<FolderPlus size={16} />} label={t("New subfolder")} onClick={onCreateChild} disabled={!m.myRights.mayCreateChild} />
<MenuItem icon={<Pencil size={16} />} label={t("Rename")} onClick={() => void rename()} disabled={isSpecial || !m.myRights.mayRename} />
<MenuItem icon={m.isSubscribed ? <EyeOff size={16} /> : <Eye size={16} />} label={m.isSubscribed ? "Hide from list" : "Show in list"} onClick={() => void useMail.getState().updateMailbox(m.id, { isSubscribed: !m.isSubscribed })} disabled={m.role === "inbox"} />
<MenuItem icon={m.isSubscribed ? <EyeOff size={16} /> : <Eye size={16} />} label={m.isSubscribed ? t("Hide from list") : t("Show in list")} onClick={() => void useMail.getState().updateMailbox(m.id, { isSubscribed: !m.isSubscribed })} disabled={m.role === "inbox"} />
{/* Sharing a mail folder is withdrawn, not removed: Stalwart accepts and
stores the share, and it never reaches the other account -- its own
docs list calendars, address books and files as shareable and not mail
+4 -4
View File
@@ -299,7 +299,7 @@ export function MessageList({ title, list, openThreadId, openMessageId, focusId,
<Popover anchor={selMenu.anchor} onClose={selMenu.close} align="end" width={240}>
<MenuItem
icon={mailbox?.role === "junk" ? <ShieldCheck size={16} /> : <AlertOctagon size={16} />}
label={mailbox?.role === "junk" ? "Not spam" : "Report spam"}
label={mailbox?.role === "junk" ? t("Not spam") : t("Report spam")}
onClick={() => void actions.spam()}
/>
<MenuItem icon={<Mail size={16} />} label={t("Mark as unread")} onClick={() => void actions.read(false)} />
@@ -532,10 +532,10 @@ export function MessageList({ title, list, openThreadId, openMessageId, focusId,
<MenuItem icon={<CalendarRange size={16} />} label={archiveDateLabel("year")} onClick={() => void useMail.getState().archiveByDate(ctxTargets, "year")} />
<MenuItem icon={<CalendarDays size={16} />} label={archiveDateLabel("month")} onClick={() => void useMail.getState().archiveByDate(ctxTargets, "month")} />
<MenuItem icon={<Trash2 size={16} />} label={t("Delete")} kbd="#" onClick={() => void actions.trash(ctxTargets)} />
<MenuItem icon={<AlertOctagon size={16} />} label={mailbox?.role === "junk" ? "Not spam" : "Report spam"} kbd="!" onClick={() => void actions.spam(ctxTargets)} />
<MenuItem icon={<AlertOctagon size={16} />} label={mailbox?.role === "junk" ? t("Not spam") : t("Report spam")} kbd="!" onClick={() => void actions.spam(ctxTargets)} />
<MenuSep />
<MenuItem icon={someUnread ? <MailOpen size={16} /> : <Mail size={16} />} label={someUnread ? "Mark as read" : "Mark as unread"} onClick={() => void actions.read(someUnread, ctxTargets)} />
<MenuItem icon={<Star size={16} />} label={someUnstarred ? "Add star" : "Remove star"} kbd="s" onClick={() => void actions.star(someUnstarred, ctxTargets)} />
<MenuItem icon={someUnread ? <MailOpen size={16} /> : <Mail size={16} />} label={someUnread ? t("Mark as read") : t("Mark as unread")} onClick={() => void actions.read(someUnread, ctxTargets)} />
<MenuItem icon={<Star size={16} />} label={someUnstarred ? t("Add star") : t("Remove star")} kbd="s" onClick={() => void actions.star(someUnstarred, ctxTargets)} />
<MenuItem icon={<FolderInput size={16} />} label={t("Move to…")} kbd="v" onClick={() => actions.move(ctxTargets)} />
<MenuItem icon={<Tag size={16} />} label={t("Label…")} kbd="l" onClick={() => actions.label(ctxTargets, ctxMenu.anchor ?? { x: 0, y: 0 })} />
<MenuSep />
+2 -2
View File
@@ -338,7 +338,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
the other three rather than down among the read-only actions. */}
<MenuItem icon={<MailPlus size={16} />} label={translate("Compose as new")} onClick={() => void useCompose.getState().composeAsNew(e)} />
<MenuSep />
<MenuItem icon={<Mail size={16} />} label={e.keywords.$seen ? "Mark as unread" : "Mark as read"} onClick={() => void useMail.getState().markRead([e.id], !e.keywords.$seen)} />
<MenuItem icon={<Mail size={16} />} label={e.keywords.$seen ? translate("Mark as unread") : translate("Mark as read")} onClick={() => void useMail.getState().markRead([e.id], !e.keywords.$seen)} />
<MenuItem icon={<Trash2 size={16} />} label={translate("Delete this message")} onClick={() => void useMail.getState().trash([e.id])} />
<MenuSep />
<MenuItem icon={<Eye size={16} />} label={translate("Show original")} onClick={() => void openSource()} />
@@ -351,7 +351,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
{from && (
<>
<MenuSep />
<MenuItem icon={<Ban size={16} />} label={senderTrusted ? "Stop trusting sender images" : "Always show images from sender"} onClick={() => updateSettings({ trustedImageSenders: senderTrusted ? settings.trustedImageSenders.filter((x) => x !== from.email.toLowerCase()) : [...settings.trustedImageSenders, from.email.toLowerCase()] })} />
<MenuItem icon={<Ban size={16} />} label={senderTrusted ? translate("Stop trusting sender images") : translate("Always show images from sender")} onClick={() => updateSettings({ trustedImageSenders: senderTrusted ? settings.trustedImageSenders.filter((x) => x !== from.email.toLowerCase()) : [...settings.trustedImageSenders, from.email.toLowerCase()] })} />
</>
)}
</Popover>
+4 -4
View File
@@ -245,17 +245,17 @@ export function ThreadView({ threadId, mailboxId, onBack, actions, onNavigate, h
<ArrowLeft size={20} />
</button>
<button className="icon-btn" title={t("Archive (e)")} onClick={() => void actions.archive(rowIds)}><Archive size={19} /></button>
<button className="icon-btn" title={inJunk ? "Not spam" : "Report spam (!)"} onClick={() => void actions.spam(rowIds)}>{inJunk ? <ShieldCheck size={19} /> : <AlertOctagon size={19} />}</button>
<button className="icon-btn" title={inJunk ? t("Not spam") : t("Report spam (!)")} onClick={() => void actions.spam(rowIds)}>{inJunk ? <ShieldCheck size={19} /> : <AlertOctagon size={19} />}</button>
<button className="icon-btn" title={t("Delete (#)")} onClick={() => void actions.trash(rowIds)}><Trash2 size={19} /></button>
<span className="tb-sep hide-mobile" />
<button className="icon-btn hide-mobile" title={anyUnread ? "Mark as read" : "Mark as unread"} onClick={() => void actions.read(anyUnread, rowIds)}>{anyUnread ? <MailOpen size={19} /> : <Mail size={19} />}</button>
<button className="icon-btn hide-mobile" title={anyUnread ? t("Mark as read") : t("Mark as unread")} onClick={() => void actions.read(anyUnread, rowIds)}>{anyUnread ? <MailOpen size={19} /> : <Mail size={19} />}</button>
<button className="icon-btn hide-mobile" title={t("Move to (v)")} onClick={() => actions.move(rowIds)}><FolderInput size={19} /></button>
<button className="icon-btn hide-mobile" title={t("Labels (l)")} onClick={(e) => setLabelAnchor({ x: e.clientX, y: e.clientY })}><Tag size={19} /></button>
<button className="icon-btn" onClick={moreMenu.open} aria-label={t("More")}><MoreVertical size={19} /></button>
<Popover anchor={moreMenu.anchor} onClose={moreMenu.close} align="start" width={240}>
<MenuItem icon={<Star size={16} />} label={anyStarred ? "Remove star" : "Add star"} onClick={() => void actions.star(!anyStarred, rowIds)} />
<MenuItem icon={<Star size={16} />} label={anyStarred ? t("Remove star") : t("Add star")} onClick={() => void actions.star(!anyStarred, rowIds)} />
<MenuItem icon={<Tag size={16} />} label={t("Label…")} onClick={() => setLabelAnchor({ x: window.innerWidth / 2, y: 100 })} />
<MenuItem icon={allExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />} label={allExpanded ? "Collapse all" : "Expand all"} onClick={() => { setAllExpanded((v) => !v); setExpanded({}); }} />
<MenuItem icon={allExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />} label={allExpanded ? t("Collapse all") : t("Expand all")} onClick={() => { setAllExpanded((v) => !v); setExpanded({}); }} />
<MenuSep />
<MenuItem icon={<Printer size={16} />} label={t("Print conversation")} onClick={() => window.print()} />
{last && accountId && (