755 lines
26 KiB
TypeScript
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());
|
|
}
|