diff --git a/README.md b/README.md
index 66a3662..2d87e40 100644
--- a/README.md
+++ b/README.md
@@ -15,19 +15,23 @@ A self-hosted web app for organizing your 3D-printing files (STL, 3MF, OBJ, STEP
- **License & commercial-use tracking** — captured per source, with a "sellable / non-commercial" indicator (a guide; verify before selling).
- **Collections** — group models (a model can be in many); rename/delete without losing models.
- **Editing** — rename/retag, edit print settings, override commercial-use, refresh metadata from source, re-download files, download all files as a zip.
+- **Notes** — free-form notes per model, saved as you type.
+- **Search, filters & sorting** — search across names, descriptions, notes, tags, file names and designers; filter by format, source and license; include *or exclude* tags (match any/all); sort and group by source or format. Press / to search.
+- **Duplicate finder** — every file gets a SHA-256 content hash plus (for STL/OBJ/3MF) a geometry fingerprint, computed in the background. Models sharing files are grouped as *exact copies* or *same shape* (renamed, re-exported, converted — or mirrored, so check those), with a recommended copy to keep. Keeping one merges the others' tags, collections and notes into it and moves them to the trash; groups can be dismissed as "not duplicates".
+- **Trash** — deleting moves a model to the trash; restore it, delete it forever, or empty the trash (only then are files removed from disk).
- **Settings** — API tokens / FlareSolverr URL stored in the database (with `.env` fallback), secrets masked.
## Tech stack
- Node.js + Express, vanilla-JS frontend (no build step), PostgreSQL.
- Three.js (viewer) via CDN import map.
-- Python + [trimesh](https://github.com/mikedh/trimesh) sidecar for 3MF→GLB conversion and volume.
+- Python + [trimesh](https://github.com/mikedh/trimesh) sidecar for 3MF→GLB conversion, volume, and geometry fingerprints.
- Optional [FlareSolverr](https://github.com/FlareSolverr/FlareSolverr) for Cloudflare-protected sources.
## Setup
1. **Postgres** — create a database and point `DATABASE_URL` at it. The schema is applied automatically on startup.
-2. **Python sidecar** (for 3MF previews/volume):
+2. **Python sidecar** (for 3MF previews/volume and shape-based duplicate detection — without it, duplicates are still found by content hash):
```bash
python3 -m venv tools/venv
tools/venv/bin/pip install trimesh numpy networkx lxml
@@ -40,6 +44,8 @@ A self-hosted web app for organizing your 3D-printing files (STL, 3MF, OBJ, STEP
```
4. Open http://localhost:3000
+Run the unit tests with `npm test`.
+
## Configuration
Settings live in the database (editable in the app's **Settings** menu) and fall back to environment variables:
diff --git a/db/schema.sql b/db/schema.sql
index fd014c0..c14c9d8 100644
--- a/db/schema.sql
+++ b/db/schema.sql
@@ -107,3 +107,25 @@ CREATE INDEX IF NOT EXISTS idx_files_model ON files(model_id);
CREATE INDEX IF NOT EXISTS idx_model_projects_p ON model_projects(project_id);
CREATE INDEX IF NOT EXISTS idx_model_tags_t ON model_tags(tag_id);
CREATE INDEX IF NOT EXISTS idx_models_source ON models(source_site, source_id);
+
+-- Content + geometry fingerprints for the duplicate finder (filled in the
+-- background by lib/fingerprint.js).
+ALTER TABLE files ADD COLUMN IF NOT EXISTS sha256 TEXT;
+ALTER TABLE files ADD COLUMN IF NOT EXISTS geometry_hash TEXT;
+-- null = not checked yet; ok | error | missing | na (format has no geometry fingerprint)
+ALTER TABLE files ADD COLUMN IF NOT EXISTS geometry_status TEXT;
+ALTER TABLE files ADD COLUMN IF NOT EXISTS fingerprinted_at TIMESTAMPTZ;
+CREATE INDEX IF NOT EXISTS idx_files_sha256 ON files(sha256);
+CREATE INDEX IF NOT EXISTS idx_files_geometry ON files(geometry_hash);
+
+-- Free-form notes per model.
+ALTER TABLE models ADD COLUMN IF NOT EXISTS notes TEXT NOT NULL DEFAULT '';
+-- Soft delete: set when a model is moved to the trash.
+ALTER TABLE models ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
+CREATE INDEX IF NOT EXISTS idx_models_deleted ON models(deleted_at);
+
+-- Duplicate groups marked "not duplicates" (signature = sorted model ids).
+CREATE TABLE IF NOT EXISTS duplicate_dismissals (
+ signature TEXT PRIMARY KEY,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
diff --git a/lib/duplicates.js b/lib/duplicates.js
new file mode 100644
index 0000000..2754992
--- /dev/null
+++ b/lib/duplicates.js
@@ -0,0 +1,86 @@
+// Groups models that hold the same files. Two models are linked when they share
+// a file with the same content hash (exact copy) or the same geometry
+// fingerprint (same shape: renamed, re-exported, converted — or mirrored, so
+// those groups are flagged for review). Links are transitive, so a pack
+// downloaded twice collapses into one group.
+
+// files: [{ id, model_id, sha256, geometry_hash }]
+// Returns [{ modelIds: [sorted], exact: boolean, matchedFiles: { modelId: count } }]
+function groupDuplicates(files) {
+ const parent = new Map();
+ const find = (x) => {
+ while (parent.get(x) !== x) {
+ parent.set(x, parent.get(parent.get(x)));
+ x = parent.get(x);
+ }
+ return x;
+ };
+ for (const f of files) parent.set(f.model_id, f.model_id);
+
+ const matched = new Map(); // modelId -> Set(fileId)
+ const inexact = []; // a model in each shape-only link
+
+ const buckets = (key) => {
+ const map = new Map();
+ for (const f of files) {
+ if (!f[key]) continue;
+ if (!map.has(f[key])) map.set(f[key], []);
+ map.get(f[key]).push(f);
+ }
+ return map.values();
+ };
+
+ const link = (bucket, exact) => {
+ const modelIds = [...new Set(bucket.map((f) => f.model_id))];
+ if (modelIds.length < 2) return;
+ for (const f of bucket) {
+ if (!matched.has(f.model_id)) matched.set(f.model_id, new Set());
+ matched.get(f.model_id).add(f.id);
+ }
+ for (const id of modelIds.slice(1)) parent.set(find(id), find(modelIds[0]));
+ if (!exact) inexact.push(modelIds[0]);
+ };
+
+ for (const bucket of buckets('sha256')) link(bucket, true);
+ for (const bucket of buckets('geometry_hash')) {
+ // Same shape but different bytes somewhere in the bucket = not a plain copy.
+ link(bucket, new Set(bucket.map((f) => f.sha256)).size === 1);
+ }
+
+ const groups = new Map();
+ for (const id of matched.keys()) {
+ const root = find(id);
+ if (!groups.has(root)) groups.set(root, []);
+ groups.get(root).push(id);
+ }
+ const inexactRoots = new Set(inexact.map(find));
+
+ return [...groups.entries()]
+ .filter(([, ids]) => ids.length > 1)
+ .map(([root, ids]) => ({
+ modelIds: ids.sort((a, b) => a - b),
+ exact: !inexactRoots.has(root),
+ matchedFiles: Object.fromEntries(ids.map((id) => [id, matched.get(id).size])),
+ }));
+}
+
+// Stable id for a group of models (used to remember "not duplicates").
+function signature(modelIds) {
+ return [...new Set(modelIds.map(Number))].sort((a, b) => a - b).join(',');
+}
+
+function keepScore(m) {
+ return (m.source_url ? 4 : 0) + (m.license ? 2 : 0) + (m.notes ? 2 : 0) + (m.description ? 1 : 0)
+ + Math.min((m.tags || []).length, 3) + Math.min((m.projects || []).length, 3) + (m.files || []).length;
+}
+
+// Which model of a group to keep: the most complete one (source, license, notes,
+// tags, collections, most files), then the one added first.
+function recommendKeep(models) {
+ return [...models].sort((a, b) =>
+ keepScore(b) - keepScore(a)
+ || new Date(a.date_added) - new Date(b.date_added)
+ || a.id - b.id)[0].id;
+}
+
+module.exports = { groupDuplicates, signature, recommendKeep };
diff --git a/lib/fingerprint.js b/lib/fingerprint.js
new file mode 100644
index 0000000..fb2afce
--- /dev/null
+++ b/lib/fingerprint.js
@@ -0,0 +1,145 @@
+// Background worker that fingerprints stored files for the duplicate finder: a
+// SHA-256 of the bytes for every file, plus a geometry fingerprint for mesh
+// formats so the same model still matches after being renamed, re-exported
+// (ASCII/binary STL) or converted (STL -> 3MF).
+
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+const { execFile } = require('child_process');
+const db = require('./db');
+
+const PYTHON = process.env.PYTHON_BIN || path.join(__dirname, '..', 'tools', 'venv', 'bin', 'python');
+const SCRIPT = path.join(__dirname, '..', 'tools', 'fingerprint.py');
+const GEOMETRY_FORMATS = ['stl', 'obj', '3mf'];
+const BATCH = 10;
+
+let pythonAvailable = true;
+let running = false;
+let rerun = false;
+
+function sha256File(filePath) {
+ return new Promise((resolve, reject) => {
+ const hash = crypto.createHash('sha256');
+ fs.createReadStream(filePath)
+ .on('error', reject)
+ .on('data', (chunk) => hash.update(chunk))
+ .on('end', () => resolve(hash.digest('hex')));
+ });
+}
+
+// n rounded to `digits` significant figures, as a stable string.
+function sig(n, digits = 4) {
+ if (!Number.isFinite(n) || n === 0) return '0';
+ return String(Number(n.toPrecision(digits)));
+}
+
+// Hash of rounded shape measurements. Position and axis-aligned rotation don't
+// change it, and rounding absorbs float noise between export formats.
+function geometryKey(fp) {
+ if (!fp || !fp.faces) return null;
+ const extents = (fp.extents || []).map((e) => Math.abs(e)).sort((a, b) => a - b).map((e) => e.toFixed(1));
+ const raw = `${fp.faces}|${sig(fp.area)}|${sig(Math.abs(fp.volume || 0))}|${extents.join('x')}`;
+ return crypto.createHash('sha1').update(raw).digest('hex');
+}
+
+// Runs the trimesh sidecar over some files. Resolves Map(absPath -> {fp}|{error});
+// output from a run that timed out or crashed part-way is still returned.
+function measure(paths) {
+ return new Promise((resolve, reject) => {
+ execFile(
+ PYTHON,
+ [SCRIPT, ...paths],
+ { maxBuffer: 4 * 1024 * 1024, timeout: 60000 * (paths.length + 1) },
+ (err, stdout) => {
+ if (err && err.code === 'ENOENT') return reject(err);
+ const results = new Map();
+ for (const line of String(stdout || '').split('\n')) {
+ if (!line.trim()) continue;
+ try {
+ const r = JSON.parse(line);
+ results.set(r.path, r);
+ } catch { /* ignore non-JSON noise */ }
+ }
+ resolve(results);
+ }
+ );
+ });
+}
+
+async function fingerprintGeometry(rows) {
+ const abs = (row) => path.resolve(row.file_path);
+ const results = await measure(rows.map(abs));
+ for (const row of rows) {
+ let r = results.get(abs(row));
+ // A batch that died part-way: retry the missing file on its own.
+ if (!r && rows.length > 1) r = (await measure([abs(row)])).get(abs(row));
+ const key = r && r.fp ? geometryKey(r.fp) : null;
+ await db.query('UPDATE files SET geometry_hash = $2, geometry_status = $3 WHERE id = $1',
+ [row.id, key, key ? 'ok' : 'error']);
+ }
+}
+
+async function run() {
+ for (;;) {
+ const { rows } = await db.query(
+ `SELECT id, file_path, format, fingerprinted_at FROM files
+ WHERE fingerprinted_at IS NULL OR (geometry_status IS NULL AND $1::boolean)
+ ORDER BY id LIMIT $2`, [pythonAvailable, BATCH]);
+ if (rows.length === 0) return;
+
+ const geometry = [];
+ for (const row of rows) {
+ const abs = path.resolve(row.file_path);
+ try {
+ if (!fs.existsSync(abs)) {
+ await db.query(`UPDATE files SET fingerprinted_at = now(), geometry_status = 'missing' WHERE id = $1`, [row.id]);
+ continue;
+ }
+ if (!row.fingerprinted_at) {
+ await db.query('UPDATE files SET sha256 = $2, fingerprinted_at = now() WHERE id = $1',
+ [row.id, await sha256File(abs)]);
+ }
+ if (!GEOMETRY_FORMATS.includes(row.format)) {
+ await db.query(`UPDATE files SET geometry_status = 'na' WHERE id = $1`, [row.id]);
+ } else if (pythonAvailable) {
+ geometry.push(row);
+ }
+ } catch (err) {
+ console.warn(`Fingerprint failed for file ${row.id}:`, err.message);
+ await db.query(`UPDATE files SET fingerprinted_at = now(), geometry_status = 'error' WHERE id = $1`, [row.id]);
+ }
+ }
+
+ if (geometry.length) {
+ try {
+ await fingerprintGeometry(geometry);
+ } catch (err) {
+ if (err.code !== 'ENOENT') throw err;
+ pythonAvailable = false;
+ console.warn(`Geometry fingerprints disabled: no Python at ${PYTHON} (content hashes still run).`);
+ }
+ }
+ }
+}
+
+// Start (or queue another pass of) the worker. Safe to call often.
+function kick() {
+ if (running) { rerun = true; return; }
+ running = true;
+ run()
+ .catch((err) => console.error('Fingerprinting stopped:', err.message))
+ .finally(() => {
+ running = false;
+ if (rerun) { rerun = false; kick(); }
+ });
+}
+
+async function pendingCount() {
+ const { rows } = await db.query(
+ `SELECT COUNT(*)::int AS n FROM files
+ WHERE fingerprinted_at IS NULL OR (geometry_status IS NULL AND $1::boolean)`, [pythonAvailable]);
+ return rows[0].n;
+}
+
+module.exports = { kick, pendingCount, geometryKey, sha256File };
diff --git a/lib/store.js b/lib/store.js
index 0cbe80a..4144118 100644
--- a/lib/store.js
+++ b/lib/store.js
@@ -4,6 +4,7 @@ const db = require('./db');
const MODEL_SELECT = `
SELECT m.id, m.name, m.description, m.source_site, m.source_url, m.source_id,
m.license, m.commercial_use, m.thumbnail_path, m.print_settings, m.date_added, m.date_modified,
+ m.notes, m.deleted_at,
d.id AS designer_id, d.name AS designer_name, d.profile_url AS designer_url,
COALESCE(t.tags, '[]'::json) AS tags,
COALESCE(p.projects, '[]'::json) AS projects,
@@ -39,8 +40,11 @@ function shapeModel(row) {
}
// ---------------------------------------------------------------- models
-async function getAllModels() {
- const { rows } = await db.query(`${MODEL_SELECT} ORDER BY m.date_added DESC`);
+// The library, or with { trash: true } the models in the trash.
+async function getAllModels({ trash = false } = {}) {
+ const { rows } = await db.query(trash
+ ? `${MODEL_SELECT} WHERE m.deleted_at IS NOT NULL ORDER BY m.deleted_at DESC`
+ : `${MODEL_SELECT} WHERE m.deleted_at IS NULL ORDER BY m.date_added DESC`);
return rows.map(shapeModel);
}
@@ -49,9 +53,15 @@ async function getModel(id) {
return shapeModel(rows[0]);
}
+async function getModelsByIds(ids) {
+ if (!ids.length) return [];
+ const { rows } = await db.query(`${MODEL_SELECT} WHERE m.id = ANY($1::int[])`, [ids]);
+ return rows.map(shapeModel);
+}
+
async function findModelBySource(site, sourceId) {
const { rows } = await db.query(
- `${MODEL_SELECT} WHERE m.source_site = $1 AND m.source_id = $2`, [site, sourceId]);
+ `${MODEL_SELECT} WHERE m.source_site = $1 AND m.source_id = $2 AND m.deleted_at IS NULL`, [site, sourceId]);
return shapeModel(rows[0]);
}
@@ -87,7 +97,7 @@ async function updateModel(id, updates) {
const fields = [];
const values = [];
let i = 1;
- for (const col of ['name', 'description', 'license', 'commercial_use', 'thumbnail_path']) {
+ for (const col of ['name', 'description', 'notes', 'license', 'commercial_use', 'thumbnail_path']) {
if (updates[col] !== undefined) { fields.push(`${col} = $${i++}`); values.push(updates[col]); }
}
if (updates.print_settings !== undefined) {
@@ -117,6 +127,78 @@ async function deleteModel(id) {
};
}
+// ---------------------------------------------------------------- trash
+async function trashModels(ids) {
+ const { rowCount } = await db.query(
+ 'UPDATE models SET deleted_at = now() WHERE id = ANY($1::int[]) AND deleted_at IS NULL', [ids]);
+ return rowCount;
+}
+
+async function restoreModel(id) {
+ const { rowCount } = await db.query(
+ 'UPDATE models SET deleted_at = NULL WHERE id = $1 AND deleted_at IS NOT NULL', [id]);
+ return rowCount > 0;
+}
+
+async function trashedModelIds() {
+ const { rows } = await db.query('SELECT id FROM models WHERE deleted_at IS NOT NULL');
+ return rows.map((r) => r.id);
+}
+
+async function countTrashed() {
+ const { rows } = await db.query('SELECT COUNT(*)::int AS n FROM models WHERE deleted_at IS NOT NULL');
+ return rows[0].n;
+}
+
+// ---------------------------------------------------------------- duplicates
+// Files of non-trashed models whose content hash or geometry fingerprint also
+// appears in another non-trashed model — the input to the duplicate finder.
+async function duplicateCandidateFiles() {
+ const { rows } = await db.query(`
+ WITH live AS (
+ SELECT f.id, f.model_id, f.sha256, f.geometry_hash
+ FROM files f JOIN models m ON m.id = f.model_id
+ WHERE m.deleted_at IS NULL AND f.size > 0
+ )
+ SELECT * FROM live
+ WHERE sha256 IN (SELECT sha256 FROM live WHERE sha256 IS NOT NULL
+ GROUP BY sha256 HAVING COUNT(DISTINCT model_id) > 1)
+ OR geometry_hash IN (SELECT geometry_hash FROM live WHERE geometry_hash IS NOT NULL
+ GROUP BY geometry_hash HAVING COUNT(DISTINCT model_id) > 1)`);
+ return rows;
+}
+
+// Keep one model of a duplicate group: fold the others' tags, collections and
+// notes into it, then move the others to the trash.
+async function mergeAndTrash(keepId, otherIds) {
+ await db.transaction(async (client) => {
+ await client.query(
+ `INSERT INTO model_tags (model_id, tag_id)
+ SELECT $1::int, tag_id FROM model_tags WHERE model_id = ANY($2::int[])
+ ON CONFLICT DO NOTHING`, [keepId, otherIds]);
+ await client.query(
+ `INSERT INTO model_projects (model_id, project_id)
+ SELECT $1::int, project_id FROM model_projects WHERE model_id = ANY($2::int[])
+ ON CONFLICT DO NOTHING`, [keepId, otherIds]);
+ const { rows } = await client.query(
+ 'SELECT notes FROM models WHERE id = $1 OR id = ANY($2::int[]) ORDER BY id = $1 DESC, id',
+ [keepId, otherIds]);
+ const notes = [...new Set(rows.map((r) => r.notes.trim()).filter(Boolean))].join('\n\n');
+ await client.query('UPDATE models SET notes = $2, date_modified = now() WHERE id = $1', [keepId, notes]);
+ await client.query('UPDATE models SET deleted_at = now() WHERE id = ANY($1::int[])', [otherIds]);
+ });
+}
+
+async function dismissDuplicateGroup(signature) {
+ await db.query(
+ 'INSERT INTO duplicate_dismissals (signature) VALUES ($1) ON CONFLICT DO NOTHING', [signature]);
+}
+
+async function dismissedDuplicateSignatures() {
+ const { rows } = await db.query('SELECT signature FROM duplicate_dismissals');
+ return rows.map((r) => r.signature);
+}
+
async function addFiles(modelId, files) {
await db.transaction(async (client) => {
for (const file of files) await insertFile(client, modelId, file);
@@ -176,9 +258,10 @@ async function setProjects(client, modelId, projectIds) {
async function getAllProjects() {
const { rows } = await db.query(`
SELECT p.id, p.name, p.description, p.date_created, p.date_modified,
- COUNT(mp.model_id)::int AS model_count
+ COUNT(m.id)::int AS model_count
FROM projects p
LEFT JOIN model_projects mp ON mp.project_id = p.id
+ LEFT JOIN models m ON m.id = mp.model_id AND m.deleted_at IS NULL
GROUP BY p.id ORDER BY p.name`);
return rows;
}
@@ -321,8 +404,10 @@ async function upsertDesigner(client, { name, source_site, profile_url }) {
}
module.exports = {
- getAllModels, getModel, findModelBySource, createModel, updateModel, deleteModel, addFiles,
+ getAllModels, getModel, getModelsByIds, findModelBySource, createModel, updateModel, deleteModel, addFiles,
updateFileConversion,
+ trashModels, restoreModel, trashedModelIds, countTrashed,
+ duplicateCandidateFiles, mergeAndTrash, dismissDuplicateGroup, dismissedDuplicateSignatures,
getAllTags,
getAllProjects, getProject, createProject, updateProject, deleteProject,
getSetting, setSetting,
diff --git a/package.json b/package.json
index 688f463..91e1354 100644
--- a/package.json
+++ b/package.json
@@ -5,7 +5,8 @@
"main": "server.js",
"scripts": {
"start": "node server.js",
- "dev": "nodemon server.js"
+ "dev": "nodemon server.js",
+ "test": "node --test test/*.test.js"
},
"keywords": [
"stl",
diff --git a/public/app.js b/public/app.js
index e0fa939..ef0f2a5 100644
--- a/public/app.js
+++ b/public/app.js
@@ -1,16 +1,27 @@
// State
-let models = [];
+let models = []; // the library (not in the trash)
+let trashModels = []; // loaded while the Trash view is open
let projects = [];
let config = { importers: {} };
-let selectedProject = 'all';
-let selectedTags = [];
-let currentView = 'grid';
+let stats = { trash: 0, duplicateGroups: 0, fingerprintsPending: 0 };
+let selectedScope = 'all'; // 'all' | 'duplicates' | 'trash' | a collection id
+let tagFilters = {}; // tag -> 'include' | 'exclude'
+let duplicateData = null; // last /api/duplicates response
+let duplicatePoll = null;
+
+// View preferences, remembered per browser.
+const prefs = { view: 'grid', tagMatch: 'any', format: '', source: '', license: '', sort: 'newest', group: '' };
+const PREFS_KEY = 'printhive.prefs';
+const SPECIAL_SCOPES = ['all', 'duplicates', 'trash'];
// DOM
const fileGrid = document.getElementById('fileGrid');
const projectsList = document.getElementById('projectsList');
const tagsList = document.getElementById('tagsList');
const searchInput = document.getElementById('searchInput');
+const contentTitle = document.getElementById('contentTitle');
+const resultCount = document.getElementById('resultCount');
+const emptyTrashBtn = document.getElementById('emptyTrashBtn');
const uploadModal = document.getElementById('uploadModal');
const projectModal = document.getElementById('projectModal');
const importModal = document.getElementById('importModal');
@@ -20,10 +31,19 @@ const editModal = document.getElementById('editModal');
const fileDetailModal = document.getElementById('fileDetailModal');
document.addEventListener('DOMContentLoaded', () => {
+ loadPrefs();
setupAuthListeners();
bootstrap();
});
+function loadPrefs() {
+ try { Object.assign(prefs, JSON.parse(localStorage.getItem(PREFS_KEY)) || {}); } catch { /* storage unavailable */ }
+}
+
+function savePrefs() {
+ try { localStorage.setItem(PREFS_KEY, JSON.stringify(prefs)); } catch { /* storage unavailable */ }
+}
+
// Decide what to show: the app (if signed in), the reset form (if ?reset=...),
// or the sign-in / create-account screen.
async function bootstrap() {
@@ -122,17 +142,24 @@ async function handleLogout() {
async function loadData() {
try {
- const [modelsRes, projectsRes, configRes] = await Promise.all([
+ const [modelsRes, projectsRes, configRes, statsRes] = await Promise.all([
fetch('/api/models'),
fetch('/api/projects'),
fetch('/api/config'),
+ fetch('/api/library/stats'),
]);
models = await modelsRes.json();
projects = await projectsRes.json();
config = await configRes.json();
+ stats = statsRes.ok ? await statsRes.json() : stats;
+ if (selectedScope === 'trash') await loadTrash();
+ if (!SPECIAL_SCOPES.includes(selectedScope) && !projects.some((p) => String(p.id) === String(selectedScope))) {
+ selectedScope = 'all'; // the open collection was deleted
+ }
renderProjects();
renderTags();
+ renderFilterOptions();
renderModels();
updateCounts();
generateMissingThumbnails(); // fire-and-forget; updates cards as they finish
@@ -146,6 +173,11 @@ async function loadData() {
}
}
+async function loadTrash() {
+ const res = await fetch('/api/models?view=trash');
+ trashModels = res.ok ? await res.json() : [];
+}
+
// Render thumbnails in the browser for models that don't have one yet, then
// persist them. Runs one at a time to avoid thrashing the GPU / WebGL contexts.
const thumbAttempted = new Set();
@@ -167,93 +199,383 @@ async function generateMissingThumbnails() {
});
if (!res.ok) continue;
const updated = await res.json();
- m.thumbnailUrl = updated.thumbnailUrl;
- const thumb = fileGrid.querySelector(`.file-card[data-model-id="${m.id}"] .file-thumbnail`);
- if (thumb && updated.thumbnailUrl) {
- thumb.innerHTML = `
`;
- }
+ updateCardThumbnail(m.id, updated.thumbnailUrl);
} catch (err) {
console.warn('Thumbnail generation failed for model', m.id, err);
}
}
}
+// ---------------------------------------------------------------- lookups
+function findModel(id) {
+ return models.find((m) => String(m.id) === String(id))
+ || trashModels.find((m) => String(m.id) === String(id));
+}
+
+function replaceModel(updated) {
+ for (const list of [models, trashModels]) {
+ const i = list.findIndex((m) => m.id === updated.id);
+ if (i !== -1) list[i] = updated;
+ }
+}
+
+const SOURCE_LABELS = {
+ upload: 'Manual upload', thingiverse: 'Thingiverse', printables: 'Printables',
+ makerworld: 'MakerWorld', cubee3d: 'Cubee3D', web: 'Web',
+};
+const sourceKey = (m) => m.source_site || 'upload';
+const sourceLabel = (key) => SOURCE_LABELS[key] || key;
+const licenseKey = (m) => (m.commercial_use === true ? 'sellable' : m.commercial_use === false ? 'noncommercial' : 'unknown');
+const totalSize = (m) => (m.files || []).reduce((s, f) => s + Number(f.size || 0), 0);
+const fileCountLabel = (m) => `${m.files.length} file${m.files.length === 1 ? '' : 's'}`;
+
+// Everything a search term can match, lower-cased. Cached per model object
+// (objects are replaced whenever data reloads, which invalidates the cache).
+const searchCache = new WeakMap();
+function searchText(m) {
+ if (!searchCache.has(m)) {
+ searchCache.set(m, [
+ m.name, m.description, m.notes, m.license, sourceLabel(sourceKey(m)),
+ m.designer && m.designer.name,
+ ...(m.tags || []),
+ ...(m.projects || []).map((p) => p.name),
+ ...(m.files || []).map((f) => f.originalName),
+ ].filter(Boolean).join('\n').toLowerCase());
+ }
+ return searchCache.get(m);
+}
+
// ---------------------------------------------------------------- rendering
function renderProjects() {
- const items = projects.map((p) => `
-
- ${escapeHtml(p.name)}
-
-
-
- ${p.model_count}
-
-
`).join('');
- const allItem = projectsList.querySelector('[data-project-id="all"]');
- allItem.classList.toggle('active', selectedProject === 'all');
- projectsList.innerHTML = '';
- projectsList.appendChild(allItem);
- projectsList.insertAdjacentHTML('beforeend', items);
+ projectsList.innerHTML = projects.length
+ ? projects.map((p) => `
+
+ ${escapeHtml(p.name)}
+
+
+
+ ${p.model_count}
+
+
`).join('')
+ : 'No collections yet.
';
}
function renderTags() {
- const all = new Set();
- models.forEach((m) => (m.tags || []).forEach((t) => all.add(t)));
- tagsList.innerHTML = Array.from(all).sort().map((tag) => `
- ${escapeHtml(tag)}`).join('');
+ const all = knownTags();
+ for (const t of Object.keys(tagFilters)) if (!all.includes(t)) delete tagFilters[t];
+ const titles = {
+ include: 'Showing models with this tag — click to exclude it',
+ exclude: 'Hiding models with this tag — click to clear',
+ };
+ tagsList.innerHTML = all.length
+ ? all.map((tag) => {
+ const state = tagFilters[tag];
+ return `${escapeHtml(tag)}`;
+ }).join('')
+ : 'No tags yet.
';
+ document.getElementById('clearTags').hidden = Object.keys(tagFilters).length === 0;
+ document.querySelectorAll('#tagMatch button').forEach((b) => {
+ b.classList.toggle('active', b.dataset.match === prefs.tagMatch);
+ });
+}
+
+// Format/source dropdowns list only what's in the library. A remembered choice
+// that no longer matches anything is reset rather than silently hiding everything.
+function renderFilterOptions() {
+ const formats = [...new Set(models.flatMap((m) => (m.files || []).map((f) => f.format)))].sort();
+ const sources = [...new Set(models.map(sourceKey))].sort();
+ fillSelect('filterFormat', 'Any format', formats.map((f) => [f, `.${f}`]), 'format');
+ fillSelect('filterSource', 'Any source', sources.map((s) => [s, sourceLabel(s)]), 'source');
+}
+
+function fillSelect(id, anyLabel, options, prefKey) {
+ if (prefs[prefKey] && !options.some(([value]) => value === prefs[prefKey])) {
+ prefs[prefKey] = '';
+ savePrefs();
+ }
+ const el = document.getElementById(id);
+ el.innerHTML = ``
+ + options.map(([value, label]) => ``).join('');
+ el.value = prefs[prefKey];
+}
+
+function scopeTitle() {
+ if (selectedScope === 'all') return 'All Models';
+ if (selectedScope === 'duplicates') return 'Duplicates';
+ if (selectedScope === 'trash') return 'Trash';
+ const p = projects.find((x) => String(x.id) === String(selectedScope));
+ return p ? p.name : 'Collection';
+}
+
+function updateScopeChrome() {
+ contentTitle.textContent = scopeTitle();
+ const dupes = selectedScope === 'duplicates';
+ document.getElementById('filterBar').hidden = dupes;
+ document.querySelector('.view-options').hidden = dupes;
+ emptyTrashBtn.hidden = !(selectedScope === 'trash' && trashModels.length);
+ document.querySelectorAll('.project-item[data-scope]').forEach((el) => {
+ el.classList.toggle('active', el.dataset.scope === String(selectedScope));
+ });
+ document.querySelectorAll('.view-btn').forEach((b) => b.classList.toggle('active', b.dataset.view === prefs.view));
}
function renderModels() {
- const filtered = getFilteredModels();
- if (filtered.length === 0) {
- fileGrid.innerHTML = `
-
-
No models found
-
Upload files or import from a URL to get started
-
`;
+ updateScopeChrome();
+ if (selectedScope === 'duplicates') { renderDuplicates(); return; }
+ clearTimeout(duplicatePoll);
+
+ const list = getFilteredModels();
+ resultCount.textContent = `${list.length} model${list.length === 1 ? '' : 's'}`;
+ fileGrid.className = prefs.view === 'list' ? 'file-grid list-view' : 'file-grid';
+
+ if (list.length === 0) {
+ fileGrid.innerHTML = emptyStateHtml();
return;
}
+ if (!prefs.group) {
+ fileGrid.innerHTML = list.map(cardHtml).join('');
+ return;
+ }
+ const groups = new Map();
+ for (const m of list) {
+ const key = groupLabel(m);
+ if (!groups.has(key)) groups.set(key, []);
+ groups.get(key).push(m);
+ }
+ fileGrid.innerHTML = [...groups.entries()]
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([label, ms]) => ``
+ + ms.map(cardHtml).join(''))
+ .join('');
+}
- fileGrid.className = currentView === 'grid' ? 'file-grid' : 'file-grid list-view';
- fileGrid.innerHTML = filtered.map((m) => {
- const totalSize = (m.files || []).reduce((s, f) => s + Number(f.size || 0), 0);
- const thumb = m.thumbnailUrl
- ? `
`
- : '📦';
- return `
+function groupLabel(m) {
+ if (prefs.group === 'source') return sourceLabel(sourceKey(m));
+ const formats = [...new Set((m.files || []).map((f) => `.${f.format}`))].sort();
+ return formats.length ? formats.join(' + ') : 'No files';
+}
+
+function emptyStateHtml() {
+ const state = (title, text) => ``;
+ if (selectedScope === 'trash' && trashModels.length === 0) {
+ return state('Trash is empty', 'Models you delete wait here until you restore them or empty the trash.');
+ }
+ if (selectedScope === 'all' && models.length === 0) {
+ return state('No models yet', 'Upload files or import from a URL to get started');
+ }
+ return state('No models match', 'Try clearing the search, filters or tag selection.');
+}
+
+function thumbHtml(m) {
+ return m.thumbnailUrl ? `
` : '📦';
+}
+
+function cardHtml(m) {
+ const trashed = Boolean(m.deleted_at);
+ const when = trashed ? `trashed ${formatDate(m.deleted_at)}` : formatDate(m.date_added);
+ return `
-
${thumb}
+
${thumbHtml(m)}
${escapeHtml(m.name)}
${m.source_site ? `
${escapeHtml(m.source_site)}` : ''}
${m.commercial_use === true ? '
$ sellable' : m.commercial_use === false ? '
non-commercial' : ''}
${m.description ? `
${escapeHtml(m.description)}
` : ''}
${(m.tags && m.tags.length) ? `
${m.tags.map((t) => `${escapeHtml(t)}`).join('')}
` : ''}
-
${m.files.length} file${m.files.length === 1 ? '' : 's'} • ${formatFileSize(totalSize)} • ${formatDate(m.date_added)}
+
${fileCountLabel(m)} • ${formatFileSize(totalSize(m))} • ${when}
+ ${trashed ? `
+
+
+
+
` : ''}
`;
- }).join('');
}
+const SORTS = {
+ newest: (a, b) => new Date(b.date_added) - new Date(a.date_added),
+ oldest: (a, b) => new Date(a.date_added) - new Date(b.date_added),
+ name: (a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }),
+ 'name-desc': (a, b) => b.name.localeCompare(a.name, undefined, { numeric: true, sensitivity: 'base' }),
+ size: (a, b) => totalSize(b) - totalSize(a),
+ modified: (a, b) => new Date(b.date_modified || b.date_added) - new Date(a.date_modified || a.date_added),
+};
+
function getFilteredModels() {
- let result = models;
- if (selectedProject !== 'all') {
- result = result.filter((m) => (m.projects || []).some((p) => String(p.id) === String(selectedProject)));
+ let result = selectedScope === 'trash' ? trashModels : models;
+ if (!SPECIAL_SCOPES.includes(selectedScope)) {
+ result = result.filter((m) => (m.projects || []).some((p) => String(p.id) === String(selectedScope)));
}
- if (selectedTags.length > 0) {
- result = result.filter((m) => selectedTags.some((t) => (m.tags || []).includes(t)));
+
+ const include = Object.keys(tagFilters).filter((t) => tagFilters[t] === 'include');
+ const exclude = Object.keys(tagFilters).filter((t) => tagFilters[t] === 'exclude');
+ if (include.length) {
+ result = result.filter((m) => {
+ const tags = m.tags || [];
+ return prefs.tagMatch === 'all' ? include.every((t) => tags.includes(t)) : include.some((t) => tags.includes(t));
+ });
}
- const term = searchInput.value.toLowerCase().trim();
- if (term) {
- result = result.filter((m) =>
- m.name.toLowerCase().includes(term) ||
- (m.description && m.description.toLowerCase().includes(term)));
- }
- return result;
+ if (exclude.length) result = result.filter((m) => !exclude.some((t) => (m.tags || []).includes(t)));
+
+ if (prefs.format) result = result.filter((m) => (m.files || []).some((f) => f.format === prefs.format));
+ if (prefs.source) result = result.filter((m) => sourceKey(m) === prefs.source);
+ if (prefs.license) result = result.filter((m) => licenseKey(m) === prefs.license);
+
+ const terms = searchInput.value.toLowerCase().split(/\s+/).filter(Boolean);
+ if (terms.length) result = result.filter((m) => terms.every((t) => searchText(m).includes(t)));
+
+ return [...result].sort(SORTS[prefs.sort] || SORTS.newest);
}
function updateCounts() {
document.getElementById('allFilesCount').textContent = models.length;
+ document.getElementById('trashCount').textContent = stats.trash ?? 0;
+ document.getElementById('duplicatesCount').textContent = stats.duplicateGroups ?? 0;
+}
+
+// ---------------------------------------------------------------- duplicates
+async function renderDuplicates() {
+ fileGrid.className = 'dup-list';
+ resultCount.textContent = '';
+ if (!duplicateData) fileGrid.innerHTML = '';
+ try {
+ const res = await fetch('/api/duplicates');
+ if (!res.ok) throw new Error((await res.json()).error);
+ duplicateData = await res.json();
+ } catch (err) {
+ fileGrid.innerHTML = `Couldn't check for duplicates
${escapeHtml(err.message)}
`;
+ return;
+ }
+ if (selectedScope !== 'duplicates') return; // navigated away while loading
+
+ const { groups, pending } = duplicateData;
+ stats.duplicateGroups = groups.length;
+ updateCounts();
+ resultCount.textContent = `${groups.length} group${groups.length === 1 ? '' : 's'}`;
+ const pendingNote = pending
+ ? `Fingerprinting ${pending} file${pending === 1 ? '' : 's'}… this list updates automatically.
`
+ : '';
+ fileGrid.innerHTML = pendingNote + (groups.length
+ ? groups.map(duplicateGroupHtml).join('')
+ : 'No duplicates found
Files are compared by content and by shape, so renamed or re-exported copies show up here too.
');
+
+ clearTimeout(duplicatePoll);
+ if (pending) duplicatePoll = setTimeout(() => { if (selectedScope === 'duplicates') renderDuplicates(); }, 4000);
+}
+
+function duplicateGroupHtml(g) {
+ const title = g.exact ? 'Exact copies' : 'Same shape';
+ const hint = g.exact
+ ? 'These models share byte-for-byte identical files.'
+ : 'Geometry matches but the files differ (re-exported, converted, or possibly mirrored) — compare before trashing.';
+ return `
+
+
+
+
${title} · ${g.models.length} models
+
${hint}
+
+
+
+ ${g.models.map((m) => duplicateCardHtml(g, m)).join('')}
+ `;
+}
+
+function duplicateCardHtml(g, m) {
+ const recommended = m.id === g.recommendedId;
+ const unique = m.files.length - m.matchedFiles;
+ return `
+
+
${thumbHtml(m)}
+
+ ${recommended ? '
Recommended keep' : ''}
+
${escapeHtml(m.name)}
+
${escapeHtml(sourceLabel(sourceKey(m)))} • ${formatDate(m.date_added)}
+
${m.matchedFiles} of ${m.files.length} files match${unique ? ` • ${unique} unique` : ''}
+
+
+
`;
+}
+
+async function handleKeepDuplicate(signature, keepId) {
+ const group = duplicateData && duplicateData.groups.find((g) => g.signature === signature);
+ if (!group) return;
+ const keep = group.models.find((m) => m.id === keepId);
+ const others = group.models.filter((m) => m.id !== keepId);
+ let message = `Keep "${keep.name}" and move ${others.length} other model${others.length === 1 ? '' : 's'} to the trash?\n\n`
+ + 'Their tags, collections and notes are merged into the one you keep.';
+ const withUnique = others.filter((m) => m.files.length > m.matchedFiles);
+ if (withUnique.length) {
+ message += `\n\nHeads up: ${withUnique.map((m) => `"${m.name}"`).join(', ')} also contain files that aren't duplicated — they go to the trash too.`;
+ }
+ if (!confirm(message)) return;
+ try {
+ const res = await fetch('/api/duplicates/resolve', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ keepId, trashIds: others.map((m) => m.id) }),
+ });
+ if (!res.ok) throw new Error((await res.json()).error);
+ await loadData();
+ } catch (err) {
+ alert('Could not resolve duplicates: ' + err.message);
+ }
+}
+
+async function handleDismissDuplicates(signature) {
+ const group = duplicateData && duplicateData.groups.find((g) => g.signature === signature);
+ if (!group) return;
+ try {
+ const res = await fetch('/api/duplicates/dismiss', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ modelIds: group.models.map((m) => m.id) }),
+ });
+ if (!res.ok) throw new Error((await res.json()).error);
+ await loadData();
+ } catch (err) {
+ alert('Could not dismiss: ' + err.message);
+ }
+}
+
+// ---------------------------------------------------------------- trash
+async function restoreModel(id) {
+ try {
+ const res = await fetch(`/api/models/${id}/restore`, { method: 'POST' });
+ if (!res.ok) throw new Error((await res.json()).error);
+ await loadData();
+ return true;
+ } catch (err) {
+ alert('Restore failed: ' + err.message);
+ return false;
+ }
+}
+
+async function purgeModel(id) {
+ const model = findModel(id);
+ if (!confirm(`Permanently delete "${model ? model.name : 'this model'}" and its files?\n\nThis can't be undone.`)) return false;
+ try {
+ const res = await fetch(`/api/models/${id}?permanent=true`, { method: 'DELETE' });
+ if (!res.ok) throw new Error((await res.json()).error);
+ await loadData();
+ return true;
+ } catch (err) {
+ alert('Delete failed: ' + err.message);
+ return false;
+ }
+}
+
+async function handleEmptyTrash() {
+ const n = trashModels.length;
+ if (!n || !confirm(`Permanently delete ${n} model${n === 1 ? '' : 's'} in the trash and all their files?\n\nThis can't be undone.`)) return;
+ try {
+ const res = await fetch('/api/trash', { method: 'DELETE' });
+ if (!res.ok) throw new Error((await res.json()).error);
+ await loadData();
+ } catch (err) {
+ alert('Emptying the trash failed: ' + err.message);
+ }
}
// ---------------------------------------------------------------- events
@@ -293,48 +615,92 @@ function setupEventListeners() {
if (f && !nameField.value) nameField.value = f.name.replace(/\.[^.]+$/, '');
});
- projectsList.addEventListener('click', (e) => {
- const item = e.target.closest('.project-item');
+ // Library views (All / Duplicates / Trash) and collections share one scope.
+ document.querySelector('.sidebar').addEventListener('click', (e) => {
+ const item = e.target.closest('.project-item[data-scope]');
if (!item) return;
- const id = item.dataset.projectId;
- const name = item.querySelector('.project-name') ? item.querySelector('.project-name').textContent : 'All Models';
-
- if (e.target.closest('.collection-rename')) { handleRenameCollection(id, name); return; }
- if (e.target.closest('.collection-delete')) { handleDeleteCollection(id, name); return; }
-
- document.querySelectorAll('.project-item').forEach((i) => i.classList.remove('active'));
- item.classList.add('active');
- selectedProject = id;
- document.getElementById('contentTitle').textContent = id === 'all' ? 'All Models' : name;
- renderModels();
+ const scope = item.dataset.scope;
+ if (e.target.closest('.collection-rename')) { handleRenameCollection(scope, item.querySelector('.project-name').textContent); return; }
+ if (e.target.closest('.collection-delete')) { handleDeleteCollection(scope, item.querySelector('.project-name').textContent); return; }
+ selectScope(scope);
});
tagsList.addEventListener('click', (e) => {
- if (!e.target.classList.contains('tag')) return;
- const tag = e.target.dataset.tag;
- e.target.classList.toggle('active');
- if (selectedTags.includes(tag)) selectedTags = selectedTags.filter((t) => t !== tag);
- else selectedTags.push(tag);
+ const el = e.target.closest('.tag');
+ if (!el) return;
+ const tag = el.dataset.tag;
+ if (!tagFilters[tag]) tagFilters[tag] = 'include';
+ else if (tagFilters[tag] === 'include') tagFilters[tag] = 'exclude';
+ else delete tagFilters[tag];
+ renderTags();
+ renderModels();
+ });
+ document.getElementById('tagMatch').addEventListener('click', (e) => {
+ const btn = e.target.closest('button[data-match]');
+ if (!btn) return;
+ prefs.tagMatch = btn.dataset.match;
+ savePrefs();
+ renderTags();
+ renderModels();
+ });
+ document.getElementById('clearTags').addEventListener('click', () => {
+ tagFilters = {};
+ renderTags();
renderModels();
});
searchInput.addEventListener('input', renderModels);
+ [['filterFormat', 'format'], ['filterSource', 'source'], ['filterLicense', 'license'], ['sortBy', 'sort'], ['groupBy', 'group']]
+ .forEach(([id, key]) => {
+ const el = document.getElementById(id);
+ el.value = prefs[key];
+ el.addEventListener('change', () => { prefs[key] = el.value; savePrefs(); renderModels(); });
+ });
+
+ // "/" jumps to search.
+ document.addEventListener('keydown', (e) => {
+ if (e.key !== '/' || e.metaKey || e.ctrlKey || e.altKey) return;
+ if (e.target.closest && e.target.closest('input, textarea, select, [contenteditable]')) return;
+ if (document.querySelector('.modal.active') || document.getElementById('filterBar').hidden) return;
+ e.preventDefault();
+ searchInput.focus();
+ });
document.querySelectorAll('.view-btn').forEach((btn) => {
btn.addEventListener('click', (e) => {
- document.querySelectorAll('.view-btn').forEach((b) => b.classList.remove('active'));
- e.target.classList.add('active');
- currentView = e.target.dataset.view;
+ prefs.view = e.target.dataset.view;
+ savePrefs();
renderModels();
});
});
fileGrid.addEventListener('click', (e) => {
- const card = e.target.closest('.file-card');
+ const card = e.target.closest('[data-model-id]');
+ const actionBtn = e.target.closest('[data-action]');
+ if (actionBtn) {
+ const group = actionBtn.closest('.dup-group');
+ switch (actionBtn.dataset.action) {
+ case 'restore': restoreModel(card.dataset.modelId); break;
+ case 'purge': purgeModel(card.dataset.modelId); break;
+ case 'keep': handleKeepDuplicate(group.dataset.signature, Number(card.dataset.modelId)); break;
+ case 'dismiss': handleDismissDuplicates(group.dataset.signature); break;
+ }
+ return;
+ }
if (card) openModelDetail(card.dataset.modelId);
});
+ emptyTrashBtn.addEventListener('click', handleEmptyTrash);
document.getElementById('deleteFileBtn').addEventListener('click', handleDeleteModel);
+ document.getElementById('restoreModelBtn').addEventListener('click', async () => {
+ if (await restoreModel(fileDetailModal.dataset.currentModelId)) closeModal(fileDetailModal);
+ });
+ document.getElementById('purgeModelBtn').addEventListener('click', async () => {
+ if (await purgeModel(fileDetailModal.dataset.currentModelId)) closeModal(fileDetailModal);
+ });
+ document.getElementById('detailNotes').addEventListener('input', queueNotesSave);
+ document.getElementById('detailNotes').addEventListener('blur', flushNotes);
+
setupTagAutocomplete(document.getElementById('fileTags'));
setupTagAutocomplete(document.getElementById('editTags'));
document.getElementById('regenThumbBtn').addEventListener('click', handleRegenThumbnail);
@@ -347,13 +713,61 @@ function setupEventListeners() {
document.getElementById('addFilesInput').addEventListener('change', handleAddFiles);
}
+async function selectScope(scope) {
+ selectedScope = scope;
+ if (scope === 'trash') await loadTrash();
+ renderModels();
+}
+
// Tracks the file currently shown in the detail viewer (for thumbnail regen).
let currentDetailFile = null;
let currentDetailThumb = null; // model image, used as a viewer fallback
function closeModal(modal) {
modal.classList.remove('active');
- if (modal === fileDetailModal && window.ModelViewer) window.ModelViewer.dispose();
+ if (modal === fileDetailModal) {
+ flushNotes();
+ if (window.ModelViewer) window.ModelViewer.dispose();
+ }
+}
+
+// ---------------------------------------------------------------- notes
+// Notes autosave shortly after typing stops (and on blur / close). The pending
+// edit remembers its model, so switching models mid-edit can't misfile it.
+let pendingNotes = null;
+let notesTimer = null;
+
+function setNotesStatus(text) {
+ document.getElementById('notesStatus').textContent = text;
+}
+
+function queueNotesSave() {
+ pendingNotes = { id: fileDetailModal.dataset.currentModelId, value: document.getElementById('detailNotes').value };
+ setNotesStatus('editing…');
+ clearTimeout(notesTimer);
+ notesTimer = setTimeout(flushNotes, 800);
+}
+
+async function flushNotes() {
+ clearTimeout(notesTimer);
+ const pending = pendingNotes;
+ pendingNotes = null;
+ if (!pending) return;
+ const model = findModel(pending.id);
+ if (model && (model.notes || '') === pending.value) { setNotesStatus(''); return; }
+ setNotesStatus('saving…');
+ try {
+ const res = await fetch(`/api/models/${pending.id}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ notes: pending.value }),
+ });
+ if (!res.ok) throw new Error((await res.json()).error);
+ replaceModel(await res.json());
+ if (fileDetailModal.dataset.currentModelId === String(pending.id)) setNotesStatus('saved');
+ } catch (err) {
+ setNotesStatus('not saved — ' + err.message);
+ }
}
// ---------------------------------------------------------------- modals
@@ -443,13 +857,20 @@ function openImportModal() {
}
function openModelDetail(modelId) {
- const model = models.find((m) => String(m.id) === String(modelId));
+ const model = findModel(modelId);
if (!model) return;
+ flushNotes(); // save any edit still pending for the previously open model
fileDetailModal.dataset.currentModelId = modelId;
currentDetailThumb = model.thumbnailUrl || null;
document.getElementById('detailFileName').textContent = model.name;
document.getElementById('detailDescription').textContent = model.description || 'No description';
+ document.getElementById('detailNotes').value = model.notes || '';
+ setNotesStatus('');
+
+ const trashed = Boolean(model.deleted_at);
+ document.getElementById('detailActions').hidden = trashed;
+ document.getElementById('trashActions').hidden = !trashed;
// Source
const src = document.getElementById('detailSource');
@@ -551,7 +972,7 @@ function renderDetailLicense(model) {
function openEditModal() {
const id = fileDetailModal.dataset.currentModelId;
- const model = models.find((m) => String(m.id) === String(id));
+ const model = findModel(id);
if (!model) return;
editModal.dataset.editId = id;
@@ -633,8 +1054,7 @@ async function handleRefreshMetadata() {
if (!res.ok) throw new Error((await res.json()).error);
const updated = await res.json();
// update in-memory state, the license panel, and the card badge
- const idx = models.findIndex((m) => String(m.id) === String(modelId));
- if (idx !== -1) models[idx] = updated;
+ replaceModel(updated);
renderDetailLicense(updated);
renderModels();
btn.textContent = 'Updated ✓';
@@ -656,9 +1076,9 @@ function loadViewer(fileId, format) {
}
function updateCardThumbnail(modelId, thumbnailUrl) {
- const m = models.find((x) => String(x.id) === String(modelId));
+ const m = findModel(modelId);
if (m) m.thumbnailUrl = thumbnailUrl;
- const thumb = fileGrid.querySelector(`.file-card[data-model-id="${modelId}"] .file-thumbnail`);
+ const thumb = fileGrid.querySelector(`[data-model-id="${modelId}"] .file-thumbnail`);
if (thumb && thumbnailUrl) thumb.innerHTML = `
`;
}
@@ -870,9 +1290,6 @@ async function handleRenameCollection(id, currentName) {
body: JSON.stringify({ name: trimmed }),
});
if (!res.ok) throw new Error((await res.json()).error);
- if (String(selectedProject) === String(id)) {
- document.getElementById('contentTitle').textContent = trimmed;
- }
await loadData();
} catch (err) {
alert('Failed to rename collection: ' + err.message);
@@ -884,10 +1301,7 @@ async function handleDeleteCollection(id, name) {
try {
const res = await fetch(`/api/projects/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error((await res.json()).error);
- if (String(selectedProject) === String(id)) {
- selectedProject = 'all';
- document.getElementById('contentTitle').textContent = 'All Models';
- }
+ if (String(selectedScope) === String(id)) selectedScope = 'all';
await loadData();
} catch (err) {
alert('Failed to delete collection: ' + err.message);
@@ -919,17 +1333,17 @@ async function handleCreateProject(e) {
async function handleDeleteModel() {
const id = fileDetailModal.dataset.currentModelId;
- if (!id || !confirm('Delete this model and all its files?')) return;
+ if (!id || !confirm('Move this model to the trash?\n\nYou can restore it from Trash until the trash is emptied.')) return;
try {
const res = await fetch(`/api/models/${id}`, { method: 'DELETE' });
if (res.ok) {
closeModal(fileDetailModal);
await loadData();
} else {
- alert('Failed to delete: ' + (await res.json()).error);
+ alert('Failed to move to trash: ' + (await res.json()).error);
}
} catch (err) {
- alert('Failed to delete: ' + err.message);
+ alert('Failed to move to trash: ' + err.message);
}
}
diff --git a/public/index.html b/public/index.html
index c56a8e9..92fd9c0 100644
--- a/public/index.html
+++ b/public/index.html
@@ -84,35 +84,83 @@
diff --git a/public/styles.css b/public/styles.css
index 9e3dcdb..dbf8fd0 100644
--- a/public/styles.css
+++ b/public/styles.css
@@ -110,10 +110,11 @@ header {
transition: all 0.12s;
}
.tag:hover { border-color: var(--border-strong); color: var(--text); }
-.tag.active { background: var(--accent-soft); color: var(--accent); border-color: var(--accent); }
+.tag.include { background: var(--accent-soft); color: var(--accent); border-color: var(--accent); }
+.tag.exclude { background: var(--danger-soft); color: var(--danger); border-color: var(--danger); text-decoration: line-through; }
/* ---- form fields ---- */
-.search-input, input[type="text"], input[type="url"], input[type="password"], input[type="file"], textarea, select {
+.search-input, input[type="text"], input[type="search"], input[type="url"], input[type="password"], input[type="file"], textarea, select {
width: 100%;
font-family: inherit;
font-size: 14px;
@@ -279,3 +280,49 @@ label { display: block; font-size: 13px; font-weight: 500; color: var(--text-mut
}
.tag-suggest-item { padding: 7px 11px; font-size: 13px; cursor: pointer; color: var(--text-muted); }
.tag-suggest-item:hover, .tag-suggest-item.active { background: var(--accent-soft); color: var(--accent); }
+
+[hidden] { display: none !important; }
+
+/* ---- sidebar tag tools ---- */
+.section-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 12px; }
+.sidebar-section .section-head h3 { margin-bottom: 0; }
+.tag-tools { display: flex; align-items: center; gap: 8px; }
+.tag-tools .btn-link { padding: 0; font-size: 12px; color: var(--text-muted); }
+.seg { display: flex; background: var(--surface-2); border: 1px solid var(--border); border-radius: 6px; padding: 2px; }
+.seg button { font-family: inherit; font-size: 11px; border: none; background: transparent; color: var(--text-muted); padding: 2px 8px; border-radius: 4px; cursor: pointer; }
+.seg button.active { background: var(--surface-3); color: var(--text); }
+
+/* ---- filter bar ---- */
+.content-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; justify-content: flex-end; }
+.result-count { font-family: var(--mono); font-size: 12px; color: var(--text-dim); }
+.filter-bar { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 18px; }
+.filter-bar .search-input { flex: 1 1 260px; width: auto; }
+.filter-bar select { width: auto; flex: 0 0 auto; padding: 9px 10px; font-size: 13px; }
+.group-header {
+ grid-column: 1 / -1; display: flex; align-items: baseline; gap: 8px;
+ font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-dim);
+ padding-bottom: 6px; border-bottom: 1px solid var(--border);
+}
+.group-header:not(:first-child) { margin-top: 10px; }
+.group-header span { font-family: var(--mono); font-weight: 400; }
+.card-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
+.notes-status { font-weight: 400; text-transform: none; letter-spacing: 0; color: var(--text-dim); margin-left: 6px; }
+
+/* ---- duplicates ---- */
+.dup-list { display: flex; flex-direction: column; gap: 18px; }
+.dup-pending { font-size: 13px; color: var(--text-muted); background: var(--surface); border: 1px dashed var(--border-strong); border-radius: var(--radius-sm); padding: 10px 14px; }
+.dup-group { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; }
+.dup-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; margin-bottom: 14px; }
+.dup-head h3 { font-size: 15px; font-weight: 600; }
+.dup-head .form-hint { margin-top: 2px; }
+.dup-head .btn-sm { flex-shrink: 0; }
+.dup-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; }
+.dup-card { background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius-sm); overflow: hidden; cursor: pointer; transition: border-color 0.15s; }
+.dup-card:hover { border-color: var(--border-strong); }
+.dup-card.recommended { border-color: var(--success); }
+.dup-card .file-thumbnail { height: 130px; font-size: 34px; }
+.dup-card .file-info { padding: 10px 12px; display: flex; flex-direction: column; gap: 4px; }
+.dup-card .file-name { margin-bottom: 0; font-size: 14px; }
+.dup-card .btn { margin-top: 6px; }
+.rec-badge { align-self: flex-start; font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em; padding: 2px 8px; border-radius: 10px; background: rgba(76, 195, 138, 0.16); color: var(--success); }
+.warn { color: var(--accent); }
diff --git a/server.js b/server.js
index 4e00f5b..8f266e6 100644
--- a/server.js
+++ b/server.js
@@ -19,6 +19,8 @@ const licenses = require('./lib/licenses');
const cookieParser = require('cookie-parser');
const auth = require('./lib/auth');
const mailer = require('./lib/mailer');
+const fingerprint = require('./lib/fingerprint');
+const duplicates = require('./lib/duplicates');
const app = express();
const PORT = process.env.PORT || 3000;
@@ -292,7 +294,7 @@ app.put('/api/settings', async (req, res, next) => {
// ----------------------------------------------------------------- models
app.get('/api/models', async (req, res, next) => {
try {
- const models = await store.getAllModels();
+ const models = await store.getAllModels({ trash: req.query.view === 'trash' });
res.json(models.map(publicModel));
} catch (e) { next(e); }
});
@@ -321,6 +323,7 @@ app.post('/api/models', upload.array('files'), async (req, res, next) => {
thumbnail_path: pickThumbnail(files),
files,
});
+ fingerprint.kick();
res.status(201).json(publicModel(model));
} catch (e) { next(e); }
});
@@ -332,6 +335,7 @@ app.post('/api/models/:id/files', upload.array('files'), async (req, res, next)
if (!existing) return res.status(404).json({ error: 'Model not found' });
const files = (req.files || []).map((f) => describeFile(f.path, f.filename, f.originalname));
const model = await store.addFiles(req.params.id, files);
+ fingerprint.kick();
res.json(publicModel(model));
} catch (e) { next(e); }
});
@@ -403,6 +407,7 @@ app.post('/api/models/:id/download-files', async (req, res, next) => {
return res.status(200).json({ ...publicModel(model), added: 0 });
}
const updated = await store.addFiles(model.id, newFiles);
+ fingerprint.kick();
res.json({ ...publicModel(updated), added: newFiles.length });
} catch (e) { next(e); }
});
@@ -429,17 +434,54 @@ async function fetchSourceFiles(model, skipNames) {
throw e;
}
+// Moves a model to the trash (restorable). ?permanent=true deletes it and its
+// files from disk — only for a model that's already in the trash.
app.delete('/api/models/:id', async (req, res, next) => {
try {
- const removed = await store.deleteModel(req.params.id);
- if (!removed) return res.status(404).json({ error: 'Model not found' });
- for (const p of removed.filePaths) { if (p && fs.existsSync(p)) fs.unlinkSync(p); }
- for (const p of removed.glbPaths) { if (p && fs.existsSync(p)) fs.unlinkSync(p); }
- if (removed.thumbnailPath && fs.existsSync(removed.thumbnailPath)) fs.unlinkSync(removed.thumbnailPath);
- res.json({ message: 'Model deleted' });
+ const model = await store.getModel(req.params.id);
+ if (!model) return res.status(404).json({ error: 'Model not found' });
+ if (req.query.permanent === 'true') {
+ if (!model.deleted_at) {
+ return res.status(400).json({ error: 'Move the model to the trash before deleting it permanently.' });
+ }
+ await purgeModel(model.id);
+ return res.json({ message: 'Model permanently deleted' });
+ }
+ await store.trashModels([model.id]);
+ res.json({ message: 'Model moved to trash' });
} catch (e) { next(e); }
});
+app.post('/api/models/:id/restore', async (req, res, next) => {
+ try {
+ if (!(await store.restoreModel(req.params.id))) {
+ return res.status(404).json({ error: 'Model not found in trash' });
+ }
+ res.json(publicModel(await store.getModel(req.params.id)));
+ } catch (e) { next(e); }
+});
+
+// Empty the trash: permanently delete every trashed model and its files.
+app.delete('/api/trash', async (req, res, next) => {
+ try {
+ const ids = await store.trashedModelIds();
+ for (const id of ids) await purgeModel(id);
+ res.json({ deleted: ids.length });
+ } catch (e) { next(e); }
+});
+
+// Delete a model's rows, then its files, GLB conversions and thumbnail on disk.
+async function purgeModel(id) {
+ const removed = await store.deleteModel(id);
+ if (!removed) return;
+ for (const p of [...removed.filePaths, ...removed.glbPaths, removed.thumbnailPath]) {
+ if (!p) continue;
+ try { fs.unlinkSync(p); } catch (err) {
+ if (err.code !== 'ENOENT') console.warn(`Could not delete ${p}:`, err.message);
+ }
+ }
+}
+
// Store a client-rendered thumbnail (PNG data URL) for a model.
app.post('/api/models/:id/thumbnail', async (req, res, next) => {
try {
@@ -614,6 +656,7 @@ app.post('/api/import', async (req, res, next) => {
projects,
files,
});
+ fingerprint.kick();
res.status(201).json({ ...publicModel(model), metadataOnly: files.length === 0, metadataFetched });
} catch (e) { next(e); }
});
@@ -626,7 +669,9 @@ async function importThingiverse(url, projects) {
const existing = await store.findModelBySource('thingiverse', String(thingId));
if (existing) { const e = new Error('Already imported'); e.status = 409; throw e; }
const meta = await thingiverse.fetchModel(thingId, token);
- return publicModel(await downloadAndCreateModel(meta, projects));
+ const model = await downloadAndCreateModel(meta, projects);
+ fingerprint.kick();
+ return publicModel(model);
}
// Fetch a page's HTML. Tries a direct request first; if the response is a
@@ -809,6 +854,68 @@ app.get('/api/tags', async (req, res, next) => {
try { res.json(await store.getAllTags()); } catch (e) { next(e); }
});
+// ----------------------------------------------------------------- library / duplicates
+// Duplicate groups among non-trashed models, minus ones marked "not duplicates".
+async function findDuplicateGroups() {
+ const [files, dismissed] = await Promise.all([
+ store.duplicateCandidateFiles(), store.dismissedDuplicateSignatures()]);
+ const skip = new Set(dismissed);
+ return duplicates.groupDuplicates(files).filter((g) => !skip.has(duplicates.signature(g.modelIds)));
+}
+
+app.get('/api/library/stats', async (req, res, next) => {
+ try {
+ const [trash, groups, pending] = await Promise.all([
+ store.countTrashed(), findDuplicateGroups(), fingerprint.pendingCount()]);
+ res.json({ trash, duplicateGroups: groups.length, fingerprintsPending: pending });
+ } catch (e) { next(e); }
+});
+
+app.get('/api/duplicates', async (req, res, next) => {
+ try {
+ const [groups, pending] = await Promise.all([findDuplicateGroups(), fingerprint.pendingCount()]);
+ const found = await store.getModelsByIds(groups.flatMap((g) => g.modelIds));
+ const byId = new Map(found.map((m) => [m.id, m]));
+ const result = groups
+ .map((g) => ({ ...g, models: g.modelIds.map((id) => byId.get(id)).filter(Boolean) }))
+ .filter((g) => g.models.length > 1)
+ .map((g) => ({
+ signature: duplicates.signature(g.modelIds),
+ exact: g.exact,
+ recommendedId: duplicates.recommendKeep(g.models),
+ models: g.models.map((m) => ({ ...publicModel(m), matchedFiles: g.matchedFiles[m.id] })),
+ }));
+ res.json({ pending, groups: result });
+ } catch (e) { next(e); }
+});
+
+// Keep one model of a duplicate group; the rest are merged into it and trashed.
+app.post('/api/duplicates/resolve', async (req, res, next) => {
+ try {
+ const keepId = Number(req.body.keepId);
+ const trashIds = [...new Set((req.body.trashIds || []).map(Number))];
+ if (!Number.isInteger(keepId) || trashIds.length === 0
+ || trashIds.some((id) => !Number.isInteger(id) || id === keepId)) {
+ return res.status(400).json({ error: 'Pass keepId and a non-empty trashIds list that excludes it.' });
+ }
+ const found = await store.getModelsByIds([keepId, ...trashIds]);
+ if (found.length !== trashIds.length + 1 || found.some((m) => m.deleted_at)) {
+ return res.status(409).json({ error: 'Some of those models are gone or already in the trash — refresh and try again.' });
+ }
+ await store.mergeAndTrash(keepId, trashIds);
+ res.json(publicModel(await store.getModel(keepId)));
+ } catch (e) { next(e); }
+});
+
+app.post('/api/duplicates/dismiss', async (req, res, next) => {
+ try {
+ const ids = (req.body.modelIds || []).map(Number).filter(Number.isInteger);
+ if (new Set(ids).size < 2) return res.status(400).json({ error: 'Pass at least two modelIds.' });
+ await store.dismissDuplicateGroup(duplicates.signature(ids));
+ res.json({ ok: true });
+ } catch (e) { next(e); }
+});
+
// ----------------------------------------------------------------- misc
function parseJson(value, fallback) {
if (value === undefined || value === null || value === '') return fallback;
@@ -822,5 +929,8 @@ app.use((err, req, res, next) => {
});
db.init()
- .then(() => app.listen(PORT, () => console.log(`Printhive running on http://localhost:${PORT}`)))
+ .then(() => {
+ app.listen(PORT, () => console.log(`Printhive running on http://localhost:${PORT}`));
+ fingerprint.kick(); // backfill fingerprints for files stored before this ran
+ })
.catch((err) => { console.error('Failed to start (DB init):', err); process.exit(1); });
diff --git a/test/duplicates.test.js b/test/duplicates.test.js
new file mode 100644
index 0000000..28b059c
--- /dev/null
+++ b/test/duplicates.test.js
@@ -0,0 +1,63 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const { groupDuplicates, signature, recommendKeep } = require('../lib/duplicates');
+const { geometryKey } = require('../lib/fingerprint');
+
+const file = (id, model_id, sha256, geometry_hash = null) => ({ id, model_id, sha256, geometry_hash });
+
+test('identical content in two models is an exact group', () => {
+ const groups = groupDuplicates([file(1, 10, 'aaa'), file(2, 20, 'aaa'), file(3, 30, 'zzz')]);
+ assert.equal(groups.length, 1);
+ assert.deepEqual(groups[0].modelIds, [10, 20]);
+ assert.equal(groups[0].exact, true);
+ assert.deepEqual(groups[0].matchedFiles, { 10: 1, 20: 1 });
+});
+
+test('same geometry with different bytes is flagged for review', () => {
+ const [group] = groupDuplicates([file(1, 10, 'aaa', 'g1'), file(2, 20, 'bbb', 'g1')]);
+ assert.deepEqual(group.modelIds, [10, 20]);
+ assert.equal(group.exact, false);
+});
+
+test('same geometry and same bytes stays exact', () => {
+ const [group] = groupDuplicates([file(1, 10, 'aaa', 'g1'), file(2, 20, 'aaa', 'g1')]);
+ assert.equal(group.exact, true);
+});
+
+test('a file repeated inside one model is not a duplicate group', () => {
+ assert.deepEqual(groupDuplicates([file(1, 10, 'aaa'), file(2, 10, 'aaa')]), []);
+});
+
+test('links are transitive, so packs collapse into one group', () => {
+ const groups = groupDuplicates([
+ file(1, 10, 'a'), file(2, 10, 'b'),
+ file(3, 20, 'a'), file(4, 20, 'b', 'g'),
+ file(5, 30, 'c', 'g'),
+ ]);
+ assert.equal(groups.length, 1);
+ assert.deepEqual(groups[0].modelIds, [10, 20, 30]);
+ assert.equal(groups[0].exact, false);
+ assert.deepEqual(groups[0].matchedFiles, { 10: 2, 20: 2, 30: 1 });
+});
+
+test('signature ignores order and repeats', () => {
+ assert.equal(signature([3, 1, 2, 1]), '1,2,3');
+ assert.equal(signature(['20', '10']), '10,20');
+});
+
+test('recommendKeep prefers the more complete model, then the older one', () => {
+ const bare = { id: 1, date_added: '2026-01-01', files: [{}], tags: [], projects: [] };
+ const sourced = { id: 2, date_added: '2026-02-01', source_url: 'https://x', license: 'CC-BY', files: [{}], tags: ['a'], projects: [] };
+ assert.equal(recommendKeep([bare, sourced]), 2);
+ const older = { ...bare, id: 3, date_added: '2025-12-01' };
+ assert.equal(recommendKeep([bare, older]), 3);
+});
+
+test('geometryKey ignores axis order, position and float noise', () => {
+ const a = { faces: 1200, area: 5321.4567, volume: -18342.12, extents: [20, 40.02, 10] };
+ const b = { faces: 1200, area: 5321.4571, volume: 18342.09, extents: [40.019, 10.001, 20] };
+ assert.equal(geometryKey(a), geometryKey(b));
+ assert.notEqual(geometryKey(a), geometryKey({ ...a, faces: 1202 }));
+ assert.notEqual(geometryKey(a), geometryKey({ ...a, extents: [20, 40.02, 12] }));
+ assert.equal(geometryKey({ faces: 0 }), null);
+});
diff --git a/tools/fingerprint.py b/tools/fingerprint.py
new file mode 100644
index 0000000..22475ab
--- /dev/null
+++ b/tools/fingerprint.py
@@ -0,0 +1,40 @@
+#!/usr/bin/env python3
+"""Measure mesh files for geometry fingerprints (duplicate detection).
+
+Usage: fingerprint.py [ ...]
+Prints one JSON line per file: {path, fp: {faces, area, volume, extents}} or {path, error}.
+Multi-object files (e.g. 3MF projects) are measured as one combined mesh.
+"""
+import sys
+import json
+import trimesh
+
+
+def measure(path):
+ mesh = trimesh.load(path, force="mesh")
+ if mesh is None or len(mesh.faces) == 0:
+ raise ValueError("no geometry found")
+ try:
+ volume = abs(float(mesh.volume))
+ except Exception:
+ volume = 0.0
+ return {
+ "faces": int(len(mesh.faces)),
+ "area": float(mesh.area),
+ "volume": volume,
+ "extents": [float(x) for x in mesh.extents],
+ }
+
+
+def main():
+ for path in sys.argv[1:]:
+ try:
+ out = {"path": path, "fp": measure(path)}
+ except Exception as exc:
+ out = {"path": path, "error": str(exc)[:200]}
+ print(json.dumps(out), flush=True)
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())