chore(embedded): cut v0.0.27-beta
Some checks failed
Notify chart dev on beta cut / notify-chart-dev (push) Failing after 9s
Some checks failed
Notify chart dev on beta cut / notify-chart-dev (push) Failing after 9s
This commit is contained in:
parent
237c353845
commit
7d93171900
7 changed files with 399 additions and 83 deletions
|
|
@ -2665,7 +2665,7 @@ td[data-field="trackingNumber"] {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC Archive</span>
|
<span class="app-header__title">ZDDC Archive</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 23:50:40 · 0326d46</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-09 15:30:13 · 237c353</span></span>
|
||||||
</div>
|
</div>
|
||||||
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
||||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data">⟳</button>
|
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data">⟳</button>
|
||||||
|
|
|
||||||
|
|
@ -2772,7 +2772,7 @@ li.CodeMirror-hint-active {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC Browse</span>
|
<span class="app-header__title">ZDDC Browse</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 23:50:41 · 0326d46</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-09 15:30:14 · 237c353</span></span>
|
||||||
</div>
|
</div>
|
||||||
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
||||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing">⟳</button>
|
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing">⟳</button>
|
||||||
|
|
|
||||||
|
|
@ -1293,6 +1293,36 @@ body.is-elevated::after {
|
||||||
background-color: var(--bg-hover);
|
background-color: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Counts read "direct+total". The direct number stays solid (immediate info);
|
||||||
|
the "+total" subtree count is muted and pulses while its subtree is still
|
||||||
|
being scanned, then goes solid once final. */
|
||||||
|
.folder-count .ct-total {
|
||||||
|
color: var(--text-secondary, #6b7280);
|
||||||
|
}
|
||||||
|
.folder-count .ct-total.pending {
|
||||||
|
color: var(--text-muted, #9aa0a6);
|
||||||
|
font-style: italic;
|
||||||
|
animation: scan-pulse 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes scan-pulse {
|
||||||
|
0%, 100% { opacity: 0.55; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Live scan status line under the tree-pane header. */
|
||||||
|
.scan-status {
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted, #8a8a8a);
|
||||||
|
border-bottom: 1px solid var(--border, #e2e2e2);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
min-height: 1.1em;
|
||||||
|
}
|
||||||
|
.scan-status:empty { display: none; }
|
||||||
|
.scan-status.scanning { color: var(--primary, #2868c8); }
|
||||||
|
|
||||||
.folder-item.selected {
|
.folder-item.selected {
|
||||||
background-color: var(--bg-selected);
|
background-color: var(--bg-selected);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|
@ -1876,7 +1906,7 @@ body.is-elevated::after {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC Classifier</span>
|
<span class="app-header__title">ZDDC Classifier</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 23:50:41 · 0326d46</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-09 15:30:13 · 237c353</span></span>
|
||||||
</div>
|
</div>
|
||||||
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
||||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;">⟳</button>
|
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;">⟳</button>
|
||||||
|
|
@ -1908,6 +1938,7 @@ body.is-elevated::after {
|
||||||
<span id="selectedFoldersCount" class="folder-count">0 folders selected</span>
|
<span id="selectedFoldersCount" class="folder-count">0 folders selected</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="scanStatus" class="scan-status" aria-live="polite"></div>
|
||||||
<div id="folderTree" class="folder-tree">
|
<div id="folderTree" class="folder-tree">
|
||||||
<!-- Dynamically populated -->
|
<!-- Dynamically populated -->
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -6383,52 +6414,191 @@ X.B(E,Y);return E}return J}())
|
||||||
// Store ZIP data for later access
|
// Store ZIP data for later access
|
||||||
const zipCache = new Map(); // path -> { zip: JSZip, fileHandle: FileSystemFileHandle }
|
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
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render the running scan status into the tree-pane header.
|
||||||
|
function updateScanStatus() {
|
||||||
|
const el = document.getElementById('scanStatus');
|
||||||
|
if (!el || !scanStats) return;
|
||||||
|
if (scanStats.done) {
|
||||||
|
const secs = ((Date.now() - scanStats.startedAt) / 1000).toFixed(1);
|
||||||
|
el.textContent = 'Scanned ' + scanStats.folders + ' folders · '
|
||||||
|
+ scanStats.files + ' files in ' + secs + 's';
|
||||||
|
el.classList.remove('scanning');
|
||||||
|
} else {
|
||||||
|
el.textContent = 'Scanning… ' + scanStats.folders + ' folders · '
|
||||||
|
+ scanStats.files + ' files'
|
||||||
|
+ (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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One-shot toast for scan errors (permission denied, 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);
|
||||||
|
const msg = 'Couldn’t scan ' + path + ': ' + (err && err.message ? err.message : err);
|
||||||
|
if (window.zddc && typeof window.zddc.toast === 'function') {
|
||||||
|
window.zddc.toast(msg, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scan directory and build folder tree with files
|
* Scan directory and build folder tree with files
|
||||||
*/
|
*/
|
||||||
async function scanDirectory(dirHandle, preserveState = false) {
|
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.
|
||||||
// Save current state if preserving
|
|
||||||
let savedExpanded = new Set();
|
let savedExpanded = new Set();
|
||||||
let savedSelected = new Set();
|
let savedSelected = new Set();
|
||||||
if (preserveState) {
|
if (preserveState) {
|
||||||
savedExpanded = getExpandedPaths(window.app.folderTree);
|
savedExpanded = getExpandedPaths(window.app.folderTree);
|
||||||
savedSelected = new Set(window.app.selectedFolders);
|
savedSelected = new Set(window.app.selectedFolders);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear ZIP cache
|
|
||||||
zipCache.clear();
|
|
||||||
|
|
||||||
// Map to store files by folder handle (or ZIP path for virtual folders)
|
|
||||||
const foldersMap = new Map();
|
|
||||||
|
|
||||||
// Recursively scan
|
|
||||||
await scanFolder(dirHandle, foldersMap, dirHandle.name);
|
|
||||||
|
|
||||||
// Build tree structure
|
|
||||||
window.app.folderTree = window.app.modules.tree.buildTree(dirHandle, foldersMap);
|
|
||||||
|
|
||||||
// Set in store
|
|
||||||
window.app.modules.store.setFolderTree(window.app.folderTree);
|
|
||||||
|
|
||||||
if (preserveState) {
|
|
||||||
// Restore expanded state
|
|
||||||
restoreExpandedPaths(window.app.folderTree, savedExpanded);
|
|
||||||
// Restore selection
|
|
||||||
window.app.selectedFolders = savedSelected;
|
|
||||||
// Render without changing selection
|
|
||||||
window.app.modules.tree.render();
|
|
||||||
window.app.modules.store.setSelectedFolders(savedSelected);
|
|
||||||
} else {
|
|
||||||
// Render tree
|
|
||||||
window.app.modules.tree.render();
|
|
||||||
// Auto-expand and select all folders
|
|
||||||
window.app.modules.tree.expandAll();
|
|
||||||
window.app.modules.tree.selectAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
// Breadth-first by level behind a bounded worker pool: level 1, then
|
||||||
|
// level 2, … each rendered as it lands (top levels appear first).
|
||||||
|
// Deeper levels keep filling in; workers await between directories so
|
||||||
|
// the UI stays responsive on a slow/large network drive.
|
||||||
|
let level = [root];
|
||||||
|
while (level.length && myGen === scanGen) {
|
||||||
|
await runWithConcurrency(level, 6, function (n) { return scanNodeChildren(n, myGen); });
|
||||||
|
const next = [];
|
||||||
|
for (const n of level) {
|
||||||
|
for (const c of n.children) {
|
||||||
|
if (preserveState && savedExpanded.has(c.path)) c.expanded = true;
|
||||||
|
if (c.scanState === 'pending') next.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
level = next;
|
||||||
|
}
|
||||||
|
if (myGen !== scanGen) return; // superseded by a newer scan
|
||||||
|
|
||||||
|
scanStats.done = true;
|
||||||
|
scanStats.current = '';
|
||||||
|
flushRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run fn over items with at most `limit` concurrent calls; resolves when
|
||||||
|
// all have settled. Termination is clean (no transient-empty-queue race).
|
||||||
|
async function runWithConcurrency(items, limit, fn) {
|
||||||
|
let i = 0;
|
||||||
|
async function runner() {
|
||||||
|
while (i < items.length) {
|
||||||
|
const idx = i++;
|
||||||
|
await fn(items[idx]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const runners = [];
|
||||||
|
for (let k = 0; k < Math.min(limit, items.length); k++) runners.push(runner());
|
||||||
|
await Promise.all(runners);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
const myGen = scanGen;
|
||||||
|
let level = [node];
|
||||||
|
while (level.length && myGen === scanGen) {
|
||||||
|
await runWithConcurrency(level, 6, function (n) { return scanNodeChildren(n, myGen); });
|
||||||
|
const next = [];
|
||||||
|
for (const n of level) {
|
||||||
|
for (const c of n.children) if (c.scanState === 'pending') next.push(c);
|
||||||
|
}
|
||||||
|
level = next;
|
||||||
|
}
|
||||||
|
flushRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -6458,43 +6628,129 @@ X.B(E,Y);return E}return J}())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Read ONE directory's immediate entries: files into node.files, child
|
||||||
* Recursively scan a folder
|
// directories into node.children (left 'pending' for the BFS to descend).
|
||||||
*/
|
// A .zip becomes an expandable zip-root child, scanned inline (its
|
||||||
async function scanFolder(dirHandle, foldersMap, currentPath) {
|
// contents are already in memory once the entry is read). Idempotent:
|
||||||
const items = [];
|
// only a 'pending' node is scanned, so concurrent callers (background +
|
||||||
|
// open-prioritised) don't double-scan.
|
||||||
|
async function scanNodeChildren(node, myGen) {
|
||||||
|
if (node.scanState !== 'pending') return;
|
||||||
|
node.scanState = 'scanning';
|
||||||
|
if (scanStats) scanStats.current = node.path;
|
||||||
|
const files = [];
|
||||||
|
const childDirs = [];
|
||||||
try {
|
try {
|
||||||
for await (const entry of dirHandle.values()) {
|
for await (const entry of node.handle.values()) {
|
||||||
|
if (myGen !== scanGen) { node.scanState = 'pending'; return; } // cancelled
|
||||||
if (entry.kind === 'file') {
|
if (entry.kind === 'file') {
|
||||||
// Create file object
|
const fo = await createFileObject(entry, node.handle);
|
||||||
const file = await createFileObject(entry, dirHandle);
|
if (!fo) continue;
|
||||||
if (file) {
|
fo.folderPath = node.path;
|
||||||
items.push(file);
|
files.push(fo);
|
||||||
|
if (scanStats) scanStats.files++;
|
||||||
// Check if it's a ZIP file - scan its contents
|
if (fo.extension === 'zip' && typeof JSZip !== 'undefined') {
|
||||||
if (file.extension === 'zip' && typeof JSZip !== 'undefined') {
|
const zipName = zddc.joinExtension(fo.originalFilename, fo.extension);
|
||||||
await scanZipFile(file, foldersMap, currentPath, items);
|
const zipPath = node.path + '/' + zipName;
|
||||||
}
|
const zh = { name: zipName, kind: 'directory', isZipRoot: true, zipPath: zipPath };
|
||||||
|
const zipNode = makeNode(zh, zipPath, node);
|
||||||
|
try { await scanZipIntoNode(zipNode, fo); }
|
||||||
|
catch (e) { reportScanError(zipPath, e); zipNode.scanState = 'done'; }
|
||||||
|
childDirs.push(zipNode);
|
||||||
|
if (scanStats) scanStats.folders++;
|
||||||
}
|
}
|
||||||
} else if (entry.kind === 'directory') {
|
} else if (entry.kind === 'directory') {
|
||||||
// Add directory reference
|
const childPath = node.path + '/' + entry.name;
|
||||||
items.push({
|
childDirs.push(makeNode(entry, childPath, node));
|
||||||
handle: entry,
|
if (scanStats) scanStats.folders++;
|
||||||
isDirectory: true
|
|
||||||
});
|
|
||||||
|
|
||||||
// Recursively scan subdirectory
|
|
||||||
const childPath = currentPath + '/' + entry.name;
|
|
||||||
await scanFolder(entry, foldersMap, childPath);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error scanning folder:', dirHandle.name, err);
|
node.scanError = true;
|
||||||
|
reportScanError(node.path, err);
|
||||||
}
|
}
|
||||||
|
node.files = files;
|
||||||
// Store files for this folder
|
node.fileCount = files.length;
|
||||||
foldersMap.set(dirHandle, items);
|
node.children = childDirs;
|
||||||
|
node.subdirCount = childDirs.length;
|
||||||
|
// Roll this folder's own files/dirs (plus the full contents of any
|
||||||
|
// inline-zip children) into the running subtree totals of this node
|
||||||
|
// and every ancestor. Regular child dirs add their own share when they
|
||||||
|
// get scanned — that's how the total fills in progressively.
|
||||||
|
let addF = files.length;
|
||||||
|
let addD = childDirs.length;
|
||||||
|
for (const c of childDirs) {
|
||||||
|
if (c.scanState === 'done') { addF += c.runFiles; addD += c.runDirs; }
|
||||||
|
}
|
||||||
|
for (let a = node; a; a = a.parent) { a.runFiles += addF; a.runDirs += addD; }
|
||||||
|
// Zip children are scanned inline ('done'); real dirs are still pending.
|
||||||
|
node.pending = childDirs.filter(function (c) { return c.scanState !== 'done'; }).length;
|
||||||
|
if (node.pending === 0) {
|
||||||
|
markDone(node);
|
||||||
|
} else {
|
||||||
|
node.scanState = 'children';
|
||||||
|
}
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -6804,6 +7060,7 @@ X.B(E,Y);return E}return J}())
|
||||||
// Export module
|
// Export module
|
||||||
window.app.modules.scanner = {
|
window.app.modules.scanner = {
|
||||||
scanDirectory,
|
scanDirectory,
|
||||||
|
ensureScanned,
|
||||||
getZipCache,
|
getZipCache,
|
||||||
extractZip
|
extractZip
|
||||||
};
|
};
|
||||||
|
|
@ -6837,6 +7094,48 @@ X.B(E,Y);return E}return J}())
|
||||||
updateSelectedCount();
|
updateSelectedCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Populate a folder row's count element with "direct+total" counts, e.g.
|
||||||
|
* "(2+10 folders, 15+300 files)" — direct (immediate children) shows as
|
||||||
|
* soon as the folder's own directory is read; the total (whole subtree)
|
||||||
|
* grows and flashes grey until the subtree is fully scanned, then goes
|
||||||
|
* solid. The "+total" part is omitted once scanning is done and there's
|
||||||
|
* nothing deeper (direct == total).
|
||||||
|
*/
|
||||||
|
function populateCount(el, folder) {
|
||||||
|
el.textContent = '';
|
||||||
|
const st = folder.scanState;
|
||||||
|
if (st === 'pending') return;
|
||||||
|
if (st === 'scanning') { el.textContent = 'scanning…'; return; }
|
||||||
|
|
||||||
|
const done = st === 'done';
|
||||||
|
const dDir = folder.subdirCount || 0, tDir = folder.runDirs || 0;
|
||||||
|
const dFile = folder.fileCount || 0, tFile = folder.runFiles || 0;
|
||||||
|
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
frag.appendChild(document.createTextNode('('));
|
||||||
|
if (dDir > 0 || tDir > 0) {
|
||||||
|
appendPair(frag, dDir, tDir, done);
|
||||||
|
frag.appendChild(document.createTextNode(tDir === 1 ? ' folder, ' : ' folders, '));
|
||||||
|
}
|
||||||
|
appendPair(frag, dFile, tFile, done);
|
||||||
|
frag.appendChild(document.createTextNode(tFile === 1 ? ' file)' : ' files)'));
|
||||||
|
el.appendChild(frag);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append "<direct>" and, when there's a subtree (or scanning is ongoing),
|
||||||
|
// "+<total>" with the total in a span that greys + pulses until final.
|
||||||
|
function appendPair(frag, direct, total, done) {
|
||||||
|
frag.appendChild(document.createTextNode(String(direct)));
|
||||||
|
if (!done || total > direct) {
|
||||||
|
frag.appendChild(document.createTextNode('+'));
|
||||||
|
const t = document.createElement('span');
|
||||||
|
t.className = 'ct-total' + (done ? '' : ' pending');
|
||||||
|
t.textContent = String(total);
|
||||||
|
frag.appendChild(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a folder element
|
* Create a folder element
|
||||||
*/
|
*/
|
||||||
|
|
@ -6845,6 +7144,11 @@ X.B(E,Y);return E}return J}())
|
||||||
|
|
||||||
const item = document.createElement('div');
|
const item = document.createElement('div');
|
||||||
item.className = 'folder-item';
|
item.className = 'folder-item';
|
||||||
|
// Grey the row until its subtree is fully scanned (scanState 'done');
|
||||||
|
// 'scanning' rows also get a subtle pulse via CSS.
|
||||||
|
if (folder.scanState && folder.scanState !== 'done') {
|
||||||
|
item.classList.add('scanning');
|
||||||
|
}
|
||||||
item.dataset.path = folder.path;
|
item.dataset.path = folder.path;
|
||||||
item.style.paddingLeft = `${level * 1.5}rem`;
|
item.style.paddingLeft = `${level * 1.5}rem`;
|
||||||
|
|
||||||
|
|
@ -6853,10 +7157,13 @@ X.B(E,Y);return E}return J}())
|
||||||
item.classList.add('selected');
|
item.classList.add('selected');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toggle button (if has children)
|
// Toggle button: shown when the folder has children OR hasn't been
|
||||||
|
// scanned yet (it might have children — expanding triggers its scan).
|
||||||
const toggle = document.createElement('span');
|
const toggle = document.createElement('span');
|
||||||
toggle.className = 'folder-toggle';
|
toggle.className = 'folder-toggle';
|
||||||
if (folder.children && folder.children.length > 0) {
|
const mightHaveChildren = (folder.children && folder.children.length > 0)
|
||||||
|
|| folder.scanState === 'pending';
|
||||||
|
if (mightHaveChildren) {
|
||||||
toggle.textContent = folder.expanded ? '▼' : '▶';
|
toggle.textContent = folder.expanded ? '▼' : '▶';
|
||||||
toggle.addEventListener('click', (e) => {
|
toggle.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
@ -6886,10 +7193,11 @@ X.B(E,Y);return E}return J}())
|
||||||
name.textContent = folder.name;
|
name.textContent = folder.name;
|
||||||
item.appendChild(name);
|
item.appendChild(name);
|
||||||
|
|
||||||
// File count
|
// Subfolder / file counts (immediate). Greyed via the row's .scanning
|
||||||
|
// class until the subtree is fully scanned.
|
||||||
const count = document.createElement('span');
|
const count = document.createElement('span');
|
||||||
count.className = 'folder-count';
|
count.className = 'folder-count';
|
||||||
count.textContent = `(${folder.fileCount || 0})`;
|
populateCount(count, folder);
|
||||||
item.appendChild(count);
|
item.appendChild(count);
|
||||||
|
|
||||||
// Extract button for ZIP roots
|
// Extract button for ZIP roots
|
||||||
|
|
@ -7099,7 +7407,7 @@ X.B(E,Y);return E}return J}())
|
||||||
*/
|
*/
|
||||||
function toggleFolder(folder, recursive = false) {
|
function toggleFolder(folder, recursive = false) {
|
||||||
folder.expanded = !folder.expanded;
|
folder.expanded = !folder.expanded;
|
||||||
|
|
||||||
if (recursive && folder.children) {
|
if (recursive && folder.children) {
|
||||||
// Recursively expand/collapse all children
|
// Recursively expand/collapse all children
|
||||||
const newState = folder.expanded;
|
const newState = folder.expanded;
|
||||||
|
|
@ -7111,8 +7419,16 @@ X.B(E,Y);return E}return J}())
|
||||||
}
|
}
|
||||||
folder.children.forEach(setAllExpanded);
|
folder.children.forEach(setAllExpanded);
|
||||||
}
|
}
|
||||||
|
|
||||||
render();
|
render();
|
||||||
|
|
||||||
|
// Opening a not-yet-complete folder jumps its subtree to the front of
|
||||||
|
// the scan so its contents are complete on open (re-renders as it
|
||||||
|
// fills in). Background scanning continues for everything else.
|
||||||
|
if (folder.expanded && folder.scanState !== 'done'
|
||||||
|
&& window.app.modules.scanner && window.app.modules.scanner.ensureScanned) {
|
||||||
|
window.app.modules.scanner.ensureScanned(folder).then(render).catch(() => {});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1619,7 +1619,7 @@ body {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC</span>
|
<span class="app-header__title">ZDDC</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 23:50:41 · 0326d46</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-09 15:30:13 · 237c353</span></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
|
|
||||||
|
|
@ -2718,7 +2718,7 @@ dialog.modal--narrow {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC Transmittal</span>
|
<span class="app-header__title">ZDDC Transmittal</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 23:50:40 · 0326d46</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-09 15:30:13 · 237c353</span></span>
|
||||||
</div>
|
</div>
|
||||||
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
|
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
|
||||||
<!-- Publish split-button (Transmittal-specific primary action;
|
<!-- Publish split-button (Transmittal-specific primary action;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
# Generated by build.sh — do not edit. One <app>=<build label> per line.
|
# Generated by build.sh — do not edit. One <app>=<build label> per line.
|
||||||
archive=v0.0.27-beta · 2026-06-08 23:50:40 · 0326d46
|
archive=v0.0.27-beta · 2026-06-09 15:30:13 · 237c353
|
||||||
transmittal=v0.0.27-beta · 2026-06-08 23:50:40 · 0326d46
|
transmittal=v0.0.27-beta · 2026-06-09 15:30:13 · 237c353
|
||||||
classifier=v0.0.27-beta · 2026-06-08 23:50:41 · 0326d46
|
classifier=v0.0.27-beta · 2026-06-09 15:30:13 · 237c353
|
||||||
landing=v0.0.27-beta · 2026-06-08 23:50:41 · 0326d46
|
landing=v0.0.27-beta · 2026-06-09 15:30:13 · 237c353
|
||||||
form=v0.0.27-beta · 2026-06-08 23:50:41 · 0326d46
|
form=v0.0.27-beta · 2026-06-09 15:30:13 · 237c353
|
||||||
tables=v0.0.27-beta · 2026-06-08 23:50:41 · 0326d46
|
tables=v0.0.27-beta · 2026-06-09 15:30:13 · 237c353
|
||||||
browse=v0.0.27-beta · 2026-06-08 23:50:41 · 0326d46
|
browse=v0.0.27-beta · 2026-06-09 15:30:14 · 237c353
|
||||||
|
|
|
||||||
|
|
@ -1670,7 +1670,7 @@ body.is-elevated::after {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 23:50:41 · 0326d46</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-09 15:30:13 · 237c353</span></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue