Files
pi-imgen/extensions/imgen/commands/social.ts
T
mozempk aee92d284c cleanup: clear stale TODOs, tighten types, translate art-prompt hints
Every 'being written in parallel' / 'ASSUMED SIGNATURE' TODO is gone: each
referenced module now exists and each assumption was checked against it
rather than the comment simply deleted.

- logo/presets/retouch: renderer typed as the real Renderer contract instead
  of unknown / (...args: never[]).
- director: the ModelRegistry assumption is VERIFIED against the installed
  @earendil-works types (find() on ModelRegistry, getModel() on ModelRuntime).
- strings: the img2imgBug TODO had it backwards -- drawthings raises a graded
  message a static string cannot express, and this is the fallback.
- poster: an Italian hint was being spliced into an English art prompt, which
  degrades these models. New director.refineArtPrompt() folds the note in via
  the LLM and falls back to the old splice on any failure, so it can only
  improve on the previous behaviour. scrubArtPrompt still runs either way.

Remaining TODOs are one category only (Italian strings living in per-file
tables rather than ui/strings.ts) and are accurate, not stale.
2026-08-27 10:30:54 +02:00

833 lines
29 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, 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<Format[]> {
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<PixelSize | null>`. 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<boolean> {
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<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, 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<SocialResult> {
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<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 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<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;
}
// 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<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) === 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());
}