Merge phase 1: library core (duplicates, trash, notes, search and filters)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
dlawler489 2026-09-15 10:36:26 +10:00
commit f401eb4ca4
21 changed files with 1509 additions and 168 deletions

1
.gitignore vendored
View file

@ -8,3 +8,4 @@ thumbnails/
.env
glb/
tools/venv/
__pycache__/

View file

@ -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 <kbd>/</kbd> 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:

View file

@ -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()
);

View file

@ -1,32 +1,22 @@
// Converts 3MF files to GLB via the Python/trimesh sidecar, so the browser can
// render Bambu/MakerWorld production-extension 3MFs (which the JS 3MFLoader can't).
const path = require('path');
const { execFile } = require('child_process');
const PYTHON = process.env.PYTHON_BIN || path.join(__dirname, '..', 'tools', 'venv', 'bin', 'python');
const SCRIPT = path.join(__dirname, '..', 'tools', 'convert_3mf.py');
const { runScript } = require('./python');
// Returns { glb, volume_cm3, bbox, faces } or throws.
function convert3mfToGlb(inputPath, outputPath) {
return new Promise((resolve, reject) => {
execFile(
PYTHON,
[SCRIPT, inputPath, outputPath],
{ maxBuffer: 16 * 1024 * 1024, timeout: 120000 },
(err, stdout, stderr) => {
if (err) return reject(new Error(stderr ? stderr.split('\n').slice(-3).join(' ') : err.message));
let result;
try {
result = JSON.parse(stdout.trim().split('\n').pop());
} catch {
return reject(new Error('Converter returned unparseable output.'));
}
if (result.error) return reject(new Error(result.error));
resolve(result);
}
);
});
async function convert3mfToGlb(inputPath, outputPath) {
const { err, stdout, stderr } = await runScript('convert_3mf.py', [inputPath, outputPath], { timeout: 120000 });
let result = null;
try {
result = JSON.parse(stdout.trim().split('\n').pop());
} catch { /* handled below */ }
if (result && result.error) throw new Error(result.error);
if (err) {
if (err.killed) throw new Error('Timed out converting this 3MF for preview.');
throw new Error(stderr ? stderr.trim().split('\n').slice(-3).join(' ') : err.message);
}
if (!result) throw new Error('Converter returned unparseable output.');
return result;
}
module.exports = { convert3mfToGlb };

86
lib/duplicates.js Normal file
View file

@ -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 };

141
lib/fingerprint.js Normal file
View file

