Files
siftlode/frontend/src/lib/jobResult.ts
T
peter 0302c466e8 fix(i18n): a failed RSS poll over one channel reads as one, and keeps its stops
rss_all_failed is a plural pair counted on the raw total (the values are
formatted strings, which pick no plural form); both sentences end with a
stop. The Hungarian 'show N more' uses the house's megjelenítése.
2026-10-02 23:42:32 +02:00

109 lines
5.3 KiB
TypeScript

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: <reason>, …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<string, unknown>;
if (typeof failure !== "string") return null;
const values: Record<string, string> = {};
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: <reason>".
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;
}