121 lines
8.1 KiB
TypeScript
121 lines
8.1 KiB
TypeScript
// A server refusal ({ code, reason, … }) in the reader's language. Its own module, not the request
|
|
// core's: a notice keeps the refusal it reports and says it again at display, in the language of the
|
|
// moment, and the notice store cannot import the request core that imports it.
|
|
import i18n from "../../i18n";
|
|
import { formatBytes, formatDecimal } from "../format";
|
|
|
|
// 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"]);
|
|
|
|
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
|
|
// well-formed requests, so this only fires on genuinely malformed input. The per-field `msg` and
|
|
// `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),
|
|
});
|
|
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");
|
|
}
|