// loader.js — fetches directory entries for either source mode. // // Server mode: GET 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 ?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, // 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// // 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 /.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 }; })();