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: <file> — <reason>.") 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 `<title>` 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 `<title>`. +- **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 `<title>` + `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(/<title[^>]*>([\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() { <div className="app"> <div className="topbar"> <div className="brand"><span className="mark">S</span> ShopDeck</div> + {libraries.length > 0 && ( + <label className="lib-switch" title="Active library"> + <select value={activeLibraryId || ''} onChange={(e) => onSwitchLibrary(e.target.value)}> + {libraries.map((l) => <option key={l.id} value={l.id}>{l.name}</option>)} + </select> + </label> + )} <nav className="tabs"> {TABS.map((t) => ( <button key={t.id} className={'tab' + (view === t.id ? ' on' : '')} onClick={() => setView(t.id)}>{t.label}</button> @@ -185,6 +224,15 @@ export default function App() { <span className="count">{data.modules.length} module{data.modules.length === 1 ? '' : 's'}</span> </div> + {data.error && ( + <div className="banner err-banner"> + <span className="banner-ic">!</span> + <span className="grow ell" title={data.error}>{data.error}</span> + <Button label="Retry" variant="secondary" onClick={reload} /> + <Button label="Manage libraries" variant="ghost" onClick={() => setView('settings')} /> + </div> + )} + {view === 'library' && ( <> <div className="toolbar"> @@ -233,9 +281,13 @@ export default function App() { {loading && <div className="empty">Loading…</div>} {!loading && shown.length === 0 && ( - <div className="empty">{data.modules.length === 0 - ? 'Library is empty. Click Import to add a module (or run npm run seed).' - : 'Nothing matches your filters.'}</div> + <div className="empty"> + {data.modules.length > 0 + ? 'Nothing matches your filters.' + : data.htmlCount > 0 + ? <>Found {data.htmlCount} HTML file{data.htmlCount === 1 ? '' : 's'} here, but {data.htmlCount === 1 ? 'it' : 'none'} could be loaded.{data.skipped?.[0] ? <><br />First: <code>{data.skipped[0].file}</code> — {data.skipped[0].reason}.</> : ''}</> + : 'Library is empty. Click Import to add a module (or run npm run seed).'} + </div> )} {!loading && shown.length > 0 && ( <div className="grid"> @@ -244,7 +296,7 @@ export default function App() { <div className="card-top"> <div className="grow"> <div className="part">{m.fields?.part || m.title}</div> - <div className="muted">{m.type.replace(/-/g, ' ')}{m.edited ? ' · edited' : ''}</div> + <div className="muted">{m.type.replace(/-/g, ' ')}{m.edited ? ' · edited' : ''}{m.inferred ? ' · no manifest' : ''}</div> </div> <Badge label={`v${m.latest}`} variant="neutral" /> </div> @@ -283,8 +335,12 @@ export default function App() { {view === 'home' && <HomeView data={data} settings={settings} go={setView} onOpenRoot={() => api?.revealRoot()} />} {view === 'generator' && <GeneratorView go={setView} onGenerated={reload} />} - {view === 'settings' && <SettingsView settings={settings} root={data.root} onChangeTheme={onChangeTheme} - onChangeMode={onChangeMode} onChangeRoot={onChangeRoot} onReveal={() => api?.revealRoot()} hasApi={!!api} />} + {view === 'settings' && <SettingsView settings={settings} root={data.root} + libraries={libraries} activeLibraryId={activeLibraryId} + onChangeTheme={onChangeTheme} onChangeMode={onChangeMode} onChangeRoot={onChangeRoot} + onReveal={() => api?.revealRoot()} hasApi={!!api} + onAddLibrary={onAddLibrary} onSwitchLibrary={onSwitchLibrary} + onRenameLibrary={(lib) => setRenaming(lib)} onRemoveLibrary={onRemoveLibrary} onRevealLibrary={onRevealLibrary} />} {view === 'about' && <AboutView />} <div className="footer"> @@ -296,6 +352,10 @@ export default function App() { {editing && <EditModal module={editing} onCancel={() => setEditing(null)} onSave={saveEdit} />} {newFolderOpen && <NameModal title="New folder" placeholder="Folder name" onCancel={() => setNewFolderOpen(false)} onSave={createFolderNow} note={folder ? `Inside: ${folder}` : 'At the library root'} />} + {renaming && <NameModal title="Rename library" placeholder="Library name" + initial={renaming.name} actionLabel="Save" sanitize={false} + note={`Just the label shown in ShopDeck — the folder on disk (${renaming.root}) is not renamed.`} + onCancel={() => setRenaming(null)} onSave={(name) => onRenameLibrary(renaming.id, name)} />} </div> ) } @@ -336,8 +396,9 @@ function EditModal({ module, onCancel, onSave }) { ) } -function NameModal({ title, placeholder, note, onCancel, onSave }) { - const [name, setName] = useState('') +function NameModal({ title, placeholder, note, initial = '', actionLabel = 'Create', sanitize = true, onCancel, onSave }) { + const [name, setName] = useState(initial) + const submit = () => { const v = name.trim(); onSave(sanitize ? v.replace(/[\\/]/g, '-') : v) } return ( <div className="overlay" onClick={onCancel}> <div className="modal" onClick={(e) => e.stopPropagation()}> @@ -348,7 +409,7 @@ function NameModal({ title, placeholder, note, onCancel, onSave }) { <div className="modal-foot"> <div className="spacer" /> <Button label="Cancel" variant="secondary" onClick={onCancel} /> - <Button label="Create" variant="primary" onClick={() => onSave(name.trim().replace(/[\\/]/g, '-'))} /> + <Button label={actionLabel} variant="primary" onClick={submit} /> </div> </div> </div> @@ -434,7 +495,46 @@ function AboutView() { ) } -function SettingsView({ settings, root, onChangeTheme, onChangeMode, onChangeRoot, onReveal, hasApi }) { +function LibrariesSection({ libraries, activeLibraryId, hasApi, onAdd, onSwitch, onRename, onRemove, onReveal }) { + const single = libraries.length <= 1 + return ( + <section className="set-sec"> + <div className="set-title">Libraries</div> + <div className="muted small">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.</div> + <div className="lib-list"> + {libraries.map((l) => { + const active = l.id === activeLibraryId + return ( + <div key={l.id} className={'lib-row' + (active ? ' active' : '')}> + <div className="grow"> + <div className="lib-name"> + <span className="ell">{l.name}</span> + {active && <Badge label="Active" variant="success" />} + </div> + <div className="muted small ell" title={l.root}>{l.root}</div> + </div> + <div className="lib-row-actions"> + {!active && <button className="linkbtn" onClick={() => onSwitch(l.id)}>Use</button>} + <button className="linkbtn" onClick={() => onRename(l)}>Rename</button> + <button className="linkbtn" onClick={() => onReveal(l.id)}>Reveal</button> + {!single && <button className="linkbtn danger" onClick={() => onRemove(l.id)}>Remove</button>} + </div> + </div> + ) + })} + </div> + <div className="set-actions"> + <Button label="Add library…" variant="secondary" onClick={onAdd} /> + </div> + {!hasApi && <div className="muted small">(Browser preview — library management works in the desktop app.)</div>} + </section> + ) +} + +function SettingsView({ settings, root, libraries, activeLibraryId, onChangeTheme, onChangeMode, onChangeRoot, + onReveal, hasApi, onAddLibrary, onSwitchLibrary, onRenameLibrary, onRemoveLibrary, onRevealLibrary }) { const [backupMsg, setBackupMsg] = useState('') async function onBackup() { if (!hasApi) return @@ -457,9 +557,13 @@ function SettingsView({ settings, root, onChangeTheme, onChangeMode, onChangeRoo </div> </section> + <LibrariesSection libraries={libraries} activeLibraryId={activeLibraryId} hasApi={hasApi} + onAdd={onAddLibrary} onSwitch={onSwitchLibrary} onRename={onRenameLibrary} + onRemove={onRemoveLibrary} onReveal={onRevealLibrary} /> + <section className="set-sec"> - <div className="set-title">Library folder</div> - <div className="muted small">The selected folder's structure is the organization. The app only reads and writes inside it.</div> + <div className="set-title">Active library folder</div> + <div className="muted small">The active library's folder structure is the organization. The app only reads and writes inside it.</div> <div className="rootline"><code>{root || settings.libraryRoot || '—'}</code></div> <div className="set-actions"> <Button label="Change folder…" variant="secondary" onClick={onChangeRoot} /> diff --git a/src/renderer/src/app.css b/src/renderer/src/app.css index 3e43c91..f80c3ed 100644 --- a/src/renderer/src/app.css +++ b/src/renderer/src/app.css @@ -37,6 +37,35 @@ body { } .count { margin-left: auto; font-size: 13px; color: var(--color-text-secondary); } +/* Active-library switcher in the top bar */ +.lib-switch { display: inline-flex; align-items: center; } +.lib-switch select { + appearance: none; -webkit-appearance: none; + background: var(--color-background-muted); color: var(--color-text-primary); + border: 1px solid var(--color-border); border-radius: var(--radius-element); + padding: 5px 26px 5px 10px; font-size: 13px; cursor: pointer; max-width: 220px; + background-image: linear-gradient(45deg, transparent 50%, var(--color-text-secondary) 50%), + linear-gradient(135deg, var(--color-text-secondary) 50%, transparent 50%); + background-position: right 12px center, right 7px center; background-size: 5px 5px, 5px 5px; + background-repeat: no-repeat; +} +.lib-switch select:hover { border-color: var(--sd-accent, #3b74f0); } + +/* Error banner (e.g. an unreachable library share) */ +.banner { + display: flex; align-items: center; gap: 10px; padding: 9px 14px; + border-bottom: 1px solid var(--color-border); font-size: 13px; +} +.err-banner { + background: color-mix(in srgb, var(--color-text-red, #e0736c) 14%, var(--color-background-surface)); + color: var(--color-text-primary); +} +.banner-ic { + flex: 0 0 auto; width: 18px; height: 18px; border-radius: 50%; + background: var(--color-text-red, #e0736c); color: #fff; display: grid; place-items: center; + font-weight: 700; font-size: 12px; +} + .tabs { display: flex; gap: 2px; margin-left: 10px; } .tab { background: none; border: none; padding: 6px 12px; font-size: 13px; color: var(--color-text-secondary); cursor: pointer; border-radius: var(--radius-element); } .tab:hover { background: var(--color-background-muted); color: var(--color-text-primary); } @@ -124,6 +153,19 @@ body { .rootline code, .footer code { font-family: ui-monospace, Consolas, monospace; font-size: 12.5px; word-break: break-all; } .set-actions { display: flex; gap: 8px; margin-top: 8px; } +/* Libraries manager */ +.lib-list { display: flex; flex-direction: column; gap: 8px; margin: 12px 0 4px; } +.lib-row { + display: flex; align-items: center; gap: 10px; padding: 10px 12px; + border: 1px solid var(--color-border); border-radius: var(--radius-container); + background: var(--color-background-card); +} +.lib-row.active { border-color: var(--sd-accent, #3b74f0); } +.lib-name { display: flex; align-items: center; gap: 8px; font-weight: 500; } +.lib-name .ell { max-width: 260px; } +.lib-row code, .lib-row .muted { font-family: ui-monospace, Consolas, monospace; } +.lib-row-actions { display: flex; gap: 12px; flex: 0 0 auto; align-items: center; } + /* Modal */ .overlay { position: fixed; inset: 0; background: rgba(0,0,0,.5); display: flex; align-items: center; justify-content: center; z-index: 100; } .modal { diff --git a/test/libraries.test.mjs b/test/libraries.test.mjs new file mode 100644 index 0000000..1c0a4d9 --- /dev/null +++ b/test/libraries.test.mjs @@ -0,0 +1,118 @@ +// Tests for the pure-Node library-list model (electron/libraries.js). No +// Electron — runs under `node --test`. Covers legacy migration, add/rename/ +// remove/switch, de-duplication, and the "always at least one library" rule. +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { + libraryDisplayName, normalizeLibraries, activeLibrary, + addLibrary, renameLibrary, removeLibrary, setActive, setLibraryRoot +} from '../electron/libraries.js' + +const DEFAULT_ROOT = '/home/user/Documents/ShopDeck Library' + +test('libraryDisplayName uses the folder\'s last segment', () => { + assert.equal(libraryDisplayName('/srv/share/Tooling Library'), 'Tooling Library') + assert.equal(libraryDisplayName('C:\\\\Users\\\\me\\\\Lib\\\\'), 'Lib') + assert.equal(libraryDisplayName(''), 'Library') +}) + +test('normalizeLibraries seeds a default library when settings are empty', () => { + const st = normalizeLibraries({}, DEFAULT_ROOT) + assert.equal(st.libraries.length, 1) + assert.equal(st.libraries[0].root, DEFAULT_ROOT) + assert.equal(st.libraries[0].name, 'ShopDeck Library') + assert.equal(st.activeLibraryId, st.libraries[0].id) + assert.ok(st.libraries[0].id, 'has a generated id') + assert.equal(st.changed, true) +}) + +test('normalizeLibraries migrates a legacy single libraryRoot', () => { + const legacy = { libraryRoot: '/srv/share/Lib', theme: 'grey' } + const st = normalizeLibraries(legacy, DEFAULT_ROOT) + assert.equal(st.libraries.length, 1) + assert.equal(st.libraries[0].root, '/srv/share/Lib') + assert.equal(st.libraries[0].name, 'Lib') + assert.equal(activeLibrary(st).root, '/srv/share/Lib') + assert.equal(st.changed, true) +}) + +test('normalizeLibraries keeps a valid multi-library shape unchanged', () => { + const good = { + libraries: [{ id: 'a', name: 'One', root: '/x' }, { id: 'b', name: 'Two', root: '/y' }], + activeLibraryId: 'b' + } + const st = normalizeLibraries(good, DEFAULT_ROOT) + assert.equal(st.changed, false) + assert.equal(st.activeLibraryId, 'b') + assert.deepEqual(st.libraries.map((l) => l.root), ['/x', '/y']) +}) + +test('normalizeLibraries backfills missing id/name and drops duplicate roots', () => { + const messy = { + libraries: [ + { root: '/x' }, // no id, no name + { id: 'dup', name: 'X2', root: '/x' } // duplicate root -> dropped + ], + activeLibraryId: 'nope' // unknown -> reset to first + } + const st = normalizeLibraries(messy, DEFAULT_ROOT) + assert.equal(st.libraries.length, 1) + assert.equal(st.libraries[0].root, '/x') + assert.equal(st.libraries[0].name, 'x') + assert.ok(st.libraries[0].id) + assert.equal(st.activeLibraryId, st.libraries[0].id) + assert.equal(st.changed, true) +}) + +test('addLibrary appends and activates; a repeat root only switches', () => { + let st = normalizeLibraries({ libraryRoot: '/a' }, DEFAULT_ROOT) + const firstId = st.activeLibraryId + st = addLibrary(st, { root: '/b', name: 'Beta', id: 'idB' }) + assert.equal(st.libraries.length, 2) + assert.equal(st.activeLibraryId, 'idB') + assert.equal(st.libraries[1].name, 'Beta') + + // adding /a again makes no duplicate — just re-activates the existing one + st = addLibrary(st, { root: '/a' }) + assert.equal(st.libraries.length, 2) + assert.equal(st.activeLibraryId, firstId) +}) + +test('renameLibrary changes only the label, and blank names are ignored', () => { + let st = { libraries: [{ id: 'a', name: 'Old', root: '/a' }], activeLibraryId: 'a' } + st = renameLibrary(st, 'a', ' New Name ') + assert.equal(st.libraries[0].name, 'New Name') + assert.equal(st.libraries[0].root, '/a', 'root (folder) untouched') + st = renameLibrary(st, 'a', ' ') + assert.equal(st.libraries[0].name, 'New Name', 'blank rename is a no-op') +}) + +test('removeLibrary unlinks, reassigns active, and never removes the last one', () => { + let st = { + libraries: [{ id: 'a', name: 'A', root: '/a' }, { id: 'b', name: 'B', root: '/b' }], + activeLibraryId: 'a' + } + st = removeLibrary(st, 'a') // removing the active one + assert.equal(st.libraries.length, 1) + assert.equal(st.activeLibraryId, 'b', 'active falls to a survivor') + + const before = st + st = removeLibrary(st, 'b') // last library can't be removed + assert.equal(st.libraries.length, 1) + assert.deepEqual(st, before) +}) + +test('setActive switches only to known ids; setLibraryRoot repoints a library', () => { + let st = { + libraries: [{ id: 'a', name: 'A', root: '/a' }, { id: 'b', name: 'B', root: '/b' }], + activeLibraryId: 'a' + } + st = setActive(st, 'b') + assert.equal(st.activeLibraryId, 'b') + st = setActive(st, 'ghost') + assert.equal(st.activeLibraryId, 'b', 'unknown id ignored') + + st = setLibraryRoot(st, '/b2') // repoints the active library (b) + assert.equal(activeLibrary(st).root, '/b2') + assert.equal(st.libraries[0].root, '/a', 'other library untouched') +}) diff --git a/test/library.test.mjs b/test/library.test.mjs index 7afe6e1..2543b8d 100644 --- a/test/library.test.mjs +++ b/test/library.test.mjs @@ -67,6 +67,42 @@ test('scanLibrary discovers modules, folders, tags, and creates .shopdeck', asyn await fs.access(join(root, '.shopdeck', 'versions', 'a_1', 'v1', 'index.html')) }) +test('scanLibrary finds a module whose dirent is a symlink (OneDrive placeholder case)', async (t) => { + // OneDrive "Files On-Demand" placeholders are reparse points that readdir + // reports as symlinks (isFile() === false). A symlink reproduces that exact + // dirent classification, which used to make the whole library scan empty. + const root = await tmpRoot() + const ext = await fs.mkdtemp(join(tmpdir(), 'sd-ext-')) + const real = join(ext, 'real.html') + await fs.writeFile(real, moduleHtml(baseMan({ id: 'od_1' })), 'utf8') + const dir = join(root, 'Sync') + await fs.mkdir(dir, { recursive: true }) + try { await fs.symlink(real, join(dir, 'od.html')) } + catch { t.skip('symlinks not permitted in this environment'); return } + + const d = await scanLibrary(root) + assert.ok(d.modules.find((m) => m.id === 'od_1'), 'symlinked module is discovered') + assert.ok(d.htmlCount >= 1) +}) + +test('scanLibrary infers generic modules for manifest-less HTML and reports htmlCount', async () => { + const root = await tmpRoot() + await fs.mkdir(join(root, 'X'), { recursive: true }) + await fs.writeFile(join(root, 'X', 'notes.html'), '<!doctype html><html><head><title>Notesno manifest', 'utf8') + await fs.writeFile(join(root, 'X', 'plain.html'), 'bare', 'utf8') + + const d = await scanLibrary(root) + assert.equal(d.htmlCount, 2) + const notes = d.modules.find((m) => m.id === 'notes') + assert.ok(notes, 'titled manifest-less file is inferred') + assert.equal(notes.type, 'document') + assert.equal(notes.inferred, true) + assert.equal(notes.title, 'Notes') + const plain = d.modules.find((m) => m.id === 'plain') + assert.ok(plain, 'untitled file is inferred from its filename') + assert.equal(plain.title, 'plain') +}) + test('a higher version is snapshotted and kept as history', async () => { const root = await tmpRoot() await writeModule(root, 'M', 'v1.html', baseMan({ id: 'p_1', version: 1, updated: '2026-01-01' })) @@ -107,22 +143,51 @@ test('setOverride changes title/tags without modifying the module file', async ( assert.equal(m2.edited, false) }) -test('importFiles copies valid modules and rejects ones without a manifest', async () => { +test('importFiles copies any self-contained HTML; a missing manifest is inferred on scan', async () => { const root = await tmpRoot() const src = await fs.mkdtemp(join(tmpdir(), 'sd-src-')) const good = join(src, 'good.html') - const bad = join(src, 'bad.html') + const bare = join(src, 'notes.html') await fs.writeFile(good, moduleHtml(baseMan({ id: 'imp_1' })), 'utf8') - await fs.writeFile(bad, 'no manifest', 'utf8') + await fs.writeFile(bare, 'Bare Notesx', 'utf8') - const res = await importFiles(root, 'Imported', [good, bad]) + const res = await importFiles(root, 'Imported', [good, bare]) assert.equal(res.find((r) => r.file === 'good.html').ok, true) - assert.equal(res.find((r) => r.file === 'bad.html').ok, false) + assert.equal(res.find((r) => r.file === 'notes.html').ok, true) const d = await scanLibrary(root) const m = d.modules.find((x) => x.id === 'imp_1') - assert.ok(m, 'imported module is listed') + assert.ok(m, 'manifest module is listed') assert.equal(m.folder, 'Imported') + assert.equal(m.inferred, false) + + const bareMod = d.modules.find((x) => x.id === 'notes') + assert.ok(bareMod, 'manifest-less module is listed via inference') + assert.equal(bareMod.inferred, true) + assert.equal(bareMod.title, 'Bare Notes') +}) + +test('scanLibrary infers a tool-swap-timeline manifest from an embedded DATA object', async () => { + const root = await tmpRoot() + await fs.mkdir(join(root, 'T'), { recursive: true }) + const DATA = { + part: '40-1318-01', + lanes: [{ lane: 0, group: '1 · Preform', tool: 'A1', desc: 'TOP' }, { lane: 1, group: '4 · Pierce / Strip', tool: 'A2', desc: 'PP' }], + events: [{ lane: 0, tool: 'A1', removed: '2020-01-01', install: '2019-06-01' }, { lane: 1, tool: 'A2', removed: '2021-03-03', install: null }] + } + const html = `Tool Swap Timeline — 40-1318-01\n` + await fs.writeFile(join(root, 'T', 'tool-swap-timeline_40-1318-01.html'), html, 'utf8') + + const d = await scanLibrary(root) + const m = d.modules.find((x) => x.id === 'tool-swap-timeline_40-1318-01') + assert.ok(m, 'inferred timeline is listed') + assert.equal(m.type, 'tool-swap-timeline') + assert.equal(m.inferred, true) + assert.equal(m.fields.part, '40-1318-01') + assert.equal(m.fields.events, 2) + assert.deepEqual(m.tags, ['preform', 'pierce-strip']) + assert.equal(m.fields.dateRange.from, '2019-06-01') + assert.equal(m.fields.dateRange.to, '2021-03-03') }) test('importTree copies a folder preserving its subfolder structure', async () => {