Files
gods-eye-view/src/i18n/index.js
T
German KhokhlovandClaude Opus 5.5 00704ddc89 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]>
2026-10-15 23:49:52 +04:00

329 lines
11 KiB
JavaScript

// God's Eye View i18n: the English source text is the message key.
//
// t('Save keys') → 'Сохранить ключи'
// t('Cleared {name}', { name }) → named interpolation
// tn(count, '{count} camera', '{count} cameras') → plural by Intl.PluralRules
// t('CLEAR', { context: 'action' }) → same English, own entry
// msg('Live Flights') → marks a string kept in data
// for a later t(); returns it as is
//
// English needs no catalog. Every other locale is one flat JSON file,
// locales/<code>.json, keyed by the English text (for tn, by the English
// plural form). A missing or empty entry falls back to English.
// `npm run i18n` keeps the catalogs in step with the code. Guide: docs/I18N.md.
import {
DEFAULT_LOCALE,
normalizeLocale,
writeStoredLocale,
} from './locale.js';
import es from './locales/es.json' with { type: 'json' };
import ru from './locales/ru.json' with { type: 'json' };
const CATALOGS = Object.freeze({ es, ru });
let currentLocale = DEFAULT_LOCALE;
/** The catalog for a locale; null for English and unknown codes. */
export function getCatalog(locale) {
return CATALOGS[normalizeLocale(locale)] ?? null;
}
/** Active locale. */
export function getLocale() {
return currentLocale;
}
/**
* Switch the active locale. Pure state change: call applyDocumentLanguage /
* applyDocumentTranslations to reflect it in the DOM.
* @returns {string} The locale actually applied (normalized).
*/
export function setLocale(locale) {
const normalized = normalizeLocale(locale) || DEFAULT_LOCALE;
if (normalized !== currentLocale) {
currentLocale = normalized;
formatterCache.clear();
}
return currentLocale;
}
/** Development-only warning; silent under node:test and in production. */
function warnMissing(text) {
if (import.meta.env?.DEV !== true) return;
console.warn(
`[i18n] no ${currentLocale} translation for ${JSON.stringify(text)}`,
);
}
const PLACEHOLDER_PATTERN = /\{([A-Za-z0-9_]+)\}/g;
/** Named {placeholder} interpolation. Unknown names stay literal so a gap
* stays visible instead of silently vanishing. */
function interpolate(pattern, values) {
if (!values) return pattern;
return pattern.replace(PLACEHOLDER_PATTERN, (literal, name) =>
Object.hasOwn(values, name) ? String(values[name]) : literal,
);
}
/** A catalog string with something to show; an empty one counts as missing. */
const filled = (value) => typeof value === 'string' && value.trim() !== '';
function lookup(text) {
const catalog = CATALOGS[currentLocale];
if (!catalog) return undefined;
const entry = Object.hasOwn(catalog, text) ? catalog[text] : undefined;
if (filled(entry) || (entry && typeof entry === 'object')) return entry;
warnMissing(text);
return undefined;
}
/**
* Translate English source text into the active locale.
* @param {string} text English text, e.g. 'Hold Space to talk'
* @param {object} [values] Named interpolation values. `context` tells
* apart identical English with different meanings: t('CLEAR', { context:
* 'action' }) is the catalog entry "action|CLEAR"; English is unchanged.
* @returns {string}
*/
export function t(text, values) {
const entry = lookup(values?.context ? `${values.context}|${text}` : text);
return interpolate(typeof entry === 'string' ? entry : text, values);
}
/**
* Translate a count-dependent message. The catalog entry for `other` holds
* one form per Intl.PluralRules category of the locale ({ one, few, many, other }
* for Russian). `{count}` is always available to the pattern.
* @param {number} count
* @param {string} one English singular, e.g. '{count} camera'
* @param {string} other English plural, e.g. '{count} cameras'
* @param {object} [values] Extra interpolation values
* @returns {string}
*/
export function tn(count, one, other, values) {
const entry = lookup(other);
const category = pluralRules(currentLocale).select(count);
let pattern;
const forms = entry && typeof entry === 'object' ? entry : null;
if (forms && (filled(forms[category]) || filled(forms.other))) {
pattern = filled(forms[category]) ? forms[category] : forms.other;
} else {
pattern = pluralRules(DEFAULT_LOCALE).select(count) === 'one' ? one : other;
}
return interpolate(pattern, { ...values, count });
}
/**
* Translate text that may be ours or may come from a provider: a catalog
* entry is used when there is one, anything else shows as given, without a
* missing-translation warning.
*/
export function translateKnown(text) {
const catalog = CATALOGS[currentLocale];
if (!catalog || typeof text !== 'string') return text;
const entry = Object.hasOwn(catalog, text) ? catalog[text] : undefined;
return filled(entry) ? entry : text;
}
/** Mark English text that is stored in data and translated later by t(). */
export function msg(text) {
return text;
}
const pluralRulesCache = new Map();
function pluralRules(locale) {
let rules = pluralRulesCache.get(locale);
if (!rules) {
rules = new Intl.PluralRules(locale);
pluralRulesCache.set(locale, rules);
}
return rules;
}
/*
* Intl formatters are memoized per (kind, locale, options): the constructors
* are expensive and panels format on refresh paths. The cache is cleared
* whenever the active locale changes.
*/
const formatterCache = new Map();
function cachedFormatter(kind, Constructor, options) {
const cacheKey = `${kind}|${currentLocale}|${options ? JSON.stringify(options) : ''}`;
let formatter = formatterCache.get(cacheKey);
if (!formatter) {
formatter = new Constructor(currentLocale, options);
formatterCache.set(cacheKey, formatter);
}
return formatter;
}
/** Locale-aware number formatting (Intl.NumberFormat). */
export function formatNumber(value, options) {
return cachedFormatter('number', Intl.NumberFormat, options).format(value);
}
/** Locale-aware date/time formatting (Intl.DateTimeFormat). */
export function formatDate(value, options) {
return cachedFormatter('date', Intl.DateTimeFormat, options).format(value);
}
const UTC_DAY_MONTH = Object.freeze({
day: '2-digit',
month: '2-digit',
timeZone: 'UTC',
});
/**
* The UTC day and month of a time, for "MM-DD HH:MM UTC" readouts: "10-13" in
* English (as before), "13.10" in Russian, "13/10" in Spanish, never read as a
* range. Takes an ISO string (its date part is used as written) or a time.
* @param {string|number|Date} value
* @returns {string} '' when the value is not a time.
*/
export function formatUtcDayMonth(value) {
const written =
typeof value === 'string' ? /^(\d{4})-(\d{2})-(\d{2})/.exec(value) : null;
const time = written
? Date.UTC(Number(written[1]), Number(written[2]) - 1, Number(written[3]))
: new Date(value).getTime();
if (!Number.isFinite(time)) return '';
if (currentLocale === 'en') return new Date(time).toISOString().slice(5, 10);
return cachedFormatter('date', Intl.DateTimeFormat, UTC_DAY_MONTH).format(
time,
);
}
/**
* Locale-aware relative time (Intl.RelativeTimeFormat), e.g.
* formatRelativeTime(-5, 'minute') → '5 minutes ago' / '5 минут назад'.
*/
export function formatRelativeTime(value, unit = 'second', options) {
return cachedFormatter('relative', Intl.RelativeTimeFormat, {
numeric: 'auto',
...options,
}).format(value, unit);
}
/** Collapse markup whitespace so a message reads the same in HTML and JS. */
export function normalizeMarkupText(text) {
return String(text ?? '')
.replace(/\s+/g, ' ')
.trim();
}
/**
* Markup opt-in: the element's own English text or attribute is the source.
* <span data-i18n>Normal</span>
* <button title="Pin" data-i18n-title>…</button>
* Also data-i18n-aria-label and data-i18n-placeholder.
*/
const DOCUMENT_TRANSLATION_TARGETS = Object.freeze([
['data-i18n', null],
['data-i18n-title', 'title'],
['data-i18n-aria-label', 'aria-label'],
['data-i18n-placeholder', 'placeholder'],
]);
/**
* Translate every opted-in element in `doc`. Runs once at boot; switching
* locale reloads the page.
* @param {Document|Element} doc
* @returns {number} Count of element/attribute writes performed.
*/
export function applyDocumentTranslations(doc) {
if (typeof doc?.querySelectorAll !== 'function') return 0;
if (!CATALOGS[currentLocale]) return 0;
let applied = 0;
for (const [marker, attribute] of DOCUMENT_TRANSLATION_TARGETS) {
for (const element of doc.querySelectorAll(`[${marker}]`)) {
const source = attribute
? element.getAttribute(attribute)
: normalizeMarkupText(element.textContent);
if (!source) continue;
const text = t(source);
if (text === source) continue;
if (attribute) element.setAttribute(attribute, text);
else element.textContent = text;
applied += 1;
}
}
return applied;
}
const unsavedWorkChecks = new Set();
/**
* Name work that switching language would lose: the switch reloads the page,
* and the language switch asks first while any check reports something.
* @param {() => string|null|undefined} check Returns a msg() label (e.g.
* msg('unsaved CCTV calibration')) while there is such work, else nothing.
* @returns {() => void} Removes the check.
*/
export function registerUnsavedWork(check) {
if (typeof check !== 'function') return () => {};
unsavedWorkChecks.add(check);
return () => unsavedWorkChecks.delete(check);
}
/**
* What a reload would lose right now, translated, without repeats.
* @returns {string[]}
*/
export function describeUnsavedWork() {
const labels = new Set();
for (const check of unsavedWorkChecks) {
try {
const label = check();
if (typeof label === 'string' && label) labels.add(t(label));
} catch {
// A failing check must not block the switch.
}
}
return [...labels];
}
/**
* Persist a locale choice, then reload preserving window.location.hash exactly
* and stripping ?lang from the search string, so the stored choice applies.
* Where storage is blocked, ?lang carries the choice instead (share links
* drop it: ShareLinkManager.copyLink).
* @returns {boolean} true when a reload was triggered.
*/
export function persistLocaleAndReload(
locale,
{
location = globalThis.location,
history = globalThis.history,
storage,
} = {},
) {
const normalized = normalizeLocale(locale) || DEFAULT_LOCALE;
const stored = writeStoredLocale(normalized, storage);
if (!location || typeof location.assign !== 'function') return false;
let target = location.href;
try {
// Without storage the choice only survives in the URL, so keep it there.
const url = new URL(location.href);
if (stored) url.searchParams.delete('lang');
else url.searchParams.set('lang', normalized);
target = url.href;
} catch {
// Keep the current href.
}
// location.assign() to the identical URL does not navigate, so strip ?lang
// in place and force a real reload instead.
try {
history?.replaceState?.(null, '', target);
} catch {
// replaceState is best-effort; the reload below still applies.
}
if (typeof location.reload === 'function') {
location.reload();
} else {
location.assign(target);
}
return true;
}