fix(notifications): the English log formats its numbers in English; the ui/ contract sees msg() keys
A formatted value took the reader's language even when the notice was said in English for the
log. The platform-namespace contract matched only t("…"), so a domain key passed through msg()
in components/ui/ went unseen. Three comments pointed at localizeDetail's old home.
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
"""A request the app refuses, said in a way the UI can put in the reader's language.
|
"""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
|
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
|
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
|
whatever sentence the route happened to hand-write. `refused` is the generic family
|
||||||
(`errors.refused.<reason>`, the params interpolated into the sentence); the older specific codes
|
(`errors.refused.<reason>`, the params interpolated into the sentence); the older specific codes
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ def own_job_for_update(db: Session, user: User, job_id: int) -> "DownloadJob":
|
|||||||
|
|
||||||
def quota_detail(e: quota.QuotaExceeded) -> dict:
|
def quota_detail(e: quota.QuotaExceeded) -> dict:
|
||||||
"""Structured error the trilingual client localizes (reason key + numbers), instead of a
|
"""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}
|
return {"code": "quota", "reason": e.reason, "limit": e.limit, "current": e.current}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,9 @@ const PLATFORM_NAMESPACES = new Set([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const files = readSources(__dirname, "./**/*.{ts,tsx}");
|
const files = readSources(__dirname, "./**/*.{ts,tsx}");
|
||||||
// `t("ns.…")` and `t(\`ns.…\`)` — the namespace is everything before the first dot.
|
// `t("ns.…")` and `t(\`ns.…\`)`, and a notice's `msg("ns.…")` (the same key, said when shown) — the
|
||||||
const KEY = /\bt\(\s*["`]([A-Za-z]+)\./g;
|
// 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", () => {
|
describe("components/ui/ speaks only platform i18n namespaces", () => {
|
||||||
it("names no feature namespace", () => {
|
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", () => {
|
it("sees a feature namespace when one is used", () => {
|
||||||
expect([...'t("plex.loading")'.matchAll(KEY)].map((m) => m[1])).toEqual(["plex"]);
|
expect([...'t("plex.loading")'.matchAll(KEY)].map((m) => m[1])).toEqual(["plex"]);
|
||||||
|
expect([...'msg("plex.loading")'.matchAll(KEY)].map((m) => m[1])).toEqual(["plex"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -42,8 +42,10 @@ export function sayNotif(text: NotifText, t: TFunction = i18n.t.bind(i18n)): str
|
|||||||
|
|
||||||
function sayValue(v: NotifValue, t: TFunction): string | number {
|
function sayValue(v: NotifValue, t: TFunction): string | number {
|
||||||
if (typeof v !== "object") return v;
|
if (typeof v !== "object") return v;
|
||||||
if ("number" in v) return formatNumber(v.number, i18n.language);
|
// In the language `t` speaks: a fixed one (the English log) names it, a component's follows i18n.
|
||||||
if ("bytes" in v) return formatBytes(v.bytes, i18n.language);
|
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);
|
return sayNotif(v, t);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -643,7 +643,7 @@ export function DownloadAuth() {
|
|||||||
notify({ level: "success", message: msg("settings.downloadAuth.saved") });
|
notify({ level: "success", message: msg("settings.downloadAuth.saved") });
|
||||||
},
|
},
|
||||||
// Validation errors (empty / not-a-cookies.txt) surface via the global error modal, which
|
// 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.
|
// statuses it does not cover go to the app-wide reporter.
|
||||||
meta: { failTitle: "settings.downloadAuth.failed" },
|
meta: { failTitle: "settings.downloadAuth.failed" },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|||||||
import { cleanup, render } from "@testing-library/react";
|
import { cleanup, render } from "@testing-library/react";
|
||||||
|
|
||||||
import { clear, events } from "../lib/log";
|
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 { reportError } from "../lib/errorDialog";
|
||||||
import { createQueryClient } from "../lib/queryClient";
|
import { createQueryClient } from "../lib/queryClient";
|
||||||
import ErrorBoundary from "./ErrorBoundary";
|
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", () => {
|
it("records the blocking error dialog", () => {
|
||||||
reportError("The server refused", "Couldn't save");
|
reportError("The server refused", "Couldn't save");
|
||||||
expect(logged("ui.error_dialog")).toMatchObject([
|
expect(logged("ui.error_dialog")).toMatchObject([
|
||||||
|
|||||||
Reference in New Issue
Block a user