Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 1m0s
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Android APK / Build signed APK (push) Successful in 2m13s
Forgejo Docker Build / Build Docker image (push) Successful in 24s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The model editing a deck could not see it, which made most of what people actually ask for unanswerable: "that slide is too crowded", "the diagram is in the wrong place", "this one looks empty" are facts about the rendered page, not about the JSON. When a vision model is configured, modifying now renders the current deck — with its figures, unlike the review pass, which runs while they are still being drawn — and hands the model one image per slide alongside the JSON. Same pipeline as review, reused rather than reimplemented: pptx, Gotenberg, PDF, pdftoppm, capped at MAX_SLIDES. The vision model then does the editing, which is a second and separately measured benefit. On a real 20-slide deck, ds-deepseek-v4-flash returned the deck unchanged for "make it better" — the echo reported yesterday — while openrouter-gemini-3.8-flash applied it. So the stronger model fixes the echo even without sight. A model the author picks explicitly still wins over both. The result is rendered and reviewed again. Generation-only was the old rule, on the reasoning that refining is a text edit; it is not. The edit is made against how the deck looked before it, so a slide that gains two bullets only overflows once it is rendered again. The reviewer may reposition but is held to the same words, so a verification pass cannot quietly undo what was just asked for. Whether an instruction achieved anything is judged on the model's edit, before the reviewer runs, or a reviewer nudging a slide into two columns would mask an instruction that did nothing. Sight is an upgrade, never a dependency: no vision model, Gotenberg down, a render that fails — each falls through to editing blind, and a test covers each of those paths. Verified against two mutations: keeping the text model when images are attached, and dropping the verification pass, each fail exactly one test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
516 lines
22 KiB
JavaScript
516 lines
22 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;
|
|
|
|
document.addEventListener('tabChanged', function (e) {
|
|
if (!e.detail || e.detail.tab !== 'myresources') return;
|
|
if (!inited) { init(); inited = true; }
|
|
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');
|
|
if (refresh) refresh.addEventListener('click', loadLibrary);
|
|
|
|
// 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) {
|
|
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 (modelRow) modelRow.hidden = models.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)';
|
|
}
|
|
|
|
function runGenerate() {
|
|
var topic = (document.getElementById('mr-topic') || {}).value || '';
|
|
if (!topic.trim()) { status('Enter a topic first.', 'bad'); return; }
|
|
|
|
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 || '',
|
|
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 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'; }
|
|
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);
|
|
}
|
|
}());
|