Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 53s
Forgejo Docker Build / Root app tests (push) Successful in 54s
Forgejo Android APK / Build signed APK (push) Successful in 1m56s
Forgejo Docker Build / Build Docker image (push) Successful in 22s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
existingDeck was declared below the branch that reads it, so `var` hoisting made it undefined there. With illustration on, a deck modification therefore appended the *markdown* instruction — "Returning the markdown is still required; a tool call is not a substitute for it" — to a prompt whose body asked for deck JSON. The model was told to produce two different artifacts in one reply, the reply parsed as neither, and deckBuild.parse returned null, so the handler answered 502 "That change could not be applied." Moved the declaration above its first reader. Same class of fault as the savedFigureIds one, in the same file. Two things made it hard to see, both fixed: - The library row read created_at, so a modification that did apply left the visible timestamp on the generation time. That timestamp is what led to "modification doesn't work" — it was the only signal available, and it was reading the wrong column. Rows now show the modified time when there is one. - A model can also return the document back unchanged. That was logged server-side and answered "Applied. Download it to see the result", which sent people to download an identical file. The response now carries `unchanged` and the UI says so, keeping the instruction in the box so it can be reworded. Also surfaced has_deck on the library list: 28 of 38 stored presentations have no deck and go through the weaker flat-markdown path, and nothing in the UI distinguished them. They now read "plain text, no slide layout". test/my-resources-refine.test.js runs the handler rather than reading it, since all three faults were invisible to source reading. Verified against a mutation: putting the declaration back where it was fails test 1 and nothing else. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
502 lines
22 KiB
JavaScript
502 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);
|
|
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 {
|
|
say('Applied' + (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);
|
|
}
|
|
}());
|