// ============================================================ // MY RESOURCES // A person's own generated teaching material. // // Private to whoever made it, and needing 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); if (list) list.addEventListener('change', onRowChange); // 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 : []; themeCatalogue = themes; nextcloudConnected = Boolean(data.nextcloudConnected); 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. // A sample deck to download: every layout, filler text, this theme. // // Not a picture of one slide, and not a picture of their deck. The question a // theme picker answers is what the thing will look like, and the file itself // answers it better than any screenshot — opened in PowerPoint, at the size it // will be shown, with the fonts actually substituted the way they will be. // // An ordinary link, so it is there whether or not anything renders. function showThemeSample() { var select = document.getElementById('mr-theme'); var box = document.getElementById('mr-theme-preview'); if (!select || !select.value || !box) return; var name = (select.options[select.selectedIndex] || {}).text || 'this theme'; var link = box.querySelector('a') || document.createElement('a'); link.href = '/api/my-resources/theme-sample/' + encodeURIComponent(select.value); link.textContent = 'Download a sample deck in ' + name; link.title = 'Every slide layout, with placeholder text, as a PowerPoint file'; link.style.cssText = 'font-size:12px;color:var(--blue);text-decoration:none;'; if (!link.parentNode) box.appendChild(link); box.hidden = false; } // 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 || '') : ''; showThemeSample(); } 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 = ' 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 = ' 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. // The catalogue, once, shared by the generate form and every library row. var themeCatalogue = []; var nextcloudConnected = false; 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 would work in a // browser carrying the session cookie and fail on a mobile client, whose // session is a bearer token an 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 = ' 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 = ' 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); }); // Re-skinning is a column write, not a regeneration: the next download // renders from the same deck in different colours. Only for a row that has // a deck — flat markdown has no palette to change. if (row.kind !== 'article' && row.has_deck !== false && themeCatalogue.length > 1) { var theme = document.createElement('select'); theme.className = 'btn-sm'; theme.dataset.theme = String(row.id); theme.title = 'Theme — changes the colours of the next download'; theme.style.cssText = 'font-size:11px;padding:2px 4px;border:1px solid var(--g200);border-radius:6px;background:var(--white,#fff);max-width:130px;'; themeCatalogue.forEach(function (t) { var option = document.createElement('option'); option.value = t.id; option.textContent = t.name; theme.appendChild(option); }); theme.value = row.theme || themeCatalogue[0].id; wrap.appendChild(theme); } // Send the rendered file to the owner's own Nextcloud. Offered only when // there is a Nextcloud to send it to. if (nextcloudConnected) { var cloud = document.createElement('button'); cloud.className = 'btn-sm btn-ghost'; cloud.type = 'button'; cloud.dataset.nextcloud = String(row.id); cloud.dataset.format = row.kind === 'article' ? 'docx' : 'pptx'; cloud.title = 'Save to my Nextcloud as ' + cloud.dataset.format.toUpperCase(); var cloudIcon = document.createElement('i'); cloudIcon.className = 'fas fa-cloud-arrow-up'; cloud.appendChild(cloudIcon); wrap.appendChild(cloud); } 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; } // Delegated like the download and delete buttons, so rows re-rendered by a // refresh do not need rebinding. function onRowChange(event) { var select = event.target.closest && event.target.closest('[data-theme]'); if (!select) return; var previous = select.dataset.previous || ''; select.disabled = true; fetch('/api/my-resources/' + encodeURIComponent(select.dataset.theme) + '/theme', { method: 'PUT', headers: getAuthHeaders(), body: JSON.stringify({ theme: select.value }) }) .then(function (r) { return r.json(); }) .then(function (data) { if (!data.success) throw new Error(data.error || 'Could not change the theme'); select.dataset.previous = select.value; if (typeof showToast === 'function') showToast('Theme changed — download it again to see it.', 'success'); }) .catch(function (err) { // Put the control back where it was: a select showing a theme the // resource does not have is worse than no feedback. if (previous) select.value = previous; if (typeof showToast === 'function') showToast(err.message, 'error'); }) .finally(function () { select.disabled = false; }); } // Rendered server-side and pushed straight to their storage — the file never // travels through this browser, which is the point: it is a copy in their own // Nextcloud, not a download they then have to file away. function sendToNextcloud(id, format, btn) { var icon = btn.querySelector('i'); var was = icon ? icon.className : ''; btn.disabled = true; if (icon) icon.className = 'fas fa-spinner fa-spin'; fetch('/api/my-resources/' + encodeURIComponent(id) + '/to-nextcloud', { method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ format: format }) }) .then(function (r) { return r.json(); }) .then(function (data) { if (!data.success) throw new Error(data.error || 'Could not send it'); if (icon) icon.className = 'fas fa-cloud-arrow-up'; if (typeof showToast === 'function') showToast('Saved to your Nextcloud: ' + data.path, 'success'); }) .catch(function (err) { if (icon) icon.className = was; if (typeof showToast === 'function') showToast(err.message, 'error'); }) .finally(function () { btn.disabled = false; }); } function onRowClick(event) { var cloud = event.target.closest && event.target.closest('[data-nextcloud]'); if (cloud) return sendToNextcloud(cloud.dataset.nextcloud, cloud.dataset.format, cloud); 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 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); } }());