pediatric-ai-scribe-v3/public/js/myResources.js
Daniel fa5ed6c2b4
All checks were successful
Forgejo Docker Build / Root app tests (push) Successful in 57s
Forgejo Docker Build / Build Docker image (push) Successful in 18s
Forgejo Docker Build / End-to-end (browser) (push) Successful in 6s
feat: sharing is by link only — the share-with-everyone switch and route are gone
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 19:00:53 +02:00

1207 lines
56 KiB
JavaScript

// ============================================================
// 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();
acceptPendingShare();
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 : [];
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);
// And the same sample as pages, here, for a phone with no PowerPoint.
var look = box.querySelector('button') || document.createElement('button');
look.type = 'button';
look.className = 'btn-sm btn-ghost';
look.style.cssText = 'margin-left:10px;font-size:12px;';
look.innerHTML = '<i class="fas fa-eye"></i> Preview';
look.onclick = function () { openPreview('/api/my-resources/theme-sample/' + encodeURIComponent(select.value) + '/preview', 'Sample deck: ' + name); };
if (!look.parentNode) box.appendChild(look);
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 = '<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 || '',
details: (document.getElementById('mr-details') || {}).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.
// 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 <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 = [];
// Someone followed a share link. The token was set aside at page load (it
// survives the trip through the SSO); now that they are signed in and on
// this tab, say what it is and who from, and add it when they say yes.
function acceptPendingShare() {
var token = '';
try { token = localStorage.getItem('ped_pending_share') || ''; localStorage.removeItem('ped_pending_share'); } catch (e) {}
if (!token) return;
var base = '/api/my-resources/share-link/' + encodeURIComponent(token);
fetch(base, { headers: getAuthHeaders() })
.then(function (r) { return r.json(); })
.then(function (d) {
if (!d.success) throw new Error(d.error || 'That link is not valid any more');
if (d.own) { showToast('That is your own resource', 'info'); return; }
var what = (d.kind === 'article' ? 'the article' : 'the deck') + ' "' + d.title + '"';
showConfirm((d.sharedBy || 'Someone') + ' shared ' + what + ' with you. Add it to your resources?', function () {
fetch(base + '/accept', { method: 'POST', headers: getAuthHeaders() })
.then(function (r) { return r.json(); })
.then(function (a) { if (!a.success) throw new Error(a.error || 'Could not add it'); showToast('"' + a.title + '" is in your resources', 'success'); loadLibrary(); })
.catch(function (err) { showToast(err.message, 'error'); });
}, { confirmText: 'Add to my resources' });
})
.catch(function (err) { showToast(err.message, 'error'); });
}
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;
// Only what is mine can be modified; a share is read-only.
library.filter(function (row) { return row.owned !== false; }).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.className = 'mr-row-body';
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' : '');
if (row.owned === false && row.shared_by_name) {
var from = document.createElement('div');
from.style.cssText = 'font-size:11px;color:var(--blue);';
from.textContent = 'Shared by ' + row.shared_by_name;
body.appendChild(from);
}
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.
// Look first, download after. The preview is the file's own pages as
// pictures, so it shows exactly what the download would.
var formats = row.kind === 'article' ? ['docx', 'pdf'] : ['pptx', 'docx', 'pdf'];
var look = document.createElement('button');
look.className = 'btn-sm btn-ghost';
look.type = 'button';
look.dataset.preview = String(row.id);
look.innerHTML = '<i class="fas fa-eye"></i> Preview';
look.dataset.previewTitle = row.title || row.topic || 'Preview';
look.dataset.previewFormats = formats.join(',');
look.title = 'See every page here, without downloading';
wrap.appendChild(look);
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.
// Reading is what a share gives; changing is the author's.
if (row.owned === false) { return wrap; }
var share = document.createElement('button');
share.className = 'btn-sm btn-ghost';
share.type = 'button';
share.dataset.share = String(row.id);
share.innerHTML = '<i class="fas fa-user-plus"></i> Share';
share.title = 'Share with people on this site';
wrap.appendChild(share);
// The theme is chosen in the editor when the deck is made or modified;
// the list row only looks and downloads.
// 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 = 'Upload to your Nextcloud as ' + cloud.dataset.format.toUpperCase();
var cloudIcon = document.createElement('i');
cloudIcon.className = 'fas fa-cloud-arrow-up';
cloud.appendChild(cloudIcon);
cloud.appendChild(document.createTextNode(' Nextcloud'));
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;
}
// 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 shareBtn = event.target.closest && event.target.closest('[data-share]');
if (shareBtn) { openSharePanel(shareBtn.dataset.share, shareBtn.closest('.saved-enc-item')); return; }
var preview = event.target.closest && event.target.closest('[data-preview]');
if (preview) {
var pid = encodeURIComponent(preview.dataset.preview);
openPreview('/api/my-resources/' + pid + '/preview', preview.dataset.previewTitle || 'Preview', {
pdf: '/api/my-resources/' + pid + '/export?format=pdf',
downloads: (preview.dataset.previewFormats || '').split(',').filter(Boolean).map(function (f) {
return { label: f.toUpperCase(), url: '/api/my-resources/' + pid + '/export?format=' + f };
})
});
return;
}
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; });
}
// ── Sharing ──────────────────────────────────────────────
// Under the row: a switch for everyone, an email to add one person, and the
// list of people it is shared with, each with a way to withdraw.
function openSharePanel(id, rowEl) {
if (!rowEl) return;
var existing = rowEl.querySelector('.mr-share');
if (existing) { existing.remove(); return; }
var panel = document.createElement('div');
panel.className = 'mr-share';
panel.style.cssText = 'flex-basis:100%;margin-top:8px;padding:10px 12px;border:1px solid var(--g200);border-radius:8px;background:var(--g50);display:grid;gap:8px;font-size:13px;';
panel.innerHTML = '<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">' +
'<button type="button" class="btn-sm btn-primary mr-share-link"><i class="fas fa-link"></i> Copy a share link</button>' +
'<span class="mr-share-link-note" style="font-size:12px;color:var(--g500);">Send it however you like. Whoever follows it and accepts gets it in their resources; the link works for 30 days.</span>' +
'<input type="text" class="mr-share-link-url admin-control" readonly hidden style="flex-basis:100%;font-size:12px;"></div>' +
'<div class="mr-share-people" style="display:grid;gap:4px;"></div>';
rowEl.appendChild(panel);
var base = '/api/my-resources/' + encodeURIComponent(id) + '/shares';
function paint(state) {
var list = panel.querySelector('.mr-share-people');
list.innerHTML = '';
if (!state.people.length) {
var none = document.createElement('div'); none.style.cssText = 'font-size:12px;color:var(--g500);';
none.textContent = 'Nobody has accepted a link yet.';
list.appendChild(none); return;
}
state.people.forEach(function (p) {
var line = document.createElement('div');
line.style.cssText = 'display:flex;align-items:center;gap:8px;';
var who = document.createElement('span'); who.style.flex = '1';
who.textContent = (p.name ? p.name + ' · ' : '') + p.email;
var out = document.createElement('button'); out.type = 'button'; out.className = 'btn-sm btn-ghost';
out.textContent = 'Remove'; out.title = 'Withdraw the share';
out.addEventListener('click', function () {
fetch(base + '/' + encodeURIComponent(p.id), { method: 'DELETE', headers: getAuthHeaders() })
.then(function (r) { return r.json(); }).then(load).catch(function (e) { showToast(e.message, 'error'); });
});
line.appendChild(who); line.appendChild(out); list.appendChild(line);
});
}
function load() {
return fetch(base, { headers: getAuthHeaders() }).then(function (r) { return r.json(); })
// Painting the panel must not redraw the library: a redraw rebuilds the
// rows and the panel vanished the moment it opened. The row's own
// "shared with everyone" line is updated in place instead.
.then(function (d) {
if (!d.success) throw new Error(d.error || 'Could not load');
paint(d);
})
.catch(function (e) { showToast(e.message, 'error'); });
}
panel.querySelector('.mr-share-link').addEventListener('click', function () {
var out = panel.querySelector('.mr-share-link-url');
fetch('/api/my-resources/' + encodeURIComponent(id) + '/share-link', { method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ days: 30 }) })
.then(function (r) { return r.json(); })
.then(function (d) {
if (!d.success) throw new Error(d.error || 'Could not make a link');
out.value = d.url; out.hidden = false; out.select();
var copy = navigator.clipboard ? navigator.clipboard.writeText(d.url) : Promise.reject();
return copy.then(function () { showToast('Link copied — send it to whoever should have this', 'success'); })
.catch(function () { showToast('Link ready below — copy it by hand', 'info'); });
})
.catch(function (err) { showToast(err.message, 'error'); });
});
load();
}
// ── The preview gallery ──────────────────────────────────
// One overlay: the pages of a document, top to bottom, at the width of the
// screen. Each page is fetched with the auth header (an <img src> cannot
// carry one) and shown as it arrives, so the first page is up before the
// last is rendered. Pinch-zoom is the browser's own.
// One page at a time, the way a deck is shown: arrows, keys, a swipe on a
// phone, a counter, and the PDF itself on a second tab for anyone who wants
// to read it as a document or print it. Pages are fetched as they are
// reached, two ahead, and kept as object URLs until the viewer closes.
function openPreview(base, title, opts) {
opts = opts || {};
var old = document.getElementById('mr-preview');
if (old) old.remove();
var overlay = document.createElement('div');
overlay.id = 'mr-preview';
overlay.className = 'mr-preview';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.innerHTML =
'<div class="mr-preview-bar">' +
'<span class="mr-preview-title"></span>' +
'<div class="mr-preview-tabs" role="tablist">' +
'<button type="button" class="mr-preview-tab is-on" data-view="pages" role="tab">Pages</button>' +
(opts.pdf ? '<button type="button" class="mr-preview-tab" data-view="pdf" role="tab">PDF</button>' : '') +
'</div>' +
'<span class="mr-preview-downloads"></span>' +
'<button type="button" class="mr-preview-close" aria-label="Close preview">&#10005; Close</button>' +
'</div>' +
'<div class="mr-preview-stage" data-view="pages">' +
'<button type="button" class="mr-preview-nav" data-step="-1" aria-label="Previous page">&#8249;</button>' +
'<div class="mr-preview-frame"><p class="mr-preview-status">Rendering pages…</p></div>' +
'<button type="button" class="mr-preview-nav" data-step="1" aria-label="Next page">&#8250;</button>' +
'</div>' +
'<div class="mr-preview-foot"><span class="mr-preview-count" role="status"></span></div>';
overlay.querySelector('.mr-preview-title').textContent = title || 'Preview';
var downloadsEl = overlay.querySelector('.mr-preview-downloads');
(opts.downloads || []).forEach(function (d) {
var b = document.createElement('button');
b.type = 'button'; b.className = 'mr-preview-dl'; b.textContent = d.label; b.title = 'Download as ' + d.label;
b.addEventListener('click', function () { fetchToFile(d.url, (title || 'resource') + '.' + d.label.toLowerCase()); });
downloadsEl.appendChild(b);
});
document.body.appendChild(overlay);
document.body.classList.add('mr-preview-open');
var urls = [], pages = 0, current = 1, blobs = {}, pending = {}, view = 'pages';
var frame = overlay.querySelector('.mr-preview-frame');
var countEl = overlay.querySelector('.mr-preview-count');
var stage = overlay.querySelector('.mr-preview-stage');
var pdfFrame = null;
function close() {
overlay.remove();
document.body.classList.remove('mr-preview-open');
urls.forEach(function (u) { URL.revokeObjectURL(u); });
document.removeEventListener('keydown', onKey);
}
function onKey(e) {
if (e.key === 'Escape') close();
else if (view === 'pages' && (e.key === 'ArrowRight' || e.key === 'PageDown' || e.key === ' ')) { e.preventDefault(); go(current + 1); }
else if (view === 'pages' && (e.key === 'ArrowLeft' || e.key === 'PageUp')) { e.preventDefault(); go(current - 1); }
else if (view === 'pages' && e.key === 'Home') go(1);
else if (view === 'pages' && e.key === 'End') go(pages);
}
document.addEventListener('keydown', onKey);
overlay.querySelector('.mr-preview-close').addEventListener('click', close);
overlay.querySelectorAll('.mr-preview-nav').forEach(function (b) {
b.addEventListener('click', function () { go(current + Number(b.dataset.step)); });
});
// A swipe on a phone turns the page.
var touchX = null;
stage.addEventListener('touchstart', function (e) { touchX = e.touches[0].clientX; }, { passive: true });
stage.addEventListener('touchend', function (e) {
if (touchX === null || view !== 'pages') return;
var dx = e.changedTouches[0].clientX - touchX; touchX = null;
if (Math.abs(dx) > 50) go(current + (dx < 0 ? 1 : -1));
}, { passive: true });
function fetchPage(n) {
if (blobs[n] || pending[n] || n < 1 || n > pages) return Promise.resolve(blobs[n]);
pending[n] = fetch(base + '/' + n, { headers: getAuthHeaders() })
.then(function (r) { if (!r.ok) throw new Error('page ' + n); return r.blob(); })
.then(function (blob) { var u = URL.createObjectURL(blob); urls.push(u); blobs[n] = u; return u; })
.finally(function () { delete pending[n]; });
return pending[n];
}
function show(n) {
frame.innerHTML = '';
var img = document.createElement('img');
img.className = 'mr-preview-page';
img.alt = 'Page ' + n + ' of ' + pages;
img.draggable = false;
frame.appendChild(img);
fetchPage(n).then(function (u) { if (current === n && u) img.src = u; })
.catch(function () { if (current === n) frame.innerHTML = '<p class="mr-preview-status">Page ' + n + ' could not be shown</p>'; });
fetchPage(n + 1); fetchPage(n + 2);
countEl.textContent = 'Page ' + n + ' of ' + pages;
overlay.querySelectorAll('.mr-preview-nav').forEach(function (b) {
b.disabled = Number(b.dataset.step) < 0 ? n <= 1 : n >= pages;
});
}
function go(n) {
if (!pages) return;
n = Math.max(1, Math.min(pages, n));
if (n === current && frame.querySelector('img')) return;
current = n;
show(n);
}
function switchView(name) {
view = name;
overlay.querySelectorAll('.mr-preview-tab').forEach(function (t) { t.classList.toggle('is-on', t.dataset.view === name); t.setAttribute('aria-selected', String(t.dataset.view === name)); });
stage.dataset.view = name;
overlay.querySelector('.mr-preview-foot').hidden = name !== 'pages';
if (name === 'pdf') {
if (!pdfFrame) {
pdfFrame = document.createElement('iframe');
pdfFrame.className = 'mr-preview-pdf';
pdfFrame.title = (title || 'Preview') + ' as PDF';
stage.appendChild(pdfFrame);
fetch(opts.pdf, { headers: getAuthHeaders() })
.then(function (r) { if (!r.ok) throw new Error('The PDF could not be produced'); return r.blob(); })
.then(function (blob) { var u = URL.createObjectURL(new Blob([blob], { type: 'application/pdf' })); urls.push(u); pdfFrame.src = u; })
.catch(function (err) { pdfFrame.remove(); pdfFrame = null; frame.innerHTML = '<p class="mr-preview-status">' + err.message + '</p>'; });
}
}
}
overlay.querySelectorAll('.mr-preview-tab').forEach(function (t) { t.addEventListener('click', function () { switchView(t.dataset.view); }); });
fetch(base, { headers: getAuthHeaders() })
.then(function (r) { return r.json(); })
.then(function (d) {
if (!d.success) throw new Error(d.error || 'Could not render a preview');
pages = d.pages;
show(1);
})
.catch(function (err) {
frame.innerHTML = '';
var p = document.createElement('p');
p.className = 'mr-preview-status';
p.textContent = err.message;
frame.appendChild(p);
});
}
function fetchToFile(url, name) {
fetch(url, { headers: getAuthHeaders() })
.then(function (r) { if (!r.ok) throw new Error('Download failed'); return r.blob(); })
.then(function (blob) { saveBlob(blob, name); })
.catch(function (err) { status(err.message); });
}
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);
}
}());