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:
parent
975c804cc7
commit
01b01f8f7a
5 changed files with 226 additions and 41 deletions
|
|
@ -265,11 +265,44 @@
|
||||||
margin-left: 1.5rem;
|
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 (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 { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
|
||||||
.ws-head h3 { margin: 0; font-size: 1.05rem; }
|
.ws-head h2 { margin: 0; font-size: 1.4rem; }
|
||||||
.ws-intro { font-size: 0.85rem; color: var(--text-muted); margin: 0.4rem 0 0.75rem; }
|
|
||||||
.ws-list { display: flex; flex-direction: column; gap: 0.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-empty { color: var(--text-muted); font-size: 0.85rem; padding: 0.75rem; border: 1px dashed var(--border); border-radius: var(--radius); }
|
||||||
.ws-row {
|
.ws-row {
|
||||||
|
|
|
||||||
|
|
@ -764,6 +764,12 @@
|
||||||
if (n.files && n.files.length) o.f = n.files.map(serFile);
|
if (n.files && n.files.length) o.f = n.files.map(serFile);
|
||||||
var realKids = (n.children || []).filter(function (c) { return !c.isZipRoot; });
|
var realKids = (n.children || []).filter(function (c) { return !c.isZipRoot; });
|
||||||
if (realKids.length) o.c = realKids.map(serNode);
|
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 o;
|
||||||
}
|
}
|
||||||
return (window.app.folderTree || []).map(serNode);
|
return (window.app.folderTree || []).map(serNode);
|
||||||
|
|
@ -786,7 +792,7 @@
|
||||||
function deNode(sn, parent) {
|
function deNode(sn, parent) {
|
||||||
var node = makeNode({ name: sn.n, kind: 'directory' }, sn.p, parent);
|
var node = makeNode({ name: sn.n, kind: 'directory' }, sn.p, parent);
|
||||||
node.handle = null;
|
node.handle = null;
|
||||||
node.scanState = 'done';
|
node.scanState = sn.s || 'done'; // 'pending'/'children' resume on reconnect
|
||||||
node.expanded = false;
|
node.expanded = false;
|
||||||
node.files = (sn.f || []).map(deFile);
|
node.files = (sn.f || []).map(deFile);
|
||||||
node.children = (sn.c || []).map(function (c) { return deNode(c, node); });
|
node.children = (sn.c || []).map(function (c) { return deNode(c, node); });
|
||||||
|
|
@ -830,6 +836,46 @@
|
||||||
return h;
|
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
|
// Export module
|
||||||
window.app.modules.scanner = {
|
window.app.modules.scanner = {
|
||||||
scanDirectory,
|
scanDirectory,
|
||||||
|
|
@ -839,7 +885,8 @@
|
||||||
snapshotTree,
|
snapshotTree,
|
||||||
loadSnapshot,
|
loadSnapshot,
|
||||||
resolveFileHandle,
|
resolveFileHandle,
|
||||||
resolveDirHandle
|
resolveDirHandle,
|
||||||
|
resumeScan
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,8 @@
|
||||||
var els = {};
|
var els = {};
|
||||||
var initialized = false;
|
var initialized = false;
|
||||||
var activeId = null;
|
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 P() { return window.app.modules.persist; }
|
||||||
function C() { return window.app.modules.classify; }
|
function C() { return window.app.modules.classify; }
|
||||||
|
|
@ -33,6 +34,7 @@
|
||||||
list: document.getElementById('workspaceList'),
|
list: document.getElementById('workspaceList'),
|
||||||
newBtn: document.getElementById('newWorkspaceBtn'),
|
newBtn: document.getElementById('newWorkspaceBtn'),
|
||||||
wsBtn: document.getElementById('workspacesBtn'),
|
wsBtn: document.getElementById('workspacesBtn'),
|
||||||
|
connectBtn: document.getElementById('connectDirBtn'),
|
||||||
};
|
};
|
||||||
if (!P() || !P().available) {
|
if (!P() || !P().available) {
|
||||||
// No IndexedDB → hide the workspace UI; legacy rename path still works.
|
// 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.newBtn) els.newBtn.addEventListener('click', newWorkspace);
|
||||||
if (els.wsBtn) els.wsBtn.addEventListener('click', showWelcome);
|
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);
|
if (els.list) els.list.addEventListener('click', onListClick);
|
||||||
|
|
||||||
// Autosave the active workspace whenever the map changes.
|
// Autosave the active workspace whenever the map changes.
|
||||||
|
|
@ -139,23 +142,27 @@
|
||||||
try { dir = await window.showDirectoryPicker(); }
|
try { dir = await window.showDirectoryPicker(); }
|
||||||
catch (e) { if (e.name !== 'AbortError') window.zddc.toast('Could not open folder — ' + (e.message || e), 'error'); return; }
|
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);
|
var name = prompt('Name this workspace:', dir.name);
|
||||||
if (name === null) name = dir.name;
|
if (name === null) name = dir.name;
|
||||||
name = name.trim() || dir.name;
|
name = name.trim() || dir.name;
|
||||||
|
|
||||||
activeId = uid();
|
window.app.rootHandle = dir;
|
||||||
activeMeta = { id: activeId, name: name, rootName: dir.name, createdAt: now(), updatedAt: now(), summary: summary() };
|
activeStoredHandle = dir;
|
||||||
await P().putWorkspace(activeMeta, {
|
window.app.modules.app.enterAppShell();
|
||||||
id: activeId, rootHandle: dir,
|
|
||||||
tree: window.app.modules.scanner.snapshotTree(),
|
|
||||||
classify: C().serialize(),
|
|
||||||
});
|
|
||||||
window.app.modules.app.setMode('classify');
|
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) {
|
async function openWorkspace(id) {
|
||||||
|
|
@ -166,12 +173,79 @@
|
||||||
|
|
||||||
activeId = id;
|
activeId = id;
|
||||||
activeMeta = meta;
|
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.app.enterAppShell();
|
||||||
window.app.modules.scanner.loadSnapshot(rec.tree || []);
|
window.app.modules.scanner.loadSnapshot(rec.tree || []);
|
||||||
C().load(rec.classify || {});
|
C().load(rec.classify || {});
|
||||||
window.app.modules.app.setMode('classify');
|
window.app.modules.app.setMode('classify');
|
||||||
hideWelcome();
|
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) {
|
function renameWorkspace(id) {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
<button id="modeRenameBtn" class="mode-btn" title="Edit a spreadsheet and rename the files in place (edits the source)">Rename in place</button>
|
||||||
</div>
|
</div>
|
||||||
<button id="workspacesBtn" class="btn btn-secondary btn-sm" title="Workspaces — open or create a classification project">≡ Workspaces</button>
|
<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>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)">◐</button>
|
<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 -->
|
<!-- Empty State — shown until a directory is selected -->
|
||||||
<div id="welcomeScreen" class="empty-state empty-state--overlay">
|
<div id="welcomeScreen" class="empty-state empty-state--overlay">
|
||||||
<div class="empty-state__inner empty-state__inner--centered">
|
<div class="empty-state__inner empty-state__inner--centered welcome">
|
||||||
<h2>ZDDC Classifier</h2>
|
<h1 class="welcome__title">ZDDC Classifier</h1>
|
||||||
<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;">
|
<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>
|
||||||
<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>
|
|
||||||
<!-- Workspaces (Classify & Copy) -->
|
<!-- Workspaces (Classify & Copy) -->
|
||||||
<section id="workspacesSection" class="workspaces">
|
<section id="workspacesSection" class="workspaces">
|
||||||
<div class="ws-head">
|
<div class="ws-head">
|
||||||
<h3>Workspaces</h3>
|
<h2>Your workspaces</h2>
|
||||||
<button id="newWorkspaceBtn" class="btn btn-primary btn-sm">+ New workspace</button>
|
<button id="newWorkspaceBtn" class="btn btn-primary">+ New workspace</button>
|
||||||
</div>
|
</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>
|
<div id="workspaceList" class="ws-list"><!-- rendered --></div>
|
||||||
</section>
|
</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 & 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><party>/<transmittal>/<name></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 -->
|
<!-- Browser Compatibility Warning -->
|
||||||
<div id="browserWarning" class="browser-warning hidden">
|
<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>
|
<p>This application requires the File System Access API, available only in Chromium-based browsers (Chrome, Edge, Brave, Opera).</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul class="welcome-list">
|
<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>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -420,6 +420,28 @@ test('snapshot: serialize + rebuild preserves structure, marks done, drops handl
|
||||||
expect(r.handleNull).toBe(true);
|
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 }) => {
|
test('persist: workspace put / list / get / delete round-trip', async ({ page }) => {
|
||||||
const r = await page.evaluate(async () => {
|
const r = await page.evaluate(async () => {
|
||||||
const P = window.app.modules.persist;
|
const P = window.app.modules.persist;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue