Second extraction pass: the strings the codemod could not see
`i18n:coverage` reported 100% while a hundred-odd strings rendered
English in every language. It was not wrong about what it measured: it
reads JSX text, and none of these were JSX text. They were toast
arguments, `confirmDialog({ title, confirmLabel })` props, `title=` and
`aria-label=` attributes, and template literals — every one built from an
expression the codemod cannot read.
176 source strings and 15 plural sets now go through t() and plural(),
translated into all nine languages. Where English put a word in a slot,
the sentence is spelled out per branch instead: `Filter ${verb}` became
"Filter saved" and "Filter created", because which word agrees with what,
and where it sits, is not a property English gets to decide for everyone.
Counts that were `${n} message${n === 1 ? "" : "s"}` are plural() calls,
so Russian and Ukrainian get three forms and Japanese and Chinese get the
one they actually have.
Two of the catalogue's own conventions were worth learning the hard way.
Plural entries are keyed on the English *other* form, not `one` — `one`
is a form English happens to have and Japanese does not. And a constant
table holding English that is translated at the render site is fine: the
literal is a key, not a leak.
Which is what the new check encodes. `scripts/i18n-literals.mjs` accepts
a string that is wrapped where it is written or is a catalogue key
somewhere, and refuses one that is neither — a string no catalogue can
translate, however many languages ship. It found twenty more than my own
sweep had, including the stale-folder toast seen in production. It runs
as part of `npm run i18n:check`.
Also fixed: the catalogue is now awaited before the first paint. The
tree is rebuilt when a catalogue lands, so components recover on their
own, but a string computed in an effect does not — a toast fired in that
gap is emitted in English and stays English. The wait costs nothing
visible, since the session bootstrap already shows a spinner and English
resolves immediately.
And the Japanese agenda title loses a space Japanese does not use:
"{date} からの予定" was written with the English habit of spacing around
a placeholder.
This commit is contained in:
@@ -859,6 +859,183 @@ export const catalog: Catalog = {
|
||||
"Saved {when}": "Сохранено {when}",
|
||||
"Unsaved": "Не сохранено",
|
||||
"Agenda from {date}": "Расписание с {date}",
|
||||
|
||||
// ── Toasts, dialogs and computed labels (second extraction pass) ───
|
||||
"(no subject)": "(без темы)",
|
||||
"(skipped: {actions})": "(пропущено: {actions})",
|
||||
"Activate": "Включить",
|
||||
"Add a contact or import a vCard file.": "Добавьте контакт или импортируйте файл vCard.",
|
||||
"Added to your calendar": "Добавлено в ваш календарь",
|
||||
"All events in this calendar will be deleted.": "Все события этого календаря будут удалены.",
|
||||
"All occurrences": "Все повторения",
|
||||
"An occurrence cannot be moved to another calendar on its own": "Отдельное повторение нельзя перенести в другой календарь",
|
||||
"Anything signed in with this password stops working immediately.": "Всё, что вошло с этим паролем, сразу перестанет работать.",
|
||||
"App password revoked": "Пароль приложения отозван",
|
||||
"Applies to every date in the series.": "Применяется ко всем датам серии.",
|
||||
"Applies to this date only.": "Применяется только к этой дате.",
|
||||
"Apply to series": "Применить ко всей серии",
|
||||
"Applying filter to existing messages…": "Фильтр применяется к существующим письмам…",
|
||||
"Attachments are still uploading": "Вложения ещё загружаются",
|
||||
"Calendar saved": "Календарь сохранён",
|
||||
"Categorised as {name}": "Назначена категория «{name}»",
|
||||
"Category cleared": "Категория снята",
|
||||
"Change this event?": "Изменить это событие?",
|
||||
"Choose a calendar": "Выберите календарь",
|
||||
"Choose an address book": "Выберите адресную книгу",
|
||||
"Colour updated": "Цвет изменён",
|
||||
"Contact created": "Контакт создан",
|
||||
"Contact deleted": "Контакт удалён",
|
||||
"Contact saved": "Контакт сохранён",
|
||||
"Copied": "Скопировано",
|
||||
"Copy of {title}": "Копия: {title}",
|
||||
"Could not cancel: {error}": "Не удалось отменить: {error}",
|
||||
"Could not copy": "Не удалось скопировать",
|
||||
"Could not copy the address": "Не удалось скопировать адрес",
|
||||
"Could not empty folder: {error}": "Не удалось очистить папку: {error}",
|
||||
"Could not load source: {error}": "Не удалось загрузить исходный текст: {error}",
|
||||
"Could not mark as read: {error}": "Не удалось отметить как прочитанное: {error}",
|
||||
"Could not save draft: {error}": "Не удалось сохранить черновик: {error}",
|
||||
"Could not save filter: {error}": "Не удалось сохранить фильтр: {error}",
|
||||
"Could not save filters: {error}": "Не удалось сохранить фильтры: {error}",
|
||||
"Could not send the receipt: {error}": "Не удалось отправить уведомление о прочтении: {error}",
|
||||
"Could not sign in.": "Не удалось войти.",
|
||||
"Could not store image: {error}": "Не удалось сохранить изображение: {error}",
|
||||
"Could not update labels: {error}": "Не удалось обновить метки: {error}",
|
||||
"Could not update the Scheduled folder: {error}": "Не удалось обновить папку «Отложенные»: {error}",
|
||||
"Could not update: {error}": "Не удалось обновить: {error}",
|
||||
"Create": "Создать",
|
||||
"Create an address book first": "Сначала создайте адресную книгу",
|
||||
"Custom colour removed": "Свой цвет снят",
|
||||
"Deactivate": "Выключить",
|
||||
"Delete failed: {error}": "Не удалось удалить: {error}",
|
||||
"Delete forever": "Удалить безвозвратно",
|
||||
"Delete script “{name}”?": "Удалить сценарий «{name}»?",
|
||||
"Delete this event?": "Удалить это событие?",
|
||||
"Delete this identity?": "Удалить этот профиль отправителя?",
|
||||
"Delete {name}?": "Удалить {name}?",
|
||||
"Deleted": "Удалено",
|
||||
"Deletes the whole series. This cannot be undone.": "Удаляет всю серию. Отменить это будет нельзя.",
|
||||
"Did you forget the attachment?": "Не забыли вложение?",
|
||||
"Discard": "Не сохранять",
|
||||
"Discard this draft?": "Удалить этот черновик?",
|
||||
"Edit calendar": "Изменить календарь",
|
||||
"Edit event": "Изменить событие",
|
||||
"Edit identity": "Изменить профиль отправителя",
|
||||
"Edit template": "Изменить шаблон",
|
||||
"End must be after start": "Окончание должно быть позже начала",
|
||||
"Event duplicated": "Событие продублировано",
|
||||
"Everything inside it goes too.": "Всё, что внутри, тоже будет удалено.",
|
||||
"Filter created": "Фильтр создан",
|
||||
"Filter created — it will run on new mail": "Фильтр создан — он будет работать для новых писем",
|
||||
"Filter saved": "Фильтр сохранён",
|
||||
"Filter saved — it will run on new mail": "Фильтр сохранён — он будет работать для новых писем",
|
||||
"Filter saved, but applying it failed: {error}": "Фильтр сохранён, но применить его не удалось: {error}",
|
||||
"Filters saved": "Фильтры сохранены",
|
||||
"Folder changed, but its filter rules could not be updated: {error}": "Папка изменена, но её правила фильтрации обновить не удалось: {error}",
|
||||
"Folder created": "Папка создана",
|
||||
"Folder deleted": "Папка удалена",
|
||||
"Folder name": "Имя папки",
|
||||
"Folder name (use / for a subfolder, e.g. Work/Invoices)": "Имя папки (/ создаёт вложенную папку, например Работа/Счета)",
|
||||
"Folder name (use / for subfolders, e.g. Work/Invoices)": "Имя папки (/ создаёт вложенные папки, например Работа/Счета)",
|
||||
"Folder “{name}” created": "Папка «{name}» создана",
|
||||
"Forwarded": "Переслано",
|
||||
"Hand to the server, held until {when} (Ctrl+Enter)": "Передать серверу и удержать до {when} (Ctrl+Enter)",
|
||||
"Hide": "Скрыть",
|
||||
"Hide quoted text": "Скрыть цитируемый текст",
|
||||
"Hide this from the compose picker": "Не предлагать при написании",
|
||||
"Identity saved": "Профиль отправителя сохранён",
|
||||
"Images in signatures need the Files feature, which this account doesn't have.": "Для изображений в подписи нужны «Файлы», которых у этой учётной записи нет.",
|
||||
"Invalid address: {address}": "Неверный адрес: {address}",
|
||||
"Invalid username or password.": "Неверное имя пользователя или пароль.",
|
||||
"It belongs to a change that was applied to this and all later occurrences, which the server will only edit as a whole. Apply to the entire series instead?": "Она относится к изменению, применённому к этому и всем последующим повторениям, а сервер правит такое изменение только целиком. Применить ко всей серии?",
|
||||
"Label name": "Название метки",
|
||||
"Larger than {size} MB limit": "Больше предела в {size} МБ",
|
||||
"Message sent": "Письмо отправлено",
|
||||
"Move failed: {error}": "Не удалось переместить: {error}",
|
||||
"Move “{name}”": "Переместить «{name}»",
|
||||
"Moved": "Перемещено",
|
||||
"Network error. Please check your connection.": "Ошибка сети. Проверьте подключение.",
|
||||
"New all-day event on {date}": "Новое событие на весь день {date}",
|
||||
"New event at {time}": "Новое событие в {time}",
|
||||
"New identity": "Новый профиль отправителя",
|
||||
"No Archive folder found. Create one named “Archive” first.": "Папка архива не найдена. Сначала создайте папку с именем «Archive».",
|
||||
"No contacts yet": "Контактов пока нет",
|
||||
"No longer shared": "Общий доступ прекращён",
|
||||
"No matches": "Совпадений нет",
|
||||
"No new mail in your inbox.": "Новых писем во входящих нет.",
|
||||
"No results": "Ничего не найдено",
|
||||
"Nothing here": "Здесь пусто",
|
||||
"Only Deleted Items and Junk Mail can be emptied.": "Очистить можно только корзину и спам.",
|
||||
"Please add at least one recipient": "Добавьте хотя бы одного получателя",
|
||||
"Please use an image under 512 KB for signatures.": "Для подписи используйте изображение меньше 512 КБ.",
|
||||
"Preview": "Просмотр",
|
||||
"Read receipt sent": "Уведомление о прочтении отправлено",
|
||||
"Remove attachments that failed to upload first": "Сначала удалите вложения, которые не загрузились",
|
||||
"Removed from calendar": "Удалено из календаря",
|
||||
"Removes this date and leaves the rest of the series.": "Удаляет эту дату и оставляет остальную серию.",
|
||||
"Rename address book": "Переименовать адресную книгу",
|
||||
"Rename category": "Переименовать категорию",
|
||||
"Replied": "Есть ответ",
|
||||
"Report spam (!)": "Пожаловаться на спам (!)",
|
||||
"Response sent": "Ответ отправлен",
|
||||
"Revoke “{name}”?": "Отозвать «{name}»?",
|
||||
"Script has errors": "В сценарии есть ошибки",
|
||||
"Script is valid": "Сценарий корректен",
|
||||
"Script name is required": "Нужно указать имя сценария",
|
||||
"Script saved": "Сценарий сохранён",
|
||||
"Send (Ctrl+Enter)": "Отправить (Ctrl+Enter)",
|
||||
"Send anyway": "Всё равно отправить",
|
||||
"Send cancelled — the message is back in Drafts": "Отправка отменена — письмо вернулось в черновики",
|
||||
"Send failed: {error}": "Не удалось отправить: {error}",
|
||||
"Send invites": "Отправить приглашения",
|
||||
"Send scheduled for {when}": "Отправка запланирована на {when}",
|
||||
"Send without a subject?": "Отправить без темы?",
|
||||
"Share “{name}”": "Открыть доступ к «{name}»",
|
||||
"Sharing updated": "Общий доступ обновлён",
|
||||
"Show": "Показать",
|
||||
"Show quoted text": "Показать цитируемый текст",
|
||||
"Show this in the compose picker": "Предлагать при написании",
|
||||
"Sign out other sessions?": "Завершить другие сеансы?",
|
||||
"Sign out others": "Завершить другие",
|
||||
"Stop sharing “{name}”?": "Закрыть общий доступ к «{name}»?",
|
||||
"Switch to {theme}": "Переключить на {theme}",
|
||||
"Template": "Шаблон",
|
||||
"Template name": "Название шаблона",
|
||||
"The contacts in it go too.": "Контакты внутри тоже будут удалены.",
|
||||
"The default identity is always offered when composing": "Профиль по умолчанию всегда предлагается при написании",
|
||||
"The new passwords don't match": "Новые пароли не совпадают",
|
||||
"The server scheduled this for {when}, not the time requested.": "Сервер запланировал это на {when}, а не на запрошенное время.",
|
||||
"This date cannot be changed on its own": "Эту дату нельзя изменить отдельно",
|
||||
"This occurrence": "Только это повторение",
|
||||
"Too many attempts. Please wait a few minutes and try again.": "Слишком много попыток. Подождите несколько минут и попробуйте снова.",
|
||||
"Try another search.": "Попробуйте другой запрос.",
|
||||
"Try different keywords or filters.": "Попробуйте другие слова или фильтры.",
|
||||
"Two-factor authentication is off": "Двухфакторная аутентификация выключена",
|
||||
"Undo window: {seconds}s": "Время на отмену: {seconds} с",
|
||||
"You're all caught up": "Всё прочитано",
|
||||
"Your browser refused the request: {error}": "Браузер отклонил запрос: {error}",
|
||||
"Your browser will ask whether to open mail links in ihasmail": "Браузер спросит, открывать ли почтовые ссылки в ihasmail",
|
||||
"Your message mentions an attachment, but nothing is attached.": "В письме упомянуто вложение, но ничего не приложено.",
|
||||
"event": "событие",
|
||||
"Hide password": "Скрыть пароль",
|
||||
"Show password": "Показать пароль",
|
||||
"Settings reset to defaults": "Настройки сброшены к значениям по умолчанию",
|
||||
"Removed. Mail links will open in whatever your browser falls back to.": "Удалено. Почтовые ссылки будут открываться там, куда переключится браузер.",
|
||||
"Edit rule": "Изменить правило",
|
||||
"Address copied": "Адрес скопирован",
|
||||
"Search or create label": "Найдите или создайте метку",
|
||||
"New label name": "Название новой метки",
|
||||
"That folder no longer exists. Showing your inbox instead.": "Этой папки больше нет. Показаны входящие.",
|
||||
"Labels updated": "Метки обновлены",
|
||||
"Unsubscribe message prepared — just hit Send": "Письмо об отписке готово — осталось нажать «Отправить»",
|
||||
"Maximize": "Развернуть",
|
||||
"Full screen": "Во весь экран",
|
||||
"Resize panes": "Изменить размер панелей",
|
||||
"QR code": "QR-код",
|
||||
"Draft discarded": "Черновик удалён",
|
||||
"Draft saved": "Черновик сохранён",
|
||||
"Emptying folder…": "Папка очищается…",
|
||||
"Nothing unread here": "Здесь нет непрочитанного",
|
||||
},
|
||||
plurals: {
|
||||
/*
|
||||
@@ -872,5 +1049,20 @@ export const catalog: Catalog = {
|
||||
// Impersonal, so the count needs no agreement at all — which is what
|
||||
// Russian interfaces actually do here.
|
||||
"{n} selected": { one: "Выбрано: {n}", few: "Выбрано: {n}", many: "Выбрано: {n}", other: "Выбрано: {n}" },
|
||||
"{n} messages will be permanently deleted.": { one: "{n} письмо будет удалено безвозвратно.", few: "{n} письма будут удалены безвозвратно.", many: "{n} писем будут удалены безвозвратно.", other: "{n} письма будут удалены безвозвратно." },
|
||||
"Signed out {n} other sessions": { one: "Завершён {n} другой сеанс", few: "Завершено {n} других сеанса", many: "Завершено {n} других сеансов", other: "Завершено {n} других сеанса" },
|
||||
"applied to {matched} of {n} messages": { one: "применён к {matched} из {n} письма", few: "применён к {matched} из {n} писем", many: "применён к {matched} из {n} писем", other: "применён к {matched} из {n} писем" },
|
||||
"Move {n} messages to…": { one: "Переместить {n} письмо в…", few: "Переместить {n} письма в…", many: "Переместить {n} писем в…", other: "Переместить {n} письма в…" },
|
||||
"This permanently deletes the folder and its {n} messages.": { one: "Папка и {n} письмо в ней будут удалены безвозвратно.", few: "Папка и {n} письма в ней будут удалены безвозвратно.", many: "Папка и {n} писем в ней будут удалены безвозвратно.", other: "Папка и {n} письма в ней будут удалены безвозвратно." },
|
||||
"Added {n} contacts": { one: "Добавлен {n} контакт", few: "Добавлено {n} контакта", many: "Добавлено {n} контактов", other: "Добавлено {n} контакта" },
|
||||
"{n} people will lose access. The contacts in it are not affected.": { one: "{n} человек потеряет доступ. Контакты внутри не затрагиваются.", few: "{n} человека потеряют доступ. Контакты внутри не затрагиваются.", many: "{n} человек потеряют доступ. Контакты внутри не затрагиваются.", other: "{n} человека потеряют доступ. Контакты внутри не затрагиваются." },
|
||||
"Imported {n} contacts": { one: "Импортирован {n} контакт", few: "Импортировано {n} контакта", many: "Импортировано {n} контактов", other: "Импортировано {n} контакта" },
|
||||
"Saved for this date. {names} apply to the whole series and were left unchanged.": { one: "Сохранено для этой даты. {names} относится ко всей серии и осталось без изменений.", few: "Сохранено для этой даты. {names} относятся ко всей серии и остались без изменений.", many: "Сохранено для этой даты. {names} относятся ко всей серии и остались без изменений.", other: "Сохранено для этой даты. {names} относятся ко всей серии и остались без изменений." },
|
||||
"{n} people will lose access. Events in it are not affected.": { one: "{n} человек потеряет доступ. События внутри не затрагиваются.", few: "{n} человека потеряют доступ. События внутри не затрагиваются.", many: "{n} человек потеряют доступ. События внутри не затрагиваются.", other: "{n} человека потеряют доступ. События внутри не затрагиваются." },
|
||||
"{n} messages could not be deleted": { one: "Не удалось удалить {n} письмо", few: "Не удалось удалить {n} письма", many: "Не удалось удалить {n} писем", other: "Не удалось удалить {n} письма" },
|
||||
"({n} deleted first)": { one: "(перед этим удалено: {n})", few: "(перед этим удалено: {n})", many: "(перед этим удалено: {n})", other: "(перед этим удалено: {n})" },
|
||||
"Marked {n} messages as read": { one: "{n} письмо отмечено как прочитанное", few: "{n} письма отмечены как прочитанные", many: "{n} писем отмечены как прочитанные", other: "{n} письма отмечены как прочитанные" },
|
||||
"in {n} folders": { one: "в {n} папке", few: "в {n} папках", many: "в {n} папках", other: "в {n} папках" },
|
||||
"Deleted {n} messages": { one: "Удалено {n} письмо", few: "Удалено {n} письма", many: "Удалено {n} писем", other: "Удалено {n} письма" },
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user