Compare commits
No commits in common. "e8b68e11494840bb72a9e958457bec185577d2b2" and "c81dc68c0b99b6026ab36da00b6894a71ae97298" have entirely different histories.
e8b68e1149
...
c81dc68c0b
7 changed files with 7 additions and 190 deletions
|
|
@ -27,7 +27,7 @@ A self-hosted web app for organizing your 3D-printing files (STL, 3MF, OBJ, FBX,
|
|||
- **AI tags & descriptions** — bring your own key for DeepSeek (the default), OpenRouter, OpenAI or any OpenAI-compatible server (Ollama, LM Studio). Tag the whole library, only models not yet tagged, or a single model from its page. Each model's name, file names and (optionally) thumbnail are sent; tags are only ever added and descriptions only filled where empty unless you ask. Optionally it also gives models with unclear names (file-name leftovers, typos, site boilerplate) a clearer one; the old name is kept and can be restored with Undo on the model page. It can also suggest collections (reusing yours where they fit): suggestions wait on the AI page, grouped by name, until you create/add or dismiss them. Each run can do any mix of tags & descriptions, names and collections, and a reply that isn't the JSON asked for is retried once. Runs show live progress, tokens and cost, can be stopped and resumed, stop at an optional spending limit, and pause (rather than keep spending) if the server restarts. "Save & test connection" in Settings checks the key, model and whether it accepts images.
|
||||
- **Search by meaning** — switch the search box to *Meaning* to find models by what they are ("wall hook" finds a "coat hanger bracket"). Uses a small embedding model (bge-small-en-v1.5, ~34 MB) that runs on the server, so it's free and private; the index updates in the background as models change.
|
||||
- **Automatic tags** — strict whole-word rules over names, file names and archive contents (part types, mechanisms, materials, techniques) plus your own "word → tag" rules. Switch it off (optionally removing its tags) or re-apply it any time. Tags you add yourself are never changed; AI and rule tags are marked on the model page.
|
||||
- **Filament & what's printable** — keep the spools you own (material, colour, brand, grams left) on the **Filament** page. Bambu Studio / OrcaSlicer projects name the filament in each slot, which Printhive reads once per model in the background, so cards and the model page say whether you can print it now and what you're short of, and the toolbar can show only what's printable. Each slot is matched to a spool by material *and* colour — a four-colour print needs those four colours, and one spool can't fill two slots. Near shades count as the same colour; the model page lists every slot, which spool fills it, and the nearest thing you own when none does. Models that aren't slicer projects don't name their filaments, and say so rather than being called unprintable. The slot list can be edited on the model page when the project's own list is wrong or you plan to print it differently — set a slot to **any colour** when the shade doesn't matter, and it's filled by any spool of that material. Edited lists are marked as yours and left alone by the background reader; "Use the project's list" puts it back. Adding a spool can start from Bambu Lab's own filament list — pick "PLA Basic — Jade White (10100)" and the material, colour and code fill themselves — and slot colours are named where they match it. That list is included by kind permission of [3D Filament Profiles](https://3dfilamentprofiles.com), whose terms otherwise reserve it; ask them before reusing `db/bambu-filaments.json` elsewhere.
|
||||
- **Filament & what's printable** — keep the spools you own (material, colour, brand, grams left) on the **Filament** page. Bambu Studio / OrcaSlicer projects name the filament in each slot, which Printhive reads once per model in the background, so cards and the model page say whether you can print it now and what you're short of, and the toolbar can show only what's printable. Each slot is matched to a spool by material *and* colour — a four-colour print needs those four colours, and one spool can't fill two slots. Near shades count as the same colour; the model page lists every slot, which spool fills it, and the nearest thing you own when none does. Models that aren't slicer projects don't name their filaments, and say so rather than being called unprintable. Adding a spool can start from Bambu Lab's own filament list — pick "PLA Basic — Jade White (10100)" and the material, colour and code fill themselves — and slot colours are named where they match it. That list is included by kind permission of [3D Filament Profiles](https://3dfilamentprofiles.com), whose terms otherwise reserve it; ask them before reusing `db/bambu-filaments.json` elsewhere.
|
||||
- **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.
|
||||
|
||||
|
|
|
|||
|
|
@ -144,28 +144,12 @@ async function pendingCount() {
|
|||
return rows[0].n;
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Look at every model again — after adding files, say.
|
||||
async function reread() {
|
||||
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);
|
||||
await db.query('UPDATE models SET filaments_status = NULL');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
run, pendingCount, reread, cleanSlots,
|
||||
run, pendingCount, reread,
|
||||
assignSpools, shortfall, isPrintable, normalise, colourDistance, rgb, COLOUR_TOLERANCE,
|
||||
};
|
||||
|
|
|
|||
12
lib/store.js
12
lib/store.js
|
|
@ -696,16 +696,6 @@ 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,
|
||||
|
|
@ -716,7 +706,7 @@ module.exports = {
|
|||
suggestCollections, collectionNameHints, listCollectionSuggestions, acceptCollectionSuggestion, dismissCollectionSuggestion,
|
||||
getAllProjects, getProject, createProject, updateProject, deleteProject, findOrCreateProject, addModelToProject,
|
||||
getSetting, setSetting,
|
||||
listSpools, createSpool, updateSpool, deleteSpool, setModelFilaments,
|
||||
listSpools, createSpool, updateSpool, deleteSpool,
|
||||
countUsers, getUserByEmail, getUserById, createUser, updateUserPassword,
|
||||
createPasswordReset, getValidReset, markResetUsed,
|
||||
getLicenseRule, upsertLicenseRule, listLicenseRules, distinctModelLicenses, applyLicenseRuleToModels,
|
||||
|
|
|
|||
|
|
@ -2146,7 +2146,6 @@ function renderDetailPrintable(model) {
|
|||
el.innerHTML = '<div class="lic-note">This model doesn’t name its filaments — only Bambu Studio and OrcaSlicer projects do.</div>';
|
||||
return;
|
||||
}
|
||||
const yours = model.filaments_status === 'edited';
|
||||
const rows = p.slots.map((s, i) => `
|
||||
<div class="slot-row">
|
||||
<span class="spool-dot" style="background:${escapeHtml(s.colour || '#888888')}"></span>
|
||||
|
|
@ -2162,84 +2161,9 @@ function renderDetailPrintable(model) {
|
|||
</div>`).join('');
|
||||
el.innerHTML = `<div>${p.ok
|
||||
? '<span class="print-badge ok">✔ printable now</span>'
|
||||
: `<span class="print-badge no">needs ${p.unfilled.length} colour${p.unfilled.length === 1 ? '' : 's'}</span>`}
|
||||
${yours ? '<span class="print-badge yours" title="You set these slots; the project’s own list is different">your list</span>' : ''}</div>
|
||||
: `<span class="print-badge no">needs ${p.unfilled.length} colour${p.unfilled.length === 1 ? '' : 's'}</span>`}</div>
|
||||
<div class="slot-list">${rows}</div>
|
||||
<div class="lic-note">A spool fills one slot, and its colour has to be close to what the slot asks for.</div>
|
||||
<div class="slot-actions">
|
||||
<button type="button" class="btn btn-secondary btn-sm" data-slots="edit">Edit slots</button>
|
||||
${yours ? '<button type="button" class="btn btn-secondary btn-sm" data-slots="revert">Use the project’s list</button>' : ''}
|
||||
</div>`;
|
||||
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');
|
||||
// A new slot starts as "any colour": adding one by hand usually means you
|
||||
// care about the material, not the shade.
|
||||
const rowHtml = (s = { material: 'PLA', colour: null }) => `
|
||||
<div class="slot-row slot-edit">
|
||||
<input type="text" class="slot-material" value="${escapeHtml(s.material || 'PLA')}" aria-label="Material" placeholder="PLA">
|
||||
<input type="color" class="slot-colour" value="${escapeHtml((s.colour || '#ffffff').toLowerCase())}" aria-label="Colour"${s.colour ? '' : ' disabled'}>
|
||||
<label class="slot-any" title="Any colour of this material will do">
|
||||
<input type="checkbox" class="slot-any-box"${s.colour ? '' : ' checked'}> any colour
|
||||
</label>
|
||||
<button type="button" class="link-btn" data-slot-drop>remove</button>
|
||||
</div>`;
|
||||
el.innerHTML = `
|
||||
<div class="slot-list" id="slotEditList">${slots.map(rowHtml).join('')}</div>
|
||||
<div class="slot-actions">
|
||||
<button type="button" class="btn btn-secondary btn-sm" id="slotAdd">Add a slot</button>
|
||||
<button type="button" class="btn btn-primary btn-sm" id="slotSave">Save</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm" id="slotCancel">Cancel</button>
|
||||
</div>
|
||||
<div class="lic-note" id="slotStatus">Colour is matched to your spools; a slot left at a colour you don’t own shows as missing.</div>`;
|
||||
|
||||
const list = document.getElementById('slotEditList');
|
||||
const bindRow = (row) => {
|
||||
row.querySelector('[data-slot-drop]').addEventListener('click', () => row.remove());
|
||||
const any = row.querySelector('.slot-any-box');
|
||||
const colour = row.querySelector('.slot-colour');
|
||||
any.addEventListener('change', () => { colour.disabled = any.checked; });
|
||||
};
|
||||
[...list.children].forEach(bindRow);
|
||||
document.getElementById('slotAdd').addEventListener('click', () => {
|
||||
list.insertAdjacentHTML('beforeend', rowHtml());
|
||||
bindRow(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(),
|
||||
// "any colour" sends nothing, which the matcher reads as any spool of
|
||||
// that material.
|
||||
colour: row.querySelector('.slot-any-box').checked ? null : 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);
|
||||
}
|
||||
<div class="lic-note">A spool fills one slot, and its colour has to be close to what the slot asks for.</div>`;
|
||||
}
|
||||
|
||||
function renderDetailLicense(model) {
|
||||
|
|
|
|||
|
|
@ -465,13 +465,6 @@ 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; }
|
||||
.slot-edit .slot-colour:disabled { opacity: 0.35; }
|
||||
.slot-any { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; color: var(--text-muted); }
|
||||
.slot-any input { width: auto; accent-color: var(--accent); }
|
||||
.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; }
|
||||
|
|
|
|||
28
server.js
28
server.js
|
|
@ -464,34 +464,6 @@ 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);
|
||||
|
|
|
|||
|
|
@ -84,49 +84,3 @@ 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([]), []);
|
||||
});
|
||||
|
||||
test('"any colour" survives the trip from the page to the database', () => {
|
||||
// What the editor sends when the box is ticked, plus the two shapes that can
|
||||
// arrive from older clients or a hand-made request.
|
||||
assert.deepEqual(filaments.cleanSlots([
|
||||
{ type: 'PLA', colour: null },
|
||||
{ type: 'PETG', colour: '' },
|
||||
{ type: 'ABS' },
|
||||
]), [
|
||||
{ type: 'PLA', colour: null },
|
||||
{ type: 'PETG', colour: null },
|
||||
{ type: 'ABS', colour: null },
|
||||
]);
|
||||
});
|
||||
|
||||
test('a slot with no colour is filled by any spool of that material', () => {
|
||||
const anyWhite = [{ type: 'PLA', colour: null }];
|
||||
assert.equal(filaments.isPrintable(anyWhite,
|
||||
[{ id: 1, material: 'PLA', colour_hex: '#123456', colour: 'Anything', remaining_g: 200 }]), true);
|
||||
// …but still not by the wrong material, and still one spool per slot.
|
||||
assert.equal(filaments.isPrintable(anyWhite,
|
||||
[{ id: 1, material: 'PETG', colour_hex: '#123456', colour: 'Anything', remaining_g: 200 }]), false);
|
||||
assert.equal(filaments.isPrintable([{ type: 'PLA', colour: null }, { type: 'PLA', colour: null }],
|
||||
[{ id: 1, material: 'PLA', colour_hex: '#123456', colour: 'Anything', remaining_g: 200 }]), false);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue