Files
pi-imgen/extensions/imgen/commands/social.ts
T

755 lines
26 KiB
TypeScript

/**
* /social — rifà le immagini per i social partendo da un lavoro che esiste già.
*
* This is the cheapest command in the extension and the one that justifies the whole
* architecture: the text lives in `spec.json` as DATA, so re-rendering last year's
* poster into four social sizes — or the same poster with a new date — is a pure Typst
* pass. **No diffusion model is involved on this path, ever.** The only branch that can
* reach a model is (a) an explicitly chosen "parti da zero", which is delegated to the
* injected fresh pipeline, and (b) the social caption, which is only ever written after
* he has said yes.
*
* Two rules shape everything below:
* - re-editions never overwrite. Changing the date forks a NEW job folder and copies
* the artwork across, because the file that went to the print shop last year must
* survive.
* - the command is a shell. It gathers input and orchestrates; `runSocial()` is the
* single implementation that a tool in tools/ calls with the same arguments.
*/
import { copyFileSync, existsSync, readFileSync, statSync } from "node:fs";
import { basename, join } from "node:path";
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
import type {
ExtensionAPI,
ExtensionCommandContext,
ExtensionContext,
} from "@earendil-works/pi-coding-agent";
import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
import type { ImgenConfig } from "../config.ts";
import type { DesignSpec, Format } from "../design/spec.ts";
import type {
Brief,
CaptionResult,
DirectorContext,
ParsedBrief,
} from "../design/director.ts";
import { FORMATS_GEOMETRY } from "../render/formats.ts";
import {
ART_FILENAME,
CAPTION_FILENAME,
JobError,
blockText,
createJob,
describeJob,
foldAccents,
jobExists,
listJobs,
loadSpec,
normaliseSlug,
openInFinder,
openJob,
reEditionSlug,
saveCaption,
saveSpec,
updateBlocks,
type Job,
type JobSummary,
} from "../job.ts";
import { S, duration, errorText, fileLabel, fill } from "../ui/strings.ts";
// ---------------------------------------------------------------------------
// Strings that ui/strings.ts does not have yet
// ---------------------------------------------------------------------------
/**
* TODO: these belong in `ui/strings.ts` as `S.social`. They live here only because
* strings.ts is owned by another module and editing it in parallel would conflict.
* Everything that already exists in `S` is reused rather than duplicated.
*/
const T = {
pickTitle: "Da quale lavoro riparto?",
pickHint: "Rifaccio le immagini per i social senza ridisegnare niente.",
noJobs: "Non ho nessun lavoro da riusare: la cartella dei lavori è vuota.",
noJobsFix: "Scrivi /poster per farne uno nuovo.",
whatTitle: "Cosa faccio con «{titolo}»?",
changeDate: "Cambia la data",
changeTitle: "Cambia il titolo",
newEdition: "Ho fatto una copia nuova in «{cartella}»: quella dell'anno scorso resta dov'è.",
artMissing: "In questo lavoro manca l'immagine di sfondo (art.png), quindi non posso rifare i file.",
artMissingFix: "Rifai la locandina con /poster: da lì in poi il resto è immediato.",
freshUnavailable: "Da qui posso solo rifare un lavoro che esiste già.",
freshUnavailableFix: "Per farne uno nuovo scrivi /poster.",
nothingRendered: "Non è uscito nessun file.",
renderingFormat: "Preparo {cosa}…",
headlessPicked: "Uso il lavoro più recente: «{titolo}».",
} as const;
// ---------------------------------------------------------------------------
// What /social produces
// ---------------------------------------------------------------------------
/**
* Every screen format, derived from the geometry table so that adding a social size to
* `render/formats.ts` adds it here with no edit. Print formats are deliberately excluded:
* /social never re-runs the upscale that A3 needs.
*/
export const SOCIAL_FORMATS: readonly Format[] = (
Object.keys(FORMATS_GEOMETRY) as Format[]
).filter((f) => FORMATS_GEOMETRY[f].kind === "screen");
// ---------------------------------------------------------------------------
// Injected collaborators
// ---------------------------------------------------------------------------
export interface SocialRenderRequest {
spec: DesignSpec;
/** Absolute path to the artwork to place. The renderer smart-crops it per ratio. */
artPath: string;
/** Job folder the files are written into. */
outDir: string;
/** Which formats to produce. The renderer re-solves the fit for each aspect. */
formats: readonly Format[];
print?: ImgenConfig["print"];
signal?: AbortSignal;
onProgress?: (message: string, fraction?: number) => void;
}
export interface SocialRenderedFile {
format: Format;
/** Absolute path of the file written. */
path: string;
warnings?: readonly string[];
}
export interface SocialRenderOutcome {
files: readonly SocialRenderedFile[];
warnings?: readonly string[];
}
/**
* The slice of the renderer this command needs.
*
* TODO: assumed to be satisfied by `render/typst.ts`'s `renderAllFormats()`, which owns
* `resolveSpec()`, the per-aspect fit and `contrast.smartCrop()`. index.ts adapts the
* real signature onto this interface, so a mismatch is fixed in one place.
*/
export interface SocialRenderer {
renderAllFormats(req: SocialRenderRequest): Promise<SocialRenderOutcome>;
}
/** The two director calls /social can make. Both are optional at runtime. */
export interface SocialDirector {
parseFreeText(text: string, ctx: DirectorContext, cfg?: ImgenConfig): Promise<ParsedBrief>;
generateCaption(
spec: DesignSpec,
brief: Brief,
ctx: DirectorContext,
cfg?: ImgenConfig,
): Promise<CaptionResult>;
}
export interface SocialDeps {
config: ImgenConfig;
renderer: SocialRenderer;
/** Only used for "parti da zero" pre-fill and for captions. Never on the re-render path. */
director?: SocialDirector;
/**
* The fresh pipeline: brief -> a job folder that already has spec.json and art.png.
* Injected by index.ts (it is the /poster pipeline). When absent, /social politely
* refuses to start from scratch instead of half-implementing it.
*/
createJobFromBrief?: (
brief: Brief,
ctx: ExtensionContext,
signal?: AbortSignal,
) => Promise<{ job: Job; spec: DesignSpec }>;
/** Overridable for tests. Defaults to job.ts's Finder helper. */
openFolder?: (dir: string) => boolean;
now?: () => number;
}
// ---------------------------------------------------------------------------
// The shared implementation
// ---------------------------------------------------------------------------
export interface SocialRequest {
/** Job folder to re-render. Required: the picking happens in the UI layer. */
slug: string;
/** Which formats. Defaults to every screen format. */
formats?: readonly Format[];
/** New title text, when this is a re-edition. */
newTitle?: string;
/** New date text, verbatim as it should be typeset. */
newDate?: string;
/**
* When the text changes, write into a NEW folder instead of the old one. Default true —
* turning it off overwrites a job that may already have been sent to a printer.
*/
fork?: boolean;
/** Write caption.txt. Only ever true after he has been asked. */
caption?: boolean;
onProgress?: (message: string, fraction?: number) => void;
}
export interface SocialResult {
slug: string;
dir: string;
/** True when the text changed and a new folder was created. */
forked: boolean;
formats: Format[];
/** Absolute paths actually written. */
files: string[];
warnings: string[];
captionPath?: string;
caption?: CaptionResult;
/** Honest flag: false on the pure re-render path, which is the normal case. */
usedModel: boolean;
elapsedMs: number;
}
/**
* Re-render one job into the social formats. Pure orchestration, no dialogs, no I/O the
* caller cannot predict — the command and the tool both go through here.
*
* Touches no model unless `caption` is true.
*/
export async function runSocial(
req: SocialRequest,
deps: SocialDeps,
ctx: DirectorContext & { signal?: AbortSignal } = {},
): Promise<SocialResult> {
const now = deps.now ?? Date.now;
const started = now();
const formats = [...(req.formats ?? SOCIAL_FORMATS)];
if (formats.length === 0) throw new Error(errorText(S.errors.exportFailed(T.nothingRendered)));
const warnings: string[] = [];
const slug = normaliseSlug(req.slug);
if (!slug) throw new Error(errorText(S.errors.unknown(`slug vuoto: ${req.slug}`)));
const source = openJob(deps.config, slug);
const spec = loadSpec(source);
// --- the re-edition: same artwork, new words, brand new folder -----------
const patch: Record<string, string> = {};
if (req.newTitle?.trim()) patch.title = req.newTitle.trim();
if (req.newDate?.trim()) patch.date = req.newDate.trim();
const changed = Object.keys(patch).length > 0;
let target = source;
let patched = changed ? updateBlocks(spec, patch) : spec;
let forked = false;
if (changed && req.fork !== false) {
// `sagra-castagna-12-set` becomes `sagra-castagna-11-set`: same title, new date.
const editionSlug = reEditionSlug(patched, req.newDate ?? blockText(spec, "date") ?? null);
target = createJob(deps.config, editionSlug);
forked = true;
const sourceArt = source.artPath;
if (existsSync(sourceArt)) {
await withFileMutationQueue(target.artPath, async () => {
copyFileSync(sourceArt, target.artPath);
});
}
patched = { ...patched, slug: target.slug };
}
if (changed || forked) {
await withFileMutationQueue(target.specPath, async () => {
saveSpec(target, patched);
});
}
// --- the artwork must exist: /social never paints ------------------------
if (!existsSync(target.artPath)) {
throw new JobError(
`missing ${ART_FILENAME} in ${target.dir}`,
`${T.artMissing}\n${T.artMissingFix}`,
target.artPath,
);
}
// --- the whole job: one Typst pass per format ---------------------------
let outcome: SocialRenderOutcome;
try {
outcome = await deps.renderer.renderAllFormats({
spec: patched,
artPath: target.artPath,
outDir: target.dir,
formats,
print: deps.config.print,
signal: ctx.signal,
onProgress: req.onProgress,
});
} catch (e) {
if (e instanceof JobError) throw e;
throw new Error(errorText(S.errors.renderFailed(messageOf(e))), { cause: e });
}
const files = outcome.files.map((f) => f.path).filter((p) => existsSync(p));
if (files.length === 0) {
throw new Error(errorText(S.errors.exportFailed(T.nothingRendered)));
}
for (const f of outcome.files) if (f.warnings) warnings.push(...f.warnings);
if (outcome.warnings) warnings.push(...outcome.warnings);
for (const f of outcome.files) {
if (!existsSync(f.path)) warnings.push(errorText(S.errors.exportFailed(fileLabel(basename(f.path)))));
}
// --- caption: only when explicitly asked for ----------------------------
let caption: CaptionResult | undefined;
let captionPath: string | undefined;
if (req.caption) {
req.onProgress?.(S.progress.caption);
const written = await writeSocialCaption(target.slug, deps, ctx);
caption = written.caption;
captionPath = written.path;
}
return {
slug: target.slug,
dir: target.dir,
forked,
formats,
files,
warnings,
captionPath,
caption,
usedModel: Boolean(req.caption),
elapsedMs: now() - started,
};
}
/**
* The caption. Called ONLY after he has answered yes — never on our own initiative, and
* never as a rewrite of copy he wrote himself. This is the one call in the file that
* reaches a model.
*/
export async function writeSocialCaption(
slug: string,
deps: SocialDeps,
ctx: DirectorContext = {},
): Promise<{ path: string; caption: CaptionResult }> {
if (!deps.director) throw new Error(errorText(S.errors.directorUnavailable));
const job = openJob(deps.config, normaliseSlug(slug));
const spec = loadSpec(job);
const caption = await deps.director.generateCaption(spec, briefFromSpec(spec), ctx, deps.config);
const path = await withFileMutationQueue(join(job.dir, CAPTION_FILENAME), async () =>
saveCaption(job, caption.full),
);
return { path, caption };
}
/** A Brief reconstructed from a spec — everything the caption writer needs, no model. */
export function briefFromSpec(spec: DesignSpec): Brief {
return {
kind: "social",
title: blockText(spec, "title"),
subtitle: blockText(spec, "subtitle"),
date: blockText(spec, "date"),
venue: blockText(spec, "venue"),
details: blockText(spec, "details"),
price: blockText(spec, "price"),
footer: blockText(spec, "footer"),
format: spec.format,
template: spec.template,
seed: spec.art.seed,
};
}
/** The closing "Ho finito" block, as one printable string. */
export function describeSocialResult(result: SocialResult): string {
const lines = [S.done.header, fill(S.done.folder, { cartella: result.dir }), "", S.done.filesHeader];
for (const f of result.files) lines.push(` • ${fileLabel(basename(f))}`);
if (result.captionPath) lines.push(` • ${fileLabel("caption.txt")}`);
if (result.warnings.length) lines.push("", ...result.warnings.map((w) => ` ! ${w}`));
lines.push("", fill(S.done.elapsed, { tempo: duration(result.elapsedMs) }));
return lines.join("\n");
}
// ---------------------------------------------------------------------------
// Registration
// ---------------------------------------------------------------------------
export function registerSocial(pi: ExtensionAPI, deps: SocialDeps): void {
pi.registerCommand("social", {
description: `${S.commands.social.description}. ${S.commands.social.usage}`,
getArgumentCompletions: (prefix: string) => {
const wanted = foldAccents(prefix.trim().toLowerCase());
return listJobs(deps.config, { withSpecOnly: true, limit: 25 })
.filter((j) => !wanted || foldAccents(j.slug).includes(wanted))
.map((j) => ({ value: j.slug, label: describeJob(j) }));
},
handler: (args: string, ctx: ExtensionCommandContext) => socialCommand(pi, deps, args, ctx),
});
}
const STATUS_KEY = "imgen-social";
async function socialCommand(
pi: ExtensionAPI,
deps: SocialDeps,
args: string,
ctx: ExtensionCommandContext,
): Promise<void> {
const text = args.trim();
const interactive = ctx.hasUI;
try {
const jobs = listJobs(deps.config, { withSpecOnly: true, limit: 12 });
const picked = await pickJob(deps, jobs, text, ctx, interactive);
if (picked === undefined) {
say(pi, S.common.cancelled);
return;
}
// "parti da zero" is the only branch that may reach a model, and only through the
// injected pipeline — /social itself never generates artwork.
let slug: string;
if (picked === FRESH) {
const fresh = await startFresh(deps, text, ctx);
if (!fresh) {
say(pi, S.common.cancelled);
return;
}
slug = fresh.job.slug;
} else {
slug = picked.slug;
}
const edits = interactive ? await askEdits(deps, slug, ctx) : {};
if (edits === undefined) {
say(pi, S.common.cancelled);
return;
}
const title = titleOf(deps, slug) ?? slug;
ctx.ui.setStatus(STATUS_KEY, fill(S.progress.busyStatus, { titolo: title }));
const result = await runSocial(
{
slug,
...edits,
onProgress: (message) => ctx.ui.setStatus(STATUS_KEY, message),
},
deps,
ctx,
);
if (result.forked) {
say(pi, fill(T.newEdition, { cartella: result.dir }));
}
await showPreview(pi, result);
// Captions are ALWAYS asked for, never written on our own initiative.
if (interactive && deps.director) {
const wants = await ctx.ui.confirm(S.confirm.wantCaption.title, S.confirm.wantCaption.message);
if (wants) {
ctx.ui.setStatus(STATUS_KEY, S.progress.caption);
try {
// Only the caption is regenerated: the images are already on disk.
const written = await writeSocialCaption(result.slug, deps, ctx);
result.captionPath = written.path;
result.caption = written.caption;
result.usedModel = true;
say(pi, [S.caption.ready, "", written.caption.full, "", S.caption.hashtagsNote].join("\n"));
say(pi, S.caption.savedTo);
} catch (e) {
// A failed caption must not lose the images that already succeeded.
ctx.ui.notify(italianOf(e), "warning");
}
} else {
say(pi, S.caption.declined);
}
}
say(pi, describeSocialResult(result));
if (interactive) {
const open = await ctx.ui.confirm(S.confirm.openFolder.title, S.confirm.openFolder.message);
if (open) {
ctx.ui.setStatus(STATUS_KEY, S.progress.openingFolder);
const opened = (deps.openFolder ?? openInFinder)(result.dir);
if (!opened) ctx.ui.notify(errorText(S.errors.openFolderFailed(result.dir)), "info");
}
ctx.ui.notify(
title ? fill(S.notify.finishedNamed, { titolo: title }) : S.notify.finished,
"info",
);
}
} catch (e) {
// THROW to fail: pi shows the message, and the message is Italian.
throw new Error(italianOf(e), { cause: e });
} finally {
// Never leave a status line behind, on any path.
ctx.ui.setStatus(STATUS_KEY, undefined);
}
}
// ---------------------------------------------------------------------------
// Dialogs
// ---------------------------------------------------------------------------
const FRESH = Symbol("fresh");
type Picked = JobSummary | typeof FRESH | undefined;
/**
* Resolves which job to work on. Free text typed after the command is used first as a
* slug, then as a search over titles; only then does the picker open.
*/
async function pickJob(
deps: SocialDeps,
jobs: JobSummary[],
text: string,
ctx: ExtensionCommandContext,
interactive: boolean,
): Promise<Picked> {
// An exact folder name wins outright: `/social sagra-castagna-2025`.
if (text) {
const asSlug = normaliseSlug(text);
if (asSlug && jobExists(deps.config, asSlug)) {
const known = jobs.find((j) => j.slug === asSlug);
if (known) return known;
return { ...emptySummary(asSlug), dir: openJob(deps.config, asSlug).dir };
}
}
const ranked = text ? rank(jobs, text) : jobs;
if (!interactive) {
// Headless must do something sensible rather than hang on a dialog.
const first = ranked[0];
if (!first) throw new Error(`${T.noJobs}\n${T.noJobsFix}`);
return first;
}
if (ranked.length === 0) return FRESH;
const labels = uniqueLabels(ranked);
const options = [...labels.map((l) => l.label), S.presets.fresh];
const chosen = await ctx.ui.select(T.pickTitle, options);
if (chosen === undefined) return undefined;
if (chosen === S.presets.fresh) return FRESH;
return labels.find((l) => l.label === chosen)?.job;
}
/** Ranks jobs by how well their title/slug matches the free text, keeping recency order. */
function rank(jobs: JobSummary[], text: string): JobSummary[] {
const tokens = foldAccents(text.toLowerCase()).split(/[^a-z0-9]+/).filter((t) => t.length > 2);
if (tokens.length === 0) return jobs;
const score = (j: JobSummary) => {
const hay = foldAccents(`${j.title ?? ""} ${j.slug}`.toLowerCase());
return tokens.reduce((n, t) => (hay.includes(t) ? n + 1 : n), 0);
};
return [...jobs].sort((a, b) => score(b) - score(a) || b.modifiedAt.getTime() - a.modifiedAt.getTime());
}
/** `select()` returns the label, so labels must be unique or the mapping back is a guess. */
function uniqueLabels(jobs: JobSummary[]): { label: string; job: JobSummary }[] {
const seen = new Map<string, number>();
return jobs.map((job) => {
const base = describeJob(job);
const n = (seen.get(base) ?? 0) + 1;
seen.set(base, n);
return { label: n === 1 ? base : `${base} [${job.slug}]`, job };
});
}
type Edits = Pick<SocialRequest, "newTitle" | "newDate">;
/**
* "Stessa locandina, data nuova" — the re-render-last-year's-poster feature. Costs
* nothing: it is a text patch on spec.json plus a Typst pass.
*/
async function askEdits(
deps: SocialDeps,
slug: string,
ctx: ExtensionCommandContext,
): Promise<Edits | undefined> {
let spec: DesignSpec;
try {
spec = loadSpec(openJob(deps.config, slug));
} catch {
return {}; // nothing to patch; the render itself will report the real problem.
}
const currentTitle = blockText(spec, "title") ?? "";
const currentDate = blockText(spec, "date") ?? "";
const options = [S.refine.actions.accept, T.changeDate, T.changeTitle];
const chosen = await ctx.ui.select(
fill(T.whatTitle, { titolo: currentTitle || slug }),
options,
);
if (chosen === undefined) return undefined;
if (chosen === S.refine.actions.accept) return {};
const edits: Edits = {};
if (chosen === T.changeDate || chosen === T.changeTitle) {
ctx.ui.notify(S.refine.instant, "info");
}
if (chosen === T.changeDate) {
const next = await askText(ctx, S.brief.labels.date, currentDate, S.brief.placeholders.date);
if (next === undefined) return undefined;
if (next.trim() && next.trim() !== currentDate) edits.newDate = next.trim();
}
if (chosen === T.changeTitle) {
const next = await askText(ctx, S.brief.labels.title, currentTitle, S.brief.placeholders.title);
if (next === undefined) return undefined;
if (next.trim() && next.trim() !== currentTitle) edits.newTitle = next.trim();
}
return edits;
}
/** Prefer the multi-line editor (it can prefill); fall back to a plain input dialog. */
async function askText(
ctx: ExtensionCommandContext,
title: string,
current: string,
placeholder: string,
): Promise<string | undefined> {
if (ctx.mode === "tui") return ctx.ui.editor(title, current);
return ctx.ui.input(title, current || placeholder);
}
/**
* The "parti da zero" branch. The brief is gathered here; the artwork is not our job.
*/
async function startFresh(
deps: SocialDeps,
text: string,
ctx: ExtensionCommandContext,
): Promise<{ job: Job; spec: DesignSpec } | undefined> {
if (!deps.createJobFromBrief) {
throw new Error(`${T.freshUnavailable}\n${T.freshUnavailableFix}`);
}
const brief: Brief = { kind: "social", freeText: text || undefined };
// Free text typed after the command pre-fills the form. A model failure inside
// parseFreeText() degrades to a local parse, so this never blocks the form.
if (text && deps.director) {
try {
const parsed = await deps.director.parseFreeText(text, ctx, deps.config);
Object.assign(brief, parsed.fields);
brief.freeText = parsed.freeText || text;
if (ctx.hasUI) {
ctx.ui.notify(
parsed.confidence === "bassa" ? S.brief.prefilledNothing : S.brief.prefilled,
"info",
);
}
} catch {
/* the form still opens with whatever he typed */
}
}
if (ctx.hasUI) {
const title = await askText(ctx, S.brief.labels.title, brief.title ?? "", S.brief.placeholders.title);
if (title === undefined) return undefined;
if (title.trim()) brief.title = title.trim();
const date = await askText(ctx, S.brief.labels.date, brief.date ?? "", S.brief.placeholders.date);
if (date === undefined) return undefined;
if (date.trim()) brief.date = date.trim();
const venue = await askText(ctx, S.brief.labels.venue, brief.venue ?? "", S.brief.placeholders.venue);
if (venue === undefined) return undefined;
if (venue.trim()) brief.venue = venue.trim();
const extra = await askText(ctx, S.brief.freeTextLabel, brief.freeText ?? "", S.brief.freeTextPlaceholder);
if (extra === undefined) return undefined;
if (extra.trim()) brief.freeText = extra.trim();
}
if (!brief.title?.trim() && !brief.freeText?.trim()) {
throw new Error(`${S.brief.needTitle}\n${errorText(S.errors.briefEmpty)}`);
}
return deps.createJobFromBrief(brief, ctx, ctx.signal);
}
// ---------------------------------------------------------------------------
// Output
// ---------------------------------------------------------------------------
/** Inline preview: the image so he can see it, the path so the model has a handle. */
async function showPreview(pi: ExtensionAPI, result: SocialResult): Promise<void> {
const preferred =
result.files.find((f) => basename(f) === "ig-post.png") ?? result.files[0];
if (!preferred) return;
const content: (TextContent | ImageContent)[] = [];
const image = readImage(preferred);
if (image) content.push(image);
content.push({
type: "text",
text: [
fill(S.done.folder, { cartella: result.dir }),
...result.files.map((f) => `${basename(f)} — ${fileLabel(basename(f))}`),
].join("\n"),
});
pi.sendMessage({ customType: "imgen-social", content, display: true, details: result });
}
/** Reads a PNG as an inline image block. Silent on failure: a preview is never worth a crash. */
function readImage(path: string): ImageContent | undefined {
try {
if (statSync(path).size > 10 * 1024 * 1024) return undefined;
return { type: "image", data: readFileSync(path).toString("base64"), mimeType: "image/png" };
} catch {
return undefined;
}
}
/** A plain transcript line. Commands have no return value, so this is how we speak. */
function say(pi: ExtensionAPI, text: string): void {
pi.sendMessage({ customType: "imgen-social-note", content: text, display: true });
}
// ---------------------------------------------------------------------------
// Small helpers
// ---------------------------------------------------------------------------
function titleOf(deps: SocialDeps, slug: string): string | undefined {
try {
return blockText(loadSpec(openJob(deps.config, slug)), "title");
} catch {
return undefined;
}
}
function emptySummary(slug: string): JobSummary {
return {
slug,
dir: "",
hasSpec: true,
hasArt: true,
hasCaption: false,
outputs: [],
modifiedAt: new Date(),
};
}
function messageOf(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
/** Errors reaching the user must be Italian; JobError and friends already carry it. */
function italianOf(e: unknown): string {
const italian = (e as { italian?: unknown })?.italian;
if (typeof italian === "string" && italian) return italian;
const msg = messageOf(e);
return msg || errorText(S.errors.unknown());
}