From b7779591499729b324c5e3136c53e77394999dd1 Mon Sep 17 00:00:00 2001 From: the3dcoder Date: Fri, 2 Oct 2026 01:49:07 -0400 Subject: [PATCH 1/3] Fix name searches and desktop workflow reliability in 0.4.1 --- CHANGELOG.md | 13 ++ README.md | 8 +- app/catalog.cjs | 107 +++++++-- app/costs.cjs | 112 +++++---- app/gcode.cjs | 40 ++++ app/main.cjs | 29 ++- app/preload.cjs | 1 + app/scanner.cjs | 8 +- docs/ROADMAP.md | 128 +++++++++++ docs/VERIFICATION.md | 17 +- package-lock.json | 4 +- package.json | 8 +- scripts/test-packaged.cjs | 2 + src/CostPanel.jsx | 67 +++++- src/LibraryTools.jsx | 3 + src/main.jsx | 115 +++++++++- src/styles.css | 66 ++++++ tests/app.test.cjs | 12 +- tests/enhancements.test.cjs | 2 +- tests/library-app.test.cjs | 16 ++ tests/reliability-app.test.cjs | 403 +++++++++++++++++++++++++++++++++ tests/reliability.test.cjs | 213 +++++++++++++++++ 22 files changed, 1288 insertions(+), 86 deletions(-) create mode 100644 app/gcode.cjs create mode 100644 docs/ROADMAP.md create mode 100644 tests/reliability-app.test.cjs create mode 100644 tests/reliability.test.cjs diff --git a/CHANGELOG.md b/CHANGELOG.md index edbb1dc..7965d47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 0.4.1 + +- Search filenames by default, with explicit path and all-details scopes retained in saved searches, selection, and CSV exports. +- List directly matching folders and ZIP/7z/RAR names separately, with pagination and open/reveal controls. Archive contents remain opt-in. +- Match accented letter case in filenames and location names, while treating wildcard characters literally. +- Prevent fast duplicate checks and scans from overwriting completed backend progress with a running state. +- Use the same bounded, fingerprint-checked G-code import for selected files and the file picker, including large ordinary files. +- Retain imported material units and density; clear stale conversions for review and preserve manual quantities. Review legacy imports that lack conversion history. +- Preserve search locations and the previous inventory after a failed scan, and record the failed attempt separately. +- Include the complete scan/transfer/consent workflow in packaged desktop checks. + +Rescan after upgrading to index folder and archive names. Source models remain unchanged unless a reviewed transfer or new-copy export is confirmed. + ## 0.4.0 - Compare matching STL, OBJ, and PLY triangle geometry across names and formats, separately from exact file duplicates. diff --git a/README.md b/README.md index b4beb75..d4d2cb6 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Search folders or drives, browse a cached gallery, keep multi-part projects toge ## Run it -Use Windows 10/11 x64. Build the release ZIP with the commands below, or download a published build when one is available under [Releases](https://github.com/the3dcoder/ModelScout/releases). Extract the complete ZIP and run **Model-Scout-0.4.0.exe**. No installer is required. The current build is unsigned. +Use Windows 10/11 x64. Build the release ZIP with the commands below, or download a published build when one is available under [Releases](https://github.com/the3dcoder/ModelScout/releases). Extract the complete ZIP and run **Model-Scout-0.4.1.exe**. No installer is required. The current build is unsigned. The portable executable stores its catalog, thumbnails, profiles, and transfer logs in `%APPDATA%\Model Scout`. Back up that folder while the app is closed if you want to preserve your library metadata. Source models stay in their original folders until you confirm a move. @@ -20,7 +20,7 @@ npm start ## Organize a library 1. Add search folders or drives. ZIP scanning is optional; 7z and RAR additionally require a local [7-Zip installation](https://www.7-zip.org/). -2. Search names, paths, tags, notes, and categories. Combine tags with all/any/exclude filters, mark favorites, and save searches. +2. Search filenames by default, without inheriting matches from parent folders or archives. Matching folder and ZIP/7z/RAR names appear separately; their contents are not included automatically. Choose **Paths** or **All file details** to search broader fields explicitly. Combine tags with all/any/exclude filters, mark favorites, and save searches. 3. Switch between the list and gallery. Thumbnails are generated for visible cards and cached; **Prepare this page** is an explicit background action. 4. Select related files and choose **Collection**. Keep creator, source link, license notes, and project notes with the set. One file can belong to several collections. Membership survives rescans; unavailable files are counted separately. 5. Run **Check duplicates**. Exact matches use SHA-256 after size filtering. Choose a keeper and review moving extras to a separate folder. @@ -29,6 +29,8 @@ npm start Category organization retains the original search-root folder, subfolders, and filenames. Collections are virtual groups, so adding a file to one does not move it. After an in-app move, scan the destination to see that file again with its collection, tags, favorites, notes, and saved cost estimate. The estimate retains its original fingerprint and may need review. +Search is insensitive to letter case, including accented names. Quoted phrases and multiple words are supported; `%` and `_` are literal characters. Folder/archive matches have their own pagination and open/reveal controls. File filters and bulk selection/export apply to model results. Archive names are indexed without unpacking them; searching inside archives remains opt-in. Rescan once after upgrading from 0.4.0 to populate folder/archive names. Existing saved searches use names by default; save them with a broader scope if needed. + Geometry comparison matches the same triangles at a fixed precision of 0.00001 model units. It ignores placement, vertex/triangle order, winding, normals, colors, and materials, while preserving scale and orientation. Units and intended use are not compared. Rotation, different triangulation, or precision differences may miss matches. These are review candidates, distinct from byte-identical duplicates; no file is automatically selected or removed. Processing is local and bounded to 64 MiB, 500,000 triangles, and 45 seconds per file. Completed results and failures are cached for unchanged files; cancel and resume, or explicitly retry failures. Groups are paged, with up to 80 files shown in each group. **Include declared OBJ/glTF assets** adds supported local material, texture, and buffer references to the review plan. Shared support files are copied once per destination and their originals remain in place, even during a model move. Missing or unsafe references block the plan. Other project formats, extension-specific glTF references, and archive-contained projects may need manual review. External assets are preserved during transfer; previews do not fetch those textures or buffers. @@ -39,6 +41,8 @@ Use filament or resin profiles with your own material prices, printer power and Enter whole-job time and material from your slicer, or import supported text G-code comments. Export/import Model Scout profiles as versioned JSON. Estimates retain their inputs and file version. Omitted rates are flagged; example/default values are not market prices. Vendor printer profiles and proprietary resin job formats are not imported automatically. A mesh volume alone cannot establish realistic printing time or material use. +Ordinary G-code imports read bounded header/footer windows, consistently from the library and file picker, including files larger than the preview limit. Archive members retain the extraction limit. Imported material conversions retain source units and density. Changing the density or material unit clears a converted quantity for review; manually entered quantities stay unchanged when density changes. Older imported estimates without conversion history require a reviewed quantity before saving again. + ## Model inspection and editing STL tools report bounds, triangles, degenerate/duplicate faces, boundary and non-manifold edges, components, winding, and signed volume. Uniform scale, XYZ rotation, and removal of zero-area or duplicate triangles produce a preview and a **new STL file** with a verification receipt. diff --git a/app/catalog.cjs b/app/catalog.cjs index 13ab0f3..7cd079d 100644 --- a/app/catalog.cjs +++ b/app/catalog.cjs @@ -1,5 +1,17 @@ const { DatabaseSync } = require("node:sqlite"); const crypto = require("node:crypto"); +const fold = (text) => + String(text || "") + .normalize("NFC") + .toLowerCase(); +const termsOf = (search) => + ( + String(search || "") + .slice(0, 500) + .match(/"[^"]+"|\S+/g) || [] + ) + .slice(0, 8) + .map((raw) => fold(raw.replace(/^"|"$/g, ""))); const versionOf = (r) => `${r.size}:${r.mtime}:${r.ctime ?? ""}`; const tagsOf = (value) => [ @@ -18,22 +30,29 @@ const present = (r) => class Catalog { constructor(file) { this.db = new DatabaseSync(file); + this.db.function( + "scout_contains", + { deterministic: true }, + (value, term) => +fold(value).includes(term), + ); this.db.exec("PRAGMA busy_timeout=5000; PRAGMA journal_mode=WAL"); const exists = this.db .prepare("SELECT name FROM sqlite_master WHERE name='files'") .get(); if ( exists && - this.db.prepare("PRAGMA user_version").get().user_version < 4 && + this.db.prepare("PRAGMA user_version").get().user_version < 5 && file !== ":memory:" ) { - this.backupPath = `${file}.before-v4-${Date.now()}.bak`; + this.backupPath = `${file}.before-v5-${Date.now()}.bak`; this.db.prepare("VACUUM INTO ?").run(this.backupPath); } this.db .exec(`CREATE TABLE IF NOT EXISTS files(id TEXT PRIMARY KEY,path TEXT,member TEXT,root TEXT,name TEXT,ext TEXT,size REAL,mtime REAL,ctime REAL,family TEXT,category TEXT,evidence TEXT,confidence TEXT,preview INTEGER,hash TEXT,analysis TEXT); CREATE TABLE IF NOT EXISTS notes(id TEXT PRIMARY KEY,category TEXT,notes TEXT,tags TEXT NOT NULL DEFAULT '[]',favorite INTEGER NOT NULL DEFAULT 0); CREATE TABLE IF NOT EXISTS settings(key TEXT PRIMARY KEY,value TEXT); + CREATE TABLE IF NOT EXISTS locations(path TEXT PRIMARY KEY,name TEXT NOT NULL,kind TEXT NOT NULL,root TEXT NOT NULL); + CREATE INDEX IF NOT EXISTS locations_name ON locations(name COLLATE NOCASE,path); CREATE TABLE IF NOT EXISTS asset_cache(id TEXT PRIMARY KEY,size REAL,mtime REAL,ctime REAL,hash TEXT,analysis TEXT,thumbnail BLOB,thumbError TEXT); CREATE TABLE IF NOT EXISTS collections(id TEXT PRIMARY KEY,name TEXT NOT NULL COLLATE NOCASE UNIQUE,creator TEXT NOT NULL DEFAULT '',license TEXT NOT NULL DEFAULT '',url TEXT NOT NULL DEFAULT '',notes TEXT NOT NULL DEFAULT ''); CREATE TABLE IF NOT EXISTS collection_members(collection_id TEXT NOT NULL,file_id TEXT NOT NULL,PRIMARY KEY(collection_id,file_id)); @@ -69,7 +88,7 @@ class Catalog { }); } this.db.exec( - "INSERT OR IGNORE INTO asset_cache(id,size,mtime,ctime,hash,analysis) SELECT id,size,mtime,ctime,hash,analysis FROM files; PRAGMA user_version=4", + "INSERT OR IGNORE INTO asset_cache(id,size,mtime,ctime,hash,analysis) SELECT id,size,mtime,ctime,hash,analysis FROM files; PRAGMA user_version=5", ); this.insert = this.db.prepare( "INSERT OR IGNORE INTO files(id,path,member,root,name,ext,size,mtime,ctime,family,category,evidence,confidence,preview,hash,analysis) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", @@ -94,7 +113,7 @@ class Catalog { beginScan() { this.flush(); this.db.exec( - "BEGIN; DROP TABLE IF EXISTS scan_backup; CREATE TABLE scan_backup AS SELECT * FROM files; DELETE FROM files; COMMIT", + "BEGIN; DROP TABLE IF EXISTS scan_backup; CREATE TABLE scan_backup AS SELECT * FROM files; DROP TABLE IF EXISTS locations_backup; CREATE TABLE locations_backup AS SELECT * FROM locations; DELETE FROM files; DELETE FROM locations; COMMIT", ); } finishScan(restore = false) { @@ -110,6 +129,19 @@ class Catalog { this.db.exec( "DELETE FROM files; INSERT INTO files SELECT * FROM scan_backup", ); + if ( + this.db + .prepare( + "SELECT name FROM sqlite_master WHERE name='locations_backup'", + ) + .get() + ) { + if (restore) + this.db.exec( + "DELETE FROM locations; INSERT INTO locations SELECT * FROM locations_backup", + ); + this.db.exec("DROP TABLE locations_backup"); + } this.db.exec("DROP TABLE scan_backup; COMMIT"); return true; } catch (e) { @@ -176,18 +208,28 @@ class Catalog { tags = [], excludeTags = [], tagMode = "all", + searchScope = "name", } = options; - const terms = - String(search) - .slice(0, 500) - .match(/"[^"]+"|\S+/g) || []; - for (const raw of terms.slice(0, 8)) { - const term = - "%" + raw.replace(/^"|"$/g, "").replace(/[\\%_]/g, "\\$&") + "%"; + for (const term of termsOf(search)) { + const fields = + searchScope === "all" + ? [ + "f.name", + "f.path", + "f.member", + "COALESCE(n.notes,'')", + "COALESCE(n.tags,'')", + "COALESCE(n.category,f.category)", + ] + : searchScope === "path" + ? ["f.path", "f.member"] + : ["f.name"]; clauses.push( - "(f.name LIKE ? ESCAPE '\\' OR f.path LIKE ? ESCAPE '\\' OR f.member LIKE ? ESCAPE '\\' OR COALESCE(n.notes,'') LIKE ? ESCAPE '\\' OR COALESCE(n.tags,'') LIKE ? ESCAPE '\\' OR COALESCE(n.category,f.category) LIKE ? ESCAPE '\\')", + "(" + + fields.map((field) => `scout_contains(${field}, ?)`).join(" OR ") + + ")", ); - params.push(term, term, term, term, term, term); + params.push(...fields.map(() => term)); } if (ext) { clauses.push("f.ext=?"); @@ -271,7 +313,40 @@ class Catalog { ) .all(...params, pageSize, page * pageSize) .map(present); - return { rows, count, page, pageSize }; + return { rows, count, page, pageSize, locations: this.locations(options) }; + } + addLocation(file, kind, root) { + this.db + .prepare("INSERT OR IGNORE INTO locations VALUES(?,?,?,?)") + .run(file, require("node:path").basename(file) || file, kind, root); + } + location(file) { + return this.db + .prepare("SELECT * FROM locations WHERE path=?") + .get(String(file)); + } + locations(options = {}) { + const terms = termsOf(options.search); + if (!terms.length) return { rows: [], count: 0, page: 0, pageSize: 20 }; + const field = options.searchScope === "path" ? "path" : "name"; + const where = + " WHERE " + terms.map(() => `scout_contains(${field}, ?)`).join(" AND "); + const params = terms; + const count = this.db + .prepare("SELECT COUNT(*) AS count FROM locations" + where) + .get(...params).count; + const page = Math.min( + Math.max(0, Math.floor(Number(options.locationPage) || 0)), + Math.max(0, Math.ceil(count / 20) - 1), + ); + const rows = this.db + .prepare( + "SELECT * FROM locations" + + where + + " ORDER BY name COLLATE NOCASE,path LIMIT 20 OFFSET ?", + ) + .all(...params, page * 20); + return { rows, count, page, pageSize: 20 }; } stats() { return { @@ -460,6 +535,7 @@ class Catalog { const query = {}; for (const k of [ "search", + "searchScope", "ext", "category", "kind", @@ -470,6 +546,9 @@ class Catalog { ]) query[k] = String(options?.[k] || "").slice(0, 500); query.tags = tagsOf(options?.tags); + query.searchScope = ["name", "path", "all"].includes(query.searchScope) + ? query.searchScope + : "name"; query.excludeTags = tagsOf(options?.excludeTags); saved.push({ name, query }); } diff --git a/app/costs.cjs b/app/costs.cjs index 68883c2..cdd6c92 100644 --- a/app/costs.cjs +++ b/app/costs.cjs @@ -1,7 +1,7 @@ const fs = require("node:fs/promises"); const path = require("node:path"); const { dialog } = require("electron"); -const { readRow } = require("./files.cjs"); +const { readEstimatesText } = require("./gcode.cjs"); module.exports = function registerCosts({ handle, catalog, win, row }) { // This pure calculation module is also used by the live calculator in the renderer. const logic = () => import("../src/costing.mjs"); @@ -45,7 +45,7 @@ module.exports = function registerCosts({ handle, catalog, win, row }) { const r = row(id); if (!["gcode", "g", "gco"].includes(r.ext)) throw new Error("Select a text G-code file."); - text = (await readRow(r)).toString("utf8"); + text = await readEstimatesText(r); source = r.name; } else { const result = await dialog.showOpenDialog(win, { @@ -55,48 +55,80 @@ module.exports = function registerCosts({ handle, catalog, win, row }) { }); if (result.canceled) return null; const file = result.filePaths[0], - stat = await fs.stat(file), - h = await fs.open(file, "r"); - try { - const length = Math.min(stat.size, 256 * 1024), - head = Buffer.alloc(length), - tail = Buffer.alloc(length); - const first = await h.read(head, 0, length, 0); - const last = - stat.size > length - ? await h.read(tail, 0, length, stat.size - length) - : { bytesRead: 0 }; - text = - head.subarray(0, first.bytesRead).toString("utf8") + - "\n" + - tail.subarray(0, last.bytesRead).toString("utf8"); - source = path.basename(file); - } finally { - await h.close(); - } + stat = await fs.lstat(file); + if (![".gcode", ".g", ".gco"].includes(path.extname(file).toLowerCase())) + throw new Error("Select a text G-code file."); + text = await readEstimatesText({ + path: file, + member: "", + size: stat.size, + mtime: stat.mtimeMs, + ctime: stat.ctimeMs, + }); + source = path.basename(file); } const { parseGcodeEstimates } = await logic(); return { ...parseGcodeEstimates(text), source }; }); - handle("saveCostEstimate", async (id, profile, inputs, source) => { - const r = row(id); - const { estimateCost, validateProfile } = await logic(); - const result = estimateCost(profile, inputs); - const value = { - profile: validateProfile(profile), - inputs: { - hours: Number(inputs.hours), - amount: Number(inputs.amount), - parts: Number(inputs.parts), - }, - source: String(source).slice(0, 300), - result, - date: new Date().toISOString(), - fileVersion: r.version, - }; - catalog.setting("cost:" + id, value); - return value; - }); + handle( + "saveCostEstimate", + async (id, profile, inputs, source, materialSource) => { + const r = row(id); + const { estimateCost, validateProfile } = await logic(); + const result = estimateCost(profile, inputs); + let provenance = null; + if (materialSource) { + const { sourceUnit, unit } = materialSource; + const sourceAmount = Number(materialSource.sourceAmount), + density = Number(materialSource.density); + const converted = sourceUnit !== unit; + if ( + !["g", "ml"].includes(sourceUnit) || + unit !== profile.materialUnit || + !Number.isFinite(sourceAmount) || + sourceAmount < 0 || + (converted && + (!Number.isFinite(density) || + density <= 0 || + density !== Number(profile.density))) + ) + throw new Error( + "Review the imported material quantity and density before saving.", + ); + const expected = !converted + ? sourceAmount + : unit === "g" + ? sourceAmount * density + : sourceAmount / density; + if (Math.abs(Number(inputs.amount) - expected) > 0.000051) + throw new Error( + "Imported material quantity changed. Enter it manually or reimport.", + ); + provenance = { + source: String(materialSource.source).slice(0, 300), + sourceUnit, + sourceAmount, + unit, + density: converted ? density : null, + }; + } + const value = { + profile: validateProfile(profile), + inputs: { + hours: Number(inputs.hours), + amount: Number(inputs.amount), + parts: Number(inputs.parts), + }, + source: String(source).slice(0, 300), + materialSource: provenance, + result, + date: new Date().toISOString(), + fileVersion: r.version, + }; + catalog.setting("cost:" + id, value); + return value; + }, + ); handle("costEstimate", (id) => { row(id); return catalog.setting("cost:" + id); diff --git a/app/gcode.cjs b/app/gcode.cjs new file mode 100644 index 0000000..9249543 --- /dev/null +++ b/app/gcode.cjs @@ -0,0 +1,40 @@ +const fs = require("node:fs/promises"); +const { readRow, unchanged } = require("./files.cjs"); +const WINDOW = 256 * 1024; +const same = (a, b) => + a.size === b.size && + a.mtimeMs === b.mtimeMs && + a.ctimeMs === b.ctimeMs && + a.ino === b.ino && + a.dev === b.dev; +async function readEstimatesText(row) { + if (row.member) return (await readRow(row)).toString("utf8"); + const before = await unchanged(row); + const handle = await fs.open(row.path, "r"); + try { + const stat = await handle.stat(); + if (!same(before, stat)) + throw new Error("Source changed since scanning. Rescan before using it."); + const headSize = Math.min(stat.size, WINDOW); + const head = Buffer.alloc(headSize); + await handle.read(head, 0, headSize, 0); + const tailStart = Math.max(headSize, stat.size - WINDOW); + const tail = Buffer.alloc(stat.size - tailStart); + if (tail.length) await handle.read(tail, 0, tail.length, tailStart); + const after = await handle.stat(); + if (!same(stat, after) || !same(after, await unchanged(row))) + throw new Error("Source changed while reading estimates. Rescan."); + if (tailStart > headSize) { + // Discard cut lines so partial comments cannot masquerade as estimates. + return ( + head.toString("utf8").replace(/[^\n]*$/, "") + + "\n" + + tail.toString("utf8").replace(/^[^\n]*\n?/, "") + ); + } + return Buffer.concat([head, tail]).toString("utf8"); + } finally { + await handle.close(); + } +} +module.exports = { readEstimatesText }; diff --git a/app/main.cjs b/app/main.cjs index 6f12c85..045b264 100644 --- a/app/main.cjs +++ b/app/main.cjs @@ -180,12 +180,22 @@ else ) throw new Error("Choose up to 50 search roots."); job = new AbortController(); + const previousScan = catalog.setting("lastScan"); thumbnails.stop(); plan = null; scan(options, catalog, job.signal, progress) .catch((e) => { catalog.finishScan(true); - const state = { running: false, phase: "failed", message: e.message }; + const state = { + ...previousScan, + roots: previousScan?.roots || options.roots, + attemptedRoots: options.roots, + running: false, + phase: "failed", + message: e.message, + errors: [{ path: options.roots.join(", "), message: e.message }], + errorCount: 1, + }; catalog.setting("lastScan", state); progress(state); }) @@ -199,6 +209,23 @@ else return true; }); handle("query", (options) => catalog.query(options)); + handle("revealLocation", async (file) => { + const location = catalog.location(file); + if (!location) + throw new Error("Location is no longer in the catalog. Rescan."); + const stat = await fsp.lstat(location.path); + if ( + stat.isSymbolicLink() || + (location.kind === "folder" ? !stat.isDirectory() : !stat.isFile()) + ) + throw new Error("Location changed. Rescan before opening it."); + if (location.kind === "archive") shell.showItemInFolder(location.path); + else { + const error = await shell.openPath(location.path); + if (error) throw new Error(error); + } + return true; + }); handle("stats", () => catalog.stats()); handle("notices", async () => ({ summary: await fsp.readFile( diff --git a/app/preload.cjs b/app/preload.cjs index 0c33e6e..6aa0b63 100644 --- a/app/preload.cjs +++ b/app/preload.cjs @@ -40,6 +40,7 @@ const names = [ "exportCostProfile", "importGcodeCost", "saveCostEstimate", + "revealLocation", "costEstimate", "meshAnalyze", "meshPrepare", diff --git a/app/scanner.cjs b/app/scanner.cjs index 82960a2..11b1808 100644 --- a/app/scanner.cjs +++ b/app/scanner.cjs @@ -47,6 +47,7 @@ async function scan( running: true, }; catalog.setting("lastScan", state); + for (const root of scope) catalog.addLocation(root, "folder", root); const queue = scope.map((root) => ({ dir: root, root })); const packs = []; let last = 0; @@ -73,6 +74,7 @@ async function scan( continue; } if (entry.isDirectory()) { + catalog.addLocation(file, "folder", root); queue.push({ dir: file, root }); continue; } @@ -86,8 +88,10 @@ async function scan( } catch (e) { error(file, e); } - } else if (archives && ["zip", "7z", "rar"].includes(ext)) - packs.push({ file, root }); + } else if (["zip", "7z", "rar"].includes(ext)) { + catalog.addLocation(file, "archive", root); + if (archives) packs.push({ file, root }); + } emit(); } } catch (e) { diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000..5a1bb0b --- /dev/null +++ b/docs/ROADMAP.md @@ -0,0 +1,128 @@ +# Model Scout backlog + +Assessment date: 2026-10-02. Reviewed against merged 0.4.0, commit +`64c096417f55d8da32a9d14d2664793e3fbfe999`. + +GitHub had no open issues or pull requests at assessment time. That does not mean +the product backlog is empty. The items below distinguish reproduced defects +from proposed features. The reliability fixes below are implemented in 0.4.1. + +## 0.4.1 resolution + +Filename search is now the default. Direct folder and archive name matches appear +separately, without inherited contents, and have their own pagination. Broader +path and metadata searches are explicit choices. Saved searches, bulk selection, +and CSV exports retain that scope. Accented letter case is also matched, addressing +[SQLite's ASCII-only default LIKE behavior](https://www.sqlite.org/lang_expr.html#like). + +All four reproduced defects below have regression coverage and implemented fixes: +authoritative worker progress, bounded G-code imports at both entry points, +saved material conversion provenance with review after assumption changes, and +preserved search locations after failed scans. Older imported estimates with no +conversion history require a reviewed quantity before saving again. Manually +entered material quantities remain unchanged after density edits. + +## Original reliability findings + +### 1. Duplicate-check completion can leave the interface stuck + +**Confirmed, high priority.** With two files of different sizes, the duplicate +worker reports a complete check with zero candidates. Depending on IPC timing, +the renderer then replaces that completion with a running state. The UI shows +`0 / 0 files checked`, disables actions, and Cancel cannot clear it because the +backend job has already finished. + +Root cause: the Check duplicates action in `src/main.jsx` sets running progress +after awaiting `api.duplicates()`, overwriting an earlier completion event. +`app/files.cjs` can complete a zero-candidate check before that invocation returns. + +Proposed correction: make backend progress authoritative and keep invocation +busy state separate. Check the same ordering pattern in scan startup. Verify +zero candidates, ordinary hashing, fast completion, failure, and cancellation +through the desktop interface without adding timing delays as a workaround. + +### 2. Selected G-code imports fail above the preview size limit + +**Confirmed.** The same generated 128 MiB-plus-one-byte G-code file imports +2 hours and 25 grams through the file picker, but selecting it from the library +returns the 128 MiB preview-limit error. + +Root cause: `app/costs.cjs` uses `readRow()` for a selected file but a bounded +header/footer read for the picker. Cost metadata extraction should have the same +read policy in both paths while retaining fingerprint checks. Archive members +need a separately bounded policy; they must not bypass extraction limits. + +Acceptance: both ordinary-file entry points return the same supported estimates +for small and large files; changed sources and unsupported content remain +explicit errors. Reading a large ordinary file must not load its full contents. + +### 3. Imported material conversions become stale after density changes + +**Confirmed.** Importing 100 grams into a resin profile measured in milliliters +at 1 g/ml gives 100 ml. Changing density to 2 g/ml leaves 100 ml in the job input, +with the original import source still displayed. The corresponding converted +quantity would be 50 ml. + +Root cause: `src/CostPanel.jsx` converts the quantity once during import and +retains only the converted input. Changing density does not invalidate it or +preserve enough provenance to explain the conversion. + +Proposed correction: distinguish direct quantities, imported conversions, and +manual edits. Retain source units and the density used; invalidate an imported +conversion when that assumption changes and prompt for review. Do not silently +alter a manually entered quantity. Verify both grams-to-ml and ml-to-grams, +profile changes, saved estimates, and manual edits. + +### 4. Failed scans discard saved search locations + +**Confirmed.** After a successful scan, scanning an unavailable folder preserves +the previous inventory, but replaces `lastScan` with an error-only object. +Reloading the interface then shows zero search locations. + +Root cause: the scan error handler in `app/main.cjs` discards root configuration; +renderer initialization reads its locations from `lastScan.roots`. + +Proposed correction: preserve the last usable scope when a scan fails and show +the failed attempt separately. Verify missing folders, disconnected roots, +restart/reload, cancellation, and retained inventory/metadata. + +## Feature priorities after the reliability patch + +| Priority | Feature | Value and acceptance boundary | +| --- | --- | --- | +| Next | Catalog backup and reviewed restore | Preserve tags, notes, collections, profiles, estimates, and saved searches. Verify backup integrity, schema compatibility, and recovery in an isolated profile. Model-file backup is a separate operation. | +| Next | Folder exclusions and reusable scan scopes | Exclude output/review folders and avoid repeatedly typing large-library locations. Show the complete scope before scanning; archive scanning remains explicit opt-in. | +| Next | Navigate all members of large geometry groups | Groups currently expose only the first 80 files. Add member pagination/search while preserving explicit candidate selection and cross-page keeper checks. | +| Later | Estimated-versus-actual print history | Record real time, material, labor, outcomes, and failures for filament and resin jobs. Keep estimate snapshots and units; do not invent calibration data. | +| Needs slicer choice | Direct slicer profile/job imports | Select the owner's actual filament and resin tools first. Import supported fields with provenance, report omitted costs, and avoid treating a slicer configuration as a complete costing profile. | +| Later | Reconcile files moved outside the app | Review exact-content matches before reattaching metadata to new paths. Show ambiguity when several copies match; never remap or delete files automatically. | +| Investigate | Rotated/retriangulated geometry similarity | Evaluate known positive and negative examples, false matches, missed matches, and runtime cost before using it for cleanup candidates. Current triangle matching remains separately labeled. | +| Investigate | Advanced repair and more assembly dependencies | Define supported formats and repair operations, preserve originals, and show geometric changes. Self-intersections, wall thickness, and universal repair are not implemented. | +| Distribution | Publish a verified Windows release bundle | GitHub currently has source and build workflows but no published release. Release the complete executable/notices/importer-sources ZIP with its checksum and accurate unsigned status. | + +Automatic deletion, automatic cloud uploads, and symlink consolidation are not +part of the proposed cleanup workflow. Website synchronization remains a later +integration decision requiring selected services and supported APIs. + +## Research and verification + +The original assessment passed all 22 existing tests, formatting, and the +production dependency audit. Separate disposable-fixture probes reproduced the +four defects above despite that passing baseline. The 0.4.1 suite now passes 27 +core/feature tests and all seven source and packaged desktop workflows, including +the new reliability regressions. The production dependency audit reports zero +vulnerabilities. No personal model files or live user catalogs were modified. + +The backup recommendation is supported by a concrete +[Manyfold backup discussion](https://github.com/manyfold3d/manyfold/discussions/3620). +[SQLite's backup documentation](https://www.sqlite.org/backup.html) describes live +database snapshots and `VACUUM INTO`, which the app already uses for migration +backups. A reviewed restore flow still needs its own design and verification. + +[PrusaSlicer's vendor-bundle documentation](https://github.com/prusa3d/PrusaSlicer/wiki/Vendor-bundles-and-updating-process) +describes real configuration bundles; imported settings need deliberate mapping +to cost assumptions. The +[OrcaSlicer costing request](https://github.com/OrcaSlicer/OrcaSlicer/issues/3502) +supports broader job-cost categories, not a claim that estimates or print history +are unique to Model Scout. These are qualitative signals, not a popularity survey. +See [feature research](FEATURE_RESEARCH.md) for earlier evidence and shipped scope. diff --git a/docs/VERIFICATION.md b/docs/VERIFICATION.md index bec6523..a919bbf 100644 --- a/docs/VERIFICATION.md +++ b/docs/VERIFICATION.md @@ -19,9 +19,10 @@ Use Windows x64 and Node.js 24.15 or newer. Tests create disposable files under WASM hashes against pinned upstream revisions and packages sources/notices with the executable. Keep the generated SHA-256 file with the ZIP. -## Local acceptance, 0.4.0 +## Local acceptance, 0.4.1 -The Windows source suite has 22 passing core/feature tests. Desktop fixtures cover +The Windows source suite has 27 passing core/feature tests. Seven source desktop +workflows and the same seven packaged workflows pass. Desktop fixtures cover scan/preview, reviewed duplicate moves, mocked cloud consent, static galleries, failed previews, favorites/tags, saved searches, FDM/resin estimates, profile import/export, mesh analysis/edit/export, collection lifecycle, referenced assets, @@ -33,6 +34,18 @@ saved-estimate relocation. Desktop regression fixtures verify paired previews, geometry keeper protection across 21 groups, and exact-duplicate keeper choices across 51 groups. Source and packaged desktop suites each cover these workflows. +Reliability fixtures verify case-insensitive filename search, including accented +names; separate paged folder/archive matches; explicit path scope; saved scope; +matching bulk selection and CSV output; file filters and pagination; thumbnail +preparation and retries; search focus; zero-candidate duplicate completion; failed +scan recovery after reload; both directions of imported density conversion; saved +and legacy estimate review; preserved manual quantities; and catalog migration +with interrupted-scan recovery. Core tests exercise a generated G-code file above +128 MiB, identical results from both import entry points, stale-source rejection, +and the retained archive-member limit. Folder/reveal shell calls, native file +dialogs, and paid cloud requests are intercepted in tests; source file operations, +parsers, cost calculations, previews, SQLite persistence, and transfers are real. + The portable build is unsigned. Source tests and local packaged tests do not establish GitHub-hosted CI, code-signing, or every format/model combination. Cloud requests are mocked in tests; a paid live API request is not part of release checks. diff --git a/package-lock.json b/package-lock.json index 4733a15..b8616d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "model-scout", - "version": "0.4.0", + "version": "0.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "model-scout", - "version": "0.4.0", + "version": "0.4.1", "license": "MIT", "dependencies": { "assimpjs": "^0.0.10", diff --git a/package.json b/package.json index 967f618..b8ae559 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "model-scout", - "version": "0.4.0", + "version": "0.4.1", "private": true, "description": "A local 3D file discovery and organization workbench", "author": "Big Bwain LLC", @@ -9,8 +9,8 @@ "scripts": { "build": "vite build", "start": "npm run build && electron .", - "test": "node --test tests/core.test.cjs tests/enhancements.test.cjs tests/organization.test.cjs tests/geometry.test.cjs", - "test:app": "node tests/app.test.cjs && node tests/library-app.test.cjs && node tests/production-app.test.cjs && node tests/organization-app.test.cjs && node tests/importers-app.test.cjs && node tests/geometry-app.test.cjs", + "test": "node --test tests/core.test.cjs tests/enhancements.test.cjs tests/organization.test.cjs tests/geometry.test.cjs tests/reliability.test.cjs", + "test:app": "node tests/app.test.cjs && node tests/library-app.test.cjs && node tests/production-app.test.cjs && node tests/organization-app.test.cjs && node tests/importers-app.test.cjs && node tests/geometry-app.test.cjs && node tests/reliability-app.test.cjs", "package": "npm run licenses && npm run build && electron-builder --win portable --x64", "format:check": "prettier --check app src tests scripts vite.config.mjs package.json", "test:packaged": "node scripts/test-packaged.cjs", @@ -21,7 +21,7 @@ "appId": "com.bigbwain.modelscout", "productName": "Model Scout", "directories": { - "output": "release/0.4.0" + "output": "release/0.4.1" }, "files": [ "app/**/*", diff --git a/scripts/test-packaged.cjs b/scripts/test-packaged.cjs index abd848a..ab3a84d 100644 --- a/scripts/test-packaged.cjs +++ b/scripts/test-packaged.cjs @@ -1,10 +1,12 @@ const { spawnSync } = require("node:child_process"); for (const file of [ + "app", "library-app", "production-app", "organization-app", "importers-app", "geometry-app", + "reliability-app", ]) { const result = spawnSync(process.execPath, [`tests/${file}.test.cjs`], { stdio: "inherit", diff --git a/src/CostPanel.jsx b/src/CostPanel.jsx index 2e1c2f8..2fbdbdd 100644 --- a/src/CostPanel.jsx +++ b/src/CostPanel.jsx @@ -34,14 +34,26 @@ export function CostPanel({ file, onClose }) { const [profiles, setProfiles] = useState([]), [inputs, setInputs] = useState({ hours: "", amount: "", parts: 1 }), [source, setSource] = useState("Manual slicer values"), + [materialSource, setMaterialSource] = useState(null), [message, setMessage] = useState(""), [busy, setBusy] = useState(false); const upload = useRef(); const chooseProfile = (value) => { setProfile(value); - if (value.materialUnit !== profile.materialUnit) { + const unitChanged = value.materialUnit !== profile.materialUnit; + const densityChanged = + materialSource?.sourceUnit !== materialSource?.unit && + materialSource && + Number(value.density) !== Number(materialSource.density); + if (unitChanged || densityChanged) { setInputs((p) => ({ ...p, amount: "" })); - setSource("Material unit changed; enter slicer quantity again"); + setMaterialSource(null); + setSource( + unitChanged + ? "Material unit changed; enter slicer quantity again" + : "Density changed; reimport or enter a reviewed material quantity", + ); + setMessage("Review the material quantity before saving this estimate."); } }; useEffect(() => { @@ -55,12 +67,19 @@ export function CostPanel({ file, onClose }) { setProfiles(list); if (saved) { setProfile(saved.profile); - setInputs(saved.inputs); + const legacyImport = + /^Imported:/.test(saved.source) && !saved.materialSource; + setInputs( + legacyImport ? { ...saved.inputs, amount: "" } : saved.inputs, + ); setSource(saved.source); + setMaterialSource(saved.materialSource || null); setMessage( - saved.fileVersion !== file.version - ? "This estimate belongs to an older file version. Re-slice and update it." - : "Loaded the last saved estimate for this file.", + legacyImport + ? `Older imported estimate has no material conversion history. Saved quantity was ${saved.inputs.amount} ${saved.profile.materialUnit}; reimport or enter a reviewed quantity.` + : saved.fileVersion !== file.version + ? "This estimate belongs to an older file version. Re-slice and update it." + : "Loaded the last saved estimate for this file.", ); } else if (list.length) setProfile(list.at(-1)); }) @@ -99,7 +118,7 @@ export function CostPanel({ file, onClose }) { min="0" step="any" value={profile[key]} - onChange={(e) => setProfile((p) => ({ ...p, [key]: e.target.value }))} + onChange={(e) => chooseProfile({ ...profile, [key]: e.target.value })} /> ); @@ -117,6 +136,30 @@ export function CostPanel({ file, onClose }) { (values.grams == null ? null : values.grams / Number(profile.density))); + const direct = + profile.materialUnit === "g" ? values.grams : values.milliliters; + const sourceUnit = + direct != null + ? profile.materialUnit + : profile.materialUnit === "g" + ? "ml" + : "g"; + const sourceAmount = + sourceUnit === "g" ? values.grams : values.milliliters; + setMaterialSource( + amount == null + ? null + : { + source: values.source, + sourceUnit, + sourceAmount, + unit: profile.materialUnit, + density: + sourceUnit === profile.materialUnit + ? null + : Number(profile.density), + }, + ); setInputs((p) => ({ ...p, hours: values.hours == null ? "" : Number(values.hours.toFixed(6)), @@ -370,6 +413,7 @@ export function CostPanel({ file, onClose }) { value={inputs.amount} onChange={(e) => { setInputs((p) => ({ ...p, amount: e.target.value })); + setMaterialSource(null); setSource("Manual or edited slicer values"); }} /> @@ -407,6 +451,14 @@ export function CostPanel({ file, onClose }) { {source}. Text G-code comments from Prusa / Orca / Cura where available. For resin binary files, enter slicer values manually.

+ {materialSource && + materialSource.sourceUnit !== materialSource.unit && ( +

+ Material converted from {materialSource.sourceAmount}{" "} + {materialSource.sourceUnit} at {materialSource.density} g/ml. + Changing density requires another review. +

+ )} {estimate ? ( <>
@@ -461,6 +513,7 @@ export function CostPanel({ file, onClose }) { profile, inputs, source, + materialSource, ); setMessage( "Estimate saved with this model and its profile assumptions.", diff --git a/src/LibraryTools.jsx b/src/LibraryTools.jsx index 0d41782..634ee02 100644 --- a/src/LibraryTools.jsx +++ b/src/LibraryTools.jsx @@ -36,6 +36,7 @@ export function SearchTools({ query, onFilter, tags, onSave }) { {(chips.length > 0 || query.search || + query.searchScope !== "name" || query.ext || query.collection) && ( filter({ collection: "", ...saved })} + onLoad={(saved) => + filter({ + collection: "", + ...saved, + searchScope: saved.searchScope || "name", + }) + } onRemove={(name) => run(async () => setSavedSearches(await api.savedSearches(name, null, true)), @@ -669,7 +684,7 @@ function App() { filter({ search: e.target.value })} /> @@ -682,6 +697,15 @@ function App() { )} +