From 2dc2d032a011723e5c4881a8882911c009ca7c93 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Tue, 12 May 2026 12:29:14 -0500 Subject: [PATCH] feat(archive,browse): treat .zip transmittal folders as folders + shared zip adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/" 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) --- archive/build.sh | 1 + archive/js/parser.js | 10 ++ archive/js/source.js | 44 ++++++ browse/build.sh | 1 + browse/js/init.js | 21 +-- browse/js/preview-markdown.js | 13 +- browse/js/preview.js | 14 +- browse/js/tree.js | 202 +++++++------------------ shared/zip-source.js | 269 ++++++++++++++++++++++++++++++++++ 9 files changed, 403 insertions(+), 172 deletions(-) create mode 100644 shared/zip-source.js diff --git a/archive/build.sh b/archive/build.sh index 41f7f98..56aba3c 100644 --- a/archive/build.sh +++ b/archive/build.sh @@ -43,6 +43,7 @@ concat_files \ "../shared/vendor/utif.min.js" \ "../shared/zddc.js" \ "../shared/hash.js" \ + "../shared/zip-source.js" \ "../shared/theme.js" \ "../shared/toast.js" \ "../shared/nav.js" \ diff --git a/archive/js/parser.js b/archive/js/parser.js index 73c76f3..87f426b 100644 --- a/archive/js/parser.js +++ b/archive/js/parser.js @@ -7,6 +7,15 @@ 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) { const groups = {}; files.forEach(file => { @@ -58,6 +67,7 @@ window.app.modules.parser = { isTransmittalFolder, + isTransmittalFolderZip, groupFilesByTrackingNumber, sortGroupedFiles, }; diff --git a/archive/js/source.js b/archive/js/source.js index 4518ef2..5767675 100644 --- a/archive/js/source.js +++ b/archive/js/source.js @@ -184,6 +184,29 @@ console.warn('Could not process directory ' + entry.name + ':', err); } } 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. // actualPath records the real containing folder for grouping-folder-scoped filtering. try { @@ -479,6 +502,27 @@ } } else { // 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/" 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) { // File directly in a grouping folder — assign to Outstanding virtual transmittal. // actualPath records the real containing folder for grouping-folder-scoped filtering. diff --git a/browse/build.sh b/browse/build.sh index 28e8f50..b53377b 100755 --- a/browse/build.sh +++ b/browse/build.sh @@ -42,6 +42,7 @@ concat_files \ "../shared/vendor/toastui-editor-all.min.js" \ "../shared/zddc.js" \ "../shared/zddc-filter.js" \ + "../shared/zip-source.js" \ "../shared/theme.js" \ "../shared/toast.js" \ "../shared/nav.js" \ diff --git a/browse/js/init.js b/browse/js/init.js index 2876e65..e7975b2 100644 --- a/browse/js/init.js +++ b/browse/js/init.js @@ -33,15 +33,18 @@ viewMode: 'browse', // The tree's in-memory representation. Each node: - // { id, name, isDir, size, modTime, ext, url, depth, - // parentId, expanded, loaded, childIds, isZip, zipFile, - // zipPath } - // - isZip: set when the node IS a .zip file we know how to - // expand inline (server file or FS handle). - // - zipFile: cached JSZip instance for this archive (set - // after first expand). - // - zipPath: relative path WITHIN a zip (set on virtual - // children of an expanded zip; null otherwise). + // { id, name, isDir, size, modTime, ext, url, handle, depth, + // parentId, expanded, loaded, childIds, isZip, + // _zipDirHandle, virtual } + // - isZip: the node IS a .zip file; expanding it lists + // the zip's members (server "<…>.zip/" listing + // online, JSZip behind a ZipDirectoryHandle + // offline). Members are ordinary dir/file nodes. + // - _zipDirHandle: cached ZipDirectoryHandle for an opened zip + // (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 // from a depth-first walk. nodes: new Map(), diff --git a/browse/js/preview-markdown.js b/browse/js/preview-markdown.js index dcfd0c5..66854cf 100644 --- a/browse/js/preview-markdown.js +++ b/browse/js/preview-markdown.js @@ -272,8 +272,17 @@ 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/" 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) { - if (node.zipParentId != null) return false; + if (isZipMemberNode(node)) return false; if (node.handle && typeof node.handle.createWritable === 'function') return true; if (node.url && window.app.state.source === 'server') return true; return false; @@ -391,7 +400,7 @@ var sourceEl = document.createElement('span'); sourceEl.className = 'md-shell__source'; - if (node.zipParentId != null) { + if (isZipMemberNode(node)) { sourceEl.textContent = 'read-only (zip)'; } else if (node.handle) { sourceEl.textContent = 'local'; diff --git a/browse/js/preview.js b/browse/js/preview.js index d35e0cf..46b7b4f 100644 --- a/browse/js/preview.js +++ b/browse/js/preview.js @@ -50,13 +50,8 @@ } async function getArrayBuffer(node) { - if (node.zipParentId != null) { - var owner = state.nodes.get(node.zipParentId); - if (!owner || !owner.zipFile) { - throw new Error('parent zip not loaded'); - } - return await owner.zipFile.file(node.zipPath).async('arraybuffer'); - } + // A zip member node carries a ZipFileHandle in node.handle, so + // it falls through the same getFile() path as any local file. if (state.source === 'server' && node.url) { var resp = await fetch(node.url); if (!resp.ok) throw new Error('HTTP ' + resp.status); @@ -70,7 +65,10 @@ } async function getBlobUrl(node) { - if (state.source === 'server' && node.url && node.zipParentId == null) { + // Server-served files (including zip members at "<…>.zip/" + // 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 }; } var buf = await getArrayBuffer(node); diff --git a/browse/js/tree.js b/browse/js/tree.js index 918fc07..460af9b 100644 --- a/browse/js/tree.js +++ b/browse/js/tree.js @@ -14,9 +14,11 @@ function newNode(raw, parentId, depth) { var id = state.nextId++; - // ZIP files are treated as folders for tree purposes — the - // chevron lets the user expand them inline. The actual - // contents are loaded on first expand via JSZip. + // A .zip file is treated as a folder for tree purposes — the + // chevron expands it. On expand, server mode fetches the + // 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 node = { id: id, @@ -37,9 +39,7 @@ loaded: false, childIds: [], isZip: isZip, - zipFile: null, // cached JSZip instance - zipPath: raw.zipPath || null, // path within zip (for virtual children) - zipParentId: raw.zipParentId || null, // ancestor zip's node id (for nested entries) + _zipDirHandle: null, // cached ZipDirectoryHandle (offline / nested zips) // True when this entry was synthesized client-side (e.g. // canonical project folders that don't exist on disk yet). // Rendered with a muted style + an "(empty)" hint. @@ -280,53 +280,60 @@ // table); the tree.setSort() method still works but only via // programmatic callers — there's no UI for changing sort yet. - // True when this zip node lives inside another zip (so its bytes - // can't be fetched as a standalone server resource — we go through - // the parent JSZip / inner-zip download path instead). In server - // mode the URL of a zip-member-inside-a-zip contains ".zip/"; in - // FS-API mode zipParentId is set. + // True when this .zip node lives inside another zip, so its bytes + // can't be fetched as a standalone server resource: we read them + // through the containing handle (offline / nested) or by fetching + // the inner-zip member URL. In server mode a zip-inside-a-zip's URL + // contains ".zip/"; offline it has a handle that is itself a zip + // entry. function zipNestedInsideZip(node) { - if (node.zipParentId != null) return true; if (state.source === 'server') { 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 + // ".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 // by node kind: - // - regular folder → server JSON listing OR FS-API enumeration - // - top-level zip, server mode → server's virtual-directory listing - // of the zip's members (no whole-zip - // download — zddc-server extracts on demand) - // - zip otherwise → fetch+JSZip; entries become virtual children - // - zip child dir → already-listed entries from the parent zip - // (the whole zip was enumerated when it - // expanded, so child dirs are pre-seeded) + // - regular folder → server JSON listing OR FS-API entries + // - top-level .zip, server mode → the server's "<…>.zip/" virtual- + // directory listing (no whole-zip + // download — zddc-server extracts a + // member only when one is requested) + // - .zip otherwise (offline, or a zip nested in a zip) + // → open it with JSZip and enumerate + // it as a directory handle; members + // become ordinary dir/file nodes async function loadChildren(node) { if (node.loaded) return; try { - if (node.isZip) { - if (state.source === 'server' && !zipNestedInsideZip(node)) { - // zddc-server serves "<…>.zip/" as a virtual - // directory: GET it as a listing, GET a member URL - // 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 + '/'); + if (node.isZip && state.source === 'server' && !zipNestedInsideZip(node)) { + setChildren(node.id, await loader.fetchServerChildren(pathFor(node) + '/')); + } else if (node.isZip) { + setChildren(node.id, await loader.fetchFsChildren(await zipDirHandle(node))); } else if (node.isDir) { var raw; 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. // Treats "expandable" as either a real directory OR a zip file // (zip files act like folders for tree purposes — the chevron diff --git a/shared/zip-source.js b/shared/zip-source.js new file mode 100644 index 0000000..827be38 --- /dev/null +++ b/shared/zip-source.js @@ -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 + // "/". 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 "/" 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); } + }; +})();