The slot list comes from the project file, which is sometimes wrong and sometimes not what you intend to print. "Edit slots" turns it into fields: material and colour per slot, with slots you can add or drop. Saved slots are marked as yours and shown with a "your list" badge, and "Use the project's list" throws the override away and re-reads the 3MF. Edits have to outlive the background reader, which is the part that would fail silently: the reader only picks up models with no status, and reread() now skips 'edited' rather than nulling everything. Verified on a real model — the edit survived a reread plus a full worker pass, and the revert restored the project's own four slots exactly. Input cleaning moved out of the route into lib/filaments.js so it can be tested: a slot with no material is dropped, and a colour that isn't a proper hex becomes "any colour of that material" rather than being rejected. Not verified: none of this has been seen rendered — the local preview browser has no session. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
724 lines
32 KiB
JavaScript
724 lines
32 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;
|
|
}
|
|
|
|
// The filament slots a model needs, set by hand. Marked 'edited' so the
|
|
// background reader treats them as yours and leaves them alone.
|
|
async function setModelFilaments(modelId, filaments) {
|
|
const { rowCount } = await db.query(
|
|
`UPDATE models SET filaments = $2, filaments_status = 'edited', date_modified = now()
|
|
WHERE id = $1 AND deleted_at IS NULL`,
|
|
[modelId, JSON.stringify(filaments)]);
|
|
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, setModelFilaments,
|
|
countUsers, getUserByEmail, getUserById, createUser, updateUserPassword,
|
|
createPasswordReset, getValidReset, markResetUsed,
|
|
getLicenseRule, upsertLicenseRule, listLicenseRules, distinctModelLicenses, applyLicenseRuleToModels,
|
|
reconcileModelsToRules,
|
|
};
|