Add duplicate finder, trash, notes, and library search/filters
- Fingerprint every file in the background: SHA-256 for all formats plus a trimesh geometry fingerprint for STL/OBJ/3MF, so renamed, re-exported or converted copies are still matched. Backfills existing files on startup. - Duplicates view groups models that share files (exact copies vs same shape), recommends which to keep, merges tags/collections/notes into the kept model and trashes the rest; groups can be dismissed. - Deleting now moves models to a restorable trash; files are only removed from disk on permanent delete or when the trash is emptied. - Per-model notes with autosave. - Search across names, notes, tags, files and designers; filter by format, source and license; include/exclude tags with any/all matching; sort and group by source or format. Preferences persist per browser. - Add node:test unit tests for duplicate grouping and fingerprints. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
e141d800c2
commit
00a4b4ba07
12 changed files with 1203 additions and 128 deletions
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).
|
- **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.
|
- **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.
|
- **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.
|
- **Settings** — API tokens / FlareSolverr URL stored in the database (with `.env` fallback), secrets masked.
|
||||||
|
|
||||||
## Tech stack
|
## Tech stack
|
||||||
|
|
||||||
- Node.js + Express, vanilla-JS frontend (no build step), PostgreSQL.
|
- Node.js + Express, vanilla-JS frontend (no build step), PostgreSQL.
|
||||||
- Three.js (viewer) via CDN import map.
|
- 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.
|
- Optional [FlareSolverr](https://github.com/FlareSolverr/FlareSolverr) for Cloudflare-protected sources.
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
1. **Postgres** — create a database and point `DATABASE_URL` at it. The schema is applied automatically on startup.
|
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
|
```bash
|
||||||
python3 -m venv tools/venv
|
python3 -m venv tools/venv
|
||||||
tools/venv/bin/pip install trimesh numpy networkx lxml
|
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
|
4. Open http://localhost:3000
|
||||||
|
|
||||||
|
Run the unit tests with `npm test`.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Settings live in the database (editable in the app's **Settings** menu) and fall back to environment variables:
|
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_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_model_tags_t ON model_tags(tag_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_models_source ON models(source_site, source_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()
|
||||||
|
);
|
||||||
|
|
|
||||||
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 };
|
||||||
145
lib/fingerprint.js
Normal file
145
lib/fingerprint.js
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
// Background worker that fingerprints stored files for the duplicate finder: a
|
||||||
|
// SHA-256 of the bytes for every file, plus a geometry fingerprint for mesh
|
||||||
|
// formats so the same model still matches after being renamed, re-exported
|
||||||
|
// (ASCII/binary STL) or converted (STL -> 3MF).
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { execFile } = require('child_process');
|
||||||
|
const db = require('./db');
|
||||||
|
|
||||||
|
const PYTHON = process.env.PYTHON_BIN || path.join(__dirname, '..', 'tools', 'venv', 'bin', 'python');
|
||||||
|
const SCRIPT = path.join(__dirname, '..', 'tools', 'fingerprint.py');
|
||||||
|
const GEOMETRY_FORMATS = ['stl', 'obj', '3mf'];
|
||||||
|
const BATCH = 10;
|
||||||
|
|
||||||
|
let pythonAvailable = true;
|
||||||
|
let running = false;
|
||||||
|
let rerun = false;
|
||||||
|
|
||||||
|
function sha256File(filePath) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const hash = crypto.createHash('sha256');
|
||||||
|
fs.createReadStream(filePath)
|
||||||
|
.on('error', reject)
|
||||||
|
.on('data', (chunk) => hash.update(chunk))
|
||||||
|
.on('end', () => resolve(hash.digest('hex')));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// n rounded to `digits` significant figures, as a stable string.
|
||||||
|
function sig(n, digits = 4) {
|
||||||
|
if (!Number.isFinite(n) || n === 0) return '0';
|
||||||
|
return String(Number(n.toPrecision(digits)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash of rounded shape measurements. Position and axis-aligned rotation don't
|
||||||
|
// change it, and rounding absorbs float noise between export formats.
|
||||||
|
function geometryKey(fp) {
|
||||||
|
if (!fp || !fp.faces) return null;
|
||||||
|
const extents = (fp.extents || []).map((e) => Math.abs(e)).sort((a, b) => a - b).map((e) => e.toFixed(1));
|
||||||
|
const raw = `${fp.faces}|${sig(fp.area)}|${sig(Math.abs(fp.volume || 0))}|${extents.join('x')}`;
|
||||||
|
return crypto.createHash('sha1').update(raw).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runs the trimesh sidecar over some files. Resolves Map(absPath -> {fp}|{error});
|
||||||
|
// output from a run that timed out or crashed part-way is still returned.
|
||||||
|
function measure(paths) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
execFile(
|
||||||
|
PYTHON,
|
||||||
|
[SCRIPT, ...paths],
|
||||||
|
{ maxBuffer: 4 * 1024 * 1024, timeout: 60000 * (paths.length + 1) },
|
||||||
|
(err, stdout) => {
|
||||||
|
if (err && err.code === 'ENOENT') return reject(err);
|
||||||
|
const results = new Map();
|
||||||
|
for (const line of String(stdout || '').split('\n')) {
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
try {
|
||||||
|
const r = JSON.parse(line);
|
||||||
|
results.set(r.path, r);
|
||||||
|
} catch { /* ignore non-JSON noise */ }
|
||||||
|
}
|
||||||
|
resolve(results);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fingerprintGeometry(rows) {
|
||||||
|
const abs = (row) => path.resolve(row.file_path);
|
||||||
|
const results = await measure(rows.map(abs));
|
||||||
|
for (const row of rows) {
|
||||||
|
let r = results.get(abs(row));
|
||||||
|
// A batch that died part-way: retry the missing file on its own.
|
||||||
|
if (!r && rows.length > 1) r = (await measure([abs(row)])).get(abs(row));
|
||||||
|
const key = r && r.fp ? geometryKey(r.fp) : null;
|
||||||
|
await db.query('UPDATE files SET geometry_hash = $2, geometry_status = $3 WHERE id = $1',
|
||||||
|
[row.id, key, key ? 'ok' : 'error']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
for (;;) {
|
||||||
|
const { rows } = await db.query(
|
||||||
|
`SELECT id, file_path, format, fingerprinted_at FROM files
|
||||||
|
WHERE fingerprinted_at IS NULL OR (geometry_status IS NULL AND $1::boolean)
|
||||||
|
ORDER BY id LIMIT $2`, [pythonAvailable, BATCH]);
|
||||||
|
if (rows.length === 0) return;
|
||||||
|
|
||||||
|
const geometry = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const abs = path.resolve(row.file_path);
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(abs)) {
|
||||||
|
await db.query(`UPDATE files SET fingerprinted_at = now(), geometry_status = 'missing' WHERE id = $1`, [row.id]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!row.fingerprinted_at) {
|
||||||
|
await db.query('UPDATE files SET sha256 = $2, fingerprinted_at = now() WHERE id = $1',
|
||||||
|
[row.id, await sha256File(abs)]);
|
||||||
|
}
|
||||||
|
if (!GEOMETRY_FORMATS.includes(row.format)) {
|
||||||
|
await db.query(`UPDATE files SET geometry_status = 'na' WHERE id = $1`, [row.id]);
|
||||||
|
} else if (pythonAvailable) {
|
||||||
|
geometry.push(row);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`Fingerprint failed for file ${row.id}:`, err.message);
|
||||||
|
await db.query(`UPDATE files SET fingerprinted_at = now(), geometry_status = 'error' WHERE id = $1`, [row.id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (geometry.length) {
|
||||||
|
try {
|
||||||
|
await fingerprintGeometry(geometry);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code !== 'ENOENT') throw err;
|
||||||
|
pythonAvailable = false;
|
||||||
|
console.warn(`Geometry fingerprints disabled: no Python at ${PYTHON} (content hashes still run).`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start (or queue another pass of) the worker. Safe to call often.
|
||||||
|
function kick() {
|
||||||
|
if (running) { rerun = true; return; }
|
||||||
|
running = true;
|
||||||
|
run()
|
||||||
|
.catch((err) => console.error('Fingerprinting stopped:', err.message))
|
||||||
|
.finally(() => {
|
||||||
|
running = false;
|
||||||
|
if (rerun) { rerun = false; kick(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pendingCount() {
|
||||||
|
const { rows } = await db.query(
|
||||||
|
`SELECT COUNT(*)::int AS n FROM files
|
||||||
|
WHERE fingerprinted_at IS NULL OR (geometry_status IS NULL AND $1::boolean)`, [pythonAvailable]);
|
||||||
|
return rows[0].n;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { kick, pendingCount, geometryKey, sha256File };
|
||||||
97
lib/store.js
97
lib/store.js
|
|
@ -4,6 +4,7 @@ const db = require('./db');
|
||||||
const MODEL_SELECT = `
|
const MODEL_SELECT = `
|
||||||
SELECT m.id, m.name, m.description, m.source_site, m.source_url, m.source_id,
|
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.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,
|
d.id AS designer_id, d.name AS designer_name, d.profile_url AS designer_url,
|
||||||
COALESCE(t.tags, '[]'::json) AS tags,
|
COALESCE(t.tags, '[]'::json) AS tags,
|
||||||
COALESCE(p.projects, '[]'::json) AS projects,
|
COALESCE(p.projects, '[]'::json) AS projects,
|
||||||
|
|
@ -39,8 +40,11 @@ function shapeModel(row) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- models
|
// ---------------------------------------------------------------- models
|
||||||
async function getAllModels() {
|
// The library, or with { trash: true } the models in the trash.
|
||||||
const { rows } = await db.query(`${MODEL_SELECT} ORDER BY m.date_added DESC`);
|
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);
|
return rows.map(shapeModel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -49,9 +53,15 @@ async function getModel(id) {
|
||||||
return shapeModel(rows[0]);
|
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) {
|
async function findModelBySource(site, sourceId) {
|
||||||
const { rows } = await db.query(
|
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]);
|
return shapeModel(rows[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -87,7 +97,7 @@ async function updateModel(id, updates) {
|
||||||
const fields = [];
|
const fields = [];
|
||||||
const values = [];
|
const values = [];
|
||||||
let i = 1;
|
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[col] !== undefined) { fields.push(`${col} = $${i++}`); values.push(updates[col]); }
|
||||||
}
|
}
|
||||||
if (updates.print_settings !== undefined) {
|
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) {
|
async function addFiles(modelId, files) {
|
||||||
await db.transaction(async (client) => {
|
await db.transaction(async (client) => {
|
||||||
for (const file of files) await insertFile(client, modelId, file);
|
for (const file of files) await insertFile(client, modelId, file);
|
||||||
|
|
@ -176,9 +258,10 @@ async function setProjects(client, modelId, projectIds) {
|
||||||
async function getAllProjects() {
|
async function getAllProjects() {
|
||||||
const { rows } = await db.query(`
|
const { rows } = await db.query(`
|
||||||
SELECT p.id, p.name, p.description, p.date_created, p.date_modified,
|
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
|
FROM projects p
|
||||||
LEFT JOIN model_projects mp ON mp.project_id = p.id
|
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`);
|
GROUP BY p.id ORDER BY p.name`);
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
@ -321,8 +404,10 @@ async function upsertDesigner(client, { name, source_site, profile_url }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getAllModels, getModel, findModelBySource, createModel, updateModel, deleteModel, addFiles,
|
getAllModels, getModel, getModelsByIds, findModelBySource, createModel, updateModel, deleteModel, addFiles,
|
||||||
updateFileConversion,
|
updateFileConversion,
|
||||||
|
trashModels, restoreModel, trashedModelIds, countTrashed,
|
||||||
|
duplicateCandidateFiles, mergeAndTrash, dismissDuplicateGroup, dismissedDuplicateSignatures,
|
||||||
getAllTags,
|
getAllTags,
|
||||||
getAllProjects, getProject, createProject, updateProject, deleteProject,
|
getAllProjects, getProject, createProject, updateProject, deleteProject,
|
||||||
getSetting, setSetting,
|
getSetting, setSetting,
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,8 @@
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.js",
|
"start": "node server.js",
|
||||||
"dev": "nodemon server.js"
|
"dev": "nodemon server.js",
|
||||||
|
"test": "node --test test/*.test.js"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"stl",
|
"stl",
|
||||||
|
|
|
||||||
606
public/app.js
606
public/app.js
|
|
@ -1,16 +1,27 @@
|
||||||
// State
|
// State
|
||||||
let models = [];
|
let models = []; // the library (not in the trash)
|
||||||
|
let trashModels = []; // loaded while the Trash view is open
|
||||||
let projects = [];
|
let projects = [];
|
||||||
let config = { importers: {} };
|
let config = { importers: {} };
|
||||||
let selectedProject = 'all';
|
let stats = { trash: 0, duplicateGroups: 0, fingerprintsPending: 0 };
|
||||||
let selectedTags = [];
|
let selectedScope = 'all'; // 'all' | 'duplicates' | 'trash' | a collection id
|
||||||
let currentView = 'grid';
|
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
|
// DOM
|
||||||
const fileGrid = document.getElementById('fileGrid');
|
const fileGrid = document.getElementById('fileGrid');
|
||||||
const projectsList = document.getElementById('projectsList');
|
const projectsList = document.getElementById('projectsList');
|
||||||
const tagsList = document.getElementById('tagsList');
|
const tagsList = document.getElementById('tagsList');
|
||||||
const searchInput = document.getElementById('searchInput');
|
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 uploadModal = document.getElementById('uploadModal');
|
||||||
const projectModal = document.getElementById('projectModal');
|
const projectModal = document.getElementById('projectModal');
|
||||||
const importModal = document.getElementById('importModal');
|
const importModal = document.getElementById('importModal');
|
||||||
|
|
@ -20,10 +31,19 @@ const editModal = document.getElementById('editModal');
|
||||||
const fileDetailModal = document.getElementById('fileDetailModal');
|
const fileDetailModal = document.getElementById('fileDetailModal');
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
loadPrefs();
|
||||||
setupAuthListeners();
|
setupAuthListeners();
|
||||||
bootstrap();
|
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=...),
|
// Decide what to show: the app (if signed in), the reset form (if ?reset=...),
|
||||||
// or the sign-in / create-account screen.
|
// or the sign-in / create-account screen.
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
|
|
@ -122,17 +142,24 @@ async function handleLogout() {
|
||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
try {
|
try {
|
||||||
const [modelsRes, projectsRes, configRes] = await Promise.all([
|
const [modelsRes, projectsRes, configRes, statsRes] = await Promise.all([
|
||||||
fetch('/api/models'),
|
fetch('/api/models'),
|
||||||
fetch('/api/projects'),
|
fetch('/api/projects'),
|
||||||
fetch('/api/config'),
|
fetch('/api/config'),
|
||||||
|
fetch('/api/library/stats'),
|
||||||
]);
|
]);
|
||||||
models = await modelsRes.json();
|
models = await modelsRes.json();
|
||||||
projects = await projectsRes.json();
|
projects = await projectsRes.json();
|
||||||
config = await configRes.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();
|
renderProjects();
|
||||||
renderTags();
|
renderTags();
|
||||||
|
renderFilterOptions();
|
||||||
renderModels();
|
renderModels();
|
||||||
updateCounts();
|
updateCounts();
|
||||||
generateMissingThumbnails(); // fire-and-forget; updates cards as they finish
|
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
|
// 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.
|
// persist them. Runs one at a time to avoid thrashing the GPU / WebGL contexts.
|
||||||
const thumbAttempted = new Set();
|
const thumbAttempted = new Set();
|
||||||
|
|
@ -167,93 +199,383 @@ async function generateMissingThumbnails() {
|
||||||
});
|
});
|
||||||
if (!res.ok) continue;
|
if (!res.ok) continue;
|
||||||
const updated = await res.json();
|
const updated = await res.json();
|
||||||
m.thumbnailUrl = updated.thumbnailUrl;
|
updateCardThumbnail(m.id, 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">`;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Thumbnail generation failed for model', m.id, 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
|
// ---------------------------------------------------------------- rendering
|
||||||
function renderProjects() {
|
function renderProjects() {
|
||||||
const items = projects.map((p) => `
|
projectsList.innerHTML = projects.length
|
||||||
<div class="project-item${String(selectedProject) === String(p.id) ? ' active' : ''}" data-project-id="${p.id}">
|
? projects.map((p) => `
|
||||||
<span class="project-name">${escapeHtml(p.name)}</span>
|
<div class="project-item" data-scope="${p.id}">
|
||||||
<span class="project-meta">
|
<span class="project-name">${escapeHtml(p.name)}</span>
|
||||||
<button class="collection-action collection-rename" title="Rename" aria-label="Rename collection">✎</button>
|
<span class="project-meta">
|
||||||
<button class="collection-action collection-delete" title="Delete" aria-label="Delete collection">×</button>
|
<button class="collection-action collection-rename" title="Rename" aria-label="Rename collection">✎</button>
|
||||||
<span class="count" data-project-count="${p.id}">${p.model_count}</span>
|
<button class="collection-action collection-delete" title="Delete" aria-label="Delete collection">×</button>
|
||||||
</span>
|
<span class="count">${p.model_count}</span>
|
||||||
</div>`).join('');
|
</span>
|
||||||
const allItem = projectsList.querySelector('[data-project-id="all"]');
|
</div>`).join('')
|
||||||
allItem.classList.toggle('active', selectedProject === 'all');
|
: '<p class="form-hint">No collections yet.</p>';
|
||||||
projectsList.innerHTML = '';
|
|
||||||
projectsList.appendChild(allItem);
|
|
||||||
projectsList.insertAdjacentHTML('beforeend', items);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTags() {
|
function renderTags() {
|
||||||
const all = new Set();
|
const all = knownTags();
|
||||||
models.forEach((m) => (m.tags || []).forEach((t) => all.add(t)));
|
for (const t of Object.keys(tagFilters)) if (!all.includes(t)) delete tagFilters[t];
|
||||||
tagsList.innerHTML = Array.from(all).sort().map((tag) => `
|
const titles = {
|
||||||
<span class="tag${selectedTags.includes(tag) ? ' active' : ''}" data-tag="${escapeHtml(tag)}">${escapeHtml(tag)}</span>`).join('');
|
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() {
|
function renderModels() {
|
||||||
const filtered = getFilteredModels();
|
updateScopeChrome();
|
||||||
if (filtered.length === 0) {
|
if (selectedScope === 'duplicates') { renderDuplicates(); return; }
|
||||||
fileGrid.innerHTML = `
|
clearTimeout(duplicatePoll);
|
||||||
<div class="empty-state">
|
|
||||||
<h3>No models found</h3>
|
const list = getFilteredModels();
|
||||||
<p>Upload files or import from a URL to get started</p>
|
resultCount.textContent = `${list.length} model${list.length === 1 ? '' : 's'}`;
|
||||||
</div>`;
|
fileGrid.className = prefs.view === 'list' ? 'file-grid list-view' : 'file-grid';
|
||||||
|
|
||||||
|
if (list.length === 0) {
|
||||||
|
fileGrid.innerHTML = emptyStateHtml();
|
||||||
return;
|
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';
|
function groupLabel(m) {
|
||||||
fileGrid.innerHTML = filtered.map((m) => {
|
if (prefs.group === 'source') return sourceLabel(sourceKey(m));
|
||||||
const totalSize = (m.files || []).reduce((s, f) => s + Number(f.size || 0), 0);
|
const formats = [...new Set((m.files || []).map((f) => `.${f.format}`))].sort();
|
||||||
const thumb = m.thumbnailUrl
|
return formats.length ? formats.join(' + ') : 'No files';
|
||||||
? `<img src="${m.thumbnailUrl}" alt="" loading="lazy">`
|
}
|
||||||
: '<span>📦</span>';
|
|
||||||
return `
|
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-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-info">
|
||||||
<div class="file-name">${escapeHtml(m.name)}</div>
|
<div class="file-name">${escapeHtml(m.name)}</div>
|
||||||
${m.source_site ? `<span class="source-badge">${escapeHtml(m.source_site)}</span>` : ''}
|
${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.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.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>` : ''}
|
${(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>
|
||||||
</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() {
|
function getFilteredModels() {
|
||||||
let result = models;
|
let result = selectedScope === 'trash' ? trashModels : models;
|
||||||
if (selectedProject !== 'all') {
|
if (!SPECIAL_SCOPES.includes(selectedScope)) {
|
||||||
result = result.filter((m) => (m.projects || []).some((p) => String(p.id) === String(selectedProject)));
|
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 (exclude.length) result = result.filter((m) => !exclude.some((t) => (m.tags || []).includes(t)));
|
||||||
if (term) {
|
|
||||||
result = result.filter((m) =>
|
if (prefs.format) result = result.filter((m) => (m.files || []).some((f) => f.format === prefs.format));
|
||||||
m.name.toLowerCase().includes(term) ||
|
if (prefs.source) result = result.filter((m) => sourceKey(m) === prefs.source);
|
||||||
(m.description && m.description.toLowerCase().includes(term)));
|
if (prefs.license) result = result.filter((m) => licenseKey(m) === prefs.license);
|
||||||
}
|
|
||||||
return result;
|
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() {
|
function updateCounts() {
|
||||||
document.getElementById('allFilesCount').textContent = models.length;
|
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
|
// ---------------------------------------------------------------- events
|
||||||
|
|
@ -293,48 +615,92 @@ function setupEventListeners() {
|
||||||
if (f && !nameField.value) nameField.value = f.name.replace(/\.[^.]+$/, '');
|
if (f && !nameField.value) nameField.value = f.name.replace(/\.[^.]+$/, '');
|
||||||
});
|
});
|
||||||
|
|
||||||
projectsList.addEventListener('click', (e) => {
|
// Library views (All / Duplicates / Trash) and collections share one scope.
|
||||||
const item = e.target.closest('.project-item');
|
document.querySelector('.sidebar').addEventListener('click', (e) => {
|
||||||
|
const item = e.target.closest('.project-item[data-scope]');
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
const id = item.dataset.projectId;
|
const scope = item.dataset.scope;
|
||||||
const name = item.querySelector('.project-name') ? item.querySelector('.project-name').textContent : 'All Models';
|
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; }
|
||||||
if (e.target.closest('.collection-rename')) { handleRenameCollection(id, name); return; }
|
selectScope(scope);
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
tagsList.addEventListener('click', (e) => {
|
tagsList.addEventListener('click', (e) => {
|
||||||
if (!e.target.classList.contains('tag')) return;
|
const el = e.target.closest('.tag');
|
||||||
const tag = e.target.dataset.tag;
|
if (!el) return;
|
||||||
e.target.classList.toggle('active');
|
const tag = el.dataset.tag;
|
||||||
if (selectedTags.includes(tag)) selectedTags = selectedTags.filter((t) => t !== tag);
|
if (!tagFilters[tag]) tagFilters[tag] = 'include';
|
||||||
else selectedTags.push(tag);
|
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();
|
renderModels();
|
||||||
});
|
});
|
||||||
|
|
||||||
searchInput.addEventListener('input', 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) => {
|
document.querySelectorAll('.view-btn').forEach((btn) => {
|
||||||
btn.addEventListener('click', (e) => {
|
btn.addEventListener('click', (e) => {
|
||||||
document.querySelectorAll('.view-btn').forEach((b) => b.classList.remove('active'));
|
prefs.view = e.target.dataset.view;
|
||||||
e.target.classList.add('active');
|
savePrefs();
|
||||||
currentView = e.target.dataset.view;
|
|
||||||
renderModels();
|
renderModels();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
fileGrid.addEventListener('click', (e) => {
|
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);
|
if (card) openModelDetail(card.dataset.modelId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
emptyTrashBtn.addEventListener('click', handleEmptyTrash);
|
||||||
document.getElementById('deleteFileBtn').addEventListener('click', handleDeleteModel);
|
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('fileTags'));
|
||||||
setupTagAutocomplete(document.getElementById('editTags'));
|
setupTagAutocomplete(document.getElementById('editTags'));
|
||||||
document.getElementById('regenThumbBtn').addEventListener('click', handleRegenThumbnail);
|
document.getElementById('regenThumbBtn').addEventListener('click', handleRegenThumbnail);
|
||||||
|
|
@ -347,13 +713,61 @@ function setupEventListeners() {
|
||||||
document.getElementById('addFilesInput').addEventListener('change', handleAddFiles);
|
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).
|
// Tracks the file currently shown in the detail viewer (for thumbnail regen).
|
||||||
let currentDetailFile = null;
|
let currentDetailFile = null;
|
||||||
let currentDetailThumb = null; // model image, used as a viewer fallback
|
let currentDetailThumb = null; // model image, used as a viewer fallback
|
||||||
|
|
||||||
function closeModal(modal) {
|
function closeModal(modal) {
|
||||||
modal.classList.remove('active');
|
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
|
// ---------------------------------------------------------------- modals
|
||||||
|
|
@ -443,13 +857,20 @@ function openImportModal() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function openModelDetail(modelId) {
|
function openModelDetail(modelId) {
|
||||||
const model = models.find((m) => String(m.id) === String(modelId));
|
const model = findModel(modelId);
|
||||||
if (!model) return;
|
if (!model) return;
|
||||||
|
flushNotes(); // save any edit still pending for the previously open model
|
||||||
fileDetailModal.dataset.currentModelId = modelId;
|
fileDetailModal.dataset.currentModelId = modelId;
|
||||||
currentDetailThumb = model.thumbnailUrl || null;
|
currentDetailThumb = model.thumbnailUrl || null;
|
||||||
|
|
||||||
document.getElementById('detailFileName').textContent = model.name;
|
document.getElementById('detailFileName').textContent = model.name;
|
||||||
document.getElementById('detailDescription').textContent = model.description || 'No description';
|
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
|
// Source
|
||||||
const src = document.getElementById('detailSource');
|
const src = document.getElementById('detailSource');
|
||||||
|
|
@ -551,7 +972,7 @@ function renderDetailLicense(model) {
|
||||||
|
|
||||||
function openEditModal() {
|
function openEditModal() {
|
||||||
const id = fileDetailModal.dataset.currentModelId;
|
const id = fileDetailModal.dataset.currentModelId;
|
||||||
const model = models.find((m) => String(m.id) === String(id));
|
const model = findModel(id);
|
||||||
if (!model) return;
|
if (!model) return;
|
||||||
editModal.dataset.editId = id;
|
editModal.dataset.editId = id;
|
||||||
|
|
||||||
|
|
@ -633,8 +1054,7 @@ async function handleRefreshMetadata() {
|
||||||
if (!res.ok) throw new Error((await res.json()).error);
|
if (!res.ok) throw new Error((await res.json()).error);
|
||||||
const updated = await res.json();
|
const updated = await res.json();
|
||||||
// update in-memory state, the license panel, and the card badge
|
// update in-memory state, the license panel, and the card badge
|
||||||
const idx = models.findIndex((m) => String(m.id) === String(modelId));
|
replaceModel(updated);
|
||||||
if (idx !== -1) models[idx] = updated;
|
|
||||||
renderDetailLicense(updated);
|
renderDetailLicense(updated);
|
||||||
renderModels();
|
renderModels();
|
||||||
btn.textContent = 'Updated ✓';
|
btn.textContent = 'Updated ✓';
|
||||||
|
|
@ -656,9 +1076,9 @@ function loadViewer(fileId, format) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateCardThumbnail(modelId, thumbnailUrl) {
|
function updateCardThumbnail(modelId, thumbnailUrl) {
|
||||||
const m = models.find((x) => String(x.id) === String(modelId));
|
const m = findModel(modelId);
|
||||||
if (m) m.thumbnailUrl = thumbnailUrl;
|
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">`;
|
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 }),
|
body: JSON.stringify({ name: trimmed }),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error((await res.json()).error);
|
if (!res.ok) throw new Error((await res.json()).error);
|
||||||
if (String(selectedProject) === String(id)) {
|
|
||||||
document.getElementById('contentTitle').textContent = trimmed;
|
|
||||||
}
|
|
||||||
await loadData();
|
await loadData();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('Failed to rename collection: ' + err.message);
|
alert('Failed to rename collection: ' + err.message);
|
||||||
|
|
@ -884,10 +1301,7 @@ async function handleDeleteCollection(id, name) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/projects/${id}`, { method: 'DELETE' });
|
const res = await fetch(`/api/projects/${id}`, { method: 'DELETE' });
|
||||||
if (!res.ok) throw new Error((await res.json()).error);
|
if (!res.ok) throw new Error((await res.json()).error);
|
||||||
if (String(selectedProject) === String(id)) {
|
if (String(selectedScope) === String(id)) selectedScope = 'all';
|
||||||
selectedProject = 'all';
|
|
||||||
document.getElementById('contentTitle').textContent = 'All Models';
|
|
||||||
}
|
|
||||||
await loadData();
|
await loadData();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('Failed to delete collection: ' + err.message);
|
alert('Failed to delete collection: ' + err.message);
|
||||||
|
|
@ -919,17 +1333,17 @@ async function handleCreateProject(e) {
|
||||||
|
|
||||||
async function handleDeleteModel() {
|
async function handleDeleteModel() {
|
||||||
const id = fileDetailModal.dataset.currentModelId;
|
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 {
|
try {
|
||||||
const res = await fetch(`/api/models/${id}`, { method: 'DELETE' });
|
const res = await fetch(`/api/models/${id}`, { method: 'DELETE' });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
closeModal(fileDetailModal);
|
closeModal(fileDetailModal);
|
||||||
await loadData();
|
await loadData();
|
||||||
} else {
|
} else {
|
||||||
alert('Failed to delete: ' + (await res.json()).error);
|
alert('Failed to move to trash: ' + (await res.json()).error);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('Failed to delete: ' + err.message);
|
alert('Failed to move to trash: ' + err.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -84,35 +84,83 @@
|
||||||
<div class="main-content">
|
<div class="main-content">
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="sidebar-section">
|
<div class="sidebar-section">
|
||||||
<h3>Collections</h3>
|
<h3>Library</h3>
|
||||||
<div id="projectsList" class="projects-list">
|
<div id="libraryList" class="projects-list">
|
||||||
<div class="project-item active" data-project-id="all">
|
<div class="project-item active" data-scope="all">
|
||||||
<span>All Models</span>
|
<span>All Models</span>
|
||||||
<span class="count" id="allFilesCount">0</span>
|
<span class="count" id="allFilesCount">0</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<div class="sidebar-section">
|
<div class="sidebar-section">
|
||||||
<h3>Tags</h3>
|
<h3>Collections</h3>
|
||||||
<div id="tagsList" class="tags-list"></div>
|
<div id="projectsList" class="projects-list"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sidebar-section">
|
<div class="sidebar-section">
|
||||||
<h3>Search</h3>
|
<div class="section-head">
|
||||||
<input type="text" id="searchInput" placeholder="Search models..." class="search-input">
|
<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>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main class="content">
|
<main class="content">
|
||||||
<div class="content-header">
|
<div class="content-header">
|
||||||
<h2 id="contentTitle">All Models</h2>
|
<h2 id="contentTitle">All Models</h2>
|
||||||
<div class="view-options">
|
<div class="content-actions">
|
||||||
<button class="view-btn active" data-view="grid">Grid</button>
|
<span id="resultCount" class="result-count"></span>
|
||||||
<button class="view-btn" data-view="list">List</button>
|
<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>
|
</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>
|
<div id="fileGrid" class="file-grid"></div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -378,6 +426,10 @@
|
||||||
<h3>Description</h3>
|
<h3>Description</h3>
|
||||||
<p id="detailDescription"></p>
|
<p id="detailDescription"></p>
|
||||||
</div>
|
</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">
|
<div class="info-section">
|
||||||
<h3>Tags</h3>
|
<h3>Tags</h3>
|
||||||
<div id="detailTags" class="tags"></div>
|
<div id="detailTags" class="tags"></div>
|
||||||
|
|
@ -397,11 +449,15 @@
|
||||||
</div>
|
</div>
|
||||||
<input type="file" id="addFilesInput" multiple style="display:none"
|
<input type="file" id="addFilesInput" multiple style="display:none"
|
||||||
accept=".stl,.3mf,.obj,.step,.stp,.gcode,.gco,.zip">
|
accept=".stl,.3mf,.obj,.step,.stp,.gcode,.gco,.zip">
|
||||||
<div class="form-actions">
|
<div class="form-actions" id="detailActions">
|
||||||
<button class="btn btn-danger" id="deleteFileBtn">Delete</button>
|
<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-secondary" id="addFilesBtn">Add files</button>
|
||||||
<button class="btn btn-primary" id="editModelBtn">Edit</button>
|
<button class="btn btn-primary" id="editModelBtn">Edit</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -110,10 +110,11 @@ header {
|
||||||
transition: all 0.12s;
|
transition: all 0.12s;
|
||||||
}
|
}
|
||||||
.tag:hover { border-color: var(--border-strong); color: var(--text); }
|
.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 ---- */
|
/* ---- 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%;
|
width: 100%;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: 14px;
|
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 { 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); }
|
.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 cookieParser = require('cookie-parser');
|
||||||
const auth = require('./lib/auth');
|
const auth = require('./lib/auth');
|
||||||
const mailer = require('./lib/mailer');
|
const mailer = require('./lib/mailer');
|
||||||
|
const fingerprint = require('./lib/fingerprint');
|
||||||
|
const duplicates = require('./lib/duplicates');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
@ -292,7 +294,7 @@ app.put('/api/settings', async (req, res, next) => {
|
||||||
// ----------------------------------------------------------------- models
|
// ----------------------------------------------------------------- models
|
||||||
app.get('/api/models', async (req, res, next) => {
|
app.get('/api/models', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const models = await store.getAllModels();
|
const models = await store.getAllModels({ trash: req.query.view === 'trash' });
|
||||||
res.json(models.map(publicModel));
|
res.json(models.map(publicModel));
|
||||||
} catch (e) { next(e); }
|
} catch (e) { next(e); }
|
||||||
});
|
});
|
||||||
|
|
@ -321,6 +323,7 @@ app.post('/api/models', upload.array('files'), async (req, res, next) => {
|
||||||
thumbnail_path: pickThumbnail(files),
|
thumbnail_path: pickThumbnail(files),
|
||||||
files,
|
files,
|
||||||
});
|
});
|
||||||
|
fingerprint.kick();
|
||||||
res.status(201).json(publicModel(model));
|
res.status(201).json(publicModel(model));
|
||||||
} catch (e) { next(e); }
|
} 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' });
|
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 files = (req.files || []).map((f) => describeFile(f.path, f.filename, f.originalname));
|
||||||
const model = await store.addFiles(req.params.id, files);
|
const model = await store.addFiles(req.params.id, files);
|
||||||
|
fingerprint.kick();
|
||||||
res.json(publicModel(model));
|
res.json(publicModel(model));
|
||||||
} catch (e) { next(e); }
|
} 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 });
|
return res.status(200).json({ ...publicModel(model), added: 0 });
|
||||||
}
|
}
|
||||||
const updated = await store.addFiles(model.id, newFiles);
|
const updated = await store.addFiles(model.id, newFiles);
|
||||||
|
fingerprint.kick();
|
||||||
res.json({ ...publicModel(updated), added: newFiles.length });
|
res.json({ ...publicModel(updated), added: newFiles.length });
|
||||||
} catch (e) { next(e); }
|
} catch (e) { next(e); }
|
||||||
});
|
});
|
||||||
|
|
@ -429,17 +434,54 @@ async function fetchSourceFiles(model, skipNames) {
|
||||||
throw e;
|
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) => {
|
app.delete('/api/models/:id', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const removed = await store.deleteModel(req.params.id);
|
const model = await store.getModel(req.params.id);
|
||||||
if (!removed) return res.status(404).json({ error: 'Model not found' });
|
if (!model) return res.status(404).json({ error: 'Model not found' });
|
||||||
for (const p of removed.filePaths) { if (p && fs.existsSync(p)) fs.unlinkSync(p); }
|
if (req.query.permanent === 'true') {
|
||||||
for (const p of removed.glbPaths) { if (p && fs.existsSync(p)) fs.unlinkSync(p); }
|
if (!model.deleted_at) {
|
||||||
if (removed.thumbnailPath && fs.existsSync(removed.thumbnailPath)) fs.unlinkSync(removed.thumbnailPath);
|
return res.status(400).json({ error: 'Move the model to the trash before deleting it permanently.' });
|
||||||
res.json({ message: 'Model deleted' });
|
}
|
||||||
|
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); }
|
} 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.
|
// Store a client-rendered thumbnail (PNG data URL) for a model.
|
||||||
app.post('/api/models/:id/thumbnail', async (req, res, next) => {
|
app.post('/api/models/:id/thumbnail', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -614,6 +656,7 @@ app.post('/api/import', async (req, res, next) => {
|
||||||
projects,
|
projects,
|
||||||
files,
|
files,
|
||||||
});
|
});
|
||||||
|
fingerprint.kick();
|
||||||
res.status(201).json({ ...publicModel(model), metadataOnly: files.length === 0, metadataFetched });
|
res.status(201).json({ ...publicModel(model), metadataOnly: files.length === 0, metadataFetched });
|
||||||
} catch (e) { next(e); }
|
} catch (e) { next(e); }
|
||||||
});
|
});
|
||||||
|
|
@ -626,7 +669,9 @@ async function importThingiverse(url, projects) {
|
||||||
const existing = await store.findModelBySource('thingiverse', String(thingId));
|
const existing = await store.findModelBySource('thingiverse', String(thingId));
|
||||||
if (existing) { const e = new Error('Already imported'); e.status = 409; throw e; }
|
if (existing) { const e = new Error('Already imported'); e.status = 409; throw e; }
|
||||||
const meta = await thingiverse.fetchModel(thingId, token);
|
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
|
// 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); }
|
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
|
// ----------------------------------------------------------------- misc
|
||||||
function parseJson(value, fallback) {
|
function parseJson(value, fallback) {
|
||||||
if (value === undefined || value === null || value === '') return fallback;
|
if (value === undefined || value === null || value === '') return fallback;
|
||||||
|
|
@ -822,5 +929,8 @@ app.use((err, req, res, next) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
db.init()
|
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); });
|
.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);
|
||||||
|
});
|
||||||
40
tools/fingerprint.py
Normal file
40
tools/fingerprint.py
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
#!/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
|
||||||
|
|
||||||
|
|
||||||
|
def measure(path):
|
||||||
|
mesh = trimesh.load(path, force="mesh")
|
||||||
|
if mesh is None or len(mesh.faces) == 0:
|
||||||
|
raise ValueError("no geometry found")
|
||||||
|
try:
|
||||||
|
volume = abs(float(mesh.volume))
|
||||||
|
except Exception:
|
||||||
|
volume = 0.0
|
||||||
|
return {
|
||||||
|
"faces": int(len(mesh.faces)),
|
||||||
|
"area": float(mesh.area),
|
||||||
|
"volume": volume,
|
||||||
|
"extents": [float(x) for x in mesh.extents],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
for path in sys.argv[1:]:
|
||||||
|
try:
|
||||||
|
out = {"path": path, "fp": measure(path)}
|
||||||
|
except Exception as exc:
|
||||||
|
out = {"path": path, "error": str(exc)[:200]}
|
||||||
|
print(json.dumps(out), flush=True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Loading…
Reference in a new issue