pediatric-ai-scribe-v3/public/js/myResources.js
Daniel bd8e413bc7
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 59s
Forgejo Docker Build / Root app tests (push) Successful in 50s
Forgejo Android APK / Build signed APK (push) Successful in 1m56s
Forgejo Docker Build / Build Docker image (push) Successful in 9s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
fix: an assistant attachment must be the image type it claims to be
The MIME type was taken on trust here. Anything at all could be posted as
image/png: it passed the size and base64 checks, was stored in the saved chat,
and was handed to a provider as a data URI. Documents and S3 uploads have always
been sniffed by fileType.js; this was the one upload path that was not.

Now sniffed with the same helper, so there is one idea of what a PNG looks like.
A PHP payload, a shell script, an ELF or PE binary, a zip, or a real PDF
labelled image/png are all refused with a message that says what is wrong.

What this does not claim: bytes hidden after a valid PNG header still make a
valid PNG, and no sniffer can promise otherwise. The protection is that the file
is never executed and never served as anything but an image.

Existing fixtures used buffers of 0x07 as stand-in images, which are correctly
refused now. They carry real file headers instead — a fixture should be the
thing it claims to be, exactly like a real upload.

Also adds the deck theme system: five palettes in assets/deck-themes.json,
render_pptx.py rebinding its palette from the theme rather than hardcoding it,
the theme carried on the deck and validated against the same catalogue the
renderer reads, a picker on the generate form, and PUT /my-resources/:id/theme
to re-skin a stored deck with no model call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 19:00:10 +02:00

835 lines
36 KiB
JavaScript

