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

+19
View File
@@ -1,4 +1,19 @@
import { t } from '../../i18n/index.js';
import { UNNAMED_PLACE_NAMES } from '../../data/installationNames.js';
const UNNAMED_NAMES = new Set(UNNAMED_PLACE_NAMES);
/**
* An installation's card title: a mapped name as it is; the English label
* the data gives an unnamed feature ("Firing range", "Military area") in the
* UI language. The record keeps the English name.
* @param {string} name
* @returns {string} Empty when the record has no name.
*/
export function installationTitle(name) {
const text = String(name ?? '');
return UNNAMED_NAMES.has(text.trim()) ? t(text.trim()) : text;
}
/**
* An installation class as a map card shows it: AIRFIELD, NAVAL BASE…, in
@@ -19,6 +34,10 @@ export function installationClassLabel(klass) {
return t('MILITARY LAND', { context: 'installation class' });
case 'base':
return t('BASE', { context: 'installation class' });
case 'barracks':
return t('BARRACKS', { context: 'installation class' });
case 'training_area':
return t('TRAINING AREA', { context: 'installation class' });
case 'places_candidate':
return t('PLACES CANDIDATE', { context: 'installation class' });
case 'installation':
@@ -0,0 +1,36 @@
// K-50: every class the bundled OSM names pack can put on a record has a
// catalog entry, so the selected card shows it in the UI language.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { setLocale, getCatalog } from '../../i18n/index.js';
import { installationClassLabel } from './classLabel.js';
const pack = JSON.parse(
readFileSync(
new URL(
'../../data/local_data/osm_military_names/names.json',
import.meta.url,
),
'utf8',
),
);
setLocale('en');
const englishLabel = Object.fromEntries(
pack.classes.map((c) => [c, installationClassLabel(c)]),
);
for (const locale of ['es', 'ru']) {
test(`every names-pack class is in the ${locale} catalog (K-50)`, () => {
const catalog = getCatalog(locale);
const missing = pack.classes.filter(
(c) => catalog[`installation class|${englishLabel[c]}`] === undefined,
);
assert.deepEqual(
missing,
[],
`no ${locale} entry for: ${missing.join(', ')}`,
);
});
}
+26 -45
View File
@@ -1,57 +1,38 @@
import * as Cesium from 'cesium';
import { installationFeedback } from '../../data/installationFeedback.js';
import { describeInstallationFeedback } from '../../data/installationFeedback.js';
import {
LAYER_ID,
DISTANCE_PREFILTER_MARGIN_M,
ANCHOR_REFRESH_M,
} from './policy.js';
import { t, msg } from '../../i18n/index.js';
import { formatNumber, t, tn } from '../../i18n/index.js';
// installationFeedback.js composes its guidance inside the portable
// node/browser graph (the server-side HUD summary reads the same module), so
// its copy stays English there. getStats is the browser presentation
// boundary: map the closed message vocabulary to the catalog, passing any
// live provider error through verbatim (docs/TRANSLATORS.md).
const INSTALLATION_FEEDBACK_KEYS = Object.freeze({
'Retrying mapped sites…': msg('Retrying mapped sites…'),
'Fetching mapped sites…': msg('Fetching mapped sites…'),
'Zoom in to search mapped installations': msg(
'Zoom in to search mapped installations',
),
'Showing cached mapped sites': msg('Showing cached mapped sites'),
'Mapped sites not loaded': msg('Mapped sites not loaded'),
'Mapped sites loaded': msg('Mapped sites loaded'),
'Overpass rate-limited': msg('Overpass rate-limited'),
'Overpass timed out': msg('Overpass timed out'),
'Overpass could not complete the query': msg(
'Overpass could not complete the query',
),
'Overpass temporarily unavailable': msg('Overpass temporarily unavailable'),
});
const INSTALLATION_RETRY_IN_RE = /^(.*) — retrying in (\d+)s$/;
const INSTALLATION_RETRY_PENDING_RE = /^(.*) — retry pending$/;
function localizeInstallationFeedback(message) {
if (typeof message !== 'string' || !message) return message;
const key = INSTALLATION_FEEDBACK_KEYS[message];
if (key) return t(key);
const retryIn = message.match(INSTALLATION_RETRY_IN_RE);
if (retryIn) {
const reasonKey = INSTALLATION_FEEDBACK_KEYS[retryIn[1]];
return t('{reason} — retrying in {seconds}s', {
reason: reasonKey ? t(reasonKey) : retryIn[1],
seconds: retryIn[2],
});
// node/browser graph (the HUD summary in loadingFeedback.js reads the same
// module), so it hands back English marked with msg() plus the values;
// getStats is the browser presentation boundary that translates them.
function localizeInstallationFeedback(feedback) {
if (feedback.text !== undefined) {
const values = {};
if (feedback.reason !== undefined) values.reason = t(feedback.reason);
if (feedback.seconds !== undefined) values.seconds = feedback.seconds;
return t(feedback.text, values);
}
const pending = message.match(INSTALLATION_RETRY_PENDING_RE);
if (pending) {
const reasonKey = INSTALLATION_FEEDBACK_KEYS[pending[1]];
return t('{reason} — retry pending', {
reason: reasonKey ? t(reasonKey) : pending[1],
});
const { count, km } = feedback;
if (km) {
const kmText = formatNumber(km);
return count === 0
? t('No mapped sites within {km} km of the contact', { km: kmText })
: tn(
count,
'{count} mapped site within {km} km of the contact',
'{count} mapped sites within {km} km of the contact',
{ km: kmText },
);
}
return message;
return count === 0
? t('No mapped sites in view')
: tn(count, '{count} mapped site in view', '{count} mapped sites in view');
}
export function createControls({ state: layerState, services, parts, source }) {
@@ -274,7 +255,7 @@ export function createControls({ state: layerState, services, parts, source }) {
retrying: layerState.loading && Boolean(layerState.failureReason),
failureReason: layerState.failureReason,
statusMessage: localizeInstallationFeedback(
installationFeedback({
describeInstallationFeedback({
...layerState,
count: layerState.lastUpdate ? mappedCount : undefined,
retrying: layerState.loading && Boolean(layerState.failureReason),
+3 -2
View File
@@ -1,5 +1,6 @@
import { militaryOsmKey } from '../../data/militaryNames.js';
import { isUnavailableCapability } from '../../sources/capability.js';
import { msg } from '../../i18n/index.js';
/** Keep named point coverage until its matching polygon takes over. */
export function retainNamedHandoff(previous, records, box) {
const named = new Map(
@@ -255,7 +256,7 @@ export function createIngestion({
payload.status === 'stale'
? `Serving cached mapped context · ${payload.records[0]?.retrievedAt || 'date unknown'}`
: saturated
? 'Too many mapped sites in view to list them all'
? msg('Too many mapped sites in view to list them all')
: placesError,
);
parts.rendering.renderRecords();
@@ -291,7 +292,7 @@ export function createIngestion({
layerState.failureReason = error?.failureReason || 'unavailable';
setInstallationStatus(
'unavailable',
error?.message || 'Installation context unavailable',
error?.message || msg('Installation context unavailable'),
);
if (!isUnavailableCapability(error))
parts.viewport.scheduleUnavailableRetry(error?.retryAfterMs);
@@ -0,0 +1,79 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { setLocale, translateKnown } from '../../i18n/index.js';
import { createControls } from './controls.js';
function stats(state) {
const layerState = { records: [], enabled: true, ...state };
return createControls({
state: layerState,
services: {},
parts: {},
source: {},
}).methods.getStats();
}
for (const locale of ['es', 'ru']) {
test(`${locale}: installation feedback is in the UI language`, () => {
setLocale(locale);
const empty = stats({ status: 'empty', lastUpdate: 1 }).statusMessage;
assert.notEqual(empty, 'No mapped sites in view');
const found = stats({
status: 'ready',
lastUpdate: 1,
records: [{ kind: 'installation' }, { kind: 'installation' }],
}).statusMessage;
assert.notEqual(found, '2 mapped sites in view');
const tiles = stats({
status: 'unavailable',
failureReason: 'tiles_unavailable',
}).statusMessage;
assert.notEqual(tiles, 'Map tiles temporarily unavailable');
const retry = stats({
status: 'unavailable',
failureReason: 'names_unavailable',
retryAt: Date.now() + 5000,
}).statusMessage;
assert.doesNotMatch(retry, /^Mapped names temporarily unavailable/);
for (const text of [
'Too many mapped sites in view to list them all',
'Installation context unavailable',
]) {
assert.notEqual(translateKnown(text), text);
}
});
}
test('en: installation feedback keeps its English wording', () => {
setLocale('en');
assert.equal(
stats({ status: 'empty', lastUpdate: 1 }).statusMessage,
'No mapped sites in view',
);
assert.equal(
stats({
status: 'ready',
lastUpdate: 1,
records: [{ kind: 'installation' }],
}).statusMessage,
'1 mapped site in view',
);
assert.equal(
stats({
status: 'ready',
lastUpdate: 1,
wide: true,
coverage: { kind: 'subject', radiusM: 25000 },
records: [{ kind: 'installation' }, { kind: 'installation' }],
}).statusMessage,
'2 mapped sites within 25 km of the contact',
);
assert.equal(
stats({
status: 'unavailable',
failureReason: 'names_unavailable',
retryAt: Date.now() + 5000,
}).statusMessage,
'Mapped names temporarily unavailable — retrying in 5s',
);
});
+3 -3
View File
@@ -1,4 +1,4 @@
import { installationClassLabel } from './classLabel.js';
import { installationClassLabel, installationTitle } from './classLabel.js';
import * as Cesium from 'cesium';
import {
MILITARY_LABEL_CAP,
@@ -163,7 +163,7 @@ export function createNamedMarkers({ state, services, parts, overlayHost }) {
id: record.id,
source: LAYER_ID,
position,
title: record.name,
title: installationTitle(record.name),
priority: record.areaM2 || 0,
accent: COLOR_BY_CLASS[record.class] || '#9ca6b0',
}),
@@ -183,7 +183,7 @@ export function createNamedMarkers({ state, services, parts, overlayHost }) {
]
: [];
marker.entry.position = position;
marker.entry.title = record.name;
marker.entry.title = installationTitle(record.name);
marker.entry.priority = record.areaM2 || 0;
entries.push(marker.entry);
ordered.push(marker);
+2 -2
View File
@@ -1,4 +1,4 @@
import { installationClassLabel } from './classLabel.js';
import { installationClassLabel, installationTitle } from './classLabel.js';
import * as Cesium from 'cesium';
import { t } from '../../i18n/index.js';
import { LAYER_ID, MAX_RENDERED, COLOR_BY_CLASS } from './policy.js';
@@ -272,7 +272,7 @@ export function createRendering({
entity.gevTrackedId = `installations:${record.id}`;
entity.gevDisplayPosition = () => displayPosition;
entity.gevLabelModel = {
title: record.name || t('MAPPED INSTALLATION'),
title: installationTitle(record.name) || t('MAPPED INSTALLATION'),
details: [
installationClassLabel(record.class),
...(record.memberNames || []).slice(0, 3),
@@ -0,0 +1,85 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { normalizeMilitaryInstallations } from '../../data/militaryInstallationData.js';
import { UNNAMED_MILITARY_AREA } from '../../sources/openFreeMap.js';
import { setLocale } from '../../i18n/index.js';
import { installationTitle } from './classLabel.js';
import { formatAwarenessLabel } from '../../data/militaryAwarenessEngine.js';
// K-33: unnamed installations get an English label in data ("Firing range",
// "Military land", "Military airfield"; OpenFreeMap polygons: "Military area").
// The data stays English; the map card title reads in the UI language.
const { records } = normalizeMilitaryInstallations({
elements: [
{ type: 'node', id: 1, lat: 1, lon: 1, tags: { military: 'range' } },
{
type: 'way',
id: 2,
center: { lat: 2, lon: 2 },
tags: { landuse: 'military' },
},
{ type: 'node', id: 3, lat: 3, lon: 3, tags: { military: 'airfield' } },
{ type: 'node', id: 4, lat: 4, lon: 4, tags: { military: 'naval_base' } },
],
});
const names = [...records.map((r) => r.name), UNNAMED_MILITARY_AREA];
test('K-33: unnamed installations keep English names in data', () => {
assert.deepEqual(names, [
'Firing range',
'Military land',
'Military airfield',
'Naval base',
'Military area',
]);
});
test('K-33: unnamed installation card title reads in the UI language', () => {
try {
for (const locale of ['es', 'ru']) {
setLocale(locale);
for (const name of names) {
const shown = installationTitle(name);
assert.ok(shown, `${locale}: "${name}" has a title`);
assert.notEqual(
shown,
name,
`${locale}: "${name}" is shown in English`,
);
}
// A mapped name is shown as it is.
assert.equal(installationTitle('Fort Bragg'), 'Fort Bragg');
}
} finally {
setLocale('en');
}
});
test('K-33: English card titles are unchanged', () => {
setLocale('en');
for (const name of names) assert.equal(installationTitle(name), name);
assert.equal(installationTitle(''), '');
});
test('K-33: the Contacts line names an unnamed installation in the UI language', () => {
try {
setLocale('en');
for (const name of names)
assert.equal(formatAwarenessLabel({ id: 'osm-1', name }), name);
for (const locale of ['es', 'ru']) {
setLocale(locale);
for (const name of names)
assert.equal(
formatAwarenessLabel({ id: 'osm-1', name }),
installationTitle(name),
);
assert.equal(
formatAwarenessLabel({ id: 'osm-2', name: 'Fort Bragg' }),
'Fort Bragg',
);
assert.equal(formatAwarenessLabel({ callsign: 'RCH123 ' }), 'RCH123');
}
} finally {
setLocale('en');
}
});