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:
2026-08-31 14:14:30 -07:00
parent a4f7d386a6
commit a6863e98cc
52 changed files with 2101 additions and 191 deletions
+192
View File
@@ -862,10 +862,202 @@ export const catalog: Catalog = {
"Saved {when}": "Enregistré {when}",
"Unsaved": "Non enregistré",
"Agenda from {date}": "Agenda à partir du {date}",
// ── Toasts, dialogs and computed labels (second extraction pass) ───
"(no subject)": "(sans objet)",
"(skipped: {actions})": "(ignoré : {actions})",
"Activate": "Activer",
"Add a contact or import a vCard file.": "Ajoutez un contact ou importez un fichier vCard.",
"Added to your calendar": "Ajouté à votre agenda",
"All events in this calendar will be deleted.": "Tous les événements de cet agenda seront supprimés.",
"All occurrences": "Toutes les occurrences",
"An occurrence cannot be moved to another calendar on its own": "Une occurrence ne peut pas être déplacée seule vers un autre agenda",
"Anything signed in with this password stops working immediately.": "Tout ce qui est connecté avec ce mot de passe cesse de fonctionner immédiatement.",
"App password revoked": "Mot de passe dapplication révoqué",
"Applies to every date in the series.": "Sapplique à toutes les dates de la série.",
"Applies to this date only.": "Sapplique uniquement à cette date.",
"Apply to series": "Appliquer à la série",
"Applying filter to existing messages…": "Application du filtre aux messages existants…",
"Attachments are still uploading": "Les pièces jointes sont encore en cours denvoi",
"Calendar saved": "Agenda enregistré",
"Categorised as {name}": "Catégorie « {name} » attribuée",
"Category cleared": "Catégorie retirée",
"Change this event?": "Modifier cet événement ?",
"Choose a calendar": "Choisissez un agenda",
"Choose an address book": "Choisissez un carnet dadresses",
"Colour updated": "Couleur modifiée",
"Contact created": "Contact créé",
"Contact deleted": "Contact supprimé",
"Contact saved": "Contact enregistré",
"Copied": "Copié",
"Copy of {title}": "Copie de {title}",
"Could not cancel: {error}": "Impossible dannuler : {error}",
"Could not copy": "Impossible de copier",
"Could not copy the address": "Impossible de copier ladresse",
"Could not empty folder: {error}": "Impossible de vider le dossier : {error}",
"Could not load source: {error}": "Impossible de charger la source : {error}",
"Could not mark as read: {error}": "Impossible de marquer comme lu : {error}",
"Could not save draft: {error}": "Impossible denregistrer le brouillon : {error}",
"Could not save filter: {error}": "Impossible denregistrer le filtre : {error}",
"Could not save filters: {error}": "Impossible denregistrer les filtres : {error}",
"Could not send the receipt: {error}": "Impossible denvoyer laccusé de lecture : {error}",
"Could not sign in.": "Connexion impossible.",
"Could not store image: {error}": "Impossible denregistrer limage : {error}",
"Could not update labels: {error}": "Impossible de mettre à jour les libellés : {error}",
"Could not update the Scheduled folder: {error}": "Impossible de mettre à jour le dossier Programmés : {error}",
"Could not update: {error}": "Impossible de mettre à jour : {error}",
"Create": "Créer",
"Create an address book first": "Créez dabord un carnet dadresses",
"Custom colour removed": "Couleur personnalisée retirée",
"Deactivate": "Désactiver",
"Delete failed: {error}": "Échec de la suppression : {error}",
"Delete forever": "Supprimer définitivement",
"Delete script “{name}”?": "Supprimer le script « {name} » ?",
"Delete this event?": "Supprimer cet événement ?",
"Delete this identity?": "Supprimer cette identité ?",
"Delete {name}?": "Supprimer {name} ?",
"Deleted": "Supprimé",
"Deletes the whole series. This cannot be undone.": "Supprime toute la série. Cette action est irréversible.",
"Did you forget the attachment?": "Avez-vous oublié la pièce jointe ?",
"Discard": "Abandonner",
"Discard this draft?": "Abandonner ce brouillon ?",
"Edit calendar": "Modifier lagenda",
"Edit event": "Modifier l’événement",
"Edit identity": "Modifier lidentité",
"Edit template": "Modifier le modèle",
"End must be after start": "La fin doit être postérieure au début",
"Event duplicated": "Événement dupliqué",
"Everything inside it goes too.": "Tout ce quil contient sera également supprimé.",
"Filter created": "Filtre créé",
"Filter created — it will run on new mail": "Filtre créé : il sappliquera au courrier entrant",
"Filter saved": "Filtre enregistré",
"Filter saved — it will run on new mail": "Filtre enregistré : il sappliquera au courrier entrant",
"Filter saved, but applying it failed: {error}": "Filtre enregistré, mais son application a échoué : {error}",
"Filters saved": "Filtres enregistrés",
"Folder changed, but its filter rules could not be updated: {error}": "Dossier modifié, mais ses règles de filtrage nont pas pu être mises à jour : {error}",
"Folder created": "Dossier créé",
"Folder deleted": "Dossier supprimé",
"Folder name": "Nom du dossier",
"Folder name (use / for a subfolder, e.g. Work/Invoices)": "Nom du dossier (utilisez / pour un sous-dossier, par ex. Travail/Factures)",
"Folder name (use / for subfolders, e.g. Work/Invoices)": "Nom du dossier (utilisez / pour des sous-dossiers, par ex. Travail/Factures)",
"Folder “{name}” created": "Dossier « {name} » créé",
"Forwarded": "Transféré",
"Hand to the server, held until {when} (Ctrl+Enter)": "Confié au serveur, retenu jusqu’à {when} (Ctrl+Entrée)",
"Hide": "Masquer",
"Hide quoted text": "Masquer le texte cité",
"Hide this from the compose picker": "Ne pas proposer à la rédaction",
"Identity saved": "Identité enregistrée",
"Images in signatures need the Files feature, which this account doesn't have.": "Les images dans les signatures nécessitent la fonction Fichiers, dont ce compte ne dispose pas.",
"Invalid address: {address}": "Adresse invalide : {address}",
"Invalid username or password.": "Nom dutilisateur ou mot de passe incorrect.",
"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?": "Elle relève dune modification appliquée à cette occurrence et à toutes les suivantes, que le serveur ne modifie que dun bloc. Lappliquer à toute la série ?",
"Label name": "Nom du libellé",
"Larger than {size} MB limit": "Dépasse la limite de {size} Mo",
"Message sent": "Message envoyé",
"Move failed: {error}": "Échec du déplacement : {error}",
"Move “{name}”": "Déplacer « {name} »",
"Moved": "Déplacé",
"Network error. Please check your connection.": "Erreur réseau. Vérifiez votre connexion.",
"New all-day event on {date}": "Nouvel événement sur la journée du {date}",
"New event at {time}": "Nouvel événement à {time}",
"New identity": "Nouvelle identité",
"No Archive folder found. Create one named “Archive” first.": "Aucun dossier darchive trouvé. Créez-en dabord un nommé « Archive ».",
"No contacts yet": "Pas encore de contacts",
"No longer shared": "Nest plus partagé",
"No matches": "Aucune correspondance",
"No new mail in your inbox.": "Aucun nouveau message dans votre boîte de réception.",
"No results": "Aucun résultat",
"Nothing here": "Rien ici",
"Only Deleted Items and Junk Mail can be emptied.": "Seuls la corbeille et les indésirables peuvent être vidés.",
"Please add at least one recipient": "Ajoutez au moins un destinataire",
"Please use an image under 512 KB for signatures.": "Utilisez une image de moins de 512 Ko pour les signatures.",
"Preview": "Aperçu",
"Read receipt sent": "Accusé de lecture envoyé",
"Remove attachments that failed to upload first": "Retirez dabord les pièces jointes dont lenvoi a échoué",
"Removed from calendar": "Retiré de lagenda",
"Removes this date and leaves the rest of the series.": "Retire cette date et laisse le reste de la série.",
"Rename address book": "Renommer le carnet dadresses",
"Rename category": "Renommer la catégorie",
"Replied": "Répondu",
"Report spam (!)": "Signaler comme spam (!)",
"Response sent": "Réponse envoyée",
"Revoke “{name}”?": "Révoquer « {name} » ?",
"Script has errors": "Le script contient des erreurs",
"Script is valid": "Le script est valide",
"Script name is required": "Un nom de script est requis",
"Script saved": "Script enregistré",
"Send (Ctrl+Enter)": "Envoyer (Ctrl+Entrée)",
"Send anyway": "Envoyer quand même",
"Send cancelled — the message is back in Drafts": "Envoi annulé : le message est de retour dans les brouillons",
"Send failed: {error}": "Échec de lenvoi : {error}",
"Send invites": "Envoyer les invitations",
"Send scheduled for {when}": "Envoi programmé pour {when}",
"Send without a subject?": "Envoyer sans objet ?",
"Share “{name}”": "Partager « {name} »",
"Sharing updated": "Partage mis à jour",
"Show": "Afficher",
"Show quoted text": "Afficher le texte cité",
"Show this in the compose picker": "Proposer à la rédaction",
"Sign out other sessions?": "Déconnecter les autres sessions ?",
"Sign out others": "Déconnecter les autres",
"Stop sharing “{name}”?": "Arrêter le partage de « {name} » ?",
"Switch to {theme}": "Passer à {theme}",
"Template": "Modèle",
"Template name": "Nom du modèle",
"The contacts in it go too.": "Les contacts quil contient seront également supprimés.",
"The default identity is always offered when composing": "Lidentité par défaut est toujours proposée à la rédaction",
"The new passwords don't match": "Les nouveaux mots de passe ne correspondent pas",
"The server scheduled this for {when}, not the time requested.": "Le serveur la programmé pour {when}, et non pour lheure demandée.",
"This date cannot be changed on its own": "Cette date ne peut pas être modifiée seule",
"This occurrence": "Cette occurrence",
"Too many attempts. Please wait a few minutes and try again.": "Trop de tentatives. Attendez quelques minutes et réessayez.",
"Try another search.": "Essayez une autre recherche.",
"Try different keywords or filters.": "Essayez dautres mots-clés ou filtres.",
"Two-factor authentication is off": "Lauthentification à deux facteurs est désactivée",
"Undo window: {seconds}s": "Délai dannulation : {seconds} s",
"You're all caught up": "Vous êtes à jour",
"Your browser refused the request: {error}": "Votre navigateur a refusé la demande : {error}",
"Your browser will ask whether to open mail links in ihasmail": "Votre navigateur vous demandera sil faut ouvrir les liens de courrier dans ihasmail",
"Your message mentions an attachment, but nothing is attached.": "Votre message mentionne une pièce jointe, mais rien nest joint.",
"event": "événement",
"Hide password": "Masquer le mot de passe",
"Show password": "Afficher le mot de passe",
"Settings reset to defaults": "Paramètres réinitialisés",
"Removed. Mail links will open in whatever your browser falls back to.": "Retiré. Les liens de courrier souvriront dans ce que votre navigateur utilise par défaut.",
"Edit rule": "Modifier la règle",
"Address copied": "Adresse copiée",
"Search or create label": "Rechercher ou créer un libellé",
"New label name": "Nom du nouveau libellé",
"That folder no longer exists. Showing your inbox instead.": "Ce dossier nexiste plus. Votre boîte de réception est affichée à la place.",
"Labels updated": "Libellés mis à jour",
"Unsubscribe message prepared — just hit Send": "Message de désabonnement préparé : il ne reste qu’à envoyer",
"Maximize": "Agrandir",
"Full screen": "Plein écran",
"Resize panes": "Redimensionner les volets",
"QR code": "QR code",
"Draft discarded": "Brouillon abandonné",
"Draft saved": "Brouillon enregistré",
"Emptying folder…": "Vidage du dossier…",
"Nothing unread here": "Rien de non lu ici",
},
plurals: {
"{n} messages": { one: "{n} message", other: "{n} messages" },
"{n} selected": { one: "{n} sélectionné", other: "{n} sélectionnés" },
"{n} conversations": { one: "{n} conversation", other: "{n} conversations" },
"{n} messages will be permanently deleted.": { one: "{n} message sera définitivement supprimé.", other: "{n} messages seront définitivement supprimés." },
"Signed out {n} other sessions": { one: "{n} autre session déconnectée", other: "{n} autres sessions déconnectées" },
"applied to {matched} of {n} messages": { one: "appliqué à {matched} message sur {n}", other: "appliqué à {matched} messages sur {n}" },
"Move {n} messages to…": { one: "Déplacer {n} message vers…", other: "Déplacer {n} messages vers…" },
"This permanently deletes the folder and its {n} messages.": { one: "Cela supprime définitivement le dossier et son {n} message.", other: "Cela supprime définitivement le dossier et ses {n} messages." },
"Added {n} contacts": { one: "{n} contact ajouté", other: "{n} contacts ajoutés" },
"{n} people will lose access. The contacts in it are not affected.": { one: "{n} personne perdra laccès. Les contacts quil contient ne sont pas affectés.", other: "{n} personnes perdront laccès. Les contacts quil contient ne sont pas affectés." },
"Imported {n} contacts": { one: "{n} contact importé", other: "{n} contacts importés" },
"Saved for this date. {names} apply to the whole series and were left unchanged.": { one: "Enregistré pour cette date. {names} sapplique à toute la série et na pas été modifié.", other: "Enregistré pour cette date. {names} sappliquent à toute la série et nont pas été modifiés." },
"{n} people will lose access. Events in it are not affected.": { one: "{n} personne perdra laccès. Les événements quil contient ne sont pas affectés.", other: "{n} personnes perdront laccès. Les événements quil contient ne sont pas affectés." },
"{n} messages could not be deleted": { one: "{n} message na pas pu être supprimé", other: "{n} messages nont pas pu être supprimés" },
"({n} deleted first)": { one: "({n} supprimé auparavant)", other: "({n} supprimés auparavant)" },
"Marked {n} messages as read": { one: "{n} message marqué comme lu", other: "{n} messages marqués comme lus" },
"in {n} folders": { one: "dans {n} dossier", other: "dans {n} dossiers" },
"Deleted {n} messages": { one: "{n} message supprimé", other: "{n} messages supprimés" },
},
};