feat(i18n): every server refusal is said by a record the contract holds
A refusal code or reason with no sentence fell through to the generic "couldn't carry out that action" unnoticed: the parity test is green when EN and HU miss the same key. One did (an edit refused for a file that is not video or audio). - lib/api/refusalCodes.ts: which sentence answers each code, as data; localizeDetail says a refusal through it, unchanged for every reader. - backendIds contract: every detail code the backend sends is in exactly one record, every reason it can carry has its sentence in EN and HU, and no family names a reason the backend cannot send. - The reasons that travel in a variable are Literal types (edit, move, cookies, quota), so the type checker refuses an unknown one too. - downloads.errors.edit.not_media in EN and HU.
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
// Which sentence answers each server refusal (`{ code, reason, … }`), as data: `localizeDetail` says a
|
||||
// refusal through these records, and `i18n/backendIds.contract.test.ts` reads every code and reason the
|
||||
// backend can send and requires each to land here with its sentence in both languages. A refusal that
|
||||
// fell through to the generic "couldn't carry out that action" used to stay unnoticed — the language
|
||||
// parity test is green when EN and HU miss the same key.
|
||||
//
|
||||
// No imports: the contract test reads this module in a plain node run, without i18n.
|
||||
|
||||
/** A code whose reasons each name their own sentence: `<prefix>.<reason><suffix>`. */
|
||||
export type RefusalFamily = { prefix: string; suffix?: string; fallback: string };
|
||||
|
||||
export const REFUSAL_FAMILIES = {
|
||||
// Every other refusal (backend `app/refusal.py`): one family, the reason naming the sentence and the
|
||||
// rest of the detail its values.
|
||||
refused: { prefix: "errors.refused", fallback: "errors.generic" },
|
||||
// An action YouTube refused (routes/_common.youtube_refusal): what the reader can do about it — wait
|
||||
// for the budget, reconnect, or retry. An unknown reason reads as the retryable one.
|
||||
youtube: { prefix: "errors.youtube", fallback: "errors.youtube.unavailable" },
|
||||
// Any row that is gone — deleted in another tab or device, or never the caller's (a stranger's row
|
||||
// 404s the same way); `reason` names WHAT is gone.
|
||||
not_found: { prefix: "errors.notFound", fallback: "errors.notFound.generic" },
|
||||
// A clip the server will not cut (downloads/clip): the range, the frame, the source; a cap comes
|
||||
// with the refusal (`limit_s`).
|
||||
clip: { prefix: "clip.errors", fallback: "clip.failed" },
|
||||
// An admin action on an account or an invite: an address no longer approved, a link or invitation
|
||||
// with nothing to go to or no mail to go by. The toast's title names the button; this is the WHY,
|
||||
// which used to be the generic "try again" no retry could fix. One namespace for the three codes.
|
||||
activate: { prefix: "errors.admin", fallback: "errors.generic" },
|
||||
resend: { prefix: "errors.admin", fallback: "errors.generic" },
|
||||
invite_send: { prefix: "errors.admin", fallback: "errors.generic" },
|
||||
// A playlist action the server refused (Watch later can't be deleted, YouTube said no, …).
|
||||
playlist: { prefix: "playlists.errors", fallback: "errors.generic" },
|
||||
// The one-step "move to another list": both reasons mean the list moved under you, named apart.
|
||||
playlist_move: { prefix: "playlists.moveErrors", fallback: "playlists.moveFailed" },
|
||||
edit: { prefix: "downloads.errors.edit", fallback: "downloads.errors.edit.generic" },
|
||||
cookies: { prefix: "downloads.errors.cookies", fallback: "downloads.errors.cookies.generic" },
|
||||
// A download URL the server refuses to fetch. The reasons are deliberately distinct — "we could not
|
||||
// look that host up" is a different thing to tell someone than "we will not fetch from that address".
|
||||
url_blocked: {
|
||||
prefix: "downloads.errors.urlBlocked",
|
||||
fallback: "downloads.errors.urlBlocked.generic",
|
||||
},
|
||||
// Password sign-in refusals, in one line. The sign-in card renders the account-shaped ones as a full
|
||||
// panel off `detailData`; this is the form every other consumer (and the card's fallback) gets.
|
||||
login: {
|
||||
prefix: "welcome.auth.blocked",
|
||||
suffix: ".short",
|
||||
fallback: "welcome.auth.genericError",
|
||||
},
|
||||
} satisfies Record<string, RefusalFamily>;
|
||||
|
||||
/** A code whose few reasons share sentences: each reason the backend sends is named here. */
|
||||
export const REFUSAL_BY_REASON = {
|
||||
quota: {
|
||||
reasons: {
|
||||
max_bytes: "downloads.errors.quota.max_bytes",
|
||||
max_jobs: "downloads.errors.quota.max_jobs",
|
||||
},
|
||||
fallback: "downloads.errors.quota.generic",
|
||||
},
|
||||
// The set-password link. Its failure is one a user meets by simply clicking an old link, so it may
|
||||
// not answer in hardcoded English under a bilingual UI.
|
||||
reset: {
|
||||
reasons: { weak_password: "welcome.auth.weakPassword", invalid: "welcome.auth.deadLink.short" },
|
||||
fallback: "welcome.auth.deadLink.short",
|
||||
},
|
||||
// A Plex collection name already taken in that library (the one collection-edit refusal).
|
||||
collection: {
|
||||
reasons: { name_taken: "plex.collection.nameTaken" },
|
||||
fallback: "plex.collection.editFailed",
|
||||
},
|
||||
} satisfies Record<string, { reasons: Record<string, string>; fallback: string }>;
|
||||
|
||||
/** A code with one sentence, whatever else it carries. */
|
||||
export const REFUSAL_FIXED = {
|
||||
// The email confirmation link — like `reset`, met by clicking an old link.
|
||||
verify: "welcome.auth.verifyInvalid",
|
||||
// Local-upload ingest: the per-file cap carries the limit in bytes; empty is a bare code.
|
||||
upload_too_large: "downloads.errors.upload.tooLarge",
|
||||
upload_empty: "downloads.errors.upload.empty",
|
||||
// The EDGE ceiling's refusal (`bodylimit.TOO_LARGE_BODY`), shared by every route and carrying no
|
||||
// number. On a DEFAULT install it is what an over-cap upload gets, because the ceiling and the
|
||||
// route's own cap are the same figure and the edge answers first — the numbered `upload_too_large`
|
||||
// answers only once an admin has LOWERED the knob (bodylimit.ROUTE_MAX_BODY_BYTES).
|
||||
// A BACKSTOP, not the plan: the middleware answers without draining, so whether the browser reads
|
||||
// this at all depends on the client having finished sending (readable up to 200 MiB over loopback,
|
||||
// a bare `onerror` through a dev proxy). That is why the upload dialog checks the file against
|
||||
// `upload_max_bytes` BEFORE sending. The sentence stays PAYLOAD-NEUTRAL: a JSON form gets it too
|
||||
// (`debug_sessions` refuses an over-size draft with the same code), and advice about a file told the
|
||||
// issue composer's user to pick a smaller one — of nothing.
|
||||
too_large: "errors.tooLarge",
|
||||
// A manual sync trigger on cooldown: a pace limit rather than an error, and the copy says so.
|
||||
sync_cooldown: "errors.syncCooldown",
|
||||
} satisfies Record<string, string>;
|
||||
|
||||
/** Codes no screen prints, so they read as the generic failure — each with why nobody reads it. */
|
||||
export const REFUSAL_UNSHOWN = {
|
||||
subtitle: "answers a <track> src; the player polls the track's status and never reads this body",
|
||||
json_required: "answers the client log beacon, which the UI always sends as JSON",
|
||||
diagnostics_off: "answers the client log beacon; the client stops streaming on it, silently",
|
||||
rate_limited: "answers the client log beacon over its rate; dropped lines are not reported",
|
||||
bad_cursor: "a list cursor the UI itself wrote; only a hand-edited request gets this",
|
||||
invalid_input: "a NUL byte in a request, which no form of the UI can send",
|
||||
} satisfies Record<string, string>;
|
||||
@@ -0,0 +1,61 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A server refusal said through the records (`refusalCodes`), in both languages, by the real i18n —
|
||||
// one case per record shape, so moving the dispatch into data changed what no reader sees.
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import i18n from "../../i18n";
|
||||
import { localizeDetail } from "./refusalText";
|
||||
|
||||
afterEach(async () => {
|
||||
await i18n.changeLanguage("en");
|
||||
});
|
||||
|
||||
describe.each(["en", "hu"])("localizeDetail (%s)", (lng) => {
|
||||
const say = async (d: unknown) => {
|
||||
await i18n.changeLanguage(lng);
|
||||
return localizeDetail(d);
|
||||
};
|
||||
|
||||
it("names an edit refused for a file that is not video or audio", async () => {
|
||||
const said = await say({ code: "edit", reason: "not_media" });
|
||||
expect(said).toBe(i18n.t("downloads.errors.edit.not_media"));
|
||||
expect(said).not.toBe(i18n.t("downloads.errors.edit.generic"));
|
||||
});
|
||||
|
||||
it("says a family's reason, and its own fallback for a reason it does not know", async () => {
|
||||
expect(await say({ code: "playlist_move", reason: "same_playlist" })).toBe(
|
||||
i18n.t("playlists.moveErrors.same_playlist"),
|
||||
);
|
||||
expect(await say({ code: "playlist_move", reason: "nope" })).toBe(
|
||||
i18n.t("playlists.moveFailed"),
|
||||
);
|
||||
});
|
||||
|
||||
it("reads a sign-in refusal's one-line form", async () => {
|
||||
expect(await say({ code: "login", reason: "suspended" })).toBe(
|
||||
i18n.t("welcome.auth.blocked.suspended.short"),
|
||||
);
|
||||
});
|
||||
|
||||
it("puts a refusal's numbers into its sentence", async () => {
|
||||
expect(await say({ code: "quota", reason: "max_jobs", limit: 7 })).toContain("7");
|
||||
expect(await say({ code: "reset", reason: "weak_password", min: 12 })).toContain("12");
|
||||
expect(await say({ code: "refused", reason: "view_name_taken", name: "Esti" })).toContain(
|
||||
"Esti",
|
||||
);
|
||||
});
|
||||
|
||||
it("says a shared-sentence code's fallback, and a fixed code's one sentence", async () => {
|
||||
expect(await say({ code: "reset", reason: "invalid" })).toBe(
|
||||
i18n.t("welcome.auth.deadLink.short"),
|
||||
);
|
||||
expect(await say({ code: "sync_cooldown" })).toBe(i18n.t("errors.syncCooldown"));
|
||||
});
|
||||
|
||||
it("reads a code no screen shows, or one it does not know, as the generic failure", async () => {
|
||||
expect(await say({ code: "bad_cursor" })).toBe(i18n.t("errors.generic"));
|
||||
expect(await say({ code: "never_sent" })).toBe(i18n.t("errors.generic"));
|
||||
expect(await say({ code: "constructor" })).toBe(i18n.t("errors.generic"));
|
||||
expect(await say(null)).toBe(i18n.t("errors.generic"));
|
||||
});
|
||||
});
|
||||
@@ -3,15 +3,52 @@
|
||||
// moment, and the notice store cannot import the request core that imports it.
|
||||
import i18n from "../../i18n";
|
||||
import { formatBytes, formatDecimal } from "../format";
|
||||
import {
|
||||
REFUSAL_BY_REASON,
|
||||
REFUSAL_FAMILIES,
|
||||
REFUSAL_FIXED,
|
||||
type RefusalFamily,
|
||||
} from "./refusalCodes";
|
||||
|
||||
// Localize a STRUCTURED server error ({ code, reason, ... }) into the user's language. FastAPI
|
||||
// usually returns a plain string `detail`, but some routes (e.g. download quota/edit) return a
|
||||
// structured object with an i18n reason key + numbers so HU/DE users don't get hardcoded English
|
||||
// with a dot-decimal separator. Falls back to a generic message for unknown shapes.
|
||||
// The refusal codes of the admin's account and invite actions (routes/admin.py). Their reasons share
|
||||
// one namespace, `errors.admin.<reason>`, which i18n/backendIds.contract checks against the backend.
|
||||
const ADMIN_REFUSALS = new Set(["activate", "resend", "invite_send"]);
|
||||
type Detail = {
|
||||
code?: string;
|
||||
reason?: string;
|
||||
limit?: number;
|
||||
limit_s?: number;
|
||||
min?: number;
|
||||
} | null;
|
||||
|
||||
// Which sentence answers which code lives in `refusalCodes` — the records the backendIds contract holds
|
||||
// against every code and reason the backend can send. A Map, so a code is looked up as data only.
|
||||
const families = new Map<string, RefusalFamily>(Object.entries(REFUSAL_FAMILIES));
|
||||
const byReason = new Map(
|
||||
Object.entries(REFUSAL_BY_REASON).map(([code, f]) => [
|
||||
code,
|
||||
{ reasons: new Map<string, string>(Object.entries(f.reasons)), fallback: f.fallback },
|
||||
]),
|
||||
);
|
||||
const fixed = new Map<string, string>(Object.entries(REFUSAL_FIXED));
|
||||
|
||||
// The numbers a refusal carries, in the reader's number format: a download cap in GB, a clip cap in
|
||||
// minutes, an upload cap in bytes, a password's minimum length; a generic `refused` interpolates
|
||||
// whatever values its sentence names (`{{min}}`, `{{name}}`, …).
|
||||
function valuesOf(o: NonNullable<Detail>): Record<string, unknown> {
|
||||
const lang = i18n.language;
|
||||
if (o.code === "refused") return { ...o };
|
||||
if (o.code === "clip")
|
||||
return { minutes: o.limit_s == null ? undefined : formatDecimal(o.limit_s / 60, lang, 1, 0) };
|
||||
if (o.code === "quota")
|
||||
return o.reason === "max_bytes"
|
||||
? { size: formatDecimal((o.limit || 0) / 1_073_741_824, lang, 1, 0) }
|
||||
: { limit: o.limit };
|
||||
if (o.code === "reset") return { min: o.limit ?? o.min };
|
||||
if (o.code === "upload_too_large") return { size: formatBytes(o.limit, lang) };
|
||||
return {};
|
||||
}
|
||||
|
||||
// Localize a STRUCTURED server error ({ code, reason, ... }) into the user's language. A reason this
|
||||
// build does not know reads as its code's fallback sentence rather than a raw key, and a code it does
|
||||
// not know (or one no screen shows, `REFUSAL_UNSHOWN`) as the generic failure.
|
||||
export function localizeDetail(d: unknown): string {
|
||||
const t = i18n.t.bind(i18n);
|
||||
// FastAPI request-validation errors: `detail` is an ARRAY of { loc, msg, type }. The UI sends
|
||||
@@ -19,102 +56,18 @@ export function localizeDetail(d: unknown): string {
|
||||
// `loc` field names are English, snake_case internal tokens — surface a clean, fully localized
|
||||
// "check your input" message rather than leaking them into a translated sentence.
|
||||
if (Array.isArray(d)) return t("errors.validation");
|
||||
const o = d as
|
||||
| { code?: string; reason?: string; limit?: number; limit_s?: number; min?: number }
|
||||
| null
|
||||
| undefined;
|
||||
if (o?.code === "quota") {
|
||||
if (o.reason === "max_bytes") {
|
||||
const gb = formatDecimal((o.limit || 0) / 1_073_741_824, i18n.language, 1, 0);
|
||||
return t("downloads.errors.quota.max_bytes", { size: gb });
|
||||
}
|
||||
if (o.reason === "max_jobs") return t("downloads.errors.quota.max_jobs", { limit: o.limit });
|
||||
return t("downloads.errors.quota.generic");
|
||||
}
|
||||
if (o?.code === "edit")
|
||||
return t(`downloads.errors.edit.${o.reason}`, t("downloads.errors.edit.generic"));
|
||||
// A clip the server will not cut (downloads/clip): the range, the frame, the source. An unknown
|
||||
// reason reads as the plain failure rather than a key; a cap comes with the refusal (`limit_s`).
|
||||
if (o?.code === "clip")
|
||||
return t(`clip.errors.${o.reason}`, {
|
||||
defaultValue: t("clip.failed"),
|
||||
minutes: o.limit_s == null ? undefined : formatDecimal(o.limit_s / 60, i18n.language, 1, 0),
|
||||
const o = d as Detail | undefined;
|
||||
if (!o?.code) return t("errors.generic");
|
||||
const family = families.get(o.code);
|
||||
if (family)
|
||||
return t(`${family.prefix}.${o.reason}${family.suffix ?? ""}`, {
|
||||
...valuesOf(o),
|
||||
defaultValue: t(family.fallback),
|
||||
});
|
||||
if (o?.code === "cookies")
|
||||
return t(`downloads.errors.cookies.${o.reason}`, t("downloads.errors.cookies.generic"));
|
||||
// Password sign-in refusals. The card renders the account-shaped ones as a full panel
|
||||
// off `detailData`; this is the one-line form every other consumer (and the card's own fallback)
|
||||
// gets. A reason this build doesn't know falls through to the generic message rather than showing
|
||||
// a raw key, which is what an i18n miss would otherwise put on screen.
|
||||
if (o?.code === "login")
|
||||
return t(`welcome.auth.blocked.${o.reason}.short`, t("welcome.auth.genericError"));
|
||||
// The two email-link confirmations. Their failure is one a user meets by simply clicking an old
|
||||
// link, so it is the last place that may answer in hardcoded English under a bilingual UI.
|
||||
if (o?.code === "verify") return t("welcome.auth.verifyInvalid");
|
||||
if (o?.code === "reset")
|
||||
return o.reason === "weak_password"
|
||||
? t("welcome.auth.weakPassword", { min: o.limit ?? o.min })
|
||||
: t("welcome.auth.deadLink.short");
|
||||
// Local-upload ingest: the per-file cap carries the limit in bytes; empty is a bare code.
|
||||
if (o?.code === "upload_too_large")
|
||||
return t("downloads.errors.upload.tooLarge", { size: formatBytes(o.limit, i18n.language) });
|
||||
if (o?.code === "upload_empty") return t("downloads.errors.upload.empty");
|
||||
// The EDGE ceiling's refusal (`bodylimit.TOO_LARGE_BODY`), shared by every route and carrying no
|
||||
// number. Not a theoretical case: on a DEFAULT install it is what an over-cap upload gets,
|
||||
// because the ceiling and the route's own cap are the same figure and the edge answers first —
|
||||
// the route's numbered `upload_too_large` above is the answer only once an admin has LOWERED the
|
||||
// knob (see bodylimit.ROUTE_MAX_BODY_BYTES). Without this branch that refusal fell through to
|
||||
// the generic "couldn't carry out that action", the one message no retry can fix.
|
||||
//
|
||||
// A BACKSTOP, not the plan. The middleware answers without draining, so whether the browser gets
|
||||
// to read this at all depends on the client having finished sending: measured readable up to
|
||||
// 200 MiB over loopback, and measured as a bare `onerror` through a dev proxy. That is why the
|
||||
// upload dialog checks the file against `upload_max_bytes` BEFORE sending — this branch is what
|
||||
// happens when that check could not run.
|
||||
//
|
||||
// `errors.tooLarge` stays PAYLOAD-NEUTRAL for that reason. It is also what a JSON form gets
|
||||
// (`debug_sessions` refuses an over-size draft with the same bare code), so wording it as advice
|
||||
// about a file told the issue composer's user to pick a smaller one — of nothing. The
|
||||
// file-specific sentence is `downloads.errors.upload.tooLarge`, one branch up, which has a number.
|
||||
if (o?.code === "too_large") return t("errors.tooLarge");
|
||||
// SEC-12: a download URL the server refuses to fetch. The reasons are deliberately distinct —
|
||||
// "we could not look that host up" is a different thing to tell someone than "we will not fetch
|
||||
// from that address" — so an unknown reason falls back to the generic refusal rather than a key.
|
||||
if (o?.code === "url_blocked")
|
||||
return t(`downloads.errors.urlBlocked.${o.reason}`, t("downloads.errors.urlBlocked.generic"));
|
||||
// The one-step "move to another list". Both reasons mean the same thing to a reader —
|
||||
// the list moved under you — but they are different enough to be worth naming separately, and an
|
||||
// unknown reason falls back to the plain failure rather than printing a key.
|
||||
if (o?.code === "playlist_move")
|
||||
return t(`playlists.moveErrors.${o.reason}`, t("playlists.moveFailed"));
|
||||
// A Plex collection name already taken in that library (the one collection-edit refusal).
|
||||
if (o?.code === "collection")
|
||||
return o.reason === "name_taken"
|
||||
? t("plex.collection.nameTaken")
|
||||
: t("plex.collection.editFailed");
|
||||
// An action YouTube refused (routes/_common.youtube_refusal): what the reader can do about it —
|
||||
// wait for the budget, reconnect, or retry. An unknown reason reads as the retryable one.
|
||||
if (o?.code === "youtube")
|
||||
return t(`errors.youtube.${o.reason}`, t("errors.youtube.unavailable"));
|
||||
// Every other refusal (backend `app/refusal.py`): one family, the reason naming the sentence and
|
||||
// the rest of the detail its values (`{{min}}`, `{{name}}`, …). A reason this build does not know
|
||||
// reads as the generic failure rather than a key.
|
||||
if (o?.code === "refused")
|
||||
return t(`errors.refused.${o.reason}`, { ...o, defaultValue: t("errors.generic") });
|
||||
// A playlist action the server refused (Watch later can't be deleted, YouTube said no, …).
|
||||
if (o?.code === "playlist") return t(`playlists.errors.${o.reason}`, t("errors.generic"));
|
||||
// An admin action on an account or an invite that the server refused: activating an address that
|
||||
// is no longer approved, a confirmation link or invitation with nothing to go to or no mail to go
|
||||
// by. The toast's title already names the button; this is the WHY, and it used to be the generic
|
||||
// "try again", which no retry could fix.
|
||||
if (ADMIN_REFUSALS.has(o?.code ?? "")) return t(`errors.admin.${o?.reason}`, t("errors.generic"));
|
||||
// Any row that is gone — deleted in another tab or device, or never the caller's (a stranger's
|
||||
// row 404s the same way). `reason` names WHAT is gone; one this build doesn't know says it
|
||||
// generically rather than printing a key.
|
||||
if (o?.code === "not_found")
|
||||
return t(`errors.notFound.${o.reason}`, t("errors.notFound.generic"));
|
||||
// SEC-14: a manual sync trigger on cooldown. Not an error the user caused so much as a pace
|
||||
// limit, and the copy says so.
|
||||
if (o?.code === "sync_cooldown") return t("errors.syncCooldown");
|
||||
return t("errors.generic");
|
||||
const shared = byReason.get(o.code);
|
||||
if (shared) {
|
||||
return t(shared.reasons.get(o.reason ?? "") ?? shared.fallback, valuesOf(o));
|
||||
}
|
||||
const one = fixed.get(o.code);
|
||||
return one ? t(one, valuesOf(o)) : t("errors.generic");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user