Files
siftlode/frontend/src/lib/actionErrors.ts
T
peter 7ae5c57b6b fix(i18n): every refusal the API answers is structured and said in the reader's language
~150 HTTPExceptions carried a hand-written English sentence as their detail,
shown verbatim under the Hungarian UI — in the blocking error dialog and on
every inline surface that printed HttpError.detail (sign-in, setup wizard,
change password, the passphrase gate, live search). Four also quoted an
upstream exception's text back to the user.

- app/refusal.py: `refused(status, reason, **params)` → {code: "refused",
  reason, …params}; localizeDetail translates errors.refused.<reason> with the
  params interpolated. "Unknown X" 404s use the existing not_found(what).
- sysconfig.set_value raises InvalidSetting (reason + bounds) instead of an
  English ValueError; the three writers answer it through setting_refused.
- The Plex connection/write/image failures log what the upstream said and
  answer a reason; the search scraper's failure is logged and answered as
  youtube/unavailable (it crashed refusal_reason before).
- HttpError.detail now only ever holds a translated reason: a string detail
  (only the framework's own can remain) is left out, so reading .detail is
  safe everywhere — including destructured, the hole the raw-detail source
  scan could not see. That scan and its five-file exception list are gone.
- Enforced: test_http_details.py fails on any HTTPException with a sentence
  for a detail; the contract test requires every refused reason and every
  not_found "what" in both languages, and no key for one that is gone.
- local/jsx-comment-text: a // inside JSX children renders as text; the rule
  is ten lines in eslint.config.js rather than eslint-plugin-react's ~1800
  lockfile lines.
2026-09-19 07:34:17 +02:00

65 lines
3.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// From the core, not the `./api` barrel: these are pure (no request), and a test that mocks the
// barrel wholesale to stub its requests must not take the error path's own plumbing with it.
import { alreadyReported, HttpError, isAborted, markReported } from "./api/core";
import { notify } from "./notifications";
import i18n from "../i18n";
const GATEWAY = new Set([502, 503, 504]);
/**
* WHY a write failed, in the reader's language — the line under the toast's title.
*
* The server's own reason wins: `req` has already run the structured detail through
* `localizeDetail`, and it is the most specific thing there is to say (quota numbers, which row is
* gone). Every refusal the app answers is structured now (backend `app/refusal.py`), and a string
* detail never reaches `HttpError.detail` (see there) — the hand-written English that once put
* "No user with that email." into a Hungarian UI has nowhere left to come from. `detailData` still
* gates it: status-0 errors carry a local sentinel in `detail`, not a reason. Otherwise the status
* says it.
*/
export function failureReason(err: unknown): string {
const t = i18n.t.bind(i18n);
if (err instanceof HttpError) {
if (err.detailData != null && err.detail) return err.detail;
// 0: `uploadReq`'s network failure. 502–504: the gateway could not reach the app. Either way the
// change did not go through, which the connection notice alone does not say.
if (err.status === 0 || GATEWAY.has(err.status)) return t("errors.reason.unreachable");
if (err.status === 403) return t("errors.reason.forbidden");
if (err.status === 404) return t("errors.notFound.generic");
if (err.status === 429) return t("errors.reason.rateLimited");
return t("errors.generic");
}
// `fetch` rejects with a TypeError when the request never reached the server.
if (err instanceof TypeError) return t("errors.reason.unreachable");
return t("errors.reason.unexpected");
}
/**
* Surface a failed write as an error toast instead of the click silently doing nothing: the title
* names WHAT failed (the caller's translated "Couldn't hide the channel"), the line under it says
* WHY (`failureReason`). Domain-agnostic — the one way every action failure is reported, including
* the app-wide mutation reporter in lib/queryClient.
*/
export function notifyActionError(
err: unknown,
title: string,
level: "error" | "warning" = "error",
): void {
// Already on screen (`req` opened the error dialog, the tab is being signed out, or a caller's own
// `onError` got there first): a second toast reported one fault twice. A cancel is not a failure.
if (alreadyReported(err) || isAborted(err)) return;
notify({ level, title, message: failureReason(err) });
markReported(err);
}
/**
* A failure the caller has a SPECIFIC sentence for, because it read the error's own code ("this
* instance cannot send email", "someone registered on that address") — shown as it is, and marked,
* so the app-wide mutation reporter does not put a second, generic toast next to it.
*/
export function notifyKnownFailure(err: unknown, message: string): void {
if (alreadyReported(err)) return;
notify({ level: "error", message });
markReported(err);
}