@ -0,0 +1,141 @@
// 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 db = require('./db');
const { PYTHON, runScript } = require('./python');
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.
async function measure(paths) {
const { err, stdout } = await runScript('fingerprint.py', paths,
{ timeout: 60000 * (paths.length + 1), isBackground: true });
if (err && err.code === 'ENOENT') throw err;
const results = new Map();
for (const line of stdout.split('\n')) {
if (!line.trim()) continue;
try {
const r = JSON.parse(line);
results.set(r.path, r);
} catch { /* ignore non-JSON noise */ }
}
return results;
}
async function fingerprintGeometry(rows) {
const abs = (row) => path.resolve(row.file_path);
// 3MF projects can be big, so each gets its own run; that keeps a queued
// preview from waiting behind a whole batch of them.
const chunks = [rows.filter((r) => r.format !== '3mf'), ...rows.filter((r) => r.format === '3mf').map((r) => [r])];
for (const chunk of chunks.filter((c) => c.length)) {
const results = await measure(chunk.map(abs));
for (const row of chunk) {
let r = results.get(abs(row));
// A batch that died part-way: retry the missing file on its own.
if (!r && chunk.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 };

42
lib/python.js Normal file
View file

@ -0,0 +1,42 @@
// Runs the Python/trimesh sidecar scripts one at a time. Loading a large mesh
// can take hundreds of MB, so a preview conversion and background
// fingerprinting never run at once. Jobs someone is waiting on (previews) go
// ahead of queued background work.
const path = require('path');
const { execFile } = require('child_process');
const PYTHON = process.env.PYTHON_BIN || path.join(__dirname, '..', 'tools', 'venv', 'bin', 'python');
const TOOLS = path.join(__dirname, '..', 'tools');
const urgent = [];
const background = [];
let busy = false;
// Resolves { err, stdout, stderr } — never rejects, so callers can use partial
// output from a run that failed part-way.
function runScript(script, args, { timeout = 120000, isBackground = false } = {}) {
return new Promise((resolve) => {
(isBackground ? background : urgent).push({ script, args, timeout, resolve });
next();
});
}
function next() {
if (busy) return;
const job = urgent.shift() || background.shift();
if (!job) return;
busy = true;
execFile(
PYTHON,
[path.join(TOOLS, job.script), ...job.args],
{ maxBuffer: 16 * 1024 * 1024, timeout: job.timeout },
(err, stdout, stderr) => {
busy = false;
job.resolve({ err, stdout: String(stdout || ''), stderr: String(stderr || '') });
next();
}
);
}
module.exports = { PYTHON, runScript };

View file

@ -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,

View file

@ -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",

View file

@ -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 = `<img src="${updated.thumbnailUrl}" alt="" loading="lazy">`;
}
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) => `
<div class="project-item${String(selectedProject) === String(p.id) ? ' active' : ''}" data-project-id="${p.id}">
<span class="project-name">${escapeHtml(p.name)}</span>
<span class="project-meta">
<button class="collection-action collection-rename" title="Rename" aria-label="Rename collection">&#9998;</button>
<button class="collection-action collection-delete" title="Delete" aria-label="Delete collection">&times;</button>
<span class="count" data-project-count="${p.id}">${p.model_count}</span>
</span>
</div>`).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) => `
<div class="project-item" data-scope="${p.id}">
<span class="project-name">${escapeHtml(p.name)}</span>
<span class="project-meta">
<button class="collection-action collection-rename" title="Rename" aria-label="Rename collection">&#9998;</button>
<button class="collection-action collection-delete" title="Delete" aria-label="Delete collection">&times;</button>
<span class="count">${p.model_count}</span>
</span>
</div>`).join('')
: '<p class="form-hint">No collections yet.</p>';
}
function renderTags() {
const all = new Set();
models.forEach((m) => (m.tags || []).forEach((t) => all.add(t)));
tagsList.innerHTML = Array.from(all).sort().map((tag) => `
<span class="tag${selectedTags.includes(tag) ? ' active' : ''}" data-tag="${escapeHtml(tag)}">${escapeHtml(tag)}</span>`).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 `<span class="tag${state ? ` ${state}` : ''}" data-tag="${escapeHtml(tag)}"
title="${titles[state] || 'Click to show only models with this tag'}">${escapeHtml(tag)}</span>`;
}).join('')
: '<p class="form-hint">No tags yet.</p>';
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 = `<option value="">${anyLabel}</option>`
+ options.map(([value, label]) => `<option value="${escapeHtml(value)}">${escapeHtml(label)}</option>`).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 = `
<div class="empty-state">
<h3>No models found</h3>
<p>Upload files or import from a URL to get started</p>
</div>`;
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]) => `<div class="group-header">${escapeHtml(label)} <span>${ms.length}</span></div>`
+ 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
? `<img src="${m.thumbnailUrl}" alt="" loading="lazy">`
: '<span>📦</span>';
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) => `<div class="empty-state"><h3>${title}</h3><p>${text}</p></div>`;
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 ? `<img src="${m.thumbnailUrl}" alt="" loading="lazy">` : '<span>📦</span>';
}
function cardHtml(m) {
const trashed = Boolean(m.deleted_at);
const when = trashed ? `trashed ${formatDate(m.deleted_at)}` : formatDate(m.date_added);
return `
<div class="file-card" data-model-id="${m.id}">
<div class="file-thumbnail">${thumb}</div>
<div class="file-thumbnail">${thumbHtml(m)}</div>
<div class="file-info">
<div class="file-name">${escapeHtml(m.name)}</div>
${m.source_site ? `<span class="source-badge">${escapeHtml(m.source_site)}</span>` : ''}
${m.commercial_use === true ? '<span class="com-badge ok" title="Commercial use allowed">$ sellable</span>' : m.commercial_use === false ? '<span class="com-badge no" title="Non-commercial only">non-commercial</span>' : ''}
${m.description ? `<div class="file-description">${escapeHtml(m.description)}</div>` : ''}
${(m.tags && m.tags.length) ? `<div class="file-tags">${m.tags.map((t) => `<span class="file-tag">${escapeHtml(t)}</span>`).join('')}</div>` : ''}
<div class="file-meta">${m.files.length} file${m.files.length === 1 ? '' : 's'} • ${formatFileSize(totalSize)} • ${formatDate(m.date_added)}</div>
<div class="file-meta">${fileCountLabel(m)} • ${formatFileSize(totalSize(m))} • ${when}</div>
${trashed ? `
<div class="card-actions">
<button class="btn btn-secondary btn-sm" data-action="restore">Restore</button>
<button class="btn btn-danger btn-sm" data-action="purge">Delete forever</button>
</div>` : ''}
</div>
</div>`;
}).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 = '<div class="empty-state"><p>Looking for duplicates…</p></div>';
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 = `<div class="empty-state"><h3>Couldn't check for duplicates</h3><p>${escapeHtml(err.message)}</p></div>`;
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
? `<div class="dup-pending">Fingerprinting ${pending} file${pending === 1 ? '' : 's'}… this list updates automatically.</div>`
: '';
fileGrid.innerHTML = pendingNote + (groups.length
? groups.map(duplicateGroupHtml).join('')
: '<div class="empty-state"><h3>No duplicates found</h3><p>Files are compared by content and by shape, so renamed or re-exported copies show up here too.</p></div>');
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 `
<section class="dup-group" data-signature="${escapeHtml(g.signature)}">
<header class="dup-head">
<div>
<h3>${title} · ${g.models.length} models</h3>
<p class="form-hint">${hint}</p>
</div>
<button class="btn btn-secondary btn-sm" data-action="dismiss">Not duplicates</button>
</header>
<div class="dup-cards">${g.models.map((m) => duplicateCardHtml(g, m)).join('')}</div>
</section>`;
}
function duplicateCardHtml(g, m) {
const recommended = m.id === g.recommendedId;
const unique = m.files.length - m.matchedFiles;
return `
<div class="dup-card${recommended ? ' recommended' : ''}" data-model-id="${m.id}">
<div class="file-thumbnail">${thumbHtml(m)}</div>
<div class="file-info">
${recommended ? '<span class="rec-badge">Recommended keep</span>' : ''}
<div class="file-name" title="${escapeHtml(m.name)}">${escapeHtml(m.name)}</div>
<div class="file-meta">${escapeHtml(sourceLabel(sourceKey(m)))} • ${formatDate(m.date_added)}</div>
<div class="file-meta">${m.matchedFiles} of ${m.files.length} files match${unique ? ` • <span class="warn">${unique} unique</span>` : ''}</div>
<button class="btn btn-primary btn-sm" data-action="keep">Keep this one</button>
</div>
</div>`;
}
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 = `<img src="${thumbnailUrl}" alt="" loading="lazy">`;
}
@ -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);
}
}

