Compare commits
6 commits
2f211d748f
...
d524966f00
| Author | SHA1 | Date | |
|---|---|---|---|
| d524966f00 | |||
| b2c16063c4 | |||
| b0d0ff13cd | |||
| bbbf5326e7 | |||
| 41d4e59899 | |||
| cfb2fab401 |
18 changed files with 633 additions and 424 deletions
|
|
@ -63,6 +63,7 @@ concat_files \
|
|||
"../shared/icons.js" \
|
||||
"../shared/zddc-source.js" \
|
||||
"js/init.js" \
|
||||
"js/util.js" \
|
||||
"js/loader.js" \
|
||||
"js/tree.js" \
|
||||
"js/preview.js" \
|
||||
|
|
|
|||
|
|
@ -25,28 +25,10 @@
|
|||
if (t) t(msg, level || 'info');
|
||||
}
|
||||
|
||||
function isoDateToday() {
|
||||
var d = new Date();
|
||||
return d.getFullYear()
|
||||
+ '-' + ('0' + (d.getMonth() + 1)).slice(-2)
|
||||
+ '-' + ('0' + d.getDate()).slice(-2);
|
||||
}
|
||||
function isoDatePlus(days) {
|
||||
var d = new Date();
|
||||
d.setDate(d.getDate() + days);
|
||||
return d.getFullYear()
|
||||
+ '-' + ('0' + (d.getMonth() + 1)).slice(-2)
|
||||
+ '-' + ('0' + d.getDate()).slice(-2);
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, function (c) {
|
||||
return ({
|
||||
'&': '&', '<': '<', '>': '>',
|
||||
'"': '"', "'": '''
|
||||
})[c];
|
||||
});
|
||||
}
|
||||
var util = window.app.modules.util;
|
||||
var escapeHtml = util.escapeHtml;
|
||||
var isoDateToday = util.isoDateToday;
|
||||
var isoDatePlus = util.isoDatePlus;
|
||||
|
||||
// Is this node a direct child of an incoming/ canonical folder
|
||||
// AND a well-formed transmittal folder? The first half is the
|
||||
|
|
@ -96,19 +78,7 @@
|
|||
return out;
|
||||
}
|
||||
|
||||
function fetchPeopleSuggestions() {
|
||||
return fetch('/.profile/access', {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
credentials: 'same-origin'
|
||||
}).then(function (r) {
|
||||
if (!r.ok) return [];
|
||||
return r.json().then(function (data) {
|
||||
var out = [];
|
||||
if (data && data.email) out.push(data.email);
|
||||
return out;
|
||||
});
|
||||
}).catch(function () { return []; });
|
||||
}
|
||||
var fetchPeopleSuggestions = util.fetchAccessEmails;
|
||||
|
||||
function openForm(initial) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
|
|
@ -189,7 +159,15 @@
|
|||
});
|
||||
});
|
||||
|
||||
// Bind the Escape handler once and remove it in close() — every
|
||||
// dismissal path (cancel, overlay-click, submit, Escape) routes
|
||||
// through close(), so the document listener can't outlive the
|
||||
// modal.
|
||||
function onKeydown(e) {
|
||||
if (e.key === 'Escape') { close(); reject(new Error('cancelled')); }
|
||||
}
|
||||
function close() {
|
||||
document.removeEventListener('keydown', onKeydown);
|
||||
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
||||
}
|
||||
box.querySelector('#acc-cancel').addEventListener('click', function () {
|
||||
|
|
@ -198,12 +176,7 @@
|
|||
overlay.addEventListener('click', function (e) {
|
||||
if (e.target === overlay) { close(); reject(new Error('cancelled')); }
|
||||
});
|
||||
document.addEventListener('keydown', function escHandler(e) {
|
||||
if (e.key === 'Escape') {
|
||||
document.removeEventListener('keydown', escHandler);
|
||||
close(); reject(new Error('cancelled'));
|
||||
}
|
||||
});
|
||||
document.addEventListener('keydown', onKeydown);
|
||||
|
||||
box.querySelector('#acc-submit').addEventListener('click', function () {
|
||||
var values = {
|
||||
|
|
@ -227,9 +200,7 @@
|
|||
});
|
||||
}
|
||||
|
||||
function quote(s) {
|
||||
return '"' + String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
|
||||
}
|
||||
var quote = util.yamlQuote;
|
||||
function buildBody(values) {
|
||||
var lines = ['received_date: ' + values.receivedDate];
|
||||
if (values.setupPlanReview) {
|
||||
|
|
@ -243,7 +214,10 @@
|
|||
return lines.join('\n');
|
||||
}
|
||||
|
||||
var busy = false;
|
||||
|
||||
async function invoke(node) {
|
||||
if (busy) return;
|
||||
var tree = window.app.modules.tree;
|
||||
if (!tree) return;
|
||||
var url = tree.pathFor(node);
|
||||
|
|
@ -275,6 +249,8 @@
|
|||
return;
|
||||
}
|
||||
|
||||
busy = true;
|
||||
try {
|
||||
status('Accept Transmittal — submitting…');
|
||||
var resp;
|
||||
try {
|
||||
|
|
@ -302,7 +278,14 @@
|
|||
+ (data && data.received_path ? data.received_path : 'received/');
|
||||
if (data && data.merged) msg += ' (merged with existing tracking)';
|
||||
if (data && data.plan_review) msg += ' · Plan Review scaffolded';
|
||||
status(msg + ' — reload to see the move.', 'success');
|
||||
status(msg, 'success');
|
||||
// Refresh the incoming/ listing so the now-moved folder drops out
|
||||
// of the tree — the stale entry was the main re-trigger hazard.
|
||||
var ev = window.app.modules.events;
|
||||
if (ev && typeof ev.refreshListing === 'function') ev.refreshListing();
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
window.app.modules.acceptTransmittal = {
|
||||
|
|
|
|||
|
|
@ -8,17 +8,6 @@
|
|||
var tree = window.app.modules.tree;
|
||||
var events = window.app.modules.events;
|
||||
|
||||
// Virtual canonical folder injection used to live here (browse
|
||||
// appended archive/working/staging/reviewing entries at a project
|
||||
// root when missing). zddc-server now emits them in the listing
|
||||
// directly so the .zddc `display:` map can override their labels
|
||||
// the same as real entries. This pass-through stub keeps the
|
||||
// events.js rescope contract intact without doing any merging.
|
||||
function passThroughEntries(entries) { return entries; }
|
||||
|
||||
// Expose for events.js's client-side rescope on dblclick.
|
||||
window.app.modules.augmentRoot = passThroughEntries;
|
||||
|
||||
// Walk a `?file=` path segment-by-segment from the current root.
|
||||
// Each non-leaf segment is matched against the parent's children
|
||||
// by name; if found and it's a folder, expand+load it (so its
|
||||
|
|
@ -132,15 +121,26 @@
|
|||
var popQS = new URLSearchParams(location.search);
|
||||
if (popQS.get('hidden') === '1') window.app.state.showHidden = true;
|
||||
else window.app.state.showHidden = false;
|
||||
// Join the shared nav token: rapid back/forward (or back/forward
|
||||
// while an in-tool rescope is mid-flight) must not apply a stale
|
||||
// listing on top of a newer one.
|
||||
var seq = events.beginNav ? events.beginNav() : 0;
|
||||
try {
|
||||
var es = await loader.fetchServerChildren(path);
|
||||
if (events.isCurrentNav && !events.isCurrentNav(seq)) return;
|
||||
window.app.state.currentPath = path;
|
||||
window.app.state.selectedId = null;
|
||||
window.app.state.lastPreviewedNodeId = null;
|
||||
tree.setRoot(es);
|
||||
tree.render();
|
||||
// Route through clearPreview so a live editor is disposed
|
||||
// (not leaked) when back/forward swaps scope.
|
||||
var pmod = window.app.modules.preview;
|
||||
if (pmod && pmod.clearPreview) pmod.clearPreview();
|
||||
else {
|
||||
var previewBody = document.getElementById('previewBody');
|
||||
if (previewBody) previewBody.innerHTML = '';
|
||||
}
|
||||
var previewTitle = document.getElementById('previewTitle');
|
||||
if (previewTitle) previewTitle.textContent = 'No file selected';
|
||||
// Reapply view mode for the new URL (incoming/ → grid, etc).
|
||||
|
|
|
|||
|
|
@ -18,17 +18,9 @@
|
|||
var t = window.zddc && window.zddc.toast;
|
||||
if (t) t(msg, level || 'info');
|
||||
}
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, function (c) {
|
||||
return ({ '&':'&','<':'<','>':'>','"':'"',"'":''' })[c];
|
||||
});
|
||||
}
|
||||
function isoDateToday() {
|
||||
var d = new Date();
|
||||
return d.getFullYear()
|
||||
+ '-' + ('0' + (d.getMonth() + 1)).slice(-2)
|
||||
+ '-' + ('0' + d.getDate()).slice(-2);
|
||||
}
|
||||
var util = window.app.modules.util;
|
||||
var escapeHtml = util.escapeHtml;
|
||||
var isoDateToday = util.isoDateToday;
|
||||
|
||||
function openForm() {
|
||||
return new Promise(function (resolve, reject) {
|
||||
|
|
@ -78,19 +70,22 @@
|
|||
input.addEventListener('input', revalidate);
|
||||
revalidate();
|
||||
|
||||
function close() { if (overlay.parentNode) overlay.parentNode.removeChild(overlay); }
|
||||
// Escape handler bound once, removed in close() so it can't
|
||||
// outlive a modal dismissed via cancel / overlay-click / submit.
|
||||
function onKeydown(e) {
|
||||
if (e.key === 'Escape') { close(); reject(new Error('cancelled')); }
|
||||
}
|
||||
function close() {
|
||||
document.removeEventListener('keydown', onKeydown);
|
||||
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
||||
}
|
||||
box.querySelector('#ct-cancel').addEventListener('click', function () {
|
||||
close(); reject(new Error('cancelled'));
|
||||
});
|
||||
overlay.addEventListener('click', function (e) {
|
||||
if (e.target === overlay) { close(); reject(new Error('cancelled')); }
|
||||
});
|
||||
document.addEventListener('keydown', function escHandler(e) {
|
||||
if (e.key === 'Escape') {
|
||||
document.removeEventListener('keydown', escHandler);
|
||||
close(); reject(new Error('cancelled'));
|
||||
}
|
||||
});
|
||||
document.addEventListener('keydown', onKeydown);
|
||||
submit.addEventListener('click', function () {
|
||||
var v = input.value.trim();
|
||||
var parsed = window.zddc.parseFolder(v);
|
||||
|
|
|
|||
|
|
@ -44,6 +44,12 @@
|
|||
}
|
||||
|
||||
// Trigger a download from a same-origin server URL via Content-Disposition.
|
||||
// NOTE: an <a download> click is fire-and-forget — a server error
|
||||
// (401/403/404/5xx) can't be observed here, so failures surface only as
|
||||
// the browser's own download error, not a toast. This is deliberate: the
|
||||
// folder path points at zddc-server's streamed virtual "<dir>.zip"
|
||||
// endpoint, and buffering it through fetch() to make errors catchable
|
||||
// would defeat the streaming (the archive can be arbitrarily large).
|
||||
function downloadUrl(filename, url) {
|
||||
var a = document.createElement('a');
|
||||
a.href = url;
|
||||
|
|
@ -97,9 +103,12 @@
|
|||
var zip = new window.JSZip();
|
||||
for (var i = 0; i < files.length; i++) {
|
||||
ev.statusInfo('Zipping ' + rootHandle.name + '… (' + (i + 1) + '/' + files.length + ')');
|
||||
// Hand JSZip the File (a Blob, backed by disk) rather than
|
||||
// pre-reading every file's arrayBuffer — otherwise the whole
|
||||
// tree's raw bytes sit in the JS heap at once before zipping.
|
||||
// JSZip reads each Blob lazily during generateAsync.
|
||||
var f = await files[i].handle.getFile();
|
||||
var buf = await f.arrayBuffer();
|
||||
zip.file(rootHandle.name + '/' + files[i].relPath, buf);
|
||||
zip.file(rootHandle.name + '/' + files[i].relPath, f);
|
||||
}
|
||||
ev.statusInfo('Generating ' + rootHandle.name + '.zip…');
|
||||
var blob = await zip.generateAsync({ type: 'blob' });
|
||||
|
|
|
|||
|
|
@ -133,6 +133,16 @@
|
|||
} catch (_e) { /* private browsing edge cases */ }
|
||||
}
|
||||
|
||||
// Navigation sequence token. Every async flow that ends by replacing
|
||||
// the tree root (refresh, rescope, reload, back/forward popstate)
|
||||
// captures a token before its fetch and bails if a newer navigation
|
||||
// has started by the time it resolves — otherwise a slow listing can
|
||||
// land on top of a newer one and leave the tree out of sync with
|
||||
// state.currentPath / the URL bar.
|
||||
var navSeq = 0;
|
||||
function beginNav() { return ++navSeq; }
|
||||
function isCurrentNav(seq) { return seq === navSeq; }
|
||||
|
||||
async function refreshListing() {
|
||||
// Snapshot expanded paths + selection BEFORE setRoot clears the
|
||||
// tree, then re-apply after the new root is in place. Keeps
|
||||
|
|
@ -141,6 +151,7 @@
|
|||
// a refresh — including the auto-refresh triggered by the
|
||||
// "Show hidden files" toggle.
|
||||
var snap = tree.snapshotState();
|
||||
var seq = beginNav();
|
||||
if (state.source === 'server') {
|
||||
var raw;
|
||||
try {
|
||||
|
|
@ -149,8 +160,10 @@
|
|||
statusError('Refresh failed: ' + e.message);
|
||||
return;
|
||||
}
|
||||
if (!isCurrentNav(seq)) return;
|
||||
tree.setRoot(raw);
|
||||
await tree.restoreState(snap);
|
||||
if (!isCurrentNav(seq)) return;
|
||||
tree.render();
|
||||
statusInfo('Refreshed (' + raw.length + ' item'
|
||||
+ (raw.length === 1 ? '' : 's') + ')');
|
||||
|
|
@ -162,8 +175,10 @@
|
|||
statusError('Refresh failed: ' + e.message);
|
||||
return;
|
||||
}
|
||||
if (!isCurrentNav(seq)) return;
|
||||
tree.setRoot(raw2);
|
||||
await tree.restoreState(snap);
|
||||
if (!isCurrentNav(seq)) return;
|
||||
tree.render();
|
||||
statusInfo('Refreshed');
|
||||
}
|
||||
|
|
@ -449,7 +464,10 @@
|
|||
// selection-only; their preview is "expand to see inside".
|
||||
if (nextNode && !nextNode.isDir && !nextNode.isZip
|
||||
&& previewModule) {
|
||||
previewModule.showFilePreview(nextNode);
|
||||
// auto:true — keyboard cursor walking the tree. If an
|
||||
// editor has unsaved edits, the preview module leaves it
|
||||
// in place rather than prompting on every keystroke.
|
||||
previewModule.showFilePreview(nextNode, { auto: true });
|
||||
state.lastPreviewedNodeId = nextId;
|
||||
}
|
||||
// Scroll the now-selected row into view.
|
||||
|
|
@ -660,11 +678,7 @@
|
|||
return parentDir;
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, function (c) {
|
||||
return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c];
|
||||
});
|
||||
}
|
||||
var escapeHtml = window.app.modules.util.escapeHtml;
|
||||
|
||||
// Valid party folder name — mirrors zddc.ValidPartyName server-side
|
||||
// (^[A-Za-z0-9][A-Za-z0-9.-]*$).
|
||||
|
|
@ -869,14 +883,20 @@
|
|||
var loader = window.app.modules.loader;
|
||||
if (!loader) return;
|
||||
if (!dirPath.endsWith('/')) dirPath += '/';
|
||||
var seq = beginNav();
|
||||
// Root-scope reload — refresh the visible top-level listing.
|
||||
if (dirPath === state.currentPath) {
|
||||
var es;
|
||||
try {
|
||||
var es = state.source === 'server'
|
||||
es = state.source === 'server'
|
||||
? await loader.fetchServerChildren(dirPath)
|
||||
: (state.rootHandle ? await loader.fetchFsChildren(state.rootHandle) : []);
|
||||
} catch (e) {
|
||||
statusError('Reload failed: ' + (e.message || e));
|
||||
return;
|
||||
}
|
||||
if (!isCurrentNav(seq)) return;
|
||||
tree.setRoot(es);
|
||||
} catch (_e) { /* swallow */ }
|
||||
tree.render();
|
||||
return;
|
||||
}
|
||||
|
|
@ -888,13 +908,18 @@
|
|||
if (tree.pathFor(n).replace(/\/$/, '') === noSlash) hit = n;
|
||||
});
|
||||
if (hit) {
|
||||
var raw;
|
||||
try {
|
||||
var raw = state.source === 'server'
|
||||
raw = state.source === 'server'
|
||||
? await loader.fetchServerChildren(dirPath)
|
||||
: (hit.handle ? await loader.fetchFsChildren(hit.handle) : []);
|
||||
} catch (e) {
|
||||
statusError('Reload failed: ' + (e.message || e));
|
||||
return;
|
||||
}
|
||||
if (!isCurrentNav(seq)) return;
|
||||
tree.setChildren(hit.id, raw);
|
||||
hit.expanded = true;
|
||||
} catch (_e) { /* swallow */ }
|
||||
tree.render();
|
||||
}
|
||||
}
|
||||
|
|
@ -1371,8 +1396,7 @@
|
|||
}
|
||||
if (state.source === 'fs') {
|
||||
if (!node.handle || node.handle.kind !== 'directory') return;
|
||||
state.rootHandle = node.handle;
|
||||
state.currentPath = node.handle.name + '/';
|
||||
var seq = beginNav();
|
||||
var raw;
|
||||
try {
|
||||
raw = await loader.fetchFsChildren(node.handle);
|
||||
|
|
@ -1380,6 +1404,12 @@
|
|||
statusError('Failed to enter ' + node.name + ': ' + e.message);
|
||||
return;
|
||||
}
|
||||
// Mutate scope state only after the fetch succeeds and only if
|
||||
// we're still the latest navigation — a bail here leaves the
|
||||
// previous scope intact rather than half-swapped.
|
||||
if (!isCurrentNav(seq)) return;
|
||||
state.rootHandle = node.handle;
|
||||
state.currentPath = node.handle.name + '/';
|
||||
tree.setRoot(raw);
|
||||
tree.render();
|
||||
statusInfo('Entered ' + node.name);
|
||||
|
|
@ -1390,6 +1420,7 @@
|
|||
// history.pushState, fetches the new directory listing, and
|
||||
// re-renders the tree from scratch. Page DOES NOT reload.
|
||||
async function rescopeServer(url, displayName) {
|
||||
var seq = beginNav();
|
||||
var entries;
|
||||
try {
|
||||
entries = await loader.fetchServerChildren(url);
|
||||
|
|
@ -1397,6 +1428,10 @@
|
|||
statusError('Failed to enter ' + displayName + ': ' + (e.message || e));
|
||||
return;
|
||||
}
|
||||
// A newer navigation (another dblclick, a refresh, back/forward)
|
||||
// started while this listing was in flight — drop this result so we
|
||||
// don't pushState/setRoot on top of it.
|
||||
if (!isCurrentNav(seq)) return;
|
||||
state.currentPath = url;
|
||||
// Selection / preview belong to the old scope; clear them so
|
||||
// the new root doesn't carry stale highlight state.
|
||||
|
|
@ -1408,9 +1443,14 @@
|
|||
tree.setRoot(entries);
|
||||
tree.render();
|
||||
// Reset the preview pane so the user sees an "empty selection"
|
||||
// state at the new scope instead of the previous file.
|
||||
// state at the new scope instead of the previous file. Route
|
||||
// through clearPreview so a live editor is disposed (not leaked).
|
||||
var pmod = previewMod();
|
||||
if (pmod && pmod.clearPreview) pmod.clearPreview();
|
||||
else {
|
||||
var previewBody = document.getElementById('previewBody');
|
||||
if (previewBody) previewBody.innerHTML = '';
|
||||
}
|
||||
var previewTitle = document.getElementById('previewTitle');
|
||||
if (previewTitle) previewTitle.textContent = 'No file selected';
|
||||
var previewMeta = document.getElementById('previewMeta');
|
||||
|
|
@ -1439,6 +1479,16 @@
|
|||
statusInfo: statusInfo,
|
||||
statusClear: statusClear,
|
||||
showBrowseRoot: showBrowseRoot,
|
||||
applyResolvedViewMode: applyResolvedViewMode
|
||||
applyResolvedViewMode: applyResolvedViewMode,
|
||||
// Re-fetch + re-render the current listing (restoring expansion +
|
||||
// selection). Workflow modules call this after a move/accept so the
|
||||
// tree reflects the change without a manual reload. upload.js already
|
||||
// depends on it being present.
|
||||
refreshListing: refreshListing,
|
||||
// Shared navigation-sequence token so the popstate handler (app.js)
|
||||
// can't race the in-tool navigations. beginNav() claims the latest
|
||||
// token; isCurrentNav(seq) reports whether it's still latest.
|
||||
beginNav: beginNav,
|
||||
isCurrentNav: isCurrentNav
|
||||
};
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -16,11 +16,7 @@
|
|||
(function () {
|
||||
'use strict';
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s == null ? '' : s)
|
||||
.replace(/&/g, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
var escapeHtml = window.app.modules.util.escapeHtml;
|
||||
|
||||
function toast(msg, kind) {
|
||||
if (window.zddc && typeof window.zddc.toast === 'function') {
|
||||
|
|
@ -40,12 +36,7 @@
|
|||
return d.toLocaleString();
|
||||
}
|
||||
|
||||
function fmtBytes(n) {
|
||||
if (n == null) return '';
|
||||
if (n < 1024) return n + ' B';
|
||||
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
|
||||
return (n / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
}
|
||||
var fmtBytes = window.app.modules.util.fmtSize;
|
||||
|
||||
// Can the principal write (restore) to this file? Mirrors the
|
||||
// events.js Rename/Delete gating: verbs===undefined means a non-zddc
|
||||
|
|
@ -346,6 +337,10 @@
|
|||
if (!confirm('Restore the version from ' + fmtTime(ent.ts) + '?\nThis is saved as a new version — nothing is lost.')) {
|
||||
return;
|
||||
}
|
||||
// The restore itself (the PUT) is the operation that can "fail".
|
||||
// Keep it in its own try so a later error while refreshing the UI
|
||||
// can't surface a misleading "Restore failed" after the restore has
|
||||
// already been persisted.
|
||||
try {
|
||||
var text = await fetchVersion(node, ent.id);
|
||||
var resp = await fetch(node.url, {
|
||||
|
|
@ -355,18 +350,22 @@
|
|||
body: text
|
||||
});
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||
} catch (e) {
|
||||
toast('Restore failed: ' + (e.message || e), 'error');
|
||||
return;
|
||||
}
|
||||
toast('Restored version from ' + fmtTime(ent.ts), 'success');
|
||||
// Reflect the new head: refetch the list.
|
||||
// Best-effort UI refresh — the restore already succeeded, so a
|
||||
// failure here is logged but never reported as a restore failure.
|
||||
try {
|
||||
var entries = await fetchList(node);
|
||||
renderList(modal, node, entries);
|
||||
// If the file is open in the preview pane, reload it.
|
||||
var preview = window.app && window.app.modules && window.app.modules.preview;
|
||||
if (preview && typeof preview.showFilePreview === 'function') {
|
||||
try { preview.showFilePreview(node); } catch (_e) { /* best effort */ }
|
||||
}
|
||||
} catch (e) {
|
||||
toast('Restore failed: ' + (e.message || e), 'error');
|
||||
preview.showFilePreview(node);
|
||||
}
|
||||
} catch (_e) { /* refresh is best-effort; restore is done */ }
|
||||
}
|
||||
|
||||
// ── Entry point ─────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -58,22 +58,10 @@
|
|||
currentRow = null;
|
||||
}
|
||||
|
||||
// ── Formatting (kept local so this module is self-contained) ──
|
||||
// ── Formatting ──
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function fmtSize(bytes) {
|
||||
if (bytes == null) return '';
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
if (bytes < 1024 * 1024 * 1024) {
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
}
|
||||
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
|
||||
}
|
||||
var escapeHtml = window.app.modules.util.escapeHtml;
|
||||
var fmtSize = window.app.modules.util.fmtSize;
|
||||
|
||||
function fmtDate(d) {
|
||||
if (!d) return '';
|
||||
|
|
|
|||
|
|
@ -69,8 +69,19 @@
|
|||
// scopeDropTarget: cascade's drop_target at currentPath
|
||||
// scopeDefaultTool: cascade's default_tool at currentPath
|
||||
// (empty when no default declared)
|
||||
// scopeCanonicalFolder: cascade's canonical-folder slot
|
||||
// ('incoming'|'received'|'working'|'staging'|…),
|
||||
// drives scope-aware menu items
|
||||
// scopeOnPlanReview: cascade above has an on_plan_review block
|
||||
// All refreshed by loader.js from response headers on each fetch.
|
||||
scopeDropTarget: false,
|
||||
scopeDefaultTool: '',
|
||||
scopeCanonicalFolder: '',
|
||||
scopeOnPlanReview: false,
|
||||
|
||||
// Whether the listing includes dotfiles. Toggled by the
|
||||
// "Show hidden files" menu item; URL-persisted via ?hidden=1.
|
||||
showHidden: false,
|
||||
|
||||
// Autofilter — when non-empty, the tree hides files that
|
||||
// don't match and folders whose subtree has no matches.
|
||||
|
|
|
|||
|
|
@ -11,10 +11,12 @@
|
|||
|
||||
var state = window.app.state;
|
||||
|
||||
// Lowercased extension (no leading dot), '' for dotfiles / no-ext /
|
||||
// trailing-dot names. Delegates to the shared parser so the rule
|
||||
// stays in one place (CLAUDE.md: all extension handling goes through
|
||||
// window.zddc).
|
||||
function splitExt(name) {
|
||||
var i = name.lastIndexOf('.');
|
||||
if (i <= 0 || i === name.length - 1) return '';
|
||||
return name.substring(i + 1).toLowerCase();
|
||||
return window.zddc.splitExtension(name).extension;
|
||||
}
|
||||
|
||||
// Build a raw entry from the server's FileInfo shape.
|
||||
|
|
@ -222,7 +224,6 @@
|
|||
fetchServerChildren: fetchServerChildren,
|
||||
fetchFsChildren: fetchFsChildren,
|
||||
autoDetectServerMode: autoDetectServerMode,
|
||||
splitExt: splitExt,
|
||||
ensureJSZip: ensureJSZip
|
||||
};
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -45,44 +45,18 @@
|
|||
}
|
||||
}
|
||||
|
||||
// Compute today + N days as a YYYY-MM-DD string.
|
||||
function isoDatePlus(days) {
|
||||
var d = new Date();
|
||||
d.setDate(d.getDate() + days);
|
||||
var y = d.getFullYear();
|
||||
var m = ('0' + (d.getMonth() + 1)).slice(-2);
|
||||
var dd = ('0' + d.getDate()).slice(-2);
|
||||
return y + '-' + m + '-' + dd;
|
||||
}
|
||||
var util = window.app.modules.util;
|
||||
var isoDatePlus = util.isoDatePlus;
|
||||
|
||||
// Fetch suggestion emails from /.profile/access so the originator
|
||||
// field has a datalist of likely values. Best-effort — silent on
|
||||
// failure (the field still accepts free text).
|
||||
async function fetchOriginatorSuggestions() {
|
||||
try {
|
||||
var resp = await fetch('/.profile/access', {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
if (!resp.ok) return [];
|
||||
var data = await resp.json();
|
||||
var out = [];
|
||||
// The endpoint exposes the current user + any role members
|
||||
// visible to them. Pull anything that looks like an email
|
||||
// for the datalist; the field is otherwise free text.
|
||||
if (data && data.email) out.push(data.email);
|
||||
return out;
|
||||
} catch (_e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
var fetchOriginatorSuggestions = util.fetchAccessEmails;
|
||||
|
||||
// Build the YAML body for the plan-review POST. Quoting is minimal
|
||||
// (just enough for emails with special chars).
|
||||
function buildBody(values) {
|
||||
function yamlString(s) {
|
||||
return '"' + String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
|
||||
}
|
||||
var yamlString = util.yamlQuote;
|
||||
return [
|
||||
'review_lead: ' + yamlString(values.reviewLead),
|
||||
'approver: ' + yamlString(values.approver),
|
||||
|
|
@ -145,7 +119,14 @@
|
|||
});
|
||||
});
|
||||
|
||||
// Escape handler bound once, removed in close() — every
|
||||
// dismissal path routes through close() so the document
|
||||
// listener never outlives the modal.
|
||||
function onKeydown(e) {
|
||||
if (e.key === 'Escape') { close(); reject(new Error('cancelled')); }
|
||||
}
|
||||
function close() {
|
||||
document.removeEventListener('keydown', onKeydown);
|
||||
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
||||
}
|
||||
|
||||
|
|
@ -159,13 +140,7 @@
|
|||
reject(new Error('cancelled'));
|
||||
}
|
||||
});
|
||||
document.addEventListener('keydown', function escHandler(e) {
|
||||
if (e.key === 'Escape') {
|
||||
document.removeEventListener('keydown', escHandler);
|
||||
close();
|
||||
reject(new Error('cancelled'));
|
||||
}
|
||||
});
|
||||
document.addEventListener('keydown', onKeydown);
|
||||
|
||||
box.querySelector('#pr-submit').addEventListener('click', function () {
|
||||
var values = {
|
||||
|
|
@ -187,14 +162,7 @@
|
|||
});
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, function (c) {
|
||||
return ({
|
||||
'&': '&', '<': '<', '>': '>',
|
||||
'"': '"', "'": '''
|
||||
})[c];
|
||||
});
|
||||
}
|
||||
var escapeHtml = util.escapeHtml;
|
||||
|
||||
// Detect whether a tree node is an archive/<party>/received/<tracking>/
|
||||
// folder. The path is path-shaped, not content-based — tracking-number
|
||||
|
|
@ -211,8 +179,11 @@
|
|||
&& parts[3].toLowerCase() === 'received';
|
||||
}
|
||||
|
||||
var busy = false;
|
||||
|
||||
// Run the Plan Review flow: open the modal, POST the result.
|
||||
async function invoke(node) {
|
||||
if (busy) return;
|
||||
var tree = window.app.modules.tree;
|
||||
if (!tree) return;
|
||||
var url = tree.pathFor(node);
|
||||
|
|
@ -227,6 +198,8 @@
|
|||
return; // cancelled
|
||||
}
|
||||
|
||||
busy = true;
|
||||
try {
|
||||
statusInfo('Plan Review — submitting…');
|
||||
var body = buildBody(values);
|
||||
var resp;
|
||||
|
|
@ -265,6 +238,9 @@
|
|||
} else {
|
||||
statusInfo('Plan Review complete.');
|
||||
}
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
window.app.modules.planReview = {
|
||||
|
|
|
|||
|
|
@ -42,34 +42,39 @@
|
|||
var SIDEBAR_DEFAULT_WIDTH = 280;
|
||||
var FM_DEFAULT_HEIGHT = 180; // px — front-matter pane height inside sidebar
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
var util = window.app.modules.util;
|
||||
var escapeHtml = util.escapeHtml;
|
||||
var hashContent = util.hashContent;
|
||||
|
||||
var currentInstance = null; // { editor, container, dirty, node, hash, tocEl, fmEl }
|
||||
var lastSidebarWidth = SIDEBAR_DEFAULT_WIDTH; // remember across mounts
|
||||
var lastFmHeight = FM_DEFAULT_HEIGHT;
|
||||
|
||||
async function hashContent(text) {
|
||||
if (!window.crypto || !window.crypto.subtle) return null;
|
||||
var enc = new TextEncoder().encode(text);
|
||||
var buf = await window.crypto.subtle.digest('SHA-256', enc);
|
||||
var bytes = new Uint8Array(buf);
|
||||
var hex = '';
|
||||
for (var i = 0; i < bytes.length; i++) {
|
||||
hex += bytes[i].toString(16).padStart(2, '0');
|
||||
}
|
||||
return hex;
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
if (currentInstance && currentInstance.editor) {
|
||||
if (currentInstance) {
|
||||
// Tear down the document-level resizer drag listeners (added
|
||||
// lazily on mousedown). They're normally removed on mouseup,
|
||||
// but a dispose mid-drag — or any switch away — would otherwise
|
||||
// strand them pointing at the dead shell. The AbortController
|
||||
// removes whatever is still attached in one call.
|
||||
if (currentInstance.ac) {
|
||||
try { currentInstance.ac.abort(); } catch (_) { /* ignore */ }
|
||||
}
|
||||
if (currentInstance.editor) {
|
||||
try { currentInstance.editor.destroy(); } catch (_) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
currentInstance = null;
|
||||
}
|
||||
|
||||
function isDirty() {
|
||||
return !!(currentInstance && currentInstance.dirty);
|
||||
}
|
||||
|
||||
function currentNode() {
|
||||
return currentInstance ? currentInstance.node : null;
|
||||
}
|
||||
|
||||
// ── Front matter ────────────────────────────────────────────────────────
|
||||
// Lightweight YAML front-matter parser. Same envelope as mdedit's:
|
||||
// `---\n…\n---\n`, key:value lines, simple `[a, b, c]` arrays.
|
||||
|
|
@ -273,38 +278,11 @@
|
|||
|
||||
// ── Save ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function saveContent(node, content) {
|
||||
if (node.handle && typeof node.handle.createWritable === 'function') {
|
||||
// Local folders are picked read-only; escalate to readwrite on
|
||||
// first save (one FS-Access prompt, then granted for the session).
|
||||
var up = window.app.modules.upload;
|
||||
if (up && up.ensureWritable) await up.ensureWritable();
|
||||
var writable = await node.handle.createWritable();
|
||||
await writable.write(content);
|
||||
await writable.close();
|
||||
return;
|
||||
}
|
||||
if (node.url && window.app.state.source === 'server') {
|
||||
var resp = await fetch(node.url, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'text/markdown; charset=utf-8' },
|
||||
body: content,
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||
return;
|
||||
}
|
||||
throw new Error('No write target for this file (read-only source).');
|
||||
function saveContent(node, content) {
|
||||
return util.saveFile(node, content, 'text/markdown; charset=utf-8');
|
||||
}
|
||||
|
||||
// A markdown file living inside a .zip is read-only: a ZipFileHandle
|
||||
// refuses createWritable (offline / nested), and zddc-server refuses
|
||||
// writes to a "<…>.zip/<member>" URL (405).
|
||||
function isZipMemberNode(node) {
|
||||
if (node.handle && node.handle.isZipEntry) return true;
|
||||
if (node.url && window.app.state.source === 'server' && /\.zip\//i.test(node.url)) return true;
|
||||
return false;
|
||||
}
|
||||
var isZipMemberNode = util.isZipMemberNode;
|
||||
|
||||
function canSave(node) {
|
||||
if (isZipMemberNode(node)) return false;
|
||||
|
|
@ -564,15 +542,20 @@
|
|||
}));
|
||||
}
|
||||
|
||||
currentInstance = {
|
||||
// One AbortController per mount — wired into the document-level
|
||||
// resizer listeners below so dispose() can detach them all at once.
|
||||
var ac = new AbortController();
|
||||
var instance = {
|
||||
editor: editor,
|
||||
container: container,
|
||||
dirty: false,
|
||||
node: node,
|
||||
hash: initialHash,
|
||||
tocEl: tocBody,
|
||||
fmEl: fmTextarea
|
||||
fmEl: fmTextarea,
|
||||
ac: ac
|
||||
};
|
||||
currentInstance = instance;
|
||||
|
||||
if (!writableMode) {
|
||||
saveBtn.disabled = true;
|
||||
|
|
@ -609,8 +592,8 @@
|
|||
resizer.classList.add('is-dragging');
|
||||
startX = e.clientX;
|
||||
startW = sidebar.getBoundingClientRect().width;
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
document.addEventListener('mousemove', onMove, { signal: ac.signal });
|
||||
document.addEventListener('mouseup', onUp, { signal: ac.signal });
|
||||
e.preventDefault();
|
||||
});
|
||||
resizer.addEventListener('keydown', function (e) {
|
||||
|
|
@ -654,8 +637,8 @@
|
|||
fmResizer.classList.add('is-dragging');
|
||||
startY = e.clientY;
|
||||
startH = fmSection.getBoundingClientRect().height;
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
document.addEventListener('mousemove', onMove, { signal: ac.signal });
|
||||
document.addEventListener('mouseup', onUp, { signal: ac.signal });
|
||||
e.preventDefault();
|
||||
});
|
||||
fmResizer.addEventListener('keydown', function (e) {
|
||||
|
|
@ -670,7 +653,8 @@
|
|||
|
||||
// ── Change tracking + auto-rerender ────────────────────────────────
|
||||
function markDirty(isDirty) {
|
||||
currentInstance.dirty = isDirty;
|
||||
if (currentInstance !== instance) return; // editor replaced
|
||||
instance.dirty = isDirty;
|
||||
// Re-read canSave at every transition, not via a closure-captured
|
||||
// value, so the gate reflects current write authority — see the
|
||||
// matching pattern in preview-yaml.js.
|
||||
|
|
@ -678,29 +662,40 @@
|
|||
dirtyEl.textContent = isDirty ? '● modified' : '';
|
||||
}
|
||||
|
||||
// The debounced handlers can resolve AFTER this editor was disposed
|
||||
// and a new file mounted (the timer + the await both outlive the
|
||||
// switch). Bail when we're no longer the active instance so we never
|
||||
// call into a destroyed Toast UI editor or write the wrong file's
|
||||
// dirty/hash state.
|
||||
var onChange = debounce(async function () {
|
||||
if (currentInstance !== instance) return;
|
||||
var body = editor.getMarkdown();
|
||||
var h = await hashContent(assembleContent(fmTextarea.value, body));
|
||||
markDirty(h !== currentInstance.hash);
|
||||
if (currentInstance !== instance) return;
|
||||
markDirty(h !== instance.hash);
|
||||
renderToc(tocBody, body, editor);
|
||||
}, 250);
|
||||
editor.on('change', onChange);
|
||||
|
||||
var onFmChange = debounce(async function () {
|
||||
if (currentInstance !== instance) return;
|
||||
var body = editor.getMarkdown();
|
||||
var h = await hashContent(assembleContent(fmTextarea.value, body));
|
||||
markDirty(h !== currentInstance.hash);
|
||||
if (currentInstance !== instance) return;
|
||||
markDirty(h !== instance.hash);
|
||||
}, 250);
|
||||
fmTextarea.addEventListener('input', onFmChange);
|
||||
|
||||
// ── Save ───────────────────────────────────────────────────────────
|
||||
async function save() {
|
||||
if (!currentInstance.dirty || !canSave(node)) return;
|
||||
if (currentInstance !== instance) return;
|
||||
if (!instance.dirty || !canSave(node)) return;
|
||||
var content = assembleContent(fmTextarea.value, editor.getMarkdown());
|
||||
try {
|
||||
statusEl.textContent = 'Saving…';
|
||||
await saveContent(node, content);
|
||||
currentInstance.hash = await hashContent(content);
|
||||
if (currentInstance !== instance) return; // switched away mid-save
|
||||
instance.hash = await hashContent(content);
|
||||
markDirty(false);
|
||||
statusEl.textContent = 'Saved ' + new Date().toLocaleTimeString();
|
||||
if (window.zddc && window.zddc.toast) {
|
||||
|
|
@ -732,7 +727,7 @@
|
|||
convertBtns.forEach(function (a) {
|
||||
a.addEventListener('click', async function (e) {
|
||||
var fmt = a.dataset.fmt;
|
||||
if (!currentInstance.dirty) {
|
||||
if (!instance.dirty) {
|
||||
// Clean — let the browser handle the click. The
|
||||
// server's response (DOCX/HTML/PDF bytes, 422,
|
||||
// 503, etc.) lands in whatever target the user
|
||||
|
|
@ -751,7 +746,7 @@
|
|||
}
|
||||
statusEl.textContent = 'Saving before download…';
|
||||
try { await save(); } catch (_) { /* save() surfaces its own error */ }
|
||||
if (currentInstance.dirty) return; // save failed; toast already shown
|
||||
if (currentInstance !== instance || instance.dirty) return; // save failed / switched away
|
||||
statusEl.textContent = 'Downloading ' + fmt.toUpperCase() + '…';
|
||||
// Re-trigger the click. dirty=false now so the handler
|
||||
// exits early on the second pass and the browser
|
||||
|
|
@ -763,6 +758,8 @@
|
|||
|
||||
window.app.modules.markdown = {
|
||||
render: render,
|
||||
dispose: dispose
|
||||
dispose: dispose,
|
||||
isDirty: isDirty,
|
||||
currentNode: currentNode
|
||||
};
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -22,10 +22,8 @@
|
|||
|
||||
if (!window.app || !window.app.modules) return;
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
var util = window.app.modules.util;
|
||||
var escapeHtml = util.escapeHtml;
|
||||
|
||||
// ── Filename routing ────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -47,32 +45,14 @@
|
|||
|
||||
// ── Save (mirrors preview-markdown.js) ─────────────────────────────────
|
||||
|
||||
async function saveContent(node, content) {
|
||||
if (node.handle && typeof node.handle.createWritable === 'function') {
|
||||
var writable = await node.handle.createWritable();
|
||||
await writable.write(content);
|
||||
await writable.close();
|
||||
return;
|
||||
}
|
||||
if (node.url && window.app.state.source === 'server') {
|
||||
var resp = await fetch(node.url, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/x-yaml; charset=utf-8' },
|
||||
body: content,
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||
return;
|
||||
}
|
||||
throw new Error('No write target for this file (read-only source).');
|
||||
function saveContent(node, content) {
|
||||
// Via the shared saveFile so local (FS-Access) saves escalate to
|
||||
// readwrite the same as the markdown editor — previously this path
|
||||
// skipped ensureWritable and failed on read-only-picked folders.
|
||||
return util.saveFile(node, content, 'application/x-yaml; charset=utf-8');
|
||||
}
|
||||
|
||||
function isZipMemberNode(node) {
|
||||
if (node.handle && node.handle.isZipEntry) return true;
|
||||
if (node.url && window.app.state.source === 'server'
|
||||
&& /\.zip\//i.test(node.url)) return true;
|
||||
return false;
|
||||
}
|
||||
var isZipMemberNode = util.isZipMemberNode;
|
||||
|
||||
function canSave(node) {
|
||||
if (isZipMemberNode(node)) return false;
|
||||
|
|
@ -96,17 +76,7 @@
|
|||
return false;
|
||||
}
|
||||
|
||||
async function hashContent(text) {
|
||||
if (!window.crypto || !window.crypto.subtle) return null;
|
||||
var enc = new TextEncoder().encode(text);
|
||||
var buf = await window.crypto.subtle.digest('SHA-256', enc);
|
||||
var bytes = new Uint8Array(buf);
|
||||
var hex = '';
|
||||
for (var i = 0; i < bytes.length; i++) {
|
||||
hex += bytes[i].toString(16).padStart(2, '0');
|
||||
}
|
||||
return hex;
|
||||
}
|
||||
var hashContent = util.hashContent;
|
||||
|
||||
// ── .zddc schema ────────────────────────────────────────────────────────
|
||||
//
|
||||
|
|
@ -378,12 +348,24 @@
|
|||
// ── Mount ───────────────────────────────────────────────────────────────
|
||||
|
||||
var currentEditor = null;
|
||||
var currentDirty = false;
|
||||
var currentNodeRef = null;
|
||||
|
||||
function dispose() {
|
||||
// CM doesn't have an explicit destroy(); GC handles it once
|
||||
// the host element is removed. Clear our reference so a stale
|
||||
// editor doesn't keep handlers alive.
|
||||
currentEditor = null;
|
||||
currentDirty = false;
|
||||
currentNodeRef = null;
|
||||
}
|
||||
|
||||
function isDirty() {
|
||||
return currentDirty;
|
||||
}
|
||||
|
||||
function currentNode() {
|
||||
return currentNodeRef;
|
||||
}
|
||||
|
||||
async function render(node, container, ctx) {
|
||||
|
|
@ -499,6 +481,8 @@
|
|||
// Force an initial lint pass now that _zddcNode is set.
|
||||
editor.performLint();
|
||||
currentEditor = editor;
|
||||
currentNodeRef = node;
|
||||
currentDirty = false;
|
||||
|
||||
if (!writable) {
|
||||
saveBtn.disabled = true;
|
||||
|
|
@ -514,12 +498,16 @@
|
|||
var initialHash = await hashContent(text);
|
||||
|
||||
function markDirty(isDirty) {
|
||||
if (currentEditor !== editor) return; // editor replaced
|
||||
currentDirty = isDirty;
|
||||
saveBtn.disabled = !isDirty || !canSave(node);
|
||||
dirtyEl.textContent = isDirty ? '● modified' : '';
|
||||
}
|
||||
|
||||
editor.on('change', async function () {
|
||||
if (currentEditor !== editor) return; // switched away
|
||||
var h = await hashContent(editor.getValue());
|
||||
if (currentEditor !== editor) return; // replaced during await
|
||||
markDirty(h !== initialHash);
|
||||
});
|
||||
|
||||
|
|
@ -564,6 +552,9 @@
|
|||
|
||||
window.app.modules.yamledit = {
|
||||
handles: handles,
|
||||
render: render
|
||||
render: render,
|
||||
dispose: dispose,
|
||||
isDirty: isDirty,
|
||||
currentNode: currentNode
|
||||
};
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -19,10 +19,8 @@
|
|||
console.error('[browse] zddc.preview not loaded — preview disabled.');
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
var util = window.app.modules.util;
|
||||
var escapeHtml = util.escapeHtml;
|
||||
|
||||
var MIME = {
|
||||
'pdf': 'application/pdf',
|
||||
|
|
@ -41,13 +39,7 @@
|
|||
|
||||
function getMime(ext) { return MIME[ext] || 'application/octet-stream'; }
|
||||
|
||||
function fmtSize(bytes) {
|
||||
if (bytes == null) return '';
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
|
||||
}
|
||||
var fmtSize = util.fmtSize;
|
||||
|
||||
async function getArrayBuffer(node) {
|
||||
// A zip member node carries a ZipFileHandle in node.handle, so
|
||||
|
|
@ -76,8 +68,62 @@
|
|||
return { url: URL.createObjectURL(blob), fromServer: false };
|
||||
}
|
||||
|
||||
// ── Editor lifecycle helpers ─────────────────────────────────────────────
|
||||
// The markdown and YAML plugins each mount a long-lived editor into the
|
||||
// preview pane. Switching files (or clearing the pane) must dispose the
|
||||
// live editor first — otherwise the Toast UI instance, its DOM, and its
|
||||
// document-level resizer listeners leak when we overwrite the container.
|
||||
|
||||
function editorModules() {
|
||||
var m = window.app.modules;
|
||||
return [m.markdown, m.yamledit].filter(Boolean);
|
||||
}
|
||||
|
||||
function disposeEditors() {
|
||||
editorModules().forEach(function (mod) {
|
||||
if (mod.dispose) { try { mod.dispose(); } catch (_) { /* ignore */ } }
|
||||
});
|
||||
}
|
||||
|
||||
// The editor module (if any) holding unsaved edits, else null.
|
||||
function dirtyEditor() {
|
||||
var mods = editorModules();
|
||||
for (var i = 0; i < mods.length; i++) {
|
||||
if (mods[i].isDirty && mods[i].isDirty()) return mods[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function samePreviewNode(a, b) {
|
||||
if (!a || !b) return false;
|
||||
if (a === b) return true;
|
||||
if (a.url && b.url) return a.url === b.url;
|
||||
return a.name === b.name && a.parentId === b.parentId;
|
||||
}
|
||||
|
||||
// Tear down any live editor and blank the pane. Used by callers that
|
||||
// reset the preview directly (rescope, popstate) so they don't leak the
|
||||
// editor or strand its dirty state.
|
||||
function clearPreview() {
|
||||
disposeEditors();
|
||||
var container = document.getElementById('previewBody');
|
||||
if (container) container.innerHTML = '';
|
||||
}
|
||||
|
||||
// Warn before a full page unload (reload / close / external nav) drops
|
||||
// unsaved editor changes. SPA-internal switches are guarded in
|
||||
// renderInline; this catches the browser-level exit.
|
||||
window.addEventListener('beforeunload', function (e) {
|
||||
if (dirtyEditor()) { e.preventDefault(); e.returnValue = ''; }
|
||||
});
|
||||
|
||||
// ── Inline rendering ────────────────────────────────────────────────────
|
||||
|
||||
// Bumped on every renderInline entry; a render that loses the race
|
||||
// (a newer selection started while its bytes were in flight) bails
|
||||
// before writing stale content into the shared pane.
|
||||
var renderSeq = 0;
|
||||
|
||||
function renderEmpty(container, msg) {
|
||||
container.innerHTML = '<div class="preview-empty">' + escapeHtml(msg) + '</div>';
|
||||
}
|
||||
|
|
@ -87,13 +133,37 @@
|
|||
+ escapeHtml(msg) + '</div>';
|
||||
}
|
||||
|
||||
async function renderInline(node) {
|
||||
async function renderInline(node, opts) {
|
||||
opts = opts || {};
|
||||
var container = document.getElementById('previewBody');
|
||||
var titleEl = document.getElementById('previewTitle');
|
||||
var metaEl = document.getElementById('previewMeta');
|
||||
var popoutBtn = document.getElementById('previewPopout');
|
||||
if (!container) return;
|
||||
|
||||
// Guard unsaved editor edits before we tear the editor down.
|
||||
var dm = dirtyEditor();
|
||||
if (dm) {
|
||||
var cur = dm.currentNode ? dm.currentNode() : null;
|
||||
if (samePreviewNode(cur, node)) {
|
||||
// Re-selecting the file we're already editing — don't reload
|
||||
// and clobber the in-progress edits.
|
||||
return;
|
||||
}
|
||||
if (opts.auto) {
|
||||
// Keyboard/auto preview (cursor walking the tree): leave the
|
||||
// dirty editor in place rather than prompting on every key.
|
||||
return;
|
||||
}
|
||||
var label = cur ? cur.name : 'this file';
|
||||
if (!window.confirm('Discard unsaved changes to ' + label + '?')) return;
|
||||
}
|
||||
// Safe to replace the pane now: dispose any live editor so its
|
||||
// instance + document-level listeners don't leak.
|
||||
disposeEditors();
|
||||
|
||||
var seq = ++renderSeq;
|
||||
|
||||
if (titleEl) titleEl.textContent = node.name;
|
||||
if (metaEl) {
|
||||
var meta = [];
|
||||
|
|
@ -134,6 +204,7 @@
|
|||
if (ext === 'pdf' || ext === 'html' || ext === 'htm') {
|
||||
try {
|
||||
var info = await getBlobUrl(node);
|
||||
if (seq !== renderSeq) return;
|
||||
var sandbox = (ext === 'pdf') ? '' : ' sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"';
|
||||
container.innerHTML = '<iframe class="preview-iframe" src="' + escapeHtml(info.url) + '"' + sandbox + '></iframe>';
|
||||
} catch (e) {
|
||||
|
|
@ -146,6 +217,7 @@
|
|||
if (preview && preview.isImage(ext) && !preview.isTiff(ext)) {
|
||||
try {
|
||||
var imgInfo = await getBlobUrl(node);
|
||||
if (seq !== renderSeq) return;
|
||||
container.innerHTML = '<img class="preview-image" alt="' + escapeHtml(node.name)
|
||||
+ '" src="' + escapeHtml(imgInfo.url) + '">';
|
||||
} catch (e) {
|
||||
|
|
@ -157,6 +229,7 @@
|
|||
if (preview && preview.isTiff(ext)) {
|
||||
try {
|
||||
var tiffBuf = await getArrayBuffer(node);
|
||||
if (seq !== renderSeq) return;
|
||||
container.innerHTML = '';
|
||||
await preview.renderTiff(document, container, tiffBuf, { fileName: node.name });
|
||||
} catch (e) {
|
||||
|
|
@ -168,6 +241,7 @@
|
|||
if (preview && preview.isZip(ext)) {
|
||||
try {
|
||||
var zipBuf = await getArrayBuffer(node);
|
||||
if (seq !== renderSeq) return;
|
||||
container.innerHTML = '';
|
||||
await preview.renderZipListing(document, container, zipBuf, { fileName: node.name });
|
||||
} catch (e) {
|
||||
|
|
@ -182,6 +256,7 @@
|
|||
if (preview && preview.isOffice(ext)) {
|
||||
try {
|
||||
var officeBuf = await getArrayBuffer(node);
|
||||
if (seq !== renderSeq) return;
|
||||
container.innerHTML = '';
|
||||
if (ext === 'docx') {
|
||||
await preview.renderDocx(document, container, officeBuf, { fileName: node.name });
|
||||
|
|
@ -197,6 +272,7 @@
|
|||
if (preview && preview.isText(ext)) {
|
||||
try {
|
||||
var txtBuf = await getArrayBuffer(node);
|
||||
if (seq !== renderSeq) return;
|
||||
var text = new TextDecoder('utf-8', { fatal: false }).decode(txtBuf);
|
||||
var MAX = 200000;
|
||||
if (text.length > MAX) {
|
||||
|
|
@ -217,6 +293,7 @@
|
|||
// Unknown type — offer a download link.
|
||||
try {
|
||||
var fallbackInfo = await getBlobUrl(node);
|
||||
if (seq !== renderSeq) return;
|
||||
container.innerHTML =
|
||||
'<div class="preview-empty">'
|
||||
+ 'No inline preview for <code>.' + escapeHtml(ext) + '</code>. '
|
||||
|
|
@ -358,11 +435,13 @@
|
|||
if (node.isDir) return;
|
||||
opts = opts || {};
|
||||
if (opts.popup) return renderInPopup(node);
|
||||
return renderInline(node);
|
||||
return renderInline(node, opts);
|
||||
}
|
||||
|
||||
window.app.modules.preview = {
|
||||
showFilePreview: showFilePreview,
|
||||
// Tear down any live editor + blank the pane (rescope / popstate).
|
||||
clearPreview: clearPreview,
|
||||
// Expose for the markdown plugin so it can read file bytes.
|
||||
getArrayBuffer: getArrayBuffer
|
||||
};
|
||||
|
|
|
|||
|
|
@ -25,11 +25,16 @@
|
|||
var t = window.zddc && window.zddc.toast;
|
||||
if (t) t(msg, level || 'info');
|
||||
}
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, function (c) {
|
||||
return ({ '&':'&','<':'<','>':'>','"':'"',"'":''' })[c];
|
||||
});
|
||||
// Re-fetch the current listing so the moved file appears/disappears
|
||||
// without a manual reload. Best-effort: absent on older builds.
|
||||
function refreshListing() {
|
||||
var ev = window.app.modules.events;
|
||||
if (ev && typeof ev.refreshListing === 'function') ev.refreshListing();
|
||||
}
|
||||
// Guard against a second invocation while a move is mid-flight (e.g. a
|
||||
// double menu click). The picker modal also blocks re-entry while open.
|
||||
var busy = false;
|
||||
var escapeHtml = window.app.modules.util.escapeHtml;
|
||||
|
||||
// ── Scope detection: path-shape, not cascade-content ──────────────
|
||||
// A file is stageable if its path matches
|
||||
|
|
@ -90,18 +95,6 @@
|
|||
.map(function (e) { return e.name; });
|
||||
}
|
||||
|
||||
async function fetchSelfEmail() {
|
||||
try {
|
||||
var r = await fetch('/.profile/access', {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
if (!r.ok) return '';
|
||||
var d = await r.json();
|
||||
return (d && d.email) || '';
|
||||
} catch (_e) { return ''; }
|
||||
}
|
||||
|
||||
// POST X-ZDDC-Op: mkdir to create a new directory. Idempotent.
|
||||
async function mkdir(absUrl) {
|
||||
var resp = await fetch(absUrl, {
|
||||
|
|
@ -267,6 +260,7 @@
|
|||
// ── Action drivers ─────────────────────────────────────────────────
|
||||
|
||||
async function invokeStage(node) {
|
||||
if (busy) return;
|
||||
var tree = window.app.modules.tree;
|
||||
if (!tree) return;
|
||||
var srcUrl = tree.pathFor(node);
|
||||
|
|
@ -289,9 +283,18 @@
|
|||
choice = await openStagePicker({ fileCount: 1, folders: folders });
|
||||
} catch (_e) { return; }
|
||||
|
||||
busy = true;
|
||||
try {
|
||||
// Stage is a non-atomic mkdir-then-move (no single composite op).
|
||||
// Track whether the folder was freshly created so that, if the
|
||||
// move then fails, we can tell the user the folder exists but the
|
||||
// file didn't make it — otherwise an empty folder appears with a
|
||||
// generic "move failed" and no explanation.
|
||||
var createdFolder = false;
|
||||
if (choice.create) {
|
||||
try {
|
||||
await mkdir(stagingBase + encodeURIComponent(choice.folderName) + '/');
|
||||
createdFolder = true;
|
||||
} catch (e) {
|
||||
status((e && e.message) || 'mkdir failed', 'error');
|
||||
return;
|
||||
|
|
@ -302,13 +305,24 @@
|
|||
try {
|
||||
await moveFile(srcUrl, dstUrl);
|
||||
} catch (e) {
|
||||
status((e && e.message) || 'move failed', 'error');
|
||||
var msg = (e && e.message) || 'move failed';
|
||||
if (createdFolder) {
|
||||
msg += ' — the new folder "' + choice.folderName
|
||||
+ '" was created but ' + node.name + ' was not moved into it.';
|
||||
}
|
||||
status(msg, 'error');
|
||||
refreshListing(); // surface the (possibly empty) new folder
|
||||
return;
|
||||
}
|
||||
status('Staged ' + node.name + ' → ' + info.party + '/staging/' + choice.folderName + '/ — reload to see the move.', 'success');
|
||||
status('Staged ' + node.name + ' → ' + info.party + '/staging/' + choice.folderName + '/', 'success');
|
||||
refreshListing();
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function invokeUnstage(node) {
|
||||
if (busy) return;
|
||||
var tree = window.app.modules.tree;
|
||||
if (!tree) return;
|
||||
var srcUrl = tree.pathFor(node);
|
||||
|
|
@ -326,13 +340,19 @@
|
|||
var target = choice.target;
|
||||
if (!target.endsWith('/')) target += '/';
|
||||
var dstUrl = target + encodeURIComponent(node.name);
|
||||
busy = true;
|
||||
try {
|
||||
try {
|
||||
await moveFile(srcUrl, dstUrl);
|
||||
} catch (e) {
|
||||
status((e && e.message) || 'move failed', 'error');
|
||||
return;
|
||||
}
|
||||
status('Unstaged ' + node.name + ' → ' + target + ' — reload to see the move.', 'success');
|
||||
status('Unstaged ' + node.name + ' → ' + target, 'success');
|
||||
refreshListing();
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
window.app.modules.stage = {
|
||||
|
|
|
|||
|
|
@ -211,13 +211,7 @@
|
|||
|
||||
// ── Rendering ────────────────────────────────────────────────────────
|
||||
|
||||
function fmtSize(bytes) {
|
||||
if (bytes == null) return '';
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
|
||||
}
|
||||
var fmtSize = window.app.modules.util.fmtSize;
|
||||
|
||||
function fmtDate(d) {
|
||||
if (!d) return '';
|
||||
|
|
@ -226,10 +220,7 @@
|
|||
+ ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
var escapeHtml = window.app.modules.util.escapeHtml;
|
||||
|
||||
// Per-extension icon map → Lucide outline-icon sprite ids. The
|
||||
// actual SVG markup is produced by window.zddc.icons.html(id),
|
||||
|
|
@ -469,10 +460,6 @@
|
|||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
// Sort headers no longer exist in the DOM (the tree replaced the
|
||||
// table); the tree.setSort() method still works but only via
|
||||
// programmatic callers — there's no UI for changing sort yet.
|
||||
|
||||
// True when this .zip node lives inside another zip, so its bytes
|
||||
// can't be fetched as a standalone server resource: we read them
|
||||
// through the containing handle (offline / nested) or by fetching
|
||||
|
|
@ -521,7 +508,14 @@
|
|||
// it as a directory handle; members
|
||||
// become ordinary dir/file nodes
|
||||
async function loadChildren(node) {
|
||||
if (node.loaded) return;
|
||||
if (node.loaded || node.loading) return;
|
||||
// In-flight guard: a folder can be (re)toggled while its first
|
||||
// load is still pending — rapid Enter/ArrowRight key-repeat, or a
|
||||
// double-click landing during a single-click's load. Without this,
|
||||
// both calls pass the !loaded check and fire duplicate fetches that
|
||||
// race in setChildren. The flag serializes per-node so the second
|
||||
// caller is a no-op until the first resolves.
|
||||
node.loading = true;
|
||||
try {
|
||||
if (node.isZip && state.source === 'server' && !zipNestedInsideZip(node)) {
|
||||
setChildren(node.id, await loader.fetchServerChildren(pathFor(node) + '/'));
|
||||
|
|
@ -541,6 +535,8 @@
|
|||
} catch (e) {
|
||||
window.app.modules.events.statusError(
|
||||
'Failed to load ' + node.name + ': ' + e.message);
|
||||
} finally {
|
||||
node.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -690,15 +686,6 @@
|
|||
loadChildren: loadChildren,
|
||||
snapshotState: snapshotState,
|
||||
restoreState: restoreState,
|
||||
setSort: function (key) {
|
||||
if (state.sort.key === key) {
|
||||
state.sort.dir = -state.sort.dir;
|
||||
} else {
|
||||
state.sort.key = key;
|
||||
state.sort.dir = 1;
|
||||
}
|
||||
render();
|
||||
},
|
||||
// Set both key and direction explicitly. dir: 1 (asc) or -1 (desc).
|
||||
// Used by the toolbar's sort dropdown.
|
||||
setSortExplicit: function (key, dir) {
|
||||
|
|
|
|||
|
|
@ -286,20 +286,11 @@
|
|||
if (lastResolved) msg += ' — last at ' + lastResolved;
|
||||
note(msg, 'success');
|
||||
}
|
||||
// Reload the listing of the workflow folder so the new +Cn file
|
||||
// appears in the tree. The workflow folder is the parent of the
|
||||
// virtual `received/` (i.e., the URL with one `/received/<file>`
|
||||
// suffix stripped).
|
||||
var refreshUrl = targetURL.replace(/\/received\/[^/]+\/?$/, '/');
|
||||
// Reload the current listing so the new +Cn file appears in the
|
||||
// tree. Best-effort.
|
||||
try {
|
||||
var ev = window.app.modules.events;
|
||||
if (ev && typeof ev.refreshListing === 'function') {
|
||||
ev.refreshListing();
|
||||
} else if (refreshUrl) {
|
||||
// Best-effort fallback: re-navigate to the workflow folder
|
||||
// so its listing is refreshed.
|
||||
// (No action — refreshListing absence implies older browse.)
|
||||
}
|
||||
if (ev && typeof ev.refreshListing === 'function') ev.refreshListing();
|
||||
} catch (_e) { /* refresh is best-effort */ }
|
||||
}
|
||||
|
||||
|
|
|
|||
131
browse/js/util.js
Normal file
131
browse/js/util.js
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
// util.js — small browse-local helpers shared across the tool's modules.
|
||||
//
|
||||
// Consolidates copies that had drifted across modules: escapeHtml (some
|
||||
// variants escaped single-quotes and handled null, others didn't), the
|
||||
// SHA-256 content hasher (byte-identical in both editors), ISO-date and
|
||||
// YAML-quote helpers (duplicated across the workflow modals), the
|
||||
// /.profile/access email lookup, byte-size formatting, and the editor
|
||||
// save/zip-member primitives. Attaches to window.app.modules.util — no new
|
||||
// global (per the two-globals rule). Concatenated right after init.js so
|
||||
// it's present when every later module's IIFE runs.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
if (!window.app || !window.app.modules) return;
|
||||
|
||||
// Escape a value for HTML text/attribute insertion. Escapes all five
|
||||
// significant characters (including the single quote, which some call
|
||||
// sites need for single-quoted attributes) and treats null/undefined
|
||||
// as an empty string. Strict superset of every previous local copy.
|
||||
function escapeHtml(s) {
|
||||
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
|
||||
return ({
|
||||
'&': '&', '<': '<', '>': '>',
|
||||
'"': '"', "'": '''
|
||||
})[c];
|
||||
});
|
||||
}
|
||||
|
||||
// SHA-256 hex of a string, or null when WebCrypto is unavailable.
|
||||
// Used to gate editor dirty-state.
|
||||
async function hashContent(text) {
|
||||
if (!window.crypto || !window.crypto.subtle) return null;
|
||||
var enc = new TextEncoder().encode(text);
|
||||
var buf = await window.crypto.subtle.digest('SHA-256', enc);
|
||||
var bytes = new Uint8Array(buf);
|
||||
var hex = '';
|
||||
for (var i = 0; i < bytes.length; i++) {
|
||||
hex += bytes[i].toString(16).padStart(2, '0');
|
||||
}
|
||||
return hex;
|
||||
}
|
||||
|
||||
function pad2(n) { return ('0' + n).slice(-2); }
|
||||
function fmtIsoDate(d) {
|
||||
return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate());
|
||||
}
|
||||
// YYYY-MM-DD for today / today + N days (local time).
|
||||
function isoDateToday() { return fmtIsoDate(new Date()); }
|
||||
function isoDatePlus(days) {
|
||||
var d = new Date();
|
||||
d.setDate(d.getDate() + days);
|
||||
return fmtIsoDate(d);
|
||||
}
|
||||
|
||||
// Double-quoted YAML scalar with backslash + quote escaping. Enough for
|
||||
// the email/string fields the workflow modals emit.
|
||||
function yamlQuote(s) {
|
||||
return '"' + String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
|
||||
}
|
||||
|
||||
// GET /.profile/access → [email] for datalist suggestions. Best-effort:
|
||||
// returns [] on any error so callers can populate a datalist blind.
|
||||
async function fetchAccessEmails() {
|
||||
try {
|
||||
var r = await fetch('/.profile/access', {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
if (!r.ok) return [];
|
||||
var d = await r.json();
|
||||
return (d && d.email) ? [d.email] : [];
|
||||
} catch (_e) { return []; }
|
||||
}
|
||||
|
||||
function fmtSize(bytes) {
|
||||
if (bytes == null) return '';
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
|
||||
}
|
||||
|
||||
// A file living inside a .zip is read-only: a ZipFileHandle refuses
|
||||
// createWritable (offline / nested) and zddc-server refuses writes to a
|
||||
// "<…>.zip/<member>" URL (405).
|
||||
function isZipMemberNode(node) {
|
||||
if (node.handle && node.handle.isZipEntry) return true;
|
||||
if (node.url && window.app.state.source === 'server'
|
||||
&& /\.zip\//i.test(node.url)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Write content back to a file's source. Local (FS-Access) folders are
|
||||
// picked read-only, so the first write escalates to readwrite via
|
||||
// upload.ensureWritable (one permission prompt, then granted for the
|
||||
// session). contentType sets the PUT Content-Type for server files.
|
||||
// Throws when the source has no write target.
|
||||
async function saveFile(node, content, contentType) {
|
||||
if (node.handle && typeof node.handle.createWritable === 'function') {
|
||||
var up = window.app.modules.upload;
|
||||
if (up && up.ensureWritable) await up.ensureWritable();
|
||||
var writable = await node.handle.createWritable();
|
||||
await writable.write(content);
|
||||
await writable.close();
|
||||
return;
|
||||
}
|
||||
if (node.url && window.app.state.source === 'server') {
|
||||
var resp = await fetch(node.url, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': contentType },
|
||||
body: content,
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||
return;
|
||||
}
|
||||
throw new Error('No write target for this file (read-only source).');
|
||||
}
|
||||
|
||||
window.app.modules.util = {
|
||||
escapeHtml: escapeHtml,
|
||||
hashContent: hashContent,
|
||||
isoDateToday: isoDateToday,
|
||||
isoDatePlus: isoDatePlus,
|
||||
yamlQuote: yamlQuote,
|
||||
fetchAccessEmails: fetchAccessEmails,
|
||||
fmtSize: fmtSize,
|
||||
isZipMemberNode: isZipMemberNode,
|
||||
saveFile: saveFile
|
||||
};
|
||||
})();
|
||||
Loading…
Reference in a new issue