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

General

- + diff --git a/ui/i18n/de.json b/ui/i18n/de.json index 8b84ae14..2ae4b214 100644 --- a/ui/i18n/de.json +++ b/ui/i18n/de.json @@ -131,6 +131,7 @@ }, "type": { "addNew": "Neu hinzufügen", + "addNewNamed": "Neu hinzufügen: {name}", "app": "App", "widget": "Widget", "folder": "Ordner", @@ -295,8 +296,8 @@ "goToPage": "Zu Seite {page} von {total}", "appsAndFolders": "Apps & Ordner", "searchResults": "Suchergebnisse", - "result": "Ergebnis", - "results": "Ergebnisse", + "resultCount_one": "{count} Ergebnis", + "resultCount_other": "{count} Ergebnisse", "apiDownTitle": "Keine Verbindung zur Dashboard-API", "apiDownSub": "Stellen Sie sicher, dass der API-Container läuft", "retry": "Erneut versuchen", @@ -400,7 +401,15 @@ "status": { "needsAttention": "Status: benötigt Aufmerksamkeit", "healthy": "Status: in Ordnung", - "pending": "{count} ausstehend", + "pending_one": "{count} ausstehend", + "pending_other": "{count} ausstehend", + "pendingValue": "{value} ausstehend", + "containerCreated": "Container erstellt", + "containerRestarting": "Container startet neu", + "containerRemoving": "Container wird entfernt", + "containerPaused": "Container pausiert", + "containerExited": "Container gestoppt", + "containerDead": "Container ausgefallen", "stale": "(möglicherweise veraltet)", "moreValues_one": "{count} weiteres Badge", "moreValues_other": "{count} weitere Badges", diff --git a/ui/i18n/en.json b/ui/i18n/en.json index 6dfc8828..59bb08e2 100644 --- a/ui/i18n/en.json +++ b/ui/i18n/en.json @@ -131,6 +131,7 @@ }, "type": { "addNew": "Add New", + "addNewNamed": "Add New: {name}", "app": "App", "widget": "Widget", "folder": "Folder", @@ -295,8 +296,8 @@ "goToPage": "Go to page {page} of {total}", "appsAndFolders": "Apps & Folders", "searchResults": "Search results", - "result": "result", - "results": "results", + "resultCount_one": "{count} result", + "resultCount_other": "{count} results", "apiDownTitle": "Could not connect to dashboard API", "apiDownSub": "Make sure the API container is running", "retry": "Retry", @@ -400,7 +401,15 @@ "status": { "needsAttention": "Status: needs attention", "healthy": "Status: healthy", - "pending": "{count} pending", + "pending_one": "{count} pending", + "pending_other": "{count} pending", + "pendingValue": "{value} pending", + "containerCreated": "Container created", + "containerRestarting": "Container restarting", + "containerRemoving": "Container being removed", + "containerPaused": "Container paused", + "containerExited": "Container stopped", + "containerDead": "Container failed", "stale": "(may be out of date)", "moreValues_one": "{count} more badge", "moreValues_other": "{count} more badges", diff --git a/ui/i18n/es.json b/ui/i18n/es.json index b6f1f7ff..a4a186e0 100644 --- a/ui/i18n/es.json +++ b/ui/i18n/es.json @@ -131,6 +131,7 @@ }, "type": { "addNew": "Añadir nuevo", + "addNewNamed": "Añadir nuevo: {name}", "app": "Aplicación", "widget": "Widget", "folder": "Carpeta", @@ -300,8 +301,9 @@ "goToPage": "Ir a la página {page} de {total}", "appsAndFolders": "Aplicaciones y carpetas", "searchResults": "Resultados de la búsqueda", - "result": "resultado", - "results": "resultados", + "resultCount_one": "{count} resultado", + "resultCount_many": "{count} de resultados", + "resultCount_other": "{count} resultados", "apiDownTitle": "No se pudo conectar con la API del panel", "apiDownSub": "Comprueba que el contenedor de la API esté en ejecución", "retry": "Reintentar", @@ -406,7 +408,16 @@ "status": { "needsAttention": "Estado: necesita atención", "healthy": "Estado: correcto", - "pending": "{count} pendientes", + "pending_one": "{count} pendiente", + "pending_many": "{count} pendientes", + "pending_other": "{count} pendientes", + "pendingValue": "{value} pendientes", + "containerCreated": "Contenedor creado", + "containerRestarting": "Contenedor reiniciándose", + "containerRemoving": "Eliminando el contenedor", + "containerPaused": "Contenedor en pausa", + "containerExited": "Contenedor detenido", + "containerDead": "Contenedor con errores", "stale": "(puede estar desactualizado)", "moreValues_one": "{count} insignia más", "moreValues_many": "{count} insignias más", diff --git a/ui/i18n/fa.json b/ui/i18n/fa.json index 65b2f819..33792833 100644 --- a/ui/i18n/fa.json +++ b/ui/i18n/fa.json @@ -131,6 +131,7 @@ }, "type": { "addNew": "افزودن جدید", + "addNewNamed": "افزودن جدید: {name}", "app": "برنامه", "widget": "ویجت", "folder": "پوشه", @@ -295,8 +296,8 @@ "goToPage": "رفتن به صفحه {page} از {total}", "appsAndFolders": "برنامه‌ها و پوشه‌ها", "searchResults": "نتایج جستجو", - "result": "نتیجه", - "results": "نتیجه", + "resultCount_one": "{count} نتیجه", + "resultCount_other": "{count} نتیجه", "apiDownTitle": "اتصال به API داشبورد ممکن نشد", "apiDownSub": "مطمئن شوید کانتینر API در حال اجراست", "retry": "تلاش دوباره", @@ -400,7 +401,15 @@ "status": { "needsAttention": "وضعیت: نیاز به رسیدگی", "healthy": "وضعیت: سالم", - "pending": "{count} در انتظار", + "pending_one": "{count} در انتظار", + "pending_other": "{count} در انتظار", + "pendingValue": "{value} در انتظار", + "containerCreated": "کانتینر ساخته شده", + "containerRestarting": "کانتینر در حال راه‌اندازی دوباره", + "containerRemoving": "کانتینر در حال حذف", + "containerPaused": "کانتینر موقتا متوقف شده", + "containerExited": "کانتینر متوقف شده", + "containerDead": "کانتینر از کار افتاده", "stale": "(ممکن است قدیمی باشد)", "moreValues_one": "{count} نشان دیگر", "moreValues_other": "{count} نشان دیگر", diff --git a/ui/i18n/fr.json b/ui/i18n/fr.json index df50ea69..eadb362f 100644 --- a/ui/i18n/fr.json +++ b/ui/i18n/fr.json @@ -131,6 +131,7 @@ }, "type": { "addNew": "Ajouter", + "addNewNamed": "Ajouter : {name}", "app": "Application", "widget": "Widget", "folder": "Dossier", @@ -300,8 +301,9 @@ "goToPage": "Aller à la page {page} sur {total}", "appsAndFolders": "Applications et dossiers", "searchResults": "Résultats de recherche", - "result": "résultat", - "results": "résultats", + "resultCount_one": "{count} résultat", + "resultCount_many": "{count} de résultats", + "resultCount_other": "{count} résultats", "apiDownTitle": "Connexion à l’API du tableau de bord impossible", "apiDownSub": "Vérifiez que le conteneur de l’API est démarré", "retry": "Réessayer", @@ -406,7 +408,16 @@ "status": { "needsAttention": "État : nécessite une intervention", "healthy": "État : normal", - "pending": "{count} en attente", + "pending_one": "{count} en attente", + "pending_many": "{count} en attente", + "pending_other": "{count} en attente", + "pendingValue": "{value} en attente", + "containerCreated": "Conteneur créé", + "containerRestarting": "Conteneur en redémarrage", + "containerRemoving": "Suppression du conteneur", + "containerPaused": "Conteneur en pause", + "containerExited": "Conteneur arrêté", + "containerDead": "Conteneur en échec", "stale": "(peut-être obsolète)", "moreValues_one": "{count} badge de plus", "moreValues_many": "{count} badges de plus", diff --git a/ui/i18n/zh-Hans.json b/ui/i18n/zh-Hans.json index 00058bba..b59bd50a 100644 --- a/ui/i18n/zh-Hans.json +++ b/ui/i18n/zh-Hans.json @@ -131,6 +131,7 @@ }, "type": { "addNew": "新建", + "addNewNamed": "新建:{name}", "app": "应用", "widget": "小组件", "folder": "文件夹", @@ -290,8 +291,7 @@ "goToPage": "转到第 {page} 页,共 {total} 页", "appsAndFolders": "应用与文件夹", "searchResults": "搜索结果", - "result": "个结果", - "results": "个结果", + "resultCount_other": "{count} 个结果", "apiDownTitle": "无法连接到仪表板 API", "apiDownSub": "请确认 API 容器正在运行", "retry": "重试", @@ -394,7 +394,14 @@ "status": { "needsAttention": "状态:需要处理", "healthy": "状态:正常", - "pending": "{count} 项待处理", + "pending_other": "{count} 项待处理", + "pendingValue": "{value} 待处理", + "containerCreated": "容器已创建", + "containerRestarting": "容器正在重启", + "containerRemoving": "容器正在删除", + "containerPaused": "容器已暂停", + "containerExited": "容器已停止", + "containerDead": "容器已失效", "stale": "(可能已过时)", "moreValues_other": "另有 {count} 个徽章", "containerNotFound": "未找到容器", diff --git a/ui/index.html b/ui/index.html index 9dc92657..76ab4b22 100644 --- a/ui/index.html +++ b/ui/index.html @@ -56,5 +56,5 @@ - + diff --git a/ui/js/admin-app-form.js b/ui/js/admin-app-form.js index 542d0a72..a595fc45 100644 --- a/ui/js/admin-app-form.js +++ b/ui/js/admin-app-form.js @@ -1,9 +1,9 @@ -import { clr, el, focusFirst, initial, inp as inpById, isolate, q as qSel, qa, qi, tgt } from '/js/utils.js?v=0d64dfd3'; +import { clr, el, focusFirst, initial, inp as inpById, isolate, q as qSel, qa, qi, tgt } from '/js/utils.js?v=b6e6c040'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { loadLocalIcons, resolveIcon, iconChain, cdnIconRef, splitIconRef } from '/js/icons.js?v=9c7b5111'; import { state } from '/js/admin-state.js?v=af772a1b'; import { DOCK_MAX } from '/js/limits.js?v=31048a24'; -import { formatNumber } from '/js/format-number.js?v=349a741d'; +import { formatNumber } from '/js/format-number.js?v=52226e9c'; import { isDockBlocked, clearsStoredSecret, @@ -12,7 +12,7 @@ import { nextActiveIndex, sameIconName, } from '/js/admin-logic.js?v=fc7f0836'; -import { t } from '/js/i18n.js?v=5579776a'; +import { t } from '/js/i18n.js?v=f754f6fe'; import { toast, errorText, @@ -24,10 +24,10 @@ import { reveal, setTogDisabled, swapContent, -} from '/js/admin-shared.js?v=2b48ff3f'; -import { createListbox } from '/js/listbox.js?v=35d8318a'; -import { MAX_LABELS } from '/js/badge-logic.js?v=9e6d9d4b'; -import { renderColorControl, BADGE_DEFAULT } from '/js/admin-color-control.js?v=3b14e88c'; +} from '/js/admin-shared.js?v=38ed2b51'; +import { createListbox } from '/js/listbox.js?v=ae7b9a38'; +import { MAX_LABELS } from '/js/badge-logic.js?v=e3c32915'; +import { renderColorControl, BADGE_DEFAULT } from '/js/admin-color-control.js?v=44fdebde'; import { badgeErrorAdvice, TONE } from '/js/admin-error.js?v=b0881957'; import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86'; import { iconSvg } from '/js/icon-set.js?v=628341d4'; diff --git a/ui/js/admin-auth.js b/ui/js/admin-auth.js index 2833df8c..2ebc608f 100644 --- a/ui/js/admin-auth.js +++ b/ui/js/admin-auth.js @@ -1,9 +1,9 @@ -import { apiGet, apiPost } from '/js/admin-shared.js?v=2b48ff3f'; -import { initI18n, t } from '/js/i18n.js?v=5579776a'; +import { apiGet, apiPost } from '/js/admin-shared.js?v=38ed2b51'; +import { initI18n, t } from '/js/i18n.js?v=f754f6fe'; import { loginErrorKey } from '/js/admin-error.js?v=b0881957'; import { pwStrength } from '/js/password-strength.js?v=389e0ed0'; -import { el, inp as inpById, qa } from '/js/utils.js?v=0d64dfd3'; -import { blockingScreenFor } from '/js/config-recovery.js?v=dbe542e1'; +import { el, inp as inpById, qa } from '/js/utils.js?v=b6e6c040'; +import { blockingScreenFor } from '/js/config-recovery.js?v=e22b5e5a'; export async function checkAuth(onLogin) { try { diff --git a/ui/js/admin-color-control.js b/ui/js/admin-color-control.js index 452a45e9..375c5882 100644 --- a/ui/js/admin-color-control.js +++ b/ui/js/admin-color-control.js @@ -1,7 +1,7 @@ -import { PE_SVG, initInlineEdit, toast, reveal } from '/js/admin-shared.js?v=2b48ff3f'; -import { t } from '/js/i18n.js?v=5579776a'; +import { PE_SVG, initInlineEdit, toast, reveal } from '/js/admin-shared.js?v=38ed2b51'; +import { t } from '/js/i18n.js?v=f754f6fe'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { qa, q } from '/js/utils.js?v=0d64dfd3'; +import { qa, q } from '/js/utils.js?v=b6e6c040'; import { iconSvg } from '/js/icon-set.js?v=628341d4'; import { HUE_NAMES, ROLE_NAMES, TILE_KEYWORDS, pageTheme, tileColor } from '/js/palette.js?v=3fb8ae43'; diff --git a/ui/js/admin-drag.js b/ui/js/admin-drag.js index 5f29ba12..efb695a7 100644 --- a/ui/js/admin-drag.js +++ b/ui/js/admin-drag.js @@ -12,7 +12,7 @@ import { applyDrop, canJoinFolder, folderRowZone } from '/js/admin-drag-logic.js?v=6b767e76'; import { state } from '/js/admin-state.js?v=af772a1b'; import { snapshotItems } from '/js/admin-save-logic.js?v=5811620e'; -import { qa } from '/js/utils.js?v=0d64dfd3'; +import { qa } from '/js/utils.js?v=b6e6c040'; /** @type {(before: unknown) => void} */ let _save = () => {}; diff --git a/ui/js/admin-list.js b/ui/js/admin-list.js index 01ae5995..ea660f0d 100644 --- a/ui/js/admin-list.js +++ b/ui/js/admin-list.js @@ -14,12 +14,12 @@ import { collapsedFolders, filter, state } from '/js/admin-state.js?v=af772a1b'; import { snapshotItems } from '/js/admin-save-logic.js?v=5811620e'; import { reorderItems } from '/js/admin-logic.js?v=fc7f0836'; -import { initDrag, wireRowDrag } from '/js/admin-drag.js?v=14a608cd'; -import { paintIcon } from '/js/admin-shared.js?v=2b48ff3f'; -import { clr, el, focusFirst, initial, qa, setUserText } from '/js/utils.js?v=0d64dfd3'; +import { initDrag, wireRowDrag } from '/js/admin-drag.js?v=d522e526'; +import { paintIcon } from '/js/admin-shared.js?v=38ed2b51'; +import { clr, el, focusFirst, initial, qa, setUserText } from '/js/utils.js?v=b6e6c040'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { t } from '/js/i18n.js?v=5579776a'; -import { sizeLabel } from '/js/admin-widget-form.js?v=8d89e268'; +import { t } from '/js/i18n.js?v=f754f6fe'; +import { sizeLabel } from '/js/admin-widget-form.js?v=0879c8a7'; import { widgetGlyph } from '/js/widget-glyphs.js?v=b383b7c8'; import { iconSvg } from '/js/icon-set.js?v=628341d4'; diff --git a/ui/js/admin-settings.js b/ui/js/admin-settings.js index 2e93c5f1..aa48a8d8 100644 --- a/ui/js/admin-settings.js +++ b/ui/js/admin-settings.js @@ -1,7 +1,7 @@ -import { toast, apiGet, apiPost, errorText, reveal, swapContent } from '/js/admin-shared.js?v=2b48ff3f'; +import { toast, apiGet, apiPost, errorText, reveal, swapContent } from '/js/admin-shared.js?v=38ed2b51'; import { socketProbeAdvice } from '/js/admin-error.js?v=b0881957'; import { pwStrength } from '/js/password-strength.js?v=389e0ed0'; -import { t } from '/js/i18n.js?v=5579776a'; +import { t } from '/js/i18n.js?v=f754f6fe'; import { shouldWritePassword, settingsSaveBlocker, @@ -11,11 +11,11 @@ import { BLOCK, } from '/js/admin-logic.js?v=fc7f0836'; import { confirmText, promptModal } from '/js/modal.js?v=6b0320bd'; -import { el, inp, setUserText } from '/js/utils.js?v=0d64dfd3'; -import { formatNumber } from '/js/format-number.js?v=349a741d'; +import { el, inp, setUserText } from '/js/utils.js?v=b6e6c040'; +import { formatNumber } from '/js/format-number.js?v=52226e9c'; import { serialWrites } from '/js/admin-save-logic.js?v=5811620e'; -import { renderColorControl } from '/js/admin-color-control.js?v=3b14e88c'; -import { BACKDROP } from '/js/background.js?v=fe705356'; +import { renderColorControl } from '/js/admin-color-control.js?v=44fdebde'; +import { BACKDROP } from '/js/background.js?v=1bd5f25e'; import { ALLOWED_HOSTS_MAX, firstBadHost, diff --git a/ui/js/admin-shared.js b/ui/js/admin-shared.js index 3d39d324..799bb387 100644 --- a/ui/js/admin-shared.js +++ b/ui/js/admin-shared.js @@ -1,12 +1,12 @@ /* Stateless helpers shared by the admin modules. Mutable state stays out. */ import { recoversSession, toastHoldMs } from '/js/admin-logic.js?v=fc7f0836'; -import { el, isolate, q } from '/js/utils.js?v=0d64dfd3'; -import { t } from '/js/i18n.js?v=5579776a'; +import { el, isolate, q } from '/js/utils.js?v=b6e6c040'; +import { t } from '/js/i18n.js?v=f754f6fe'; import { errorAdvice } from '/js/admin-error.js?v=b0881957'; -import { formatNumber } from '/js/format-number.js?v=349a741d'; +import { formatList, formatNumber } from '/js/format-number.js?v=52226e9c'; import { iconChain } from '/js/icons.js?v=9c7b5111'; import { iconSvg } from '/js/icon-set.js?v=628341d4'; -import { blockingScreenFor, recoveryShown, showBlockingScreen } from '/js/config-recovery.js?v=dbe542e1'; +import { blockingScreenFor, recoveryShown, showBlockingScreen } from '/js/config-recovery.js?v=e22b5e5a'; export const API = ''; @@ -153,7 +153,7 @@ export function withheldText(body) { .map(w => w.label) .filter(Boolean) .map(isolate); - return names.length ? t('toast.secretsWithheld', { items: names.join(', ') }) : ''; + return names.length ? t('toast.secretsWithheld', { items: formatList(names) }) : ''; } /* A native `disabled` control is skipped by screen readers. aria-disabled keeps diff --git a/ui/js/admin-widget-form.js b/ui/js/admin-widget-form.js index 6a0a1466..7a53c324 100644 --- a/ui/js/admin-widget-form.js +++ b/ui/js/admin-widget-form.js @@ -1,11 +1,11 @@ import { state } from '/js/admin-state.js?v=af772a1b'; -import { PE_SVG, initInlineEdit, swapContent } from '/js/admin-shared.js?v=2b48ff3f'; -import { createListbox } from '/js/listbox.js?v=35d8318a'; -import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=02ffdaba'; +import { PE_SVG, initInlineEdit, swapContent } from '/js/admin-shared.js?v=38ed2b51'; +import { createListbox } from '/js/listbox.js?v=ae7b9a38'; +import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=8c3f820d'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { sizesForView, widgetConfigMode, rejectionLines, carriesTypedValues } from '/js/admin-logic.js?v=fc7f0836'; -import { t } from '/js/i18n.js?v=5579776a'; -import { focusFirst, q, qi, qa } from '/js/utils.js?v=0d64dfd3'; +import { t } from '/js/i18n.js?v=f754f6fe'; +import { focusFirst, q, qi, qa } from '/js/utils.js?v=b6e6c040'; import { iconSvg } from '/js/icon-set.js?v=628341d4'; const SIZES_WITH_ICONS = new Set(['small', 'medium', 'large', 'xlarge']); diff --git a/ui/js/admin.js b/ui/js/admin.js index 3937e376..9aad2c8f 100644 --- a/ui/js/admin.js +++ b/ui/js/admin.js @@ -1,7 +1,7 @@ -import { buildAppForm, buildFolderForm, captureActLabels, serializeKvRows } from '/js/admin-app-form.js?v=7e288a05'; -import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=db741421'; -import { recoveryShown } from '/js/config-recovery.js?v=dbe542e1'; -import { focusRow, initList, render, syncFilterUI } from '/js/admin-list.js?v=581b7729'; +import { buildAppForm, buildFolderForm, captureActLabels, serializeKvRows } from '/js/admin-app-form.js?v=f7f58a4d'; +import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=8aee98e6'; +import { recoveryShown } from '/js/config-recovery.js?v=e22b5e5a'; +import { focusRow, initList, render, syncFilterUI } from '/js/admin-list.js?v=547999d2'; import { resolveAdminSection } from '/js/admin-logic.js?v=fc7f0836'; import { afterImport, @@ -21,7 +21,7 @@ import { settingsDirty, showBgFields, showWallpaperFile, -} from '/js/admin-settings.js?v=a5dc252e'; +} from '/js/admin-settings.js?v=a310b96a'; import { apiGet, apiPost, @@ -35,15 +35,15 @@ import { setReauthHandler, toast, withheldText, -} from '/js/admin-shared.js?v=2b48ff3f'; +} from '/js/admin-shared.js?v=38ed2b51'; import { collapsedFolders, filter, state } from '/js/admin-state.js?v=af772a1b'; -import { buildWidgetForm } from '/js/admin-widget-form.js?v=8d89e268'; +import { buildWidgetForm } from '/js/admin-widget-form.js?v=0879c8a7'; import { initFluidHover } from '/js/fluid-hover.js?v=cb886e86'; -import { formatNumber } from '/js/format-number.js?v=349a741d'; +import { formatNumber } from '/js/format-number.js?v=52226e9c'; import { initGlideSelect, syncGlideSelect } from '/js/glide-select.js?v=8b39e9d0'; -import { createListbox } from '/js/listbox.js?v=35d8318a'; +import { createListbox } from '/js/listbox.js?v=ae7b9a38'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { initI18n, LANGUAGES, t } from '/js/i18n.js?v=5579776a'; +import { initI18n, LANGUAGES, t } from '/js/i18n.js?v=f754f6fe'; import { loadLocalIcons } from '/js/icons.js?v=9c7b5111'; import { ensureSprite, iconSvg } from '/js/icon-set.js?v=628341d4'; import { @@ -80,8 +80,8 @@ import { storeGet, storeSet, tgt, -} from '/js/utils.js?v=0d64dfd3'; -import { applyBackground, resolveBackground } from '/js/background.js?v=fe705356'; +} from '/js/utils.js?v=b6e6c040'; +import { applyBackground, resolveBackground } from '/js/background.js?v=1bd5f25e'; import { parseYamlTolerant, YamlLiteError, YamlTooLargeError } from '/js/yaml-lite.js?v=a42ef914'; ensureSprite(); @@ -321,7 +321,7 @@ function buildAddNewCard() { b.className = 'tile-opt' + (kind === state.ctype ? ' on' : ''); b.dataset.ctype = kind; b.setAttribute('aria-pressed', String(kind === state.ctype)); - b.setAttribute('aria-label', t('type.addNew') + ': ' + label); + b.setAttribute('aria-label', t('type.addNewNamed', { name: label })); setHtml(b, html`${raw(iconSvg(kind, 38))}${label}`); b.onclick = () => { if (state.ctype === kind) return; @@ -1053,7 +1053,7 @@ function initLanguage() { const box = createListbox({ id: 'lang', label: t('common.language'), - options: LANGUAGES.map(l => ({ value: l.code, label: l.name })), + options: LANGUAGES.map(l => ({ value: l.code, label: l.name, lang: l.code })), value: hidden.value || 'en', onChange: v => { hidden.value = String(v); diff --git a/ui/js/background.js b/ui/js/background.js index 9214749c..7e6928c4 100644 --- a/ui/js/background.js +++ b/ui/js/background.js @@ -1,5 +1,5 @@ // @ts-check -import { cssColor, sanitizeCssUrl } from '/js/utils.js?v=0d64dfd3'; +import { cssColor, sanitizeCssUrl } from '/js/utils.js?v=b6e6c040'; import { loadWallpaper, saveWallpaper } from '/js/wallpaper-cache.js?v=c5f8a3e6'; export const BACKDROP = '#0d1117'; diff --git a/ui/js/badge-logic.js b/ui/js/badge-logic.js index e02a1d55..d7c84129 100644 --- a/ui/js/badge-logic.js +++ b/ui/js/badge-logic.js @@ -5,16 +5,33 @@ const EN = { 'status.needsAttention': 'Status: needs attention', 'status.healthy': 'Status: healthy', - 'status.pending': '{count} pending', + 'status.pending_one': '{count} pending', + 'status.pending_other': '{count} pending', + 'status.pendingValue': '{value} pending', 'status.stale': '(may be out of date)', 'status.moreValues_one': '{count} more badge', 'status.moreValues_other': '{count} more badges', 'status.containerNotFound': 'Container not found', 'status.containerState': 'Container {state}', + 'status.containerCreated': 'Container created', + 'status.containerRestarting': 'Container restarting', + 'status.containerRemoving': 'Container being removed', + 'status.containerPaused': 'Container paused', + 'status.containerExited': 'Container stopped', + 'status.containerDead': 'Container failed', 'status.pingFailed': 'Ping failed: {error}', 'status.pingReturned': 'Ping returned {status}', }; +const CONTAINER_STATES = { + created: 'status.containerCreated', + restarting: 'status.containerRestarting', + removing: 'status.containerRemoving', + paused: 'status.containerPaused', + exited: 'status.containerExited', + dead: 'status.containerDead', +}; + /** English, with the same counted-message resolution the real translator does. @param {string} key @param {Record} [vars] */ function _fallback(key, vars) { @@ -90,7 +107,10 @@ export function healthReason(detail, translate) { parts.push(tr('status.containerNotFound')); } else if (detail.state && detail.state !== 'running') { /* Untranslated. The text comes from the daemon. */ - parts.push(_clip(detail.status || tr('status.containerState', { state: detail.state }), REASON_MAX)); + const known = CONTAINER_STATES[detail.state]; + parts.push( + _clip(detail.status || (known ? tr(known) : tr('status.containerState', { state: detail.state })), REASON_MAX), + ); } else if (detail.status && /unhealthy/i.test(detail.status)) { parts.push(_clip(detail.status, REASON_MAX)); } @@ -226,13 +246,18 @@ export function computeBadgeVisual({ const txt = num + (unit ? ' ' + unit : ''); /* Status text, so meaning is not carried by colour alone. */ + const pending = (value, suffix) => + typeof value === 'number' && Number.isFinite(value) && value <= 99 && !suffix + ? tr('status.pending', { count: value }) + : tr('status.pendingValue', { + value: + (typeof value !== 'number' ? value : value > 99 ? `${fmt(99)}+` : fmt(value)) + + (suffix ? ' ' + suffix : ''), + }); let aria = ''; if (health) aria = tr('status.needsAttention'); - else if (top) aria = top.name ? `${top.name}: ${fmt(top.value)}` : tr('status.pending', { count: fmt(top.value) }); - else if (active) - aria = tr('status.pending', { - count: (activity > 99 ? `${fmt(99)}+` : fmt(activity)) + (custom.unit ? ' ' + custom.unit : ''), - }); + else if (top) aria = top.name ? `${top.name}: ${fmt(top.value)}` : pending(top.value, ''); + else if (active) aria = pending(activity, custom.unit); else if (fixed) aria = staticBdg.label; else if (cls.includes('green')) aria = tr('status.healthy'); diff --git a/ui/js/badge-popover.js b/ui/js/badge-popover.js index aafe563d..8a41a011 100644 --- a/ui/js/badge-popover.js +++ b/ui/js/badge-popover.js @@ -2,7 +2,7 @@ /* One element for the whole dashboard. A per-tile popover outlives the tiles the grid rebuilds on every resize. */ -import { formatNumber } from '/js/format-number.js?v=349a741d'; +import { formatNumber } from '/js/format-number.js?v=52226e9c'; const HOVER_IN_MS = 320; const COMPAT_CLICK_MS = 500; diff --git a/ui/js/clock-date.js b/ui/js/clock-date.js index 22867803..aa98f3a6 100644 --- a/ui/js/clock-date.js +++ b/ui/js/clock-date.js @@ -16,6 +16,11 @@ function format(lang, opts) { @param {string} lang @param {'long'|'short'} width @returns {(date: Date) => { full: string, weekday: string, date: string }} */ export function clockDateParts(lang, width) { + try { + lang = Intl.getCanonicalLocales(lang)[0] || 'en'; + } catch { + lang = 'en'; + } /** @type {Intl.DateTimeFormatOptions} */ const opts = { weekday: width, month: width, day: 'numeric' }; if (String(lang).split('-')[0] !== 'fa') { diff --git a/ui/js/config-recovery.js b/ui/js/config-recovery.js index 03937080..1cc97168 100644 --- a/ui/js/config-recovery.js +++ b/ui/js/config-recovery.js @@ -1,6 +1,6 @@ // @ts-check import { esc, html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { LANGUAGES, initI18n, t } from '/js/i18n.js?v=5579776a'; +import { LANGUAGES, initI18n, t } from '/js/i18n.js?v=f754f6fe'; import { sanitizeI18nMarkup } from '/js/i18n-markup.js?v=8c90e1dd'; import { fillNames, pickLanguage, screenFor } from '/js/config-recovery-logic.js?v=e6e24763'; diff --git a/ui/js/dashboard.js b/ui/js/dashboard.js index 81280db6..cebc010f 100644 --- a/ui/js/dashboard.js +++ b/ui/js/dashboard.js @@ -28,12 +28,12 @@ import { storeSet, teardownWidgets, titleWhenTruncated, -} from '/js/utils.js?v=0d64dfd3'; +} from '/js/utils.js?v=b6e6c040'; import { initFluidHover } from '/js/fluid-hover.js?v=cb886e86'; -import { initSpotlight } from '/js/spotlight.js?v=e89d7f89'; +import { initSpotlight } from '/js/spotlight.js?v=c39b3f63'; import { html, setHtml, raw } from '/js/html.js?v=c71f8903'; -import { initI18n, t, currentLang } from '/js/i18n.js?v=5579776a'; -import { blockingScreenFor, showBlockingScreen } from '/js/config-recovery.js?v=dbe542e1'; +import { initI18n, t, currentLang } from '/js/i18n.js?v=f754f6fe'; +import { blockingScreenFor, showBlockingScreen } from '/js/config-recovery.js?v=e22b5e5a'; import { pwStrength, passwordMismatch } from '/js/password-strength.js?v=389e0ed0'; import { setupErrorKey } from '/js/admin-error.js?v=b0881957'; import { sanitizeItemLinks } from '/js/link-url.js?v=54adb40f'; @@ -46,10 +46,10 @@ import { buildMobile, resetMobileChrome, mkFolderGlyph, -} from '/js/ui.js?v=d648c39b'; -import { badgeMinimum, badgeSignature, computeBadgeVisual, readBadgeUpdate } from '/js/badge-logic.js?v=9e6d9d4b'; -import { formatNumber } from '/js/format-number.js?v=349a741d'; -import { closeBadgePopover, wireBadgePopover } from '/js/badge-popover.js?v=1e0ab5d1'; +} from '/js/ui.js?v=fd480686'; +import { badgeMinimum, badgeSignature, computeBadgeVisual, readBadgeUpdate } from '/js/badge-logic.js?v=e3c32915'; +import { formatNumber } from '/js/format-number.js?v=52226e9c'; +import { closeBadgePopover, wireBadgePopover } from '/js/badge-popover.js?v=9f510a4c'; import { observeGlass } from '/js/glass-rim.js?v=3faec233'; import { configChanged, @@ -59,7 +59,7 @@ import { restorePage, wheelPager, } from '/js/dashboard-logic.js?v=6a29cedc'; -import { applyBackground, BACKDROP, resolveBackground } from '/js/background.js?v=fe705356'; +import { applyBackground, BACKDROP, resolveBackground } from '/js/background.js?v=1bd5f25e'; import { repeatJittered } from '/js/jitter.js?v=087a1fcf'; import { isMobileLayout, onLayoutChange } from '/js/layout.js?v=e9f4b607'; import { startWakeLock } from '/js/wake-lock.js?v=6b9591cf'; diff --git a/ui/js/format-number.js b/ui/js/format-number.js index 5396af03..16605662 100644 --- a/ui/js/format-number.js +++ b/ui/js/format-number.js @@ -59,6 +59,18 @@ export function formatNumber(value, options) { return cached.format(value); } +/** Join items the way the interface language writes a list. + @param {string[]} items @param {'conjunction'|'unit'} [type] + @returns {string} */ +export function formatList(items, type = 'conjunction') { + const list = items.map(String); + try { + return new Intl.ListFormat(language || 'en', { type, style: type === 'unit' ? 'short' : 'long' }).format(list); + } catch { + return list.join(', '); + } +} + /* Ten entries, built once per locale from the same cached formatter. Formatting each character separately called Intl on every digit of every polled string. */ let digitMap = null; diff --git a/ui/js/i18n.js b/ui/js/i18n.js index f99d0675..e11eaa6f 100644 --- a/ui/js/i18n.js +++ b/ui/js/i18n.js @@ -4,7 +4,7 @@ import { setHtml } from '/js/html.js?v=c71f8903'; import { i18nMarkup } from '/js/i18n-markup.js?v=8c90e1dd'; -import { formatNumber, setNumberLanguage } from '/js/format-number.js?v=349a741d'; +import { formatNumber, setNumberLanguage } from '/js/format-number.js?v=52226e9c'; /* The locale registry: the one place a supported language is defined. diff --git a/ui/js/listbox.js b/ui/js/listbox.js index 63f1d8d8..b0db0513 100644 --- a/ui/js/listbox.js +++ b/ui/js/listbox.js @@ -8,8 +8,9 @@ import { nextActiveIndex } from '/js/admin-logic.js?v=fc7f0836'; import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86'; +import { formatList } from '/js/format-number.js?v=52226e9c'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { qa } from '/js/utils.js?v=0d64dfd3'; +import { qa } from '/js/utils.js?v=b6e6c040'; import { iconSvg } from '/js/icon-set.js?v=628341d4'; const CHEV = iconSvg('chevrons', 22, 'solid', 'dd-chev'); @@ -52,7 +53,7 @@ function focusBeside(from, step) { /** An entry with `group` and no `value` is a heading. The keyboard skips it. - @typedef {{ value?: string, label?: string, group?: string }} ListboxOption */ + @typedef {{ value?: string, label?: string, group?: string, lang?: string }} ListboxOption */ /** Builds a picker and returns it with its own small API. @@ -114,8 +115,15 @@ export function createListbox( const sel = selected(); if (summary) text.textContent = summary(sel); else if (!sel.length) text.textContent = placeholder; - else text.textContent = sel.map(o => o.label).join(', '); + else + text.textContent = formatList( + sel.map(o => o.label), + 'unit', + ); text.classList.toggle('is-ph', !sel.length && !summary); + const lang = !summary && sel.length === 1 ? sel[0].lang : ''; + if (lang) text.lang = lang; + else text.removeAttribute('lang'); } function paintList() { @@ -133,6 +141,8 @@ export function createListbox( ); items().forEach(li => { li.tabIndex = -1; + const o = opts.find(x => x.group == null && String(x.value) === li.dataset.val); + if (o && o.lang) li.lang = o.lang; }); } diff --git a/ui/js/spotlight.js b/ui/js/spotlight.js index 9e43568e..59cc4bef 100644 --- a/ui/js/spotlight.js +++ b/ui/js/spotlight.js @@ -1,7 +1,6 @@ import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86'; -import { mk, clr, el, initial, inp as inpById, q, qa, setUserText } from '/js/utils.js?v=0d64dfd3'; -import { t } from '/js/i18n.js?v=5579776a'; -import { formatNumber } from '/js/format-number.js?v=349a741d'; +import { mk, clr, el, initial, inp as inpById, q, qa, setUserText } from '/js/utils.js?v=b6e6c040'; +import { t } from '/js/i18n.js?v=f754f6fe'; /* Attached to the window so a re-open can undo the previous one. */ const _w = /** @type {any} */ (window); @@ -146,8 +145,7 @@ export function initSpotlight({ }); res.appendChild(f); fluidHoverClear(res); - if (live) - live.textContent = formatNumber(cur.length) + ' ' + (cur.length === 1 ? t('home.result') : t('home.results')); + if (live) live.textContent = t('home.resultCount', { count: cur.length }); inp.setAttribute('aria-expanded', 'true'); inp.setAttribute('aria-activedescendant', cur.length ? 'sr-opt-0' : ''); }; diff --git a/ui/js/ui.js b/ui/js/ui.js index 12f98b03..5feea029 100644 --- a/ui/js/ui.js +++ b/ui/js/ui.js @@ -16,8 +16,8 @@ import { q, qa, setUserText, -} from '/js/utils.js?v=0d64dfd3'; -import { t, currentLang } from '/js/i18n.js?v=5579776a'; +} from '/js/utils.js?v=b6e6c040'; +import { t, currentLang } from '/js/i18n.js?v=f754f6fe'; import { toneForColor } from '/js/label-contrast.js?v=0b1ebb19'; import { mobileMetrics, gridColumnWidth, gridCellCount } from '/js/mobile-metrics.js?v=349f4300'; import { smoothRectPath } from '/js/smooth-corner.js?v=b7dda7e1'; diff --git a/ui/js/utils.js b/ui/js/utils.js index d81e2bda..de489029 100644 --- a/ui/js/utils.js +++ b/ui/js/utils.js @@ -3,7 +3,7 @@ import { toneForColor } from '/js/label-contrast.js?v=0b1ebb19'; import { SETTINGS_ICON, SETTINGS_ICON_LIGHT } from '/js/settings-icon.js?v=4079b66a'; import { mkGlassRim } from '/js/glass-rim.js?v=3faec233'; import { smoothRectPath } from '/js/smooth-corner.js?v=b7dda7e1'; -import { t } from '/js/i18n.js?v=5579776a'; +import { t } from '/js/i18n.js?v=f754f6fe'; import { pageTheme, tileColor } from '/js/palette.js?v=3fb8ae43'; export const mk = (tag, a = {}) => { diff --git a/ui/js/widget-config-form.js b/ui/js/widget-config-form.js index d85d2ef2..62153f58 100644 --- a/ui/js/widget-config-form.js +++ b/ui/js/widget-config-form.js @@ -1,11 +1,11 @@ /* Renders a widget's declared `fields` into a config form and reads the values back. Each builder returns { el, get, control, liveValue }. */ -import { t } from '/js/i18n.js?v=5579776a'; +import { t } from '/js/i18n.js?v=f754f6fe'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { reveal, wireInlineEdit } from '/js/admin-shared.js?v=2b48ff3f'; -import { createListbox } from '/js/listbox.js?v=35d8318a'; -import { renderColorControl } from '/js/admin-color-control.js?v=3b14e88c'; +import { reveal, wireInlineEdit } from '/js/admin-shared.js?v=38ed2b51'; +import { createListbox } from '/js/listbox.js?v=ae7b9a38'; +import { renderColorControl } from '/js/admin-color-control.js?v=44fdebde'; import { seedCarried, applyOptionSet, @@ -15,8 +15,8 @@ import { visibleFieldFlags, } from '/js/admin-logic.js?v=fc7f0836'; import { optionsErrorAdvice, TONE } from '/js/admin-error.js?v=b0881957'; -import { qi } from '/js/utils.js?v=0d64dfd3'; -import { formatNumber } from '/js/format-number.js?v=349a741d'; +import { qi } from '/js/utils.js?v=b6e6c040'; +import { formatNumber } from '/js/format-number.js?v=52226e9c'; const PE = ''; diff --git a/ui/js/widget-toolbox.js b/ui/js/widget-toolbox.js index 994a00fe..2ed09a24 100644 --- a/ui/js/widget-toolbox.js +++ b/ui/js/widget-toolbox.js @@ -5,7 +5,7 @@ import { esc, html, setHtml } from '/js/html.js?v=c71f8903'; import { isSafeLinkUrl } from '/js/link-url.js?v=54adb40f'; import { jitter } from '/js/jitter.js?v=087a1fcf'; import { errorState as _errorState, errorKind, errorCopy } from '/js/widget-error.js?v=3bfb691c'; -import { setNumberLanguage } from '/js/format-number.js?v=349a741d'; +import { formatNumber, setNumberLanguage } from '/js/format-number.js?v=52226e9c'; export { esc, html, setHtml }; @@ -403,7 +403,7 @@ async function _loadStrings() { /* English is a usable answer */ } } -_loadStrings(); +const _sharedStrings = _loadStrings(); /** @param {string} key @param {string} fallback */ function _t(key, fallback) { @@ -421,6 +421,7 @@ let _own = null; @returns {Promise} */ export async function loadStrings() { + await _sharedStrings; if (!_widgetName || _lang === 'en' || _own) return; try { const r = await fetch(`/widgets/${encodeURIComponent(_widgetName)}/i18n/${encodeURIComponent(_lang)}.json`, { @@ -447,17 +448,38 @@ export function cssZoom() { return (w && b.getBoundingClientRect().width / w) || 1; } -/** @param {string} key @param {string} fallback @returns {string} */ -export function wt(key, fallback) { +const _fill = (s, vars) => (vars ? s.replace(/\{(\w+)\}/g, (m, k) => (vars[k] == null ? m : String(vars[k]))) : s); + +/** @param {string} key @param {string} fallback + @param {Record} [vars] placeholder values + @returns {string} */ +export function wt(key, fallback, vars) { const v = _own && _own[key]; - return typeof v === 'string' && v ? v : fallback; + return _fill(typeof v === 'string' && v ? v : fallback, vars); +} + +/** A counted message, stored as `key_` in the widget catalog. `{count}` + is filled in the reader's digits; other placeholders come from `vars`. + @param {string} key @param {number} count + @param {{ one: string, other: string }} fallback English forms + @param {Record} [vars] @returns {string} */ +export function wtCount(key, count, fallback, vars) { + let category = 'other'; + try { + category = new Intl.PluralRules(_lang).select(count); + } catch { + /* An unusable tag: the `other` form still reads. */ + } + const own = _own && (_own[`${key}_${category}`] || _own[`${key}_other`]); + const en = new Intl.PluralRules('en').select(count) === 'one' ? fallback.one : fallback.other; + return _fill(typeof own === 'string' && own ? own : en, { ...vars, count: formatNumber(count) }); } /** @param {number} ts @returns {string} */ /* Digit shape follows the interface language. Re-exported here so a widget takes it from the toolbox rather than reaching for toLocaleString, which is the same thing until someone passes it a language. */ -export { formatNumber, localiseDigits } from '/js/format-number.js?v=349a741d'; +export { formatNumber, formatList, localiseDigits } from '/js/format-number.js?v=52226e9c'; export function sinceLabel(ts) { if (!ts) return ''; @@ -482,6 +504,31 @@ export function sinceLabel(ts) { } } +/** A signed distance from now, in the interface language: "2 hr. ago", "in 3 days". + @param {number} ts epoch milliseconds @returns {string} */ +export function relativeTime(ts) { + const s = (ts - Date.now()) / 1000; + const abs = Math.abs(s); + const [value, unit] = + abs < 3600 + ? [Math.max(1, Math.round(abs / 60)), 'minute'] + : abs < 86400 + ? [Math.round(abs / 3600), 'hour'] + : [Math.round(abs / 86400), 'day']; + const signed = s < 0 ? -value : value; + try { + return new Intl.RelativeTimeFormat(_lang, { numeric: 'always', style: 'short' }).format( + signed, + /** @type {Intl.RelativeTimeFormatUnit} */ (unit), + ); + } catch { + return new Intl.RelativeTimeFormat('en', { numeric: 'always', style: 'short' }).format( + signed, + /** @type {Intl.RelativeTimeFormatUnit} */ (unit), + ); + } +} + /** The shared error, empty and healthy states, wired to the widget catalog. @param {any} opts */ export function errorState(opts = {}) { diff --git a/ui/test/checklist-teardown.test.mjs b/ui/test/checklist-teardown.test.mjs index 3592be78..3d1082c6 100644 --- a/ui/test/checklist-teardown.test.mjs +++ b/ui/test/checklist-teardown.test.mjs @@ -52,7 +52,7 @@ function makeParts(values) { return { top: this.top, bottom: this.top, left: 0, right: 0, width: 0, height: 0 }; }, }; - const text = { textContent: '', classList: { toggle() {} } }; + const text = { textContent: '', classList: { toggle() {} }, removeAttribute() {} }; const list = { hidden: true, style: {}, diff --git a/ui/test/clock-date.test.mjs b/ui/test/clock-date.test.mjs index 45715cae..f37d7d50 100644 --- a/ui/test/clock-date.test.mjs +++ b/ui/test/clock-date.test.mjs @@ -34,6 +34,13 @@ test('an unknown language falls back to English', () => { ); }); +test('a malformed Persian tag falls back instead of throwing', () => { + assert.equal( + clockDateFormatter('fa-', 'short')(at('2026-10-04')), + clockDateFormatter('en', 'short')(at('2026-10-04')), + ); +}); + test('both clock faces use the shared formatter', () => { for (const face of ['digital', 'analog']) { const src = fs.readFileSync(new URL(`../widgets/clock/${face}.html`, import.meta.url), 'utf8'); diff --git a/ui/test/connections-map-mount.mjs b/ui/test/connections-map-mount.mjs index 39bd2603..7c6a5d39 100644 --- a/ui/test/connections-map-mount.mjs +++ b/ui/test/connections-map-mount.mjs @@ -157,6 +157,7 @@ export async function mount({ theme = 'dark' } = {}) { errorState: () => ({ ok() {}, empty() {}, fail() {} }), theme: () => theme, formatNumber: n => String(n), + formatList: items => items.join(', '), cssZoom: () => 1, }; new Function(dataScript)(); diff --git a/ui/test/connections-map.test.mjs b/ui/test/connections-map.test.mjs index e38677e8..6c8282e7 100644 --- a/ui/test/connections-map.test.mjs +++ b/ui/test/connections-map.test.mjs @@ -184,7 +184,7 @@ test('an unavailable service shows a hollow dot and says so', async () => { const [dot, , state] = line.children; assert.equal(dot.style.backgroundColor, 'transparent'); assert.match(dot.style.boxShadow, /^inset /); - assert.equal(state.textContent, ', Unavailable'); + assert.equal(state.textContent, ', unavailable'); assert.match(map.sr.textContent, /^Down: Unavailable$/); } finally { map.restore(); diff --git a/ui/test/dashboard-localisation.test.mjs b/ui/test/dashboard-localisation.test.mjs index 76a61118..cdcfafd5 100644 --- a/ui/test/dashboard-localisation.test.mjs +++ b/ui/test/dashboard-localisation.test.mjs @@ -25,9 +25,11 @@ const { healthReason, computeBadgeVisual } = await import('../js/badge-logic.js' which is what the i18n module does. */ const translatorFor = file => { const cat = JSON.parse(read(`i18n/${file}`)); + const rules = new Intl.PluralRules(file.slice(0, -5)); return (key, vars) => { const [section, name] = key.split('.'); - const s = cat[section]?.[name] ?? key; + const counted = typeof vars?.count === 'number' ? cat[section]?.[`${name}_${rules.select(vars.count)}`] : null; + const s = counted ?? cat[section]?.[name] ?? key; return vars ? s.replace(/\{(\w+)\}/g, (m, k) => (k in vars ? String(vars[k]) : m)) : s; }; }; @@ -38,7 +40,8 @@ test('every locale carries the status strings', () => { const needed = [ 'needsAttention', 'healthy', - 'pending', + 'pending_other', + 'pendingValue', 'stale', 'containerNotFound', 'containerState', @@ -72,7 +75,8 @@ test('every locale carries the API-unreachable message', () => { /* A translation that loses a placeholder produces a message with a hole in it. */ test('no translation drops a placeholder', () => { const withVars = { - 'status.pending': ['count'], + 'status.pending_other': ['count'], + 'status.pendingValue': ['value'], 'status.containerState': ['state'], 'status.pingFailed': ['error'], 'status.pingReturned': ['status'], @@ -135,6 +139,20 @@ test('the reason a tile is red is translated', () => { /* Docker's own text comes from the daemon, so inventing a translation for "Exited (1) 2 hours ago" would be guessing at a string this code never produces. */ +test('a pending count takes the plural form of its language', () => { + const es = translatorFor('es.json'); + assert.equal(computeBadgeVisual({ health: 0, activity: 1, hasHC: true, translate: es }).aria, '1 pendiente'); + assert.equal(computeBadgeVisual({ health: 0, activity: 3, hasHC: true, translate: es }).aria, '3 pendientes'); +}); + +test('a container state without daemon text is named in the reader language', () => { + const de = translatorFor('de.json'); + assert.equal(healthReason({ state: 'exited' }, de), de('status.containerExited')); + assert.doesNotMatch(healthReason({ state: 'exited' }, de), /exited/); + assert.equal(healthReason({ state: 'exited' }), 'Container stopped'); + assert.equal(healthReason({ state: 'mystery' }), 'Container mystery'); +}); + test("Docker's own status text is passed through untranslated", () => { const fa = translatorFor('fa.json'); assert.equal(healthReason({ state: 'exited', status: 'Exited (1) 2 hours ago' }, fa), 'Exited (1) 2 hours ago'); diff --git a/ui/test/disk-health-status.test.mjs b/ui/test/disk-health-status.test.mjs index c3685714..491cebea 100644 --- a/ui/test/disk-health-status.test.mjs +++ b/ui/test/disk-health-status.test.mjs @@ -34,11 +34,22 @@ assert.ok(alertFn, 'updateAlert is defined in the widget'); function runAlert(bays) { const els = { alert: { hidden: true, textContent: '' }, nums: { hidden: false } }; - new Function('bayData', 'unread', 'formatNumber', 'wt', 'document', `${led[0]}; ${alertFn[0]}; updateAlert();`)( + new Function( + 'bayData', + 'unread', + 'formatNumber', + 'wt', + 'wtCount', + 'formatList', + 'document', + `${led[0]}; ${alertFn[0]}; updateAlert();`, + )( bays, d => !!d?.error, n => String(n), (_k, fallback) => fallback, + (_k, count, fallback, vars) => (count === 1 ? fallback.one : fallback.other).replace('{list}', vars.list), + items => items.join(', '), { getElementById: id => els[id] }, ); return els; diff --git a/ui/test/innerhtml-ratchet.test.mjs b/ui/test/innerhtml-ratchet.test.mjs index 8808a762..b88903ca 100644 --- a/ui/test/innerhtml-ratchet.test.mjs +++ b/ui/test/innerhtml-ratchet.test.mjs @@ -31,7 +31,7 @@ import { fileURLToPath } from 'node:url'; `replaceChildren()`, which says what it does and cannot become a write by someone appending an interpolation to it later. */ const BUDGET = { - 'widgets/backup/backup.html': 3, + 'widgets/backup/backup.html': 2, 'widgets/connections/connections-vpn.html': 1, 'widgets/dashboard-switch/index.html': 2, 'widgets/dns/index.html': 1, diff --git a/ui/test/listbox.test.mjs b/ui/test/listbox.test.mjs index f1da3885..a321d334 100644 --- a/ui/test/listbox.test.mjs +++ b/ui/test/listbox.test.mjs @@ -210,3 +210,15 @@ test('a required select lands on its first option', () => { 'fetched options arrive after the field is built', ); }); + +/* Each language is named in its own script. WCAG 2.2 SC 3.1.2 needs the option + and the button showing it to say which language that is. */ +test('the language picker marks each language name with its language', () => { + assert.match(read('js/admin.js'), /LANGUAGES\.map\(l => \(\{ value: l\.code, label: l\.name, lang: l\.code \}\)\)/); + assert.match(src, /if \(o && o\.lang\) li\.lang = o\.lang;/, 'the option does not carry its language'); + assert.match( + src, + /if \(lang\) text\.lang = lang;\s*else text\.removeAttribute\('lang'\);/, + 'the button does not carry it', + ); +}); diff --git a/ui/test/localised-digits.test.mjs b/ui/test/localised-digits.test.mjs index 75988137..16b333e9 100644 --- a/ui/test/localised-digits.test.mjs +++ b/ui/test/localised-digits.test.mjs @@ -112,7 +112,7 @@ test('the badge takes its formatter by injection, not by import', () => { test('widgets take it from the toolbox rather than each inventing one', () => { const toolbox = read('js/widget-toolbox.js'); - assert.match(toolbox, /export \{ formatNumber, localiseDigits \}/); + assert.match(toolbox, /export \{ formatNumber, formatList, localiseDigits \}/); for (const w of ['dns', 'weather', 'books', 'system-summary', 'disk-health']) { const src = read(`widgets/${w}/index.html`); assert.match(src, /formatNumber/, `${w} still writes raw digits`); @@ -211,12 +211,12 @@ const GLUED = new RegExp( const IN_HOLE = String.raw`\$\{\s*${RECEIVER}${NUMBER}\s*\}`; const HOLE = new RegExp(String.raw`(?:>|\p{L}{2}\s+)\s*${IN_HOLE}|${IN_HOLE}(?=\s*<|\s+\p{L}{2})`, 'u'); -/* The line with every t(), wt(), formatNumber() and localiseDigits() call +/* The line with every t(), wt(), wtCount(), formatNumber() and localiseDigits() call removed, parentheses balanced. */ function withoutSentences(line) { let out = ''; for (let i = 0; i < line.length; ) { - const call = /^\b(?:w?t|formatNumber|localiseDigits)\(/.exec(line.slice(i)); + const call = /^\b(?:w?t|wtCount|formatNumber|localiseDigits)\(/.exec(line.slice(i)); if (!call || /[\w$.]/.test(line[i - 1] ?? '')) { out += line[i++]; continue; @@ -338,6 +338,7 @@ test('the digit check sees a count, a rounded value and a fixed decimal', () => } for (const line of [ "lead.textContent = t('import.confirm', { count: d.items.length });", + "el.textContent = wtCount('ui.baysFailed', failed.length, { one: 'Bay {list}', other: 'Bays {list}' });", "mt.textContent = t('folder.appsCount', { count: (item.children || []).length });", 'h.textContent = `${heading} (${formatNumber(rows.length)})`;', "if (rows.length) el.setAttribute('aria-label', label);", @@ -377,17 +378,26 @@ test('no interface module or widget writes a number in Latin digits', () => { assert.deepEqual(found, [], `format it with formatNumber():\n ${found.join('\n ')}`); }); -test('every Backup age is in the reader digits, including the youngest', async () => { +test('every Backup age comes from the toolbox relative time', () => { const src = read('widgets/backup/backup.html'); - const fn = name => { - const at = src.indexOf(`function ${name}(`); - return src.slice(at, src.indexOf('\n}\n', at) + 2); - }; - await withLocale('fa-IR', ({ formatNumber }) => { - const relTime = new Function('formatNumber', `${fn('parseDate')}${fn('relTime')}return relTime;`)(formatNumber); - for (const seconds of [30, 600, 5 * 3600, 3 * 86400]) { - const age = relTime(new Date(Date.now() - seconds * 1000).toISOString()); - assert.doesNotMatch(age, /[0-9]/, `${seconds} s reads ${age}`); - } + assert.match(src, /return d \? relativeTime\(d\.getTime\(\)\) : '\u2014';/); + assert.doesNotMatch(src, /' ago'|'in '/, 'an age is written in English'); +}); + +test('a list is joined in the interface language', async () => { + await withLocale( + 'en-US', + ({ formatList }) => { + assert.equal( + formatList(['1', '2', '3']), + new Intl.ListFormat('fa', { type: 'conjunction' }).format(['1', '2', '3']), + ); + assert.match(formatList(['a', 'b'], 'unit'), /،/); + }, + 'fa', + ); + await withLocale('en-US', ({ formatList }) => { + assert.equal(formatList(['a', 'b', 'c']), 'a, b, and c'); }); + await withLocale('en-US', ({ formatList }) => assert.equal(formatList(['a', 'b']), 'a, b'), 'not a tag'); }); diff --git a/ui/test/widget-escaping.test.mjs b/ui/test/widget-escaping.test.mjs index b9eab80a..faa5a7c2 100644 --- a/ui/test/widget-escaping.test.mjs +++ b/ui/test/widget-escaping.test.mjs @@ -71,16 +71,14 @@ test('colorOrFallback does not accept named colours', () => { /* ── The four sites ───────────────────────────────────────────────────────── */ /* A ratchet in the same shape as ui/test/innerhtml-ratchet.test.mjs. Clears - (`= ''`) write no markup and are not counted. backup.html keeps three writes + (`= ''`) write no markup and are not counted. backup.html keeps two writes on purpose: a literal ' 
 ' placeholder the running-state SVG, which interpolates nothing - `Last ${last}` / `Next ${next}`, where relTime() returns only '—' or a - number followed by a unit - None can carry a value from config or from upstream. */ -const BUDGET = { [MAP]: 0, [STATS]: 0, [BACKUP]: 3 }; + Neither can carry a value from config or from upstream. */ +const BUDGET = { [MAP]: 0, [STATS]: 0, [BACKUP]: 2 }; test('no widget frontend touched here exceeds its innerHTML budget', () => { const over = []; @@ -96,7 +94,7 @@ test('no widget frontend touched here exceeds its innerHTML budget', () => { test('the upstream users value is escaped, not concatenated', () => { const src = read(MAP); assert.ok(!src.includes('+info.users+'), 'info.users is still concatenated into markup'); - assert.match(src, /\$\{info\.users\}/, 'info.users should be an html`` interpolation'); + assert.match(src, /\$\{[^}]*formatNumber\(info\.users\)[^}]*\}/, 'info.users should be an html`` interpolation'); }); test('the backup failure draws no upstream text', () => { diff --git a/ui/test/widget-i18n.test.mjs b/ui/test/widget-i18n.test.mjs index 6ed82f16..e728a531 100644 --- a/ui/test/widget-i18n.test.mjs +++ b/ui/test/widget-i18n.test.mjs @@ -307,3 +307,62 @@ test('a books shelf label comes from the catalog, not the source key', () => { assert.ok(src.includes(key), `${key} is never looked up`); assert.match(src, /if\s*\(sh&&sh\.name\)\s*return sh\.name/, 'a named shelf no longer wins'); }); + +test('every counted widget message carries the plural categories its language uses', () => { + for (const name of widgetCatalogDirs()) { + const dir = `widgets/${name}/i18n`; + const en = JSON.parse(read(`${dir}/en.json`)); + const counted = [ + ...new Set( + Object.keys(en) + .filter(k => PLURAL.test(k)) + .map(k => k.replace(PLURAL, '')), + ), + ]; + for (const file of fs.readdirSync(path.join(root, dir)).filter(f => f.endsWith('.json'))) { + const cat = JSON.parse(read(`${dir}/${file}`)); + const wanted = new Intl.PluralRules(file.slice(0, -5)).resolvedOptions().pluralCategories; + for (const base of counted) { + for (const category of wanted) + assert.ok(cat[`${base}_${category}`], `${dir}/${file} is missing ${base}_${category}`); + const extra = CATEGORIES.filter(c => !wanted.includes(c) && cat[`${base}_${c}`]); + assert.deepEqual(extra, [], `${dir}/${file} has ${base} forms its language never selects: ${extra}`); + } + } + } +}); + +/* A plural chosen by `=== 1` is the English rule, and words glued with spaces + keep the English word order. Both belong in one catalog message. */ +test('no widget or interface module picks a plural or joins translated words in code', () => { + const files = [ + ...fs + .readdirSync(path.join(root, 'js')) + .filter(f => f.endsWith('.js')) + .map(f => `js/${f}`), + ...fs + .readdirSync(path.join(root, 'widgets'), { withFileTypes: true }) + .filter(e => e.isDirectory()) + .flatMap(e => + fs + .readdirSync(path.join(root, 'widgets', e.name)) + .filter(f => f.endsWith('.html') || f.endsWith('.js')) + .map(f => `widgets/${e.name}/${f}`), + ), + ]; + /* A symbol such as ✓ or ↑ before a message is not a word. */ + const wordy = lit => /^\s+$/.test(lit) || /[\p{L},.():;]/u.test(lit); + const glued = line => + [...line.matchAll(/(['"`])([^'"`]*)\1\s*\+\s*w?t\(/g)].some(m => wordy(m[2])) || + [...line.matchAll(/\bw?t\([^()]*\)\s*\+\s*(['"`])([^'"`]*)\1/g)].some(m => wordy(m[2])); + const offenders = []; + for (const f of files) { + read(f) + .split('\n') + .forEach((line, i) => { + if (/===\s*1\s*\?[^:]*\bw?t\(/.test(line) || glued(line)) + offenders.push(`${f}:${i + 1}: ${line.trim().slice(0, 100)}`); + }); + } + assert.deepEqual(offenders, []); +}); diff --git a/ui/test/widget-toolbox-i18n.test.mjs b/ui/test/widget-toolbox-i18n.test.mjs new file mode 100644 index 00000000..23a37501 --- /dev/null +++ b/ui/test/widget-toolbox-i18n.test.mjs @@ -0,0 +1,67 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { register } from 'node:module'; + +register('./js-root-hooks.mjs', import.meta.url); + +const CORE = { widget: { justNow: 'à l’instant' } }; +const OWN = { + 'ui.items_one': '{count} élément', + 'ui.items_many': '{count} d’éléments', + 'ui.items_other': '{count} éléments', + 'ui.greet': 'Bonjour {name}', +}; +let releaseCore; +const coreHeld = new Promise(r => { + releaseCore = r; +}); +globalThis.location = { search: '?lang=fr', pathname: '/widgets/demo/index.html' }; +globalThis.fetch = async url => { + if (url === '/i18n/fr.json') await coreHeld; + const body = url === '/i18n/fr.json' ? CORE : url === '/widgets/demo/i18n/fr.json' ? OWN : null; + return { ok: !!body, json: async () => body }; +}; + +const { loadStrings, wt, wtCount, relativeTime, sinceLabel, formatNumber } = await import('../js/widget-toolbox.js'); + +test('loadStrings waits for the shared strings as well as the widget catalog', async () => { + let done = false; + const loading = loadStrings().then(() => { + done = true; + }); + await new Promise(r => setTimeout(r, 20)); + assert.equal(done, false, 'loadStrings resolved before the shared strings arrived'); + releaseCore(); + await loading; + assert.equal(sinceLabel(Date.now()), 'à l’instant'); +}); + +test('a counted message takes the category of the widget language', () => { + const en = { one: '{count} item', other: '{count} items' }; + assert.equal(wtCount('ui.items', 0, en), '0 élément', 'French puts zero in one'); + assert.equal(wtCount('ui.items', 2, en), '2 éléments'); + assert.equal(wtCount('ui.items', 1_000_000, en), `${formatNumber(1_000_000)} d’éléments`); +}); + +test('a counted message missing from the catalog falls back to the English forms', () => { + const en = { one: '{count} thing', other: '{count} things' }; + assert.equal(wtCount('ui.absent', 1, en), '1 thing'); + assert.equal(wtCount('ui.absent', 0, en), '0 things'); +}); + +test('wt fills placeholders in one pass', () => { + assert.equal(wt('ui.greet', 'Hello {name}', { name: '{name}' }), 'Bonjour {name}'); + assert.equal(wt('ui.absent', 'Hi {name}', { name: 'Ada' }), 'Hi Ada'); + assert.equal(wt('ui.greet', 'Hello {name}'), 'Bonjour {name}'); +}); + +test('a relative time reads in the widget language, past and future', () => { + const now = Date.now(); + assert.equal( + relativeTime(now - 2 * 3600e3), + new Intl.RelativeTimeFormat('fr', { style: 'short' }).format(-2, 'hour'), + ); + assert.equal(relativeTime(now + 3 * 86400e3), new Intl.RelativeTimeFormat('fr', { style: 'short' }).format(3, 'day')); + assert.equal(relativeTime(now - 10e3), new Intl.RelativeTimeFormat('fr', { style: 'short' }).format(-1, 'minute')); + assert.doesNotMatch(relativeTime(now - 3600e3), /ago|\bin\b/); +}); diff --git a/ui/widgets/backup/backup.html b/ui/widgets/backup/backup.html index 47b9582d..53a39c46 100644 --- a/ui/widgets/backup/backup.html +++ b/ui/widgets/backup/backup.html @@ -121,7 +121,7 @@