View file

@ -84,35 +84,83 @@
<div class="main-content">
<aside class="sidebar">
<div class="sidebar-section">
<h3>Collections</h3>
<div id="projectsList" class="projects-list">
<div class="project-item active" data-project-id="all">
<h3>Library</h3>
<div id="libraryList" class="projects-list">
<div class="project-item active" data-scope="all">
<span>All Models</span>
<span class="count" id="allFilesCount">0</span>
</div>
<div class="project-item" data-scope="duplicates">
<span>Duplicates</span>
<span class="count" id="duplicatesCount">0</span>
</div>
<div class="project-item" data-scope="trash">
<span>Trash</span>
<span class="count" id="trashCount">0</span>
</div>
</div>
</div>
<div class="sidebar-section">
<h3>Tags</h3>
<div id="tagsList" class="tags-list"></div>
<h3>Collections</h3>
<div id="projectsList" class="projects-list"></div>
</div>
<div class="sidebar-section">
<h3>Search</h3>
<input type="text" id="searchInput" placeholder="Search models..." class="search-input">
<div class="section-head">
<h3>Tags</h3>
<div class="tag-tools">
<button type="button" class="btn-link" id="clearTags" hidden>Clear</button>
<div class="seg" id="tagMatch" title="When several tags are included, match any or all of them">
<button type="button" data-match="any">Any</button>
<button type="button" data-match="all">All</button>
</div>
</div>
</div>
<div id="tagsList" class="tags-list"></div>
<p class="form-hint">Click a tag to include it, again to exclude it.</p>
</div>
</aside>
<main class="content">
<div class="content-header">
<h2 id="contentTitle">All Models</h2>
<div class="view-options">
<button class="view-btn active" data-view="grid">Grid</button>
<button class="view-btn" data-view="list">List</button>
<div class="content-actions">
<span id="resultCount" class="result-count"></span>
<button type="button" id="emptyTrashBtn" class="btn btn-danger btn-sm" hidden>Empty trash</button>
<div class="view-options">
<button class="view-btn active" data-view="grid">Grid</button>
<button class="view-btn" data-view="list">List</button>
</div>
</div>
</div>
<div id="filterBar" class="filter-bar">
<input type="search" id="searchInput" class="search-input"
placeholder="Search names, tags, notes, files, designers… ( / )" aria-label="Search models">
<select id="filterFormat" aria-label="Filter by format"><option value="">Any format</option></select>
<select id="filterSource" aria-label="Filter by source"><option value="">Any source</option></select>
<select id="filterLicense" aria-label="Filter by license">
<option value="">Any license</option>
<option value="sellable">Sellable</option>
<option value="noncommercial">Non-commercial</option>
<option value="unknown">License unclear</option>
</select>
<select id="sortBy" aria-label="Sort">
<option value="newest">Newest first</option>
<option value="oldest">Oldest first</option>
<option value="name">Name A–Z</option>
<option value="name-desc">Name Z–A</option>
<option value="size">Largest first</option>
<option value="modified">Recently edited</option>
</select>
<select id="groupBy" aria-label="Group">
<option value="">No grouping</option>
<option value="source">Group by source</option>
<option value="format">Group by format</option>
</select>
</div>
<div id="fileGrid" class="file-grid"></div>
</main>
</div>
@ -378,6 +426,10 @@
<h3>Description</h3>
<p id="detailDescription"></p>
</div>
<div class="info-section">
<h3>Notes <span id="notesStatus" class="notes-status"></span></h3>
<textarea id="detailNotes" rows="3" placeholder="Your notes — settings that worked, tweaks, who it's for…"></textarea>
</div>
<div class="info-section">
<h3>Tags</h3>
<div id="detailTags" class="tags"></div>
@ -397,11 +449,15 @@
</div>
<input type="file" id="addFilesInput" multiple style="display:none"
accept=".stl,.3mf,.obj,.step,.stp,.gcode,.gco,.zip">
<div class="form-actions">
<button class="btn btn-danger" id="deleteFileBtn">Delete</button>
<div class="form-actions" id="detailActions">
<button class="btn btn-danger" id="deleteFileBtn">Move to trash</button>
<button class="btn btn-secondary" id="addFilesBtn">Add files</button>
<button class="btn btn-primary" id="editModelBtn">Edit</button>
</div>
<div class="form-actions" id="trashActions" hidden>
<button class="btn btn-danger" id="purgeModelBtn">Delete forever</button>
<button class="btn btn-primary" id="restoreModelBtn">Restore</button>
</div>
</div>
</div>
</div>

