Files
atelier/sidecar/api/collections.ts
T
pastilhas 82ca696ccf refactor(web): two panels on the left, the work on the right
Four panels became three, and the arrangement carries the change: everything
that organises or configures is stacked in one narrow column, and the whole of
the rest is one surface for looking at images.

Collections is a LIST, not a grid of covers — it sits in a narrow column beside
the gallery, where two cards per row meant four collections visible. A row is a
name, a count, and rename/delete on hover. Create is a dialog rather than an
always-present input, and a filter field replaces it in that space, since
finding one is the frequent act and making one is not.

Selecting a collection now shows it in the GALLERY. It used to replace the list
inside its own panel, which meant the selector disappeared the moment it was
used, and both panels kept their own copy of one collection's images and
disagreed after any edit. `?collection=` is read by the gallery now; the
collections hook stopped fetching detail entirely.

Deleting asks the question that cannot be undone: keep the images, or delete
them too. That needed a server route — DELETE /collections/:id?images=delete —
which reads the members BEFORE the collection goes, since deleting it takes the
memberships with it. Unchecked by default: the recoverable answer is the one
you get by pressing Enter without reading.

Providers stopped being a panel. It was a card per provider, each with a key
field and three buttons, filling a quarter of the screen for something set once
— it is a dropdown at the top of Generate now, listing "adapter · model" with a
delete on each, and adding one opens a dialog. The form and the dropdown share
one provider list (useProviders); they were two fetches, so adding a provider
left the form showing the old models until a reload.
2026-08-20 21:34:34 +01:00

174 lines
7.0 KiB
TypeScript

