ZDDC/classifier/js/scanner.js
2026-06-11 13:32:31 -05:00

1007 lines
44 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Directory Scanner Module
* Scans directories and collects files
*/
(function() {
'use strict';
// Store ZIP data for later access
const zipCache = new Map(); // path -> { zip: JSZip, fileHandle: FileSystemFileHandle }
// ── Incremental-scan state ───────────────────────────────────────────────
// The scan no longer reads the whole tree before rendering. It walks
// breadth-first behind a small worker pool, renders progressively (top
// levels appear first), and shows live status — so a huge network drive
// never looks stalled. Each folder tracks its own scan state + counts.
let scanGen = 0; // bumped per scan; stale workers bail
let scanStats = null; // { folders, files, current, done, startedAt }
let renderTimer = null; // throttle for progressive re-render
// How many directories to read in flight at once. The scan is I/O-bound —
// each readdir is a round-trip to the backing store (cloud-sync / network
// mounts like OneDrive or Samba have high per-op latency), so the lever is
// parallel in-flight reads, not CPU threads. This only helps the
// many-folders case; a single fat folder is enumerated one entry at a time
// by the File System Access API and can't be parallelized. Raise it if the
// store tolerates more concurrency; too high risks cloud-provider
// throttling.
var SCAN_CONCURRENCY = 32;
function scheduleRender() {
if (renderTimer) return;
renderTimer = setTimeout(function () {
renderTimer = null;
try { window.app.modules.tree.render(); } catch (_) { /* ignore */ }
updateScanStatus();
}, 180);
}
function flushRender() {
if (renderTimer) { clearTimeout(renderTimer); renderTimer = null; }
try { window.app.modules.tree.render(); } catch (_) { /* ignore */ }
updateScanStatus();
}
// elapsed since the scan started, e.g. "3.2s" or "1m 04s".
function elapsedStr() {
if (!scanStats) return '0s';
const ms = Date.now() - scanStats.startedAt;
if (ms < 60000) return (ms / 1000).toFixed(1) + 's';
const m = Math.floor(ms / 60000);
const s = Math.round((ms % 60000) / 1000);
return m + 'm ' + (s < 10 ? '0' : '') + s + 's';
}
// Render the running scan status (with live elapsed time) into the footer.
function updateScanStatus() {
const el = document.getElementById('scanStatus');
if (!el || !scanStats) return;
if (scanStats.done) {
el.textContent = 'Scanned ' + scanStats.folders + ' folders · '
+ scanStats.files + ' files in ' + elapsedStr();
el.classList.remove('scanning');
} else {
el.textContent = 'Scanning… ' + scanStats.folders + ' folders · '
+ scanStats.files + ' files · ' + elapsedStr()
+ (scanStats.current ? ' — ' + scanStats.current : '');
el.classList.add('scanning');
}
}
// Make a tree node. scanState: 'pending' (children not read) →
// 'scanning' → 'children' (immediate children read, subtree still going) →
// 'done' (entire subtree enumerated). The UI greys a node until 'done'.
function makeNode(handle, path, parent) {
const node = {
name: handle.name,
path: path,
handle: handle,
parent: parent || null,
files: [],
fileCount: 0, // direct files in this folder
subdirCount: 0, // direct subfolders
runFiles: 0, // files in the whole subtree (grows as scanned; final on 'done')
runDirs: 0, // subfolders in the whole subtree
children: [],
expanded: false,
scanState: 'pending',
pending: 0, // child dirs not yet 'done'
};
if (handle.isZipRoot) { node.isZipRoot = true; node.zipPath = handle.zipPath; }
if (handle.isVirtualDir) { node.isVirtualDir = true; node.zipPath = handle.zipPath; }
return node;
}
// Mark a node's subtree fully scanned: roll up recursive totals and
// propagate completion to the parent (which flips to 'done' once all its
// children are done). This is what turns a folder from grey to solid.
function markDone(node) {
if (node.scanState === 'done') return;
// runFiles/runDirs were accumulated into this node (and its ancestors)
// as each descendant was scanned, so by the time the subtree is
// complete they already hold the final totals — nothing to compute.
node.scanState = 'done';
const p = node.parent;
if (p && p.scanState !== 'done') {
p.pending -= 1;
if (p.pending <= 0 && (p.scanState === 'children' || p.scanState === 'scanning')) {
markDone(p);
}
}
}
// Translate a File System Access API error into accurate, actionable text.
// The browser's raw DOMException messages are cryptic and often read like a
// permission problem when they aren't — we key off err.name (reliable)
// rather than the message. Returns a plain-language explanation; the raw
// name + message are still appended by the caller for troubleshooting.
function describeFsError(err) {
var name = err && err.name ? err.name : '';
switch (name) {
case 'NotAllowedError':
return 'Permission to read this folder was denied or revoked. '
+ 'Re-pick the root folder to re-grant access.';
case 'InvalidStateError':
// The handle was read once, then the directory changed underneath
// it (common on a network/SMB share that's being written to, or
// after a disconnect/reconnect). NOT a permissions problem.
return 'The folder changed on disk since it was first read '
+ '(common on a busy or reconnecting network share). '
+ 'Rescan to pick up the current contents.';
case 'NotFoundError':
return 'The folder no longer exists — it may have been moved, '
+ 'renamed, or deleted since the scan started.';
case 'NotReadableError':
return 'The folder could not be read — the share may have '
+ 'disconnected, or the OS denied access.';
case 'SecurityError':
return 'The browser blocked access to this folder for security '
+ 'reasons.';
case 'TypeMismatchError':
return 'Expected a folder here but found a file (or vice-versa).';
case 'AbortError':
return 'Reading this folder was aborted.';
default:
return 'Could not read this folder.';
}
}
// One-shot toast for scan errors (permission denied, stale handles, network
// hiccups on a share). De-duped per path so a flaky folder doesn't spam.
const scanErrorsSeen = new Set();
function reportScanError(path, err) {
console.error('Scan error:', path, err);
if (scanErrorsSeen.has(path)) return;
scanErrorsSeen.add(path);
// Plain-language explanation, then the raw error in parentheses so the
// user can copy it (toasts are selectable) for deeper troubleshooting.
var raw = err && err.name
? err.name + (err.message ? ': ' + err.message : '')
: (err && err.message ? err.message : String(err));
var msg = 'Couldnt scan ' + path + ' — ' + describeFsError(err)
+ '\n\n(' + raw + ')';
if (window.zddc && typeof window.zddc.toast === 'function') {
window.zddc.toast(msg, 'error');
}
}
/**
* Scan directory and build folder tree with files
*/
async function scanDirectory(dirHandle, preserveState = false) {
// Preserve which folders were expanded across a rescan (e.g. after a
// ZIP extract) so the user doesn't lose their place.
let savedExpanded = new Set();
let savedSelected = new Set();
if (preserveState) {
savedExpanded = getExpandedPaths(window.app.folderTree);
savedSelected = new Set(window.app.selectedFolders);
}
const myGen = ++scanGen;
zipCache.clear();
scanStats = { folders: 0, files: 0, current: dirHandle.name, done: false, startedAt: Date.now() };
// Root node — render immediately so the pane never sits blank.
const root = makeNode(dirHandle, dirHandle.name, null);
root.expanded = true;
window.app.folderTree = [root];
window.app.modules.store.setFolderTree(window.app.folderTree);
if (!preserveState) {
// Select the root so the grid shows its immediate files at once,
// instead of auto-loading the ENTIRE drive (the old behaviour,
// which is exactly what stalled on a large share).
window.app.selectedFolders = new Set([root.path]);
window.app.lastSelectedFolderPath = root.path;
window.app.modules.store.setSelectedFolders(window.app.selectedFolders);
} else {
window.app.selectedFolders = savedSelected;
window.app.modules.store.setSelectedFolders(savedSelected);
}
flushRender();
// Tick the footer's elapsed time once a second even if no new folder
// landed (so a slow directory doesn't make the timer look frozen).
const ticker = setInterval(function () {
if (myGen !== scanGen || (scanStats && scanStats.done)) { clearInterval(ticker); return; }
updateScanStatus();
}, 1000);
// Continuous breadth-first walk: up to SCAN_CONCURRENCY directory reads
// in flight at once, pulling newly-discovered child dirs as they land
// (no per-level barrier, so the pool stays saturated). Top levels still
// appear first (FIFO). The cap is the lever — see SCAN_CONCURRENCY.
await drainQueue([root], myGen, SCAN_CONCURRENCY);
if (preserveState && savedExpanded.size) {
restoreExpandedPaths(window.app.folderTree, savedExpanded);
}
clearInterval(ticker);
if (myGen !== scanGen) return; // superseded by a newer scan
scanStats.done = true;
scanStats.current = '';
flushRender();
// Completion toast with the totals + elapsed time.
if (window.zddc && typeof window.zddc.toast === 'function') {
window.zddc.toast(
'Scan complete — ' + scanStats.folders + ' folders, '
+ scanStats.files + ' files in ' + elapsedStr() + '.',
'success');
}
}
// Continuous worker pool over a shared queue: keep up to `conc` directory
// reads in flight at once, pulling newly-discovered child dirs as they land
// — no per-level barrier, so workers never idle waiting on the slowest dir
// in a level. Roughly breadth-first (FIFO; a node's children are enqueued
// after it), so top levels still surface first. Resolves when the queue is
// drained and no read is in flight (clean termination, no empty-queue race).
function drainQueue(seed, myGen, conc) {
const queue = seed.slice();
let active = 0;
return new Promise(function (resolve) {
function finishIfIdle() {
if (queue.length === 0 && active === 0) resolve();
}
function pump() {
while (myGen === scanGen && active < conc && queue.length) {
const node = queue.shift();
active++;
Promise.resolve(scanNodeChildren(node, myGen)).then(function () {
active--;
if (myGen === scanGen) {
const kids = node.children;
for (let i = 0; i < kids.length; i++) {
if (kids[i].scanState === 'pending') queue.push(kids[i]);
}
}
pump();
finishIfIdle();
}, function () { active--; pump(); finishIfIdle(); });
}
finishIfIdle();
}
pump();
});
}
// Force a folder's subtree to scan NOW (jumped ahead of the background
// walk). Called when the user opens a folder, so an opened folder always
// shows complete contents. Idempotent + shares the live scan generation.
async function ensureScanned(node) {
if (!node || !node.handle || node.scanState === 'done') return;
await drainQueue([node], scanGen, SCAN_CONCURRENCY);
flushRender();
}
/**
* Get all expanded folder paths from tree
*/
function getExpandedPaths(folders, paths = new Set()) {
for (const folder of folders) {
if (folder.expanded) {
paths.add(folder.path);
}
if (folder.children) {
getExpandedPaths(folder.children, paths);
}
}
return paths;
}
/**
* Restore expanded state to tree
*/
function restoreExpandedPaths(folders, expandedPaths) {
for (const folder of folders) {
folder.expanded = expandedPaths.has(folder.path);
if (folder.children) {
restoreExpandedPaths(folder.children, expandedPaths);
}
}
}
// Read ONE directory's immediate entries: files into node.files, child
// directories into node.children (left 'pending' for the BFS to descend).
// A .zip becomes an expandable zip-root child, scanned inline (its
// contents are already in memory once the entry is read). Idempotent:
// only a 'pending' node is scanned, so concurrent callers (background +
// open-prioritised) don't double-scan.
async function scanNodeChildren(node, myGen) {
// A .zip is a lazy node — read its contents only when opened.
if (node.scanState === 'zip-pending') { await scanZipNode(node); return; }
if (node.scanState !== 'pending') return;
node.scanState = 'scanning';
if (scanStats) scanStats.current = node.path;
const files = [];
const childDirs = [];
try {
for await (const entry of node.handle.values()) {
if (myGen !== scanGen) { node.scanState = 'pending'; return; } // cancelled
if (entry.kind === 'file') {
const fo = createFileObject(entry, node.handle);
fo.folderPath = node.path;
files.push(fo);
if (scanStats) scanStats.files++;
// A .zip is a single file by default (one classifiable unit).
// The user can later "Expand as folder" (expandZipAsFolder) to
// pull its members into the fileset.
} else if (entry.kind === 'directory') {
const childPath = node.path + '/' + entry.name;
childDirs.push(makeNode(entry, childPath, node));
if (scanStats) scanStats.folders++;
}
}
} catch (err) {
node.scanError = true;
reportScanError(node.path, err);
}
node.files = files;
node.fileCount = files.length;
node.children = childDirs;
node.subdirCount = childDirs.length;
// Roll this folder's own files/dirs into the running subtree totals of
// this node + every ancestor. Real child dirs add their share when they
// get scanned; lazy zip nodes add theirs when opened (scanZipNode).
const addF = files.length;
const addD = childDirs.length;
for (let a = node; a; a = a.parent) { a.runFiles += addF; a.runDirs += addD; }
// Only real unscanned dirs hold the parent open; zip-pending children
// are lazy, so they don't.
node.pending = childDirs.filter(function (c) { return c.scanState === 'pending'; }).length;
if (node.pending === 0) {
markDone(node);
} else {
node.scanState = 'children';
}
scheduleRender();
}
// Read a lazy zip node's contents on demand (when opened), building its
// child nodes and folding its internal totals into ancestors.
async function scanZipNode(node) {
if (node.scanState !== 'zip-pending') return;
var fileObj = node._zipFileObj;
if (!fileObj) {
// Restored from a snapshot — no live file object. Resolve the .zip
// from the workspace root by its path so it can be opened on demand.
if (!window.app.rootHandle || !node.zipPath) return;
try {
var dir = await resolveDirHandle(window.app.rootHandle, relFromRoot(parentPath(node.zipPath)));
fileObj = { handle: await dir.getFileHandle(baseName(node.zipPath)), folderHandle: dir };
} catch (e) {
reportScanError(node.path, e); node.scanState = 'done'; node.runFiles = 0; node.runDirs = 0; return;
}
}
node.scanState = 'scanning';
scheduleRender();
try {
await scanZipIntoNode(node, fileObj); // builds children, runFiles/runDirs, sets 'done'
} catch (e) {
reportScanError(node.path, e);
node.scanState = 'done';
node.runFiles = 0;
node.runDirs = 0;
}
node._zipFileObj = null;
// The zip counted as 1 dir in its parent already; now fold in its
// internal files/dirs to every ancestor's running totals.
for (let a = node.parent; a; a = a.parent) {
a.runFiles += node.runFiles;
a.runDirs += node.runDirs;
}
scheduleRender();
}
// Build a zip-root node's children from its archive contents (in memory),
// marking the whole zip subtree 'done' immediately. Mirrors the on-disk
// node shape so the rest of the app treats zip folders like real ones.
async function scanZipIntoNode(zipNode, fileObj) {
const f = await fileObj.handle.getFile();
const zip = await JSZip.loadAsync(await f.arrayBuffer());
const zipPath = zipNode.path;
zipCache.set(zipPath, { zip: zip, fileHandle: fileObj.handle, folderHandle: fileObj.folderHandle });
const dirNodes = new Map();
dirNodes.set(zipPath, zipNode);
function ensureDir(dirPath) {
if (dirNodes.has(dirPath)) return dirNodes.get(dirPath);
const parentPath = dirPath.substring(0, dirPath.lastIndexOf('/'));
const parent = ensureDir(parentPath);
const name = dirPath.substring(dirPath.lastIndexOf('/') + 1);
const vh = { name: name, kind: 'directory', isVirtualDir: true, zipPath: zipPath, virtualPath: dirPath };
const child = makeNode(vh, dirPath, parent);
parent.children.push(child);
dirNodes.set(dirPath, child);
return child;
}
zip.forEach(function (relativePath, entry) {
if (entry.dir) {
ensureDir(zipPath + '/' + relativePath.replace(/\/$/, ''));
} else {
const fileName = relativePath.split('/').pop();
const fileDir = relativePath.includes('/')
? zipPath + '/' + relativePath.substring(0, relativePath.lastIndexOf('/'))
: zipPath;
const dirNode = ensureDir(fileDir);
const split = zddc.splitExtension(fileName);
dirNode.files.push({
originalFilename: split.name,
extension: split.extension,
size: entry._data ? entry._data.uncompressedSize : 0,
lastModified: entry.date ? entry.date.getTime() : Date.now(),
isVirtual: true,
zipPath: zipPath,
zipEntryPath: relativePath,
folderPath: dirNode.path,
trackingNumber: '', revision: '', status: '', title: '',
isDirty: false, error: false, errorMessage: '', validation: null, sha256: null
});
}
});
finalizeZipNode(zipNode);
}
// Roll up a zip node's counts + mark its whole subtree 'done'.
function finalizeZipNode(node) {
node.fileCount = node.files.length;
node.subdirCount = node.children.length;
let rf = node.files.length, rd = node.children.length;
for (const c of node.children) { finalizeZipNode(c); rf += c.runFiles; rd += c.runDirs; }
node.runFiles = rf;
node.runDirs = rd;
node.scanState = 'done';
node.pending = 0;
}
/**
* Scan a ZIP file and add its contents as virtual folders
*/
async function scanZipFile(zipFileObj, foldersMap, parentPath, parentItems) {
try {
const fileObj = await zipFileObj.handle.getFile();
const arrayBuffer = await fileObj.arrayBuffer();
const zip = await JSZip.loadAsync(arrayBuffer);
const zipPath = parentPath + '/' + zddc.joinExtension(zipFileObj.originalFilename, zipFileObj.extension);
// Cache the ZIP for later extraction
zipCache.set(zipPath, {
zip: zip,
fileHandle: zipFileObj.handle,
folderHandle: zipFileObj.folderHandle
});
// Mark the file as a ZIP container
zipFileObj.isZipContainer = true;
zipFileObj.zipPath = zipPath;
// Build virtual folder structure from ZIP contents
const virtualFolders = new Map(); // path -> { files: [], subdirs: Set }
virtualFolders.set(zipPath, { files: [], subdirs: new Set() });
zip.forEach((relativePath, zipEntry) => {
if (zipEntry.dir) {
// It's a directory
const dirPath = zipPath + '/' + relativePath.replace(/\/$/, '');
if (!virtualFolders.has(dirPath)) {
virtualFolders.set(dirPath, { files: [], subdirs: new Set() });
}
// Add to parent's subdirs
const parentDir = dirPath.substring(0, dirPath.lastIndexOf('/'));
if (virtualFolders.has(parentDir)) {
virtualFolders.get(parentDir).subdirs.add(dirPath);
}
} else {
// It's a file
const fileName = relativePath.split('/').pop();
const fileDir = relativePath.includes('/')
? zipPath + '/' + relativePath.substring(0, relativePath.lastIndexOf('/'))
: zipPath;
// Ensure parent directories exist
ensureVirtualPath(virtualFolders, zipPath, fileDir);
// Create virtual file object
const split = zddc.splitExtension(fileName);
const virtualFile = {
originalFilename: split.name,
extension: split.extension,
size: zipEntry._data ? zipEntry._data.uncompressedSize : 0,
lastModified: zipEntry.date ? zipEntry.date.getTime() : Date.now(),
// Virtual file markers
isVirtual: true,
zipPath: zipPath,
zipEntryPath: relativePath,
// Editable fields
trackingNumber: '',
revision: '',
status: '',
title: '',
// State
isDirty: false,
error: false,
errorMessage: '',
validation: null,
sha256: null
};
virtualFolders.get(fileDir).files.push(virtualFile);
}
});
// Convert virtual folders to format compatible with tree builder
// Create a virtual handle for the ZIP root
const zipVirtualHandle = {
name: zddc.joinExtension(zipFileObj.originalFilename, zipFileObj.extension),
kind: 'directory',
isZipRoot: true,
zipPath: zipPath
};
// Store virtual folder data
buildVirtualFolderMap(virtualFolders, zipPath, foldersMap, zipVirtualHandle);
// Add ZIP as a virtual directory in parent
parentItems.push({
handle: zipVirtualHandle,
isDirectory: true,
isZipRoot: true
});
} catch (err) {
console.error('Error scanning ZIP file:', zipFileObj.originalFilename, err);
}
}
/**
* Ensure all parent directories exist in virtual folder map
*/
function ensureVirtualPath(virtualFolders, zipPath, targetPath) {
if (virtualFolders.has(targetPath)) return;
const parts = targetPath.substring(zipPath.length + 1).split('/').filter(p => p);
let currentPath = zipPath;
for (const part of parts) {
const parentPath = currentPath;
currentPath = currentPath + '/' + part;
if (!virtualFolders.has(currentPath)) {
virtualFolders.set(currentPath, { files: [], subdirs: new Set() });
}
if (virtualFolders.has(parentPath)) {
virtualFolders.get(parentPath).subdirs.add(currentPath);
}
}
}
/**
* Build virtual folder entries for the foldersMap
* Uses path strings as keys for virtual folders to avoid object reference issues
*/
function buildVirtualFolderMap(virtualFolders, zipPath, foldersMap, zipVirtualHandle) {
const rootData = virtualFolders.get(zipPath);
if (!rootData) return;
// Create items array for ZIP root
const rootItems = [...rootData.files];
// Add subdirectories
for (const subdirPath of rootData.subdirs) {
const subdirName = subdirPath.split('/').pop();
const subdirHandle = {
name: subdirName,
kind: 'directory',
isVirtualDir: true,
virtualPath: subdirPath,
zipPath: zipPath
};
rootItems.push({
handle: subdirHandle,
isDirectory: true,
isVirtualDir: true
});
// Recursively add subdir contents
buildVirtualSubfolder(virtualFolders, subdirPath, foldersMap, zipPath);
}
// Store with both the handle object AND the path string as keys
// This ensures lookup works regardless of which reference is used
foldersMap.set(zipVirtualHandle, rootItems);
foldersMap.set(zipPath, rootItems); // Path-based key for tree building
}
/**
* Recursively build virtual subfolder entries
*/
function buildVirtualSubfolder(virtualFolders, folderPath, foldersMap, zipPath) {
const folderData = virtualFolders.get(folderPath);
if (!folderData) return;
const folderName = folderPath.split('/').pop();
const folderHandle = {
name: folderName,
kind: 'directory',
isVirtualDir: true,
virtualPath: folderPath,
zipPath: zipPath
};
const items = [...folderData.files];
// Store with path string key for tree building lookup
foldersMap.set(folderPath, items);
// Add subdirectories
for (const subdirPath of folderData.subdirs) {
const subdirName = subdirPath.split('/').pop();
const subdirHandle = {
name: subdirName,
kind: 'directory',
isVirtualDir: true,
virtualPath: subdirPath,
zipPath: zipPath
};
items.push({
handle: subdirHandle,
isDirectory: true,
isVirtualDir: true
});
// Recursively add subdir contents
buildVirtualSubfolder(virtualFolders, subdirPath, foldersMap, zipPath);
}
foldersMap.set(folderHandle, items);
}
/**
* Get cached ZIP data
*/
function getZipCache(zipPath) {
return zipCache.get(zipPath);
}
/**
* Extract a ZIP file to its parent directory
*/
async function extractZip(zipPath) {
const cached = zipCache.get(zipPath);
if (!cached) {
throw new Error('ZIP not found in cache');
}
const { zip, folderHandle } = cached;
// Get the ZIP filename without extension for the extract folder name
const zipName = zipPath.split('/').pop();
const extractFolderName = zipName.replace(/\.zip$/i, '');
// Create extraction folder
const extractFolder = await folderHandle.getDirectoryHandle(extractFolderName, { create: true });
// Extract all files
const entries = [];
zip.forEach((relativePath, zipEntry) => {
if (!zipEntry.dir) {
entries.push({ path: relativePath, entry: zipEntry });
}
});
for (const { path, entry } of entries) {
try {
// Create subdirectories if needed
const parts = path.split('/');
const fileName = parts.pop();
let currentDir = extractFolder;
for (const part of parts) {
if (part) {
currentDir = await currentDir.getDirectoryHandle(part, { create: true });
}
}
// Write file
const content = await entry.async('arraybuffer');
const fileHandle = await currentDir.getFileHandle(fileName, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(content);
await writable.close();
} catch (err) {
console.error('Error extracting file:', path, err);
}
}
return extractFolderName;
}
/**
* Create file object with metadata
*/
// Build a file row from JUST the directory entry — no getFile(). Listing a
// network share is already slow; the old code opened EVERY file to read
// size/lastModified (which the grid doesn't even display), turning a
// listing into one network round-trip per file. size/lastModified are now
// loaded on demand by preview / SHA / rename, which call getFile()
// themselves. The scan is now a pure directory listing.
function createFileObject(fileHandle, folderHandle) {
const split = zddc.splitExtension(fileHandle.name);
return {
handle: fileHandle,
folderHandle: folderHandle,
originalFilename: split.name,
extension: split.extension,
size: null,
lastModified: null,
// Editable fields
trackingNumber: '',
revision: '',
status: '',
title: '',
// State
isDirty: false,
error: false,
errorMessage: '',
validation: null,
sha256: null
// folderPath added by the caller.
};
}
// ── Workspace snapshot (scan once, resume without re-walking the FS) ────
// Serialize the completed scan to compact JSON (short keys: large trees).
// Zip subtrees ARE preserved: a scanned archive keeps its virtual folders +
// members so classifications inside it survive reopen; copy/preview re-load
// the archive lazily from the root (ensureZipLoaded). An archive that was
// never opened persists as a lazy 'zip' node that reopens on demand.
function snapshotTree() {
function serFile(f) {
var o = { o: f.originalFilename, e: f.extension, p: f.folderPath };
if (f.isVirtual) { o.z = f.zipPath; o.ze = f.zipEntryPath; } // zip member
return o;
}
function serNode(n) {
var o = { n: n.name, p: n.path };
if (n.isZipRoot) o.zr = 1; // archive root (zipPath === n.path)
else if (n.isVirtualDir) o.vd = n.zipPath; // folder inside an archive
if (n.files && n.files.length) o.f = n.files.map(serFile);
if (n.children && n.children.length) o.c = n.children.map(serNode);
// Record scan progress so an interrupted scan can resume: 'children'
// = direct entries fully read (kids may still be pending); anything
// unfinished → 'pending' to re-read. An unopened archive persists as
// 'zip' (reopen lazily, never a real dir re-walk). 'done' is the
// default and omitted.
var st = n.scanState;
if (n.isZipRoot && st !== 'done') o.s = 'zip';
else if (st && st !== 'done') o.s = (st === 'children') ? 'children' : 'pending';
return o;
}
return (window.app.folderTree || []).map(serNode);
}
// Rebuild window.app.folderTree from a snapshot — handle-less nodes, marked
// 'done', subtree totals recomputed. Handles are resolved lazily from the
// workspace root handle at copy/preview time.
function loadSnapshot(snap) {
function deFile(sf) {
var fo = {
handle: null, folderHandle: null,
originalFilename: sf.o, extension: sf.e,
size: null, lastModified: null,
trackingNumber: '', revision: '', status: '', title: '',
isDirty: false, error: false, errorMessage: '', validation: null, sha256: null,
folderPath: sf.p,
};
if (sf.z) { fo.isVirtual = true; fo.zipPath = sf.z; fo.zipEntryPath = sf.ze; }
return fo;
}
function deNode(sn, parent) {
var desc = { name: sn.n, kind: 'directory' };
if (sn.zr) { desc.isZipRoot = true; desc.zipPath = sn.p; }
else if (sn.vd) { desc.isVirtualDir = true; desc.zipPath = sn.vd; }
var node = makeNode(desc, sn.p, parent);
node.handle = null;
if (sn.zr || sn.vd) node.virtualPath = sn.p;
// 'zip' restores an unopened archive (reopen lazily); else resume marker.
node.scanState = sn.s === 'zip' ? 'zip-pending' : (sn.s || 'done');
node.expanded = false;
node.files = (sn.f || []).map(deFile);
node.children = (sn.c || []).map(function (c) { return deNode(c, node); });
node.fileCount = node.files.length;
node.subdirCount = node.children.length;
return node;
}
var roots = (snap || []).map(function (sn) { return deNode(sn, null); });
if (roots[0]) roots[0].expanded = true;
(function totals(nodes) {
nodes.forEach(function (n) {
totals(n.children);
var rf = n.files.length, rd = n.children.length;
n.children.forEach(function (c) { rf += c.runFiles; rd += c.runDirs; });
n.runFiles = rf; n.runDirs = rd;
});
})(roots);
window.app.folderTree = roots;
if (window.app.modules.store && window.app.modules.store.setFolderTree) {
window.app.modules.store.setFolderTree(roots);
}
return roots;
}
// ── Lazy handle resolution (snapshot files carry paths, not handles) ────
function relFromRoot(p) { var i = (p || '').indexOf('/'); return i < 0 ? '' : p.slice(i + 1); }
function parentPath(p) { var i = (p || '').lastIndexOf('/'); return i < 0 ? '' : p.slice(0, i); }
function baseName(p) { var i = (p || '').lastIndexOf('/'); return i < 0 ? p : p.slice(i + 1); }
// Load (and cache) a zip archive by its tree path. After a snapshot restore
// the in-memory cache is empty, so resolve the .zip from the workspace root
// and parse it on demand. Returns the cache record { zip, fileHandle, ... }.
async function ensureZipLoaded(rootHandle, zipPath) {
var cached = zipCache.get(zipPath);
if (cached && cached.zip) return cached;
if (!rootHandle) throw new Error('source directory not connected');
var dir = await resolveDirHandle(rootHandle, relFromRoot(parentPath(zipPath)));
var fh = await dir.getFileHandle(baseName(zipPath));
var zip = await JSZip.loadAsync(await (await fh.getFile()).arrayBuffer());
var rec = { zip: zip, fileHandle: fh, folderHandle: dir };
zipCache.set(zipPath, rec);
return rec;
}
// Read a zip member's bytes as a Blob (lazily loading its archive).
async function extractZipMember(rootHandle, fileObj) {
var rec = await ensureZipLoaded(rootHandle, fileObj.zipPath);
var entry = rec.zip.file(fileObj.zipEntryPath);
if (!entry) throw new Error('zip member not found: ' + fileObj.zipEntryPath);
return await entry.async('blob');
}
// ── per-zip mode toggle (single file ⇄ expandable folder) ───────────────
function findNodeByPath(path) {
var hit = null;
(function walk(ns) { (ns || []).forEach(function (n) { if (hit) return; if (n.path === path) hit = n; else walk(n.children); }); })(window.app.folderTree || []);
return hit;
}
// Recompute subtree totals after a structural change (expand/collapse a zip).
function recomputeTotals() {
(function walk(ns) {
(ns || []).forEach(function (n) {
walk(n.children || []);
var rf = (n.files || []).length, rd = (n.children || []).length;
(n.children || []).forEach(function (c) { rf += c.runFiles || 0; rd += c.runDirs || 0; });
n.runFiles = rf; n.runDirs = rd;
n.fileCount = (n.files || []).length; n.subdirCount = (n.children || []).length;
});
})(window.app.folderTree || []);
}
// Turn a .zip FILE into an expandable archive folder in place: scan its
// members into the fileset and drop the now-meaningless single-file
// assignment. Members come from the live handle, or (snapshot-restored) are
// re-read from the workspace root via scanZipNode's fallback.
async function expandZipAsFolder(file) {
var parent = findNodeByPath(file.folderPath);
if (!parent) return null;
var zipName = zddc.joinExtension(file.originalFilename, file.extension);
var zipPath = parent.path + '/' + zipName;
var zipNode = makeNode({ name: zipName, kind: 'directory', isZipRoot: true, zipPath: zipPath }, zipPath, parent);
zipNode._zipFileObj = file.handle ? file : null; // null → scanZipNode resolves from root
zipNode.scanState = 'zip-pending';
zipNode.expanded = true;
parent.files = (parent.files || []).filter(function (f) { return f !== file; });
parent.children = parent.children || [];
parent.children.push(zipNode);
var c = window.app.modules.classify;
if (c && c.dropAssignments) c.dropAssignments([c.srcKeyForFile(file)]);
await scanZipNode(zipNode);
recomputeTotals();
return zipNode;
}
// Collapse an expanded archive folder back to a single .zip file, dropping
// every member's assignment.
function collapseZipToFile(zipNode) {
if (!zipNode || !zipNode.isZipRoot) return null;
var parent = zipNode.parent || findNodeByPath(zipNode.path.slice(0, zipNode.path.lastIndexOf('/')));
if (!parent) return null;
var c = window.app.modules.classify;
if (c && c.dropAssignments) {
var keys = [];
(function walk(n) { (n.files || []).forEach(function (f) { keys.push(c.srcKeyForFile(f)); }); (n.children || []).forEach(walk); })(zipNode);
c.dropAssignments(keys);
}
var split = zddc.splitExtension(zipNode.name);
var file = {
handle: (zipNode._zipFileObj && zipNode._zipFileObj.handle) || null,
folderHandle: (zipNode._zipFileObj && zipNode._zipFileObj.folderHandle) || null,
originalFilename: split.name, extension: split.extension,
size: null, lastModified: null,
trackingNumber: '', revision: '', status: '', title: '',
isDirty: false, error: false, errorMessage: '', validation: null, sha256: null,
folderPath: parent.path,
};
parent.children = (parent.children || []).filter(function (n) { return n !== zipNode; });
parent.files = parent.files || [];
parent.files.push(file);
zipCache.delete(zipNode.zipPath);
recomputeTotals();
return file;
}
async function resolveDirHandle(rootHandle, relPath) {
var cur = rootHandle;
var parts = (relPath || '').split('/').filter(Boolean);
for (var i = 0; i < parts.length; i++) { cur = await cur.getDirectoryHandle(parts[i]); }
return cur;
}
// Resolve (and cache) a file object's handle from the workspace root.
async function resolveFileHandle(rootHandle, fileObj) {
if (fileObj.handle) return fileObj.handle;
var dir = await resolveDirHandle(rootHandle, relFromRoot(fileObj.folderPath));
var name = zddc.joinExtension(fileObj.originalFilename, fileObj.extension);
var h = await dir.getFileHandle(name);
fileObj.handle = h;
fileObj.folderHandle = dir;
return h;
}
// Resume an interrupted scan: walk the loaded tree for 'pending' folders,
// resolve their handles from the (reconnected) root, and drain only those —
// already-scanned folders are left alone. Returns true if work was done.
async function resumeScan(rootHandle) {
if (!rootHandle) return false;
var pend = [];
(function walk(ns) {
(ns || []).forEach(function (n) {
if (n.scanState === 'pending') pend.push(n);
else walk(n.children);
});
})(window.app.folderTree || []);
if (!pend.length) return false;
var myGen = ++scanGen;
zipCache.clear();
scanStats = { folders: 0, files: 0, current: '', done: false, startedAt: Date.now() };
var ticker = setInterval(function () {
if (myGen !== scanGen || (scanStats && scanStats.done)) { clearInterval(ticker); return; }
updateScanStatus();
}, 1000);
for (var i = 0; i < pend.length; i++) {
try { pend[i].handle = await resolveDirHandle(rootHandle, relFromRoot(pend[i].path)); }
catch (e) { pend[i].scanState = 'done'; reportScanError(pend[i].path, e); }
}
await drainQueue(pend.filter(function (n) { return n.handle; }), myGen, SCAN_CONCURRENCY);
clearInterval(ticker);
if (myGen !== scanGen) return true;
scanStats.done = true;
scanStats.current = '';
flushRender();
if (window.zddc && typeof window.zddc.toast === 'function') {
window.zddc.toast('Resumed scan complete — ' + scanStats.folders + ' folders, '
+ scanStats.files + ' files added in ' + elapsedStr() + '.', 'success');
}
return true;
}
// Export module
window.app.modules.scanner = {
scanDirectory,
ensureScanned,
getZipCache,
extractZip,
snapshotTree,
loadSnapshot,
resolveFileHandle,
resolveDirHandle,
ensureZipLoaded,
extractZipMember,
expandZipAsFolder,
collapseZipToFile,
resumeScan
};
})();