diff --git a/backend/app/refusal.py b/backend/app/refusal.py index fc70f7b6..95b2cf04 100644 --- a/backend/app/refusal.py +++ b/backend/app/refusal.py @@ -1,7 +1,7 @@ """A request the app refuses, said in a way the UI can put in the reader's language. Every refusal the API answers carries a structured `detail` — `{code, reason, …params}` — and the client -translates it (`lib/api/core.ts` `localizeDetail`). A bare English string reached the Hungarian UI +translates it (`lib/api/refusalText.ts` `localizeDetail`). A bare English string reached the Hungarian UI verbatim: the blocking error dialog, and every inline surface that printed `HttpError.detail`, showed whatever sentence the route happened to hand-write. `refused` is the generic family (`errors.refused.`, the params interpolated into the sentence); the older specific codes diff --git a/backend/app/routes/files.py b/backend/app/routes/files.py index 27645322..d0f17765 100644 --- a/backend/app/routes/files.py +++ b/backend/app/routes/files.py @@ -139,7 +139,7 @@ def own_job_for_update(db: Session, user: User, job_id: int) -> "DownloadJob": def quota_detail(e: quota.QuotaExceeded) -> dict: """Structured error the trilingual client localizes (reason key + numbers), instead of a - pre-baked English string with a dot-decimal GB value. See lib/api.ts localizeDetail().""" + pre-baked English string with a dot-decimal GB value. See lib/api/refusalText.ts localizeDetail().""" return {"code": "quota", "reason": e.reason, "limit": e.limit, "current": e.current} diff --git a/frontend/src/components/ui/uiI18n.contract.test.ts b/frontend/src/components/ui/uiI18n.contract.test.ts index c3c93275..934d3873 100644 --- a/frontend/src/components/ui/uiI18n.contract.test.ts +++ b/frontend/src/components/ui/uiI18n.contract.test.ts @@ -19,8 +19,9 @@ const PLATFORM_NAMESPACES = new Set([ ]); const files = readSources(__dirname, "./**/*.{ts,tsx}"); -// `t("ns.…")` and `t(\`ns.…\`)` — the namespace is everything before the first dot. -const KEY = /\bt\(\s*["`]([A-Za-z]+)\./g; +// `t("ns.…")` and `t(\`ns.…\`)`, and a notice's `msg("ns.…")` (the same key, said when shown) — the +// namespace is everything before the first dot. +const KEY = /\b(?:t|msg)\(\s*["`]([A-Za-z]+)\./g; describe("components/ui/ speaks only platform i18n namespaces", () => { it("names no feature namespace", () => { @@ -37,5 +38,6 @@ describe("components/ui/ speaks only platform i18n namespaces", () => { it("sees a feature namespace when one is used", () => { expect([...'t("plex.loading")'.matchAll(KEY)].map((m) => m[1])).toEqual(["plex"]); + expect([...'msg("plex.loading")'.matchAll(KEY)].map((m) => m[1])).toEqual(["plex"]); }); }); diff --git a/frontend/src/lib/notifications.ts b/frontend/src/lib/notifications.ts index 566ccfcb..993abbf7 100644 --- a/frontend/src/lib/notifications.ts +++ b/frontend/src/lib/notifications.ts @@ -42,8 +42,10 @@ export function sayNotif(text: NotifText, t: TFunction = i18n.t.bind(i18n)): str function sayValue(v: NotifValue, t: TFunction): string | number { if (typeof v !== "object") return v; - if ("number" in v) return formatNumber(v.number, i18n.language); - if ("bytes" in v) return formatBytes(v.bytes, i18n.language); + // In the language `t` speaks: a fixed one (the English log) names it, a component's follows i18n. + const lang = (t as { lng?: string }).lng ?? i18n.language; + if ("number" in v) return formatNumber(v.number, lang); + if ("bytes" in v) return formatBytes(v.bytes, lang); return sayNotif(v, t); } diff --git a/frontend/src/modules/settings/SettingsPanel.tsx b/frontend/src/modules/settings/SettingsPanel.tsx index 2b416c66..58673a5c 100644 --- a/frontend/src/modules/settings/SettingsPanel.tsx +++ b/frontend/src/modules/settings/SettingsPanel.tsx @@ -643,7 +643,7 @@ export function DownloadAuth() { notify({ level: "success", message: msg("settings.downloadAuth.saved") }); }, // Validation errors (empty / not-a-cookies.txt) surface via the global error modal, which - // localizes the structured { code: "cookies" } detail (see lib/api core localizeDetail); the + // localizes the structured { code: "cookies" } detail (see lib/api/refusalText.ts localizeDetail); the // statuses it does not cover go to the app-wide reporter. meta: { failTitle: "settings.downloadAuth.failed" }, }); diff --git a/frontend/src/shell/clientLogWiring.dom.test.tsx b/frontend/src/shell/clientLogWiring.dom.test.tsx index 2f4981b6..0a9d4675 100644 --- a/frontend/src/shell/clientLogWiring.dom.test.tsx +++ b/frontend/src/shell/clientLogWiring.dom.test.tsx @@ -7,7 +7,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { cleanup, render } from "@testing-library/react"; import { clear, events } from "../lib/log"; -import { notify, raw } from "../lib/notifications"; +import { msg, notify, raw } from "../lib/notifications"; +import i18n from "../i18n"; import { reportError } from "../lib/errorDialog"; import { createQueryClient } from "../lib/queryClient"; import ErrorBoundary from "./ErrorBoundary"; @@ -31,6 +32,27 @@ describe("client log wiring", () => { ]); }); + it("records a notice in English whatever the reader's language, its numbers included", async () => { + await i18n.changeLanguage("hu"); + try { + notify({ + message: msg("settings.takeout.read", { + name: raw("watch-history.json"), + count: 12345, + n: { number: 12345 }, + }), + }); + } finally { + await i18n.changeLanguage("en"); + } + const english = i18n.getFixedT("en")("settings.takeout.read", { + name: "watch-history.json", + count: 12345, + n: "12,345", + }); + expect(logged("ui.notify").map((e) => e.msg)).toEqual([english]); + }); + it("records the blocking error dialog", () => { reportError("The server refused", "Couldn't save"); expect(logged("ui.error_dialog")).toMatchObject([