269 lines
11 KiB
JavaScript
269 lines
11 KiB
JavaScript
// 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); }
|
|
};
|
|
})();
|