i18n: fix review findings and polish ru/es strings

Fixes the 51 P0–P2 findings from the full review of the i18n branch and
the issues found while checking every new string on screen in ru and es.

- Translate errors and statuses that still reached ru/es in English:
  feed and tile errors, provider statuses, the ALPR chip, the traffic
  mode chip, voice plan step labels, fire feed values, receiver bands,
  scene save reasons and the Cesium attribution link.
- Show preset city names in the UI language (src/ui/placeNames.js);
  data and everything sent to the model stay English.
- Share layer names between the layer panel, HUD and voice card
  (src/ui/layerNames.js).
- Keep long ru/es strings whole: voice card error lines, analyst
  titles, the voice tier status, the Nepal evidence button, the ALPR
  retry chip. English layout is unchanged.
- Ask before a language switch drops unsaved work.
- Catalog fixes: glossary terms (пуск, пресет), gender agreement and
  consistent "peticiones" in es, fewer calques in both languages.
- Fix English text that was itself wrong (e.g. "Exit cockpit to fly to
  a weather", raw tool ids in voice plan steps).

Catalogs: es/ru 2535 messages each, complete.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
German KhokhlovandClaude Opus 5.5 committed 2026-10-15 23:49:52 +04:00
1 parent 8029747b7e
commit 00704ddc89
212 files changed
+9871 -909

No files matched your search

+76 -35
View File
@@ -11,7 +11,7 @@
// Messages are the English literals passed to t(), tn() and msg() in src/,
// plus the markup opted in with data-i18n* in src/ui/templates and index.html.
// It also lists UI text that is not marked at all (see findUnmarked).
import { readFileSync, writeFileSync } from 'node:fs';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -62,23 +62,27 @@ function decodeHtml(text) {
const normalize = (text) => text.replace(/\s+/g, ' ').trim();
function trackedFiles(...pathspecs) {
return execFileSync(
'git',
[
'ls-files',
'--cached',
'--others',
'--exclude-standard',
'--',
...pathspecs,
],
{
cwd: ROOT,
encoding: 'utf8',
},
)
.split('\n')
.filter(Boolean);
return (
execFileSync(
'git',
[
'ls-files',
'--cached',
'--others',
'--exclude-standard',
'--',
...pathspecs,
],
{
cwd: ROOT,
encoding: 'utf8',
},
)
.split('\n')
.filter(Boolean)
// The index still lists a file moved or deleted without git mv / git rm.
.filter((file) => existsSync(path.join(ROOT, file)))
);
}
/**
@@ -213,8 +217,8 @@ const JS_SINKS = [
/\bsetAttribute\(\s*['"](?:title|aria-label|aria-valuetext|placeholder|alt)['"]\s*,\s*/g,
/\b(?:createTextNode|insertAdjacentText)\(\s*(?:['"][a-z]+['"]\s*,\s*)?/g,
/\binsertAdjacentHTML\(\s*['"][a-z]+['"]\s*,\s*/g,
/\b(?:showToast|toast|setHint|announce)\(\s*/g,
/(?:^|[{,\s])(?:label|title|tooltip|placeholder|hint|ariaLabel|caption|subtitle|kicker|emptyText|blurb|countLabel)\s*:\s*/gm,
/\b(?:showToast|toast|setHint|announce|say)\(\s*/g,
/(?:^|[{,\s])(?:label|title|tooltip|placeholder|hint|ariaLabel|accessibilityLabel|caption|subtitle|kicker|emptyText|blurb|countLabel)\s*:\s*/gm,
];
const MARKERS = /\b(?:t|tn|msg)\(/y;
@@ -438,6 +442,15 @@ export function readSameAsEnglish(locale) {
/** The English a message shows: its text without the "context|" prefix. */
const englishOf = (text) => text.slice(text.indexOf('|') + 1);
/** A translation with nothing to show: t() falls back to English for it. */
const blank = (value) => value.trim() === '';
/** Words to read, beyond {placeholders}, codes and punctuation. */
const hasLetters = (value) => /\p{L}{2}/u.test(value.replace(/\{[^}]*\}/g, ''));
const STILL_ENGLISH = (text, locale) =>
`"${text}" is still English: translate it, or list it under "${locale}" in src/i18n/same-as-english.json`;
/**
* Compare one catalog with the extracted messages.
* @param {Set<string>} [sameAsEnglish] Messages allowed to read as English.
@@ -464,44 +477,72 @@ export function checkCatalog(
errors.push(`"${text}" needs plural forms ${categories.join('/')}`);
continue;
}
const english = new Set([info.one, englishOf(text)]);
const stillEnglish = [];
for (const category of categories) {
if (typeof entry[category] !== 'string')
const form = entry[category];
if (typeof form !== 'string')
errors.push(`"${text}" lacks the "${category}" form`);
else if (placeholders(entry[category]) !== placeholders(text))
errors.push(
`"${text}" [${category}] placeholders differ: ${entry[category]}`,
);
else if (blank(form))
errors.push(`"${text}" [${category}] is empty: translate it`);
else if (placeholders(form) !== placeholders(text))
errors.push(`"${text}" [${category}] placeholders differ: ${form}`);
else if (english.has(form) && hasLetters(form))
stillEnglish.push(category);
}
if (stillEnglish.length && !sameAsEnglish.has(text))
errors.push(
`${STILL_ENGLISH(text, locale)} (forms ${stillEnglish.join('/')})`,
);
} else if (typeof entry !== 'string') {
errors.push(`"${text}" must be a string`);
} else if (blank(entry)) {
errors.push(`"${text}" is empty: translate it`);
} else if (placeholders(entry) !== placeholders(text)) {
errors.push(`"${text}" placeholders differ: ${entry}`);
} else if (
entry === englishOf(text) &&
/\p{L}{2}/u.test(entry.replace(/\{[^}]*\}/g, '')) &&
hasLetters(entry) &&
!sameAsEnglish.has(text)
) {
errors.push(
`"${text}" is still English: translate it, or list it under "${locale}" in src/i18n/same-as-english.json`,
);
errors.push(STILL_ENGLISH(text, locale));
}
}
const stale = Object.keys(catalog).filter((text) => !messages.has(text));
return { missing, stale, errors };
}
/**
* What `--missing <locale>` prints: every missing entry, ready to translate.
* A template, not a translation: the check rejects a string left empty and a
* plural form left as the English.
*/
export function missingTemplate(locale, catalog, messages) {
const categories = new Intl.PluralRules(locale).resolvedOptions()
.pluralCategories;
const out = {};
for (const text of checkCatalog(locale, catalog, messages, new Set())
.missing) {
const info = messages.get(text);
out[text] = info.plural
? Object.fromEntries(
categories.map((category) => [
category,
category === 'one' ? info.one : text,
]),
)
: '';
}
return out;
}
function main(args) {
const messages = extractMessages();
const missingFor = args.includes('--missing')
? args[args.indexOf('--missing') + 1]
: null;
if (missingFor) {
const catalog = readCatalog(missingFor);
const out = {};
for (const text of checkCatalog(missingFor, catalog, messages).missing) {
const info = messages.get(text);
out[text] = info.plural ? { one: info.one, other: text } : '';
}
const out = missingTemplate(missingFor, readCatalog(missingFor), messages);
console.log(JSON.stringify(out, null, 2));
return 0;
}