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

+25 -5
View File
@@ -11,7 +11,7 @@ import {
createWindInspectionMarker,
WIND_UNITS,
} from './inspection.js';
import { t, msg } from '../../i18n/index.js';
import { t, msg, translateKnown } from '../../i18n/index.js';
import { unitLabel } from '../../ui/unitLabels.js';
/** Format a forecast timestamp explicitly in UTC. */
@@ -22,6 +22,25 @@ export function formatWindValidTime(value) {
: null;
}
/*
* Reasons the server puts in the manifest (server/providers/wind.js). They stay
* English in data and are translated where they are shown.
*/
const SERVER_REASONS = new Set([msg('Wind upstream unavailable')]);
const reasonText = (reason) =>
SERVER_REASONS.has(reason) ? t(reason) : translateKnown(reason) || null;
/**
* A wind problem in the UI language. Source errors are English data; the
* HTTP status line carries a number, so it goes back onto a placeholder.
*/
function presentWindProblem(problem) {
const http = /^Wind HTTP (\d+)$/.exec(problem);
if (http)
return t('{source} HTTP {status}', { source: t('Wind'), status: http[1] });
return t(problem);
}
/** Forecast issue time is source age; valid time is reported separately. */
export function windStats(manifest) {
const run = Date.parse(manifest?.cycle?.runIso);
@@ -29,7 +48,8 @@ export function windStats(manifest) {
count: manifest?.grid ? manifest.grid.nx * manifest.grid.ny : 0,
lastUpdate: Number.isFinite(run) ? run : null,
error:
manifest?.reason || (manifest?.unavailable ? 'Wind unavailable' : null),
manifest?.reason ||
(manifest?.unavailable ? msg('Wind unavailable') : null),
};
}
@@ -306,8 +326,8 @@ export function createWindLayer({
? t('Loading forecast')
: preparing
? t('Preparing flow')
: (error && t(error)) ||
manifest?.reason ||
: (error && presentWindProblem(error)) ||
reasonText(manifest?.reason) ||
imageryError ||
(scalarMissing ? t('Selected field unavailable') : null) ||
(manifest?.stale ? t('Cached forecast · stale') : null),
@@ -387,7 +407,7 @@ export function createWindLayer({
model: model === 'ifs' ? 'ECMWF IFS' : 'GFS',
field: label,
unit: unitLabel(legendUnit),
})}${historyStatus ? `\n${historyStatus}` : ''}\n${t('Valid: {time}', { time: valid || t('Unavailable') })}${loading ? ` · ${t('loading')}` : preparing ? ` · ${t('preparing')}` : ''}\n${t('Issued: {time}', { time: run || t('Unavailable') })}${manifest?.stale ? ' · STALE' : ''}${error ? `\n${t(error)}` : ''}${scalarMissing ? `\n${t('Selected field unavailable · wind remains visible')}` : ''}${imageryError && !scalarMissing ? `\n${t('{error} · wind remains visible', { error: imageryError })}` : ''}`,
})}${historyStatus ? `\n${historyStatus}` : ''}\n${t('Valid: {time}', { time: valid || t('Unavailable') })}${loading ? ` · ${t('loading')}` : preparing ? ` · ${t('preparing')}` : ''}\n${t('Issued: {time}', { time: run || t('Unavailable') })}${manifest?.stale ? ` · ${t('STALE')}` : ''}${error ? `\n${presentWindProblem(error)}` : ''}${scalarMissing ? `\n${t('Selected field unavailable · wind remains visible')}` : ''}${imageryError && !scalarMissing ? `\n${t('{error} · wind remains visible', { error: imageryError })}` : ''}`,
infoTitle: t(
'Surface wind at 10 m. Approximately 1° global grid. Curves follow the 10 m wind field, lifted 12 km for visibility; display height is not weather altitude. View lighting is for readability. Animation shows flow through one fixed forecast; it does not advance time. Color fields drape the globe basemap or the active photorealistic 3D Tiles.',
),
+12 -8
View File
@@ -1,5 +1,9 @@
import { readWindBody } from '../../sources/windBody.js';
// Imported by src/tools: no i18n here. The texts stay English data and the
// wind layer and layer panel show them translated.
const msg = (text) => text;
/** Create a bounded, cancellable source for the same-origin wind provider. */
export function createWindSource({
fetchImpl = (...args) => globalThis.fetch(...args),
@@ -8,15 +12,15 @@ export function createWindSource({
return {
async getSnapshot({ signal, model = 'gfs', overlay = 'none' } = {}) {
if (!['gfs', 'ifs'].includes(model))
throw new Error('Unknown wind model');
throw new Error(msg('Unknown wind model'));
if (!['none', 'temperature', 'pressure'].includes(overlay))
throw new Error('Unknown weather overlay');
throw new Error(msg('Unknown weather overlay'));
const query = `model=${model}${overlay === 'none' ? '' : `&overlay=${overlay}`}`;
const controller = new AbortController();
const abort = () => controller.abort(signal.reason);
signal?.addEventListener('abort', abort, { once: true });
const timer = setTimeout(
() => controller.abort(new Error('Wind request timed out')),
() => controller.abort(new Error(msg('Wind request timed out'))),
timeoutMs,
);
const active = controller.signal;
@@ -42,8 +46,8 @@ export function createWindSource({
scalar === undefined &&
scalarError ===
(overlay === 'temperature'
? 'Temperature field unavailable'
: 'Mean sea level pressure field unavailable');
? msg('Temperature field unavailable')
: msg('Mean sea level pressure field unavailable'));
const scalarValid =
overlay === 'none'
? scalar === undefined && scalarError === undefined
@@ -73,7 +77,7 @@ export function createWindSource({
`^/api/wind/grid/${model}-[\\w.-]+\\.bin\\?${query}$`,
).test(manifest.gridUrl)
)
throw new Error('Malformed wind manifest');
throw new Error(msg('Malformed wind manifest'));
const count = grid.nx * grid.ny;
const expectedBytes = count * (scalar ? 12 : 8);
const gridResponse = await fetchImpl(manifest.gridUrl, {
@@ -84,10 +88,10 @@ export function createWindSource({
throw new Error(`Wind HTTP ${gridResponse.status}`);
const bytes = await readWindBody(gridResponse, expectedBytes, active);
if (bytes.byteLength !== expectedBytes)
throw new Error('Malformed wind grid');
throw new Error(msg('Malformed wind grid'));
const values = new Float32Array(bytes.buffer);
if (!values.every(Number.isFinite))
throw new Error('Malformed wind grid');
throw new Error(msg('Malformed wind grid'));
return {
...manifest,
u: values.slice(0, count),
+47
View File
@@ -0,0 +1,47 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { setLocale } from '../../i18n/index.js';
import { createWindLayer } from './index.js';
test('stale wind forecast is marked in the UI language (K-40)', async () => {
const rendering = Object.fromEntries(
['attach', 'start', 'stop', 'clear', 'destroy', 'setField'].map((n) => [
n,
() => {},
]),
);
rendering.getDiagnostics = () => ({});
const layer = createWindLayer({
feed: {
getSnapshot: async () => ({
model: 'gfs',
grid: { nx: 2, ny: 2 },
cycle: {
runIso: '2026-09-14T12:00:00Z',
validIso: '2026-09-14T18:00:00Z',
},
stale: true,
reason: 'Wind upstream unavailable',
}),
},
createRendering: () => rendering,
});
layer.init({ container: {} });
layer.enable();
await layer.update();
try {
assert.match(layer.getRowControls().info, / · STALE(\n|$)/);
for (const [locale, word] of [
['es', 'DESACTUALIZADO'],
['ru', 'УСТАРЕЛ'],
]) {
setLocale(locale);
const info = layer.getRowControls().info;
assert.doesNotMatch(info, /STALE/);
assert.match(info, new RegExp(word));
}
} finally {
setLocale('en');
layer.destroy();
}
});
@@ -0,0 +1,65 @@
// K-38: wind source failures are English data (src/layers/wind/source.js) and
// are shown in the UI language; the HTTP line keeps its status number.
import test from 'node:test';
import assert from 'node:assert/strict';
import { createWindLayer } from './index.js';
import { setLocale } from '../../i18n/index.js';
function harness(problem) {
const rendering = Object.fromEntries(
[
'attach',
'start',
'stop',
'clear',
'destroy',
'setField',
'setOptions',
].map((name) => [name, () => {}]),
);
rendering.getDiagnostics = () => ({});
const layer = createWindLayer({
feed: {
getSnapshot: async () => {
throw new Error(problem);
},
},
createRendering: () => rendering,
});
layer.init({ container: {} });
layer.enable();
return layer;
}
async function statusFor(problem) {
const layer = harness(problem);
await layer.update();
return layer.getRowControls().summary.status;
}
test('en: wind source failures read as before', async () => {
assert.equal(await statusFor('Wind HTTP 503'), 'Wind HTTP 503');
assert.equal(
await statusFor('Wind request timed out'),
'Wind request timed out',
);
});
for (const locale of ['es', 'ru']) {
test(`${locale}: wind source failures are shown in the UI language`, async () => {
setLocale(locale);
try {
const http = await statusFor('Wind HTTP 503');
assert.notEqual(http, 'Wind HTTP 503');
assert.match(http, /503/);
for (const problem of [
'Wind request timed out',
'Malformed wind manifest',
'Malformed wind grid',
])
assert.notEqual(await statusFor(problem), problem);
} finally {
setLocale('en');
}
});
}
@@ -0,0 +1,91 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createWindLayer, windStats } from './index.js';
import { setLocale, translateKnown } from '../../i18n/index.js';
import { windProxy } from '../../../server/providers/wind.js';
function harness(feed) {
const rendering = Object.fromEntries(
[
'attach',
'start',
'stop',
'clear',
'destroy',
'setField',
'setOptions',
].map((name) => [name, () => {}]),
);
rendering.getDiagnostics = () => ({});
const layer = createWindLayer({ feed, createRendering: () => rendering });
layer.init({ container: {} });
layer.enable();
return layer;
}
async function serverManifestOnUpstreamFailure() {
let handler;
const failing = async () => {
throw new Error('upstream down');
};
windProxy({ models: { gfs: failing, ifs: failing } }).configureServer({
middlewares: { use: (_path, fn) => (handler = fn) },
});
let body = '';
await handler(
{ method: 'GET', url: '/manifest?model=gfs&overlay=none' },
{ writeHead() {}, end: (b) => (body = b), once() {}, removeListener() {} },
);
return JSON.parse(body);
}
test('en: the upstream failure reason reads as before (K-17)', async () => {
const manifest = await serverManifestOnUpstreamFailure();
const layer = harness({ getSnapshot: async () => manifest });
await layer.update();
assert.equal(
layer.getRowControls().summary.status,
'Wind upstream unavailable',
);
});
for (const locale of ['es', 'ru']) {
test(`${locale}: upstream failure status is shown in the UI language`, async () => {
const manifest = await serverManifestOnUpstreamFailure();
assert.equal(manifest.reason, 'Wind upstream unavailable');
setLocale(locale);
try {
const layer = harness({ getSnapshot: async () => manifest });
await layer.update();
const status = layer.getRowControls().summary.status;
const stats = translateKnown(windStats(manifest).error);
assert.notEqual(status, 'Wind upstream unavailable');
assert.notEqual(stats, 'Wind upstream unavailable');
} finally {
setLocale('en');
}
});
test(`${locale}: a stale cached forecast is reported in the UI language`, async () => {
// Shape the server returns when a cached forecast is kept after a failure.
const manifest = {
model: 'gfs',
grid: { nx: 2, ny: 2 },
cycle: {
runIso: '2026-09-14T12:00:00Z',
validIso: '2026-09-14T18:00:00Z',
},
stale: true,
reason: 'Wind upstream unavailable',
};
setLocale(locale);
try {
const layer = harness({ getSnapshot: async () => manifest });
await layer.update();
const status = layer.getRowControls().summary.status;
assert.doesNotMatch(status, /[A-Za-z]{4,} upstream unavailable/);
} finally {
setLocale('en');
}
});
}