import type { TFunction } from "i18next"; import type { JobResult } from "./api"; import { formatNumber } from "./format"; import { vocabKey } from "./vocab"; /** A notice from before the result rode along as data — and every audit row — holds only the server's * one-liner: `_summarize`'s "new=3, failed=0" (backend/app/scheduler.py). A flat one reads back into a * result, and `enrich`'s bare count ("50") into `{enriched: 50}` as `_result_data` names it; a nested * phase (a Python dict repr) or anything else does not, and stays as it was. */ export function parseSummary(summary: string | null | undefined): JobResult | null { if (!summary || /[{}[\]]/.test(summary)) return null; if (/^\d+$/.test(summary)) return { enriched: Number(summary) }; const out: JobResult = {}; for (const part of summary.split(", ")) { const m = /^(\w+)=(.+)$/.exec(part); if (!m) return null; const [, key, raw] = m; out[key!] = /^-?\d+$/.test(raw!) ? Number(raw) : raw!; } return out; } /** A scheduler job's result in the reader's language (E30): "új videó: 3 · sikertelen: 0". The server * stored "new=3, failed=0" — English keys, a Python dict repr for a nested phase. A count's key is * named from `scheduler.result`, a `skipped` / `reason` word from `scheduler.skip`; an unnamed key or * word shows as it is, and an `error` stays the upstream's own text (it is diagnostic). */ export function jobResultText(t: TFunction, result: JobResult, lang: string): string { return Object.entries(result) .map(([key, value]) => { const label = t(`scheduler.result.${key}`, { defaultValue: key.replace(/_/g, " ") }); if (typeof value === "number") return `${label}: ${formatNumber(value, lang)}`; if (typeof value === "string") return key === "error" ? `${label}: ${value}` : `${label}: ${t(`scheduler.skip.${vocabKey(value)}`, { defaultValue: value })}`; return `${label} (${jobResultText(t, value, lang)})`; }) .join(" · "); } /** A failure the job named (the server's `progress.JobFailed`: `{failure: , …params}`) in the * reader's language (E31); null for any other failure, which keeps the upstream's own text. */ export function jobFailureText(t: TFunction, result: unknown, lang: string): string | null { if (!result || typeof result !== "object") return null; const { failure, ...params } = result as Record; if (typeof failure !== "string") return null; const values: Record = {}; for (const [k, v] of Object.entries(params)) values[k] = typeof v === "number" ? formatNumber(v, lang) : String(v); if (typeof params.cause === "string") values.cause = rssCause(t, params.cause); // A failure counted over `total` reads as a plural pair: i18next picks the form from a NUMERIC // count, and the formatted values above are strings ("1 399" would pick none). const count = typeof params.total === "number" ? params.total : undefined; return t(`scheduler.failure.${failure}`, { ...values, count, defaultValue: "" }) || null; } /** Why a feed did not answer, from the poll's reason code (`http_404`, `transport`, …). */ function rssCause(t: TFunction, cause: string): string { const http = /^http_(\d+)$/.exec(cause); if (http) return t("scheduler.rssCause.http", { status: http[1] }); return t(`scheduler.rssCause.${cause}`, { defaultValue: cause }); } /** What a job run said, as the inbox notice and the audit row both show it (E30): a title naming the job * and how the run ended, and its result in the reader's language — from the data when it rode along, * else read back from the English one-liner. A skipped run names only why; a failed run says the * failure the job named (`jobFailureText`, E31), else keeps the upstream's own text (diagnostic). */ export function jobRunText( t: TFunction, run: { job_id: string; status?: unknown; summary?: unknown; result?: unknown }, lang: string, ): { title: string; body: string | null } { const job = t(`scheduler.jobs.${run.job_id}`, run.job_id); const summary = typeof run.summary === "string" && run.summary ? run.summary : null; const titleKey = run.status === "error" ? "inbox.jobDone.titleError" : run.status === "skipped" ? "inbox.jobDone.titleSkipped" : "inbox.jobDone.titleOk"; let result: JobResult | null = null; if (run.status !== "error") { result = run.result && typeof run.result === "object" ? (run.result as JobResult) : (parseSummary(summary) ?? (run.status === "skipped" && summary ? { skipped: summary } : null)); } // A skipped run's title already says "skipped": its body is only the reason, not "skipped: ". const reason = run.status === "skipped" ? onlySkipReason(result) : null; const failure = run.status === "error" ? jobFailureText(t, run.result, lang) : null; return { title: t(titleKey, { job }), body: failure ? failure : reason !== null ? t(`scheduler.skip.${vocabKey(reason)}`, { defaultValue: reason }) : result ? jobResultText(t, result, lang) : summary, }; } function onlySkipReason(result: JobResult | null): string | null { const keys = result ? Object.keys(result) : []; const word = result?.skipped; return keys.length === 1 && typeof word === "string" ? word : null; }