Compare commits
No commits in common. "d524966f0033c1e6dbcdffe9a6bd8da45cbca27c" and "2f211d748f7bc075912344669115a345e193ba4b" have entirely different histories.
d524966f00
...
2f211d748f
18 changed files with 424 additions and 633 deletions
|
|
@ -63,7 +63,6 @@ concat_files \
|
||||||
"../shared/icons.js" \
|
"../shared/icons.js" \
|
||||||
"../shared/zddc-source.js" \
|
"../shared/zddc-source.js" \
|
||||||
"js/init.js" \
|
"js/init.js" \
|
||||||
"js/util.js" \
|
|
||||||
"js/loader.js" \
|
"js/loader.js" \
|
||||||
"js/tree.js" \
|
"js/tree.js" \
|
||||||
"js/preview.js" \
|
"js/preview.js" \
|
||||||
|
|
|
||||||
|
|
@ -25,10 +25,28 @@
|
||||||
if (t) t(msg, level || 'info');
|
if (t) t(msg, level || 'info');
|
||||||
}
|
}
|
||||||
|
|
||||||
var util = window.app.modules.util;
|
function isoDateToday() {
|
||||||
var escapeHtml = util.escapeHtml;
|
var d = new Date();
|
||||||
var isoDateToday = util.isoDateToday;
|
return d.getFullYear()
|
||||||
var isoDatePlus = util.isoDatePlus;
|
+ '-' + ('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];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Is this node a direct child of an incoming/ canonical folder
|
// Is this node a direct child of an incoming/ canonical folder
|
||||||
// AND a well-formed transmittal folder? The first half is the
|
// AND a well-formed transmittal folder? The first half is the
|
||||||
|
|
@ -78,7 +96,19 @@
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
var fetchPeopleSuggestions = util.fetchAccessEmails;
|
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 []; });
|
||||||
|
}
|
||||||
|
|
||||||
function openForm(initial) {
|
function openForm(initial) {
|
||||||
return new Promise(function (resolve, reject) {
|
return new Promise(function (resolve, reject) {
|
||||||
|
|
@ -159,15 +189,7 @@
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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() {
|
function close() {
|
||||||
document.removeEventListener('keydown', onKeydown);
|
|
||||||
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
||||||
}
|
}
|
||||||
box.querySelector('#acc-cancel').addEventListener('click', function () {
|
box.querySelector('#acc-cancel').addEventListener('click', function () {
|
||||||
|
|
@ -176,7 +198,12 @@
|
||||||
overlay.addEventListener('click', function (e) {
|
overlay.addEventListener('click', function (e) {
|
||||||
if (e.target === overlay) { close(); reject(new Error('cancelled')); }
|
if (e.target === overlay) { close(); reject(new Error('cancelled')); }
|
||||||
});
|
});
|
||||||
document.addEventListener('keydown', onKeydown);
|
document.addEventListener('keydown', function escHandler(e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
document.removeEventListener('keydown', escHandler);
|
||||||
|
close(); reject(new Error('cancelled'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
box.querySelector('#acc-submit').addEventListener('click', function () {
|
box.querySelector('#acc-submit').addEventListener('click', function () {
|
||||||
var values = {
|
var values = {
|
||||||
|
|
@ -200,7 +227,9 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
var quote = util.yamlQuote;
|
function quote(s) {
|
||||||
|
return '"' + String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
|
||||||
|
}
|
||||||
function buildBody(values) {
|
function buildBody(values) {
|
||||||
var lines = ['received_date: ' + values.receivedDate];
|
var lines = ['received_date: ' + values.receivedDate];
|
||||||
if (values.setupPlanReview) {
|
if (values.setupPlanReview) {
|
||||||
|
|
@ -214,10 +243,7 @@
|
||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
var busy = false;
|
|
||||||
|
|
||||||
async function invoke(node) {
|
async function invoke(node) {
|
||||||
if (busy) return;
|
|
||||||
var tree = window.app.modules.tree;
|
var tree = window.app.modules.tree;
|
||||||
if (!tree) return;
|
if (!tree) return;
|
||||||
var url = tree.pathFor(node);
|
var url = tree.pathFor(node);
|
||||||
|
|
@ -249,43 +275,34 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
busy = true;
|
status('Accept Transmittal — submitting…');
|
||||||
|
var resp;
|
||||||
try {
|
try {
|
||||||
status('Accept Transmittal — submitting…');
|
resp = await fetch(url, {
|
||||||
var resp;
|
method: 'POST',
|
||||||
try {
|
headers: {
|
||||||
resp = await fetch(url, {
|
'X-ZDDC-Op': 'accept-transmittal',
|
||||||
method: 'POST',
|
'Content-Type': 'application/yaml'
|
||||||
headers: {
|
},
|
||||||
'X-ZDDC-Op': 'accept-transmittal',
|
body: buildBody(values),
|
||||||
'Content-Type': 'application/yaml'
|
credentials: 'same-origin'
|
||||||
},
|
});
|
||||||
body: buildBody(values),
|
} catch (e) {
|
||||||
credentials: 'same-origin'
|
status('Accept failed: ' + (e && e.message ? e.message : e), 'error');
|
||||||
});
|
return;
|
||||||
} catch (e) {
|
|
||||||
status('Accept failed: ' + (e && e.message ? e.message : e), 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!resp.ok) {
|
|
||||||
var text = '';
|
|
||||||
try { text = await resp.text(); } catch (_e) { /* ignore */ }
|
|
||||||
status('Accept failed (' + resp.status + '): ' + text, 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var data; try { data = await resp.json(); } catch (_e) { data = null; }
|
|
||||||
var msg = 'Accepted ' + (data && data.moved_files ? data.moved_files : '?') + ' file(s) into '
|
|
||||||
+ (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, '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;
|
|
||||||
}
|
}
|
||||||
|
if (!resp.ok) {
|
||||||
|
var text = '';
|
||||||
|
try { text = await resp.text(); } catch (_e) { /* ignore */ }
|
||||||
|
status('Accept failed (' + resp.status + '): ' + text, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var data; try { data = await resp.json(); } catch (_e) { data = null; }
|
||||||
|
var msg = 'Accepted ' + (data && data.moved_files ? data.moved_files : '?') + ' file(s) into '
|
||||||
|
+ (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');
|
||||||
}
|
}
|
||||||
|
|
||||||
window.app.modules.acceptTransmittal = {
|
window.app.modules.acceptTransmittal = {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,17 @@
|
||||||
var tree = window.app.modules.tree;
|
var tree = window.app.modules.tree;
|
||||||
var events = window.app.modules.events;
|
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.
|
// Walk a `?file=` path segment-by-segment from the current root.
|
||||||
// Each non-leaf segment is matched against the parent's children
|
// 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
|
// by name; if found and it's a folder, expand+load it (so its
|
||||||
|
|
@ -121,26 +132,15 @@
|
||||||
var popQS = new URLSearchParams(location.search);
|
var popQS = new URLSearchParams(location.search);
|
||||||
if (popQS.get('hidden') === '1') window.app.state.showHidden = true;
|
if (popQS.get('hidden') === '1') window.app.state.showHidden = true;
|
||||||
else window.app.state.showHidden = false;
|
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 {
|
try {
|
||||||
var es = await loader.fetchServerChildren(path);
|
var es = await loader.fetchServerChildren(path);
|
||||||
if (events.isCurrentNav && !events.isCurrentNav(seq)) return;
|
|
||||||
window.app.state.currentPath = path;
|
window.app.state.currentPath = path;
|
||||||
window.app.state.selectedId = null;
|
window.app.state.selectedId = null;
|
||||||
window.app.state.lastPreviewedNodeId = null;
|
window.app.state.lastPreviewedNodeId = null;
|
||||||
tree.setRoot(es);
|
tree.setRoot(es);
|
||||||
tree.render();
|
tree.render();
|
||||||
// Route through clearPreview so a live editor is disposed
|
var previewBody = document.getElementById('previewBody');
|
||||||
// (not leaked) when back/forward swaps scope.
|
if (previewBody) previewBody.innerHTML = '';
|
||||||
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');
|
var previewTitle = document.getElementById('previewTitle');
|
||||||
if (previewTitle) previewTitle.textContent = 'No file selected';
|
if (previewTitle) previewTitle.textContent = 'No file selected';
|
||||||
// Reapply view mode for the new URL (incoming/ → grid, etc).
|
// Reapply view mode for the new URL (incoming/ → grid, etc).
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,17 @@
|
||||||
var t = window.zddc && window.zddc.toast;
|
var t = window.zddc && window.zddc.toast;
|
||||||
if (t) t(msg, level || 'info');
|
if (t) t(msg, level || 'info');
|
||||||
}
|
}
|
||||||
var util = window.app.modules.util;
|
function escapeHtml(s) {
|
||||||
var escapeHtml = util.escapeHtml;
|
return String(s).replace(/[&<>"']/g, function (c) {
|
||||||
var isoDateToday = util.isoDateToday;
|
return ({ '&':'&','<':'<','>':'>','"':'"',"'":''' })[c];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function isoDateToday() {
|
||||||
|
var d = new Date();
|
||||||
|
return d.getFullYear()
|
||||||
|
+ '-' + ('0' + (d.getMonth() + 1)).slice(-2)
|
||||||
|
+ '-' + ('0' + d.getDate()).slice(-2);
|
||||||
|
}
|
||||||
|
|
||||||
function openForm() {
|
function openForm() {
|
||||||
return new Promise(function (resolve, reject) {
|
return new Promise(function (resolve, reject) {
|
||||||
|
|
@ -70,22 +78,19 @@
|
||||||
input.addEventListener('input', revalidate);
|
input.addEventListener('input', revalidate);
|
||||||
revalidate();
|
revalidate();
|
||||||
|
|
||||||
// Escape handler bound once, removed in close() so it can't
|
function close() { if (overlay.parentNode) overlay.parentNode.removeChild(overlay); }
|
||||||
// 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 () {
|
box.querySelector('#ct-cancel').addEventListener('click', function () {
|
||||||
close(); reject(new Error('cancelled'));
|
close(); reject(new Error('cancelled'));
|
||||||
});
|
});
|
||||||
overlay.addEventListener('click', function (e) {
|
overlay.addEventListener('click', function (e) {
|
||||||
if (e.target === overlay) { close(); reject(new Error('cancelled')); }
|
if (e.target === overlay) { close(); reject(new Error('cancelled')); }
|
||||||
});
|
});
|
||||||
document.addEventListener('keydown', onKeydown);
|
document.addEventListener('keydown', function escHandler(e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
document.removeEventListener('keydown', escHandler);
|
||||||
|
close(); reject(new Error('cancelled'));
|
||||||
|
}
|
||||||
|
});
|
||||||
submit.addEventListener('click', function () {
|
submit.addEventListener('click', function () {
|
||||||
var v = input.value.trim();
|
var v = input.value.trim();
|
||||||
var parsed = window.zddc.parseFolder(v);
|
var parsed = window.zddc.parseFolder(v);
|
||||||
|
|
|
||||||
|
|
@ -44,12 +44,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trigger a download from a same-origin server URL via Content-Disposition.
|
// 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) {
|
function downloadUrl(filename, url) {
|
||||||
var a = document.createElement('a');
|
var a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
|
|
@ -103,12 +97,9 @@
|
||||||
var zip = new window.JSZip();
|
var zip = new window.JSZip();
|
||||||
for (var i = 0; i < files.length; i++) {
|
for (var i = 0; i < files.length; i++) {
|
||||||
ev.statusInfo('Zipping ' + rootHandle.name + '… (' + (i + 1) + '/' + files.length + ')');
|
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 f = await files[i].handle.getFile();
|
||||||
zip.file(rootHandle.name + '/' + files[i].relPath, f);
|
var buf = await f.arrayBuffer();
|
||||||
|
zip.file(rootHandle.name + '/' + files[i].relPath, buf);
|
||||||
}
|
}
|
||||||
ev.statusInfo('Generating ' + rootHandle.name + '.zip…');
|
ev.statusInfo('Generating ' + rootHandle.name + '.zip…');
|
||||||
var blob = await zip.generateAsync({ type: 'blob' });
|
var blob = await zip.generateAsync({ type: 'blob' });
|
||||||
|
|
|
||||||
|
|
@ -133,16 +133,6 @@
|
||||||
} catch (_e) { /* private browsing edge cases */ }
|
} 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() {
|
async function refreshListing() {
|
||||||
// Snapshot expanded paths + selection BEFORE setRoot clears the
|
// Snapshot expanded paths + selection BEFORE setRoot clears the
|
||||||
// tree, then re-apply after the new root is in place. Keeps
|
// tree, then re-apply after the new root is in place. Keeps
|
||||||
|
|
@ -151,7 +141,6 @@
|
||||||
// a refresh — including the auto-refresh triggered by the
|
// a refresh — including the auto-refresh triggered by the
|
||||||
// "Show hidden files" toggle.
|
// "Show hidden files" toggle.
|
||||||
var snap = tree.snapshotState();
|
var snap = tree.snapshotState();
|
||||||
var seq = beginNav();
|
|
||||||
if (state.source === 'server') {
|
if (state.source === 'server') {
|
||||||
var raw;
|
var raw;
|
||||||
try {
|
try {
|
||||||
|
|
@ -160,10 +149,8 @@
|
||||||
statusError('Refresh failed: ' + e.message);
|
statusError('Refresh failed: ' + e.message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isCurrentNav(seq)) return;
|
|
||||||
tree.setRoot(raw);
|
tree.setRoot(raw);
|
||||||
await tree.restoreState(snap);
|
await tree.restoreState(snap);
|
||||||
if (!isCurrentNav(seq)) return;
|
|
||||||
tree.render();
|
tree.render();
|
||||||
statusInfo('Refreshed (' + raw.length + ' item'
|
statusInfo('Refreshed (' + raw.length + ' item'
|
||||||
+ (raw.length === 1 ? '' : 's') + ')');
|
+ (raw.length === 1 ? '' : 's') + ')');
|
||||||
|
|
@ -175,10 +162,8 @@
|
||||||
statusError('Refresh failed: ' + e.message);
|
statusError('Refresh failed: ' + e.message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isCurrentNav(seq)) return;
|
|
||||||
tree.setRoot(raw2);
|
tree.setRoot(raw2);
|
||||||
await tree.restoreState(snap);
|
await tree.restoreState(snap);
|
||||||
if (!isCurrentNav(seq)) return;
|
|
||||||
tree.render();
|
tree.render();
|
||||||
statusInfo('Refreshed');
|
statusInfo('Refreshed');
|
||||||
}
|
}
|
||||||
|
|
@ -464,10 +449,7 @@
|
||||||
// selection-only; their preview is "expand to see inside".
|
// selection-only; their preview is "expand to see inside".
|
||||||
if (nextNode && !nextNode.isDir && !nextNode.isZip
|
if (nextNode && !nextNode.isDir && !nextNode.isZip
|
||||||
&& previewModule) {
|
&& previewModule) {
|
||||||
// auto:true — keyboard cursor walking the tree. If an
|
previewModule.showFilePreview(nextNode);
|
||||||
// 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;
|
state.lastPreviewedNodeId = nextId;
|
||||||
}
|
}
|
||||||
// Scroll the now-selected row into view.
|
// Scroll the now-selected row into view.
|
||||||
|
|
@ -678,7 +660,11 @@
|
||||||
return parentDir;
|
return parentDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
var escapeHtml = window.app.modules.util.escapeHtml;
|
function escapeHtml(s) {
|
||||||
|
return String(s).replace(/[&<>"']/g, function (c) {
|
||||||
|
return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Valid party folder name — mirrors zddc.ValidPartyName server-side
|
// Valid party folder name — mirrors zddc.ValidPartyName server-side
|
||||||
// (^[A-Za-z0-9][A-Za-z0-9.-]*$).
|
// (^[A-Za-z0-9][A-Za-z0-9.-]*$).
|
||||||
|
|
@ -883,20 +869,14 @@
|
||||||
var loader = window.app.modules.loader;
|
var loader = window.app.modules.loader;
|
||||||
if (!loader) return;
|
if (!loader) return;
|
||||||
if (!dirPath.endsWith('/')) dirPath += '/';
|
if (!dirPath.endsWith('/')) dirPath += '/';
|
||||||
var seq = beginNav();
|
|
||||||
// Root-scope reload — refresh the visible top-level listing.
|
// Root-scope reload — refresh the visible top-level listing.
|
||||||
if (dirPath === state.currentPath) {
|
if (dirPath === state.currentPath) {
|
||||||
var es;
|
|
||||||
try {
|
try {
|
||||||
es = state.source === 'server'
|
var es = state.source === 'server'
|
||||||
? await loader.fetchServerChildren(dirPath)
|
? await loader.fetchServerChildren(dirPath)
|
||||||
: (state.rootHandle ? await loader.fetchFsChildren(state.rootHandle) : []);
|
: (state.rootHandle ? await loader.fetchFsChildren(state.rootHandle) : []);
|
||||||
} catch (e) {
|
tree.setRoot(es);
|
||||||
statusError('Reload failed: ' + (e.message || e));
|
} catch (_e) { /* swallow */ }
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!isCurrentNav(seq)) return;
|
|
||||||
tree.setRoot(es);
|
|
||||||
tree.render();
|
tree.render();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -908,18 +888,13 @@
|
||||||
if (tree.pathFor(n).replace(/\/$/, '') === noSlash) hit = n;
|
if (tree.pathFor(n).replace(/\/$/, '') === noSlash) hit = n;
|
||||||
});
|
});
|
||||||
if (hit) {
|
if (hit) {
|
||||||
var raw;
|
|
||||||
try {
|
try {
|
||||||
raw = state.source === 'server'
|
var raw = state.source === 'server'
|
||||||
? await loader.fetchServerChildren(dirPath)
|
? await loader.fetchServerChildren(dirPath)
|
||||||
: (hit.handle ? await loader.fetchFsChildren(hit.handle) : []);
|
: (hit.handle ? await loader.fetchFsChildren(hit.handle) : []);
|
||||||
} catch (e) {
|
tree.setChildren(hit.id, raw);
|
||||||
statusError('Reload failed: ' + (e.message || e));
|
hit.expanded = true;
|
||||||
return;
|
} catch (_e) { /* swallow */ }
|
||||||
}
|
|
||||||
if (!isCurrentNav(seq)) return;
|
|
||||||
tree.setChildren(hit.id, raw);
|
|
||||||
hit.expanded = true;
|
|
||||||
tree.render();
|
tree.render();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1396,7 +1371,8 @@
|
||||||
}
|
}
|
||||||
if (state.source === 'fs') {
|
if (state.source === 'fs') {
|
||||||
if (!node.handle || node.handle.kind !== 'directory') return;
|
if (!node.handle || node.handle.kind !== 'directory') return;
|
||||||
var seq = beginNav();
|
state.rootHandle = node.handle;
|
||||||
|
state.currentPath = node.handle.name + '/';
|
||||||
var raw;
|
var raw;
|
||||||
try {
|
try {
|
||||||
raw = await loader.fetchFsChildren(node.handle);
|
raw = await loader.fetchFsChildren(node.handle);
|
||||||
|
|
@ -1404,12 +1380,6 @@
|
||||||
statusError('Failed to enter ' + node.name + ': ' + e.message);
|
statusError('Failed to enter ' + node.name + ': ' + e.message);
|
||||||
return;
|
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.setRoot(raw);
|
||||||
tree.render();
|
tree.render();
|
||||||
statusInfo('Entered ' + node.name);
|
statusInfo('Entered ' + node.name);
|
||||||
|
|
@ -1420,7 +1390,6 @@
|
||||||
// history.pushState, fetches the new directory listing, and
|
// history.pushState, fetches the new directory listing, and
|
||||||
// re-renders the tree from scratch. Page DOES NOT reload.
|
// re-renders the tree from scratch. Page DOES NOT reload.
|
||||||
async function rescopeServer(url, displayName) {
|
async function rescopeServer(url, displayName) {
|
||||||
var seq = beginNav();
|
|
||||||
var entries;
|
var entries;
|
||||||
try {
|
try {
|
||||||
entries = await loader.fetchServerChildren(url);
|
entries = await loader.fetchServerChildren(url);
|
||||||
|
|
@ -1428,10 +1397,6 @@
|
||||||
statusError('Failed to enter ' + displayName + ': ' + (e.message || e));
|
statusError('Failed to enter ' + displayName + ': ' + (e.message || e));
|
||||||
return;
|
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;
|
state.currentPath = url;
|
||||||
// Selection / preview belong to the old scope; clear them so
|
// Selection / preview belong to the old scope; clear them so
|
||||||
// the new root doesn't carry stale highlight state.
|
// the new root doesn't carry stale highlight state.
|
||||||
|
|
@ -1443,14 +1408,9 @@
|
||||||
tree.setRoot(entries);
|
tree.setRoot(entries);
|
||||||
tree.render();
|
tree.render();
|
||||||
// Reset the preview pane so the user sees an "empty selection"
|
// Reset the preview pane so the user sees an "empty selection"
|
||||||
// state at the new scope instead of the previous file. Route
|
// state at the new scope instead of the previous file.
|
||||||
// through clearPreview so a live editor is disposed (not leaked).
|
var previewBody = document.getElementById('previewBody');
|
||||||
var pmod = previewMod();
|
if (previewBody) previewBody.innerHTML = '';
|
||||||
if (pmod && pmod.clearPreview) pmod.clearPreview();
|
|
||||||
else {
|
|
||||||
var previewBody = document.getElementById('previewBody');
|
|
||||||
if (previewBody) previewBody.innerHTML = '';
|
|
||||||
}
|
|
||||||
var previewTitle = document.getElementById('previewTitle');
|
var previewTitle = document.getElementById('previewTitle');
|
||||||
if (previewTitle) previewTitle.textContent = 'No file selected';
|
if (previewTitle) previewTitle.textContent = 'No file selected';
|
||||||
var previewMeta = document.getElementById('previewMeta');
|
var previewMeta = document.getElementById('previewMeta');
|
||||||
|
|
@ -1479,16 +1439,6 @@
|
||||||
statusInfo: statusInfo,
|
statusInfo: statusInfo,
|
||||||
statusClear: statusClear,
|
statusClear: statusClear,
|
||||||
showBrowseRoot: showBrowseRoot,
|
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,7 +16,11 @@
|
||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
var escapeHtml = window.app.modules.util.escapeHtml;
|
function escapeHtml(s) {
|
||||||
|
return String(s == null ? '' : s)
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
function toast(msg, kind) {
|
function toast(msg, kind) {
|
||||||
if (window.zddc && typeof window.zddc.toast === 'function') {
|
if (window.zddc && typeof window.zddc.toast === 'function') {
|
||||||
|
|
@ -36,7 +40,12 @@
|
||||||
return d.toLocaleString();
|
return d.toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
var fmtBytes = window.app.modules.util.fmtSize;
|
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';
|
||||||
|
}
|
||||||
|
|
||||||
// Can the principal write (restore) to this file? Mirrors the
|
// Can the principal write (restore) to this file? Mirrors the
|
||||||
// events.js Rename/Delete gating: verbs===undefined means a non-zddc
|
// events.js Rename/Delete gating: verbs===undefined means a non-zddc
|
||||||
|
|
@ -337,10 +346,6 @@
|
||||||
if (!confirm('Restore the version from ' + fmtTime(ent.ts) + '?\nThis is saved as a new version — nothing is lost.')) {
|
if (!confirm('Restore the version from ' + fmtTime(ent.ts) + '?\nThis is saved as a new version — nothing is lost.')) {
|
||||||
return;
|
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 {
|
try {
|
||||||
var text = await fetchVersion(node, ent.id);
|
var text = await fetchVersion(node, ent.id);
|
||||||
var resp = await fetch(node.url, {
|
var resp = await fetch(node.url, {
|
||||||
|
|
@ -350,22 +355,18 @@
|
||||||
body: text
|
body: text
|
||||||
});
|
});
|
||||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||||
} catch (e) {
|
toast('Restored version from ' + fmtTime(ent.ts), 'success');
|
||||||
toast('Restore failed: ' + (e.message || e), 'error');
|
// Reflect the new head: refetch the list.
|
||||||
return;
|
|
||||||
}
|
|
||||||
toast('Restored version from ' + fmtTime(ent.ts), 'success');
|
|
||||||
// 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);
|
var entries = await fetchList(node);
|
||||||
renderList(modal, node, entries);
|
renderList(modal, node, entries);
|
||||||
// If the file is open in the preview pane, reload it.
|
// If the file is open in the preview pane, reload it.
|
||||||
var preview = window.app && window.app.modules && window.app.modules.preview;
|
var preview = window.app && window.app.modules && window.app.modules.preview;
|
||||||
if (preview && typeof preview.showFilePreview === 'function') {
|
if (preview && typeof preview.showFilePreview === 'function') {
|
||||||
preview.showFilePreview(node);
|
try { preview.showFilePreview(node); } catch (_e) { /* best effort */ }
|
||||||
}
|
}
|
||||||
} catch (_e) { /* refresh is best-effort; restore is done */ }
|
} catch (e) {
|
||||||
|
toast('Restore failed: ' + (e.message || e), 'error');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Entry point ─────────────────────────────────────────────────────
|
// ── Entry point ─────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -58,10 +58,22 @@
|
||||||
currentRow = null;
|
currentRow = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Formatting ──
|
// ── Formatting (kept local so this module is self-contained) ──
|
||||||
|
|
||||||
var escapeHtml = window.app.modules.util.escapeHtml;
|
function escapeHtml(s) {
|
||||||
var fmtSize = window.app.modules.util.fmtSize;
|
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';
|
||||||
|
}
|
||||||
|
|
||||||
function fmtDate(d) {
|
function fmtDate(d) {
|
||||||
if (!d) return '';
|
if (!d) return '';
|
||||||
|
|
|
||||||
|
|
@ -69,19 +69,8 @@
|
||||||
// scopeDropTarget: cascade's drop_target at currentPath
|
// scopeDropTarget: cascade's drop_target at currentPath
|
||||||
// scopeDefaultTool: cascade's default_tool at currentPath
|
// scopeDefaultTool: cascade's default_tool at currentPath
|
||||||
// (empty when no default declared)
|
// (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,
|
scopeDropTarget: false,
|
||||||
scopeDefaultTool: '',
|
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
|
// Autofilter — when non-empty, the tree hides files that
|
||||||
// don't match and folders whose subtree has no matches.
|
// don't match and folders whose subtree has no matches.
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,10 @@
|
||||||
|
|
||||||
var state = window.app.state;
|
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) {
|
function splitExt(name) {
|
||||||
return window.zddc.splitExtension(name).extension;
|
var i = name.lastIndexOf('.');
|
||||||
|
if (i <= 0 || i === name.length - 1) return '';
|
||||||
|
return name.substring(i + 1).toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build a raw entry from the server's FileInfo shape.
|
// Build a raw entry from the server's FileInfo shape.
|
||||||
|
|
@ -224,6 +222,7 @@
|
||||||
fetchServerChildren: fetchServerChildren,
|
fetchServerChildren: fetchServerChildren,
|
||||||
fetchFsChildren: fetchFsChildren,
|
fetchFsChildren: fetchFsChildren,
|
||||||
autoDetectServerMode: autoDetectServerMode,
|
autoDetectServerMode: autoDetectServerMode,
|
||||||
|
splitExt: splitExt,
|
||||||
ensureJSZip: ensureJSZip
|
ensureJSZip: ensureJSZip
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -45,18 +45,44 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var util = window.app.modules.util;
|
// Compute today + N days as a YYYY-MM-DD string.
|
||||||
var isoDatePlus = util.isoDatePlus;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch suggestion emails from /.profile/access so the originator
|
// Fetch suggestion emails from /.profile/access so the originator
|
||||||
// field has a datalist of likely values. Best-effort — silent on
|
// field has a datalist of likely values. Best-effort — silent on
|
||||||
// failure (the field still accepts free text).
|
// failure (the field still accepts free text).
|
||||||
var fetchOriginatorSuggestions = util.fetchAccessEmails;
|
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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Build the YAML body for the plan-review POST. Quoting is minimal
|
// Build the YAML body for the plan-review POST. Quoting is minimal
|
||||||
// (just enough for emails with special chars).
|
// (just enough for emails with special chars).
|
||||||
function buildBody(values) {
|
function buildBody(values) {
|
||||||
var yamlString = util.yamlQuote;
|
function yamlString(s) {
|
||||||
|
return '"' + String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
|
||||||
|
}
|
||||||
return [
|
return [
|
||||||
'review_lead: ' + yamlString(values.reviewLead),
|
'review_lead: ' + yamlString(values.reviewLead),
|
||||||
'approver: ' + yamlString(values.approver),
|
'approver: ' + yamlString(values.approver),
|
||||||
|
|
@ -119,14 +145,7 @@
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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() {
|
function close() {
|
||||||
document.removeEventListener('keydown', onKeydown);
|
|
||||||
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -140,7 +159,13 @@
|
||||||
reject(new Error('cancelled'));
|
reject(new Error('cancelled'));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
document.addEventListener('keydown', onKeydown);
|
document.addEventListener('keydown', function escHandler(e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
document.removeEventListener('keydown', escHandler);
|
||||||
|
close();
|
||||||
|
reject(new Error('cancelled'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
box.querySelector('#pr-submit').addEventListener('click', function () {
|
box.querySelector('#pr-submit').addEventListener('click', function () {
|
||||||
var values = {
|
var values = {
|
||||||
|
|
@ -162,7 +187,14 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
var escapeHtml = util.escapeHtml;
|
function escapeHtml(s) {
|
||||||
|
return String(s).replace(/[&<>"']/g, function (c) {
|
||||||
|
return ({
|
||||||
|
'&': '&', '<': '<', '>': '>',
|
||||||
|
'"': '"', "'": '''
|
||||||
|
})[c];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Detect whether a tree node is an archive/<party>/received/<tracking>/
|
// Detect whether a tree node is an archive/<party>/received/<tracking>/
|
||||||
// folder. The path is path-shaped, not content-based — tracking-number
|
// folder. The path is path-shaped, not content-based — tracking-number
|
||||||
|
|
@ -179,11 +211,8 @@
|
||||||
&& parts[3].toLowerCase() === 'received';
|
&& parts[3].toLowerCase() === 'received';
|
||||||
}
|
}
|
||||||
|
|
||||||
var busy = false;
|
|
||||||
|
|
||||||
// Run the Plan Review flow: open the modal, POST the result.
|
// Run the Plan Review flow: open the modal, POST the result.
|
||||||
async function invoke(node) {
|
async function invoke(node) {
|
||||||
if (busy) return;
|
|
||||||
var tree = window.app.modules.tree;
|
var tree = window.app.modules.tree;
|
||||||
if (!tree) return;
|
if (!tree) return;
|
||||||
var url = tree.pathFor(node);
|
var url = tree.pathFor(node);
|
||||||
|
|
@ -198,48 +227,43 @@
|
||||||
return; // cancelled
|
return; // cancelled
|
||||||
}
|
}
|
||||||
|
|
||||||
busy = true;
|
statusInfo('Plan Review — submitting…');
|
||||||
|
var body = buildBody(values);
|
||||||
|
var resp;
|
||||||
try {
|
try {
|
||||||
statusInfo('Plan Review — submitting…');
|
resp = await fetch(url, {
|
||||||
var body = buildBody(values);
|
method: 'POST',
|
||||||
var resp;
|
headers: {
|
||||||
try {
|
'X-ZDDC-Op': 'plan-review',
|
||||||
resp = await fetch(url, {
|
'Content-Type': 'application/yaml'
|
||||||
method: 'POST',
|
},
|
||||||
headers: {
|
body: body,
|
||||||
'X-ZDDC-Op': 'plan-review',
|
credentials: 'same-origin'
|
||||||
'Content-Type': 'application/yaml'
|
});
|
||||||
},
|
} catch (e) {
|
||||||
body: body,
|
statusError('Plan Review failed: ' + (e && e.message ? e.message : e));
|
||||||
credentials: 'same-origin'
|
return;
|
||||||
});
|
}
|
||||||
} catch (e) {
|
if (!resp.ok) {
|
||||||
statusError('Plan Review failed: ' + (e && e.message ? e.message : e));
|
var text = '';
|
||||||
return;
|
try { text = await resp.text(); } catch (_e) { /* ignore */ }
|
||||||
}
|
statusError('Plan Review failed (' + resp.status + '): ' + text);
|
||||||
if (!resp.ok) {
|
return;
|
||||||
var text = '';
|
}
|
||||||
try { text = await resp.text(); } catch (_e) { /* ignore */ }
|
var data;
|
||||||
statusError('Plan Review failed (' + resp.status + '): ' + text);
|
try { data = await resp.json(); } catch (_e) { data = null; }
|
||||||
return;
|
if (data && data.reviewing && data.staging) {
|
||||||
}
|
var rPart = data.reviewing.created ? 'created' : 'updated';
|
||||||
var data;
|
var sPart = data.staging.created ? 'created' : 'updated';
|
||||||
try { data = await resp.json(); } catch (_e) { data = null; }
|
var seal = (data.received && data.received.created)
|
||||||
if (data && data.reviewing && data.staging) {
|
? ' Canonical record sealed.'
|
||||||
var rPart = data.reviewing.created ? 'created' : 'updated';
|
: (data.received && !data.received.zddc_written)
|
||||||
var sPart = data.staging.created ? 'created' : 'updated';
|
? ' Canonical dates left untouched (already sealed).'
|
||||||
var seal = (data.received && data.received.created)
|
: '';
|
||||||
? ' Canonical record sealed.'
|
statusInfo('Plan Review: reviewing ' + rPart + ', staging ' + sPart + '.' + seal +
|
||||||
: (data.received && !data.received.zddc_written)
|
' Reload the relevant folder to see the new entries.');
|
||||||
? ' Canonical dates left untouched (already sealed).'
|
} else {
|
||||||
: '';
|
statusInfo('Plan Review complete.');
|
||||||
statusInfo('Plan Review: reviewing ' + rPart + ', staging ' + sPart + '.' + seal +
|
|
||||||
' Reload the relevant folder to see the new entries.');
|
|
||||||
} else {
|
|
||||||
statusInfo('Plan Review complete.');
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
busy = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,39 +42,34 @@
|
||||||
var SIDEBAR_DEFAULT_WIDTH = 280;
|
var SIDEBAR_DEFAULT_WIDTH = 280;
|
||||||
var FM_DEFAULT_HEIGHT = 180; // px — front-matter pane height inside sidebar
|
var FM_DEFAULT_HEIGHT = 180; // px — front-matter pane height inside sidebar
|
||||||
|
|
||||||
var util = window.app.modules.util;
|
function escapeHtml(s) {
|
||||||
var escapeHtml = util.escapeHtml;
|
return String(s).replace(/&/g, '&').replace(/</g, '<')
|
||||||
var hashContent = util.hashContent;
|
.replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
var currentInstance = null; // { editor, container, dirty, node, hash, tocEl, fmEl }
|
var currentInstance = null; // { editor, container, dirty, node, hash, tocEl, fmEl }
|
||||||
var lastSidebarWidth = SIDEBAR_DEFAULT_WIDTH; // remember across mounts
|
var lastSidebarWidth = SIDEBAR_DEFAULT_WIDTH; // remember across mounts
|
||||||
var lastFmHeight = FM_DEFAULT_HEIGHT;
|
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() {
|
function dispose() {
|
||||||
if (currentInstance) {
|
if (currentInstance && currentInstance.editor) {
|
||||||
// Tear down the document-level resizer drag listeners (added
|
try { currentInstance.editor.destroy(); } catch (_) { /* ignore */ }
|
||||||
// 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;
|
currentInstance = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isDirty() {
|
|
||||||
return !!(currentInstance && currentInstance.dirty);
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentNode() {
|
|
||||||
return currentInstance ? currentInstance.node : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Front matter ────────────────────────────────────────────────────────
|
// ── Front matter ────────────────────────────────────────────────────────
|
||||||
// Lightweight YAML front-matter parser. Same envelope as mdedit's:
|
// Lightweight YAML front-matter parser. Same envelope as mdedit's:
|
||||||
// `---\n…\n---\n`, key:value lines, simple `[a, b, c]` arrays.
|
// `---\n…\n---\n`, key:value lines, simple `[a, b, c]` arrays.
|
||||||
|
|
@ -278,11 +273,38 @@
|
||||||
|
|
||||||
// ── Save ────────────────────────────────────────────────────────────────
|
// ── Save ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function saveContent(node, content) {
|
async function saveContent(node, content) {
|
||||||
return util.saveFile(node, content, 'text/markdown; charset=utf-8');
|
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).');
|
||||||
}
|
}
|
||||||
|
|
||||||
var isZipMemberNode = util.isZipMemberNode;
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
function canSave(node) {
|
function canSave(node) {
|
||||||
if (isZipMemberNode(node)) return false;
|
if (isZipMemberNode(node)) return false;
|
||||||
|
|
@ -542,20 +564,15 @@
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// One AbortController per mount — wired into the document-level
|
currentInstance = {
|
||||||
// resizer listeners below so dispose() can detach them all at once.
|
|
||||||
var ac = new AbortController();
|
|
||||||
var instance = {
|
|
||||||
editor: editor,
|
editor: editor,
|
||||||
container: container,
|
container: container,
|
||||||
dirty: false,
|
dirty: false,
|
||||||
node: node,
|
node: node,
|
||||||
hash: initialHash,
|
hash: initialHash,
|
||||||
tocEl: tocBody,
|
tocEl: tocBody,
|
||||||
fmEl: fmTextarea,
|
fmEl: fmTextarea
|
||||||
ac: ac
|
|
||||||
};
|
};
|
||||||
currentInstance = instance;
|
|
||||||
|
|
||||||
if (!writableMode) {
|
if (!writableMode) {
|
||||||
saveBtn.disabled = true;
|
saveBtn.disabled = true;
|
||||||
|
|
@ -592,8 +609,8 @@
|
||||||
resizer.classList.add('is-dragging');
|
resizer.classList.add('is-dragging');
|
||||||
startX = e.clientX;
|
startX = e.clientX;
|
||||||
startW = sidebar.getBoundingClientRect().width;
|
startW = sidebar.getBoundingClientRect().width;
|
||||||
document.addEventListener('mousemove', onMove, { signal: ac.signal });
|
document.addEventListener('mousemove', onMove);
|
||||||
document.addEventListener('mouseup', onUp, { signal: ac.signal });
|
document.addEventListener('mouseup', onUp);
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
});
|
});
|
||||||
resizer.addEventListener('keydown', function (e) {
|
resizer.addEventListener('keydown', function (e) {
|
||||||
|
|
@ -637,8 +654,8 @@
|
||||||
fmResizer.classList.add('is-dragging');
|
fmResizer.classList.add('is-dragging');
|
||||||
startY = e.clientY;
|
startY = e.clientY;
|
||||||
startH = fmSection.getBoundingClientRect().height;
|
startH = fmSection.getBoundingClientRect().height;
|
||||||
document.addEventListener('mousemove', onMove, { signal: ac.signal });
|
document.addEventListener('mousemove', onMove);
|
||||||
document.addEventListener('mouseup', onUp, { signal: ac.signal });
|
document.addEventListener('mouseup', onUp);
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
});
|
});
|
||||||
fmResizer.addEventListener('keydown', function (e) {
|
fmResizer.addEventListener('keydown', function (e) {
|
||||||
|
|
@ -653,8 +670,7 @@
|
||||||
|
|
||||||
// ── Change tracking + auto-rerender ────────────────────────────────
|
// ── Change tracking + auto-rerender ────────────────────────────────
|
||||||
function markDirty(isDirty) {
|
function markDirty(isDirty) {
|
||||||
if (currentInstance !== instance) return; // editor replaced
|
currentInstance.dirty = isDirty;
|
||||||
instance.dirty = isDirty;
|
|
||||||
// Re-read canSave at every transition, not via a closure-captured
|
// Re-read canSave at every transition, not via a closure-captured
|
||||||
// value, so the gate reflects current write authority — see the
|
// value, so the gate reflects current write authority — see the
|
||||||
// matching pattern in preview-yaml.js.
|
// matching pattern in preview-yaml.js.
|
||||||
|
|
@ -662,40 +678,29 @@
|
||||||
dirtyEl.textContent = isDirty ? '● modified' : '';
|
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 () {
|
var onChange = debounce(async function () {
|
||||||
if (currentInstance !== instance) return;
|
|
||||||
var body = editor.getMarkdown();
|
var body = editor.getMarkdown();
|
||||||
var h = await hashContent(assembleContent(fmTextarea.value, body));
|
var h = await hashContent(assembleContent(fmTextarea.value, body));
|
||||||
if (currentInstance !== instance) return;
|
markDirty(h !== currentInstance.hash);
|
||||||
markDirty(h !== instance.hash);
|
|
||||||
renderToc(tocBody, body, editor);
|
renderToc(tocBody, body, editor);
|
||||||
}, 250);
|
}, 250);
|
||||||
editor.on('change', onChange);
|
editor.on('change', onChange);
|
||||||
|
|
||||||
var onFmChange = debounce(async function () {
|
var onFmChange = debounce(async function () {
|
||||||
if (currentInstance !== instance) return;
|
|
||||||
var body = editor.getMarkdown();
|
var body = editor.getMarkdown();
|
||||||
var h = await hashContent(assembleContent(fmTextarea.value, body));
|
var h = await hashContent(assembleContent(fmTextarea.value, body));
|
||||||
if (currentInstance !== instance) return;
|
markDirty(h !== currentInstance.hash);
|
||||||
markDirty(h !== instance.hash);
|
|
||||||
}, 250);
|
}, 250);
|
||||||
fmTextarea.addEventListener('input', onFmChange);
|
fmTextarea.addEventListener('input', onFmChange);
|
||||||
|
|
||||||
// ── Save ───────────────────────────────────────────────────────────
|
// ── Save ───────────────────────────────────────────────────────────
|
||||||
async function save() {
|
async function save() {
|
||||||
if (currentInstance !== instance) return;
|
if (!currentInstance.dirty || !canSave(node)) return;
|
||||||
if (!instance.dirty || !canSave(node)) return;
|
|
||||||
var content = assembleContent(fmTextarea.value, editor.getMarkdown());
|
var content = assembleContent(fmTextarea.value, editor.getMarkdown());
|
||||||
try {
|
try {
|
||||||
statusEl.textContent = 'Saving…';
|
statusEl.textContent = 'Saving…';
|
||||||
await saveContent(node, content);
|
await saveContent(node, content);
|
||||||
if (currentInstance !== instance) return; // switched away mid-save
|
currentInstance.hash = await hashContent(content);
|
||||||
instance.hash = await hashContent(content);
|
|
||||||
markDirty(false);
|
markDirty(false);
|
||||||
statusEl.textContent = 'Saved ' + new Date().toLocaleTimeString();
|
statusEl.textContent = 'Saved ' + new Date().toLocaleTimeString();
|
||||||
if (window.zddc && window.zddc.toast) {
|
if (window.zddc && window.zddc.toast) {
|
||||||
|
|
@ -727,7 +732,7 @@
|
||||||
convertBtns.forEach(function (a) {
|
convertBtns.forEach(function (a) {
|
||||||
a.addEventListener('click', async function (e) {
|
a.addEventListener('click', async function (e) {
|
||||||
var fmt = a.dataset.fmt;
|
var fmt = a.dataset.fmt;
|
||||||
if (!instance.dirty) {
|
if (!currentInstance.dirty) {
|
||||||
// Clean — let the browser handle the click. The
|
// Clean — let the browser handle the click. The
|
||||||
// server's response (DOCX/HTML/PDF bytes, 422,
|
// server's response (DOCX/HTML/PDF bytes, 422,
|
||||||
// 503, etc.) lands in whatever target the user
|
// 503, etc.) lands in whatever target the user
|
||||||
|
|
@ -746,7 +751,7 @@
|
||||||
}
|
}
|
||||||
statusEl.textContent = 'Saving before download…';
|
statusEl.textContent = 'Saving before download…';
|
||||||
try { await save(); } catch (_) { /* save() surfaces its own error */ }
|
try { await save(); } catch (_) { /* save() surfaces its own error */ }
|
||||||
if (currentInstance !== instance || instance.dirty) return; // save failed / switched away
|
if (currentInstance.dirty) return; // save failed; toast already shown
|
||||||
statusEl.textContent = 'Downloading ' + fmt.toUpperCase() + '…';
|
statusEl.textContent = 'Downloading ' + fmt.toUpperCase() + '…';
|
||||||
// Re-trigger the click. dirty=false now so the handler
|
// Re-trigger the click. dirty=false now so the handler
|
||||||
// exits early on the second pass and the browser
|
// exits early on the second pass and the browser
|
||||||
|
|
@ -758,8 +763,6 @@
|
||||||
|
|
||||||
window.app.modules.markdown = {
|
window.app.modules.markdown = {
|
||||||
render: render,
|
render: render,
|
||||||
dispose: dispose,
|
dispose: dispose
|
||||||
isDirty: isDirty,
|
|
||||||
currentNode: currentNode
|
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,10 @@
|
||||||
|
|
||||||
if (!window.app || !window.app.modules) return;
|
if (!window.app || !window.app.modules) return;
|
||||||
|
|
||||||
var util = window.app.modules.util;
|
function escapeHtml(s) {
|
||||||
var escapeHtml = util.escapeHtml;
|
return String(s).replace(/&/g, '&').replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
// ── Filename routing ────────────────────────────────────────────────────
|
// ── Filename routing ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -45,14 +47,32 @@
|
||||||
|
|
||||||
// ── Save (mirrors preview-markdown.js) ─────────────────────────────────
|
// ── Save (mirrors preview-markdown.js) ─────────────────────────────────
|
||||||
|
|
||||||
function saveContent(node, content) {
|
async function saveContent(node, content) {
|
||||||
// Via the shared saveFile so local (FS-Access) saves escalate to
|
if (node.handle && typeof node.handle.createWritable === 'function') {
|
||||||
// readwrite the same as the markdown editor — previously this path
|
var writable = await node.handle.createWritable();
|
||||||
// skipped ensureWritable and failed on read-only-picked folders.
|
await writable.write(content);
|
||||||
return util.saveFile(node, content, 'application/x-yaml; charset=utf-8');
|
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).');
|
||||||
}
|
}
|
||||||
|
|
||||||
var isZipMemberNode = util.isZipMemberNode;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
function canSave(node) {
|
function canSave(node) {
|
||||||
if (isZipMemberNode(node)) return false;
|
if (isZipMemberNode(node)) return false;
|
||||||
|
|
@ -76,7 +96,17 @@
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
var hashContent = util.hashContent;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
// ── .zddc schema ────────────────────────────────────────────────────────
|
// ── .zddc schema ────────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
|
|
@ -348,24 +378,12 @@
|
||||||
// ── Mount ───────────────────────────────────────────────────────────────
|
// ── Mount ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
var currentEditor = null;
|
var currentEditor = null;
|
||||||
var currentDirty = false;
|
|
||||||
var currentNodeRef = null;
|
|
||||||
|
|
||||||
function dispose() {
|
function dispose() {
|
||||||
// CM doesn't have an explicit destroy(); GC handles it once
|
// CM doesn't have an explicit destroy(); GC handles it once
|
||||||
// the host element is removed. Clear our reference so a stale
|
// the host element is removed. Clear our reference so a stale
|
||||||
// editor doesn't keep handlers alive.
|
// editor doesn't keep handlers alive.
|
||||||
currentEditor = null;
|
currentEditor = null;
|
||||||
currentDirty = false;
|
|
||||||
currentNodeRef = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isDirty() {
|
|
||||||
return currentDirty;
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentNode() {
|
|
||||||
return currentNodeRef;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function render(node, container, ctx) {
|
async function render(node, container, ctx) {
|
||||||
|
|
@ -481,8 +499,6 @@
|
||||||
// Force an initial lint pass now that _zddcNode is set.
|
// Force an initial lint pass now that _zddcNode is set.
|
||||||
editor.performLint();
|
editor.performLint();
|
||||||
currentEditor = editor;
|
currentEditor = editor;
|
||||||
currentNodeRef = node;
|
|
||||||
currentDirty = false;
|
|
||||||
|
|
||||||
if (!writable) {
|
if (!writable) {
|
||||||
saveBtn.disabled = true;
|
saveBtn.disabled = true;
|
||||||
|
|
@ -498,16 +514,12 @@
|
||||||
var initialHash = await hashContent(text);
|
var initialHash = await hashContent(text);
|
||||||
|
|
||||||
function markDirty(isDirty) {
|
function markDirty(isDirty) {
|
||||||
if (currentEditor !== editor) return; // editor replaced
|
|
||||||
currentDirty = isDirty;
|
|
||||||
saveBtn.disabled = !isDirty || !canSave(node);
|
saveBtn.disabled = !isDirty || !canSave(node);
|
||||||
dirtyEl.textContent = isDirty ? '● modified' : '';
|
dirtyEl.textContent = isDirty ? '● modified' : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
editor.on('change', async function () {
|
editor.on('change', async function () {
|
||||||
if (currentEditor !== editor) return; // switched away
|
|
||||||
var h = await hashContent(editor.getValue());
|
var h = await hashContent(editor.getValue());
|
||||||
if (currentEditor !== editor) return; // replaced during await
|
|
||||||
markDirty(h !== initialHash);
|
markDirty(h !== initialHash);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -552,9 +564,6 @@
|
||||||
|
|
||||||
window.app.modules.yamledit = {
|
window.app.modules.yamledit = {
|
||||||
handles: handles,
|
handles: handles,
|
||||||
render: render,
|
render: render
|
||||||
dispose: dispose,
|
|
||||||
isDirty: isDirty,
|
|
||||||
currentNode: currentNode
|
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,10 @@
|
||||||
console.error('[browse] zddc.preview not loaded — preview disabled.');
|
console.error('[browse] zddc.preview not loaded — preview disabled.');
|
||||||
}
|
}
|
||||||
|
|
||||||
var util = window.app.modules.util;
|
function escapeHtml(s) {
|
||||||
var escapeHtml = util.escapeHtml;
|
return String(s).replace(/&/g, '&').replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
var MIME = {
|
var MIME = {
|
||||||
'pdf': 'application/pdf',
|
'pdf': 'application/pdf',
|
||||||
|
|
@ -39,7 +41,13 @@
|
||||||
|
|
||||||
function getMime(ext) { return MIME[ext] || 'application/octet-stream'; }
|
function getMime(ext) { return MIME[ext] || 'application/octet-stream'; }
|
||||||
|
|
||||||
var fmtSize = util.fmtSize;
|
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';
|
||||||
|
}
|
||||||
|
|
||||||
async function getArrayBuffer(node) {
|
async function getArrayBuffer(node) {
|
||||||
// A zip member node carries a ZipFileHandle in node.handle, so
|
// A zip member node carries a ZipFileHandle in node.handle, so
|
||||||
|
|
@ -68,62 +76,8 @@
|
||||||
return { url: URL.createObjectURL(blob), fromServer: false };
|
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 ────────────────────────────────────────────────────
|
// ── 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) {
|
function renderEmpty(container, msg) {
|
||||||
container.innerHTML = '<div class="preview-empty">' + escapeHtml(msg) + '</div>';
|
container.innerHTML = '<div class="preview-empty">' + escapeHtml(msg) + '</div>';
|
||||||
}
|
}
|
||||||
|
|
@ -133,37 +87,13 @@
|
||||||
+ escapeHtml(msg) + '</div>';
|
+ escapeHtml(msg) + '</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderInline(node, opts) {
|
async function renderInline(node) {
|
||||||
opts = opts || {};
|
|
||||||
var container = document.getElementById('previewBody');
|
var container = document.getElementById('previewBody');
|
||||||
var titleEl = document.getElementById('previewTitle');
|
var titleEl = document.getElementById('previewTitle');
|
||||||
var metaEl = document.getElementById('previewMeta');
|
var metaEl = document.getElementById('previewMeta');
|
||||||
var popoutBtn = document.getElementById('previewPopout');
|
var popoutBtn = document.getElementById('previewPopout');
|
||||||
if (!container) return;
|
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 (titleEl) titleEl.textContent = node.name;
|
||||||
if (metaEl) {
|
if (metaEl) {
|
||||||
var meta = [];
|
var meta = [];
|
||||||
|
|
@ -204,7 +134,6 @@
|
||||||
if (ext === 'pdf' || ext === 'html' || ext === 'htm') {
|
if (ext === 'pdf' || ext === 'html' || ext === 'htm') {
|
||||||
try {
|
try {
|
||||||
var info = await getBlobUrl(node);
|
var info = await getBlobUrl(node);
|
||||||
if (seq !== renderSeq) return;
|
|
||||||
var sandbox = (ext === 'pdf') ? '' : ' sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"';
|
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>';
|
container.innerHTML = '<iframe class="preview-iframe" src="' + escapeHtml(info.url) + '"' + sandbox + '></iframe>';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -217,7 +146,6 @@
|
||||||
if (preview && preview.isImage(ext) && !preview.isTiff(ext)) {
|
if (preview && preview.isImage(ext) && !preview.isTiff(ext)) {
|
||||||
try {
|
try {
|
||||||
var imgInfo = await getBlobUrl(node);
|
var imgInfo = await getBlobUrl(node);
|
||||||
if (seq !== renderSeq) return;
|
|
||||||
container.innerHTML = '<img class="preview-image" alt="' + escapeHtml(node.name)
|
container.innerHTML = '<img class="preview-image" alt="' + escapeHtml(node.name)
|
||||||
+ '" src="' + escapeHtml(imgInfo.url) + '">';
|
+ '" src="' + escapeHtml(imgInfo.url) + '">';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -229,7 +157,6 @@
|
||||||
if (preview && preview.isTiff(ext)) {
|
if (preview && preview.isTiff(ext)) {
|
||||||
try {
|
try {
|
||||||
var tiffBuf = await getArrayBuffer(node);
|
var tiffBuf = await getArrayBuffer(node);
|
||||||
if (seq !== renderSeq) return;
|
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
await preview.renderTiff(document, container, tiffBuf, { fileName: node.name });
|
await preview.renderTiff(document, container, tiffBuf, { fileName: node.name });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -241,7 +168,6 @@
|
||||||
if (preview && preview.isZip(ext)) {
|
if (preview && preview.isZip(ext)) {
|
||||||
try {
|
try {
|
||||||
var zipBuf = await getArrayBuffer(node);
|
var zipBuf = await getArrayBuffer(node);
|
||||||
if (seq !== renderSeq) return;
|
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
await preview.renderZipListing(document, container, zipBuf, { fileName: node.name });
|
await preview.renderZipListing(document, container, zipBuf, { fileName: node.name });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -256,7 +182,6 @@
|
||||||
if (preview && preview.isOffice(ext)) {
|
if (preview && preview.isOffice(ext)) {
|
||||||
try {
|
try {
|
||||||
var officeBuf = await getArrayBuffer(node);
|
var officeBuf = await getArrayBuffer(node);
|
||||||
if (seq !== renderSeq) return;
|
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
if (ext === 'docx') {
|
if (ext === 'docx') {
|
||||||
await preview.renderDocx(document, container, officeBuf, { fileName: node.name });
|
await preview.renderDocx(document, container, officeBuf, { fileName: node.name });
|
||||||
|
|
@ -272,7 +197,6 @@
|
||||||
if (preview && preview.isText(ext)) {
|
if (preview && preview.isText(ext)) {
|
||||||
try {
|
try {
|
||||||
var txtBuf = await getArrayBuffer(node);
|
var txtBuf = await getArrayBuffer(node);
|
||||||
if (seq !== renderSeq) return;
|
|
||||||
var text = new TextDecoder('utf-8', { fatal: false }).decode(txtBuf);
|
var text = new TextDecoder('utf-8', { fatal: false }).decode(txtBuf);
|
||||||
var MAX = 200000;
|
var MAX = 200000;
|
||||||
if (text.length > MAX) {
|
if (text.length > MAX) {
|
||||||
|
|
@ -293,7 +217,6 @@
|
||||||
// Unknown type — offer a download link.
|
// Unknown type — offer a download link.
|
||||||
try {
|
try {
|
||||||
var fallbackInfo = await getBlobUrl(node);
|
var fallbackInfo = await getBlobUrl(node);
|
||||||
if (seq !== renderSeq) return;
|
|
||||||
container.innerHTML =
|
container.innerHTML =
|
||||||
'<div class="preview-empty">'
|
'<div class="preview-empty">'
|
||||||
+ 'No inline preview for <code>.' + escapeHtml(ext) + '</code>. '
|
+ 'No inline preview for <code>.' + escapeHtml(ext) + '</code>. '
|
||||||
|
|
@ -435,13 +358,11 @@
|
||||||
if (node.isDir) return;
|
if (node.isDir) return;
|
||||||
opts = opts || {};
|
opts = opts || {};
|
||||||
if (opts.popup) return renderInPopup(node);
|
if (opts.popup) return renderInPopup(node);
|
||||||
return renderInline(node, opts);
|
return renderInline(node);
|
||||||
}
|
}
|
||||||
|
|
||||||
window.app.modules.preview = {
|
window.app.modules.preview = {
|
||||||
showFilePreview: showFilePreview,
|
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.
|
// Expose for the markdown plugin so it can read file bytes.
|
||||||
getArrayBuffer: getArrayBuffer
|
getArrayBuffer: getArrayBuffer
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -25,16 +25,11 @@
|
||||||
var t = window.zddc && window.zddc.toast;
|
var t = window.zddc && window.zddc.toast;
|
||||||
if (t) t(msg, level || 'info');
|
if (t) t(msg, level || 'info');
|
||||||
}
|
}
|
||||||
// Re-fetch the current listing so the moved file appears/disappears
|
function escapeHtml(s) {
|
||||||
// without a manual reload. Best-effort: absent on older builds.
|
return String(s).replace(/[&<>"']/g, function (c) {
|
||||||
function refreshListing() {
|
return ({ '&':'&','<':'<','>':'>','"':'"',"'":''' })[c];
|
||||||
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 ──────────────
|
// ── Scope detection: path-shape, not cascade-content ──────────────
|
||||||
// A file is stageable if its path matches
|
// A file is stageable if its path matches
|
||||||
|
|
@ -95,6 +90,18 @@
|
||||||
.map(function (e) { return e.name; });
|
.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.
|
// POST X-ZDDC-Op: mkdir to create a new directory. Idempotent.
|
||||||
async function mkdir(absUrl) {
|
async function mkdir(absUrl) {
|
||||||
var resp = await fetch(absUrl, {
|
var resp = await fetch(absUrl, {
|
||||||
|
|
@ -260,7 +267,6 @@
|
||||||
// ── Action drivers ─────────────────────────────────────────────────
|
// ── Action drivers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
async function invokeStage(node) {
|
async function invokeStage(node) {
|
||||||
if (busy) return;
|
|
||||||
var tree = window.app.modules.tree;
|
var tree = window.app.modules.tree;
|
||||||
if (!tree) return;
|
if (!tree) return;
|
||||||
var srcUrl = tree.pathFor(node);
|
var srcUrl = tree.pathFor(node);
|
||||||
|
|
@ -283,46 +289,26 @@
|
||||||
choice = await openStagePicker({ fileCount: 1, folders: folders });
|
choice = await openStagePicker({ fileCount: 1, folders: folders });
|
||||||
} catch (_e) { return; }
|
} catch (_e) { return; }
|
||||||
|
|
||||||
busy = true;
|
if (choice.create) {
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var dstUrl = stagingBase + encodeURIComponent(choice.folderName) + '/' + encodeURIComponent(node.name);
|
|
||||||
try {
|
try {
|
||||||
await moveFile(srcUrl, dstUrl);
|
await mkdir(stagingBase + encodeURIComponent(choice.folderName) + '/');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
var msg = (e && e.message) || 'move failed';
|
status((e && e.message) || 'mkdir failed', 'error');
|
||||||
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;
|
return;
|
||||||
}
|
}
|
||||||
status('Staged ' + node.name + ' → ' + info.party + '/staging/' + choice.folderName + '/', 'success');
|
|
||||||
refreshListing();
|
|
||||||
} finally {
|
|
||||||
busy = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var dstUrl = stagingBase + encodeURIComponent(choice.folderName) + '/' + encodeURIComponent(node.name);
|
||||||
|
try {
|
||||||
|
await moveFile(srcUrl, dstUrl);
|
||||||
|
} catch (e) {
|
||||||
|
status((e && e.message) || 'move failed', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
status('Staged ' + node.name + ' → ' + info.party + '/staging/' + choice.folderName + '/ — reload to see the move.', 'success');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function invokeUnstage(node) {
|
async function invokeUnstage(node) {
|
||||||
if (busy) return;
|
|
||||||
var tree = window.app.modules.tree;
|
var tree = window.app.modules.tree;
|
||||||
if (!tree) return;
|
if (!tree) return;
|
||||||
var srcUrl = tree.pathFor(node);
|
var srcUrl = tree.pathFor(node);
|
||||||
|
|
@ -340,19 +326,13 @@
|
||||||
var target = choice.target;
|
var target = choice.target;
|
||||||
if (!target.endsWith('/')) target += '/';
|
if (!target.endsWith('/')) target += '/';
|
||||||
var dstUrl = target + encodeURIComponent(node.name);
|
var dstUrl = target + encodeURIComponent(node.name);
|
||||||
busy = true;
|
|
||||||
try {
|
try {
|
||||||
try {
|
await moveFile(srcUrl, dstUrl);
|
||||||
await moveFile(srcUrl, dstUrl);
|
} catch (e) {
|
||||||
} catch (e) {
|
status((e && e.message) || 'move failed', 'error');
|
||||||
status((e && e.message) || 'move failed', 'error');
|
return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
status('Unstaged ' + node.name + ' → ' + target, 'success');
|
|
||||||
refreshListing();
|
|
||||||
} finally {
|
|
||||||
busy = false;
|
|
||||||
}
|
}
|
||||||
|
status('Unstaged ' + node.name + ' → ' + target + ' — reload to see the move.', 'success');
|
||||||
}
|
}
|
||||||
|
|
||||||
window.app.modules.stage = {
|
window.app.modules.stage = {
|
||||||
|
|
|
||||||
|
|
@ -211,7 +211,13 @@
|
||||||
|
|
||||||
// ── Rendering ────────────────────────────────────────────────────────
|
// ── Rendering ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
var fmtSize = window.app.modules.util.fmtSize;
|
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';
|
||||||
|
}
|
||||||
|
|
||||||
function fmtDate(d) {
|
function fmtDate(d) {
|
||||||
if (!d) return '';
|
if (!d) return '';
|
||||||
|
|
@ -220,7 +226,10 @@
|
||||||
+ ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
|
+ ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
|
||||||
}
|
}
|
||||||
|
|
||||||
var escapeHtml = window.app.modules.util.escapeHtml;
|
function escapeHtml(s) {
|
||||||
|
return String(s).replace(/&/g, '&').replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
// Per-extension icon map → Lucide outline-icon sprite ids. The
|
// Per-extension icon map → Lucide outline-icon sprite ids. The
|
||||||
// actual SVG markup is produced by window.zddc.icons.html(id),
|
// actual SVG markup is produced by window.zddc.icons.html(id),
|
||||||
|
|
@ -460,6 +469,10 @@
|
||||||
el.innerHTML = html;
|
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
|
// True when this .zip node lives inside another zip, so its bytes
|
||||||
// can't be fetched as a standalone server resource: we read them
|
// can't be fetched as a standalone server resource: we read them
|
||||||
// through the containing handle (offline / nested) or by fetching
|
// through the containing handle (offline / nested) or by fetching
|
||||||
|
|
@ -508,14 +521,7 @@
|
||||||
// it as a directory handle; members
|
// it as a directory handle; members
|
||||||
// become ordinary dir/file nodes
|
// become ordinary dir/file nodes
|
||||||
async function loadChildren(node) {
|
async function loadChildren(node) {
|
||||||
if (node.loaded || node.loading) return;
|
if (node.loaded) 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 {
|
try {
|
||||||
if (node.isZip && state.source === 'server' && !zipNestedInsideZip(node)) {
|
if (node.isZip && state.source === 'server' && !zipNestedInsideZip(node)) {
|
||||||
setChildren(node.id, await loader.fetchServerChildren(pathFor(node) + '/'));
|
setChildren(node.id, await loader.fetchServerChildren(pathFor(node) + '/'));
|
||||||
|
|
@ -535,8 +541,6 @@
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
window.app.modules.events.statusError(
|
window.app.modules.events.statusError(
|
||||||
'Failed to load ' + node.name + ': ' + e.message);
|
'Failed to load ' + node.name + ': ' + e.message);
|
||||||
} finally {
|
|
||||||
node.loading = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -686,6 +690,15 @@
|
||||||
loadChildren: loadChildren,
|
loadChildren: loadChildren,
|
||||||
snapshotState: snapshotState,
|
snapshotState: snapshotState,
|
||||||
restoreState: restoreState,
|
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).
|
// Set both key and direction explicitly. dir: 1 (asc) or -1 (desc).
|
||||||
// Used by the toolbar's sort dropdown.
|
// Used by the toolbar's sort dropdown.
|
||||||
setSortExplicit: function (key, dir) {
|
setSortExplicit: function (key, dir) {
|
||||||
|
|
|
||||||
|
|
@ -286,11 +286,20 @@
|
||||||
if (lastResolved) msg += ' — last at ' + lastResolved;
|
if (lastResolved) msg += ' — last at ' + lastResolved;
|
||||||
note(msg, 'success');
|
note(msg, 'success');
|
||||||
}
|
}
|
||||||
// Reload the current listing so the new +Cn file appears in the
|
// Reload the listing of the workflow folder so the new +Cn file
|
||||||
// tree. Best-effort.
|
// 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\/[^/]+\/?$/, '/');
|
||||||
try {
|
try {
|
||||||
var ev = window.app.modules.events;
|
var ev = window.app.modules.events;
|
||||||
if (ev && typeof ev.refreshListing === 'function') ev.refreshListing();
|
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.)
|
||||||
|
}
|
||||||
} catch (_e) { /* refresh is best-effort */ }
|
} catch (_e) { /* refresh is best-effort */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,131 +0,0 @@
|
||||||
// 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