View file

@ -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); }

128
server.js
View file

@ -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); });

63
test/duplicates.test.js Normal file
View file

@ -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);
});

BIN
test/fixtures/components.3mf vendored Normal file

Binary file not shown.

BIN
test/fixtures/cube.stl vendored Normal file

Binary file not shown.

BIN
test/fixtures/cube_rotated.3mf vendored Normal file

Binary file not shown.

55
test/geometry.test.js Normal file
View file

@ -0,0 +1,55 @@
// Exercises the Python sidecar scripts against small fixtures. Skipped when the
// Python venv isn't installed.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { execFileSync } = require('child_process');
const { PYTHON } = require('../lib/python');
const { geometryKey } = require('../lib/fingerprint');
const TOOLS = path.join(__dirname, '..', 'tools');
const fixture = (name) => path.join(__dirname, 'fixtures', name);
const skip = !fs.existsSync(PYTHON) && 'Python venv not installed';
function fingerprints(...names) {
const out = execFileSync(PYTHON, [path.join(TOOLS, 'fingerprint.py'), ...names.map(fixture)], { encoding: 'utf8' });
return out.trim().split('\n').map((line) => JSON.parse(line));
}
test('a cube matches across binary STL and a rotated, moved 3MF', { skip }, () => {
const [stl, threemf] = fingerprints('cube.stl', 'cube_rotated.3mf');
assert.ok(stl.fp && threemf.fp, JSON.stringify([stl, threemf]));
assert.equal(threemf.fp.faces, 12);
assert.equal(geometryKey(stl.fp), geometryKey(threemf.fp));
});
// components.3mf is laid out like a Bambu project: the root model's objects are
// components pointing (via p:path) at object 1 of a separate part file, which
// also holds a decoy object 2. One placement is mirrored.
test('3MF components resolve to the one object they reference, once per placement', { skip }, () => {
const [r] = fingerprints('components.3mf');
assert.ok(r.fp, JSON.stringify(r));
assert.equal(r.fp.faces, 24);
assert.ok(Math.abs(r.fp.volume - 16000) < 1e-6, `volume ${r.fp.volume}`);
assert.deepEqual(r.fp.extents.map(Math.round).sort((a, b) => a - b), [10, 40, 120]);
});
test('3MF converts to GLB with shared geometry and a combined bounding box', { skip }, () => {
const out = path.join(os.tmpdir(), `printhive-test-${process.pid}.glb`);
try {
const stdout = execFileSync(PYTHON, [path.join(TOOLS, 'convert_3mf.py'), fixture('components.3mf'), out], { encoding: 'utf8' });
const summary = JSON.parse(stdout.trim().split('\n').pop());
assert.equal(summary.faces, 24);
assert.deepEqual([summary.bbox.x, summary.bbox.y, summary.bbox.z].map(Math.round), [120, 40, 10]);
assert.ok(Math.abs(summary.volume_cm3 - 16) < 1e-6);
const glb = fs.readFileSync(out);
const gltf = JSON.parse(glb.subarray(20, 20 + glb.readUInt32LE(12)).toString('utf8'));
assert.equal(gltf.meshes.length, 1, 'the object is stored once');
assert.equal(gltf.nodes.filter((n) => n.mesh !== undefined).length, 2, 'and placed twice');
} finally {
fs.rmSync(out, { force: true });
}
});

