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

+310 -11
View File
@@ -9,11 +9,26 @@
* referents the card lists (the referent registry reads the same answer, so
* "the second one" always means the second row on screen).
*
* What the model receives stays English (`analystHeadline` feeds the spoken
* answer); the card shows the same answer in the active language.
*
* Pure: no DOM, no Cesium.
* @module voice/resultDisplay
*/
import { voiceLayer } from './layerManifest.js';
import {
formatDate,
formatNumber,
getLocale,
msg,
t,
tn,
translateKnown,
} from '../i18n/index.js';
import { layerNameForId, sourceLabel } from '../ui/layerNames.js';
import { localizePlaceName } from '../ui/placeNames.js';
import { formatQuantity } from '../ui/unitLabels.js';
const LAYER_NOUNS = Object.freeze({
flights: 'aircraft',
@@ -52,13 +67,17 @@ export function analystNounFor(layerKeys) {
/** The count/scope headline shared by the analyst card and spoken answer. */
const ANALYST_HEADLINE_MAX = 64;
// The card's headline wraps, and a translation runs longer than the English:
// "Al menos 12 aeronaves militares en un radio de 40 km de Los Ángeles" is 67.
// English keeps the spoken bound, so its card title still equals `say`.
const SHOWN_HEADLINE_MAX = 120;
function boundedHeadline(value) {
function boundedHeadline(value, max = ANALYST_HEADLINE_MAX) {
const text = String(value || '')
.replace(/\s+/g, ' ')
.trim();
if (text.length <= ANALYST_HEADLINE_MAX) return text;
return `${text.slice(0, ANALYST_HEADLINE_MAX - 1).trimEnd()}…`;
if (text.length <= max) return text;
return `${text.slice(0, max - 1).trimEnd()}…`;
}
export function analystHeadline(result) {
@@ -72,6 +91,282 @@ export function analystHeadline(result) {
);
}
/* ---------------- the analyst card in the active language ---------------- */
/**
* The count and its noun as the card shows them. The English matches
* `analystNounFor`, one form for any count as the spoken headline has it.
*/
function shownCount(count, noun) {
const values = { n: formatNumber(count) };
switch (noun) {
case 'aircraft':
return tn(count, '{n} aircraft', '{n} aircraft', values);
case 'military aircraft':
return tn(
count,
'{n} military aircraft',
'{n} military aircraft',
values,
);
case 'ships':
return tn(count, '{n} ships', '{n} ships', values);
case 'satellites':
return tn(count, '{n} satellites', '{n} satellites', values);
case 'launches':
return tn(count, '{n} launches', '{n} launches', values);
case 'earthquakes':
return tn(count, '{n} earthquakes', '{n} earthquakes', values);
case 'fires':
return tn(count, '{n} fires', '{n} fires', values);
case 'fire perimeters':
return tn(count, '{n} fire perimeters', '{n} fire perimeters', values);
case 'cyclones':
return tn(count, '{n} cyclones', '{n} cyclones', values);
case 'buses':
return tn(count, '{n} buses', '{n} buses', values);
case 'bike share':
return tn(count, '{n} bike share', '{n} bike share', values);
case 'flock cameras':
return tn(count, '{n} flock cameras', '{n} flock cameras', values);
case 'military bases':
return tn(count, '{n} military bases', '{n} military bases', values);
case 'datacenters':
return tn(count, '{n} datacenters', '{n} datacenters', values);
case 'dams':
return tn(count, '{n} dams', '{n} dams', values);
case 'results':
return tn(count, '{n} results', '{n} results', values);
default:
return `${values.n} ${noun}`;
}
}
const shownRadius = (km) => formatQuantity(Number(km), 'km');
const FIXED_SCOPES = new Map(
[msg('anywhere in the loaded data'), msg('in view')].map((text) => [
text,
text,
]),
);
/**
* The engine's scope phrases (analystEngine, the Contacts window in
* gevActions) as the card shows them. The English is exactly the engine's;
* a phrase not listed shows as given.
*/
const SCOPE_PATTERNS = [
[
/^within (\d+(?:\.\d+)?) km of the view centre$/,
([, km]) =>
t('within {radius} of the view centre', { radius: shownRadius(km) }),
],
[
/^within (\d+(?:\.\d+)?) km of (.+) \(Contacts window\)$/,
([, km, place]) =>
t('within {radius} of {place} (Contacts window)', {
radius: shownRadius(km),
place: localizePlaceName(place),
}),
],
[
/^within (\d+(?:\.\d+)?) km of (.+)$/,
([, km, place]) =>
t('within {radius} of {place}', {
radius: shownRadius(km),
place: localizePlaceName(place),
}),
],
[
/^within (\d+(?:\.\d+)?) km$/,
([, km]) => t('within {radius}', { radius: shownRadius(km) }),
],
[/^over (.+)$/, ([, region]) => t('over {region}', { region })],
];
function shownScope(text) {
const scope = String(text || '').trim();
if (!scope) return '';
if (FIXED_SCOPES.has(scope)) return t(FIXED_SCOPES.get(scope));
for (const [pattern, show] of SCOPE_PATTERNS) {
const match = pattern.exec(scope);
if (match) return show(match);
}
return translateKnown(scope);
}
/** The analyst headline in the active language, for the card. */
function shownAnalystHeadline(result) {
const keys = (result?.coverage?.layersQueried || []).map(
(layer) => layer.layerKey,
);
const count = Number(result?.count) || 0;
const counted = shownCount(count, analystNounFor(keys));
const items =
result?.complete === false
? t('At least {items}', { items: counted })
: counted;
const scope = shownScope(result?.scopeLabel);
return boundedHeadline(
scope ? t('{items} {scope}', { items, scope }) : items,
getLocale() === 'en' ? ANALYST_HEADLINE_MAX : SHOWN_HEADLINE_MAX,
);
}
/** A layer key as the card names it. */
function shownLayerKey(key) {
const text = String(key || '').trim();
return layerNameForId(text) || text;
}
const shownLayerKeys = (list) =>
String(list || '')
.split(/,\s*/)
.map(shownLayerKey)
.filter(Boolean)
.join(', ');
/** The feed windows layers declare in the voice manifest. */
const FIXED_WINDOWS = new Map(
[msg('last 24 h'), msg('past 30 d and upcoming')].map((text) => [text, text]),
);
function shownWindow(text) {
return String(text || '')
.split(/;\s*/)
.map((part) => part.trim())
.filter(Boolean)
.map((part) =>
FIXED_WINDOWS.has(part)
? t(FIXED_WINDOWS.get(part))
: translateKnown(part),
)
.join('; ');
}
const UTC_CLOCK = Object.freeze({
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23',
timeZone: 'UTC',
});
const UTC_DATE = Object.freeze({
day: '2-digit',
month: '2-digit',
year: 'numeric',
timeZone: 'UTC',
});
/**
* The snapshot time the note carries (ISO, as the model got it) as the card
* shows it: "14:32:05 UTC", with the date first ("15.10.2026, 14:32:05 UTC")
* when the snapshot is not from today (UTC). English shows the ISO time as
* before; a value that is not a time shows as written.
*/
export function shownSnapshotTime(iso, now = Date.now()) {
const time = Date.parse(iso);
if (getLocale() === 'en' || !Number.isFinite(time)) return iso;
const clock = `${formatDate(time, UTC_CLOCK)} UTC`;
const today = new Date(now).toISOString().slice(0, 10);
return new Date(time).toISOString().slice(0, 10) === today
? clock
: `${formatDate(time, UTC_DATE)}, ${clock}`;
}
/** Coverage notes and caveats the engine and the action runner write. */
const FIXED_NOTES = new Map(
[
msg('the flights layer loads by viewport'),
msg('distance is ground distance, not overhead'),
msg(
'the count is a lower bound because at least one layer reached its retained limit.',
),
].map((text) => [text, text]),
);
const LAYER_STATUS_NOTES = Object.freeze({
off: (label) => t('{label} off', { label }),
loading: (label) => t('{label} loading', { label }),
error: (label) => t('{label} error', { label }),
});
const NOTE_PATTERNS = [
[
/^counted the first ([\d,]+) loaded records$/,
([, returned]) =>
t('counted the first {returned} loaded records', {
returned: formatNumber(Number(returned.replace(/,/g, ''))),
}),
],
[
/^counted ([\d,]+) of ([\d,]+) loaded records$/,
([, returned, total]) =>
t('counted {returned} of {total} loaded records', {
returned: formatNumber(Number(returned.replace(/,/g, ''))),
total: formatNumber(Number(total.replace(/,/g, ''))),
}),
],
[
/^(.+) just turned on — still loading, counts will rise$/,
([, layers]) =>
t('{layers} just turned on — still loading, counts will rise', {
layers: shownLayerKeys(layers),
}),
],
[
// The note ends in a period unless a lower-bound clause follows it.
/^Contacts panel snapshot(?: at (\S+))? — the same retained cohort supplies the card, speech, and follow-up memory(\.?)$/,
([, time, period]) =>
(time
? t(
'Contacts panel snapshot at {time} — the same retained cohort supplies the card, speech, and follow-up memory',
{ time: shownSnapshotTime(time) },
)
: t(
'Contacts panel snapshot — the same retained cohort supplies the card, speech, and follow-up memory',
)) + period,
],
[
/^([a-z0-9-]+) (off|loading|error)$/,
([, key, status]) =>
layerNameForId(key)
? LAYER_STATUS_NOTES[status](shownLayerKey(key))
: null,
],
[
/^([a-z0-9-]+): (.+)$/,
([, key, note]) =>
layerNameForId(key) ? `${shownLayerKey(key)}: ${shownNote(note)}` : null,
],
];
function shownNote(text) {
if (FIXED_NOTES.has(text)) return t(FIXED_NOTES.get(text));
for (const [pattern, show] of NOTE_PATTERNS) {
const match = pattern.exec(text);
const shown = match ? show(match) : null;
if (shown) return shown;
}
return translateKnown(text);
}
/** Feed states as the card's chips show them; the values stay English. */
const FEED_STATE_LABELS = Object.freeze({
loading: msg('loading'),
degraded: msg('degraded'),
stale: msg('stale'),
partial: msg('partial'),
fallback: msg('fallback'),
unavailable: msg('unavailable'),
});
function shownFeedState(state) {
const key = FEED_STATE_LABELS[state];
return key ? t(key) : state;
}
function analystCard(result) {
if (!result?.ok) return null;
const display = result.display || {};
@@ -91,28 +386,32 @@ function analystCard(result) {
].map((label) => ({ label }));
return {
display: {
title: analystHeadline(result),
title: shownAnalystHeadline(result),
// The precise scope, unless the title already says exactly that.
lines: [
display.scope !== result.scopeLabel ? display.scope : null,
display.window,
display.scope !== result.scopeLabel ? shownScope(display.scope) : null,
shownWindow(display.window),
].filter(Boolean),
chips: [
...(result.feedState && result.feedState !== 'nominal'
? [{ label: result.feedState }]
? [{ label: shownFeedState(result.feedState) }]
: []),
...(result.partial ? [{ label: 'partial' }] : []),
...(result.partial ? [{ label: t('partial') }] : []),
],
// Coverage qualifications lead the feed detail. The bounded card groups
// overflow in its existing Notes disclosure rather than dropping them.
preserveNotes: true,
notes: [
...notesOf(result.coverage?.note, display.caveat),
...notesOf(result.coverage?.note, display.caveat).map(shownNote),
...(unanswered.length
? [`Not answered: ${unanswered.join(', ')}`]
? [
t('Not answered: {layers}', {
layers: unanswered.map(shownLayerKey).join(', '),
}),
]
: []),
],
sources,
sources: sources.map(({ label }) => ({ label: sourceLabel(label) })),
},
referents: Array.isArray(result.items) ? result.items : [],
resultSet: true,