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 @@