diff --git a/docs/my-resources.md b/docs/my-resources.md
index f0017e4a..5e9fe766 100644
--- a/docs/my-resources.md
+++ b/docs/my-resources.md
@@ -253,6 +253,42 @@ download that works without it.
of Python. Both pip packages are pinned: unpinned, a rebuild from the same commit
could produce different documents.
+## The image library
+
+Library → **Images** is every picture the account has generated, across all
+three features — My Resources, the Clinical Assistant and the Learning Hub —
+newest first. A figure outlives the deck it was drawn for: the deck gets
+replaced, the diagram is still good.
+
+`GET /api/generated-images` returns only finished jobs, scoped by `owner_id` in
+the statement rather than filtered afterwards. Paging is keyset (`created_at <
+cursor`), not `OFFSET`, because a gallery that grows while you scroll repeats or
+skips a row under `OFFSET`. The prompt is the only human-readable label an image
+has — there is no filename and no title — so it is decrypted for the caption; a
+prompt that cannot be decrypted costs the caption, never the picture.
+
+Tiles request the stored 256px preview through `data-image-thumb`, so thirty
+tiles cost a few kB each rather than thirty full-size downloads. Every fetch
+goes through `hydrateImage`, never a bare `src`: assets are served `no-store`
+and a bare `src` would not carry the session on a mobile client.
+
+### Deleting
+
+`DELETE /api/generated-images/:id` removes the bytes before the row, and refuses
+the whole operation if storage is unreachable. The other order would leave a row
+pointing at a key that is gone — an image listed in the gallery that renders
+broken — whereas failing between the two leaves a complete, working image and an
+error worth retrying.
+
+Both derived previews go with the original; they live under their own prefix in
+the same bucket, and missing them would leave paid-for bytes behind that are
+still readable. `THUMB_WIDTHS` is defined once, in `generatedImageStorage.js`,
+because a width that is written but never deleted is exactly what two copies of
+that list produces.
+
+A resource that embedded the figure keeps working: a deck stores the job id and
+renders without the figure when it has gone.
+
## Slide review
Off unless an administrator names a model, in **Admin → Slide review**.
diff --git a/public/components/my-resources.html b/public/components/my-resources.html
index 911961d4..b07dbba6 100644
--- a/public/components/my-resources.html
+++ b/public/components/my-resources.html
@@ -149,13 +149,37 @@
Library
-
+
+
+
+
+
+
+
+
-
+
-
+
+
+
+
+
Loading…
+
+
+
+
+
diff --git a/public/js/myResources.js b/public/js/myResources.js
index 8d551046..0b9f8d95 100644
--- a/public/js/myResources.js
+++ b/public/js/myResources.js
@@ -73,7 +73,18 @@
if (generate) generate.addEventListener('click', runGenerate);
var refresh = document.getElementById('btn-mr-refresh');
- if (refresh) refresh.addEventListener('click', loadLibrary);
+ // Refreshes whichever view is showing, rather than always the documents.
+ if (refresh) refresh.addEventListener('click', function () {
+ var panel = document.getElementById('mr-images-panel');
+ if (panel && !panel.hidden) loadImages(true); else loadLibrary();
+ });
+
+ var docsTab = document.getElementById('tab-mr-docs');
+ var imagesTab = document.getElementById('tab-mr-images');
+ if (docsTab) docsTab.addEventListener('click', function () { showLibraryTab('docs'); });
+ if (imagesTab) imagesTab.addEventListener('click', function () { showLibraryTab('images'); });
+ var moreImages = document.getElementById('btn-mr-images-more');
+ if (moreImages) moreImages.addEventListener('click', function () { loadImages(false); });
// One delegated handler: rows are rebuilt on every refresh, so binding per
// row would leak listeners and miss anything added later.
@@ -254,6 +265,172 @@
});
}
+ // ── The image library ──────────────────────────────────────────────────────
+ // Every picture this account has generated, across all three features. Kept
+ // because a figure outlives the deck it was drawn for: the deck gets replaced,
+ // the diagram is still good.
+ //
+ // Thumbnails, not originals. The server stores a 256px preview beside each
+ // asset, so a grid of thirty costs a few kB each instead of thirty full-size
+ // renders — data-image-thumb is what asks hydrateImage for the small copy.
+ var imagesLoaded = false;
+ var nextImagesBefore = null;
+
+ function showLibraryTab(which) {
+ var docs = which !== 'images';
+ [['tab-mr-docs', docs], ['tab-mr-images', !docs]].forEach(function (pair) {
+ var tab = document.getElementById(pair[0]);
+ if (!tab) return;
+ tab.setAttribute('aria-selected', pair[1] ? 'true' : 'false');
+ tab.style.background = pair[1] ? 'var(--white)' : 'transparent';
+ });
+ var list = document.getElementById('mr-list');
+ var panel = document.getElementById('mr-images-panel');
+ var search = document.getElementById('mr-docs-search');
+ if (list) list.hidden = !docs;
+ if (search) search.hidden = !docs;
+ if (panel) panel.hidden = docs;
+ // Fetched the first time the tab is opened, not on page load: most visits
+ // never open it, and it is a database read plus a thumbnail per tile.
+ if (!docs && !imagesLoaded) loadImages(true);
+ }
+
+ function loadImages(reset) {
+ var grid = document.getElementById('mr-images-grid');
+ var empty = document.getElementById('mr-images-empty');
+ var more = document.getElementById('btn-mr-images-more');
+ if (!grid) return;
+ if (reset) { grid.textContent = ''; nextImagesBefore = null; }
+ if (empty) { empty.hidden = false; empty.textContent = 'Loading…'; }
+
+ var url = '/api/generated-images?limit=60' +
+ (nextImagesBefore && !reset ? '&before=' + encodeURIComponent(nextImagesBefore) : '');
+ fetch(url, { headers: getAuthHeaders() })
+ .then(function (r) { return r.json(); })
+ .then(function (data) {
+ if (!data.success) throw new Error(data.error || 'Could not load your images');
+ imagesLoaded = true;
+ nextImagesBefore = data.nextBefore || null;
+ if (more) more.hidden = !nextImagesBefore;
+ if (empty) {
+ empty.hidden = Boolean(data.images.length) || Boolean(grid.children.length);
+ empty.textContent = 'No images yet. Tick "Add illustrations" when you generate, ' +
+ 'or ask the Clinical Assistant for a diagram.';
+ }
+ import('/js/generatedImages.js').then(function (m) {
+ data.images.forEach(function (image) { grid.appendChild(imageTile(image, m)); });
+ }).catch(function () {
+ if (empty) { empty.hidden = false; empty.textContent = 'Images could not be displayed.'; }
+ });
+ })
+ .catch(function (err) {
+ if (empty) { empty.hidden = false; empty.textContent = err.message; }
+ });
+ }
+
+ // Built as elements, never innerHTML: the caption is a model-written prompt.
+ function imageTile(image, m) {
+ var tile = document.createElement('figure');
+ tile.style.cssText = 'margin:0;border:1px solid var(--g200);border-radius:8px;overflow:hidden;' +
+ 'display:flex;flex-direction:column;background:var(--white);';
+
+ var frame = document.createElement('div');
+ frame.style.cssText = 'aspect-ratio:4/3;background:var(--g100);display:flex;align-items:center;justify-content:center;overflow:hidden;';
+ var img = document.createElement('img');
+ img.alt = image.prompt ? 'Generated illustration: ' + image.prompt.slice(0, 80) : 'Generated illustration';
+ img.setAttribute('data-image-thumb', '256');
+ img.style.cssText = 'width:100%;height:100%;object-fit:cover;display:block;';
+ frame.appendChild(img);
+ m.hydrateImage(img, image.imageUrl).catch(function () {
+ img.remove();
+ frame.textContent = 'Unavailable';
+ frame.style.fontSize = '11px';
+ frame.style.color = 'var(--g500)';
+ });
+
+ var caption = document.createElement('figcaption');
+ caption.style.cssText = 'padding:8px;display:flex;flex-direction:column;gap:6px;min-width:0;';
+
+ var text = document.createElement('div');
+ text.style.cssText = 'font-size:11px;color:var(--g600);line-height:1.35;max-height:3.4em;overflow:hidden;';
+ text.textContent = image.prompt || 'No description recorded';
+
+ var meta = document.createElement('div');
+ meta.style.cssText = 'font-size:10px;color:var(--g500);display:flex;gap:6px;flex-wrap:wrap;';
+ meta.textContent = image.source + ' · ' + new Date(image.createdAt).toLocaleDateString();
+
+ var actions = document.createElement('div');
+ actions.style.cssText = 'display:flex;gap:6px;';
+ var open = document.createElement('button');
+ open.type = 'button';
+ open.className = 'btn-sm btn-ghost';
+ open.style.cssText = 'font-size:11px;padding:3px 8px;';
+ open.textContent = 'Open';
+ open.addEventListener('click', function () { openImage(image, m); });
+
+ var remove = document.createElement('button');
+ remove.type = 'button';
+ remove.className = 'btn-sm btn-ghost';
+ remove.style.cssText = 'font-size:11px;padding:3px 8px;color:var(--red);';
+ remove.textContent = 'Delete';
+ remove.addEventListener('click', function () { deleteImage(image, tile, remove); });
+
+ actions.append(open, remove);
+ caption.append(text, meta, actions);
+ tile.append(frame, caption);
+ return tile;
+ }
+
+ // The full-size copy, fetched the same authenticated way as the tile. A plain
+ // link would not carry the session on a mobile client, and the asset is served
+ // no-store on purpose.
+ function openImage(image, m) {
+ var overlay = document.createElement('div');
+ overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.72);z-index:9999;' +
+ 'display:flex;align-items:center;justify-content:center;padding:24px;';
+ var full = document.createElement('img');
+ full.alt = image.prompt || 'Generated illustration';
+ full.style.cssText = 'max-width:100%;max-height:100%;object-fit:contain;border-radius:6px;';
+ overlay.appendChild(full);
+ overlay.addEventListener('click', function () { overlay.remove(); });
+ document.addEventListener('keydown', function escape(e) {
+ if (e.key !== 'Escape') return;
+ overlay.remove();
+ document.removeEventListener('keydown', escape);
+ });
+ document.body.appendChild(overlay);
+ m.hydrateImage(full, image.imageUrl).catch(function () {
+ full.remove();
+ overlay.textContent = 'That image could not be loaded.';
+ overlay.style.color = 'var(--white)';
+ });
+ }
+
+ function deleteImage(image, tile, button) {
+ // Asked, because it removes the picture from every deck that used it. The
+ // deck keeps working; the figure is simply no longer there.
+ if (!window.confirm('Delete this image? Any resource that used it will render without it.')) return;
+ button.disabled = true;
+ fetch('/api/generated-images/' + encodeURIComponent(image.id), {
+ method: 'DELETE', headers: getAuthHeaders()
+ })
+ .then(function (r) { return r.json(); })
+ .then(function (data) {
+ if (!data.success) throw new Error(data.error || 'Could not delete that image');
+ tile.remove();
+ var grid = document.getElementById('mr-images-grid');
+ var empty = document.getElementById('mr-images-empty');
+ if (grid && !grid.children.length && empty) {
+ empty.hidden = false;
+ empty.textContent = 'No images yet.';
+ }
+ })
+ .catch(function (err) {
+ button.disabled = false;
+ if (typeof showToast === 'function') showToast(err.message, 'error');
+ });
+ }
+
// The library as last fetched. Held so searching and the Modify picker both
// work from one copy rather than each asking the server again.
var library = [];
diff --git a/src/routes/generatedImages.js b/src/routes/generatedImages.js
index a31d9117..c4548534 100644
--- a/src/routes/generatedImages.js
+++ b/src/routes/generatedImages.js
@@ -40,6 +40,93 @@ async function sendAsset(req, res, download) {
res.setHeader('Content-Disposition', (download ? 'attachment' : 'inline') + '; filename="generated-image.' + ({ 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp' })[image.mime] + '"');
res.send(image.bytes);
}
+// ── The owner's own pictures, wherever they were made ───────────────────────
+// Every image this account generated, newest first, across all three workflows.
+// The per-workflow endpoint below answers "how is this job doing"; this answers
+// "what have I made", which is the question a library is for.
+//
+// Only finished jobs: a queued or failed one has nothing to show, and listing it
+// would put a broken frame in a gallery. The prompt is stored encrypted and is
+// decrypted here because it is the only human-readable label an image has —
+// there is no filename and no title.
+const WORKFLOW_LABELS = {
+ clinical_assistant: 'Clinical Assistant',
+ learning_hub: 'Learning Hub',
+ my_resources: 'My Resources'
+};
+
+router.get('/generated-images', async (req, res) => {
+ try {
+ const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 60, 1), 120);
+ // Keyset, not OFFSET: a gallery that grows while you scroll would otherwise
+ // repeat or skip a row on every page.
+ const before = req.query.before ? new Date(String(req.query.before)) : null;
+ if (before && isNaN(before.getTime())) throw images.failure(400, 'Invalid cursor');
+
+ const params = [req.user.id];
+ let where = "owner_id=$1 AND stage='done'";
+ if (before) { params.push(before.toISOString()); where += ' AND created_at < $' + params.length; }
+ params.push(limit + 1);
+
+ const result = await db.query(
+ 'SELECT id, workflow, model, byte_length, mime, prompt_cipher, created_at ' +
+ 'FROM generated_image_jobs WHERE ' + where +
+ ' ORDER BY created_at DESC LIMIT $' + params.length, params);
+
+ const rows = result.rows.slice(0, limit);
+ const encryption = require('../utils/crypto');
+ res.json({
+ success: true,
+ images: rows.map(function (row) {
+ let prompt = '';
+ // A prompt that cannot be decrypted is not worth failing a gallery for;
+ // the picture is still there and still downloadable.
+ try { prompt = String(encryption.decryptString(row.prompt_cipher) || ''); } catch (e) { prompt = ''; }
+ return {
+ id: row.id,
+ workflow: row.workflow,
+ source: WORKFLOW_LABELS[row.workflow] || row.workflow,
+ model: row.model,
+ bytes: row.byte_length,
+ mime: row.mime,
+ createdAt: row.created_at,
+ prompt: prompt.slice(0, 300),
+ thumbUrl: '/api/generated-images/' + row.id + '?w=256',
+ imageUrl: '/api/generated-images/' + row.id,
+ downloadUrl: '/api/generated-images/' + row.id + '?download=1'
+ };
+ }),
+ nextBefore: result.rows.length > limit ? rows[rows.length - 1].created_at : null
+ });
+ } catch (e) { fail(res, e); }
+});
+
+// Deleting is the owner's. Scoped by owner_id in the statement rather than
+// checked first, so a borrowed id deletes nothing rather than someone else's
+// picture, and an id that is not theirs is indistinguishable from one that does
+// not exist.
+//
+// The bytes go before the row. The other order would leave a row pointing at a
+// key that is gone — a listed image that renders broken — whereas failing
+// between the two leaves a complete, working image and an error the caller can
+// retry. Storage that is unreachable refuses the delete rather than removing
+// the row and stranding the object in the bucket.
+//
+// A resource that embedded the figure keeps working: a deck stores the job id
+// and renders without the figure when it has gone.
+router.delete('/generated-images/:id', async (req, res) => {
+ try {
+ if (!images.UUID.test(req.params.id)) throw images.failure(404, 'Image not found');
+ const owned = await db.query(
+ 'SELECT id FROM generated_image_jobs WHERE id=$1 AND owner_id=$2',
+ [req.params.id, req.user.id]);
+ if (!owned.rows.length) throw images.failure(404, 'Image not found');
+
+ await images.service().discard(req.params.id, req.user.id);
+ res.json({ success: true });
+ } catch (e) { fail(res, e); }
+});
+
router.get('/generated-images/:id', async (req, res) => {
try { await sendAsset(req, res, req.query.download === '1'); } catch (e) { fail(res, e); }
});
diff --git a/src/utils/generatedImageStorage.js b/src/utils/generatedImageStorage.js
index 473f3441..f66a9bf9 100644
--- a/src/utils/generatedImageStorage.js
+++ b/src/utils/generatedImageStorage.js
@@ -49,6 +49,10 @@ async function download(url, { lookup = dns.lookup, request = https.get, signal
}
// Derived previews are addressed by asset id and width, so a request can only
// ever reach a preview of the asset it already has permission to read.
+// Defined here, the lower-level module, and re-exported by generatedImages.
+// Two copies would drift: a width added for writing and not for deleting would
+// be paid for once and then left in the bucket forever.
+const THUMB_WIDTHS = Object.freeze([256, 640]);
function thumbKey(id, width) { return 'thumbs/' + id + '/' + width; }
function createStorage(env = process.env) {
@@ -92,7 +96,18 @@ function createStorage(env = process.env) {
for await (const chunk of response.Body) { size += chunk.length; if (size > MAX_BYTES) { response.Body.destroy(); throw new Error('Invalid stored image size'); } chunks.push(chunk); }
return inspect(Buffer.concat(chunks), response.ContentType);
},
+ // Deleting an image means deleting the picture, not just the row that points
+ // at it. The original and both derived previews live under two different
+ // prefixes, so all three keys go — otherwise "delete" leaves the bytes in
+ // the bucket, paid for and still readable by anything holding credentials.
+ //
+ // A key that is already gone is success: S3 DeleteObject is idempotent, and
+ // a job whose thumbnails were never rendered has none to remove.
+ async remove(id) {
+ const keys = ['assets/' + id].concat(THUMB_WIDTHS.map(function (w) { return thumbKey(id, w); }));
+ for (const Key of keys) await client.send(new DeleteObjectCommand({ Bucket, Key }));
+ },
close() { client.destroy(); }
};
}
-module.exports = { MAX_BYTES, inspect, decodeBase64, download, createStorage };
+module.exports = { MAX_BYTES, THUMB_WIDTHS, inspect, decodeBase64, download, createStorage };
diff --git a/src/utils/generatedImages.js b/src/utils/generatedImages.js
index a724a569..a1d287f9 100644
--- a/src/utils/generatedImages.js
+++ b/src/utils/generatedImages.js
@@ -43,7 +43,8 @@ function shouldRetryImageFallback(state) {
// Previews are generated once and stored beside the original, so a 56px tile
// costs a few kB instead of ~280kB. Only these widths are allowed: a caller
// cannot ask for arbitrary sizes and turn this into a CPU amplifier.
-const THUMB_WIDTHS = Object.freeze([256, 640]);
+// One definition, in generatedImageStorage, which is also what deletes them.
+const { THUMB_WIDTHS } = storageUtil;
function thumbWidth(requested) {
const width = Number(requested);
@@ -239,7 +240,26 @@ async function warmThumbs(id) {
}
}
-async function get(id, owner, workflow) {
+// Remove a picture and everything derived from it. Storage first, then the row:
+ // a row without its object is a broken image in a gallery, while an object
+ // without its row is only wasted space, and this cannot produce the former.
+ async function discard(id, owner) {
+ if (!UUID.test(id)) throw failure(404, 'Image job not found');
+ try {
+ await getStorage().remove(id);
+ } catch (e) {
+ // Bytes that could not be removed must not become a row that says they
+ // were: the caller is told to try again rather than shown a success that
+ // left the image in the bucket.
+ throw failure(503, 'Image storage is unavailable; nothing was deleted');
+ }
+ const result = await db.query(
+ 'DELETE FROM generated_image_jobs WHERE id=$1 AND owner_id=$2 RETURNING id', [id, owner]);
+ if (!result.rows.length) throw failure(404, 'Image job not found');
+ return { deleted: id };
+ }
+
+ async function get(id, owner, workflow) {
if (!UUID.test(id)) throw failure(404, 'Image job not found');
const result = await db.query('SELECT id,stage,model,error_code,context_included,context_total,prompt_units,budget FROM generated_image_jobs WHERE id=$1 AND owner_id=$2 AND workflow=$3', [id, owner, workflow]);
if (!result.rows[0]) throw failure(404, 'Image job not found');
@@ -272,7 +292,7 @@ async function get(id, owner, workflow) {
if (active) await active;
// The same storage client serves draining HTTP reads; process shutdown owns its lifetime.
}
- return { enqueue, get, asset, ready, snapshot, claim, tick, start, stop, thumbnail, warmThumbs };
+ return { enqueue, get, asset, discard, ready, snapshot, claim, tick, start, stop, thumbnail, warmThumbs };
}
let singleton;
function service() { return singleton || (singleton = createImageService({ db: require('../db/database') })); }
diff --git a/test/generated-image-cache.test.js b/test/generated-image-cache.test.js
index 4ec59904..a5921804 100644
--- a/test/generated-image-cache.test.js
+++ b/test/generated-image-cache.test.js
@@ -49,7 +49,7 @@ test('a preview cannot widen access or be asked for arbitrary sizes', () => {
const utils = read('src/utils/generatedImages.js');
const route = read('src/routes/generatedImages.js');
// An open width parameter would let a caller drive arbitrary resizes.
- assert.match(utils, /const THUMB_WIDTHS = Object\.freeze\(\[256, 640\]\);/);
+ assert.match(utils, /const \{ THUMB_WIDTHS \} = storageUtil;/);
assert.match(utils, /return THUMB_WIDTHS\.includes\(width\) \? width : null;/);
// Permission is checked against the ORIGINAL before any preview is served.
assert.match(route, /await images\.service\(\)\.asset\(req\.params\.id, req\.user\); \/\/ authorise/);
diff --git a/test/image-library.test.js b/test/image-library.test.js
new file mode 100644
index 00000000..f43dd9ac
--- /dev/null
+++ b/test/image-library.test.js
@@ -0,0 +1,93 @@
+// The image library: every picture this account generated, across all three
+// features, with thumbnails rather than originals and a delete that removes the
+// bytes and not only the row.
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+
+const read = file => fs.readFileSync(path.join(__dirname, '..', file), 'utf8');
+
+test('the listing is the owner’s own finished images, newest first', () => {
+ const route = read('src/routes/generatedImages.js');
+ assert.match(route, /router\.get\('\/generated-images'/);
+ // Scoped by owner in the statement, not filtered afterwards.
+ assert.match(route, /owner_id=\$1 AND stage='done'/);
+ assert.match(route, /ORDER BY created_at DESC/);
+ // Unfinished jobs have nothing to show; listing one puts a broken frame in a
+ // gallery.
+ assert.match(route, /Only finished jobs/);
+});
+
+test('paging is keyset, not OFFSET', () => {
+ // A gallery that grows while you scroll repeats or skips a row under OFFSET.
+ const route = read('src/routes/generatedImages.js');
+ assert.match(route, /created_at < \$/);
+ assert.doesNotMatch(route, /\bOFFSET\s+\$?\d/i, 'no SQL OFFSET; the word in a comment is fine');
+ assert.match(route, /nextBefore/);
+});
+
+test('a prompt that cannot be decrypted does not cost the gallery its picture', () => {
+ const route = read('src/routes/generatedImages.js');
+ assert.match(route, /try \{ prompt = String\(encryption\.decryptString/);
+ assert.match(route, /catch \(e\) \{ prompt = ''; \}/);
+});
+
+test('deleting removes the bytes before the row, and refuses if it cannot', () => {
+ // A row without its object is a broken image in a gallery; an object without
+ // its row is only wasted space. Only one of those is allowed to happen.
+ const lib = read('src/utils/generatedImages.js');
+ assert.match(lib, /async function discard\(id, owner\)/);
+ const discard = lib.slice(lib.indexOf('async function discard'));
+ const storageFirst = discard.indexOf('getStorage().remove(id)');
+ const rowSecond = discard.indexOf('DELETE FROM generated_image_jobs');
+ assert.ok(storageFirst > -1 && rowSecond > storageFirst, 'storage must be removed first');
+ assert.match(discard.slice(0, 800), /throw failure\(503, 'Image storage is unavailable; nothing was deleted'\)/);
+});
+
+test('deleting removes the previews too, not just the original', () => {
+ // Both derived widths live under their own prefix. Missing them leaves paid-for
+ // bytes in the bucket that are still readable.
+ const storage = read('src/utils/generatedImageStorage.js');
+ assert.match(storage, /async remove\(id\)/);
+ assert.match(storage, /\['assets\/' \+ id\]\.concat\(THUMB_WIDTHS\.map/);
+});
+
+test('the thumbnail widths have one definition', () => {
+ // Two copies drift: a width written but never deleted is paid for once and
+ // then left in the bucket forever.
+ const storage = read('src/utils/generatedImageStorage.js');
+ const lib = read('src/utils/generatedImages.js');
+ assert.match(storage, /const THUMB_WIDTHS = Object\.freeze\(\[256, 640\]\)/);
+ assert.match(lib, /const \{ THUMB_WIDTHS \} = storageUtil/);
+ assert.doesNotMatch(lib, /THUMB_WIDTHS = Object\.freeze/, 'not defined twice');
+ assert.deepEqual(require('../src/utils/generatedImages').THUMB_WIDTHS, [256, 640]);
+});
+
+test('a borrowed id deletes nothing rather than someone else’s picture', () => {
+ const route = read('src/routes/generatedImages.js');
+ assert.match(route, /SELECT id FROM generated_image_jobs WHERE id=\$1 AND owner_id=\$2/);
+ const lib = read('src/utils/generatedImages.js');
+ assert.match(lib, /DELETE FROM generated_image_jobs WHERE id=\$1 AND owner_id=\$2/);
+});
+
+test('the grid asks for the stored preview, not the original', () => {
+ // Thirty tiles at full size is thirty full-size downloads.
+ const ui = read('public/js/myResources.js');
+ assert.match(ui, /setAttribute\('data-image-thumb', '256'\)/);
+ assert.match(ui, /hydrateImage\(img, image\.imageUrl\)/,
+ 'fetched through the authenticated loader, never a bare src');
+});
+
+test('the caption is a model-written prompt and never reaches the page as HTML', () => {
+ const ui = read('public/js/myResources.js');
+ const tile = ui.slice(ui.indexOf('function imageTile'), ui.indexOf('function openImage'));
+ assert.match(tile, /text\.textContent = image\.prompt/);
+ assert.doesNotMatch(tile, /innerHTML/);
+});
+
+test('the images tab loads on first open, not on page load', () => {
+ // Most visits never open it, and it costs a query plus a thumbnail per tile.
+ const ui = read('public/js/myResources.js');
+ assert.match(ui, /if \(!docs && !imagesLoaded\) loadImages\(true\)/);
+});