feat(archive,browse): treat .zip transmittal folders as folders + shared zip adapter
New shared/zip-source.js: a ZipDirectoryHandle / ZipFileHandle pair that exposes a JSZip instance behind the File-System-Access surface (values/entries/keys, getDirectoryHandle/getFileHandle, getFile) — read-only, with a zip-slip guard. Mirrors shared/zddc-source.js's HTTP polyfill. Wired into archive's and browse's build.sh (both already bundle JSZip). archive: a .zip whose name minus ".zip" parses as a transmittal-folder name is now scanned as that transmittal folder. Offline, the zip is opened in the browser (ZipDirectoryHandle) and its members enumerated exactly like an uncompressed folder's files — table/export/hash paths are unchanged (they go through file.handle.getFile()). Online, the scanner recurses into the server's "<…>.zip/" virtual-directory listing, so members come back as "<…>.zip/<member>" URLs the server extracts on demand — no whole-zip download. browse: the offline (file://) zip path is migrated onto the shared adapter — expanding a .zip now opens it as a ZipDirectoryHandle and its members become ordinary dir/file nodes handled by the normal fetchFsChildren path (nested zips fall out by recursion). The bespoke flat-entry walker (loadZipChildren / setZipDirChildren / zipEntries / zipParentId / zipPath / _zipSyntheticDir) is gone — one zip implementation repo-wide. Markdown members inside a zip are flagged read-only (the ZipFileHandle refuses createWritable; server "<…>.zip/" URLs 405 on PUT). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
735fed89c2
commit
2dc2d032a0
9 changed files with 403 additions and 172 deletions
|
|
@ -43,6 +43,7 @@ concat_files \
|
||||||
"../shared/vendor/utif.min.js" \
|
"../shared/vendor/utif.min.js" \
|
||||||
"../shared/zddc.js" \
|
"../shared/zddc.js" \
|
||||||
"../shared/hash.js" \
|
"../shared/hash.js" \
|
||||||
|
"../shared/zip-source.js" \
|
||||||
"../shared/theme.js" \
|
"../shared/theme.js" \
|
||||||
"../shared/toast.js" \
|
"../shared/toast.js" \
|
||||||
"../shared/nav.js" \
|
"../shared/nav.js" \
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,15 @@
|
||||||
return !!(parsed && parsed.valid);
|
return !!(parsed && parsed.valid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A .zip whose name (minus the .zip extension) parses as a
|
||||||
|
// transmittal-folder name is treated as that transmittal folder —
|
||||||
|
// its members are scanned the same as an uncompressed folder's
|
||||||
|
// files. (A plain `archive.zip` etc. is just a file.)
|
||||||
|
function isTransmittalFolderZip(name) {
|
||||||
|
var parts = zddc.splitExtension(name);
|
||||||
|
return parts.extension === 'zip' && isTransmittalFolder(parts.name);
|
||||||
|
}
|
||||||
|
|
||||||
function groupFilesByTrackingNumber(files) {
|
function groupFilesByTrackingNumber(files) {
|
||||||
const groups = {};
|
const groups = {};
|
||||||
files.forEach(file => {
|
files.forEach(file => {
|
||||||
|
|
@ -58,6 +67,7 @@
|
||||||
|
|
||||||
window.app.modules.parser = {
|
window.app.modules.parser = {
|
||||||
isTransmittalFolder,
|
isTransmittalFolder,
|
||||||
|
isTransmittalFolderZip,
|
||||||
groupFilesByTrackingNumber,
|
groupFilesByTrackingNumber,
|
||||||
sortGroupedFiles,
|
sortGroupedFiles,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -184,6 +184,29 @@
|
||||||
console.warn('Could not process directory ' + entry.name + ':', err);
|
console.warn('Could not process directory ' + entry.name + ':', err);
|
||||||
}
|
}
|
||||||
} else if (entry.kind === 'file') {
|
} else if (entry.kind === 'file') {
|
||||||
|
// A zipped transmittal folder (e.g.
|
||||||
|
// "2025-05-12_DOC-001 (IFI) - Title.zip") is treated as
|
||||||
|
// that transmittal folder: open the zip in the browser
|
||||||
|
// and scan its members like an uncompressed folder's
|
||||||
|
// files. The .zip stays in the recorded path so it's
|
||||||
|
// unambiguous; the displayed name drops it.
|
||||||
|
if (window.app.modules.parser.isTransmittalFolderZip(entry.name)) {
|
||||||
|
const base = zddc.splitExtension(entry.name).name;
|
||||||
|
const zipPath = currentPath + '/' + entry.name;
|
||||||
|
try {
|
||||||
|
const zh = await window.zddc.zip.fromFileHandle(entry);
|
||||||
|
callbacks.onTransmittalFolder({
|
||||||
|
name: base,
|
||||||
|
path: zipPath,
|
||||||
|
displayPath: getDisplayPath(zipPath),
|
||||||
|
handle: zh
|
||||||
|
});
|
||||||
|
await scanLocalTransmittalFolder(zh, zipPath, 0, zipPath, callbacks);
|
||||||
|
} catch (zipErr) {
|
||||||
|
console.warn('Could not open zip transmittal ' + entry.name + ':', zipErr);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
// File directly in a grouping folder — assign to the Outstanding virtual transmittal.
|
// File directly in a grouping folder — assign to the Outstanding virtual transmittal.
|
||||||
// actualPath records the real containing folder for grouping-folder-scoped filtering.
|
// actualPath records the real containing folder for grouping-folder-scoped filtering.
|
||||||
try {
|
try {
|
||||||
|
|
@ -479,6 +502,27 @@
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// It's a file
|
// It's a file
|
||||||
|
// A zipped transmittal folder at the grouping level:
|
||||||
|
// zddc-server serves "<…>.zip/" as a virtual directory
|
||||||
|
// of the zip's members, so recurse into it like an
|
||||||
|
// uncompressed transmittal folder. Members come back
|
||||||
|
// with URLs like "<…>.zip/<member>" that the server
|
||||||
|
// extracts on demand — no whole-zip download.
|
||||||
|
if (transmittalPath === null && window.app.modules.parser.isTransmittalFolderZip(rawName)) {
|
||||||
|
const base = zddc.splitExtension(rawName).name;
|
||||||
|
const zipDirUrl = itemUrl + '/'; // itemUrl is the .zip file URL (no trailing slash)
|
||||||
|
callbacks.onTransmittalFolder({
|
||||||
|
name: base,
|
||||||
|
path: logicalPath,
|
||||||
|
displayPath: getDisplayPath(logicalPath),
|
||||||
|
handle: null,
|
||||||
|
url: zipDirUrl
|
||||||
|
});
|
||||||
|
subdirPromises.push(
|
||||||
|
scanHttpRecursive(zipDirUrl, rootUrl, depth + 1, logicalPath, callbacks)
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (transmittalPath === null) {
|
if (transmittalPath === null) {
|
||||||
// File directly in a grouping folder — assign to Outstanding virtual transmittal.
|
// File directly in a grouping folder — assign to Outstanding virtual transmittal.
|
||||||
// actualPath records the real containing folder for grouping-folder-scoped filtering.
|
// actualPath records the real containing folder for grouping-folder-scoped filtering.
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ concat_files \
|
||||||
"../shared/vendor/toastui-editor-all.min.js" \
|
"../shared/vendor/toastui-editor-all.min.js" \
|
||||||
"../shared/zddc.js" \
|
"../shared/zddc.js" \
|
||||||
"../shared/zddc-filter.js" \
|
"../shared/zddc-filter.js" \
|
||||||
|
"../shared/zip-source.js" \
|
||||||
"../shared/theme.js" \
|
"../shared/theme.js" \
|
||||||
"../shared/toast.js" \
|
"../shared/toast.js" \
|
||||||
"../shared/nav.js" \
|
"../shared/nav.js" \
|
||||||
|
|
|
||||||
|
|
@ -33,15 +33,18 @@
|
||||||
viewMode: 'browse',
|
viewMode: 'browse',
|
||||||
|
|
||||||
// The tree's in-memory representation. Each node:
|
// The tree's in-memory representation. Each node:
|
||||||
// { id, name, isDir, size, modTime, ext, url, depth,
|
// { id, name, isDir, size, modTime, ext, url, handle, depth,
|
||||||
// parentId, expanded, loaded, childIds, isZip, zipFile,
|
// parentId, expanded, loaded, childIds, isZip,
|
||||||
// zipPath }
|
// _zipDirHandle, virtual }
|
||||||
// - isZip: set when the node IS a .zip file we know how to
|
// - isZip: the node IS a .zip file; expanding it lists
|
||||||
// expand inline (server file or FS handle).
|
// the zip's members (server "<…>.zip/" listing
|
||||||
// - zipFile: cached JSZip instance for this archive (set
|
// online, JSZip behind a ZipDirectoryHandle
|
||||||
// after first expand).
|
// offline). Members are ordinary dir/file nodes.
|
||||||
// - zipPath: relative path WITHIN a zip (set on virtual
|
// - _zipDirHandle: cached ZipDirectoryHandle for an opened zip
|
||||||
// children of an expanded zip; null otherwise).
|
// (offline / nested-in-zip path only).
|
||||||
|
// - handle: a FileSystemFileHandle/DirectoryHandle (fs
|
||||||
|
// mode) — or, inside an opened zip, a
|
||||||
|
// ZipFileHandle/ZipDirectoryHandle.
|
||||||
// Stored flat in a Map keyed by id; render order derived
|
// Stored flat in a Map keyed by id; render order derived
|
||||||
// from a depth-first walk.
|
// from a depth-first walk.
|
||||||
nodes: new Map(),
|
nodes: new Map(),
|
||||||
|
|
|
||||||
|
|
@ -272,8 +272,17 @@
|
||||||
throw new Error('No write target for this file (read-only source).');
|
throw new Error('No write target for this file (read-only source).');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 (node.zipParentId != null) return false;
|
if (isZipMemberNode(node)) return false;
|
||||||
if (node.handle && typeof node.handle.createWritable === 'function') return true;
|
if (node.handle && typeof node.handle.createWritable === 'function') return true;
|
||||||
if (node.url && window.app.state.source === 'server') return true;
|
if (node.url && window.app.state.source === 'server') return true;
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -391,7 +400,7 @@
|
||||||
|
|
||||||
var sourceEl = document.createElement('span');
|
var sourceEl = document.createElement('span');
|
||||||
sourceEl.className = 'md-shell__source';
|
sourceEl.className = 'md-shell__source';
|
||||||
if (node.zipParentId != null) {
|
if (isZipMemberNode(node)) {
|
||||||
sourceEl.textContent = 'read-only (zip)';
|
sourceEl.textContent = 'read-only (zip)';
|
||||||
} else if (node.handle) {
|
} else if (node.handle) {
|
||||||
sourceEl.textContent = 'local';
|
sourceEl.textContent = 'local';
|
||||||
|
|
|
||||||
|
|
@ -50,13 +50,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getArrayBuffer(node) {
|
async function getArrayBuffer(node) {
|
||||||
if (node.zipParentId != null) {
|
// A zip member node carries a ZipFileHandle in node.handle, so
|
||||||
var owner = state.nodes.get(node.zipParentId);
|
// it falls through the same getFile() path as any local file.
|
||||||
if (!owner || !owner.zipFile) {
|
|
||||||
throw new Error('parent zip not loaded');
|
|
||||||
}
|
|
||||||
return await owner.zipFile.file(node.zipPath).async('arraybuffer');
|
|
||||||
}
|
|
||||||
if (state.source === 'server' && node.url) {
|
if (state.source === 'server' && node.url) {
|
||||||
var resp = await fetch(node.url);
|
var resp = await fetch(node.url);
|
||||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||||
|
|
@ -70,7 +65,10 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getBlobUrl(node) {
|
async function getBlobUrl(node) {
|
||||||
if (state.source === 'server' && node.url && node.zipParentId == null) {
|
// Server-served files (including zip members at "<…>.zip/<member>"
|
||||||
|
// URLs) load straight from the server — preserves Content-Type
|
||||||
|
// and lets relative links inside HTML resolve back to the server.
|
||||||
|
if (state.source === 'server' && node.url) {
|
||||||
return { url: node.url, fromServer: true };
|
return { url: node.url, fromServer: true };
|
||||||
}
|
}
|
||||||
var buf = await getArrayBuffer(node);
|
var buf = await getArrayBuffer(node);
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,11 @@
|
||||||
|
|
||||||
function newNode(raw, parentId, depth) {
|
function newNode(raw, parentId, depth) {
|
||||||
var id = state.nextId++;
|
var id = state.nextId++;
|
||||||
// ZIP files are treated as folders for tree purposes — the
|
// A .zip file is treated as a folder for tree purposes — the
|
||||||
// chevron lets the user expand them inline. The actual
|
// chevron expands it. On expand, server mode fetches the
|
||||||
// contents are loaded on first expand via JSZip.
|
// server's "<…>.zip/" virtual-directory listing; offline mode
|
||||||
|
// opens the zip with JSZip behind a ZipDirectoryHandle. Either
|
||||||
|
// way the zip's members become ordinary directory/file nodes.
|
||||||
var isZip = !raw.isDir && raw.ext === 'zip';
|
var isZip = !raw.isDir && raw.ext === 'zip';
|
||||||
var node = {
|
var node = {
|
||||||
id: id,
|
id: id,
|
||||||
|
|
@ -37,9 +39,7 @@
|
||||||
loaded: false,
|
loaded: false,
|
||||||
childIds: [],
|
childIds: [],
|
||||||
isZip: isZip,
|
isZip: isZip,
|
||||||
zipFile: null, // cached JSZip instance
|
_zipDirHandle: null, // cached ZipDirectoryHandle (offline / nested zips)
|
||||||
zipPath: raw.zipPath || null, // path within zip (for virtual children)
|
|
||||||
zipParentId: raw.zipParentId || null, // ancestor zip's node id (for nested entries)
|
|
||||||
// True when this entry was synthesized client-side (e.g.
|
// True when this entry was synthesized client-side (e.g.
|
||||||
// canonical project folders that don't exist on disk yet).
|
// canonical project folders that don't exist on disk yet).
|
||||||
// Rendered with a muted style + an "(empty)" hint.
|
// Rendered with a muted style + an "(empty)" hint.
|
||||||
|
|
@ -280,53 +280,60 @@
|
||||||
// table); the tree.setSort() method still works but only via
|
// table); the tree.setSort() method still works but only via
|
||||||
// programmatic callers — there's no UI for changing sort yet.
|
// 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 go through
|
// can't be fetched as a standalone server resource: we read them
|
||||||
// the parent JSZip / inner-zip download path instead). In server
|
// through the containing handle (offline / nested) or by fetching
|
||||||
// mode the URL of a zip-member-inside-a-zip contains ".zip/"; in
|
// the inner-zip member URL. In server mode a zip-inside-a-zip's URL
|
||||||
// FS-API mode zipParentId is set.
|
// contains ".zip/"; offline it has a handle that is itself a zip
|
||||||
|
// entry.
|
||||||
function zipNestedInsideZip(node) {
|
function zipNestedInsideZip(node) {
|
||||||
if (node.zipParentId != null) return true;
|
|
||||||
if (state.source === 'server') {
|
if (state.source === 'server') {
|
||||||
return pathFor(node).toLowerCase().indexOf('.zip/') !== -1;
|
return pathFor(node).toLowerCase().indexOf('.zip/') !== -1;
|
||||||
}
|
}
|
||||||
return false;
|
return !!(node.handle && node.handle.isZipEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open a .zip node as a directory handle (a ZipDirectoryHandle over
|
||||||
|
// a JSZip instance), cached on the node. Bytes come from a real
|
||||||
|
// FileSystemFileHandle / ZipFileHandle when present (offline, or a
|
||||||
|
// zip nested in a zip), else from a server URL — zddc-server returns
|
||||||
|
// the raw .zip for "<…>.zip" and the inner-zip bytes for
|
||||||
|
// "<outer>.zip/inner.zip".
|
||||||
|
async function zipDirHandle(node) {
|
||||||
|
if (node._zipDirHandle) return node._zipDirHandle;
|
||||||
|
await loader.ensureJSZip();
|
||||||
|
var zh;
|
||||||
|
if (node.handle) {
|
||||||
|
zh = await window.zddc.zip.fromFileHandle(node.handle);
|
||||||
|
} else if (node.url) {
|
||||||
|
var resp = await fetch(node.url, { credentials: 'same-origin' });
|
||||||
|
if (!resp.ok) throw new Error('HTTP ' + resp.status + ' fetching ' + node.url);
|
||||||
|
zh = await window.zddc.zip.fromBlob(await resp.arrayBuffer(), node.name);
|
||||||
|
} else {
|
||||||
|
throw new Error('cannot open zip ' + node.name + ' (no handle or URL)');
|
||||||
|
}
|
||||||
|
node._zipDirHandle = zh;
|
||||||
|
return zh;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load a folder's children (lazy; idempotent re-loads). Dispatches
|
// Load a folder's children (lazy; idempotent re-loads). Dispatches
|
||||||
// by node kind:
|
// by node kind:
|
||||||
// - regular folder → server JSON listing OR FS-API enumeration
|
// - regular folder → server JSON listing OR FS-API entries
|
||||||
// - top-level zip, server mode → server's virtual-directory listing
|
// - top-level .zip, server mode → the server's "<…>.zip/" virtual-
|
||||||
// of the zip's members (no whole-zip
|
// directory listing (no whole-zip
|
||||||
// download — zddc-server extracts on demand)
|
// download — zddc-server extracts a
|
||||||
// - zip otherwise → fetch+JSZip; entries become virtual children
|
// member only when one is requested)
|
||||||
// - zip child dir → already-listed entries from the parent zip
|
// - .zip otherwise (offline, or a zip nested in a zip)
|
||||||
// (the whole zip was enumerated when it
|
// → open it with JSZip and enumerate
|
||||||
// expanded, so child dirs are pre-seeded)
|
// it as a directory handle; members
|
||||||
|
// become ordinary dir/file nodes
|
||||||
async function loadChildren(node) {
|
async function loadChildren(node) {
|
||||||
if (node.loaded) return;
|
if (node.loaded) return;
|
||||||
try {
|
try {
|
||||||
if (node.isZip) {
|
if (node.isZip && state.source === 'server' && !zipNestedInsideZip(node)) {
|
||||||
if (state.source === 'server' && !zipNestedInsideZip(node)) {
|
setChildren(node.id, await loader.fetchServerChildren(pathFor(node) + '/'));
|
||||||
// zddc-server serves "<…>.zip/" as a virtual
|
} else if (node.isZip) {
|
||||||
// directory: GET it as a listing, GET a member URL
|
setChildren(node.id, await loader.fetchFsChildren(await zipDirHandle(node)));
|
||||||
// to extract just that file. Same shape as any
|
|
||||||
// server directory, so no JSZip on the client.
|
|
||||||
setChildren(node.id,
|
|
||||||
await loader.fetchServerChildren(pathFor(node) + '/'));
|
|
||||||
} else {
|
|
||||||
await loadZipChildren(node);
|
|
||||||
}
|
|
||||||
} else if (node._zipSyntheticDir) {
|
|
||||||
// Synthetic dir node materialized when a zip's entry
|
|
||||||
// list referenced "a/b/file" but had no "a/" entry.
|
|
||||||
// Re-walk the owning zip's flat entry list with the
|
|
||||||
// dir's full prefix.
|
|
||||||
var owner = state.nodes.get(node.zipParentId);
|
|
||||||
if (!owner || !owner.zipEntries) {
|
|
||||||
throw new Error('zip parent not loaded');
|
|
||||||
}
|
|
||||||
setZipDirChildren(node, owner, node.zipPath + '/');
|
|
||||||
} else if (node.isDir) {
|
} else if (node.isDir) {
|
||||||
var raw;
|
var raw;
|
||||||
if (state.source === 'server') {
|
if (state.source === 'server') {
|
||||||
|
|
@ -344,117 +351,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch a zip's bytes, parse with JSZip, and materialize its
|
|
||||||
// entries as a tree of virtual nodes. JSZip's entry list is flat
|
|
||||||
// (full paths); we reconstruct the directory hierarchy on top.
|
|
||||||
async function loadZipChildren(zipNode) {
|
|
||||||
await loader.ensureJSZip();
|
|
||||||
var arrayBuffer;
|
|
||||||
if (state.source === 'server' && zipNode.url) {
|
|
||||||
var resp = await fetch(zipNode.url);
|
|
||||||
if (!resp.ok) throw new Error('HTTP ' + resp.status + ' fetching ' + zipNode.url);
|
|
||||||
arrayBuffer = await resp.arrayBuffer();
|
|
||||||
} else if (zipNode.handle) {
|
|
||||||
// FS-API: top-level zip in a local folder.
|
|
||||||
var f = await zipNode.handle.getFile();
|
|
||||||
arrayBuffer = await f.arrayBuffer();
|
|
||||||
} else if (zipNode.zipParentId != null) {
|
|
||||||
// Nested zip inside another zip — read from parent JSZip.
|
|
||||||
var parent = state.nodes.get(zipNode.zipParentId);
|
|
||||||
if (!parent || !parent.zipFile) {
|
|
||||||
throw new Error('parent zip not loaded');
|
|
||||||
}
|
|
||||||
arrayBuffer = await parent.zipFile.file(zipNode.zipPath).async('arraybuffer');
|
|
||||||
} else {
|
|
||||||
throw new Error('cannot fetch zip bytes (no source)');
|
|
||||||
}
|
|
||||||
var zip = await window.JSZip.loadAsync(arrayBuffer);
|
|
||||||
zipNode.zipFile = zip;
|
|
||||||
|
|
||||||
// Build a path → raw-entry map. Entry paths are
|
|
||||||
// "dir/sub/file.ext" or "dir/" for directories. We slice
|
|
||||||
// to immediate children of zipNode (i.e. zero slashes after
|
|
||||||
// a leading prefix). For nested directories, we synthesize
|
|
||||||
// folder nodes that lazy-expand to the next level via the
|
|
||||||
// same raw-entry list — keep it on the zipNode for replay.
|
|
||||||
zipNode.zipEntries = []; // for re-walk on expand of subdirs
|
|
||||||
zip.forEach(function (relPath, entry) {
|
|
||||||
zipNode.zipEntries.push({
|
|
||||||
path: relPath.replace(/\/$/, ''),
|
|
||||||
isDir: entry.dir,
|
|
||||||
size: (entry._data && entry._data.uncompressedSize) || 0,
|
|
||||||
modTime: entry.date instanceof Date ? entry.date : null,
|
|
||||||
rawPath: relPath
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Now seed top-level children of the zip itself.
|
|
||||||
setZipDirChildren(zipNode, zipNode, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Populate node's childIds with the entries directly under
|
|
||||||
// pathPrefix (relative to the owning zip). Directory entries
|
|
||||||
// become folder nodes whose own children are seeded on first
|
|
||||||
// expand by this same function (recursively descending zipPath).
|
|
||||||
function setZipDirChildren(node, zipOwner, pathPrefix) {
|
|
||||||
var seen = new Map(); // immediate child name → raw entry
|
|
||||||
zipOwner.zipEntries.forEach(function (e) {
|
|
||||||
if (!e.path.startsWith(pathPrefix)) return;
|
|
||||||
var rest = e.path.substring(pathPrefix.length);
|
|
||||||
if (rest === '') return;
|
|
||||||
// Take the FIRST segment of the remaining path
|
|
||||||
var slash = rest.indexOf('/');
|
|
||||||
var firstSeg = slash === -1 ? rest : rest.substring(0, slash);
|
|
||||||
var isImmediateFile = !e.isDir && slash === -1;
|
|
||||||
var isImmediateDir = e.isDir && slash === -1;
|
|
||||||
// For deeply-nested entries (rest contains a slash), we
|
|
||||||
// surface only the first segment as a synthetic folder
|
|
||||||
// entry. For immediate entries, we emit the entry as-is.
|
|
||||||
if (isImmediateFile || isImmediateDir) {
|
|
||||||
// Immediate entry — use the real metadata.
|
|
||||||
seen.set(firstSeg, {
|
|
||||||
name: firstSeg,
|
|
||||||
isDir: e.isDir,
|
|
||||||
size: e.size,
|
|
||||||
modTime: e.modTime,
|
|
||||||
ext: e.isDir ? '' : loader.splitExt(firstSeg),
|
|
||||||
url: null,
|
|
||||||
handle: null,
|
|
||||||
zipPath: e.path,
|
|
||||||
zipParentId: zipOwner.id
|
|
||||||
});
|
|
||||||
} else if (slash !== -1 && !seen.has(firstSeg)) {
|
|
||||||
// Deeper entry, no explicit dir entry yet — synthesize.
|
|
||||||
seen.set(firstSeg, {
|
|
||||||
name: firstSeg,
|
|
||||||
isDir: true,
|
|
||||||
size: 0,
|
|
||||||
modTime: null,
|
|
||||||
ext: '',
|
|
||||||
url: null,
|
|
||||||
handle: null,
|
|
||||||
zipPath: pathPrefix + firstSeg,
|
|
||||||
zipParentId: zipOwner.id
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// Drop existing children (re-load case)
|
|
||||||
node.childIds.forEach(function (id) { state.nodes.delete(id); });
|
|
||||||
node.childIds = [];
|
|
||||||
seen.forEach(function (raw) {
|
|
||||||
var n = newNode(raw, node.id, node.depth + 1);
|
|
||||||
// Synthetic dir nodes inside zip don't have a dedicated
|
|
||||||
// load path — they re-walk zipEntries on expand. Mark
|
|
||||||
// them so the dispatcher knows.
|
|
||||||
if (raw.isDir && !n.isZip) {
|
|
||||||
n._zipSyntheticDir = true;
|
|
||||||
}
|
|
||||||
node.childIds.push(n.id);
|
|
||||||
});
|
|
||||||
sortNodes(node.childIds);
|
|
||||||
node.loaded = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Toggle a folder's expanded state. Loads children on first expand.
|
// Toggle a folder's expanded state. Loads children on first expand.
|
||||||
// Treats "expandable" as either a real directory OR a zip file
|
// Treats "expandable" as either a real directory OR a zip file
|
||||||
// (zip files act like folders for tree purposes — the chevron
|
// (zip files act like folders for tree purposes — the chevron
|
||||||
|
|
|
||||||
269
shared/zip-source.js
Normal file
269
shared/zip-source.js
Normal file
|
|
@ -0,0 +1,269 @@
|
||||||
|
// shared/zip-source.js — present the contents of a .zip as a tree of
|
||||||
|
// File System Access API handles, so tools written against
|
||||||
|
// FileSystemDirectoryHandle / FileSystemFileHandle (archive's scanner,
|
||||||
|
// browse's tree) can navigate into a zip with no special-casing.
|
||||||
|
//
|
||||||
|
// Mirrors shared/zddc-source.js's HttpDirectoryHandle / HttpFileHandle
|
||||||
|
// pair, but read-only and backed by a JSZip instance instead of HTTP.
|
||||||
|
// Online tools that talk to zddc-server should use the server's
|
||||||
|
// "<…>.zip/" virtual-directory route instead (no whole-zip download);
|
||||||
|
// this adapter is for the offline (file://) path where the zip bytes
|
||||||
|
// are already in hand, and for zips nested inside other zips.
|
||||||
|
//
|
||||||
|
// Requires window.JSZip (vendored at shared/vendor/jszip.min.js and
|
||||||
|
// concatenated by the tool's build.sh) — referenced lazily, only from
|
||||||
|
// fromBlob / fromFileHandle, so this module is harmless to include in
|
||||||
|
// a build that doesn't bundle JSZip (it just won't be usable there).
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
if (!window.zddc) window.zddc = {};
|
||||||
|
|
||||||
|
// Minimal extension → media-type map so getFile() returns a Blob
|
||||||
|
// with a usable `type` (iframes/img tags need it to render inline).
|
||||||
|
var MIME = {
|
||||||
|
pdf: 'application/pdf',
|
||||||
|
html: 'text/html', htm: 'text/html',
|
||||||
|
txt: 'text/plain', md: 'text/markdown', csv: 'text/csv',
|
||||||
|
json: 'application/json', xml: 'application/xml',
|
||||||
|
yaml: 'application/yaml', yml: 'application/yaml',
|
||||||
|
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
|
||||||
|
gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml',
|
||||||
|
bmp: 'image/bmp', tif: 'image/tiff', tiff: 'image/tiff',
|
||||||
|
zip: 'application/zip',
|
||||||
|
doc: 'application/msword',
|
||||||
|
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
xls: 'application/vnd.ms-excel',
|
||||||
|
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||||
|
};
|
||||||
|
function mimeFor(name) {
|
||||||
|
var dot = name.lastIndexOf('.');
|
||||||
|
if (dot < 0) return '';
|
||||||
|
return MIME[name.slice(dot + 1).toLowerCase()] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseName(p) {
|
||||||
|
var s = p.replace(/\/+$/, '');
|
||||||
|
var i = s.lastIndexOf('/');
|
||||||
|
return i >= 0 ? s.slice(i + 1) : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject zip entry names that aren't safe to surface: absolute
|
||||||
|
// paths, backslash separators, or anything that escapes via "..".
|
||||||
|
// Returns the cleaned forward-slash path (trailing "/" preserved
|
||||||
|
// for directory entries) or null.
|
||||||
|
function cleanEntryName(name) {
|
||||||
|
if (!name || name.indexOf('\\') !== -1 || name[0] === '/') return null;
|
||||||
|
var isDir = name.endsWith('/');
|
||||||
|
var parts = [];
|
||||||
|
var segs = name.split('/');
|
||||||
|
for (var i = 0; i < segs.length; i++) {
|
||||||
|
var s = segs[i];
|
||||||
|
if (s === '' || s === '.') continue;
|
||||||
|
if (s === '..') return null;
|
||||||
|
parts.push(s);
|
||||||
|
}
|
||||||
|
if (parts.length === 0) return null;
|
||||||
|
return parts.join('/') + (isDir ? '/' : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// ZipFileHandle — FileSystemFileHandle polyfill (read-only)
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
|
||||||
|
function ZipFileHandle(jszip, fullPath, size, modTime) {
|
||||||
|
this.kind = 'file';
|
||||||
|
this.name = baseName(fullPath);
|
||||||
|
this._zip = jszip;
|
||||||
|
this._path = fullPath; // path within the zip (no trailing /)
|
||||||
|
this._size = size || 0;
|
||||||
|
this._modTime = modTime || null;
|
||||||
|
}
|
||||||
|
ZipFileHandle.prototype.getFile = async function () {
|
||||||
|
var entry = this._zip.file(this._path);
|
||||||
|
if (!entry) {
|
||||||
|
var err = new Error('NotFoundError: ' + this._path);
|
||||||
|
err.name = 'NotFoundError';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
var buf = await entry.async('arraybuffer');
|
||||||
|
return new File([buf], this.name, {
|
||||||
|
type: mimeFor(this.name),
|
||||||
|
lastModified: this._modTime ? this._modTime.getTime() : Date.now()
|
||||||
|
});
|
||||||
|
};
|
||||||
|
ZipFileHandle.prototype.createWritable = async function () {
|
||||||
|
var err = new Error('Zip archives are read-only');
|
||||||
|
err.name = 'NoModificationAllowedError';
|
||||||
|
throw err;
|
||||||
|
};
|
||||||
|
ZipFileHandle.prototype.queryPermission = async function () { return 'granted'; };
|
||||||
|
ZipFileHandle.prototype.requestPermission = async function () { return 'granted'; };
|
||||||
|
ZipFileHandle.prototype.isZipEntry = true;
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// ZipDirectoryHandle — FileSystemDirectoryHandle polyfill (read-only)
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
|
||||||
|
// jszip: the JSZip instance. prefix: "" for the zip root, else
|
||||||
|
// "<dir>/". name: the label for this level.
|
||||||
|
function ZipDirectoryHandle(jszip, prefix, name) {
|
||||||
|
this.kind = 'directory';
|
||||||
|
this._zip = jszip;
|
||||||
|
this._prefix = prefix || '';
|
||||||
|
this.name = name != null ? name : (this._prefix ? baseName(this._prefix) : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk the flat entry list once, returning a Map of immediate child
|
||||||
|
// name → { isDir, size, modTime, fullPath }. Synthesises directory
|
||||||
|
// children that have no explicit "<dir>/" entry.
|
||||||
|
ZipDirectoryHandle.prototype._children = function () {
|
||||||
|
var prefix = this._prefix;
|
||||||
|
var seen = new Map();
|
||||||
|
var zip = this._zip;
|
||||||
|
Object.keys(zip.files).forEach(function (rawName) {
|
||||||
|
var clean = cleanEntryName(rawName);
|
||||||
|
if (clean === null) return;
|
||||||
|
var entryIsDir = clean.endsWith('/');
|
||||||
|
var bare = entryIsDir ? clean.slice(0, -1) : clean;
|
||||||
|
if (prefix) {
|
||||||
|
if (bare === prefix.slice(0, -1)) return; // the prefix dir itself
|
||||||
|
if (bare.indexOf(prefix) !== 0) return;
|
||||||
|
}
|
||||||
|
var rest = prefix ? bare.slice(prefix.length) : bare;
|
||||||
|
if (rest === '') return;
|
||||||
|
var slash = rest.indexOf('/');
|
||||||
|
var seg = slash === -1 ? rest : rest.slice(0, slash);
|
||||||
|
var nested = slash !== -1;
|
||||||
|
var existing = seen.get(seg);
|
||||||
|
if (nested) {
|
||||||
|
if (!existing) {
|
||||||
|
seen.set(seg, { isDir: true, size: 0, modTime: null, fullPath: prefix + seg });
|
||||||
|
} else {
|
||||||
|
existing.isDir = true;
|
||||||
|
}
|
||||||
|
} else if (entryIsDir) {
|
||||||
|
var entry = zip.files[rawName];
|
||||||
|
seen.set(seg, {
|
||||||
|
isDir: true, size: 0,
|
||||||
|
modTime: entry && entry.date instanceof Date ? entry.date : null,
|
||||||
|
fullPath: prefix + seg
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (!existing || existing.isDir !== false) {
|
||||||
|
var fent = zip.files[rawName];
|
||||||
|
seen.set(seg, {
|
||||||
|
isDir: false,
|
||||||
|
size: (fent && fent._data && fent._data.uncompressedSize) || 0,
|
||||||
|
modTime: fent && fent.date instanceof Date ? fent.date : null,
|
||||||
|
fullPath: prefix + seg
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return seen;
|
||||||
|
};
|
||||||
|
|
||||||
|
ZipDirectoryHandle.prototype._handleFor = function (seg, info) {
|
||||||
|
if (info.isDir) {
|
||||||
|
return new ZipDirectoryHandle(this._zip, this._prefix + seg + '/', seg);
|
||||||
|
}
|
||||||
|
return new ZipFileHandle(this._zip, info.fullPath, info.size, info.modTime);
|
||||||
|
};
|
||||||
|
|
||||||
|
ZipDirectoryHandle.prototype.values = function () {
|
||||||
|
var self = this;
|
||||||
|
return (async function* () {
|
||||||
|
var children = self._children();
|
||||||
|
var names = Array.from(children.keys()).sort();
|
||||||
|
for (var i = 0; i < names.length; i++) {
|
||||||
|
yield self._handleFor(names[i], children.get(names[i]));
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
};
|
||||||
|
ZipDirectoryHandle.prototype.entries = function () {
|
||||||
|
var iter = this.values();
|
||||||
|
return (async function* () {
|
||||||
|
for (;;) {
|
||||||
|
var step = await iter.next();
|
||||||
|
if (step.done) return;
|
||||||
|
yield [step.value.name, step.value];
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
};
|
||||||
|
ZipDirectoryHandle.prototype.keys = function () {
|
||||||
|
var iter = this.values();
|
||||||
|
return (async function* () {
|
||||||
|
for (;;) {
|
||||||
|
var step = await iter.next();
|
||||||
|
if (step.done) return;
|
||||||
|
yield step.value.name;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
};
|
||||||
|
ZipDirectoryHandle.prototype.getDirectoryHandle = async function (name) {
|
||||||
|
var children = this._children();
|
||||||
|
var info = children.get(name);
|
||||||
|
if (!info || !info.isDir) {
|
||||||
|
var err = new Error('NotFoundError: ' + name);
|
||||||
|
err.name = 'NotFoundError';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return this._handleFor(name, info);
|
||||||
|
};
|
||||||
|
ZipDirectoryHandle.prototype.getFileHandle = async function (name) {
|
||||||
|
var children = this._children();
|
||||||
|
var info = children.get(name);
|
||||||
|
if (!info || info.isDir) {
|
||||||
|
var err = new Error('NotFoundError: ' + name);
|
||||||
|
err.name = 'NotFoundError';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return this._handleFor(name, info);
|
||||||
|
};
|
||||||
|
ZipDirectoryHandle.prototype.queryPermission = async function () { return 'granted'; };
|
||||||
|
ZipDirectoryHandle.prototype.requestPermission = async function () { return 'granted'; };
|
||||||
|
ZipDirectoryHandle.prototype.isZipEntry = true;
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// Constructors
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
|
||||||
|
function requireJSZip() {
|
||||||
|
if (!window.JSZip) {
|
||||||
|
throw new Error('JSZip is not available — this build does not bundle it');
|
||||||
|
}
|
||||||
|
return window.JSZip;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a ZipDirectoryHandle rooted at the top level of `blob`
|
||||||
|
// (an ArrayBuffer, Blob, Uint8Array, or anything JSZip.loadAsync
|
||||||
|
// accepts). `name` labels the root level (default: empty).
|
||||||
|
async function fromBlob(blob, name) {
|
||||||
|
var JSZip = requireJSZip();
|
||||||
|
var src = blob;
|
||||||
|
if (blob && typeof blob.arrayBuffer === 'function') {
|
||||||
|
src = await blob.arrayBuffer();
|
||||||
|
}
|
||||||
|
var zip = await JSZip.loadAsync(src);
|
||||||
|
return new ZipDirectoryHandle(zip, '', name || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a ZipDirectoryHandle from a FileSystemFileHandle (or this
|
||||||
|
// adapter's own ZipFileHandle — so a zip nested inside a zip works
|
||||||
|
// by recursion). The handle's basename labels the root level.
|
||||||
|
async function fromFileHandle(fileHandle) {
|
||||||
|
var f = await fileHandle.getFile();
|
||||||
|
return fromBlob(f, fileHandle.name || (f && f.name) || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.zddc.zip = {
|
||||||
|
ZipDirectoryHandle: ZipDirectoryHandle,
|
||||||
|
ZipFileHandle: ZipFileHandle,
|
||||||
|
fromBlob: fromBlob,
|
||||||
|
fromFileHandle: fromFileHandle,
|
||||||
|
// True for handles produced by this adapter (vs. real FS Access
|
||||||
|
// handles or the HTTP polyfill).
|
||||||
|
isZipHandle: function (h) { return !!(h && h.isZipEntry === true); }
|
||||||
|
};
|
||||||
|
})();
|
||||||
Loading…
Reference in a new issue