// 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"; import { REFUSAL_BY_REASON, REFUSAL_FAMILIES, REFUSAL_FIXED, type RefusalFamily, } from "./refusalCodes"; 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(Object.entries(REFUSAL_FAMILIES)); const byReason = new Map( Object.entries(REFUSAL_BY_REASON).map(([code, f]) => [ code, { reasons: new Map(Object.entries(f.reasons)), fallback: f.fallback }, ]), ); const fixed = new Map(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 (a GIF's in seconds), 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): Record { const lang = i18n.language; if (o.code === "refused") return { ...o }; if (o.code === "clip" || o.code === "edit") return o.limit_s == null ? {} : { minutes: formatDecimal(o.limit_s / 60, lang, 1, 0), seconds: formatDecimal(o.limit_s, lang, 0, 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 // 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 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), }); 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"); }