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

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