import type { TFunction } from "i18next"; import type { AuditRow, JobResult } from "./api"; import { formatNumber } from "./format"; import { jobResultText } from "./jobResult"; /** How the server's summary says an API token did the work (`apitokens.FilesCaller.via`, held by * the test): the token's prefix follows. */ const VIA_TOKEN = "api token "; function fmt(v: unknown): string { return v == null || v === "" ? "—" : String(v); } /** A logged config value in the reader's language: a switch reads on/off, a count is grouped, a * choice is named as the Configuration page names it. */ function configValue(t: TFunction, key: string, v: unknown, lang: string): string { if (typeof v === "boolean") return t(v ? "audit.on" : "audit.off"); if (typeof v === "number") return formatNumber(v, lang); if (typeof v === "string" && v) return t(`config.choices.${key}.${v}`, { defaultValue: v }); return fmt(v); } /** A row's `after` as a job-style result (counts), or null when it holds none. */ function counts(after: Record): JobResult | null { const out: JobResult = {}; for (const [k, v] of Object.entries(after)) if (typeof v === "number") out[k] = v; return Object.keys(out).length ? out : null; } /** The details of the rows whose server `summary` is a fixed English line, rebuilt from the row's * data in the reader's language: a config change names the field and its values, an interval or batch change its new value, a count-bearing * action its counts; a pause/resume says nothing the action has not. `undefined` = not one of these (or * an older row without the data); the generic summary + change rendering applies. */ function localDetails( t: TFunction, row: AuditRow, lang: string, ): { head: string | null; sub: string | null } | undefined { const after = row.after ?? {}; const before = row.before ?? {}; if ((row.action === "config_set" || row.action === "config_reset") && row.target_id) { const key = row.target_id; const head = t(`config.fields.${key}.label`, key); const reset = row.action === "config_reset"; // A secret's value is never logged: there is no before/after to show. if (!row.before && !row.after) return { head, sub: t(reset ? "audit.secretReset" : "audit.secretUpdated") }; const to = reset ? t("audit.default") : configValue(t, key, after.value, lang); return { head, sub: `${configValue(t, key, before.value, lang)} → ${to}` }; } if ( row.action === "scheduler_interval" && row.target_id && typeof after.interval_minutes === "number" ) return { head: t(`scheduler.jobs.${row.target_id}`, row.target_id), sub: t("scheduler.everyMin", { count: after.interval_minutes }), }; if (row.action === "maintenance_tune" && typeof after.revalidate_batch === "number") return { head: t("scheduler.maintenance.batchLabel"), sub: formatNumber(after.revalidate_batch, lang), }; if (row.action === "audit_clear" || row.action === "discovery_purge") { const result = counts(after); return result ? { head: null, sub: jobResultText(t, result, lang) } : undefined; } if (row.action === "sync_pause" || row.action === "sync_resume") return { head: null, sub: null }; if (row.action === "api_token_revoke") return { head: null, sub: t("audit.tokenRevoked") }; if (row.summary?.startsWith(VIA_TOKEN)) return { head: t("audit.viaToken", { token: row.summary.slice(VIA_TOKEN.length) }), sub: null }; return undefined; } /** A compact before→after (or after-only) rendering of the JSON snapshots, e.g. "role: user → admin". */ function changeText(row: AuditRow): string | null { const { before, after } = row; if (before && after) { return Object.keys(after) .map((k) => `${k}: ${fmt(before[k])} → ${fmt(after[k])}`) .join(", "); } if (after) { return Object.entries(after) .map(([k, v]) => `${k}: ${fmt(v)}`) .join(", "); } return null; } /** An audit row in the reader's language: `head` names what changed (a config field, a job; else * the server's summary), `sub` the change itself (before → after, a count). Either may be null. */ export function auditText( t: TFunction, row: AuditRow, lang: string, ): { head: string | null; sub: string | null } { const local = localDetails(t, row, lang); return local ?? { head: row.summary, sub: changeText(row) }; } export function auditActionLabel(t: TFunction, action: string): string { // Canonical key → localized label; falls back to the raw key for any unmapped action. return t(`auditActions.${action}`, action); }