View file

@ -6,8 +6,11 @@ Prints a JSON summary {glb, volume_cm3, bbox, faces} to stdout.
"""
import sys
import json
import trimesh
import threemf_stream
def main():
if len(sys.argv) != 3:
@ -15,28 +18,38 @@ def main():
return 2
in_path, out_path = sys.argv[1], sys.argv[2]
scene = trimesh.load(in_path, force="scene")
geoms = list(scene.geometry.values())
if not geoms:
try:
meshes, placements = threemf_stream.load(in_path)
except Exception as exc:
print(json.dumps({"error": f"could not read 3MF: {str(exc)[:200]}"}))
return 1
if not placements:
print(json.dumps({"error": "no geometry found in 3MF"}))
return 1
# Each object is stored once; repeated placements become instances of it.
scene = trimesh.Scene()
added = set()
for i, (key, transform) in enumerate(placements):
node = f"part{i}"
if key in added:
scene.graph.update(frame_from=scene.graph.base_frame, frame_to=node, matrix=transform, geometry=key)
else:
v, f = meshes[key]
scene.add_geometry(trimesh.Trimesh(vertices=v, faces=f, process=False),
node_name=node, geom_name=key, transform=transform)
added.add(key)
scene.export(out_path, file_type="glb")
combined = trimesh.util.concatenate(geoms)
ext = combined.bounds[1] - combined.bounds[0]
summary = {
stats = threemf_stream.measure(meshes, placements)
ext = stats["extents"]
print(json.dumps({
"glb": out_path,
"bbox": {"x": round(float(ext[0]), 3), "y": round(float(ext[1]), 3), "z": round(float(ext[2]), 3)},
"faces": int(sum(len(g.faces) for g in geoms)),
}
try:
# trimesh volume is in model units (mm) -> cm^3 to match the rest of the app
summary["volume_cm3"] = round(float(combined.volume) / 1000.0, 3)
except Exception:
summary["volume_cm3"] = None
print(json.dumps(summary))
"bbox": {"x": round(ext[0], 3), "y": round(ext[1], 3), "z": round(ext[2], 3)},
"faces": stats["faces"],
# mm^3 -> cm^3 to match the rest of the app
"volume_cm3": round(stats["volume"] / 1000.0, 3),
}))
return 0

49
tools/fingerprint.py Normal file
View file

@ -0,0 +1,49 @@
#!/usr/bin/env python3
"""Measure mesh files for geometry fingerprints (duplicate detection).
Usage: fingerprint.py <file> [<file> ...]
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
import threemf_stream
def measure(path):
if path.lower().endswith(".3mf"):
stats = threemf_stream.measure(*threemf_stream.load(path))
if not stats["faces"]:
raise ValueError("no geometry found")
return stats
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())

