feat(classifier): welcome rewrite + resumable scan + reconnect on restore

- 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) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-06-09 16:38:08 -05:00
parent 975c804cc7
commit 01b01f8f7a
5 changed files with 226 additions and 41 deletions

View file

@ -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 {

View file

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

View file

@ -14,6 +14,7 @@
var initialized = false;
var activeId = null;
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 workspaces source directory isnt 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) {

View file

@ -35,6 +35,7 @@
<button id="modeRenameBtn" class="mode-btn" title="Edit a spreadsheet and rename the files in place (edits the source)">Rename in place</button>
</div>
<button id="workspacesBtn" class="btn btn-secondary btn-sm" title="Workspaces — open or create a classification project">≡ Workspaces</button>
<button id="connectDirBtn" class="btn btn-primary btn-sm" title="Connect this workspace's source directory to preview, copy, or finish scanning" hidden>⮷ Connect directory</button>
</div>
<div class="header-right">
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)"></button>
@ -171,25 +172,42 @@
<!-- Empty State — shown until a directory is selected -->
<div id="welcomeScreen" class="empty-state empty-state--overlay">
<div class="empty-state__inner empty-state__inner--centered">
<h2>ZDDC Classifier</h2>
<p style="background:var(--bg-secondary);padding:0.75rem 1rem;border-left:3px solid var(--warning);text-align:left;font-size:0.9rem;color:var(--text-muted);margin-bottom:1rem;">
<strong>This standalone tool is being absorbed into the Browse app.</strong>
Browse's <em>Grid</em> view-mode now provides the same spreadsheet
workflow alongside file navigation. This standalone build remains
available for offline use and air-gapped environments.
</p>
<div class="empty-state__inner empty-state__inner--centered welcome">
<h1 class="welcome__title">ZDDC Classifier</h1>
<p class="welcome__lede">Turn a messy folder of project files into clean, correctly-named ZDDC documents — organized by tracking number and transmittal — <strong>without ever changing your originals</strong>.</p>
<!-- Workspaces (Classify & Copy) -->
<section id="workspacesSection" class="workspaces">
<div class="ws-head">
<h3>Workspaces</h3>
<button id="newWorkspaceBtn" class="btn btn-primary btn-sm">+ New workspace</button>
<h2>Your workspaces</h2>
<button id="newWorkspaceBtn" class="btn btn-primary">+ New workspace</button>
</div>
<p class="ws-intro">Scan a folder <em>once</em>, 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.</p>
<div id="workspaceList" class="ws-list"><!-- rendered --></div>
</section>
<p class="ws-or">— or — click <strong>Use Local Directory</strong> above to open a folder <strong>without</strong> saving a workspace (good for a quick look or an in-place rename via the “Rename in place” toggle).</p>
<!-- Two-method tutorial -->
<div class="welcome__methods">
<section class="method method--primary">
<h3 class="method__title">① Classify &amp; copy <span class="method__tag">recommended · non-destructive</span></h3>
<p class="method__what">Build a tidy copy of a project in a separate output folder. Your source files are only ever <em>read</em>, never renamed or moved.</p>
<ol class="method__steps">
<li><strong>New workspace</strong> → pick a folder. It scans <em>once</em> and saves to this browser, so you can close the tab and pick up later.</li>
<li><strong>Preview</strong> a file (single-click it in the left tree) to see what it actually is.</li>
<li><strong>Drag</strong> it onto the right pane — onto a <em>tracking-number</em> folder (the folder path becomes the number, the leaf is the revision, e.g. <code>A (IFR)</code>), and onto a <em>transmittal</em> (party + date + TRN/SUB + sequence).</li>
<li><strong>Copy</strong> when ready → choose an output directory; renamed copies are written as <code>&lt;party&gt;/&lt;transmittal&gt;/&lt;name&gt;</code>, with duplicates detected.</li>
</ol>
</section>
<section class="method">
<h3 class="method__title">② Rename in place <span class="method__tag method__tag--warn">edits your files</span></h3>
<p class="method__what">A quick spreadsheet for a folder you own: fill in tracking number, revision, status and title, and rename the files on disk.</p>
<ol class="method__steps">
<li>Click <strong>Use Local Directory</strong> (top bar) to open a folder.</li>
<li>Switch the toggle to <strong>Rename in place</strong>.</li>
<li>Edit cells (or paste columns from Excel); names already in ZDDC format are parsed automatically and validated as you type.</li>
<li><strong>Save All</strong> renames the files where they sit.</li>
</ol>
</section>
</div>
<!-- Browser Compatibility Warning -->
<div id="browserWarning" class="browser-warning hidden">
@ -197,16 +215,7 @@
<p>This application requires the File System Access API, available only in Chromium-based browsers (Chrome, Edge, Brave, Opera).</p>
</div>
<ul class="welcome-list">
<li>Files already named to ZDDC format are parsed automatically</li>
<li>Edit cells directly, or copy columns to and from Excel</li>
<li>Real-time validation highlights non-compliant names</li>
<li>Rename one file or all modified files at once</li>
</ul>
<p>Click <strong>Use Local Directory</strong> to begin.</p>
<p class="note">This application works entirely in your browser. No data is transmitted to any server.</p>
<p class="welcome__note">Everything runs in your browser — no files are uploaded. Tip: if your folder lives on OneDrive/SharePoint, set it to <em>“Always keep on this device”</em> first for a much faster scan.</p>
</div>
</div>
</div>

View file

@ -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;