import { join } from 'node:path'; import { DATA_DIR } from '../env'; import { SHIPPED_CATALOGUE } from './shipped'; import { CATALOGUE_REVISION, type Catalogue, type CatalogueModel, type CatalogueProvider } from './types'; /** * Where a refreshed catalogue lives: beside the images, NOT in the plugin directory. * * A plugin directory is a git clone that an update replaces, so a file written there disappears on * upgrade with nothing logging it — the same rule env.ts states for generated images, and the reason * `$OFFICER_ROOT/data/atelier` exists at all. */ export const CATALOGUE_PATH = join(DATA_DIR, 'catalogue.json'); /** In-memory copy, so every add-connection dialog does not read the disk. Cleared by a refresh. */ let cached: Catalogue | null = null; /** * Merge a refreshed catalogue over the shipped one. * * Shipped is the FLOOR, never the ceiling: a refresh may correct a price, add a model or retire one, * and it may also fail halfway. Merging rather than replacing means a refresh that returned three * models out of five leaves the other two intact instead of deleting them, and a refresh that * returned nothing at all changes nothing. * * Per model, the refreshed FIELDS win one by one rather than the refreshed OBJECT winning whole — the * models endpoint knows ids and nothing else, so an entry built from it would otherwise wipe the * sizes, qualities and price that were curated by hand. */ export function mergeCatalogue(base: Catalogue, refreshed: Catalogue | null): Catalogue { if (!refreshed || refreshed.revision !== base.revision) return base; const providers: CatalogueProvider[] = base.providers.map((provider) => { const incoming = refreshed.providers.find((entry) => entry.name === provider.name); if (!incoming) return provider; const models: CatalogueModel[] = provider.models.map((model) => { const update = incoming.models.find((entry) => entry.id === model.id); return update ? { ...model, ...definedOnly(update) } : model; }); // Models the refresh found that we have never shipped: kept, with whatever it established and // nothing invented. They are usable — a request names a model by string — and honest about cost. for (const model of incoming.models) { if (!models.some((entry) => entry.id === model.id)) models.push(model); } return { ...provider, ...definedOnly(incoming), models }; }); // A provider the refresh knows about and we do not ship. Same reasoning as a new model. for (const provider of refreshed.providers) { if (!providers.some((entry) => entry.name === provider.name)) providers.push(provider); } return { revision: base.revision, updatedAt: refreshed.updatedAt, providers }; } /** `{...a, ...b}` with b's `undefined`s dropped, so "not established" never overwrites "curated". */ function definedOnly(value: T): Partial { return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined)) as Partial; } async function readRefreshed(): Promise { try { const file = Bun.file(CATALOGUE_PATH); if (!(await file.exists())) return null; const parsed = (await file.json()) as Catalogue; // A file written by a plugin with a different shape is ignored rather than half-read. It is not // corruption — it is last month's format, and the shipped catalogue is a better answer than a // partially-understood one. if (parsed?.revision !== CATALOGUE_REVISION || !Array.isArray(parsed.providers)) { console.warn(`[atelier:catalogue] ignoring ${CATALOGUE_PATH}: revision ${parsed?.revision} is not ${CATALOGUE_REVISION}`); return null; } return parsed; } catch (err) { console.error('[atelier:catalogue] could not read the refreshed catalogue:', err); return null; } } /** The catalogue as everything else should see it: shipped, with any successful refresh merged over. */ export async function getCatalogue(): Promise { if (cached) return cached; cached = mergeCatalogue(SHIPPED_CATALOGUE, await readRefreshed()); return cached; } export async function writeCatalogue(catalogue: Catalogue): Promise { await Bun.write(CATALOGUE_PATH, `${JSON.stringify(catalogue, null, 2)}\n`); cached = null; } /** When the refreshed file was last written, or null if there has never been one. */ export async function catalogueAge(): Promise { const file = Bun.file(CATALOGUE_PATH); if (!(await file.exists())) return null; return Date.now() - file.lastModified; } export function invalidateCatalogue(): void { cached = null; }