import { rm } from 'node:fs/promises';
import { join } from 'node:path';
import {
addToCollection,
collectionImages,
createCollection,
deleteCollection,
deleteGenerations,
getCollection,
getCollectionByName,
listCollections,
removeFromCollection,
renameCollection,
} from '../db';
import { createRouter } from '../create-router';
import { IMAGES_DIR } from '../env';
import { openaiError, UUID_PATTERN } from '../errors';
import { serializeCollection, serializeCollectionSummary, serializeGeneration } from '../serialize';
export const collectionsRouter = createRouter();
const notFound = (id: string) => openaiError(`No collection with id "${id}".`, 'not_found');
/** Trimmed, and required to be something. A collection called " " is a collection nobody can name again. */
function cleanName(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
}
collectionsRouter.get('/', async (ctx) =>
ctx.json({ data: (await listCollections()).map(serializeCollectionSummary) }),
);
collectionsRouter.post('/', async (ctx) => {
const body = (await ctx.req.json().catch(() => null)) as { name?: unknown; description?: unknown } | null;
const name = cleanName(body?.name);
if (!name) return ctx.json(openaiError("'name' is required.", 'invalid_request_error', 'name'), 400);
// Answered as a conflict rather than silently returning the existing one: POST here is an explicit
// "make a new collection", and quietly handing back someone else's is worse than refusing.
// The generation API's ?collection= is the find-or-create path.
if (await getCollectionByName(name)) {
return ctx.json(openaiError(`A collection named "${name}" already exists.`, 'invalid_request_error', 'name'), 409);
}
const description = typeof body?.description === 'string' ? body.description : null;
return ctx.json({ data: serializeCollection(await createCollection(name, description)) }, 201);
});
collectionsRouter.get('/:id', async (ctx) => {
const id = ctx.req.param('id');
if (!UUID_PATTERN.test(id)) return ctx.json(notFound(id), 404);
const collection = await getCollection(id);
if (!collection) return ctx.json(notFound(id), 404);
return ctx.json({
data: { ...serializeCollection(collection), images: (await collectionImages(id)).map(serializeGeneration) },
});
});
collectionsRouter.patch('/:id', async (ctx) => {
const id = ctx.req.param('id');
if (!UUID_PATTERN.test(id) || !(await getCollection(id))) return ctx.json(notFound(id), 404);
const body = (await ctx.req.json().catch(() => null)) as { name?: unknown; description?: unknown } | null;
if (!body) return ctx.json(openaiError('Request body must be a JSON object.', 'invalid_request_error'), 400);
const values: { name?: string; description?: string | null } = {};
if (body.name !== undefined) {
const name = cleanName(body.name);
if (!name) return ctx.json(openaiError("'name' must be a non-empty string.", 'invalid_request_error', 'name'), 400);
const existing = await getCollectionByName(name);
if (existing && existing.id !== id) {
return ctx.json(
openaiError(`A collection named "${name}" already exists.`, 'invalid_request_error', 'name'),
409,
);
}
values.name = name;
}
if (body.description !== undefined) {
values.description = typeof body.description === 'string' ? body.description : null;
}
if (Object.keys(values).length === 0) {
return ctx.json(openaiError('Nothing to update.', 'invalid_request_error'), 400);
}
const updated = await renameCollection(id, values);
return ctx.json({ data: updated ? serializeCollection(updated) : null });
});
/**
* Delete a collection. `?images=delete` deletes what was in it as well; the default keeps them.
*
* The two are genuinely different intentions and only the caller knows which one this is. "I filed
* these wrongly" wants the folder gone and the images kept; "this whole afternoon was a dead end"
* wants the images gone too, and doing that by hand is one delete per image. Which is why the
* destructive one has to be asked for by name rather than inferred.
*
* Order matters: read the members BEFORE the collection goes, because deleting it takes the
* memberships with it and there is then nothing left that says which images were in it.
*
* A file that will not unlink is not an error the caller can act on — the row is already gone and
* retrying deletes nothing. It is left on disk and logged rather than failing a delete that, from
* every side the user can see, succeeded.
*/
collectionsRouter.delete('/:id', async (ctx) => {
const id = ctx.req.param('id');
if (!UUID_PATTERN.test(id) || !(await getCollection(id))) return ctx.json(notFound(id), 404);
const alsoImages = ctx.req.query('images') === 'delete';
const members = alsoImages ? await collectionImages(id) : [];
await deleteCollection(id);
let deletedImages = 0;
if (alsoImages && members.length > 0) {
const removed = await deleteGenerations(members.map((row) => row.id));
deletedImages = removed.length;
for (const row of removed) {
if (!row.imagePath) continue;
await rm(join(IMAGES_DIR, row.imagePath), { force: true }).catch((err: unknown) => {
console.error(`[atelier] could not unlink ${row.imagePath}:`, err);
});
}
}
return ctx.json({ data: { id, deleted: true, deletedImages } });
});
/** Add images. Ones already in the collection are counted as added and change nothing. */
collectionsRouter.post('/:id/items', async (ctx) => {
const id = ctx.req.param('id');
if (!UUID_PATTERN.test(id) || !(await getCollection(id))) return ctx.json(notFound(id), 404);
const body = (await ctx.req.json().catch(() => null)) as { generationIds?: unknown } | null;
const ids = Array.isArray(body?.generationIds) ? body.generationIds : null;
if (!ids || ids.some((value) => typeof value !== 'string' || !UUID_PATTERN.test(value))) {
return ctx.json(
openaiError("'generationIds' must be an array of uuids.", 'invalid_request_error', 'generationIds'),
400,
);
}
try {
const added = await addToCollection(id, ids as string[]);
return ctx.json({ data: { added, images: (await collectionImages(id)).map(serializeGeneration) } });
} catch {
// The foreign key is the check: an id that names no generation cannot be filed against one.
return ctx.json(
openaiError('One or more generationIds do not exist.', 'invalid_request_error', 'generationIds'),
400,
);
}
});
collectionsRouter.delete('/:id/items/:generationId', async (ctx) => {
const id = ctx.req.param('id');
const generationId = ctx.req.param('generationId');
if (!UUID_PATTERN.test(id) || !(await getCollection(id))) return ctx.json(notFound(id), 404);
if (!UUID_PATTERN.test(generationId)) {
return ctx.json(openaiError('Not a valid generation id.', 'invalid_request_error', 'generationId'), 400);
}
await removeFromCollection(id, [generationId]);
return ctx.json({ data: { removed: generationId, images: (await collectionImages(id)).map(serializeGeneration) } });
});