// 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); } }; })();