Adding a spool no longer means typing a hex code: choose "PLA Basic — Jade White (10100)" and the material, colour name, swatch and filament code fill themselves. Slot colours on the model page are named where they match, so a slot reads "#586579 · Basic Blue Gray" instead of bare hex. The filament code is stored per spool. It isn't decoration — the AMS reports filament IDs, so this is what a later read-only printer connection will match a loaded tray against. db/bambu-filaments.json holds Bambu Lab's 325 filaments (material, type, colour name, code, colours), included by kind permission of 3dfilamentprofiles.com, whose terms otherwise reserve the data. The permission and source are recorded in the file itself as well as the README, so nobody reuses it by accident. Naming uses a much tighter threshold than matching (12 against 50): identifying a colour and substituting for one are different questions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
714 lines
31 KiB
JavaScript
714 lines
31 KiB
JavaScript
const db = require('./db');
|
|
const { MODEL_FORMATS, ARCHIVE_FORMATS } = require('./fileProcessor');
|
|
|
|
// ---- shared SELECT that hydrates a model with tags, projects, files, designer ----
|
|
const MODEL_SELECT = `
|
|
SELECT m.id, m.name, m.description, m.source_site, m.source_url, m.source_id,
|
|
m.license, m.commercial_use, m.thumbnail_path, m.print_settings, m.date_added, m.date_modified,
|
|
m.notes, m.deleted_at, m.ai_tagged_at, m.original_name, m.filaments, m.filaments_status,
|
|
d.id AS designer_id, d.name AS designer_name, d.profile_url AS designer_url,
|
|
COALESCE(t.tags, '[]'::json) AS tags,
|
|
COALESCE(t.tag_sources, '{}'::json) AS tag_sources,
|
|
COALESCE(p.projects, '[]'::json) AS projects,
|
|
COALESCE(f.files, '[]'::json) AS files
|
|
FROM models m
|
|
LEFT JOIN designers d ON d.id = m.designer_id
|
|
LEFT JOIN LATERAL (
|
|
SELECT json_agg(tg.name ORDER BY tg.name) AS tags, json_object_agg(tg.name, mt.source) AS tag_sources
|
|
FROM model_tags mt JOIN tags tg ON tg.id = mt.tag_id
|
|
WHERE mt.model_id = m.id
|
|
) t ON true
|
|
LEFT JOIN LATERAL (
|
|
SELECT json_agg(json_build_object('id', pr.id, 'name', pr.name) ORDER BY pr.name) AS projects
|
|
FROM model_projects mp JOIN projects pr ON pr.id = mp.project_id
|
|
WHERE mp.model_id = m.id
|
|
) p ON true
|
|
LEFT JOIN LATERAL (
|
|
SELECT json_agg(json_build_object(
|
|
'id', fl.id, 'originalName', fl.original_name, 'fileName', fl.file_name,
|
|
'format', fl.format, 'size', fl.size, 'volume', fl.volume,
|
|
'bbox', fl.bbox, 'facets', fl.facets, 'archiveStatus', fl.archive_status,
|
|
'entryCount', (SELECT COUNT(*)::int FROM archive_entries ae WHERE ae.file_id = fl.id)) ORDER BY fl.id) AS files
|
|
FROM files fl WHERE fl.model_id = m.id
|
|
) f ON true
|
|
`;
|
|
|
|
function shapeModel(row) {
|
|
if (!row) return null;
|
|
const { designer_id, designer_name, designer_url, ...rest } = row;
|
|
return {
|
|
...rest,
|
|
designer: designer_id ? { id: designer_id, name: designer_name, url: designer_url } : null,
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------- models
|
|
// The library, or with { trash: true } the models in the trash.
|
|
async function getAllModels({ trash = false } = {}) {
|
|
const { rows } = await db.query(trash
|
|
? `${MODEL_SELECT} WHERE m.deleted_at IS NOT NULL ORDER BY m.deleted_at DESC`
|
|
: `${MODEL_SELECT} WHERE m.deleted_at IS NULL ORDER BY m.date_added DESC`);
|
|
return rows.map(shapeModel);
|
|
}
|
|
|
|
async function getModel(id) {
|
|
const { rows } = await db.query(`${MODEL_SELECT} WHERE m.id = $1`, [id]);
|
|
return shapeModel(rows[0]);
|
|
}
|
|
|
|
async function getModelsByIds(ids) {
|
|
if (!ids.length) return [];
|
|
const { rows } = await db.query(`${MODEL_SELECT} WHERE m.id = ANY($1::int[])`, [ids]);
|
|
return rows.map(shapeModel);
|
|
}
|
|
|
|
async function findModelBySource(site, sourceId) {
|
|
const { rows } = await db.query(
|
|
`${MODEL_SELECT} WHERE m.source_site = $1 AND m.source_id = $2 AND m.deleted_at IS NULL`, [site, sourceId]);
|
|
return shapeModel(rows[0]);
|
|
}
|
|
|
|
// data = { name, description, source_site, source_url, source_id, license,
|
|
// designer:{name,source_site,profile_url}, thumbnail_path, print_settings,
|
|
// tags:[string], projects:[id], files:[{...}] }
|
|
async function createModel(data) {
|
|
return db.transaction(async (client) => {
|
|
let designerId = null;
|
|
if (data.designer && data.designer.name) {
|
|
designerId = await upsertDesigner(client, data.designer);
|
|
}
|
|
const { rows } = await client.query(
|
|
`INSERT INTO models (name, description, source_site, source_url, source_id,
|
|
license, commercial_use, designer_id, thumbnail_path, print_settings)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING id`,
|
|
[data.name, data.description || '', data.source_site || null, data.source_url || null,
|
|
data.source_id || null, data.license || null, data.commercial_use ?? null, designerId,
|
|
data.thumbnail_path || null, data.print_settings || {}]
|
|
);
|
|
const modelId = rows[0].id;
|
|
await setTags(client, modelId, data.tags || []);
|
|
await setProjects(client, modelId, data.projects || []);
|
|
for (const file of data.files || []) {
|
|
await insertFile(client, modelId, file);
|
|
}
|
|
return modelId;
|
|
}).then(getModel);
|
|
}
|
|
|
|
async function updateModel(id, updates) {
|
|
await db.transaction(async (client) => {
|
|
const fields = [];
|
|
const values = [];
|
|
let i = 1;
|
|
for (const col of ['name', 'description', 'notes', 'license', 'commercial_use', 'thumbnail_path']) {
|
|
if (updates[col] !== undefined) { fields.push(`${col} = $${i++}`); values.push(updates[col]); }
|
|
}
|
|
// A name you give it yourself replaces an AI rename, so there's nothing left to undo.
|
|
if (updates.name !== undefined) {
|
|
fields.push(`original_name = CASE WHEN name IS DISTINCT FROM $${i++} THEN NULL ELSE original_name END`);
|
|
values.push(updates.name);
|
|
}
|
|
if (updates.print_settings !== undefined) {
|
|
fields.push(`print_settings = $${i++}`); values.push(updates.print_settings);
|
|
}
|
|
if (fields.length) {
|
|
fields.push(`date_modified = now()`);
|
|
values.push(id);
|
|
await client.query(`UPDATE models SET ${fields.join(', ')} WHERE id = $${i}`, values);
|
|
}
|
|
if (updates.tags !== undefined) await setTags(client, id, updates.tags);
|
|
if (updates.projects !== undefined) await setProjects(client, id, updates.projects);
|
|
});
|
|
return getModel(id);
|
|
}
|
|
|
|
// Returns the on-disk file rows so the caller can unlink them.
|
|
async function deleteModel(id) {
|
|
const { rows } = await db.query('SELECT id, file_path, glb_path FROM files WHERE model_id = $1', [id]);
|
|
const entries = await db.query(
|
|
'SELECT e.id FROM archive_entries e JOIN files f ON f.id = e.file_id WHERE f.model_id = $1', [id]);
|
|
const model = await db.query('SELECT thumbnail_path FROM models WHERE id = $1', [id]);
|
|
const result = await db.query('DELETE FROM models WHERE id = $1 RETURNING id', [id]);
|
|
if (result.rowCount === 0) return null;
|
|
return {
|
|
fileIds: rows.map((r) => r.id),
|
|
entryIds: entries.rows.map((e) => e.id),
|
|
filePaths: rows.map((r) => r.file_path),
|
|
glbPaths: [
|
|
...rows.map((r) => r.glb_path).filter(Boolean),
|
|
...entries.rows.map((e) => `glb/entry-${e.id}.glb`),
|
|
],
|
|
thumbnailPath: model.rows[0] && model.rows[0].thumbnail_path,
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------- files / archive entries
|
|
async function getFile(id) {
|
|
const { rows } = await db.query('SELECT * FROM files WHERE id = $1', [id]);
|
|
return rows[0] || null;
|
|
}
|
|
|
|
async function getArchiveEntries(fileId) {
|
|
const { rows } = await db.query(
|
|
'SELECT id, path, format, size FROM archive_entries WHERE file_id = $1 ORDER BY path', [fileId]);
|
|
return rows.map((r) => ({ ...r, size: Number(r.size) }));
|
|
}
|
|
|
|
// An entry plus the archive it lives in ({ id, file_path, format }).
|
|
async function getArchiveEntry(id) {
|
|
const { rows } = await db.query(
|
|
`SELECT e.id, e.file_id, e.path, e.format, e.size, f.file_path, f.format AS archive_format
|
|
FROM archive_entries e JOIN files f ON f.id = e.file_id WHERE e.id = $1`, [id]);
|
|
const r = rows[0];
|
|
if (!r) return null;
|
|
return {
|
|
id: r.id, file_id: r.file_id, path: r.path, format: r.format, size: Number(r.size),
|
|
archive: { id: r.file_id, file_path: r.file_path, format: r.archive_format },
|
|
};
|
|
}
|
|
|
|
async function replaceArchiveEntries(fileId, entries) {
|
|
await db.transaction(async (client) => {
|
|
await client.query('DELETE FROM archive_entries WHERE file_id = $1', [fileId]);
|
|
if (!entries.length) return;
|
|
await client.query(
|
|
`INSERT INTO archive_entries (file_id, path, format, size)
|
|
SELECT $1::int, * FROM unnest($2::text[], $3::text[], $4::bigint[])
|
|
ON CONFLICT (file_id, path) DO NOTHING`,
|
|
[fileId, entries.map((e) => e.path), entries.map((e) => e.format), entries.map((e) => e.size)]);
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------- 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.
|
|
// Ids are prefixed ('f12' for a file, 'e7' for an archive entry) so both kinds
|
|
// can be matched together. Pictures and documents never count.
|
|
async function duplicateCandidateFiles() {
|
|
const { rows } = await db.query(`
|
|
WITH live AS (
|
|
SELECT 'f' || f.id AS 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 AND f.format = ANY($1)
|
|
UNION ALL
|
|
SELECT 'e' || e.id, f.model_id, e.sha256, e.geometry_hash
|
|
FROM archive_entries e JOIN files f ON f.id = e.file_id JOIN models m ON m.id = f.model_id
|
|
WHERE m.deleted_at IS NULL AND e.size > 0 AND e.format = ANY($2)
|
|
)
|
|
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)`,
|
|
[[...MODEL_FORMATS, ...ARCHIVE_FORMATS], MODEL_FORMATS]);
|
|
return rows;
|
|
}
|
|
|
|
// How many duplicate-matchable items (model files, archives, models inside
|
|
// archives) each model holds: { modelId: n }.
|
|
async function matchableCounts(modelIds) {
|
|
if (!modelIds.length) return {};
|
|
const { rows } = await db.query(`
|
|
SELECT m.id,
|
|
(SELECT COUNT(*) FROM files f WHERE f.model_id = m.id AND f.format = ANY($2))
|
|
+ (SELECT COUNT(*) FROM archive_entries e JOIN files f ON f.id = e.file_id
|
|
WHERE f.model_id = m.id AND e.format = ANY($3)) AS n
|
|
FROM models m WHERE m.id = ANY($1::int[])`,
|
|
[modelIds, [...MODEL_FORMATS, ...ARCHIVE_FORMATS], MODEL_FORMATS]);
|
|
return Object.fromEntries(rows.map((r) => [r.id, Number(r.n)]));
|
|
}
|
|
|
|
// Keep one model of a duplicate group: fold the others' tags, collections and
|
|
// notes into it, then move the others to the trash.
|
|
async function mergeAndTrash(keepId, otherIds) {
|
|
await db.transaction(async (client) => {
|
|
await client.query(
|
|
`INSERT INTO model_tags (model_id, tag_id)
|
|
SELECT $1::int, tag_id FROM model_tags WHERE model_id = ANY($2::int[])
|
|
ON CONFLICT DO NOTHING`, [keepId, otherIds]);
|
|
await client.query(
|
|
`INSERT INTO model_projects (model_id, project_id)
|
|
SELECT $1::int, project_id FROM model_projects WHERE model_id = ANY($2::int[])
|
|
ON CONFLICT DO NOTHING`, [keepId, otherIds]);
|
|
const { rows } = await client.query(
|
|
'SELECT notes FROM models WHERE id = $1 OR id = ANY($2::int[]) ORDER BY id = $1 DESC, id',
|
|
[keepId, otherIds]);
|
|
const notes = [...new Set(rows.map((r) => r.notes.trim()).filter(Boolean))].join('\n\n');
|
|
await client.query('UPDATE models SET notes = $2, date_modified = now() WHERE id = $1', [keepId, notes]);
|
|
await client.query('UPDATE models SET deleted_at = now() WHERE id = ANY($1::int[])', [otherIds]);
|
|
});
|
|
}
|
|
|
|
async function dismissDuplicateGroup(signature) {
|
|
await db.query(
|
|
'INSERT INTO duplicate_dismissals (signature) VALUES ($1) ON CONFLICT DO NOTHING', [signature]);
|
|
}
|
|
|
|
async function dismissedDuplicateSignatures() {
|
|
const { rows } = await db.query('SELECT signature FROM duplicate_dismissals');
|
|
return rows.map((r) => r.signature);
|
|
}
|
|
|
|
async function addFiles(modelId, files) {
|
|
await db.transaction(async (client) => {
|
|
for (const file of files) await insertFile(client, modelId, file);
|
|
// New files may give the background worker something to make a thumbnail from.
|
|
await client.query('UPDATE models SET thumbnail_status = NULL WHERE id = $1', [modelId]);
|
|
});
|
|
return getModel(modelId);
|
|
}
|
|
|
|
// Record a 3MF's GLB conversion + computed geometry on the file row.
|
|
async function updateFileConversion(id, { glb_path, volume, bbox }) {
|
|
await db.query(
|
|
`UPDATE files SET glb_path = $2,
|
|
volume = COALESCE($3, volume),
|
|
bbox = COALESCE($4, bbox)
|
|
WHERE id = $1`,
|
|
[id, glb_path, volume ?? null, bbox ?? null]);
|
|
}
|
|
|
|
async function insertFile(client, modelId, file) {
|
|
await client.query(
|
|
`INSERT INTO files (model_id, file_name, original_name, file_path, format, size, volume, bbox, facets)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
|
|
[modelId, file.fileName, file.originalName, file.filePath, file.format,
|
|
file.size || 0, file.volume ?? null, file.bbox ?? null, file.facets ?? null]
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------- tags
|
|
// Always tag in the same order. Two transactions touching the same tags in
|
|
// opposite orders each hold a row the other wants, which Postgres resolves by
|
|
// killing one of them ("deadlock detected").
|
|
const tagOrder = (names) => [...new Set((names || []).map((n) => String(n).trim()).filter(Boolean))].sort();
|
|
|
|
// The tag's id, creating it if it's new. Deliberately not ON CONFLICT DO UPDATE:
|
|
// that rewrites the row and so write-locks it for the rest of the transaction,
|
|
// even though the name never changes — which is what made tagging deadlock.
|
|
async function tagId(client, name) {
|
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
const { rows: found } = await client.query('SELECT id FROM tags WHERE name = $1', [name]);
|
|
if (found.length) return found[0].id;
|
|
const { rows } = await client.query(
|
|
'INSERT INTO tags (name) VALUES ($1) ON CONFLICT (name) DO NOTHING RETURNING id', [name]);
|
|
if (rows.length) return rows[0].id;
|
|
// Someone else is inserting this very tag and hasn't committed yet.
|
|
await new Promise((r) => setTimeout(r, 20 * (attempt + 1)));
|
|
}
|
|
throw new Error(`Could not get an id for the tag “${name}”`);
|
|
}
|
|
|
|
// Sets a model's tags from an edit. A tag that stays keeps where it came from
|
|
// (rule / ai); new ones are yours.
|
|
async function setTags(client, modelId, tagNames) {
|
|
const { rows: before } = await client.query('SELECT tag_id, source FROM model_tags WHERE model_id = $1', [modelId]);
|
|
const sourceOf = new Map(before.map((r) => [r.tag_id, r.source]));
|
|
await client.query('DELETE FROM model_tags WHERE model_id = $1', [modelId]);
|
|
for (const name of tagOrder(tagNames)) {
|
|
const id = await tagId(client, name);
|
|
await client.query(
|
|
'INSERT INTO model_tags (model_id, tag_id, source) VALUES ($1,$2,$3) ON CONFLICT DO NOTHING',
|
|
[modelId, id, sourceOf.get(id) || 'user']);
|
|
}
|
|
}
|
|
|
|
// Automatic tagging: makes the model's rule tags exactly `tagNames`. A tag the
|
|
// model already has from you or the AI is left as it is.
|
|
async function replaceRuleTags(modelId, tagNames) {
|
|
await db.transaction(async (client) => {
|
|
const ids = [];
|
|
for (const name of tagOrder(tagNames)) ids.push(await tagId(client, name));
|
|
await client.query(
|
|
`DELETE FROM model_tags WHERE model_id = $1 AND source = 'rule' AND NOT (tag_id = ANY($2::int[]))`, [modelId, ids]);
|
|
for (const id of ids) {
|
|
await client.query(`INSERT INTO model_tags (model_id, tag_id, source) VALUES ($1,$2,'rule') ON CONFLICT DO NOTHING`,
|
|
[modelId, id]);
|
|
}
|
|
});
|
|
}
|
|
|
|
async function removeTagsFrom(source) {
|
|
const { rowCount } = await db.query('DELETE FROM model_tags WHERE source = $1', [source]);
|
|
return rowCount;
|
|
}
|
|
|
|
// AI tagging: adds the suggested tags (never removes any), fills in the
|
|
// description if it's empty (or replaces it when asked to), and renames the model
|
|
// when given a name, keeping the name it had before AI first renamed it.
|
|
// markTagged records that the model has had AI tags and a description.
|
|
async function applyAiResult(modelId, { tags, description, overwriteDescription, name = null, markTagged = true }) {
|
|
await db.transaction(async (client) => {
|
|
for (const tagName of tagOrder(tags)) { // not `name`: that's this function's rename parameter
|
|
const id = await tagId(client, tagName);
|
|
await client.query(`INSERT INTO model_tags (model_id, tag_id, source) VALUES ($1,$2,'ai') ON CONFLICT DO NOTHING`,
|
|
[modelId, id]);
|
|
}
|
|
await client.query(
|
|
`UPDATE models SET ai_tagged_at = CASE WHEN $5::boolean THEN now() ELSE ai_tagged_at END,
|
|
description = CASE WHEN $2::text <> '' AND ($3::boolean OR btrim(description) = '') THEN $2 ELSE description END,
|
|
original_name = CASE WHEN $4::text IS NOT NULL THEN COALESCE(original_name, name) ELSE original_name END,
|
|
name = COALESCE($4::text, name)
|
|
WHERE id = $1`, [modelId, description || '', Boolean(overwriteDescription), name, Boolean(markTagged)]);
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------- collection suggestions
|
|
// Records the AI's collection ideas for a model. A name matching an existing
|
|
// collection or earlier idea takes its spelling, so ideas group together; ideas
|
|
// the model already belongs to, or that you dismissed for it, are skipped.
|
|
// Returns the names recorded.
|
|
async function suggestCollections(modelId, names) {
|
|
const recorded = [];
|
|
for (const raw of names) {
|
|
const { rows: [known] } = await db.query(
|
|
`SELECT name FROM (
|
|
SELECT name, 0 AS rank FROM projects WHERE lower(name) = lower($1)
|
|
UNION ALL
|
|
SELECT name, 1 AS rank FROM collection_suggestions WHERE lower(name) = lower($1)
|
|
) n ORDER BY rank, name = lower(name), name LIMIT 1`, [raw]);
|
|
// An all-lowercase idea gives way to a capitalised spelling of the same name.
|
|
const name = known && !(known.name === known.name.toLowerCase() && raw !== raw.toLowerCase()) ? known.name : raw;
|
|
const { rowCount: member } = await db.query(
|
|
`SELECT 1 FROM model_projects mp JOIN projects p ON p.id = mp.project_id
|
|
WHERE mp.model_id = $1 AND lower(p.name) = lower($2)`, [modelId, name]);
|
|
if (member) continue;
|
|
const { rowCount } = await db.query(
|
|
'INSERT INTO collection_suggestions (model_id, name) VALUES ($1, $2) ON CONFLICT DO NOTHING', [modelId, name]);
|
|
if (rowCount) recorded.push(name);
|
|
}
|
|
return recorded;
|
|
}
|
|
|
|
// Collection names the AI should reuse: your collections, then ideas waiting for review.
|
|
async function collectionNameHints(limit = 100) {
|
|
const { rows } = await db.query(
|
|
`SELECT name FROM projects
|
|
UNION
|
|
SELECT name FROM collection_suggestions WHERE status = 'pending'
|
|
ORDER BY name LIMIT $1`, [limit]);
|
|
return rows.map((r) => r.name);
|
|
}
|
|
|
|
// Waiting ideas grouped by name: [{ name, project_id (if it exists), models: [{ id, name }] }].
|
|
async function listCollectionSuggestions() {
|
|
const { rows } = await db.query(`
|
|
SELECT (array_agg(COALESCE(p.name, s.name) ORDER BY p.id IS NULL, s.name = lower(s.name), s.name))[1] AS name,
|
|
MIN(p.id) AS project_id,
|
|
json_agg(DISTINCT jsonb_build_object('id', m.id, 'name', m.name)) AS models
|
|
FROM collection_suggestions s
|
|
JOIN models m ON m.id = s.model_id AND m.deleted_at IS NULL
|
|
LEFT JOIN projects p ON lower(p.name) = lower(s.name)
|
|
WHERE s.status = 'pending'
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM model_projects mp JOIN projects pp ON pp.id = mp.project_id
|
|
WHERE mp.model_id = m.id AND lower(pp.name) = lower(s.name))
|
|
GROUP BY lower(s.name)
|
|
ORDER BY COUNT(DISTINCT m.id) DESC, 1`);
|
|
return rows.map((r) => ({ ...r, models: r.models.sort((a, b) => a.name.localeCompare(b.name)) }));
|
|
}
|
|
|
|
// Adds the models an idea was suggested for (or just modelIds) to the collection of
|
|
// that name, creating it if needed. Returns { project, added }, or null if none waited.
|
|
async function acceptCollectionSuggestion(name, modelIds = null) {
|
|
return db.transaction(async (client) => {
|
|
const { rows: waiting } = await client.query(
|
|
`SELECT s.model_id FROM collection_suggestions s JOIN models m ON m.id = s.model_id AND m.deleted_at IS NULL
|
|
WHERE s.status = 'pending' AND lower(s.name) = lower($1) AND ($2::int[] IS NULL OR s.model_id = ANY($2::int[]))`,
|
|
[name, modelIds]);
|
|
if (!waiting.length) return null;
|
|
let { rows: [project] } = await client.query(
|
|
'SELECT * FROM projects WHERE lower(name) = lower($1) ORDER BY id LIMIT 1', [name]);
|
|
if (!project) {
|
|
// Named with the idea's best spelling, however it was asked for.
|
|
({ rows: [project] } = await client.query(
|
|
`INSERT INTO projects (name)
|
|
SELECT COALESCE((SELECT name FROM collection_suggestions WHERE status = 'pending' AND lower(name) = lower($1)
|
|
ORDER BY name = lower(name), name LIMIT 1), $1)
|
|
RETURNING *`, [name]));
|
|
}
|
|
// Sorted, so two of these at once take the model_projects rows in the same
|
|
// order rather than deadlocking on each other.
|
|
const ids = waiting.map((w) => w.model_id).sort((a, b) => a - b);
|
|
for (const id of ids) {
|
|
await client.query('INSERT INTO model_projects (model_id, project_id) VALUES ($1, $2) ON CONFLICT DO NOTHING', [id, project.id]);
|
|
}
|
|
await client.query(
|
|
`UPDATE collection_suggestions SET status = 'accepted' WHERE lower(name) = lower($1) AND model_id = ANY($2::int[])`,
|
|
[name, ids]);
|
|
return { project, added: ids.length };
|
|
});
|
|
}
|
|
|
|
async function dismissCollectionSuggestion(name, modelIds = null) {
|
|
const { rowCount } = await db.query(
|
|
`UPDATE collection_suggestions SET status = 'dismissed'
|
|
WHERE status = 'pending' AND lower(name) = lower($1) AND ($2::int[] IS NULL OR model_id = ANY($2::int[]))`,
|
|
[name, modelIds]);
|
|
return rowCount;
|
|
}
|
|
|
|
// The library's most used tags, so AI suggestions reuse the same words.
|
|
async function topTags(limit = 150) {
|
|
const { rows } = await db.query(
|
|
`SELECT t.name FROM tags t JOIN model_tags mt ON mt.tag_id = t.id
|
|
GROUP BY t.name ORDER BY COUNT(*) DESC, t.name LIMIT $1`, [limit]);
|
|
return rows.map((r) => r.name);
|
|
}
|
|
|
|
async function getAllTags() {
|
|
const { rows } = await db.query('SELECT name FROM tags ORDER BY name');
|
|
return rows.map((r) => r.name);
|
|
}
|
|
|
|
// ---------------------------------------------------------------- projects
|
|
async function setProjects(client, modelId, projectIds) {
|
|
await client.query('DELETE FROM model_projects WHERE model_id = $1', [modelId]);
|
|
for (const pid of [...projectIds].sort((a, b) => a - b)) { // one lock order, always
|
|
await client.query(
|
|
'INSERT INTO model_projects (model_id, project_id) VALUES ($1,$2) ON CONFLICT DO NOTHING',
|
|
[modelId, pid]);
|
|
}
|
|
}
|
|
|
|
async function getAllProjects() {
|
|
const { rows } = await db.query(`
|
|
SELECT p.id, p.name, p.description, p.date_created, p.date_modified,
|
|
COUNT(m.id)::int AS model_count
|
|
FROM projects p
|
|
LEFT JOIN model_projects mp ON mp.project_id = p.id
|
|
LEFT JOIN models m ON m.id = mp.model_id AND m.deleted_at IS NULL
|
|
GROUP BY p.id ORDER BY p.name`);
|
|
return rows;
|
|
}
|
|
|
|
async function getProject(id) {
|
|
const { rows } = await db.query('SELECT * FROM projects WHERE id = $1', [id]);
|
|
return rows[0] || null;
|
|
}
|
|
|
|
async function createProject({ name, description }) {
|
|
const { rows } = await db.query(
|
|
'INSERT INTO projects (name, description) VALUES ($1,$2) RETURNING *',
|
|
[name, description || '']);
|
|
return rows[0];
|
|
}
|
|
|
|
// The collection with this name, created if there isn't one yet.
|
|
async function findOrCreateProject(name) {
|
|
const { rows: [existing] } = await db.query(
|
|
'SELECT * FROM projects WHERE lower(name) = lower($1) ORDER BY id LIMIT 1', [name]);
|
|
if (existing) return existing;
|
|
const { rows: [created] } = await db.query('INSERT INTO projects (name) VALUES ($1) RETURNING *', [name]);
|
|
return created;
|
|
}
|
|
|
|
async function addModelToProject(modelId, projectId) {
|
|
await db.query('INSERT INTO model_projects (model_id, project_id) VALUES ($1, $2) ON CONFLICT DO NOTHING',
|
|
[modelId, projectId]);
|
|
}
|
|
|
|
async function updateProject(id, { name, description }) {
|
|
const { rows } = await db.query(
|
|
`UPDATE projects SET name = COALESCE($2, name),
|
|
description = COALESCE($3, description), date_modified = now()
|
|
WHERE id = $1 RETURNING *`, [id, name ?? null, description ?? null]);
|
|
return rows[0] || null;
|
|
}
|
|
|
|
async function deleteProject(id) {
|
|
const { rowCount } = await db.query('DELETE FROM projects WHERE id = $1', [id]);
|
|
return rowCount > 0;
|
|
}
|
|
|
|
// ---------------------------------------------------------------- license rules
|
|
async function getLicenseRule(name) {
|
|
const { rows } = await db.query('SELECT name, commercial FROM license_rules WHERE name = $1', [name]);
|
|
return rows[0] || null;
|
|
}
|
|
|
|
async function upsertLicenseRule(name, commercial) {
|
|
await db.query(
|
|
`INSERT INTO license_rules (name, commercial) VALUES ($1, $2)
|
|
ON CONFLICT (name) DO UPDATE SET commercial = EXCLUDED.commercial`,
|
|
[name, commercial ?? null]);
|
|
}
|
|
|
|
async function listLicenseRules() {
|
|
const { rows } = await db.query(`
|
|
SELECT lr.name, lr.commercial, COUNT(m.id)::int AS model_count
|
|
FROM license_rules lr
|
|
LEFT JOIN models m ON m.license = lr.name
|
|
GROUP BY lr.name, lr.commercial
|
|
ORDER BY lr.name`);
|
|
return rows;
|
|
}
|
|
|
|
async function distinctModelLicenses() {
|
|
const { rows } = await db.query("SELECT DISTINCT license FROM models WHERE license IS NOT NULL AND license <> ''");
|
|
return rows.map((r) => r.license);
|
|
}
|
|
|
|
// Push a rule's value onto every model that carries that license.
|
|
async function applyLicenseRuleToModels(name, commercial) {
|
|
await db.query('UPDATE models SET commercial_use = $2 WHERE license = $1', [name, commercial ?? null]);
|
|
}
|
|
|
|
// Align every model's commercial_use to its license rule (license = source of truth).
|
|
async function reconcileModelsToRules() {
|
|
await db.query(`
|
|
UPDATE models m SET commercial_use = lr.commercial
|
|
FROM license_rules lr
|
|
WHERE m.license = lr.name AND m.commercial_use IS DISTINCT FROM lr.commercial`);
|
|
}
|
|
|
|
// ---------------------------------------------------------------- users / auth
|
|
async function countUsers() {
|
|
const { rows } = await db.query('SELECT COUNT(*)::int AS n FROM users');
|
|
return rows[0].n;
|
|
}
|
|
|
|
async function getUserByEmail(email) {
|
|
const { rows } = await db.query('SELECT * FROM users WHERE lower(email) = lower($1)', [email]);
|
|
return rows[0] || null;
|
|
}
|
|
|
|
async function getUserById(id) {
|
|
const { rows } = await db.query('SELECT id, email, created_at FROM users WHERE id = $1', [id]);
|
|
return rows[0] || null;
|
|
}
|
|
|
|
async function createUser(email, passwordHash) {
|
|
const { rows } = await db.query(
|
|
'INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING id, email, created_at',
|
|
[email.trim(), passwordHash]);
|
|
return rows[0];
|
|
}
|
|
|
|
async function updateUserPassword(id, passwordHash) {
|
|
await db.query('UPDATE users SET password_hash = $2 WHERE id = $1', [id, passwordHash]);
|
|
}
|
|
|
|
async function createPasswordReset(userId, tokenHash, expiresAt) {
|
|
await db.query(
|
|
'INSERT INTO password_resets (user_id, token_hash, expires_at) VALUES ($1,$2,$3)',
|
|
[userId, tokenHash, expiresAt]);
|
|
}
|
|
|
|
async function getValidReset(tokenHash) {
|
|
const { rows } = await db.query(
|
|
`SELECT * FROM password_resets
|
|
WHERE token_hash = $1 AND used = false AND expires_at > now()`, [tokenHash]);
|
|
return rows[0] || null;
|
|
}
|
|
|
|
async function markResetUsed(id) {
|
|
await db.query('UPDATE password_resets SET used = true WHERE id = $1', [id]);
|
|
}
|
|
|
|
// ---------------------------------------------------------------- settings
|
|
async function getSetting(key) {
|
|
const { rows } = await db.query('SELECT value FROM settings WHERE key = $1', [key]);
|
|
return rows.length ? rows[0].value : null;
|
|
}
|
|
|
|
// value of '' or null clears the setting.
|
|
async function setSetting(key, value) {
|
|
if (value === null || value === '') {
|
|
await db.query('DELETE FROM settings WHERE key = $1', [key]);
|
|
return;
|
|
}
|
|
await db.query(
|
|
`INSERT INTO settings (key, value, updated_at) VALUES ($1, $2, now())
|
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`,
|
|
[key, value]);
|
|
}
|
|
|
|
// ---------------------------------------------------------------- designers
|
|
async function upsertDesigner(client, { name, source_site, profile_url }) {
|
|
const { rows } = await client.query(
|
|
`INSERT INTO designers (name, source_site, profile_url) VALUES ($1,$2,$3)
|
|
ON CONFLICT (name, source_site) DO UPDATE SET profile_url = EXCLUDED.profile_url
|
|
RETURNING id`, [name, source_site || null, profile_url || null]);
|
|
return rows[0].id;
|
|
}
|
|
|
|
// ---------------------------------------------------------------- filament
|
|
async function listSpools() {
|
|
const { rows } = await db.query(
|
|
`SELECT * FROM filament_spools ORDER BY material, colour, id`);
|
|
return rows;
|
|
}
|
|
|
|
async function createSpool(data) {
|
|
const { rows } = await db.query(
|
|
`INSERT INTO filament_spools (material, colour, colour_hex, brand, total_g, remaining_g, notes, code)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`,
|
|
[String(data.material || '').trim(), data.colour || '', data.colour_hex || null, data.brand || '',
|
|
data.total_g ?? null, data.remaining_g ?? null, data.notes || '', data.code || null]);
|
|
return rows[0];
|
|
}
|
|
|
|
// Only the fields given are changed, so a weight edit can't wipe the notes.
|
|
async function updateSpool(id, data) {
|
|
const { rows } = await db.query(
|
|
`UPDATE filament_spools SET
|
|
material = COALESCE($2, material),
|
|
colour = COALESCE($3, colour),
|
|
colour_hex = COALESCE($4, colour_hex),
|
|
brand = COALESCE($5, brand),
|
|
total_g = COALESCE($6, total_g),
|
|
remaining_g = COALESCE($7, remaining_g),
|
|
notes = COALESCE($8, notes),
|
|
code = COALESCE($9, code),
|
|
date_modified = now()
|
|
WHERE id = $1 RETURNING *`,
|
|
[id, data.material ?? null, data.colour ?? null, data.colour_hex ?? null, data.brand ?? null,
|
|
data.total_g ?? null, data.remaining_g ?? null, data.notes ?? null, data.code ?? null]);
|
|
return rows[0] || null;
|
|
}
|
|
|
|
async function deleteSpool(id) {
|
|
const { rowCount } = await db.query('DELETE FROM filament_spools WHERE id = $1', [id]);
|
|
return rowCount > 0;
|
|
}
|
|
|
|
module.exports = {
|
|
getAllModels, getModel, getModelsByIds, findModelBySource, createModel, updateModel, deleteModel, addFiles,
|
|
updateFileConversion,
|
|
trashModels, restoreModel, trashedModelIds, countTrashed,
|
|
duplicateCandidateFiles, matchableCounts, mergeAndTrash, dismissDuplicateGroup, dismissedDuplicateSignatures,
|
|
getFile, getArchiveEntries, getArchiveEntry, replaceArchiveEntries,
|
|
getAllTags, replaceRuleTags, removeTagsFrom, applyAiResult, topTags,
|
|
suggestCollections, collectionNameHints, listCollectionSuggestions, acceptCollectionSuggestion, dismissCollectionSuggestion,
|
|
getAllProjects, getProject, createProject, updateProject, deleteProject, findOrCreateProject, addModelToProject,
|
|
getSetting, setSetting,
|
|
listSpools, createSpool, updateSpool, deleteSpool,
|
|
countUsers, getUserByEmail, getUserById, createUser, updateUserPassword,
|
|
createPasswordReset, getValidReset, markResetUsed,
|
|
getLicenseRule, upsertLicenseRule, listLicenseRules, distinctModelLicenses, applyLicenseRuleToModels,
|
|
reconcileModelsToRules,
|
|
};
|