feedback5 WI: English game texts from the official EN client data (data/i18n/en.json) and the i18n core
shared/i18n.js: gettext-style t() / tc() with the Chinese text as msgid (untranslated -> Chinese), {name},
{0} and {n|one|other} params, lists, game-data names (dn / tName), setLang / getLang / onLangChange, and the
server message wire format (msg -> wireMessage -> translateWire). Chinese stays the default, English is a
switch (the owner's decision of 2026-10-05).
tools/build-i18n.mjs (build-data.mjs untouched) walks the zh_CN and EN tables side by side and writes
data/i18n/en.json: the official English of every data/*.json text by record id and field. Blackboard texts
are matched by template (the remake's numbers read back and filled into the English template); stage names
are composed; what the EN client lacks falls back to tools/i18n/fallback-remake.json and fallback-pr70.json
(PR #70 by @YuriRestia). Default EN source ArknightsAssets/ArknightsGamedata (has act2autochess);
Kengxxiao/ArknightsGameData_YoStar is archived (2025-11) and has no 盟约 season (--source yostar).
Coverage: 14 922 / 14 931 texts (99.9 %), 99.3 % official; only 9 CN-only 甄选 operator names stay Chinese.
shared/i18nData.js: the overlay format (index-aligned leaves + a 2-char hash of each Chinese source, so a
stale leaf is never applied). public/js/data.js: setLocale('en') downloads the overlay once; get / lookup /
list return localized copies; getRaw keeps the Chinese file; localeName maps names.
This commit is contained in:
1 parent
a98b02477d
commit
e596478bef
8 files changed
+1593
-3
No files matched your search
+304
@@ -0,0 +1,304 @@
|
||||
// shared/i18n.js — gettext-style translation for the browser and Node (no framework, no dependency, no Node builtin).
|
||||
//
|
||||
// The Chinese source string is the message id: `t('整备区已满')`, `t('还剩 {n} 秒', { n })`. A message without a
|
||||
// translation in the current language falls back to the Chinese text, so an untranslated string is never blank.
|
||||
// Chinese ('zh') is the default language; English ('en') is a switch (the owner's decision of 2026-10-05: an English
|
||||
// browser still opens in Chinese until the player picks English). docs/I18N.md explains how to add and translate
|
||||
// strings.
|
||||
//
|
||||
// Context: the same Chinese text may need two translations ('关闭' = "Close" on a button, "Off" on a switch):
|
||||
// `tc('toggle', '关闭')` looks up the key 'toggle::关闭' first, then '关闭'; Chinese shows '关闭'.
|
||||
// Module-level tables are evaluated once, before a language can change: mark their strings N_('…') (a no-op that
|
||||
// tools/i18n.mjs extracts) and call t() where they are shown.
|
||||
//
|
||||
// Placeholders (the same in msgids and translations):
|
||||
// {name} the value of params.name (params may also be an array: {0}, {1} …)
|
||||
// {n|one|other} `one` when Number(params.n) === 1, else `other` — the only plural form (English needs no more)
|
||||
// A value that is an array renders as a list joined with the language's separator ('、' / ', '); a value `{ dn: '…' }`
|
||||
// (made by `dn()`) is a game-data name, translated through the name resolver the client installs (setNameResolver).
|
||||
// A placeholder whose value is missing stays verbatim, so official templates such as '{0}博士…' survive untouched.
|
||||
//
|
||||
// Server → client messages (m.toast / m.ticker): the server builds `msg(msgid, params)` and sends
|
||||
// `wireMessage(m)` = { text: <the Chinese rendering>, msgid, params } — `text` keeps older clients working, the client
|
||||
// renders `translateWire(frame)` in its own language. A plain string message is its own msgid.
|
||||
|
||||
/** Supported languages; the first one is the default and the source language of every msgid. */
|
||||
export const LANGS = Object.freeze(['zh', 'en']);
|
||||
export const DEFAULT_LANG = 'zh';
|
||||
|
||||
/** List separator per language (array params). */
|
||||
const LIST_SEP = Object.freeze({ zh: '、', en: ', ' });
|
||||
|
||||
/** @type {Map<string, Map<string, string>>} lang → msgid → translation */
|
||||
const catalogs = new Map();
|
||||
let current = DEFAULT_LANG;
|
||||
/** @type {Set<(lang: string, prev: string) => void>} */
|
||||
const listeners = new Set();
|
||||
/** @type {((name: string, lang: string) => string) | null} */
|
||||
let nameResolver = null;
|
||||
|
||||
/**
|
||||
* A supported language code from loose input ('en-US' → 'en', 'zh_CN' → 'zh'), or null.
|
||||
* @param {unknown} v
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function normalizeLang(v) {
|
||||
const s = typeof v === 'string' ? v.trim().toLowerCase() : '';
|
||||
if (!s) return null;
|
||||
for (const l of LANGS) if (s === l || s.startsWith(`${l}-`) || s.startsWith(`${l}_`)) return l;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The current language code. */
|
||||
export function getLang() { return current; }
|
||||
|
||||
/**
|
||||
* Switch the language (unknown codes fall back to the default). Listeners run when it actually changed.
|
||||
* @param {unknown} lang
|
||||
* @returns {boolean} true when the language changed
|
||||
*/
|
||||
export function setLang(lang) {
|
||||
const next = normalizeLang(lang) || DEFAULT_LANG;
|
||||
if (next === current) return false;
|
||||
const prev = current;
|
||||
current = next;
|
||||
for (const fn of [...listeners]) {
|
||||
try { fn(next, prev); } catch (err) { /** @type {any} */ (globalThis).console?.error('[i18n] listener failed', err); }
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to language changes.
|
||||
* @param {(lang: string, prev: string) => void} fn
|
||||
* @returns {() => void} unsubscribe
|
||||
*/
|
||||
export function onLangChange(fn) {
|
||||
listeners.add(fn);
|
||||
return () => { listeners.delete(fn); };
|
||||
}
|
||||
|
||||
/**
|
||||
* Add translations for a language (merged into what is there). Keys starting with '_' are metadata and skipped; only
|
||||
* non-empty string values count.
|
||||
* @param {string} lang
|
||||
* @param {Record<string, unknown> | null | undefined} dict msgid → translation
|
||||
* @returns {number} entries added
|
||||
*/
|
||||
export function addMessages(lang, dict) {
|
||||
const l = normalizeLang(lang);
|
||||
if (!l || !dict || typeof dict !== 'object') return 0;
|
||||
let cat = catalogs.get(l);
|
||||
if (!cat) { cat = new Map(); catalogs.set(l, cat); }
|
||||
let n = 0;
|
||||
for (const [k, v] of Object.entries(dict)) {
|
||||
if (k.startsWith('_') || typeof v !== 'string' || !v) continue;
|
||||
cat.set(k, v);
|
||||
n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a language's translations.
|
||||
* @param {string} lang
|
||||
* @param {Record<string, unknown> | null | undefined} dict
|
||||
* @returns {number}
|
||||
*/
|
||||
export function setMessages(lang, dict) {
|
||||
const l = normalizeLang(lang);
|
||||
if (!l) return 0;
|
||||
catalogs.delete(l);
|
||||
return addMessages(l, dict);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a msgid has a translation in a language (the default language always "has" its own msgids).
|
||||
* @param {string} msgid
|
||||
* @param {string} [lang]
|
||||
*/
|
||||
export function hasMessage(msgid, lang = current) {
|
||||
const l = normalizeLang(lang) || DEFAULT_LANG;
|
||||
return l === DEFAULT_LANG || !!catalogs.get(l)?.has(String(msgid));
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the translator of game-data names (`{ dn: '…' }` params and `tName`). The client maps a Chinese name to the
|
||||
* current language through the localized game data; without a resolver names stay Chinese.
|
||||
* @param {((name: string, lang: string) => string) | null} fn
|
||||
*/
|
||||
export function setNameResolver(fn) { nameResolver = typeof fn === 'function' ? fn : null; }
|
||||
|
||||
/**
|
||||
* A game-data name (operator, item, bond …) in the current language.
|
||||
* @param {unknown} name the Chinese name from the data
|
||||
* @param {string} [lang]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function tName(name, lang = current) {
|
||||
const s = name == null ? '' : String(name);
|
||||
if (!s || lang === DEFAULT_LANG || !nameResolver) return s;
|
||||
try { return nameResolver(s, lang) || s; } catch { return s; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a param as a game-data name (translated by the client's name resolver).
|
||||
* @param {unknown} name
|
||||
* @returns {{ dn: string }}
|
||||
*/
|
||||
export const dn = (name) => ({ dn: name == null ? '' : String(name) });
|
||||
|
||||
const own = (o, k) => o != null && typeof o === 'object' && Object.prototype.hasOwnProperty.call(o, k);
|
||||
|
||||
/**
|
||||
* Render one param value.
|
||||
* @param {unknown} v
|
||||
* @param {string} lang
|
||||
* @param {((name: string, lang: string) => string) | null} resolve
|
||||
* @returns {string}
|
||||
*/
|
||||
function renderValue(v, lang, resolve) {
|
||||
if (v == null) return '';
|
||||
if (Array.isArray(v)) return v.map((x) => renderValue(x, lang, resolve)).filter((s) => s !== '').join(LIST_SEP[lang] || LIST_SEP.en);
|
||||
if (typeof v === 'object') {
|
||||
if (own(v, 'dn')) {
|
||||
const name = String(/** @type {any} */ (v).dn ?? '');
|
||||
if (!resolve || lang === DEFAULT_LANG || !name) return name;
|
||||
try { return resolve(name, lang) || name; } catch { return name; }
|
||||
}
|
||||
return '';
|
||||
}
|
||||
return String(v);
|
||||
}
|
||||
|
||||
const PLACEHOLDER = /\{([A-Za-z0-9_$]+)(?:\|([^{}|]*)\|([^{}|]*))?\}/g;
|
||||
|
||||
/**
|
||||
* Fill a template's placeholders (pure: no global language state).
|
||||
* @param {unknown} template
|
||||
* @param {Record<string, unknown> | unknown[] | null | undefined} [params]
|
||||
* @param {{ lang?: string, resolveName?: ((name: string, lang: string) => string) | null }} [opts]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function format(template, params, opts = {}) {
|
||||
const s = template == null ? '' : String(template);
|
||||
if (!params || typeof params !== 'object' || !s.includes('{')) return s;
|
||||
const lang = normalizeLang(opts.lang) || DEFAULT_LANG;
|
||||
const resolve = opts.resolveName || null;
|
||||
return s.replace(PLACEHOLDER, (m, key, one, other) => {
|
||||
if (!own(params, key)) return m;
|
||||
const v = /** @type {any} */ (params)[key];
|
||||
if (one !== undefined) return Number(v) === 1 ? one : other;
|
||||
return renderValue(v, lang, resolve);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a message into the current language (gettext style: the msgid is the Chinese text).
|
||||
* @param {unknown} msgid
|
||||
* @param {Record<string, unknown> | unknown[] | null} [params]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function t(msgid, params) {
|
||||
const id = msgid == null ? '' : String(msgid);
|
||||
let text = id;
|
||||
if (current !== DEFAULT_LANG) {
|
||||
const tr = catalogs.get(current)?.get(id);
|
||||
if (tr) text = tr;
|
||||
}
|
||||
return params ? format(text, params, { lang: current, resolveName: nameResolver }) : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a message with a context (see the header): the key `${context}::${msgid}` first, then the msgid alone.
|
||||
* @param {string} context
|
||||
* @param {unknown} msgid
|
||||
* @param {Record<string, unknown> | unknown[] | null} [params]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function tc(context, msgid, params) {
|
||||
const id = msgid == null ? '' : String(msgid);
|
||||
if (current !== DEFAULT_LANG) {
|
||||
const tr = catalogs.get(current)?.get(`${context}::${id}`);
|
||||
if (tr) return params ? format(tr, params, { lang: current, resolveName: nameResolver }) : tr;
|
||||
}
|
||||
return t(id, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a string as a msgid without translating it (module-level tables: translate with t() where it is shown).
|
||||
* @template {string} S
|
||||
* @param {S} s
|
||||
* @returns {S}
|
||||
*/
|
||||
export const N_ = (s) => s;
|
||||
|
||||
/**
|
||||
* A structured message (server → client): the msgid with its params.
|
||||
* @param {string} msgid
|
||||
* @param {Record<string, unknown> | unknown[]} [params]
|
||||
* @returns {{ msgid: string, params: Record<string, unknown> | unknown[] | undefined }}
|
||||
*/
|
||||
export const msg = (msgid, params) => ({ msgid: String(msgid), params });
|
||||
|
||||
/** @param {unknown} m @returns {m is { msgid: string, params?: any }} */
|
||||
const isMsg = (m) => !!m && typeof m === 'object' && typeof (/** @type {any} */ (m).msgid) === 'string';
|
||||
|
||||
/**
|
||||
* The Chinese text of a message (a plain string is returned as is).
|
||||
* @param {unknown} m a string or msg()
|
||||
* @returns {string}
|
||||
*/
|
||||
export function renderMessage(m) {
|
||||
if (isMsg(m)) return format(m.msgid, m.params, { lang: DEFAULT_LANG });
|
||||
return m == null ? '' : String(m);
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON-safe copy of message params: strings, finite numbers, booleans, `{ dn }` and arrays of those (anything else is
|
||||
* dropped), so a frame never carries an object the client would have to trust.
|
||||
* @param {unknown} v
|
||||
* @param {number} [depth]
|
||||
* @returns {unknown}
|
||||
*/
|
||||
function cleanParam(v, depth = 0) {
|
||||
if (typeof v === 'string') return v.slice(0, 200);
|
||||
if (typeof v === 'number') return Number.isFinite(v) ? v : null;
|
||||
if (typeof v === 'boolean') return v;
|
||||
if (Array.isArray(v) && depth < 1) return v.slice(0, 32).map((x) => cleanParam(x, depth + 1));
|
||||
if (v && typeof v === 'object' && own(v, 'dn')) return { dn: String(/** @type {any} */ (v).dn ?? '').slice(0, 200) };
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The wire fields of a message for m.toast / m.ticker: `{ text }` for a plain string, `{ text, msgid, params }` for a
|
||||
* msg() — `text` is the Chinese rendering (older clients show it as before).
|
||||
* @param {unknown} m
|
||||
* @returns {{ text: string, msgid?: string, params?: Record<string, unknown> | unknown[] }}
|
||||
*/
|
||||
export function wireMessage(m) {
|
||||
if (!isMsg(m)) return { text: renderMessage(m) };
|
||||
const out = { text: renderMessage(m), msgid: m.msgid };
|
||||
const p = m.params;
|
||||
if (Array.isArray(p)) return { ...out, params: p.slice(0, 16).map((x) => cleanParam(x)) };
|
||||
if (p && typeof p === 'object') {
|
||||
/** @type {Record<string, unknown>} */
|
||||
const params = {};
|
||||
for (const [k, v] of Object.entries(p).slice(0, 16)) if (/^[A-Za-z0-9_$]{1,32}$/.test(k)) params[k] = cleanParam(v);
|
||||
return { ...out, params };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The text of a received m.toast / m.ticker frame in the current language: its msgid with params when present, else
|
||||
* its text taken as a msgid (static server texts are their own msgid). Missing translations show the Chinese text.
|
||||
* @param {{ text?: unknown, msgid?: unknown, params?: unknown } | null | undefined} frame
|
||||
* @returns {string}
|
||||
*/
|
||||
export function translateWire(frame) {
|
||||
if (!frame || typeof frame !== 'object') return '';
|
||||
const params = frame.params && typeof frame.params === 'object' ? /** @type {any} */ (frame.params) : null;
|
||||
if (typeof frame.msgid === 'string' && frame.msgid) return t(frame.msgid, params);
|
||||
return typeof frame.text === 'string' ? t(frame.text) : '';
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
// shared/i18nData.js — the localized game-data overlay (data/i18n/<lang>.json): its format, written by
|
||||
// tools/build-i18n.mjs and applied by public/js/data.js (also usable under Node).
|
||||
//
|
||||
// { version: 1, lang: 'en', meta: { … sources, coverage … },
|
||||
// names: { <Chinese name>: <name> }, // every translated game-data name (ticker / toast args)
|
||||
// files: { <data file>: { <record id>: RecordOverlay } } }
|
||||
//
|
||||
// A RecordOverlay mirrors the record of data/<file>.json: a string leaf replaces the Chinese string at the same path,
|
||||
// an object recurses, an array is index-aligned (null = keep the Chinese entry). `_h` (on every record overlay) holds
|
||||
// the 2-character hash (textHash2) of each Chinese string a leaf replaces, in walk order (object keys in their order,
|
||||
// array entries by index, `_h` itself skipped). A record that is a plain string (config `seasonName`) has { _s, _h }. A leaf whose Chinese source no longer hashes the same — data/*.json was
|
||||
// rebuilt after the overlay — is not applied: the player sees the current Chinese text rather than a stale translation.
|
||||
|
||||
export const OVERLAY_VERSION = 1;
|
||||
|
||||
/**
|
||||
* 32-bit FNV-1a of a string, as 2 base-36 characters (1296 buckets): the per-leaf staleness check.
|
||||
* @param {string} s
|
||||
* @returns {string}
|
||||
*/
|
||||
export function textHash2(s) {
|
||||
let h = 0x811c9dc5;
|
||||
const str = String(s);
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
h ^= str.charCodeAt(i);
|
||||
h = Math.imul(h, 0x01000193) >>> 0;
|
||||
}
|
||||
return (h % 1296).toString(36).padStart(2, '0');
|
||||
}
|
||||
|
||||
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
||||
|
||||
/**
|
||||
* Visit every string leaf of a record overlay in walk order.
|
||||
* @param {any} ov record overlay (or a sub-tree of it)
|
||||
* @param {(path: (string|number)[], text: string) => void} visit
|
||||
* @param {(string|number)[]} [path]
|
||||
*/
|
||||
export function walkOverlay(ov, visit, path = []) {
|
||||
if (typeof ov === 'string') { visit(path, ov); return; }
|
||||
if (Array.isArray(ov)) {
|
||||
for (let i = 0; i < ov.length; i++) if (ov[i] != null) walkOverlay(ov[i], visit, [...path, i]);
|
||||
return;
|
||||
}
|
||||
if (isObj(ov)) {
|
||||
for (const k of Object.keys(ov)) {
|
||||
if (path.length === 0 && k === '_h') continue;
|
||||
walkOverlay(ov[k], visit, [...path, k]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The value at a path of a record, or undefined.
|
||||
* @param {any} base
|
||||
* @param {(string|number)[]} path
|
||||
*/
|
||||
export function getPath(base, path) {
|
||||
let cur = base;
|
||||
for (const k of path) {
|
||||
if (cur == null || typeof cur !== 'object') return undefined;
|
||||
cur = cur[k];
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
||||
/** A path as a lookup key (an array record's top-level indexes become object keys, so numbers and strings match). */
|
||||
const pathKey = (path) => path.map(String).join('\u0000');
|
||||
|
||||
/**
|
||||
* Build a record overlay from its leaves (the build tool): sets each `en` at its path and computes `_h` from the
|
||||
* Chinese sources in the final walk order. A record that is itself an array (config `tips`, `titles` …) gets an object
|
||||
* overlay keyed by index ("0", "1" …: integer keys keep index order).
|
||||
* @param {{ path: (string|number)[], zh: string, en: string }[]} leaves
|
||||
* @returns {any|null} null when there is no leaf
|
||||
*/
|
||||
export function buildRecordOverlay(leaves) {
|
||||
if (!leaves.length) return null;
|
||||
// a record that is a plain string (config `seasonName`): { _s, _h }
|
||||
if (leaves.length === 1 && leaves[0].path.length === 0) return { _s: leaves[0].en, _h: textHash2(leaves[0].zh) };
|
||||
/** @type {any} */
|
||||
const root = {};
|
||||
const zhAt = new Map();
|
||||
for (const { path, zh, en } of leaves) {
|
||||
let cur = root;
|
||||
for (let i = 0; i < path.length - 1; i++) {
|
||||
const k = path[i];
|
||||
const nextIsIndex = typeof path[i + 1] === 'number';
|
||||
if (cur[k] == null) cur[k] = nextIsIndex ? [] : {};
|
||||
cur = cur[k];
|
||||
}
|
||||
cur[path[path.length - 1]] = en;
|
||||
zhAt.set(pathKey(path), zh);
|
||||
}
|
||||
// sparse arrays → explicit nulls (JSON has no holes)
|
||||
const fill = (v) => {
|
||||
if (Array.isArray(v)) { for (let i = 0; i < v.length; i++) v[i] = v[i] === undefined ? null : fill(v[i]); return v; }
|
||||
if (isObj(v)) { for (const k of Object.keys(v)) v[k] = fill(v[k]); return v; }
|
||||
return v;
|
||||
};
|
||||
fill(root);
|
||||
let h = '';
|
||||
walkOverlay(root, (path) => { h += textHash2(zhAt.get(pathKey(path)) ?? ''); });
|
||||
return { ...root, _h: h };
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a record overlay to a Chinese record: a copy that shares every untouched branch (copy-on-write), with each leaf
|
||||
* whose Chinese source still matches its hash replaced. The input record is never modified.
|
||||
* @param {any} base
|
||||
* @param {any} ov
|
||||
* @returns {{ value: any, applied: number, stale: number }}
|
||||
*/
|
||||
export function applyRecordOverlay(base, ov) {
|
||||
if (isObj(ov) && typeof ov._s === 'string') {
|
||||
const ok = typeof base === 'string' && textHash2(base) === ov._h;
|
||||
return { value: ok ? ov._s : base, applied: ok ? 1 : 0, stale: ok ? 0 : 1 };
|
||||
}
|
||||
if (!isObj(ov) || base == null || typeof base !== 'object') return { value: base, applied: 0, stale: 0 };
|
||||
const hashes = typeof ov._h === 'string' ? ov._h : '';
|
||||
let i = 0;
|
||||
let applied = 0;
|
||||
let stale = 0;
|
||||
const rec = (b, o, depth) => {
|
||||
if (typeof o === 'string') {
|
||||
const want = hashes.slice(i * 2, i * 2 + 2);
|
||||
i++;
|
||||
if (typeof b === 'string' && want && textHash2(b) === want) { applied++; return o; }
|
||||
stale++;
|
||||
return b;
|
||||
}
|
||||
if (Array.isArray(o)) {
|
||||
if (!Array.isArray(b)) { walkOverlay(o, () => { i++; stale++; }); return b; }
|
||||
let out = b;
|
||||
for (let k = 0; k < o.length; k++) {
|
||||
if (o[k] == null) continue;
|
||||
const nv = rec(b[k], o[k], depth + 1);
|
||||
if (nv !== b[k]) { if (out === b) out = b.slice(); out[k] = nv; }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (isObj(o)) {
|
||||
if (b == null || typeof b !== 'object') { walkOverlay(o, () => { i++; stale++; }); return b; }
|
||||
// an array record's overlay is keyed by index (see buildRecordOverlay)
|
||||
const arr = Array.isArray(b);
|
||||
let out = b;
|
||||
for (const k of Object.keys(o)) {
|
||||
if (depth === 0 && k === '_h') continue;
|
||||
const at = arr ? (/^\d+$/.test(k) ? Number(k) : -1) : k;
|
||||
const cur = at === -1 ? undefined : b[at];
|
||||
const nv = rec(cur, o[k], depth + 1);
|
||||
if (at !== -1 && nv !== cur) { if (out === b) out = arr ? b.slice() : { ...b }; out[at] = nv; }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return b;
|
||||
};
|
||||
const value = rec(base, ov, 0);
|
||||
return { value, applied, stale };
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a file's overlay to a whole data file (the `{ id: record }` maps of data/*.json; other shapes are returned
|
||||
* unchanged). Untouched records are shared with the input.
|
||||
* @param {any} json the Chinese file
|
||||
* @param {Record<string, any> | null | undefined} fileOverlay `files[<name>]` of the overlay
|
||||
* @returns {{ value: any, applied: number, stale: number }}
|
||||
*/
|
||||
export function applyFileOverlay(json, fileOverlay) {
|
||||
if (!isObj(json) || !isObj(fileOverlay)) return { value: json, applied: 0, stale: 0 };
|
||||
let out = json;
|
||||
let applied = 0;
|
||||
let stale = 0;
|
||||
for (const [id, ov] of Object.entries(fileOverlay)) {
|
||||
if (!Object.prototype.hasOwnProperty.call(json, id)) continue;
|
||||
const r = applyRecordOverlay(json[id], ov);
|
||||
applied += r.applied;
|
||||
stale += r.stale;
|
||||
if (r.value !== json[id]) { if (out === json) out = { ...json }; out[id] = r.value; }
|
||||
}
|
||||
return { value: out, applied, stale };
|
||||
}
|
||||
Reference in new issue
Block a user