diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml
new file mode 100644
index 0000000..e0aebe5
--- /dev/null
+++ b/.github/workflows/build-windows.yml
@@ -0,0 +1,40 @@
+name: build-windows
+
+# On-demand Windows installer build. Unlike release.yml (which fires on a v*
+# tag and publishes a public GitHub Release), this builds the same NSIS + portable
+# installers and uploads them as a private workflow ARTIFACT — downloadable by
+# repo collaborators, auto-expiring, nothing published outward.
+#
+# Trigger it any of these ways:
+# - Actions tab -> "build-windows" -> Run workflow (workflow_dispatch, once on the default branch)
+# - push a lightweight tag matching winbuild-* (e.g. winbuild-1)
+# - push to the claude/shopdeck-file-selection-bug branch (builds this WIP branch)
+
+on:
+ workflow_dispatch:
+ push:
+ branches: ['claude/shopdeck-file-selection-bug-j00q7y']
+ tags: ['winbuild-*']
+
+jobs:
+ build:
+ runs-on: windows-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: npm
+ - run: npm ci
+ - run: npm test # same green-tests gate as release.yml
+ - run: npm run build:win # electron-vite build + electron-builder (NSIS + portable)
+ - name: Upload installer artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: shopdeck-windows-installer
+ if-no-files-found: error
+ retention-days: 30
+ path: |
+ dist/*.exe
+ dist/*.blockmap
+ dist/latest.yml
diff --git a/CLAUDE.md b/CLAUDE.md
index 03f93b2..315f019 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -15,9 +15,14 @@ plain files so a shared/network deployment is a later config change.
packages. Storage is plain files + a JSON index; the whole stack is pure JS.
- **Modules stay untouched.** ShopDeck displays a module's HTML byte-for-byte in
its own window. Astryx/React dress only the wrapper chrome, never the module.
-- **The manifest is the contract.** ShopDeck reads only the embedded
- `module-manifest` block. See `MODULE-SPEC.md` in the sibling
- `Part timeline plus timeline` project for the authoritative field reference.
+- **The manifest is the contract — but it's optional.** ShopDeck prefers the
+ embedded `module-manifest` block; when a self-contained HTML has none (e.g. a
+ timeline exported by the standalone tool), `scanLibrary` **infers** one from the
+ file — its `
`, filename, and, for tool-swap timelines, the embedded
+ `const DATA` object (part, events, date range, operation tags). Inferred modules
+ are flagged `inferred: true` (the card shows "· no manifest"). Files with a real
+ manifest stay authoritative. See `MODULE-SPEC.md` in the sibling
+ `Part timeline plus timeline` project for the field reference.
- Git identity is **AxialForge** (global). Check `git config --local --get-regexp '^user\.'`
before the first commit — a stale local override has mis-attributed commits before.
diff --git a/FIXES.md b/FIXES.md
new file mode 100644
index 0000000..593f14b
--- /dev/null
+++ b/FIXES.md
@@ -0,0 +1,212 @@
+# ShopDeck — Fixes & Changes
+
+A record of the bugs found and fixed (and the features added) while getting the
+library working on a real Windows + OneDrive setup. All of this lives on the
+`claude/shopdeck-file-selection-bug-j00q7y` branch (PR #1).
+
+The reported symptom was: **"the app won't let me see or correctly select files,"**
+starting with a fatal error dialog on launch. Chasing that down surfaced a chain
+of related issues — three distinct bugs and two feature gaps — all documented below.
+
+---
+
+## 1. Launch crash — `UNKNOWN: unknown error, watch`
+
+**Symptom.** On launch the app died immediately with Electron's fatal dialog:
+
+```
+A JavaScript error occurred in the main process
+Uncaught Exception:
+Error: UNKNOWN: unknown error, watch
+ at FSWatcher._handle.onchange (node:internal/fs/watchers:267:21)
+```
+
+The main process crashed, so the window never rendered — hence "can't see or
+select files."
+
+**Root cause.** The library auto-refresh watcher created a **recursive**
+`fs.watch` over the library root:
+
+```js
+watcher = watch(root, { recursive: true }, handler) // wrapped in try/catch
+```
+
+The surrounding `try/catch` only guards the **synchronous** setup call. On
+Windows, a recursive watch over an **OneDrive-synced** folder (the default root
+was `Documents/ShopDeck Library`, and Documents is commonly OneDrive-synced) or
+a network share fires an **asynchronous** `error` event — `UNKNOWN: unknown
+error, watch` — from the `FSWatcher` handle, long after setup. With no `'error'`
+listener attached, Node's `EventEmitter` re-throws it as an uncaught exception
+and the whole main process dies.
+
+**Fix.**
+- Attach an `'error'` listener to the watcher that closes it and degrades to
+ manual refresh instead of crashing.
+- Add main-process `uncaughtException` / `unhandledRejection` guards so no stray
+ async failure can ever show that fatal dialog again.
+
+**Files.** `electron/main.js`
+**Commit.** `44a3ec6`
+
+---
+
+## 2. Unreachable library bricked the UI (sibling of the crash)
+
+**Symptom.** If the configured library folder was an offline network share or a
+deleted folder, the app hung forever on **"Loading…"** with nothing shown and no
+error.
+
+**Root cause.** Every IPC handler funnels through `libraryRoot()`, which did
+`await fs.mkdir(root, …)`. That throws when the root is unreachable, so
+`library:scan` rejected — and the renderer's `reload()` had **no `try/catch`**,
+so the rejection was swallowed, `setLoading(false)` never ran, and the view was
+stuck. Same failure family as the watch crash: a network-share subsystem failure
+that should degrade gracefully but instead broke the app.
+
+**Fix.**
+- `libraryRoot()` never throws now (best-effort `mkdir`, ignore failure).
+- `library:scan` degrades to a soft error object (`{ root, folders: [],
+ modules: [], error }`) instead of rejecting.
+- The renderer wraps its reload in `try/catch/finally` and shows a dismissable
+ **error banner** with **Retry** and **Manage libraries** instead of an empty
+ window.
+
+**Files.** `electron/main.js`, `src/renderer/src/App.jsx`, `src/renderer/src/app.css`
+**Commit.** `d730fcc`
+
+---
+
+## 3. Library showed the folder but **zero modules** (OneDrive on-demand files)
+
+**Symptom.** After the crash was fixed, the library listed the folder in the nav
+tree but showed **0 modules** inside it — even though the `.html` files were
+clearly there in Explorer. It reproduced on a plain `C:` drive too.
+
+**Root cause.** OneDrive **"Files On-Demand"** placeholder files are reparse
+points, and `fs.readdir(dir, { withFileTypes: true })` reports them as
+**symlinks, not files** — `dirent.isFile()` returns `false`. The scan's `walk()`
+required `entry.isFile()`, so it **skipped every module**, while directories
+(which keep their folder attribute) were still detected. That's the exact
+fingerprint: folder visible, files invisible. Moving the files to another folder
+on the **same volume** keeps them as placeholders, so it reproduced off OneDrive
+as well.
+
+**Fix.**
+- New `entryKind()` helper: trust the dirent when it's decisive, otherwise
+ **`stat`** to resolve the real type (`stat` reads metadata only — it does *not*
+ download an online-only file). `walk()` and `walkHtmlUnder()` use it, so
+ placeholder/symlinked `.html` files are discovered. A realpath-keyed `seen`
+ set guards against symlink/junction cycles now that reparse entries are
+ followed.
+- `scanLibrary` also returns `htmlCount` and a `skipped` list (file + reason),
+ and the empty-state message uses them to **explain itself** ("Found N HTML
+ files here, but … First: — .") instead of a bare "Library is
+ empty" — so any remaining cause is visible without another debugging round.
+
+**Files.** `electron/library.js`, `src/renderer/src/App.jsx`
+**Commit.** `0aa5aee`
+
+---
+
+## 4. Standalone-tool timelines had **no manifest** (files loaded but were rejected)
+
+**Symptom.** With the scan now finding the files, the app reported: *"Found 9
+HTML files here, but none are valid modules — each needs a `module-manifest`
+block. First: … — no module-manifest block found."*
+
+**Root cause.** The timeline files were exported by the **standalone timeline
+tool**, not by ShopDeck's own generator. They carry a `` and an embedded
+`const DATA = {…}` object, but **no `module-manifest`** block. ShopDeck was built
+to catalog only files that carry that manifest, so it correctly found the files
+but rejected all of them. The requirement was to support **both** generated
+(manifest-carrying) and standalone (manifest-less) files, since multiple users
+would be dropping in files.
+
+**Fix.** ShopDeck now **infers a manifest** when a file has none (or an invalid
+one):
+- **Any HTML** gets an id from its filename and a title from its ``.
+- **Tool-swap timelines** are recognized from the embedded `DATA` and enriched
+ with the part number, event (swap) count, date range, and operation tags — so
+ their cards look just like generated ones. (Verified against the real exported
+ file: part `40-1318-01`, 147 events, 2011→2026, tags
+ preform/blocker/finish/pierce-strip/trim.)
+- Inferred modules are flagged `inferred: true` and the card shows a small
+ **"· no manifest"** note. Files with a real manifest stay authoritative.
+- `importFiles` / `importTree` no longer reject manifest-less HTML either.
+
+**Files.** `electron/library.js`, `src/renderer/src/App.jsx`, `CLAUDE.md`
+**Commit.** `627ec6a`
+
+---
+
+## Feature A — Multiple libraries + rename
+
+Added support for keeping **several named libraries** (local folders or network
+shares) and switching between them.
+
+- **New pure-Node model `electron/libraries.js`.** A library is a named pointer
+ `{ id, name, root }`. **Rename** edits the app-side label only — the folder on
+ disk is never renamed (consistent with how ShopDeck stores title/tag overrides
+ and never modifies your files). **Remove** just unlinks a library; the files
+ stay on disk. The last library can't be removed. Legacy single-root installs
+ migrate automatically on first run.
+- `electron/main.js` keeps a normalized `{ libraries, activeLibraryId }` view;
+ the existing `libraryRoot()` now resolves the **active** library, so every
+ downstream handler keeps working unchanged. New IPC:
+ `libraries:list/add/rename/remove/switch/reveal`.
+- `electron/preload.js` exposes `window.shopdeck.libraries.*`.
+- Renderer: an **active-library switcher** in the top bar, and a full
+ **Libraries** manager in Settings (use / rename / reveal / remove / add).
+
+**Files.** `electron/libraries.js`, `electron/main.js`, `electron/preload.js`,
+`src/renderer/src/App.jsx`, `src/renderer/src/app.css`, `test/libraries.test.mjs`
+**Commit.** `d730fcc`
+
+---
+
+## Feature B — On-demand Windows installer build (CI)
+
+Because the installer can only be built on Windows, added a CI workflow that
+builds the NSIS + portable installers on a `windows-latest` runner and uploads
+them as a **private, auto-expiring artifact** (no public release). This is what
+produced each test build during debugging.
+
+- Triggers: `workflow_dispatch`, a `winbuild-*` tag, or a push to the feature
+ branch.
+- The existing `release.yml` (public GitHub Release on a `v*` tag) is untouched
+ and remains the path for real releases.
+
+**Files.** `.github/workflows/build-windows.yml`
+**Commits.** `d8ec34c`, `c967885`
+
+---
+
+## Testing
+
+Electron itself can't be launched in the build/agent environment (package
+install scripts are blocked there, per `CLAUDE.md`), so verification relied on
+the pure-Node test suite plus real-file checks:
+
+- `test/library.test.mjs` — manifest parsing/validation, scanning, versioning,
+ overrides, import, **OneDrive/symlink discovery**, and **manifest inference**
+ (timeline + generic).
+- `test/libraries.test.mjs` — the multi-library model (migration, add/rename/
+ remove/switch, de-dup, "always at least one library").
+- Full pure-Node suite: **24 tests passing.** CI (`node --test` on Ubuntu) and
+ the Windows installer build are both green on the branch.
+
+Each fix was confirmed on the user's real Windows machine via a fresh installer
+build, ending with all 9 timelines showing correctly.
+
+---
+
+## Summary
+
+| # | Problem | Cause | Fix | Commit |
+|---|---------|-------|-----|--------|
+| 1 | Fatal crash on launch | Async `fs.watch` error, no `'error'` listener | Watcher error handler + process guards | `44a3ec6` |
+| 2 | Stuck on "Loading…" | `libraryRoot()`/scan threw on unreachable root | Soft-error scan + retry banner | `d730fcc` |
+| 3 | Folder shown, 0 modules | OneDrive placeholders read as symlinks; `isFile()` false | `stat`-resolve entries + self-diagnosing empty state | `0aa5aee` |
+| 4 | Files found but rejected | Standalone timelines carry no manifest | Infer a manifest from `` + `DATA` | `627ec6a` |
+| A | — | Feature: multiple libraries + rename | `electron/libraries.js` + UI | `d730fcc` |
+| B | — | Feature: on-demand Windows installer build | `build-windows.yml` | `d8ec34c` |
diff --git a/electron/libraries.js b/electron/libraries.js
new file mode 100644
index 0000000..8f7c52d
--- /dev/null
+++ b/electron/libraries.js
@@ -0,0 +1,114 @@
+// Pure-Node library-list model for ShopDeck. No Electron imports — shared by the
+// Electron main process and unit tests (runs under `node --test`).
+//
+// A "library" is a named pointer to a root folder:
+// { id, name, root }
+// The NAME is a friendly, app-side label (stored in settings) — renaming a
+// library changes that label only; the folder on disk is never touched, exactly
+// like the title/tag overrides in library.js. Multiple libraries can coexist;
+// `activeLibraryId` selects the one the app is currently showing.
+//
+// Settings shape:
+// { libraries: [{id,name,root}], activeLibraryId, theme, mode, showUpdater }
+// The legacy single-root `libraryRoot` field is migrated on first read.
+
+import { randomUUID } from 'node:crypto'
+
+/**
+ * A sensible default display name from a folder path (its last segment).
+ * Splits on both `/` and `\` so a Windows path resolves correctly even when
+ * this runs on a POSIX host (e.g. CI) — node:path.basename is platform-specific.
+ */
+export function libraryDisplayName(root) {
+ const clean = String(root || '').replace(/[/\\]+$/, '')
+ const seg = clean.split(/[/\\]/).pop()
+ return seg || clean || 'Library'
+}
+
+/**
+ * Ensure settings carry a valid libraries[] + activeLibraryId, migrating from a
+ * legacy single `libraryRoot` when needed. De-dupes by root and backfills any
+ * missing id/name. Returns { libraries, activeLibraryId, changed } where
+ * `changed` is true when the caller should persist the normalized shape.
+ */
+export function normalizeLibraries(settings = {}, defaultRoot) {
+ const s = settings || {}
+ let changed = false
+
+ const seen = new Set()
+ let libraries = (Array.isArray(s.libraries) ? s.libraries : [])
+ .filter((l) => l && typeof l.root === 'string' && l.root.trim())
+ .filter((l) => { if (seen.has(l.root)) { changed = true; return false } seen.add(l.root); return true })
+ .map((l) => {
+ const id = l.id || randomUUID()
+ const name = (typeof l.name === 'string' && l.name.trim()) ? l.name : libraryDisplayName(l.root)
+ if (id !== l.id || name !== l.name) changed = true
+ return { id, name, root: l.root }
+ })
+
+ // Migrate a legacy single-root install (or seed the built-in default).
+ if (libraries.length === 0) {
+ const root = (typeof s.libraryRoot === 'string' && s.libraryRoot) ? s.libraryRoot : defaultRoot
+ libraries = [{ id: randomUUID(), name: libraryDisplayName(root), root }]
+ changed = true
+ }
+
+ let activeLibraryId = s.activeLibraryId
+ if (!libraries.some((l) => l.id === activeLibraryId)) {
+ const match = libraries.find((l) => l.root === s.libraryRoot)
+ activeLibraryId = (match || libraries[0]).id
+ changed = true
+ }
+
+ return { libraries, activeLibraryId, changed }
+}
+
+/** The currently-active library record (falls back to the first, or null). */
+export function activeLibrary(state) {
+ if (!state || !Array.isArray(state.libraries) || state.libraries.length === 0) return null
+ return state.libraries.find((l) => l.id === state.activeLibraryId) || state.libraries[0]
+}
+
+/**
+ * Add a library for `root` and make it active. If a library already points at
+ * that root, no duplicate is created — it just becomes active instead.
+ */
+export function addLibrary(state, { root, name, id = randomUUID() } = {}) {
+ if (!root || !String(root).trim()) return state
+ const existing = state.libraries.find((l) => l.root === root)
+ if (existing) return { libraries: state.libraries, activeLibraryId: existing.id }
+ const lib = { id, name: (name && name.trim()) ? name.trim() : libraryDisplayName(root), root }
+ return { libraries: [...state.libraries, lib], activeLibraryId: id }
+}
+
+/** Rename a library's friendly label (the folder on disk is never touched). */
+export function renameLibrary(state, id, name) {
+ const clean = String(name || '').trim()
+ if (!clean) return state
+ return { ...state, libraries: state.libraries.map((l) => (l.id === id ? { ...l, name: clean } : l)) }
+}
+
+/**
+ * Unlink a library from ShopDeck. Files on disk are left alone. The last
+ * remaining library can't be removed (there is always at least one). If the
+ * active library is removed, the first survivor becomes active.
+ */
+export function removeLibrary(state, id) {
+ if (state.libraries.length <= 1) return state
+ const libraries = state.libraries.filter((l) => l.id !== id)
+ if (libraries.length === state.libraries.length) return state
+ const activeLibraryId = state.activeLibraryId === id ? libraries[0].id : state.activeLibraryId
+ return { libraries, activeLibraryId }
+}
+
+/** Set the active library (no-op if the id isn't known). */
+export function setActive(state, id) {
+ if (!state.libraries.some((l) => l.id === id)) return state
+ return { ...state, activeLibraryId: id }
+}
+
+/** Change the folder a specific library points at (defaults to the active one). */
+export function setLibraryRoot(state, root, id = state.activeLibraryId) {
+ if (!root || !String(root).trim()) return state
+ return { ...state, libraries: state.libraries.map((l) => (l.id === id ? { ...l, root } : l)) }
+}
diff --git a/electron/library.js b/electron/library.js
index 2d5da10..587da53 100644
--- a/electron/library.js
+++ b/electron/library.js
@@ -58,24 +58,102 @@ async function saveIndex(root, idx) {
await fs.writeFile(join(root, HIDDEN, 'index.json'), JSON.stringify(idx, null, 2), 'utf8')
}
+// Classify a directory entry. OneDrive "Files On-Demand" placeholders are reparse
+// points, and fs.readdir(withFileTypes) reports them as symlinks — dirent.isFile()
+// AND isDirectory() are both false — so a naive isFile() check skips every module
+// in a synced folder and the library looks empty even though the files are really
+// there (this bit users whose library lived under OneDrive). Trust the dirent when
+// it's decisive; otherwise stat to resolve the real type. stat reads metadata only
+// — it does NOT download an online-only file.
+async function entryKind(full, dirent) {
+ if (dirent.isDirectory()) return 'dir'
+ if (dirent.isFile()) return 'file'
+ try { return (await fs.stat(full)).isDirectory() ? 'dir' : 'file' }
+ catch { return 'skip' }
+}
+
// Recursively collect .html files and all sub-folders, skipping the hidden dir.
+// A realpath-keyed `seen` set guards against symlink/junction cycles now that we
+// follow reparse entries.
async function walk(root) {
const htmls = []
const folders = []
+ const seen = new Set()
async function rec(dir) {
+ let key
+ try { key = await fs.realpath(dir) } catch { key = dir }
+ if (seen.has(key)) return
+ seen.add(key)
let entries
try { entries = await fs.readdir(dir, { withFileTypes: true }) } catch { return }
for (const e of entries) {
if (e.name === HIDDEN) continue
const full = join(dir, e.name)
- if (e.isDirectory()) { folders.push(toPosix(relative(root, full))); await rec(full) }
- else if (e.isFile() && /\.html?$/i.test(e.name)) htmls.push(full)
+ const kind = await entryKind(full, e)
+ if (kind === 'dir') { folders.push(toPosix(relative(root, full))); await rec(full) }
+ else if (kind === 'file' && /\.html?$/i.test(e.name)) htmls.push(full)
}
}
await rec(root)
return { htmls, folders: folders.sort() }
}
+// ---- Manifest inference (for HTML files WITHOUT an embedded module-manifest) -
+// ShopDeck prefers the embedded module-manifest block, but not every self-contained
+// HTML has one (e.g. timelines exported by the standalone tool). These helpers
+// synthesise a manifest from what's in the file so such files still list and open,
+// side by side with generated modules.
+
+const decodeEntities = (s) => String(s)
+ .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
+ .replace(/"/g, '"').replace(/?39;/g, "'").replace(/—/g, '—').replace(/ /g, ' ')
+
+// A manifest id from a filename base: lowercase, only [a-z0-9._-], starting with an
+// alphanumeric — matches validateManifest's id rule.
+const slugId = (s) => String(s || '').trim().toLowerCase()
+ .replace(/[^a-z0-9._-]+/g, '-').replace(/^[^a-z0-9]+/, '').replace(/-+$/g, '')
+
+// Pull the timeline's embedded `const DATA = {…};` object if present. DATA is JSON,
+// so the first `};` after it is its end (JSON literals carry no semicolons).
+function timelineDataOf(html) {
+ const m = html.match(/const\s+DATA\s*=\s*(\{[\s\S]*?\})\s*;/)
+ if (!m) return null
+ let d
+ try { d = JSON.parse(m[1]) } catch { return null }
+ if (!d || !Array.isArray(d.events)) return null
+ const dates = []
+ for (const e of d.events) { if (e && e.removed) dates.push(e.removed); if (e && e.install) dates.push(e.install) }
+ dates.sort()
+ const groups = [...new Set((d.lanes || []).map((l) => String((l && l.group) || '').replace(/^\s*\d+\s*·\s*/, '').trim()).filter(Boolean))]
+ const tags = groups.map((g) => g.toLowerCase().replace(/\s*\/\s*/g, '-').replace(/\s+/g, '-'))
+ return { part: d.part || null, events: d.events.length, dmin: d.dmin || dates[0] || null, dmax: d.dmax || dates[dates.length - 1] || null, tags }
+}
+
+/** Build a best-effort manifest for an HTML file that carries no (valid) manifest. */
+function inferManifest(html, absFile, mtime) {
+ const base = basename(absFile, extname(absFile))
+ const titleTag = (html.match(/]*>([\s\S]*?)<\/title>/i) || [])[1]
+ const title = decodeEntities((titleTag || base).trim()).trim() || base
+ let id = slugId(base)
+ if (!id && !title) return null
+ if (!id) id = 'module'
+ const day = (mtime instanceof Date && !isNaN(mtime.valueOf())) ? mtime.toISOString().slice(0, 10) : today()
+
+ const man = { schema: 1, id, type: 'document', title, version: 1, created: day, updated: day, tags: [], fields: {} }
+
+ // A tool-swap timeline carries its own DATA object — enrich the card from it.
+ const tl = timelineDataOf(html)
+ if (tl) {
+ man.type = 'tool-swap-timeline'
+ man.category = 'Tooling'
+ if (tl.part) { const tid = slugId(`tool-swap-timeline_${tl.part}`); if (tid) man.id = tid }
+ man.tags = tl.tags
+ man.description = tl.part ? `Die-set removal history for part ${tl.part}.` : ''
+ man.fields = { part: tl.part, partFamily: tl.part ? String(tl.part).split('-')[0] : '', events: tl.events, dateRange: { from: tl.dmin, to: tl.dmax } }
+ }
+ return man
+}
+
/**
* Scan the library root into UI-ready data. Snapshots any not-yet-archived
* module version so history survives (whether the update happened in-app or by
@@ -88,15 +166,25 @@ export async function scanLibrary(root) {
const { htmls, folders } = await walk(root)
const modules = []
+ const skipped = [] // files that were found but couldn't become modules (with why)
for (const file of htmls) {
+ const relFile = toPosix(relative(root, file))
let html
- try { html = await fs.readFile(file, 'utf8') } catch { continue }
- const man = parseManifest(html)
- if (!man || !validateManifest(man).ok) continue
+ try { html = await fs.readFile(file, 'utf8') } catch { skipped.push({ file: relFile, reason: 'could not read the file (an offline OneDrive file?)' }); continue }
+ let man = parseManifest(html)
+ let inferred = false
+ if (!man || !validateManifest(man).ok) {
+ // No embedded manifest (or an invalid one): synthesise one from the file so
+ // standalone-tool timelines and other manifest-less work-item HTML still show.
+ let mtime = null
+ try { mtime = (await fs.stat(file)).mtime } catch { /* keep null → today() */ }
+ const guess = inferManifest(html, file, mtime)
+ if (guess && validateManifest(guess).ok) { man = guess; inferred = true }
+ else { skipped.push({ file: relFile, reason: man ? 'invalid module-manifest' : 'no module-manifest and no readable title' }); continue }
+ }
const id = man.id
- const relFile = toPosix(relative(root, file))
const relFolder = dirname(relFile) === '.' ? '' : dirname(relFile)
const srcAbs = await siblingSource(file)
@@ -134,7 +222,8 @@ export async function scanLibrary(root) {
latest,
currentVersion: man.version,
versions: rec.versions.map((v) => ({ version: v.version, updated: v.updated, created: v.created, source: v.source })),
- edited: !!(rec.overrides.title || rec.overrides.tags)
+ edited: !!(rec.overrides.title || rec.overrides.tags),
+ inferred
})
}
@@ -149,7 +238,7 @@ export async function scanLibrary(root) {
const prev = byId.get(m.id)
if (!prev || finalized.currentVersion > prev.currentVersion) byId.set(m.id, finalized)
}
- return { root, folders, modules: [...byId.values()] }
+ return { root, folders, modules: [...byId.values()], htmlCount: htmls.length, skipped }
}
/** Resolve the on-disk index.html for opening (latest = the live file; older = an archived snapshot). */
@@ -199,26 +288,33 @@ export async function importFiles(root, destRel, filePaths) {
for (const f of filePaths) {
let html
try { html = await fs.readFile(f, 'utf8') } catch { done.push({ ok: false, file: basename(f), error: 'unreadable' }); continue }
+ // A manifest is optional — files without one are inferred on scan — so any
+ // readable self-contained HTML can be imported.
const man = parseManifest(html)
- if (!man || !validateManifest(man).ok) { done.push({ ok: false, file: basename(f), error: 'invalid or missing manifest' }); continue }
await fs.copyFile(f, join(destDir, basename(f)))
const src = await siblingSource(f)
if (src) await fs.copyFile(src, join(destDir, basename(src)))
- done.push({ ok: true, file: basename(f), id: man.id })
+ done.push({ ok: true, file: basename(f), id: man?.id || slugId(basename(f, extname(f))) || null })
}
return done
}
async function walkHtmlUnder(dir) {
const out = []
+ const seen = new Set()
async function rec(d) {
+ let key
+ try { key = await fs.realpath(d) } catch { key = d }
+ if (seen.has(key)) return
+ seen.add(key)
let ents
try { ents = await fs.readdir(d, { withFileTypes: true }) } catch { return }
for (const e of ents) {
if (e.name === HIDDEN) continue
const full = join(d, e.name)
- if (e.isDirectory()) await rec(full)
- else if (/\.html?$/i.test(e.name)) out.push(full)
+ const kind = await entryKind(full, e)
+ if (kind === 'dir') await rec(full)
+ else if (kind === 'file' && /\.html?$/i.test(e.name)) out.push(full)
}
}
await rec(dir)
@@ -235,15 +331,14 @@ export async function importTree(root, destRel, srcDir) {
for (const f of files) {
let html
try { html = await fs.readFile(f, 'utf8') } catch { done.push({ ok: false, file: basename(f), error: 'unreadable' }); continue }
- const man = parseManifest(html)
- if (!man || !validateManifest(man).ok) { done.push({ ok: false, file: basename(f), error: 'invalid or missing manifest' }); continue }
+ const man = parseManifest(html) // optional — inferred on scan when absent
const rel = toPosix(relative(srcDir, dirname(f))) // subfolder under the picked dir
const dest = join(root, destRel || '', rel)
await fs.mkdir(dest, { recursive: true })
await fs.copyFile(f, join(dest, basename(f)))
const src = await siblingSource(f)
if (src) await fs.copyFile(src, join(dest, basename(src)))
- done.push({ ok: true, file: basename(f), id: man.id, folder: toPosix(join(destRel || '', rel)) })
+ done.push({ ok: true, file: basename(f), id: man?.id || slugId(basename(f, extname(f))) || null, folder: toPosix(join(destRel || '', rel)) })
}
return done
}
diff --git a/electron/main.js b/electron/main.js
index 1dec66a..e821ba1 100644
--- a/electron/main.js
+++ b/electron/main.js
@@ -2,13 +2,24 @@ import { app, BrowserWindow, ipcMain, dialog, shell, screen } from 'electron'
import { join, basename } from 'node:path'
import { promises as fs, watch } from 'node:fs'
import { scanLibrary, resolveModuleFile, setOverride, importFiles, importTree, createFolder, moduleVersions, deleteModule, deleteFolder, backupLibrary } from './library.js'
+import { normalizeLibraries, activeLibrary as pickActive, addLibrary, renameLibrary, removeLibrary, setActive, setLibraryRoot } from './libraries.js'
import * as updater from './updater.js'
import XLSX from 'xlsx'
import { generateTimeline } from './generators/timeline.js'
import timelineTemplate from './generators/timeline-template.html?raw'
+// ---- Crash resilience -------------------------------------------------------
+// A single stray async error in the main process otherwise shows Electron's
+// fatal "A JavaScript error occurred in the main process" dialog and kills the
+// app. Log and keep running so a non-critical failure (e.g. a file watcher on a
+// flaky network share) never takes the whole window down with it.
+process.on('uncaughtException', (err) => { console.error('[main] uncaught exception:', err) })
+process.on('unhandledRejection', (err) => { console.error('[main] unhandled rejection:', err) })
+
// ---- Settings (userData/settings.json) --------------------------------------
-const DEFAULTS = { libraryRoot: null, theme: 'grey', showUpdater: true, mode: 'editing' }
+// `libraries` is the multi-library list (see electron/libraries.js); `libraryRoot`
+// is the legacy single-root field kept only so old installs migrate cleanly.
+const DEFAULTS = { libraries: null, activeLibraryId: null, libraryRoot: null, theme: 'grey', showUpdater: true, mode: 'editing' }
const settingsFile = () => join(app.getPath('userData'), 'settings.json')
const defaultRoot = () => join(app.getPath('documents'), 'ShopDeck Library')
@@ -21,12 +32,45 @@ async function saveSettings(patch) {
await fs.writeFile(settingsFile(), JSON.stringify(next, null, 2), 'utf8')
return next
}
-async function libraryRoot() {
+
+// The normalized { libraries, activeLibraryId } view, migrating a legacy single
+// libraryRoot on first read and persisting the result so it only happens once.
+async function libraryState() {
const s = await loadSettings()
- const root = s.libraryRoot || defaultRoot()
- await fs.mkdir(root, { recursive: true })
- if (!s.libraryRoot) await saveSettings({ libraryRoot: root }) // persist the resolved default
- return root
+ const st = normalizeLibraries(s, defaultRoot())
+ if (st.changed) await saveSettings({ libraries: st.libraries, activeLibraryId: st.activeLibraryId })
+ return { libraries: st.libraries, activeLibraryId: st.activeLibraryId }
+}
+async function persistState(st) {
+ await saveSettings({ libraries: st.libraries, activeLibraryId: st.activeLibraryId })
+ return st
+}
+async function libraryRoot() {
+ const lib = pickActive(await libraryState())
+ // Best-effort ensure the folder exists. Never throw here: an offline share or
+ // a deleted folder is surfaced by scan (as a soft error) instead of taking
+ // down whichever IPC handler happened to call this first.
+ try { await fs.mkdir(lib.root, { recursive: true }) } catch { /* surfaced by scan */ }
+ return lib.root
+}
+
+// Turn a raw fs error from an unreachable/missing library into a short, plain
+// message the renderer can show — the graceful-degradation sibling of the
+// watcher fix (a network-share failure must not brick the app).
+function friendlyRootError(err) {
+ const msg = String((err && err.message) || err || '')
+ if (/ENOENT|no such file/i.test(msg)) return 'The library folder could not be found — it may have been moved or renamed, or a network share is offline.'
+ if (/EPERM|EACCES|permission denied/i.test(msg)) return "ShopDeck doesn't have permission to read this library folder."
+ if (/UNKNOWN|EBUSY|ENETUNREACH|ETIMEDOUT|EHOSTDOWN|EHOSTUNREACH|ENXIO/i.test(msg)) return 'The library folder is unavailable — a network share may be disconnected.'
+ return 'The library could not be opened: ' + (msg.split('\n')[0].slice(0, 160) || 'unknown error')
+}
+
+// Scan the active library, degrading to a soft error object (never a rejection)
+// so the renderer can render a retry banner instead of hanging on "Loading…".
+async function scanActive() {
+ const lib = pickActive(await libraryState())
+ try { return { ...(await scanLibrary(lib.root)), library: lib } }
+ catch (err) { return { root: lib.root, folders: [], modules: [], library: lib, error: friendlyRootError(err) } }
}
// ---- Windows ----------------------------------------------------------------
@@ -55,10 +99,18 @@ async function startWatch() {
if (watcher) { try { watcher.close() } catch { /* ignore */ } watcher = null }
const root = await libraryRoot()
try {
- watcher = watch(root, { recursive: true }, (_evt, file) => {
+ const w = watch(root, { recursive: true }, (_evt, file) => {
if (file && String(file).includes('.shopdeck')) return // our own metadata
scheduleNotify()
})
+ // fs.watch can fail ASYNCHRONOUSLY, long after this synchronous setup call.
+ // On Windows a recursive watch over an OneDrive-synced Documents folder
+ // (the default root) or a network share emits an 'error' event —
+ // "UNKNOWN: unknown error, watch" from FSWatcher._handle.onchange. With no
+ // 'error' listener, EventEmitter rethrows it and the whole main process
+ // dies with a fatal dialog. Catch it and degrade to manual refresh.
+ w.on('error', () => { try { w.close() } catch { /* ignore */ } if (watcher === w) watcher = null })
+ watcher = w
} catch { /* some network shares don't emit events; degrade quietly */ }
}
@@ -98,8 +150,9 @@ function openModuleWindow({ indexPath, title, version }) {
ipcMain.handle('settings:get', () => loadSettings())
ipcMain.handle('settings:set', (_e, patch) => saveSettings(patch || {}))
-ipcMain.handle('library:scan', async () => scanLibrary(await libraryRoot()))
+ipcMain.handle('library:scan', () => scanActive())
+// Change the folder the ACTIVE library points at (multi-library repoint).
ipcMain.handle('library:chooseRoot', async () => {
const res = await dialog.showOpenDialog(mainWindow, {
title: 'Choose library folder (local or network share)',
@@ -107,9 +160,45 @@ ipcMain.handle('library:chooseRoot', async () => {
})
if (res.canceled || !res.filePaths[0]) return { canceled: true }
const root = res.filePaths[0]
- await saveSettings({ libraryRoot: root })
+ await persistState(setLibraryRoot(await libraryState(), root))
+ startWatch()
+ return { canceled: false, root, ...(await scanActive()) }
+})
+
+// ---- Libraries (multiple named library roots) -------------------------------
+ipcMain.handle('libraries:list', () => libraryState())
+
+ipcMain.handle('libraries:add', async () => {
+ const res = await dialog.showOpenDialog(mainWindow, {
+ title: 'Add a library folder (local or network share)',
+ properties: ['openDirectory', 'createDirectory']
+ })
+ if (res.canceled || !res.filePaths[0]) return { canceled: true }
+ const next = await persistState(addLibrary(await libraryState(), { root: res.filePaths[0] }))
+ startWatch()
+ return { canceled: false, ...next }
+})
+
+ipcMain.handle('libraries:rename', async (_e, { id, name }) =>
+ persistState(renameLibrary(await libraryState(), id, name)))
+
+ipcMain.handle('libraries:remove', async (_e, { id }) => {
+ const next = await persistState(removeLibrary(await libraryState(), id))
startWatch()
- return { canceled: false, root, ...(await scanLibrary(root)) }
+ return next
+})
+
+ipcMain.handle('libraries:switch', async (_e, { id }) => {
+ const next = await persistState(setActive(await libraryState(), id))
+ startWatch()
+ return next
+})
+
+ipcMain.handle('libraries:reveal', async (_e, { id }) => {
+ const st = await libraryState()
+ const lib = st.libraries.find((l) => l.id === id)
+ if (lib) await shell.openPath(lib.root)
+ return true
})
ipcMain.handle('library:reveal', async () => { await shell.openPath(await libraryRoot()); return true })
diff --git a/electron/preload.js b/electron/preload.js
index 565b4a5..1e07ad9 100644
--- a/electron/preload.js
+++ b/electron/preload.js
@@ -9,6 +9,16 @@ contextBridge.exposeInMainWorld('shopdeck', {
revealRoot: () => ipcRenderer.invoke('library:reveal'),
openExternal: (url) => ipcRenderer.invoke('shell:openExternal', { url }),
+ // Multiple named libraries + rename (see electron/libraries.js).
+ libraries: {
+ list: () => ipcRenderer.invoke('libraries:list'),
+ add: () => ipcRenderer.invoke('libraries:add'),
+ rename: (id, name) => ipcRenderer.invoke('libraries:rename', { id, name }),
+ remove: (id) => ipcRenderer.invoke('libraries:remove', { id }),
+ switch: (id) => ipcRenderer.invoke('libraries:switch', { id }),
+ reveal: (id) => ipcRenderer.invoke('libraries:reveal', { id })
+ },
+
open: (id, version, title) => ipcRenderer.invoke('module:open', { id, version, title }),
showSource: (id, version) => ipcRenderer.invoke('module:source', { id, version }),
setMeta: (id, patch) => ipcRenderer.invoke('module:setMeta', { id, ...patch }),
diff --git a/package.json b/package.json
index b324b9d..d095ea6 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "shopdeck",
- "version": "1.0.0",
+ "version": "1.1.0",
"description": "Desktop library for storing, organizing, presenting, and version-tracking interactive work-item modules.",
"author": "AxialForge <37514365+AxialForge@users.noreply.github.com>",
"license": "Apache-2.0",
diff --git a/src/renderer/src/App.jsx b/src/renderer/src/App.jsx
index 4223d1b..a030718 100644
--- a/src/renderer/src/App.jsx
+++ b/src/renderer/src/App.jsx
@@ -54,9 +54,13 @@ function buildTree(folders) {
return root
}
+const SAMPLE_LIBS = [{ id: 'sample', name: 'ShopDeck Library', root: SAMPLE.root }]
+
export default function App() {
const [settings, setSettings] = useState({ theme: 'grey', libraryRoot: '', mode: 'editing' })
const [data, setData] = useState({ root: '', folders: [], modules: [] })
+ const [libraries, setLibraries] = useState(SAMPLE_LIBS)
+ const [activeLibraryId, setActiveLibraryId] = useState('sample')
const [loading, setLoading] = useState(true)
const [view, setView] = useState('home') // home | library | generator | settings | about
const [dropping, setDropping] = useState(false)
@@ -66,17 +70,29 @@ export default function App() {
const [activeTags, setActiveTags] = useState(() => new Set())
const [editing, setEditing] = useState(null) // module being edited
const [newFolderOpen, setNewFolderOpen] = useState(false)
+ const [renaming, setRenaming] = useState(null) // library being renamed
const [thumbs, setThumbs] = useState({}) // id -> data URL ('' = tried, none)
const applyTheme = (t) => { document.documentElement.dataset.theme = t || 'grey' }
const reload = useCallback(async () => {
setLoading(true)
- const s = api ? await api.getSettings() : { theme: 'grey', libraryRoot: SAMPLE.root, mode: 'editing' }
- applyTheme(s.theme)
- setSettings(s)
- setData(api ? await api.scan() : SAMPLE)
- setLoading(false)
+ try {
+ const s = api ? await api.getSettings() : { theme: 'grey', libraryRoot: SAMPLE.root, mode: 'editing' }
+ applyTheme(s.theme)
+ setSettings(s)
+ if (api) {
+ const st = await api.libraries.list()
+ setLibraries(st.libraries || [])
+ setActiveLibraryId(st.activeLibraryId || null)
+ }
+ setData(api ? await api.scan() : SAMPLE)
+ } catch (err) {
+ // Never leave the UI stuck on "Loading…" — surface the failure instead.
+ setData((d) => ({ ...d, error: String((err && err.message) || err) }))
+ } finally {
+ setLoading(false)
+ }
}, [])
useEffect(() => { reload() }, [reload])
@@ -156,6 +172,22 @@ export default function App() {
}
async function onChangeTheme(t) { applyTheme(t); setSettings((s) => ({ ...s, theme: t })); if (api) await api.setSettings({ theme: t }) }
async function onChangeRoot() { if (!api) return; const r = await api.chooseRoot(); if (!r?.canceled) reload() }
+
+ // ---- Libraries -------------------------------------------------------------
+ async function onAddLibrary() { if (!api) return; const r = await api.libraries.add(); if (!r?.canceled) reload() }
+ async function onSwitchLibrary(id) {
+ if (!api || id === activeLibraryId) return
+ setFolder(''); setActiveTags(new Set()); setQuery('') // filters are per-library
+ await api.libraries.switch(id); reload()
+ }
+ async function onRenameLibrary(id, name) {
+ setRenaming(null)
+ if (!api || !name) return
+ const st = await api.libraries.rename(id, name)
+ setLibraries(st.libraries || []); setActiveLibraryId(st.activeLibraryId || null)
+ }
+ async function onRemoveLibrary(id) { if (!api) return; await api.libraries.remove(id); reload() }
+ function onRevealLibrary(id) { api?.libraries.reveal(id) }
async function saveEdit(id, title, tags) { if (api) await api.setMeta(id, { title, tags }); setEditing(null); reload() }
async function createFolderNow(name) {
setNewFolderOpen(false)
@@ -177,6 +209,13 @@ export default function App() {
Keep several libraries (local folders or network shares) and switch between
+ them from the bar up top. Renaming changes only the label shown here — the folder on disk is never
+ touched. Removing a library just unlinks it from ShopDeck; your files stay on disk.