From eaf1bd93d523ec128b706245ae57fd319940bd8b Mon Sep 17 00:00:00 2001 From: dlawler489 <104159223@student.swin.edu.au> Date: Thu, 17 Sep 2026 13:15:48 +1000 Subject: [PATCH] Let the required colours be edited on the model page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/filaments.js | 22 ++++++++++++-- lib/store.js | 12 +++++++- public/app.js | 68 ++++++++++++++++++++++++++++++++++++++++-- public/styles.css | 4 +++ server.js | 28 +++++++++++++++++ test/filaments.test.js | 21 +++++++++++++ 6 files changed, 149 insertions(+), 6 deletions(-) diff --git a/lib/filaments.js b/lib/filaments.js index 7e8f280..ce429e4 100644 --- a/lib/filaments.js +++ b/lib/filaments.js @@ -144,12 +144,28 @@ async function pendingCount() { return rows[0].n; } -// Look at every model again — after adding files, say. +// Look at every model again — after adding files, say. Slots you set by hand +// are left alone: they aren't the reader's to overwrite. async function reread() { - await db.query('UPDATE models SET filaments_status = NULL'); + await db.query("UPDATE models SET filaments_status = NULL WHERE filaments_status IS DISTINCT FROM 'edited'"); +} + +// Slots as sent from the page. Keeps the material, accepts only a proper hex as +// a colour, and drops anything with no material — a slot that asks for nothing +// would quietly count as satisfied. +function cleanSlots(list) { + if (!Array.isArray(list)) return null; + return list + .map((f) => ({ + type: String((f && f.type) || '').trim(), + colour: /^#[0-9a-f]{6}$/i.test(String((f && f.colour) || '').trim()) + ? String(f.colour).trim().toUpperCase() + : null, + })) + .filter((f) => f.type); } module.exports = { - run, pendingCount, reread, + run, pendingCount, reread, cleanSlots, assignSpools, shortfall, isPrintable, normalise, colourDistance, rgb, COLOUR_TOLERANCE, }; diff --git a/lib/store.js b/lib/store.js index 8a89f76..4860950 100644 --- a/lib/store.js +++ b/lib/store.js @@ -696,6 +696,16 @@ async function deleteSpool(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, @@ -706,7 +716,7 @@ module.exports = { suggestCollections, collectionNameHints, listCollectionSuggestions, acceptCollectionSuggestion, dismissCollectionSuggestion, getAllProjects, getProject, createProject, updateProject, deleteProject, findOrCreateProject, addModelToProject, getSetting, setSetting, - listSpools, createSpool, updateSpool, deleteSpool, + listSpools, createSpool, updateSpool, deleteSpool, setModelFilaments, countUsers, getUserByEmail, getUserById, createUser, updateUserPassword, createPasswordReset, getValidReset, markResetUsed, getLicenseRule, upsertLicenseRule, listLicenseRules, distinctModelLicenses, applyLicenseRuleToModels, diff --git a/public/app.js b/public/app.js index d798cca..aee4ab0 100644 --- a/public/app.js +++ b/public/app.js @@ -2146,6 +2146,7 @@ function renderDetailPrintable(model) { el.innerHTML = '
This model doesn’t name its filaments — only Bambu Studio and OrcaSlicer projects do.
'; return; } + const yours = model.filaments_status === 'edited'; const rows = p.slots.map((s, i) => `
@@ -2161,9 +2162,72 @@ function renderDetailPrintable(model) {
`).join(''); el.innerHTML = `
${p.ok ? '✔ printable now' - : `needs ${p.unfilled.length} colour${p.unfilled.length === 1 ? '' : 's'}`}
+ : `needs ${p.unfilled.length} colour${p.unfilled.length === 1 ? '' : 's'}`} + ${yours ? 'your list' : ''}
${rows}
-
A spool fills one slot, and its colour has to be close to what the slot asks for.
`; +
A spool fills one slot, and its colour has to be close to what the slot asks for.
+
+ + ${yours ? '' : ''} +
`; + el.querySelector('[data-slots="edit"]').addEventListener('click', () => editSlots(model, p.slots)); + const revert = el.querySelector('[data-slots="revert"]'); + if (revert) revert.addEventListener('click', () => revertSlots(model.id)); +} + +// Editing turns the list into fields: material and colour per slot, with slots +// you can add or drop. Saved slots are marked as yours, so the background +// reader stops overwriting them from the project file. +function editSlots(model, slots) { + const el = document.getElementById('detailPrintable'); + const rowHtml = (s = { material: 'PLA', colour: '#FFFFFF' }) => ` +
+ + + +
`; + el.innerHTML = ` +
${slots.map(rowHtml).join('')}
+
+ + + +
+
Colour is matched to your spools; a slot left at a colour you don’t own shows as missing.
`; + + const list = document.getElementById('slotEditList'); + const bindDrop = (row) => row.querySelector('[data-slot-drop]').addEventListener('click', () => row.remove()); + [...list.children].forEach(bindDrop); + document.getElementById('slotAdd').addEventListener('click', () => { + list.insertAdjacentHTML('beforeend', rowHtml()); + bindDrop(list.lastElementChild); + }); + document.getElementById('slotCancel').addEventListener('click', () => renderDetailPrintable(findModel(model.id) || model)); + document.getElementById('slotSave').addEventListener('click', async () => { + const filaments = [...list.children].map((row) => ({ + type: row.querySelector('.slot-material').value.trim(), + colour: row.querySelector('.slot-colour').value, + })).filter((f) => f.type); + const status = document.getElementById('slotStatus'); + try { + await postJson(`/api/models/${model.id}/filaments`, { filaments }, 'PUT'); + await loadData(); + openModelDetail(model.id); + } catch (err) { + status.classList.add('is-error'); + status.textContent = err.message; + } + }); +} + +async function revertSlots(id) { + try { + await getJson(`/api/models/${id}/filaments`, { method: 'DELETE' }); + await loadData(); + openModelDetail(id); + } catch (err) { + alert(err.message); + } } function renderDetailLicense(model) { diff --git a/public/styles.css b/public/styles.css index 0a57e02..37810f2 100644 --- a/public/styles.css +++ b/public/styles.css @@ -465,6 +465,10 @@ label { display: block; font-size: 13px; font-weight: 500; color: var(--text-mut .watch-actions { margin-left: auto; display: flex; flex-wrap: wrap; gap: 8px; } .watch-actions .btn-sm { align-self: auto; } .slot-list { display: flex; flex-direction: column; gap: 6px; margin: 8px 0; } +.slot-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; } +.slot-edit .slot-material { width: 110px; padding: 5px 8px; font-size: 13px; } +.slot-edit .slot-colour { width: 42px; padding: 2px; } +.print-badge.yours { background: color-mix(in srgb, #6366f1 20%, transparent); color: #a5b4fc; } .slot-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; font-size: 13px; } .print-badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px; white-space: nowrap; } .print-badge.ok { background: color-mix(in srgb, #22c55e 18%, transparent); color: #4ade80; } diff --git a/server.js b/server.js index 735941f..110846f 100644 --- a/server.js +++ b/server.js @@ -464,6 +464,34 @@ async function refreshMetadata(model) { // Re-download files from the source into an existing model (recovers imports // that came back empty/partial, e.g. when FlareSolverr was flaky). Only adds // files not already present. +// The filament slots a model needs — for when the project's own list is wrong, +// or you plan to print it in something else. Saved as yours, so the background +// reader won't overwrite it. +app.put('/api/models/:id/filaments', async (req, res, next) => { + try { + const sent = (req.body || {}).filaments; + const cleaned = filaments.cleanSlots(sent); + if (!cleaned) return res.status(400).json({ error: 'Send { filaments: [{ type, colour }] }' }); + if (sent.length > 32) return res.status(400).json({ error: 'That is more slots than any printer has.' }); + if (!(await store.setModelFilaments(Number(req.params.id), cleaned))) { + return res.status(404).json({ error: 'Model not found' }); + } + res.json(publicModel(await store.getModel(req.params.id))); + } catch (e) { next(e); } +}); + +// Throw away your edits and read the project's own list again. +app.delete('/api/models/:id/filaments', async (req, res, next) => { + try { + const { rowCount } = await db.query( + `UPDATE models SET filaments = NULL, filaments_status = NULL WHERE id = $1 AND deleted_at IS NULL`, + [Number(req.params.id)]); + if (!rowCount) return res.status(404).json({ error: 'Model not found' }); + fingerprint.kick(); // the reader picks it up again + res.json({ message: 'Back to the project’s own filament list' }); + } catch (e) { next(e); } +}); + app.post('/api/models/:id/download-files', async (req, res, next) => { try { const model = await store.getModel(req.params.id); diff --git a/test/filaments.test.js b/test/filaments.test.js index fa20e00..c57e078 100644 --- a/test/filaments.test.js +++ b/test/filaments.test.js @@ -84,3 +84,24 @@ test('a model we know nothing about is not claimed to be printable', () => { assert.equal(filaments.isPrintable([], [spool('PLA', '#ffffff')]), false); assert.deepEqual(filaments.assignSpools(null, []), []); }); + +test('slots sent from the page are cleaned before they reach the database', () => { + const cleaned = filaments.cleanSlots([ + { type: ' pla ', colour: '#ffffff' }, // trimmed, hex upper-cased + { type: 'PETG', colour: 'not a colour' }, // bad colour becomes "any colour" + { type: '', colour: '#000000' }, // no material: dropped entirely + { type: 'PLA', colour: '#8e9089' }, + ]); + assert.deepEqual(cleaned, [ + { type: 'pla', colour: '#FFFFFF' }, + { type: 'PETG', colour: null }, + { type: 'PLA', colour: '#8E9089' }, + ]); +}); + +test('anything that isn’t a list of slots is refused outright', () => { + assert.equal(filaments.cleanSlots(null), null); + assert.equal(filaments.cleanSlots('PLA'), null); + assert.equal(filaments.cleanSlots({ type: 'PLA' }), null); + assert.deepEqual(filaments.cleanSlots([]), []); +});