234 lines
11 KiB
JavaScript
234 lines
11 KiB
JavaScript
// loader.js — fetches directory entries for either source mode.
|
|
//
|
|
// Server mode: GET <urlPath> with Accept: application/json. zddc-server
|
|
// (and Caddy's built-in browse, which we mirror) returns an array of
|
|
// FileInfo {name, size, url, mod_time, mode, is_dir, is_symlink}.
|
|
//
|
|
// FS-API mode: enumerate a FileSystemDirectoryHandle's children. No
|
|
// network involved; works on local folders the user picked.
|
|
(function () {
|
|
'use strict';
|
|
|
|
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) {
|
|
return window.zddc.splitExtension(name).extension;
|
|
}
|
|
|
|
// Build a raw entry from the server's FileInfo shape.
|
|
function fromServerEntry(e) {
|
|
// Server returns directory names with a trailing "/". Strip
|
|
// it for display; the is_dir flag is the canonical signal.
|
|
var name = e.is_dir ? e.name.replace(/\/$/, '') : e.name;
|
|
// displayName is the friendlier label set by the parent .zddc
|
|
// `display:` map (when present). The on-disk basename stays in
|
|
// .name so URL composition (pathFor) and the chevron's title
|
|
// attribute still reflect the real folder name.
|
|
var displayName = (typeof e.display_name === 'string' && e.display_name)
|
|
? e.display_name
|
|
: '';
|
|
return {
|
|
name: name,
|
|
displayName: displayName,
|
|
isDir: e.is_dir,
|
|
size: e.size || 0,
|
|
modTime: e.mod_time ? new Date(e.mod_time) : null,
|
|
ext: e.is_dir ? '' : splitExt(name),
|
|
url: e.url || null,
|
|
// Server-computed write authority — true if the policy
|
|
// decider would allow a PUT for the calling principal.
|
|
// Absent / false means "save will 403"; preview editors
|
|
// read this to mount in read-only mode. Superseded by
|
|
// verbs (below); kept in lockstep during the transition.
|
|
writable: !!e.writable,
|
|
// Server-computed verb set: canonical "rwcda" subset the
|
|
// calling principal holds at this entry's URL. Per-entry
|
|
// gating in the context menu (Rename/Delete) reads this
|
|
// through zddc.cap.has(node, 'w'|'d').
|
|
//
|
|
// "rw…" — zddc-server emitted explicit grant.
|
|
// "" — zddc-server emitted explicit zero grant
|
|
// (rare; usually the entry would have been
|
|
// filtered before reaching the client).
|
|
// undefined — the server didn't emit a verbs field at
|
|
// all (Caddy or any non-zddc backend).
|
|
// cap.has and the events.js gates treat
|
|
// this as "verbs unknown" and skip the
|
|
// per-entry cascade gate; canMutate +
|
|
// whatever the server enforces on the
|
|
// actual PUT/DELETE still apply.
|
|
verbs: typeof e.verbs === 'string' ? e.verbs : undefined,
|
|
// Server-computed: true when this file lives in a history:true
|
|
// cascade subtree, so every save is versioned and
|
|
// GET <url>?history lists prior versions. Drives the "History…"
|
|
// context-menu affordance (server mode only — offline has no
|
|
// authenticated identity to attribute saves to).
|
|
history: !!e.history,
|
|
// Server-computed: cascade-resolved default tool for a DIRECTORY
|
|
// entry (e.g. "tables", "classifier"). Browse renders a dir whose
|
|
// defaultTool=="tables" (mdl/rsk/ssr) as a click-to-table leaf —
|
|
// the table opens in the preview pane instead of the dir expanding.
|
|
defaultTool: (typeof e.default_tool === 'string') ? e.default_tool : '',
|
|
// FS-API specific (null in server mode):
|
|
handle: null
|
|
};
|
|
}
|
|
|
|
// Build a raw entry from a FileSystemHandle.
|
|
async function fromHandle(handle) {
|
|
var name = handle.name;
|
|
var isDir = handle.kind === 'directory';
|
|
var size = 0;
|
|
var modTime = null;
|
|
if (!isDir) {
|
|
try {
|
|
var f = await handle.getFile();
|
|
size = f.size;
|
|
modTime = new Date(f.lastModified);
|
|
} catch (_e) {
|
|
// permission lost; leave size/modTime defaults
|
|
}
|
|
}
|
|
return {
|
|
name: name,
|
|
isDir: isDir,
|
|
size: size,
|
|
modTime: modTime,
|
|
ext: isDir ? '' : splitExt(name),
|
|
url: null,
|
|
handle: handle
|
|
};
|
|
}
|
|
|
|
// Fetch children of a directory in server mode.
|
|
// path must end with '/' so the request hits the directory route.
|
|
//
|
|
// 404 is treated as "empty directory" rather than a hard error.
|
|
// A directory that doesn't exist on the server (e.g. a fresh
|
|
// project's working/ before any drafts have been created, or a
|
|
// dir deleted between listing and expand) is functionally
|
|
// indistinguishable from an empty one for tree-rendering purposes.
|
|
// Server-side, zddc-server already returns 200 + [] for canonical
|
|
// project folders that are missing on disk; this fallback covers
|
|
// the same UX for anything else and for non-zddc-server backends.
|
|
async function fetchServerChildren(path) {
|
|
if (!path.endsWith('/')) path += '/';
|
|
// ?hidden=1 surfaces .-prefixed and _-prefixed entries when the
|
|
// user has flipped the "Show hidden" toggle. The server still
|
|
// ACL-gates per-entry, so this is purely additive — anyone
|
|
// without read on the parent dir already sees nothing.
|
|
var url = path;
|
|
if (window.app && window.app.state && window.app.state.showHidden) {
|
|
url += (url.indexOf('?') === -1 ? '?' : '&') + 'hidden=1';
|
|
}
|
|
var resp = await fetch(url, {
|
|
headers: { 'Accept': 'application/json' },
|
|
credentials: 'same-origin'
|
|
});
|
|
// Capture cascade-resolved scope flags from response headers
|
|
// before bailing on 404. zddc-server emits X-ZDDC-Drop-Target
|
|
// for directories the cascade marks as upload destinations
|
|
// (see zddc/internal/zddc/lookups.go DropTargetAt). The flag
|
|
// is leaf-only — it describes THIS path, not its descendants
|
|
// — so a rescope or popstate re-reads it from the new listing.
|
|
var dropTargetHdr = (resp.headers.get('X-ZDDC-Drop-Target') || '').toLowerCase();
|
|
window.app.state.scopeDropTarget = dropTargetHdr === 'true';
|
|
// X-ZDDC-Default-Tool surfaces the cascade-resolved default
|
|
// tool name for the current path. Browse uses it to decide
|
|
// grid-mode auto-activation (when default_tool==classifier)
|
|
// without re-implementing the cascade client-side.
|
|
window.app.state.scopeDefaultTool =
|
|
(resp.headers.get('X-ZDDC-Default-Tool') || '').toLowerCase();
|
|
// X-ZDDC-On-Plan-Review surfaces whether the cascade above
|
|
// this path has an on_plan_review block. Drives visibility of
|
|
// the "Plan Review" right-click menu item on received/<tracking>/
|
|
// folders.
|
|
window.app.state.scopeOnPlanReview =
|
|
(resp.headers.get('X-ZDDC-On-Plan-Review') || '').toLowerCase() === 'true';
|
|
// X-ZDDC-Canonical-Folder names the canonical project-layout
|
|
// slot this directory occupies — "incoming", "received",
|
|
// "working", "staging", etc. Drives scope-aware menu items:
|
|
// Accept Transmittal (folders under incoming), Stage/Unstage
|
|
// (files under working/staging), Create Transmittal folder
|
|
// (right-click in staging).
|
|
window.app.state.scopeCanonicalFolder =
|
|
(resp.headers.get('X-ZDDC-Canonical-Folder') || '').toLowerCase();
|
|
if (resp.status === 404) {
|
|
return [];
|
|
}
|
|
if (!resp.ok) {
|
|
throw new Error('HTTP ' + resp.status + ' fetching ' + path);
|
|
}
|
|
var data = await resp.json();
|
|
if (!Array.isArray(data)) {
|
|
throw new Error('Unexpected response shape from ' + path);
|
|
}
|
|
return data.map(fromServerEntry);
|
|
}
|
|
|
|
// Enumerate a FileSystemDirectoryHandle's immediate children.
|
|
async function fetchFsChildren(dirHandle) {
|
|
var entries = [];
|
|
for await (var [_name, handle] of dirHandle.entries()) {
|
|
entries.push(await fromHandle(handle));
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
// Probe whether THIS page is being served by zddc-server (or any
|
|
// server that responds to JSON listing requests). If so, switch to
|
|
// server mode automatically and load the current directory.
|
|
async function autoDetectServerMode() {
|
|
// Only attempt when running over http(s) and the location's
|
|
// path looks like a directory. Probing on file:// is pointless.
|
|
if (location.protocol !== 'http:' && location.protocol !== 'https:') {
|
|
return false;
|
|
}
|
|
// Strip any /<tool>.html from the path to get the directory.
|
|
var path = location.pathname;
|
|
// If the URL points at the browse.html itself, the directory
|
|
// is the parent. If it's a directory ending in '/', use it.
|
|
var dirPath;
|
|
if (path.endsWith('/')) {
|
|
dirPath = path;
|
|
} else {
|
|
// e.g. '/some/dir/browse.html' → '/some/dir/'
|
|
var slash = path.lastIndexOf('/');
|
|
dirPath = slash >= 0 ? path.substring(0, slash + 1) : '/';
|
|
}
|
|
|
|
try {
|
|
var entries = await fetchServerChildren(dirPath);
|
|
state.source = 'server';
|
|
state.currentPath = dirPath;
|
|
return { entries: entries, path: dirPath };
|
|
} catch (_e) {
|
|
// Not a server-backed page (e.g. opened via file://).
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// JSZip is vendored into the bundle (shared/vendor/jszip.min.js
|
|
// is concatenated ahead of init.js by build.sh), so it's always
|
|
// already attached to window.JSZip by the time any tree code runs.
|
|
// We keep the helper because tree.js calls it before reaching for
|
|
// window.JSZip; if the bundle is ever rebuilt without the vendor
|
|
// copy this will throw a clear error rather than silently failing.
|
|
function ensureJSZip() {
|
|
if (window.JSZip) return Promise.resolve();
|
|
return Promise.reject(new Error(
|
|
'JSZip not bundled — rebuild browse with shared/vendor/jszip.min.js'));
|
|
}
|
|
|
|
// Public API
|
|
window.app.modules.loader = {
|
|
fetchServerChildren: fetchServerChildren,
|
|
fetchFsChildren: fetchFsChildren,
|
|
autoDetectServerMode: autoDetectServerMode,
|
|
ensureJSZip: ensureJSZip
|
|
};
|
|
})();
|