mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
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]>
329 lines
11 KiB
JavaScript
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;
|
|
}
|