160
tools/threemf_stream.py Normal file
View file

@ -0,0 +1,160 @@
"""Streaming 3MF reader shared by convert_3mf.py and fingerprint.py.
trimesh's 3MF loader re-reads a whole production-extension part file (every
mesh in it) for each component that points into that file. Bambu Studio
projects are hundreds of components pointing into a few large part files, so
the geometry multiplies until the process runs out of memory.
This reader parses each part file once, element by element, resolves each
component to the one object it names, and returns every object's mesh once
plus the transforms where it is placed — so parts reused across plates share
their geometry.
"""
import zipfile
from array import array
import numpy as np
from lxml import etree
UNIT_MM = {"micron": 0.001, "millimeter": 1.0, "centimeter": 10.0, "inch": 25.4, "foot": 304.8, "meter": 1000.0}
PARSER = dict(huge_tree=True, resolve_entities=False, no_network=True)
MAX_DEPTH = 32
def _local(name):
return name.rpartition("}")[2]
def _path_attr(el):
# The production extension's p:path, whatever prefix the file bound it to.
for key, value in el.attrib.items():
if _local(key) == "path":
return value
return None
def _matrix(text):
m = np.eye(4)
if text:
v = [float(x) for x in text.split()]
if len(v) == 12:
# 3MF lists "m00 m01 m02 m10 ... m32" for row vectors; store column form.
m[:3, :3] = np.array(v[:9]).reshape(3, 3).T
m[:3, 3] = v[9:12]
return m
def _parse_part(fh):
"""Returns (objects, build items, unit) for one model part.
objects: {id: (vertices (n,3) float64, faces (m,3) int32, [(path|None, objectid, 4x4)])}
"""
objects, items, unit = {}, [], None
oid = verts = tris = comps = None
for event, el in etree.iterparse(fh, events=("start", "end"), **PARSER):
name = _local(el.tag)
if event == "start":
if name == "model":
unit = el.get("unit")
elif name == "object":
oid, verts, tris, comps = el.get("id"), array("d"), array("i"), []
continue
if name == "vertex":
verts.extend((float(el.get("x")), float(el.get("y")), float(el.get("z"))))
elif name == "triangle":
tris.extend((int(el.get("v1")), int(el.get("v2")), int(el.get("v3"))))
elif name == "component":
comps.append((_path_attr(el), el.get("objectid"), _matrix(el.get("transform"))))
elif name == "item":
items.append((el.get("objectid"), _matrix(el.get("transform"))))
elif name == "object":
v = np.frombuffer(verts, dtype=np.float64).reshape(-1, 3)
f = np.frombuffer(tris, dtype=np.int32).reshape(-1, 3)
if len(f) and (f.min() < 0 or f.max() >= len(v)):
f = f[:0] # corrupt indices: drop the mesh rather than crash later
objects[oid] = (v, f, comps)
# Free what's been read so memory stays flat on huge parts.
el.clear()
parent = el.getparent()
while parent is not None and el.getprevious() is not None:
del parent[0]
return objects, items, unit
def _root_part(z):
try:
rels = etree.fromstring(z.read("_rels/.rels"), etree.XMLParser(resolve_entities=False, no_network=True))
for rel in rels:
if rel.get("Type", "").endswith("/3dmodel"):
return rel.get("Target", "").lstrip("/")
except (KeyError, etree.XMLSyntaxError):
pass
return "3D/3dmodel.model"
def load(path):
"""Returns (meshes, placements).
meshes: {key: (vertices, faces)} — each object once, in file units.
placements: [(key, 4x4 transform)] — one per placed instance, mapping to millimetres.
"""
with zipfile.ZipFile(path) as z:
names = set(z.namelist())
parts = {}
def part(name):
name = name.lstrip("/")
if name not in parts:
if name not in names:
raise ValueError(f"3MF references a missing part: {name}")
with z.open(name) as fh:
parts[name] = _parse_part(fh)
return parts[name]
root = _root_part(z)
_, items, unit = part(root)
scale = np.diag([UNIT_MM.get(unit or "millimeter", 1.0)] * 3 + [1.0])
meshes, placements = {}, []
def walk(name, oid, transform, depth):
if depth > MAX_DEPTH:
raise ValueError("3MF components nest too deeply")
obj = part(name)[0].get(oid)
if obj is None:
return # undefined reference; slicers skip these too
v, f, comps = obj
if len(f):
key = f"{name}#{oid}"
meshes.setdefault(key, (v, f))
placements.append((key, transform))
for cpath, coid, m in comps:
walk(cpath.lstrip("/") if cpath else name, coid, transform @ m, depth + 1)
for oid, m in items:
walk(root, oid, scale @ m, 0)
return meshes, placements
def measure(meshes, placements):
"""Faces, surface area (mm^2), volume (mm^3) and extents (mm) of everything
placed, as if concatenated into one mesh — computed per instance to keep
memory flat."""
faces, area, volume = 0, 0.0, 0.0
lo, hi = np.full(3, np.inf), np.full(3, -np.inf)
for key, t in placements:
v, f = meshes[key]
w = v @ t[:3, :3].T + t[:3, 3]
a, b, c = w[f[:, 0]], w[f[:, 1]], w[f[:, 2]]
cross = np.cross(b - a, c - a)
faces += len(f)
area += 0.5 * float(np.linalg.norm(cross, axis=1).sum())
# A mirroring transform flips winding; count its volume as positive
# (matching trimesh, which re-winds faces when applying such a transform).
signed = float(np.einsum("ij,ij->i", a, np.cross(b, c)).sum()) / 6.0
volume += -signed if np.linalg.det(t[:3, :3]) < 0 else signed
if len(w):
lo, hi = np.minimum(lo, w.min(axis=0)), np.maximum(hi, w.max(axis=0))
extents = (hi - lo) if faces else np.zeros(3)
return {"faces": faces, "area": area, "volume": abs(volume), "extents": [float(x) for x in extents]}