/** * /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, needsUpscale } from "../render/formats.ts"; import { ART_FILENAME, CAPTION_FILENAME, JobError, blockText, createJob, describeJob, foldAccents, jobExists, listJobs, loadSpec, normaliseSlug, openInFinder, openJob, outputFile, 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?", 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.", 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 not in this list * because /social never *paints* and never runs the upscale that a fresh A3 needs — but * see `defaultFormatsFor()`: when the folder's `art.png` is ALREADY at print size (the * normal case for a job that came from /poster, whose artwork was upscaled once), the * job's own print format is added back for free. Re-typesetting an A3 PDF over artwork * that already exists is a Typst pass like any other. */ export const SOCIAL_FORMATS: readonly Format[] = ( Object.keys(FORMATS_GEOMETRY) as Format[] ).filter((f) => FORMATS_GEOMETRY[f].kind === "screen"); /** True for the formats that get a bleed, a TrimBox and a PDF. */ function isPrintFormat(format: Format): boolean { return FORMATS_GEOMETRY[format]?.kind === "print"; } /** * The formats a /social run produces when the caller named none: every screen size, plus * the source job's OWN print format when the artwork on disk is already sharp enough for * it. Without this, "stessa locandina, data nuova" hands him a folder with four social * PNGs and nothing he can take to the copy shop, while `S.done.reopenNote` has promised * him exactly that. * * `needsUpscale()` is the whole test: true means the artwork would print soft, and /social * is not allowed to fix that (an upscale is a model pass), so the print format is dropped * and only the social sizes come out. */ async function defaultFormatsFor( spec: DesignSpec, artPath: string, print: ImgenConfig["print"], ): Promise { const screens = [...SOCIAL_FORMATS]; const format = spec.format; if (!format || !isPrintFormat(format)) return screens; return (await artIsPrintReady(format, artPath, print)) ? [format, ...screens] : screens; } /** * Is `art.png` already big enough to print at this format? Never throws: an artwork we * cannot measure is treated as not print-ready, so the worst case is the old behaviour. * * Uses `render/contrast.ts`'s `imageSize(path): Promise`. Imported * lazily on purpose — a static import would drag `sharp` into extension start-up, where * nothing needs it. */ async function artIsPrintReady( format: Format, artPath: string, print: ImgenConfig["print"], ): Promise { try { const { imageSize } = await import("../render/contrast.ts"); const px = await imageSize(artPath); if (!px) return false; return !needsUpscale(format, px, print); } catch { return false; } } // --------------------------------------------------------------------------- // 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[]; /** * Write the print PDF for the print formats in `formats`. /social sets this itself: * true exactly when a print format is being produced, so a screen-only run never pays * for a PDF nobody opens. */ pdf?: boolean; 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. * * Satisfied by `render/typst.ts`'s `renderAllFormats()`, which owns `resolveSpec()`, the * per-aspect fit and `contrast.smartCrop()`. index.ts adapts it onto this interface, so a * mismatch is fixed in one place. The adapter must honour `req.pdf`, or a print format in * `formats` yields a PNG and no PDF. */ export interface SocialRenderer { renderAllFormats(req: SocialRenderRequest): Promise; } /** The two director calls /social can make. Both are optional at runtime. */ export interface SocialDirector { parseFreeText(text: string, ctx: DirectorContext, cfg?: ImgenConfig): Promise; generateCaption( spec: DesignSpec, brief: Brief, ctx: DirectorContext, cfg?: ImgenConfig, ): Promise; } 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, plus the job's own print format when * the artwork already on disk is big enough to print — see `defaultFormatsFor()`. */ 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 { const now = deps.now ?? Date.now; const started = now(); // The default set depends on the artwork, which may still be about to be copied into a // fork, so it is resolved below — once `target.artPath` is known to exist. const asked = req.formats ? [...req.formats] : undefined; if (asked && asked.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 = {}; 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 forked folder carries the ORIGINAL artwork, already upscaled by /poster: if it is // still print-sharp, the A3 PDF comes out of this run for free. const formats = asked ?? (await defaultFormatsFor(patched, target.artPath, deps.config.print)); if (formats.length === 0) throw new Error(errorText(S.errors.exportFailed(T.nothingRendered))); // --- 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, pdf: formats.some(isPrintFormat), 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 { 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; } // Headless picked for him: say which job, so the choice is never silent. if (!interactive && !text) { say(pi, fill(T.headlessPicked, { titolo: titleOf(deps, slug) ?? 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 { // 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(); 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; /** * "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 { 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 { 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 { const preferred = result.files.find((f) => basename(f) === outputFile("ig-post")) ?? 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()); }