From 01b01f8f7a5cfdffcf968e3726b09e2d16ad4f56 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Tue, 9 Jun 2026 16:38:08 -0500 Subject: [PATCH] feat(classifier): welcome rewrite + resumable scan + reconnect on restore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Welcome: drop the 'absorbed into Browse' notice; bigger, inviting intro with a two-method tutorial (Classify & copy — recommended/non-destructive; Rename in place — edits files) and a OneDrive 'keep on device' tip. - Resumable scan: the snapshot now records per-folder scan state, the workspace record is created up front, and the partial snapshot is persisted every 5s during the (slow) scan. scanner.resumeScan() resolves handles for only the still-pending folders and drains them — so an interrupted scan picks up where it left off instead of starting over. - Reconnect on restore: opening a workspace no longer assumes the source is connected; a header 'Connect directory' button (and a prompt) re-grants the persisted handle in one click or lets you re-pick it. Until connected you can still edit the data model; connecting also resumes any pending scan. - Tests: resume-scan via mock root handle (31 classify/classifier green). Co-Authored-By: Claude Opus 4.8 (1M context) --- classifier/css/layout.css | 39 ++++++++++++-- classifier/js/scanner.js | 51 ++++++++++++++++++- classifier/js/workspace.js | 102 ++++++++++++++++++++++++++++++++----- classifier/template.html | 53 +++++++++++-------- tests/classify.spec.js | 22 ++++++++ 5 files changed, 226 insertions(+), 41 deletions(-) diff --git a/classifier/css/layout.css b/classifier/css/layout.css index 159c9d4..3f59fca 100644 --- a/classifier/css/layout.css +++ b/classifier/css/layout.css @@ -265,11 +265,44 @@ margin-left: 1.5rem; } +/* ── Welcome screen (intro + tutorial) ─────────────────────────────────── */ +.empty-state--overlay { overflow-y: auto; } +.welcome { max-width: 900px; padding: 1.5rem 0.5rem 2.5rem; } +.welcome__title { font-size: 2.6rem; line-height: 1.1; margin: 0 0 0.6rem; } +.welcome__lede { + font-size: 1.2rem; line-height: 1.55; color: var(--text); + margin: 0 auto 2rem; max-width: 62ch; +} +.welcome__methods { + display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; + margin: 1.75rem 0 0; text-align: left; +} +@media (max-width: 780px) { .welcome__methods { grid-template-columns: 1fr; } } +.method { + border: 1px solid var(--border); border-radius: var(--radius); + padding: 1rem 1.15rem; background: var(--bg); +} +.method--primary { border-color: var(--primary); box-shadow: inset 0 0 0 1px var(--primary); } +.method__title { font-size: 1.1rem; margin: 0 0 0.5rem; } +.method__tag { + display: inline-block; font-size: 0.68rem; font-weight: 700; + text-transform: uppercase; letter-spacing: 0.04em; color: var(--primary); + margin-left: 0.4rem; vertical-align: middle; +} +.method__tag--warn { color: var(--warning); } +.method__what { font-size: 0.95rem; color: var(--text-muted); margin: 0 0 0.7rem; } +.method__steps { margin: 0; padding-left: 1.25rem; font-size: 0.95rem; line-height: 1.6; } +.method__steps li { margin: 0.35rem 0; } +.method__steps code { + background: var(--bg-secondary); padding: 0.05rem 0.35rem; + border-radius: 4px; font-size: 0.85em; +} +.welcome__note { font-size: 0.9rem; color: var(--text-muted); margin-top: 1.5rem; } + /* ── Workspaces (welcome manager) ──────────────────────────────────────── */ -.workspaces { text-align: left; margin: 1rem 0; } +.workspaces { text-align: left; margin: 1.5rem 0 0.5rem; } .ws-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; } -.ws-head h3 { margin: 0; font-size: 1.05rem; } -.ws-intro { font-size: 0.85rem; color: var(--text-muted); margin: 0.4rem 0 0.75rem; } +.ws-head h2 { margin: 0; font-size: 1.4rem; } .ws-list { display: flex; flex-direction: column; gap: 0.4rem; } .ws-empty { color: var(--text-muted); font-size: 0.85rem; padding: 0.75rem; border: 1px dashed var(--border); border-radius: var(--radius); } .ws-row { diff --git a/classifier/js/scanner.js b/classifier/js/scanner.js index e011501..7ab7dcd 100644 --- a/classifier/js/scanner.js +++ b/classifier/js/scanner.js @@ -764,6 +764,12 @@ if (n.files && n.files.length) o.f = n.files.map(serFile); var realKids = (n.children || []).filter(function (c) { return !c.isZipRoot; }); if (realKids.length) o.c = realKids.map(serNode); + // Record scan progress so an interrupted scan can resume: 'children' + // = direct entries fully read (kids may still be pending); anything + // unfinished (pending/scanning/zip) → 'pending' to re-read. 'done' + // is the default and omitted. + var st = n.scanState; + if (st && st !== 'done') o.s = (st === 'children') ? 'children' : 'pending'; return o; } return (window.app.folderTree || []).map(serNode); @@ -786,7 +792,7 @@ function deNode(sn, parent) { var node = makeNode({ name: sn.n, kind: 'directory' }, sn.p, parent); node.handle = null; - node.scanState = 'done'; + node.scanState = sn.s || 'done'; // 'pending'/'children' resume on reconnect node.expanded = false; node.files = (sn.f || []).map(deFile); node.children = (sn.c || []).map(function (c) { return deNode(c, node); }); @@ -830,6 +836,46 @@ 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, @@ -839,7 +885,8 @@ snapshotTree, loadSnapshot, resolveFileHandle, - resolveDirHandle + resolveDirHandle, + resumeScan }; })(); diff --git a/classifier/js/workspace.js b/classifier/js/workspace.js index 43fe11a..b088557 100644 --- a/classifier/js/workspace.js +++ b/classifier/js/workspace.js @@ -13,7 +13,8 @@ var els = {}; var initialized = false; var activeId = null; - var activeMeta = null; // {id,name,rootName,createdAt,updatedAt,summary} + var activeMeta = null; // {id,name,rootName,createdAt,updatedAt,summary} + var activeStoredHandle = null; // the workspace's persisted source dir handle function P() { return window.app.modules.persist; } function C() { return window.app.modules.classify; } @@ -33,6 +34,7 @@ list: document.getElementById('workspaceList'), newBtn: document.getElementById('newWorkspaceBtn'), wsBtn: document.getElementById('workspacesBtn'), + connectBtn: document.getElementById('connectDirBtn'), }; if (!P() || !P().available) { // No IndexedDB → hide the workspace UI; legacy rename path still works. @@ -42,6 +44,7 @@ } if (els.newBtn) els.newBtn.addEventListener('click', newWorkspace); if (els.wsBtn) els.wsBtn.addEventListener('click', showWelcome); + if (els.connectBtn) els.connectBtn.addEventListener('click', function () { tryReconnect(false); }); if (els.list) els.list.addEventListener('click', onListClick); // Autosave the active workspace whenever the map changes. @@ -139,23 +142,27 @@ try { dir = await window.showDirectoryPicker(); } catch (e) { if (e.name !== 'AbortError') window.zddc.toast('Could not open folder — ' + (e.message || e), 'error'); return; } - window.app.rootHandle = dir; - window.app.modules.app.enterAppShell(); - // The one slow pass: a full scan (then never again for this workspace). - await window.app.modules.scanner.scanDirectory(dir); - var name = prompt('Name this workspace:', dir.name); if (name === null) name = dir.name; name = name.trim() || dir.name; - activeId = uid(); - activeMeta = { id: activeId, name: name, rootName: dir.name, createdAt: now(), updatedAt: now(), summary: summary() }; - await P().putWorkspace(activeMeta, { - id: activeId, rootHandle: dir, - tree: window.app.modules.scanner.snapshotTree(), - classify: C().serialize(), - }); + window.app.rootHandle = dir; + activeStoredHandle = dir; + window.app.modules.app.enterAppShell(); window.app.modules.app.setMode('classify'); + hideWelcome(); + + activeId = uid(); + activeMeta = { id: activeId, name: name, rootName: dir.name, createdAt: now(), updatedAt: now(), summary: { files: 0, done: 0, excluded: 0 } }; + // Create the record UP FRONT so an interrupted scan survives and resumes. + await saveSnapshotFull(); + updateConnectUI(); + + // Periodically persist the partial snapshot during the (slow) scan, so an + // interruption resumes from where it left off instead of starting over. + var iv = setInterval(saveSnapshotFull, 5000); + try { await window.app.modules.scanner.scanDirectory(dir); } + finally { clearInterval(iv); saveSnapshotFull(); } } async function openWorkspace(id) { @@ -166,12 +173,79 @@ activeId = id; activeMeta = meta; - window.app.rootHandle = rec.rootHandle || null; + activeStoredHandle = rec.rootHandle || null; + window.app.rootHandle = null; // not connected until reconnect window.app.modules.app.enterAppShell(); window.app.modules.scanner.loadSnapshot(rec.tree || []); C().load(rec.classify || {}); window.app.modules.app.setMode('classify'); hideWelcome(); + + // Offer to reconnect the source directory (needed to preview, copy, or + // finish an interrupted scan). Silent if permission is already granted. + await tryReconnect(true); + updateConnectUI(); + } + + // Persist the full workspace (meta + snapshot + map + source handle). + function saveSnapshotFull() { + if (!activeId || !activeMeta) return Promise.resolve(); + activeMeta.updatedAt = now(); + activeMeta.summary = summary(); + return P().putWorkspace(activeMeta, { + id: activeId, + rootHandle: window.app.rootHandle || activeStoredHandle || null, + tree: window.app.modules.scanner.snapshotTree(), + classify: C().serialize(), + }); + } + + // Connect (or reconnect) the source directory. silentOnly=true never shows a + // permission prompt or picker — it only adopts an already-granted handle and + // otherwise nudges the user to click "Connect directory". + async function tryReconnect(silentOnly) { + var h = activeStoredHandle; + if (h && typeof h.queryPermission === 'function') { + var p = 'denied'; + try { p = await h.queryPermission({ mode: 'read' }); } catch (_) { /* ignore */ } + if (p === 'granted') { window.app.rootHandle = h; return afterConnect(); } + if (!silentOnly) { + var p2 = 'denied'; + try { p2 = await h.requestPermission({ mode: 'read' }); } catch (_) { /* ignore */ } + if (p2 === 'granted') { window.app.rootHandle = h; return afterConnect(); } + } + } + if (silentOnly) { + if (!window.app.rootHandle && activeId) { + window.zddc.toast('This workspace’s source directory isn’t connected — click “Connect directory” to preview, copy, or finish scanning.', 'info', { durationMs: 8000 }); + } + return false; + } + // Explicit: no usable stored handle (or permission denied) → let the user pick. + if (!window.showDirectoryPicker) { window.zddc.toast('Connecting a directory needs the File System Access API.', 'error'); return false; } + try { + var picked = await window.showDirectoryPicker(); + window.app.rootHandle = picked; + activeStoredHandle = picked; + return afterConnect(); + } catch (e) { + if (e.name !== 'AbortError') window.zddc.toast('Could not connect directory — ' + (e.message || e), 'error'); + return false; + } + } + + async function afterConnect() { + updateConnectUI(); + // Resume any still-pending folders now that we have the handle. + var did = await window.app.modules.scanner.resumeScan(window.app.rootHandle); + saveSnapshotFull(); // persist refreshed snapshot + the (re-granted) handle + return true; + } + + function updateConnectUI() { + if (!els.connectBtn) return; + var show = !!activeId && !window.app.rootHandle; + els.connectBtn.hidden = !show; } function renameWorkspace(id) { diff --git a/classifier/template.html b/classifier/template.html index c53be4f..290f85c 100644 --- a/classifier/template.html +++ b/classifier/template.html @@ -35,6 +35,7 @@ +
@@ -171,25 +172,42 @@
-
-

ZDDC Classifier

-

- This standalone tool is being absorbed into the Browse app. - Browse's Grid view-mode now provides the same spreadsheet - workflow alongside file navigation. This standalone build remains - available for offline use and air-gapped environments. -

+
+

ZDDC Classifier

+

Turn a messy folder of project files into clean, correctly-named ZDDC documents — organized by tracking number and transmittal — without ever changing your originals.

+
-

Workspaces

- +

Your workspaces

+
-

Scan a folder once, then map files onto tracking numbers and transmittals and copy renamed copies to an output directory — the source is never modified. Workspaces save in this browser so you can resume across sessions.

-

— or — click Use Local Directory above to open a folder without saving a workspace (good for a quick look or an in-place rename via the “Rename in place” toggle).

+ +
+
+

① Classify & copy recommended · non-destructive

+

Build a tidy copy of a project in a separate output folder. Your source files are only ever read, never renamed or moved.

+
    +
  1. New workspace → pick a folder. It scans once and saves to this browser, so you can close the tab and pick up later.
  2. +
  3. Preview a file (single-click it in the left tree) to see what it actually is.
  4. +
  5. Drag it onto the right pane — onto a tracking-number folder (the folder path becomes the number, the leaf is the revision, e.g. A (IFR)), and onto a transmittal (party + date + TRN/SUB + sequence).
  6. +
  7. Copy when ready → choose an output directory; renamed copies are written as <party>/<transmittal>/<name>, with duplicates detected.
  8. +
+
+
+

② Rename in place edits your files

+

A quick spreadsheet for a folder you own: fill in tracking number, revision, status and title, and rename the files on disk.

+
    +
  1. Click Use Local Directory (top bar) to open a folder.
  2. +
  3. Switch the toggle to Rename in place.
  4. +
  5. Edit cells (or paste columns from Excel); names already in ZDDC format are parsed automatically and validated as you type.
  6. +
  7. Save All renames the files where they sit.
  8. +
+
+
-
    -
  • Files already named to ZDDC format are parsed automatically
  • -
  • Edit cells directly, or copy columns to and from Excel
  • -
  • Real-time validation highlights non-compliant names
  • -
  • Rename one file or all modified files at once
  • -
- -

Click Use Local Directory to begin.

- -

This application works entirely in your browser. No data is transmitted to any server.

+

Everything runs in your browser — no files are uploaded. Tip: if your folder lives on OneDrive/SharePoint, set it to “Always keep on this device” first for a much faster scan.

diff --git a/tests/classify.spec.js b/tests/classify.spec.js index 864a663..b9bcaaf 100644 --- a/tests/classify.spec.js +++ b/tests/classify.spec.js @@ -420,6 +420,28 @@ test('snapshot: serialize + rebuild preserves structure, marks done, drops handl expect(r.handleNull).toBe(true); }); +test('scan: resume scans only the pending folders from a snapshot', async ({ page }) => { + const r = await page.evaluate(async () => { + const sc = window.app.modules.scanner; + // Snapshot: Root (done) with a child 'sub' left pending. + sc.loadSnapshot([{ n: 'Root', p: 'Root', c: [{ n: 'sub', p: 'Root/sub', s: 'pending' }] }]); + // Mock root handle: Root/sub contains one file. + const subDir = { kind: 'directory', name: 'sub', values: async function* () { yield { kind: 'file', name: 'x.pdf' }; } }; + const root = { + kind: 'directory', name: 'Root', + getDirectoryHandle: async (n) => { if (n === 'sub') return subDir; const e = new Error('NF'); e.name = 'NotFoundError'; throw e; }, + }; + window.app.rootHandle = root; + const did = await sc.resumeScan(root); + const sub = window.app.folderTree[0].children[0]; + return { did, subState: sub.scanState, subFiles: sub.files.length, name: sub.files[0] && sub.files[0].originalFilename }; + }); + expect(r.did).toBe(true); + expect(r.subState).toBe('done'); + expect(r.subFiles).toBe(1); + expect(r.name).toBe('x'); +}); + test('persist: workspace put / list / get / delete round-trip', async ({ page }) => { const r = await page.evaluate(async () => { const P = window.app.modules.persist;