diff --git a/changelog.json b/changelog.json index f868d8c..e73217f 100644 --- a/changelog.json +++ b/changelog.json @@ -5,12 +5,14 @@ "en": [ "Opening the Awards panel no longer pulls the whole logbook several times at once — a large log briefly took gigabytes of memory.", "Awards: the Missing refs button now only appears where it means something — a worldwide award like POTA could only ever answer “nothing found”.", - "Worked before: a prefixed call like ZA/OE8NDR matched every other visitor to that country instead of that one operator." + "Worked before: a prefixed call like ZA/OE8NDR matched every other visitor to that country instead of that one operator.", + "French: seventeen strings were still in English, the whole update panel among them, plus Spot lifetime and Chase new grids." ], "fr": [ "Ouvrir le panneau Awards ne tire plus plusieurs fois le journal entier en même temps — un gros log occupait brièvement des gigaoctets de mémoire.", "Awards : le bouton Réf. manquantes n’apparaît plus que là où il a un sens — un diplôme mondial comme POTA ne pouvait répondre que « aucun manque ».", - "Déjà contacté : un indicatif préfixé comme ZA/OE8NDR rapprochait tous les autres visiteurs du pays au lieu de ce seul opérateur." + "Déjà contacté : un indicatif préfixé comme ZA/OE8NDR rapprochait tous les autres visiteurs du pays au lieu de ce seul opérateur.", + "Français : dix-sept textes étaient restés en anglais, dont tout le panneau de mise à jour, la durée de vie des spots et Chasser les nouveaux locators." ] }, { diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index 0cd08c9..5ad6e9a 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -599,6 +599,7 @@ const fr: Dict = { 'awards.followHint': 'Diplômes affichés dans l’onglet Awards. Laisse la colonne de droite vide pour tous les afficher.', 'awards.available': 'Tous les diplômes', 'awards.followed': 'Suivis', 'awards.search': 'Filtrer…', 'awards.addAll': 'Tout ajouter', 'awards.clear': 'Vider', 'awards.allTracked': 'Tous les diplômes sont suivis.', 'awards.noneFollowed': 'Aucun pour l’instant — l’onglet Awards les montre tous.', 'sec.awards': 'Diplômes', 'sec.cat': 'Interface CAT', 'sec.rotator': 'Rotator', 'sec.winkeyer': 'Manipulateur CW', 'sec.antenna': 'Antenne motorisée', 'sec.antgenius': 'Antenna Genius', 'sec.tunergenius': 'Tuner Genius', 'sec.pgxl': 'Amplificateur', 'sec.psu': 'Alimentation', + 'upd.available': 'OpsLog v{v} disponible', 'upd.current': 'Tu es en v{v}.', 'upd.install': 'Mettre à jour maintenant', 'upd.download': 'Télécharger', 'upd.later': 'Plus tard', 'upd.downloading': 'Téléchargement…', 'upd.installing': 'Installation…', 'upd.restartNote': 'OpsLog redémarrera sur la nouvelle version.', 'upd.retry': 'Réessayer', 'upd.browser': 'Ouvrir la page', 'psu.title': 'Alimentation de laboratoire (Modbus RTU)', 'psu.hint': 'Une alimentation programmable sur port série — BSIDE, Wanptek et les autres alimentations Modbus RTU utilisant les codes fonction 03 et 06. OpsLog affiche ce qu’elle débite et commute sa sortie.', 'psu.enable': 'Piloter cette alimentation', 'psu.port': 'Port COM', 'psu.baud': 'Vitesse', 'psu.address': 'Adresse Modbus', 'psu.wireHint': '9600 bauds, 8 bits de données, sans parité, 1 bit de stop, adresse 1 — les réglages d’usine. Ne les changez ici que si vous les avez changés sur l’alimentation.', 'psu.writeScope': 'OpsLog n’écrit jamais que la marche/arrêt de la sortie. La tension, le courant et les protections sont lus et affichés, jamais modifiés — ils restent sur la face avant de l’alimentation.', 'psu.output': 'Sortie', 'psu.setTo': 'réglée sur', 'psu.tripped': 'PROTECTION DÉCLENCHÉE', 'psu.offline': 'Ne répond pas', 'sec.flex': 'FlexRadio', 'sec.audio': 'Périphériques audio', // Panneau Manipulateur CW @@ -705,7 +706,7 @@ const fr: Dict = { 'clu.muteWorkedHint': '(elles restent dans la liste, simplement discrètes — la couleur reste pour ce qui est à faire)', 'clu.slotHighlight': 'Colorer les stations non contactées sur cette bande et ce mode', 'clu.slotHighlightHint': "(par indicatif, quel que soit le statut de l'entité)", - 'bo.open': 'ouvert', 'bo.liveTip': '{band} est ouvert — {n} stations, ~{km} km, {sector}{season}. Cliquer pour le bandmap.', 'bo.enable': 'Surveiller les ouvertures de bande', 'bo.enableHint': "(10, 12, 6, 4 et 2 m. Activer ajoute les deux nœuds RBN et souscrit au flux PSK Reporter — la détection a besoin de bien plus d oreilles qu un cluster ne peut en fournir.)", 'bo.feedUp': 'Flux PSK Reporter actif — {n} décodages vus', 'bo.feedDown': 'Flux PSK Reporter inactif — il faut ton locator, et un instant pour se connecter', 'clu.workedSameSlot': 'Déjà contacté seulement sur le même slot', + 'bo.open': 'ouvert', 'bo.liveTip': '{band} est ouvert — {n} stations, ~{km} km, {sector}{season}. Cliquer pour le bandmap.', 'bo.enable': 'Surveiller les ouvertures de bande', 'bo.enableHint': "(10, 12, 6, 4 et 2 m. Activer ajoute les deux nœuds RBN et souscrit au flux PSK Reporter — la détection a besoin de bien plus d oreilles qu un cluster ne peut en fournir.)", 'bo.feedUp': 'Flux PSK Reporter actif — {n} décodages vus', 'bo.feedDown': 'Flux PSK Reporter inactif — il faut ton locator, et un instant pour se connecter', 'clu.workedSameSlot': 'Déjà contacté seulement sur le même slot', 'clu.spotTtl': 'Durée de vie des spots', 'clu.spotTtlNever': 'Garder', 'clu.spotTtlHint': 'minutes — les spots plus vieux que ça sont retirés de la liste et des bandmaps. 0 les conserve.', 'clu.chaseGrids': 'Chasser les nouveaux locators', 'clu.chaseGridsHint': '(apprend les locators depuis TES propres décodages WSJT-X ET depuis PSK Reporter, et les garde dans leur propre base pour que le cluster les affiche dès la première seconde)', 'clu.chaseGridsStat': '{n} locators connus — {p} en attente d’écriture', 'clu.workedSameSlotHint': '— un spot n\'affiche « contacté » que si vous avez contacté cet indicatif sur la MÊME bande et le MÊME mode, pas juste n\'importe où. Se combine avec le groupage des modes numériques (Réglages → Général) : activé, un indicatif contacté en 20m FT8 compte aussi comme contacté pour un spot 20m FT4 ; désactivé, FT8 et FT4 sont des slots distincts.', 'bk.hintMysql': "À la fermeture (1×/jour) OpsLog sauvegarde le SQLite local (config) ET exporte le log MySQL partagé en ADIF — opslog-log-.adi — pour protéger tes contacts même s'ils sont sur le serveur. La rotation garde les N derniers de chaque.", 'bk.hint': "OpsLog peut copier la base SQLite dans un dossier de ton choix à la fermeture, une fois par jour. La rotation garde les N dernières copies et supprime les plus anciennes.", @@ -840,7 +841,7 @@ const fr: Dict = { 'awed.importKeepMine': 'Garder le mien', 'awed.importReplace': 'Remplacer le mien', 'awed.importCopy': 'Importer en {code}-2', - 'qctx.selected': '{n} QSO sélectionné(s)', 'qctx.fixCountry': 'Corriger pays et zones depuis cty.dat', 'qctx.updateQrz': 'Mettre à jour depuis les annuaires', 'qctx.updateClublog': 'Mettre à jour depuis ClubLog (exceptions)', 'qctx.updateUlsCounty': 'Mettre à jour le comté US depuis la base ULS', 'qctx.updateUlsCountyTitle': 'Recalcule le comté des contacts US sélectionnés depuis la base ULS téléchargée, en remplaçant la valeur enregistrée. Les contacts hors US et les indicatifs absents de la base ne sont pas touchés.', 'qctx.fromUlsLabel': 'depuis la base ULS', 'qctx.sendQslEmail': 'Envoyer la QSL OpsLog par e-mail', 'qctx.sendRecording': "Envoyer l'enregistrement par e-mail", 'qctx.bulkEdit': "Édition groupée d'un champ… ({n})", 'qctx.exportSelectedAdif': 'Exporter la sélection en ADIF ({n})', 'qctx.exportFilteredAdif': 'Exporter la vue filtrée en ADIF (sans limite)', 'qctx.exportSelectedCabrillo': 'Exporter la sélection en Cabrillo ({n})', 'qctx.exportFilteredCabrillo': 'Exporter la vue filtrée en Cabrillo (sans limite)', 'qctx.sendTo': 'Envoyer vers {name}', 'qctx.delete': 'Supprimer {n} QSO…', + 'qctx.selected': '{n} QSO sélectionné(s)', 'qctx.fixCountry': 'Corriger pays et zones depuis cty.dat', 'qctx.updateQrz': 'Mettre à jour depuis les annuaires', 'qctx.updateClublog': 'Mettre à jour depuis ClubLog (exceptions)', 'qctx.updateUlsCounty': 'Mettre à jour le comté US depuis la base ULS', 'qctx.updateUlsCountyTitle': 'Recalcule le comté des contacts US sélectionnés depuis la base ULS téléchargée, en remplaçant la valeur enregistrée. Les contacts hors US et les indicatifs absents de la base ne sont pas touchés.', 'qctx.fromUlsLabel': 'depuis la base ULS', 'qctx.sendQslEmail': 'Envoyer la QSL OpsLog par e-mail', 'qctx.sendRecording': "Envoyer l'enregistrement par e-mail", 'qctx.bulkEdit': "Édition groupée d'un champ… ({n})", 'qctx.exportSelectedAdif': 'Exporter la sélection en ADIF ({n})', 'qctx.exportSelectedFields': 'Exporter la sélection — choisir les champs… ({n})', 'qctx.exportFilteredAdif': 'Exporter la vue filtrée en ADIF (sans limite)', 'qctx.exportSelectedCabrillo': 'Exporter la sélection en Cabrillo ({n})', 'qctx.exportFilteredCabrillo': 'Exporter la vue filtrée en Cabrillo (sans limite)', 'qctx.sendTo': 'Envoyer vers {name}', 'qctx.delete': 'Supprimer {n} QSO…', 'exp.title': 'Exporter en ADIF', 'exp.desc': 'Choisissez les champs à écrire.', 'exp.stdTitle': 'Champs ADIF standard', 'exp.stdDesc': 'Uniquement les champs officiels ADIF 3.1.7 — idéal pour l’envoi vers d’autres carnets (LoTW, QRZ, Club Log…).', 'exp.allTitle': 'Tous les champs OpsLog', 'exp.allDesc': 'Tout, y compris les champs spécifiques à OpsLog et les balises applicatives — une sauvegarde complète ré-importable ici.', diff --git a/i18n_test.go b/i18n_test.go new file mode 100644 index 0000000..a7229f8 --- /dev/null +++ b/i18n_test.go @@ -0,0 +1,59 @@ +package main + +import ( + "os" + "regexp" + "strings" + "testing" +) + +// Every user-visible string ships in BOTH languages. That is a project rule, +// and until now it was only a rule: seventeen keys had drifted into English +// only, among them the whole update panel and two DX-cluster settings, found by +// a French operator photographing his own screen. +// +// A key present in one dictionary and not the other falls back to the key +// itself, so the interface shows "clu.spotTtl" where a label belongs — or, as +// here, the English text, which reads as deliberate and is not. +func TestEveryStringIsInBothLanguages(t *testing.T) { + src, err := os.ReadFile("frontend/src/lib/i18n.tsx") + if err != nil { + t.Fatalf("read i18n.tsx: %v", err) + } + en := dictKeys(t, string(src), "const en: Dict = {") + fr := dictKeys(t, string(src), "const fr: Dict = {") + + if len(en) == 0 || len(fr) == 0 { + t.Fatal("one of the dictionaries came back empty — this test has stopped checking anything") + } + for k := range en { + if !fr[k] { + t.Errorf("%q has no French — a French operator sees the English string, or the key itself", k) + } + } + for k := range fr { + if !en[k] { + t.Errorf("%q exists only in French — an English operator sees the key", k) + } + } +} + +// dictKeys collects the keys of one dictionary literal: from its opening line +// to the closing brace in column 0. +func dictKeys(t *testing.T, src, opening string) map[string]bool { + t.Helper() + i := strings.Index(src, opening) + if i < 0 { + t.Fatalf("%q not found — the dictionaries have been renamed and this test needs updating", opening) + } + body := src[i+len(opening):] + if j := strings.Index(body, "\n};"); j >= 0 { + body = body[:j] + } + keyRe := regexp.MustCompile(`'([a-zA-Z0-9_.]+)'\s*:`) + out := map[string]bool{} + for _, m := range keyRe.FindAllStringSubmatch(body, -1) { + out[m[1]] = true + } + return out +}