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:
commit
f401eb4ca4
21 changed files with 1509 additions and 168 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -8,3 +8,4 @@ thumbnails/
|
|||
.env
|
||||
glb/
|
||||
tools/venv/
|
||||
__pycache__/
|
||||
|
|
|
|||
10
README.md
10
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 <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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
86
lib/duplicates.js
Normal 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
141
lib/fingerprint.js
Normal 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
42
lib/python.js
Normal 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 };
|
||||
97
lib/store.js
97
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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
606
public/app.js
606
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 = `<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">✎</button>
|
||||
<button class="collection-action collection-delete" title="Delete" aria-label="Delete collection">×</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">✎</button>
|
||||
<button class="collection-action collection-delete" title="Delete" aria-label="Delete collection">×</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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
128
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); });
|
||||
|
|
|
|||
63
test/duplicates.test.js
Normal file
63
test/duplicates.test.js
Normal 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
BIN
test/fixtures/components.3mf
vendored
Normal file
Binary file not shown.
BIN
test/fixtures/cube.stl
vendored
Normal file
BIN
test/fixtures/cube.stl
vendored
Normal file
Binary file not shown.
BIN
test/fixtures/cube_rotated.3mf
vendored
Normal file
BIN
test/fixtures/cube_rotated.3mf
vendored
Normal file
Binary file not shown.
55
test/geometry.test.js
Normal file
55
test/geometry.test.js
Normal 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 });
|
||||
}
|
||||
});
|
||||
|
|
@ -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
49
tools/fingerprint.py
Normal 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
160
tools/threemf_stream.py
Normal 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]}
|
||||
Loading…
Reference in a new issue