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

- +
+ +
+ + +
+ +
-
+ -
+
+ + +
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\)/); +});