From c44a6aaf763be4f4faf176baf1d73fbaea597b4d Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:38:07 -0400 Subject: [PATCH 1/2] Use locale plural rules and whole messages in translated text Counted strings take the language's CLDR plural category, sentences built from translated fragments become single catalog messages, lists use Intl.ListFormat, Backup ages use Intl.RelativeTimeFormat, and the language picker marks each name with its language. --- api/src/widget-data.js | 17 +++++- api/test/widget-catalogs.test.js | 16 +++-- api/test/widget-options-labels.test.js | 46 ++++++++++++++ ui/admin/index.html | 2 +- ui/i18n/de.json | 15 ++++- ui/i18n/en.json | 15 ++++- ui/i18n/es.json | 17 +++++- ui/i18n/fa.json | 15 ++++- ui/i18n/fr.json | 17 +++++- ui/i18n/zh-Hans.json | 13 +++- ui/index.html | 2 +- ui/js/admin-app-form.js | 14 ++--- ui/js/admin-auth.js | 8 +-- ui/js/admin-color-control.js | 6 +- ui/js/admin-drag.js | 2 +- ui/js/admin-list.js | 10 +-- ui/js/admin-settings.js | 12 ++-- ui/js/admin-shared.js | 10 +-- ui/js/admin-widget-form.js | 10 +-- ui/js/admin.js | 28 ++++----- ui/js/background.js | 2 +- ui/js/badge-logic.js | 39 +++++++++--- ui/js/badge-popover.js | 2 +- ui/js/clock-date.js | 5 ++ ui/js/config-recovery.js | 2 +- ui/js/dashboard.js | 18 +++--- ui/js/format-number.js | 12 ++++ ui/js/i18n.js | 2 +- ui/js/listbox.js | 16 ++++- ui/js/spotlight.js | 8 +-- ui/js/ui.js | 4 +- ui/js/utils.js | 2 +- ui/js/widget-config-form.js | 12 ++-- ui/js/widget-toolbox.js | 59 ++++++++++++++++-- ui/test/checklist-teardown.test.mjs | 2 +- ui/test/clock-date.test.mjs | 7 +++ ui/test/connections-map-mount.mjs | 1 + ui/test/connections-map.test.mjs | 2 +- ui/test/dashboard-localisation.test.mjs | 24 +++++++- ui/test/disk-health-status.test.mjs | 13 +++- ui/test/innerhtml-ratchet.test.mjs | 2 +- ui/test/listbox.test.mjs | 12 ++++ ui/test/localised-digits.test.mjs | 38 +++++++----- ui/test/widget-escaping.test.mjs | 10 ++- ui/test/widget-i18n.test.mjs | 59 ++++++++++++++++++ ui/test/widget-toolbox-i18n.test.mjs | 67 +++++++++++++++++++++ ui/widgets/backup/backup.html | 23 ++++--- ui/widgets/backup/i18n/de.json | 13 ++-- ui/widgets/backup/i18n/en.json | 13 ++-- ui/widgets/backup/i18n/es.json | 13 ++-- ui/widgets/backup/i18n/fa.json | 13 ++-- ui/widgets/backup/i18n/fr.json | 13 ++-- ui/widgets/backup/i18n/zh-Hans.json | 13 ++-- ui/widgets/books/i18n/de.json | 3 +- ui/widgets/books/i18n/en.json | 3 +- ui/widgets/books/i18n/es.json | 3 +- ui/widgets/books/i18n/fa.json | 3 +- ui/widgets/books/i18n/fr.json | 3 +- ui/widgets/books/i18n/zh-Hans.json | 3 +- ui/widgets/books/index.html | 7 ++- ui/widgets/clock/analog.html | 4 +- ui/widgets/clock/digital.html | 4 +- ui/widgets/connections/connections-map.html | 8 +-- ui/widgets/connections/connections-vpn.html | 8 ++- ui/widgets/connections/i18n/de.json | 11 +++- ui/widgets/connections/i18n/en.json | 11 +++- ui/widgets/connections/i18n/es.json | 14 ++++- ui/widgets/connections/i18n/fa.json | 9 ++- ui/widgets/connections/i18n/fr.json | 14 ++++- ui/widgets/connections/i18n/zh-Hans.json | 6 +- ui/widgets/dashboard-switch/index.html | 2 +- ui/widgets/disk-health/i18n/de.json | 4 +- ui/widgets/disk-health/i18n/en.json | 4 +- ui/widgets/disk-health/i18n/es.json | 5 +- ui/widgets/disk-health/i18n/fa.json | 4 +- ui/widgets/disk-health/i18n/fr.json | 5 +- ui/widgets/disk-health/i18n/zh-Hans.json | 3 +- ui/widgets/disk-health/index.html | 6 +- ui/widgets/dns/index.html | 2 +- ui/widgets/github/contributions.html | 2 +- ui/widgets/github/pullrequests.html | 2 +- ui/widgets/nowplaying/i18n/de.json | 10 +-- ui/widgets/nowplaying/i18n/en.json | 10 +-- ui/widgets/nowplaying/i18n/es.json | 10 +-- ui/widgets/nowplaying/i18n/fa.json | 10 +-- ui/widgets/nowplaying/i18n/fr.json | 10 +-- ui/widgets/nowplaying/i18n/zh-Hans.json | 10 +-- ui/widgets/nowplaying/index.html | 14 ++--- ui/widgets/system-summary/data.js | 2 +- ui/widgets/system-summary/i18n/de.json | 1 + ui/widgets/system-summary/i18n/en.json | 1 + ui/widgets/system-summary/i18n/es.json | 1 + ui/widgets/system-summary/i18n/fa.json | 1 + ui/widgets/system-summary/i18n/fr.json | 1 + ui/widgets/system-summary/i18n/zh-Hans.json | 1 + ui/widgets/system-summary/index.html | 2 +- ui/widgets/weather/i18n/de.json | 4 +- ui/widgets/weather/i18n/en.json | 4 +- ui/widgets/weather/i18n/es.json | 4 +- ui/widgets/weather/i18n/fa.json | 4 +- ui/widgets/weather/i18n/fr.json | 4 +- ui/widgets/weather/i18n/zh-Hans.json | 4 +- ui/widgets/weather/index.html | 11 ++-- 103 files changed, 790 insertions(+), 278 deletions(-) create mode 100644 api/test/widget-options-labels.test.js create mode 100644 ui/test/widget-toolbox-i18n.test.mjs diff --git a/api/src/widget-data.js b/api/src/widget-data.js index 1544ad73..00b33ff9 100644 --- a/api/src/widget-data.js +++ b/api/src/widget-data.js @@ -5,6 +5,7 @@ const { fetchChecked, fetchUnchecked, SsrfBlockedError } = require('./proxy'); const { parsePrometheus } = require('./parse-prometheus'); const { cpuSample, ramPercent, cpuTemp, diskStats, procCount, uptimeSeconds } = require('./metrics'); const { getRegistry, WIDGETS_PATH } = require('./widgets'); +const { translatorFor } = require('./widget-i18n'); const { preserveWidgetSecrets } = require('./widget-secrets'); const { widgetConfigMatchesSaved, leavesStoredSecretBlank, RETYPE_MESSAGE } = require('./secret-scope'); const { dispatchProvider } = require('./provider-dispatch'); @@ -121,6 +122,18 @@ on('GET', '/api/widget-data/:id', async (req, res) => { /* Config-time "Fetch" for a select field declared with optionsFrom. The id is '__preview__' before the first save. */ +function labelOptions(body, entry, widget) { + if (!body || !Array.isArray(body.options) || !body.options.some(o => o && o.labelKey)) return body; + const translate = translatorFor(entry.dir, widget, loadConfig().settings?.language || 'en'); + const options = body.options.map(o => { + if (!o || typeof o.labelKey !== 'string') return o; + const { labelKey, ...rest } = o; + const found = translate(labelKey); + return found === labelKey ? rest : { ...rest, label: found }; + }); + return { ...body, options }; +} + on('POST', '/api/widget-options/:id', async (req, res) => { if (!checkOrigin(req, res)) return; const limited = rateLimit(getIp(req), 'widget-options', LIMITS.WIDGET_OPTIONS.max, LIMITS.WIDGET_OPTIONS.windowMs); @@ -158,7 +171,7 @@ on('POST', '/api/widget-options/:id', async (req, res) => { body.row, true, ); - json(res, out.status, out.body); + json(res, out.status, labelOptions(out.body, entry, body.widgetType)); } catch (e) { if (e instanceof SsrfBlockedError) return fail(res, e, { status: e.status }); if (withheld) @@ -168,4 +181,4 @@ on('POST', '/api/widget-options/:id', async (req, res) => { } }); -module.exports = { getWidgetData, normalizeBase, dataFnContext, resolveRow }; +module.exports = { getWidgetData, normalizeBase, dataFnContext, resolveRow, labelOptions }; diff --git a/api/test/widget-catalogs.test.js b/api/test/widget-catalogs.test.js index d39d1137..53a462a7 100644 --- a/api/test/widget-catalogs.test.js +++ b/api/test/widget-catalogs.test.js @@ -23,6 +23,11 @@ const widgets = fs .filter(d => d.isDirectory() && fs.existsSync(path.join(WIDGETS_DIR, d.name, 'widget.json'))) .map(d => d.name); +/* A counted message is one key per plural category, and each language uses its + own categories. Keys are compared by base name. */ +const PLURAL = /_(zero|one|two|few|many|other)$/; +const baseKeys = cat => [...new Set(Object.keys(cat).map(k => k.replace(PLURAL, '')))].sort(); + const hasCatalog = w => fs.existsSync(path.join(WIDGETS_DIR, w, 'i18n', 'en.json')); const readCatalog = (w, lang) => JSON.parse(fs.readFileSync(path.join(WIDGETS_DIR, w, 'i18n', lang + '.json'), 'utf8')); @@ -48,12 +53,12 @@ test('at least one bundled widget ships a catalog, or these tests prove nothing' for (const widget of widgets.filter(hasCatalog)) { test(`${widget}: every language has exactly the English keys`, () => { - const en = Object.keys(readCatalog(widget, 'en')).sort(); + const en = baseKeys(readCatalog(widget, 'en')); for (const lang of LANGUAGES.filter(l => l !== 'en')) { const file = path.join(WIDGETS_DIR, widget, 'i18n', lang + '.json'); assert.ok(fs.existsSync(file), `${widget} is missing ${lang}.json`); assert.deepEqual( - Object.keys(readCatalog(widget, lang)).sort(), + baseKeys(readCatalog(widget, lang)), en, `${widget}/${lang}.json does not have the same keys as en.json`, ); @@ -82,9 +87,10 @@ for (const widget of widgets.filter(hasCatalog)) { const en = readCatalog(widget, 'en'); for (const lang of LANGUAGES.filter(l => l !== 'en')) { const other = readCatalog(widget, lang); - for (const [k, v] of Object.entries(en)) { - const want = (v.match(/\{[a-zA-Z0-9_]+\}/g) || []).sort(); - const got = ((other[k] || '').match(/\{[a-zA-Z0-9_]+\}/g) || []).sort(); + for (const [k, v] of Object.entries(other)) { + const source = en[k] ?? en[`${k.replace(PLURAL, '')}_other`] ?? ''; + const want = (source.match(/\{[a-zA-Z0-9_]+\}/g) || []).sort(); + const got = (v.match(/\{[a-zA-Z0-9_]+\}/g) || []).sort(); assert.deepEqual(got, want, `${widget}/${lang}.json: ${k} does not carry the same placeholders`); } } diff --git a/api/test/widget-options-labels.test.js b/api/test/widget-options-labels.test.js new file mode 100644 index 00000000..579dbecb --- /dev/null +++ b/api/test/widget-options-labels.test.js @@ -0,0 +1,46 @@ +const path = require('node:path'); + +process.env.WIDGETS_PATH = path.join(__dirname, '../../ui/widgets'); +const { tmpDir } = require('../test-support/tmp'); +process.env.CONFIG_PATH = path.join(tmpDir('option-labels'), 'apps.json'); + +const { test, beforeEach } = require('node:test'); +const assert = require('node:assert/strict'); +const { saveConfig } = require('../src/config'); +const { labelOptions } = require('../src/widget-data'); +const { _resetCache } = require('../src/widget-i18n'); + +const entry = { dir: path.join(__dirname, '../../ui/widgets/system-summary') }; +const body = () => ({ + options: [ + { value: '*', label: 'All interfaces', labelKey: 'network.interface.all' }, + { value: 'eth0', label: 'eth0' }, + ], +}); + +beforeEach(() => _resetCache()); + +test('an option named by key takes its label from the configured language', () => { + saveConfig({ items: [], settings: { language: 'de' } }); + const out = labelOptions(body(), entry, 'system-summary'); + assert.deepEqual(out.options, [ + { value: '*', label: 'Alle Schnittstellen' }, + { value: 'eth0', label: 'eth0' }, + ]); +}); + +test('an option whose key no catalog has keeps its English label', () => { + saveConfig({ items: [], settings: { language: 'fr' } }); + const out = labelOptions( + { options: [{ value: 'x', label: 'Fallback', labelKey: 'no.such.key' }] }, + entry, + 'system-summary', + ); + assert.deepEqual(out.options, [{ value: 'x', label: 'Fallback' }]); +}); + +test('a reply without keyed options passes through untouched', () => { + const plain = { options: [{ value: 'a', label: 'a' }] }; + assert.equal(labelOptions(plain, entry, 'system-summary'), plain); + assert.equal(labelOptions(null, entry, 'system-summary'), null); +}); diff --git a/ui/admin/index.html b/ui/admin/index.html index 2ca8e491..67770dcc 100644 --- a/ui/admin/index.html +++ b/ui/admin/index.html @@ -301,6 +301,6 @@