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;
}
+41 -5
View File
@@ -439,6 +439,7 @@
"modules": [
"src/ui/layers.js",
"src/ui/layerPanel.js",
"src/ui/layerNames.js",
"src/ui/weatherPanel.js",
"src/ui/railCards.js",
"src/ui/railCardBlocks.js",
@@ -467,6 +468,7 @@
"src/ui/location.js",
"src/ui/locationControls.js",
"src/ui/locationSearch.js",
"src/ui/placeNames.js",
"src/locationStatus.js",
"src/app/stateChannel.js",
"src/i18n/index.js",
@@ -527,7 +529,8 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json"
"src/i18n/locales/ru.json",
"src/ui/layerNames.js"
],
"external": []
},
@@ -581,6 +584,7 @@
"ui-shell": {
"exports": ["./ui/shell"],
"modules": [
"src/ui/placeNames.js",
"src/hudLayouts.js",
"src/hudLayoutPolicy.js",
"src/cyberSonar.js",
@@ -602,6 +606,7 @@
"src/data/layerStateCoordinator.js",
"src/data/layerStateTokenReservations.json",
"src/data/militaryAwarenessEngine.js",
"src/data/installationNames.js",
"src/data/scenePick.js",
"src/keySetupCore.mjs",
"src/localRequestGate.mjs",
@@ -719,7 +724,8 @@
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/ui/unitLabels.js",
"src/ui/keyGuidance.js"
"src/ui/keyGuidance.js",
"src/ui/layerNames.js"
],
"external": ["cesium"]
},
@@ -786,6 +792,7 @@
"src/data/bhoteKoshiFloodPath.js",
"src/maps/catalog.js",
"src/scenes/director.js",
"src/scenes/sceneSaveError.js",
"src/scenes/playbackAdapter.js",
"src/director/playback.js",
"src/scenes/recipes.js",
@@ -1078,7 +1085,8 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json"
"src/i18n/locales/ru.json",
"src/ui/unitLabels.js"
],
"external": ["cesium", "satellite.js"]
},
@@ -1119,6 +1127,7 @@
"src/data/cameraSensitivity.js",
"src/data/installationFeedback.js",
"src/data/militaryInstallationData.js",
"src/data/installationNames.js",
"src/layers/installations/controls.js",
"src/layers/installations/index.js",
"src/layers/installations/ingestion.js",
@@ -1160,6 +1169,7 @@
"src/cockpitMath.js",
"src/data/iconOrientation.js",
"src/data/militaryAwarenessEngine.js",
"src/data/installationNames.js",
"src/layers/awareness/controls.js",
"src/layers/awareness/dependencies.js",
"src/layers/awareness/focus.js",
@@ -1219,6 +1229,7 @@
"src/sources/capability.js",
"src/sources/httpBody.js",
"src/sources/openFreeMap.js",
"src/data/installationNames.js",
"src/sources/militaryTileGeometry.js",
"src/sources/vectorTiles.js",
"src/sources/phaseTiming.js",
@@ -1537,6 +1548,9 @@
"src/voice/referents.js",
"src/voice/layerManifest.js",
"src/voice/speech.js",
"src/ui/layerNames.js",
"src/ui/unitLabels.js",
"src/ui/placeNames.js",
"src/voice/narration.js",
"src/voice/earcon.js",
"src/voice/pointerContext.js",
@@ -1568,6 +1582,7 @@
"./standalone/catalog"
],
"modules": [
"src/ui/placeNames.js",
"src/cyberSonarScene.js",
"src/cyberSonar.js",
"src/hudLayouts.js",
@@ -1710,6 +1725,7 @@
"src/data/meshFloorSampler.js",
"src/data/militaryAwareness.js",
"src/data/militaryAwarenessEngine.js",
"src/data/installationNames.js",
"src/data/militaryFlights.js",
"src/data/militaryInstallationData.js",
"src/data/militaryInstallations.js",
@@ -2005,6 +2021,7 @@
"src/renderGovernor.js",
"src/rightRailPolicy.js",
"src/scenes/director.js",
"src/scenes/sceneSaveError.js",
"src/scenes/playbackAdapter.js",
"src/director/playback.js",
"src/scenes/recipes.js",
@@ -2123,6 +2140,7 @@
"src/voice/control.js",
"src/voice/gevActions.js",
"src/voice/speech.js",
"src/ui/layerNames.js",
"src/voice/earcon.js",
"src/voice/narration.js",
"src/voice/turnMetrics.js",
@@ -2156,6 +2174,7 @@
"src/ui/geoFileImport.js",
"src/data/routeSteps.js",
"src/layers/directions/index.js",
"src/data/pointerOwnerNames.js",
"src/layers/directions/source.js",
"src/data/directions.js",
"src/app/layers/directions.js",
@@ -2486,6 +2505,7 @@
"src/data/local_data/natural_earth/regions.json",
"src/data/local_data/neighborhoods/san-francisco.json",
"src/data/militaryAwarenessEngine.js",
"src/data/installationNames.js",
"src/data/militaryInstallationData.js",
"src/data/modelVisualAnchor.js",
"src/data/motionModel.js",
@@ -2767,6 +2787,7 @@
"src/services/groundFloor.js",
"src/data/routeSteps.js",
"src/layers/directions/index.js",
"src/data/pointerOwnerNames.js",
"src/layers/directions/source.js",
"src/services/terrainHeights.js",
"src/data/terrainHeights.js",
@@ -3016,7 +3037,11 @@
"src/navigationPolicy.js",
"src/ui/navigationController.js",
"src/app/stateChannel.js",
"src/data/cameraArrival.js"
"src/data/cameraArrival.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/i18n/index.js"
],
"external": ["cesium"]
},
@@ -3170,6 +3195,7 @@
"exports": ["./layers/directions"],
"modules": [
"src/layers/directions/index.js",
"src/data/pointerOwnerNames.js",
"src/layers/directions/source.js",
"src/data/routeSteps.js",
"src/i18n/index.js",
@@ -3212,6 +3238,7 @@
"voice-session-controls": {
"exports": ["./voice/session-controls"],
"modules": [
"src/ui/placeNames.js",
"src/voice/sessionCommands.js",
"src/voice/session.js",
"src/voice/control.js",
@@ -3221,6 +3248,8 @@
"src/voice/referents.js",
"src/voice/layerManifest.js",
"src/voice/speech.js",
"src/ui/layerNames.js",
"src/ui/unitLabels.js",
"src/voice/pointerReticle.js",
"src/i18n/index.js",
"src/i18n/locale.js",
@@ -3271,6 +3300,7 @@
"modules": [
"src/layers/installations/source.js",
"src/data/militaryInstallationData.js",
"src/data/installationNames.js",
"src/data/tomtomTiles.js",
"src/sources/capability.js",
"src/sources/httpBody.js",
@@ -3312,6 +3342,7 @@
"src/sources/overpassRoads.js",
"src/sources/httpBody.js",
"src/sources/openFreeMap.js",
"src/data/installationNames.js",
"src/sources/militaryTileGeometry.js",
"src/sources/vectorTiles.js",
"src/sources/phaseTiming.js",
@@ -3496,7 +3527,11 @@
"scripts/qa-transit-controls.mjs",
"scripts/qa-transit-browser.mjs",
"src/layers/transit/source.js",
"src/sources/transitHistory.js"
"src/sources/transitHistory.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json"
],
"external": ["cesium", "pbf"]
},
@@ -3670,6 +3705,7 @@
"src/data/retryableLoad.js",
"src/data/local_data/osm_military_names/names.json",
"src/sources/openFreeMap.js",
"src/data/installationNames.js",
"src/sources/vectorTiles.js",
"src/sources/phaseTiming.js",
"src/data/tomtomTiles.js",
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env node
/**
* qa-scene-dialog-file-drop — the SHARE SCENE dialog's file control accepts a
* file dropped from the desktop, proved in a real browser.
*
* The dialog replaces the browser-drawn "Choose files / No file chosen" line
* with a translated one (src/ui/sceneSharing.js, fileChooser). The native
* input must stay the whole control underneath it: a shrunken 1 px input
* accepts drops only on its own pixel, so dragging a data-pack file onto the
* visible row selected nothing. This harness drops a file onto the centre of
* the visible row through CDP and expects `input.files` to hold it.
*
* Usage:
* node scripts/qa-scene-dialog-file-drop.mjs
*
* Needs the repository's puppeteer (devDependencies). Exit status 0 when the
* drop selects the file, 1 otherwise.
*/
import http from 'node:http';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import puppeteer from 'puppeteer';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const TYPES = {
'.js': 'text/javascript',
'.mjs': 'text/javascript',
'.json': 'application/json',
'.html': 'text/html',
};
const PAGE = `<!doctype html><html lang="en"><body>
<script type="module">
import { createSceneDialog } from '/src/ui/sceneSharing.js';
window.openDialog = () => {
const dialog = createSceneDialog('Share selected scene', () => {});
const files = dialog.input('Choose data-pack files', '', { type: 'file' });
files.multiple = true;
window.filesInput = files;
// Centre of the visible row (the wrapper's last child).
const row = files.parentElement.lastElementChild;
const r = row.getBoundingClientRect();
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
};
window.ready = true;
</script></body></html>`;
async function dropOn(client, x, y, file) {
const data = { items: [], files: [file], dragOperationsMask: 1 };
for (const type of ['dragEnter', 'dragOver', 'drop']) {
await client.send('Input.dispatchDragEvent', { type, x, y, data });
}
}
async function main() {
const dir = await mkdtemp(path.join(tmpdir(), 'qa-scene-dialog-drop-'));
const pack = path.join(dir, 'pack.json');
await writeFile(pack, '{}');
const server = http.createServer(async (req, res) => {
if (req.url === '/') {
res.writeHead(200, { 'content-type': 'text/html' });
res.end(PAGE);
return;
}
try {
const body = await readFile(path.join(ROOT, req.url));
res.writeHead(200, {
'content-type': TYPES[path.extname(req.url)] || 'text/plain',
});
res.end(body);
} catch {
res.writeHead(404).end();
}
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const url = `http://127.0.0.1:${server.address().port}/`;
const browser = await puppeteer.launch({ headless: true });
try {
const page = await browser.newPage();
await page.setViewport({ width: 1000, height: 800 });
const errors = [];
page.on('pageerror', (error) => errors.push(String(error)));
await page.goto(url);
await page.waitForFunction(() => window.ready === true);
const point = await page.evaluate(() => window.openDialog());
const client = await page.createCDPSession();
await dropOn(client, point.x, point.y, pack);
const names = await page.evaluate(() =>
[...window.filesInput.files].map((file) => file.name),
);
console.log(JSON.stringify({ names, errors }));
if (errors.length) throw new Error(`page errors: ${errors.join('; ')}`);
if (names.length !== 1 || names[0] !== 'pack.json') {
throw new Error(
`expected the dropped pack.json to be selected, got ${JSON.stringify(names)}`,
);
}
console.log('OK: a file dropped on the visible control is selected');
} finally {
await browser.close();
server.close();
await rm(dir, { recursive: true, force: true });
}
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});