// ============================================================
// MY RESOURCES
// A person's own generated teaching material.
//
// Separate from the Learning Hub on purpose: that is moderator-owned content
// published into categories for everyone, this is private and needs no role
// beyond being signed in. The server enforces that independently — every query
// there filters on the owner — so this only has to be an honest interface to it.
// ============================================================
(function () {
var inited = false;
// A model an administrator adds has to show up here too, without a reload.
// loadOptions also decides whether the model row is shown at all, so this is
// the same call rather than a partial refresh of the select.
document.addEventListener('models-changed', function () {
if (inited) loadOptions();
});
document.addEventListener('tabChanged', function (e) {
if (!e.detail || e.detail.tab !== 'myresources') return;
if (!inited) { init(); inited = true; }
// Reopening the tab is a fresh start, not a resumed one. The DOM survives
// the visit, so without this the previous run's illustration and status sit
// under an empty form as though they belonged to it.
else clearResults();
loadLibrary();
});
// What an administrator has switched on. Read once, used by both option
// groups and by the instruction check.
var available = { images: false, web: false, pubmed: false };
// Someone who writes "add a diagram of the airway" in the instructions has
// said what they want as plainly as ticking the box would. Left alone, the
// request is simply dropped and the resource comes back without a picture
// and without a word about why. These are the ways people actually ask.
var IMAGE_REQUEST = /\b(image|images|diagram|diagrams|illustration|illustrations|illustrate|illustrated|figure|figures|picture|pictures|drawing|drawings|chart|charts|graphic|graphics|infographic|visual|visuals|schematic)\b/i;
function looksLikeImageRequest(text) {
return IMAGE_REQUEST.test(String(text || ''));
}
// Turning the option on is the honest response: it does what was asked, it is
// visible, and it can be turned straight back off. Silently ignoring the
// sentence is not. Once it has been switched off by hand, it stays off —
// re-ticking it on every keystroke would be a fight.
function wireImageIntent(textareaId, checkboxId, hintId) {
var box = document.getElementById(textareaId);
var check = document.getElementById(checkboxId);
var hint = document.getElementById(hintId);
if (!box || !check || !hint) return;
var overruled = false;
check.addEventListener('change', function () {
if (!check.checked) overruled = true;
if (check.checked) hint.hidden = true;
});
box.addEventListener('input', function () {
var asked = looksLikeImageRequest(box.value);
if (!asked) { hint.hidden = true; return; }
if (!available.images) {
hint.textContent = 'Your instructions mention a figure, but no image model is configured, so none can be made.';
hint.hidden = false;
return;
}
if (check.checked || overruled) {
hint.hidden = overruled ? false : true;
if (overruled) hint.textContent = 'Your instructions ask for a figure. Tick the illustration option above to get one.';
return;
}
check.checked = true;
hint.textContent = 'Illustration switched on, because your instructions ask for a figure. Untick it if you would rather not.';
hint.hidden = false;
});
}
function init() {
var kind = document.getElementById('mr-kind');
if (kind) kind.addEventListener('change', syncFormatFields);
syncFormatFields();
var generate = document.getElementById('btn-mr-generate');
if (generate) generate.addEventListener('click', runGenerate);
var refresh = document.getElementById('btn-mr-refresh');
// 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.
var list = document.getElementById('mr-list');
if (list) list.addEventListener('click', onRowClick);
// Filtering is local: the whole library is already in hand, so searching it
// is instant and costs no request.
var search = document.getElementById('mr-search');
if (search) search.addEventListener('input', renderLibrary);
var modify = document.getElementById('btn-mr-modify');
if (modify) modify.addEventListener('click', runModify);
wireImageIntent('mr-refinement', 'mr-with-images', 'mr-image-hint');
wireImageIntent('mr-modify-instructions', 'mr-modify-images', 'mr-modify-image-hint');
loadOptions();
}
// What an administrator has approved. The model row stays hidden unless there
// is a genuine choice to make — one approved model is not a decision anyone
// should be asked to take.
function loadOptions() {
fetch('/api/my-resources/options', { headers: getAuthHeaders() })
.then(function (r) { return r.json(); })
.then(function (data) {
if (!data || !data.success) return;
var select = document.getElementById('mr-model');
var modelRow = document.getElementById('mr-model-row');
var models = data.models || [];
if (select) {
// Rebuilt, but a choice already made is kept: this also runs when an
// administrator adds a model, and moving someone off the model they
// picked mid-form would be worse than not refreshing at all.
var chosen = select.value;
select.textContent = '';
models.forEach(function (id) {
var option = document.createElement('option');
option.value = id;
option.textContent = id;
if (id === data.defaultModel) option.selected = true;
select.appendChild(option);
});
if (chosen && models.indexOf(chosen) !== -1) select.value = chosen;
}
if (modelRow) modelRow.hidden = models.length < 2;
// The theme catalogue. Structural slides carry no colour of their own,
// so this is the whole of a deck's styling — one choice rather than a
// colour field the model would have to guess a value for.
var themeSelect = document.getElementById('mr-theme');
var themeRow = document.getElementById('mr-theme-row');
var themes = Array.isArray(data.themes) ? data.themes : [];
if (themeSelect && themes.length) {
var chosenTheme = themeSelect.value;
themeSelect.textContent = '';
themes.forEach(function (t) {
var option = document.createElement('option');
option.value = t.id;
option.textContent = t.name;
option.title = t.description || '';
themeSelect.appendChild(option);
});
if (chosenTheme && themes.some(function (t) { return t.id === chosenTheme; })) {
themeSelect.value = chosenTheme;
}
describeTheme(themes);
themeSelect.onchange = function () { describeTheme(themes); };
}
if (themeRow) themeRow.hidden = themes.length < 2;
// Hidden entirely unless an administrator enabled it, so an option
// never appears that someone could tick and then be refused. Both
// groups are driven from the same answer: Generate and Modify offer
// the same choices, so they must offer the same ones.
available = {
images: Boolean(data.imagesAvailable),
web: Boolean(data.webSearchAvailable),
pubmed: Boolean(data.pubmedAvailable)
};
[['mr-images-row', 'images'], ['mr-web-row', 'web'], ['mr-pubmed-row', 'pubmed'],
['mr-modify-images-row', 'images'], ['mr-modify-web-row', 'web'],
['mr-modify-pubmed-row', 'pubmed']].forEach(function (pair) {
var row = document.getElementById(pair[0]);
if (row) row.hidden = !available[pair[1]];
});
})
.catch(function () { /* the defaults still work without this */ });
}
function syncFormatFields() {
var isArticle = (document.getElementById('mr-kind') || {}).value === 'article';
var slides = document.getElementById('mr-slide-count');
var words = document.getElementById('mr-word-wrap');
if (slides && slides.parentElement) slides.parentElement.hidden = isArticle;
if (words) words.hidden = !isArticle;
}
function status(text, tone) {
var el = document.getElementById('mr-status');
if (!el) return;
el.textContent = text || '';
el.style.color = tone === 'bad' ? 'var(--red)' : tone === 'good' ? 'var(--green)' : 'var(--g600)';
}
// Everything the last run left on screen. The tab keeps its DOM between
// visits — app.js loads a component once and marks it data-loaded — so an
// illustration from a previous generation stayed visible under an empty form
// when the tab was reopened, looking like output for a topic nobody had
// typed. It cleared on a full page refresh and only then, which is why it
// read as a leak.
// Says what the chosen theme is for, which is the part a name cannot carry.
function describeTheme(themes) {
var select = document.getElementById('mr-theme');
var hint = document.getElementById('mr-theme-hint');
if (!select || !hint) return;
var chosen = themes.filter(function (t) { return t.id === select.value; })[0];
hint.textContent = chosen ? (chosen.description || '') : '';
}
function clearResults() {
['mr-images', 'mr-searches', 'mr-image-failures'].forEach(function (id) {
var el = document.getElementById(id);
if (el) el.textContent = '';
});
status('');
}
function runGenerate() {
var topic = (document.getElementById('mr-topic') || {}).value || '';
if (!topic.trim()) { status('Enter a topic first.', 'bad'); return; }
clearResults();
var btn = document.getElementById('btn-mr-generate');
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Generating...'; }
status('Searching the library and writing. This takes a moment.');
var corpusBox = document.getElementById('mr-use-corpus');
fetch('/api/my-resources/generate', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
topic: topic.trim(),
kind: (document.getElementById('mr-kind') || {}).value || 'presentation',
slideCount: (document.getElementById('mr-slide-count') || {}).value,
wordCount: (document.getElementById('mr-word-count') || {}).value,
refinement: (document.getElementById('mr-refinement') || {}).value || '',
useCorpus: corpusBox && corpusBox.checked === false ? 'false' : 'true',
model: (document.getElementById('mr-model') || {}).value || '',
theme: (document.getElementById('mr-theme') || {}).value || '',
withImages: (document.getElementById('mr-with-images') || {}).checked ? 'true' : 'false',
withWebSearch: (document.getElementById('mr-web-search') || {}).checked ? 'true' : 'false',
withPubmed: (document.getElementById('mr-pubmed') || {}).checked ? 'true' : 'false'
})
})
.then(function (r) { return r.json(); })
.then(function (data) {
if (!data.success) throw new Error(data.error || 'Generation failed');
// Say what it was written from. Ungrounded material presented as
// grounded is the failure worth preventing.
var g = data.grounding || {};
status(g.used
? 'Saved. Written from ' + g.count + ' library excerpt' + (g.count === 1 ? '' : 's') + '.'
: 'Saved. Not grounded' + (g.reason ? ' — ' + g.reason : '') + '; written from the model alone.',
g.used ? 'good' : null);
reportSearches(data.searches);
// A deck that fell back came out as plain slides. Silently handing
// someone the plainer artifact left them comparing two decks with no
// idea why one had layouts and the other did not — and asking again
// usually gets the designed one, which is only worth knowing if the
// fallback is visible.
if (data.deckFallback) {
status('Saved, but as plain slides: the model could not produce a slide ' +
'design twice (' + data.deckFallback + '). Generating again usually gets one.', 'bad');
}
showIllustrations(data.imageJobs || []);
reportImageFailures(data.imageFailures);
loadLibrary();
})
.catch(function (err) { status(err.message, 'bad'); })
.finally(function () {
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-wand-magic-sparkles"></i> Generate'; }
});
}
// Say what was searched for. A query that left the network is worth showing
// plainly rather than leaving someone to wonder whether it happened.
function reportSearches(searches) {
(searches || []).forEach(function (s) {
if (typeof showToast !== 'function') return;
var where = s.tool === 'pubmed_search' ? 'PubMed' : 'the web';
showToast(s.count
? 'Searched ' + where + ' for "' + s.query + '" \u2014 ' + s.count + ' used.'
: 'Nothing found on ' + where + ' for "' + s.query + '".', 'info');
});
}
function ticked(id) {
var el = document.getElementById(id);
return Boolean(el && el.checked);
}
// The shared poller, so an image made here behaves exactly like one made in
// the assistant: same status line, same durable job, same asset endpoint. It
// lives in an ES module and this file is a classic script, hence the dynamic
// import — which also means a failure to load it cannot break generation.
function reportImageFailures(failures) {
if (!(failures || []).length || typeof showToast !== 'function') return;
// Fewer pictures than asked for, without a word, would look like the model
// ignoring the request rather than the queue refusing it.
showToast(failures.length === 1
? 'One illustration could not be started: ' + failures[0]
: failures.length + ' illustrations could not be started.', 'error');
}
function showIllustrations(jobs) {
var box = document.getElementById('mr-images');
if (!box) return;
box.innerHTML = '';
if (!jobs.length) return;
import('/js/generatedImages.js').then(function (m) {
m.renderImageJobs(box, jobs, 'my_resources', function (card, data) {
var img = document.createElement('img');
img.alt = 'Generated teaching illustration';
img.style.maxWidth = '100%';
card.append(img);
m.hydrateImage(img, data.imageUrl).catch(function () { img.alt = 'Private image unavailable'; });
});
}).catch(function () {
if (typeof showToast === 'function') showToast('An illustration was generated but could not be displayed.', 'info');
});
}
// ── 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;
// aria-selected is the whole state: the stylesheet keys off it, so the
// visible highlight and what a screen reader announces cannot disagree.
tab.setAttribute('aria-selected', pair[1] ? 'true' : 'false');
});
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.className = 'img-tile';
// The picture is the control: clicking it opens the full size. A button
// rather than a div, so it is reachable and announced without inventing
// keyboard handling.
var frame = document.createElement('button');
frame.type = 'button';
frame.className = 'img-tile-frame loading';
frame.title = 'Open full size';
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.loading = 'lazy';
frame.appendChild(img);
frame.addEventListener('click', function () { openImage(image, m); });
var badge = document.createElement('span');
badge.className = 'img-tile-badge';
badge.textContent = image.source;
frame.appendChild(badge);
m.hydrateImage(img, image.imageUrl)
.then(function () { frame.classList.remove('loading'); })
.catch(function () {
frame.classList.remove('loading');
img.remove();
var gone = document.createElement('span');
gone.style.cssText = 'font-size:11px;color:var(--g500);';
gone.textContent = 'Unavailable';
frame.appendChild(gone);
});
var body = document.createElement('div');
body.className = 'img-tile-body';
var prompt = document.createElement('figcaption');
prompt.className = 'img-tile-prompt';
prompt.textContent = image.prompt || 'No description recorded';
prompt.title = image.prompt || '';
var meta = document.createElement('div');
meta.className = 'img-tile-meta';
meta.textContent = new Date(image.createdAt).toLocaleDateString(undefined,
{ year: 'numeric', month: 'short', day: 'numeric' }) +
(image.bytes ? ' · ' + Math.max(1, Math.round(image.bytes / 1024)) + ' kB' : '');
var actions = document.createElement('div');
actions.className = 'img-tile-actions';
var download = document.createElement('button');
download.type = 'button';
download.className = 'img-tile-action';
download.textContent = 'Download';
download.addEventListener('click', function () { saveImage(image, m, download); });
var remove = document.createElement('button');
remove.type = 'button';
remove.className = 'img-tile-action danger';
remove.textContent = 'Delete';
remove.addEventListener('click', function () { deleteImage(image, tile, remove); });
actions.append(download, remove);
body.append(prompt, meta, actions);
tile.append(frame, body);
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.className = 'img-lightbox';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.setAttribute('aria-label', 'Generated illustration');
var full = document.createElement('img');
full.alt = image.prompt || 'Generated illustration';
var bar = document.createElement('div');
bar.className = 'img-lightbox-bar';
var caption = document.createElement('p');
caption.className = 'img-lightbox-caption';
caption.textContent = image.prompt || 'No description recorded';
var close = document.createElement('button');
close.type = 'button';
close.className = 'img-tile-action';
close.textContent = 'Close';
var save = document.createElement('button');
save.type = 'button';
save.className = 'img-tile-action';
save.textContent = 'Download';
save.addEventListener('click', function () { saveImage(image, m, save); });
bar.append(save, close);
overlay.append(full, caption, bar);
// Escape and a click on the backdrop both close it; a click on the picture
// or the buttons does not.
function dismiss() {
overlay.remove();
document.removeEventListener('keydown', onKey);
if (lastFocus && lastFocus.focus) lastFocus.focus();
}
function onKey(e) { if (e.key === 'Escape') dismiss(); }
var lastFocus = document.activeElement;
overlay.addEventListener('click', function (e) { if (e.target === overlay) dismiss(); });
close.addEventListener('click', dismiss);
document.addEventListener('keydown', onKey);
document.body.appendChild(overlay);
close.focus();
m.hydrateImage(full, image.imageUrl).catch(function () {
full.remove();
caption.textContent = 'That image could not be loaded.';
});
}
// Fetched, then saved from memory. A plain <a download href> would work in a
// browser carrying the session cookie and fail on a mobile client, whose
// session is a bearer token an <a> cannot send — and these assets are served
// no-store precisely so they are not sitting in a cache to be linked to.
function saveImage(image, m, button) {
var label = button.textContent;
button.disabled = true;
button.textContent = 'Saving…';
m.privateImageBlob(image.imageUrl)
.then(function (blob) {
var url = URL.createObjectURL(blob);
var link = document.createElement('a');
link.href = url;
link.download = 'illustration-' + image.id.slice(0, 8) + '.' +
({ 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp' })[image.mime] || 'png';
document.body.appendChild(link);
link.click();
link.remove();
// Revoked on the next frame: revoking immediately races the download in
// some browsers.
setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
})
.catch(function () {
if (typeof showToast === 'function') showToast('That image could not be downloaded.', 'error');
})
.finally(function () { button.disabled = false; button.textContent = label; });
}
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 = [];
function loadLibrary() {
var list = document.getElementById('mr-list');
if (!list) return;
fetch('/api/my-resources', { headers: getAuthHeaders() })
.then(function (r) { return r.json(); })
.then(function (data) {
library = (data && data.resources) || [];
renderLibrary();
syncModifyTargets();
})
.catch(function () {
library = [];
list.textContent = '';
var failed = document.createElement('p');
failed.style.cssText = 'margin:0;font-size:13px;color:var(--red);';
failed.textContent = 'Could not load your resources.';
list.appendChild(failed);
syncModifyTargets();
});
}
function matches(row, needle) {
if (!needle) return true;
return (String(row.title || '') + ' ' + String(row.topic || '')).toLowerCase().indexOf(needle) !== -1;
}
function renderLibrary() {
var list = document.getElementById('mr-list');
if (!list) return;
var search = document.getElementById('mr-search');
var needle = String((search && search.value) || '').trim().toLowerCase();
var rows = library.filter(function (row) { return matches(row, needle); });
list.textContent = '';
if (!rows.length) {
var empty = document.createElement('p');
empty.style.cssText = 'margin:0;font-size:13px;color:var(--g400);';
// Two different situations, and saying "nothing yet" to someone whose
// search simply missed would be wrong.
empty.textContent = library.length
? 'Nothing matches "' + needle + '".'
: 'Nothing yet. Generate something above and it will appear here.';
list.appendChild(empty);
return;
}
rows.forEach(function (row) { list.appendChild(renderRow(row)); });
}
// The Modify picker is the same library, so it is rebuilt from it — and the
// selection survives a refresh, because losing it mid-sentence is maddening.
function syncModifyTargets() {
var select = document.getElementById('mr-modify-target');
if (!select) return;
var previous = select.value;
select.textContent = '';
if (!library.length) {
var none = document.createElement('option');
none.value = '';
none.textContent = 'Nothing to modify yet';
select.appendChild(none);
select.disabled = true;
return;
}
select.disabled = false;
library.forEach(function (row) {
var option = document.createElement('option');
option.value = String(row.id);
option.textContent = (row.title || 'Untitled') +
' \u2014 ' + (row.kind === 'article' ? 'article'
: row.has_deck === false ? 'presentation, plain text' : 'presentation');
select.appendChild(option);
});
if (previous && library.some(function (row) { return String(row.id) === previous; })) {
select.value = previous;
}
}
function runModify() {
var select = document.getElementById('mr-modify-target');
var box = document.getElementById('mr-modify-instructions');
var status = document.getElementById('mr-modify-status');
var btn = document.getElementById('btn-mr-modify');
function say(message, tone) {
if (!status) return;
status.textContent = message || '';
status.style.color = tone === 'bad' ? 'var(--red)' : tone === 'good' ? 'var(--green)' : 'var(--g600)';
}
var id = select && select.value;
var instructions = String((box && box.value) || '').trim();
if (!id) return say('Nothing to modify yet.', 'bad');
if (!instructions) return say('Say what to change.', 'bad');
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Applying'; }
clearResults();
say('Rewriting…');
fetch('/api/my-resources/' + encodeURIComponent(id) + '/refine', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
instructions: instructions,
useCorpus: ticked('mr-modify-corpus') ? 'true' : 'false',
withPubmed: ticked('mr-modify-pubmed') ? 'true' : 'false',
withWebSearch: ticked('mr-modify-web-search') ? 'true' : 'false',
withImages: ticked('mr-modify-images') ? 'true' : 'false',
model: (document.getElementById('mr-model') || {}).value || ''
})
})
.then(function (r) { return r.json(); })
.then(function (data) {
if (!data.success) throw new Error(data.error || 'Could not apply the changes');
// Same reporting as generating: what it was written from, what was
// searched, and any figure that came back.
var g = data.grounding || {};
// The model can return the document back unchanged. That is a failed
// modification, and saying "Applied" for it sent people off to download
// an identical file and conclude the feature was broken.
if (data.unchanged) {
say('The model returned it unchanged — nothing was modified. ' +
'Try naming the slide or section to change, and what to change about it.', 'bad');
} else {
// Whether it could see the slides is worth saying: it is the
// difference between "slide 4 looks crowded" being actionable and
// being guesswork, and it explains why this took longer.
say('Applied' +
(data.saw ? ', after looking at all ' + data.saw + ' slides' : '') +
(g.used ? ', using ' + g.count + ' library excerpt' + (g.count === 1 ? '' : 's') : '') +
'. Download it to see the result.', 'good');
}
reportSearches(data.searches);
showIllustrations(data.imageJobs || []);
reportImageFailures(data.imageFailures);
if (box && !data.unchanged) box.value = '';
loadLibrary();
})
.catch(function (err) { say(err.message, 'bad'); })
.finally(function () {
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-pen-to-square"></i> Apply changes'; }
});
}
// Built as elements rather than innerHTML: a title comes from a model, and
// this is the one place it reaches the page.
function renderRow(row) {
var wrap = document.createElement('div');
wrap.className = 'saved-enc-item';
wrap.style.cssText = 'padding:8px 12px;display:flex;align-items:center;gap:10px;flex-wrap:wrap;';
var body = document.createElement('div');
body.style.flex = '1';
body.style.minWidth = '180px';
var title = document.createElement('div');
title.style.cssText = 'font-weight:600;font-size:13px;';
title.textContent = row.title || 'Untitled';
// The modified time, when there is one. Showing only the creation time meant
// a modification that did apply still looked like nothing had happened, so
// the timestamp was the thing people used to conclude modify was broken.
var created = new Date(row.created_at);
var updated = row.updated_at ? new Date(row.updated_at) : created;
var edited = updated - created > 2000;
var meta = document.createElement('div');
meta.style.cssText = 'font-size:11px;color:var(--g500);';
meta.textContent = (row.kind === 'article' ? 'Article' : 'Presentation') +
' · ' + (edited ? 'modified ' + updated.toLocaleString()
: created.toLocaleString()) +
(row.grounded_count ? ' · ' + row.grounded_count + ' library excerpts' : ' · not grounded') +
// Only worth saying when it is the weaker kind. A deck is the normal case.
(row.kind !== 'article' && row.has_deck === false ? ' · plain text, no slide layout' : '');
body.appendChild(title);
body.appendChild(meta);
wrap.appendChild(body);
// An article has no slides, so offering PowerPoint would produce a deck of
// paragraphs. A presentation as Word is fine — prose absorbs slide content
// without overflowing anything.
var formats = row.kind === 'article' ? ['docx', 'pdf'] : ['pptx', 'docx', 'pdf'];
formats.forEach(function (format) {
var btn = document.createElement('button');
btn.className = 'btn-sm btn-ghost';
btn.type = 'button';
btn.dataset.download = String(row.id);
btn.dataset.format = format;
btn.textContent = format.toUpperCase();
btn.title = 'Download as ' + format.toUpperCase();
wrap.appendChild(btn);
});
var del = document.createElement('button');
del.className = 'btn-sm btn-ghost';
del.type = 'button';
del.dataset.remove = String(row.id);
del.style.color = 'var(--red)';
del.title = 'Delete';
var icon = document.createElement('i');
icon.className = 'fas fa-trash';
del.appendChild(icon);
wrap.appendChild(del);
return wrap;
}
function onRowClick(event) {
var download = event.target.closest && event.target.closest('[data-download]');
if (download) return downloadResource(download.dataset.download, download.dataset.format, download);
var remove = event.target.closest && event.target.closest('[data-remove]');
if (remove) {
showConfirm('Delete this resource? This cannot be undone.', function () {
fetch('/api/my-resources/' + encodeURIComponent(remove.dataset.remove), {
method: 'DELETE', headers: getAuthHeaders()
})
.then(function (r) { return r.json(); })
.then(function (d) {
if (!d.success) throw new Error(d.error || 'Could not delete');
loadLibrary();
})
.catch(function (err) { showToast(err.message, 'error'); });
}, { danger: true, confirmText: 'Delete' });
}
}
// Fetched rather than linked, because the download needs the auth header and
// an <a href> cannot carry one.
function downloadResource(id, format, btn) {
var original = btn.textContent;
btn.disabled = true;
btn.textContent = '...';
fetch('/api/my-resources/' + encodeURIComponent(id) + '/export?format=' + encodeURIComponent(format), {
headers: getAuthHeaders()
})
.then(function (r) {
if (!r.ok) return r.json().then(function (d) { throw new Error(d.error || 'Download failed'); });
var name = 'resource.' + format;
var disposition = r.headers.get('Content-Disposition') || '';
var match = disposition.match(/filename="([^"]+)"/);
if (match) name = match[1];
return r.blob().then(function (blob) { saveBlob(blob, name); });
})
.catch(function (err) { showToast(err.message, 'error'); })
.finally(function () { btn.disabled = false; btn.textContent = original; });
}
function saveBlob(blob, name) {
var url = URL.createObjectURL(blob);
var link = document.createElement('a');
link.href = url;
link.download = name;
document.body.appendChild(link);
link.click();
link.remove();
setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
}
}());