printhive/public/app.js
dlawler489 2635a1e9f0 Pick spools from the maker's filament list, and name slot colours
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>
2026-09-17 13:01:09 +10:00

3132 lines
138 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// State
let models = []; // the library (not in the trash)
let trashModels = []; // loaded while the Trash view is open
let projects = [];
let config = { importers: {} };
let stats = { trash: 0, duplicateGroups: 0, fingerprintsPending: 0 };
let selectedScope = 'all'; // 'all' | 'duplicates' | 'trash' | a collection id
let spools = []; // filament on hand, so cards can say what's printable
let palette = []; // the maker's filament list: names, codes and colours
let tagFilters = {}; // tag -> 'include' | 'exclude'
let duplicateData = null; // last /api/duplicates response
let duplicatePoll = null;
// View preferences, remembered per browser.
const prefs = { view: 'grid', tagMatch: 'any', format: '', source: '', license: '', printable: '', sort: 'newest', group: '', slicer: '', slicerTemplate: '', searchMode: 'words' };
const PREFS_KEY = 'printhive.prefs';
// Scopes that aren't a collection id. Leaving one out means loadData() decides
// the collection vanished and drops you back to All Models on the next refresh.
const SPECIAL_SCOPES = ['all', 'duplicates', 'trash', 'ai', 'watch', 'filament'];
// DOM
const fileGrid = document.getElementById('fileGrid');
const projectsList = document.getElementById('projectsList');
const tagsList = document.getElementById('tagsList');
const searchInput = document.getElementById('searchInput');
const contentTitle = document.getElementById('contentTitle');
const resultCount = document.getElementById('resultCount');
const emptyTrashBtn = document.getElementById('emptyTrashBtn');
const uploadModal = document.getElementById('uploadModal');
const projectModal = document.getElementById('projectModal');
const importModal = document.getElementById('importModal');
const settingsModal = document.getElementById('settingsModal');
const setupModal = document.getElementById('setupModal');
const editModal = document.getElementById('editModal');
const fileDetailModal = document.getElementById('fileDetailModal');
const slicerModal = document.getElementById('slicerModal');
const tokenModal = document.getElementById('tokenModal');
document.addEventListener('DOMContentLoaded', () => {
loadPrefs();
setupAuthListeners();
bootstrap();
});
function loadPrefs() {
try { Object.assign(prefs, JSON.parse(localStorage.getItem(PREFS_KEY)) || {}); } catch { /* storage unavailable */ }
}
function savePrefs() {
try { localStorage.setItem(PREFS_KEY, JSON.stringify(prefs)); } catch { /* storage unavailable */ }
}
// Decide what to show: the app (if signed in), the reset form (if ?reset=...),
// or the sign-in / create-account screen.
async function bootstrap() {
const resetToken = new URLSearchParams(location.search).get('reset');
if (resetToken) { showAuth('reset'); return; }
let status;
try {
status = await (await fetch('/api/auth/status')).json();
} catch {
status = { authenticated: false, needsSetup: false };
}
if (status.authenticated) startApp();
else showAuth(status.needsSetup ? 'register' : 'login');
}
let appStarted = false;
function startApp() {
document.getElementById('authScreen').style.display = 'none';
document.querySelector('.container').style.display = '';
if (!appStarted) { setupEventListeners(); appStarted = true; }
loadData();
}
function showAuth(mode) {
document.querySelector('.container').style.display = 'none';
document.getElementById('authScreen').style.display = 'flex';
['login', 'register', 'forgot', 'reset'].forEach((m) => {
document.getElementById('auth-' + m).style.display = m === mode ? '' : 'none';
});
document.getElementById('authError').textContent = '';
}
function setupAuthListeners() {
document.getElementById('auth-login').addEventListener('submit', handleLogin);
document.getElementById('auth-register').addEventListener('submit', handleRegister);
document.getElementById('auth-forgot').addEventListener('submit', handleForgot);
document.getElementById('auth-reset').addEventListener('submit', handleReset);
document.querySelectorAll('.auth-nav').forEach((b) => {
b.addEventListener('click', () => showAuth(b.dataset.auth));
});
}
async function authPost(url, body) {
const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || 'Something went wrong.');
return data;
}
function authError(msg) { document.getElementById('authError').textContent = msg; }
async function handleLogin(e) {
e.preventDefault();
try {
await authPost('/api/auth/login', {
email: document.getElementById('loginEmail').value,
password: document.getElementById('loginPassword').value,
});
location.reload();
} catch (err) { authError(err.message); }
}
async function handleRegister(e) {
e.preventDefault();
try {
await authPost('/api/auth/register', {
email: document.getElementById('regEmail').value,
password: document.getElementById('regPassword').value,
});
location.reload();
} catch (err) { authError(err.message); }
}
async function handleForgot(e) {
e.preventDefault();
try {
await authPost('/api/auth/forgot', { email: document.getElementById('forgotEmail').value });
authError('If that email has an account and email is configured, a reset link is on its way.');
} catch (err) { authError(err.message); }
}
async function handleReset(e) {
e.preventDefault();
const token = new URLSearchParams(location.search).get('reset');
try {
await authPost('/api/auth/reset', { token, password: document.getElementById('resetPassword').value });
window.history.replaceState({}, '', location.pathname); // drop ?reset=
showAuth('login');
authError('Password updated — you can sign in now.');
} catch (err) { authError(err.message); }
}
async function handleLogout() {
try { await fetch('/api/auth/logout', { method: 'POST' }); } catch { /* ignore */ }
location.reload();
}
async function loadData() {
try {
const [modelsRes, projectsRes, configRes, statsRes, filamentRes] = await Promise.all([
fetch('/api/models'),
fetch('/api/projects'),
fetch('/api/config'),
fetch('/api/library/stats'),
fetch('/api/filament'),
]);
models = await modelsRes.json();
projects = await projectsRes.json();
config = await configRes.json();
stats = statsRes.ok ? await statsRes.json() : stats;
spools = filamentRes.ok ? (await filamentRes.json()).spools : [];
// Static and ~40KB, so fetched once rather than on every refresh.
if (!palette.length) {
palette = await fetch('/api/filament/palette')
.then((r) => (r.ok ? r.json() : { filaments: [] })).then((p) => p.filaments || []).catch(() => []);
}
if (selectedScope === 'trash') await loadTrash();
if (!SPECIAL_SCOPES.includes(selectedScope) && !projects.some((p) => String(p.id) === String(selectedScope))) {
selectedScope = 'all'; // the open collection was deleted
}
renderProjects();
renderTags();
renderFilterOptions();
renderModels();
updateCounts();
generateMissingThumbnails(); // fire-and-forget; updates cards as they finish
watchBackgroundWork();
// First-run onboarding: prompt for import sources once.
if (config.setupComplete === false && !setupModal.classList.contains('active')) {
setupModal.classList.add('active');
}
} catch (error) {
console.error('Error loading data:', error);
}
}
// While the server is still fingerprinting or making thumbnails, check back
// every few seconds and reload once its progress changes, so new thumbnails
// and duplicates show up without a manual refresh.
let backgroundPoll = null;
function watchBackgroundWork() {
clearTimeout(backgroundPoll);
const pendingOf = (s) => (s.fingerprintsPending || 0) + (s.thumbnailsPending || 0) + (s.tagsPending || 0);
const pending = pendingOf(stats);
if (!pending) return;
backgroundPoll = setTimeout(async () => {
try {
const next = await (await fetch('/api/library/stats')).json();
if (pendingOf(next) !== pending || next.duplicateGroups !== stats.duplicateGroups) {
await loadData(); // re-arms the watch
return;
}
stats = next;
watchBackgroundWork();
} catch { /* offline: try again on the next reload */ }
}, 5000);
}
async function loadTrash() {
const res = await fetch('/api/models?view=trash');
trashModels = res.ok ? await res.json() : [];
}
// The server makes STL/OBJ/3MF thumbnails in the background; the browser
// renders the formats only it can read, then saves them. One at a time, to
// avoid thrashing the GPU / WebGL contexts.
const BROWSER_THUMBNAIL_FORMATS = ['fbx', '3dm', 'step', 'stp'];
const thumbAttempted = new Set();
async function generateMissingThumbnails() {
if (!window.ModelViewer) return;
for (const m of models) {
if (m.thumbnailUrl || thumbAttempted.has(m.id)) continue;
const file = (m.files || []).find((f) => BROWSER_THUMBNAIL_FORMATS.includes(f.format));
if (!file) continue;
thumbAttempted.add(m.id);
try {
const src = viewerSourceFor({ kind: 'file', id: file.id, format: file.format });
const image = await window.ModelViewer.snapshot(src.url, src.format, 512, { resolve: resolverFor(m) });
if (!image) continue;
const res = await fetch(`/api/models/${m.id}/thumbnail`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image }),
});
if (!res.ok) continue;
const updated = await res.json();
updateCardThumbnail(m.id, updated.thumbnailUrl);
} catch (err) {
console.warn('Thumbnail generation failed for model', m.id, err);
}
}
}
// ---------------------------------------------------------------- lookups
function findModel(id) {
return models.find((m) => String(m.id) === String(id))
|| trashModels.find((m) => String(m.id) === String(id));
}
function replaceModel(updated) {
for (const list of [models, trashModels]) {
const i = list.findIndex((m) => m.id === updated.id);
if (i !== -1) list[i] = updated;
}
}
const SOURCE_LABELS = {
upload: 'Manual upload', thingiverse: 'Thingiverse', printables: 'Printables',
makerworld: 'MakerWorld', cubee3d: 'Cubee3D', web: 'Web',
};
const sourceKey = (m) => m.source_site || 'upload';
const sourceLabel = (key) => SOURCE_LABELS[key] || key;
const licenseKey = (m) => (m.commercial_use === true ? 'sellable' : m.commercial_use === false ? 'noncommercial' : 'unknown');
// Can you print it with what's on the shelf? Mirrors lib/filaments.js on the
// server: material only (a different colour still prints), an empty spool
// doesn't count, and a model whose filaments were never read is "unknown"
// rather than "no" — only slicer projects name their filaments.
const filamentType = (s) => String(s || '').trim().toUpperCase().replace(/\s+/g, ' ');
// Mirrors lib/filaments.js. Bambu writes #RRGGBB and #RRGGBBAA; alpha isn't colour.
function rgbOf(hex) {
const m = /^#?([0-9a-f]{6})(?:[0-9a-f]{2})?$/i.exec(String(hex || '').trim());
if (!m) return null;
const n = parseInt(m[1], 16);
return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
}
// Redmean distance: nearer to how an eye judges colours than plain RGB.
function colourDistance(a, b) {
const x = rgbOf(a);
const y = rgbOf(b);
if (!x || !y) return null;
const rm = (x.r + y.r) / 2;
const dr = x.r - y.r;
const dg = x.g - y.g;
const db = x.b - y.b;
return Math.sqrt((2 + rm / 256) * dr * dr + 4 * dg * dg + (2 + (255 - rm) / 256) * db * db);
}
const COLOUR_TOLERANCE = 50; // keeps #000000 and #161616 (~66 apart) distinct
// The maker's name for a colour, when the palette has one that close. The
// threshold is deliberately tight: this is identification, not substitution.
function paletteName(hex, material) {
let best = null;
for (const f of palette) {
if (material && filamentType(f.material) !== filamentType(material)) continue;
for (const c of f.colours || []) {
const d = colourDistance(hex, c);
if (d !== null && d <= 12 && (!best || d < best.d)) best = { d, name: f.name, type: f.type };
}
}
return best ? `${best.type} ${best.name}` : null;
}
// Which spool fills which slot — best colour matches first, one spool per slot.
function printability(m) {
const slots = ((m && m.filaments) || []).map((f, i) => ({
index: i, material: filamentType(f.type), colour: f.colour || null,
}));
if (!slots.length) return { known: false, slots: [], unfilled: [], ok: false };
const shelf = spools
.filter((s) => s.remaining_g == null || Number(s.remaining_g) > 0)
.map((s, i) => ({ key: s.id == null ? `i${i}` : `s${s.id}`, material: filamentType(s.material),
colour: s.colour_hex || null, name: s.colour || s.material }));
const pairs = [];
for (const slot of slots) {
for (const spool of shelf) {
if (spool.material !== slot.material) continue;
if (slot.colour === null) { pairs.push({ slot: slot.index, spool: spool.key, d: 0 }); continue; }
const d = colourDistance(slot.colour, spool.colour);
if (d !== null && d <= COLOUR_TOLERANCE) pairs.push({ slot: slot.index, spool: spool.key, d });
}
}
pairs.sort((a, b) => a.d - b.d);
const filledBy = new Map();
const used = new Set();
for (const p of pairs) {
if (filledBy.has(p.slot) || used.has(p.spool)) continue;
filledBy.set(p.slot, p.spool);
used.add(p.spool);
}
const resolved = slots.map((slot) => {
const key = filledBy.get(slot.index);
const by = key ? shelf.find((s) => s.key === key) : null;
let nearest = null;
if (!by && slot.colour) {
for (const spool of shelf) {
if (spool.material !== slot.material) continue;
const d = colourDistance(slot.colour, spool.colour);
if (d !== null && (!nearest || d < nearest.distance)) nearest = { name: spool.name, distance: Math.round(d) };
}
}
return { ...slot, filled: Boolean(by), by: by ? by.name : null, nearest };
});
const unfilled = resolved.filter((s) => !s.filled);
return { known: true, slots: resolved, unfilled, ok: unfilled.length === 0 };
}
function printBadgeHtml(m) {
const p = printability(m);
if (!p.known) return '';
if (p.ok) return `<span class="print-badge ok" title="A spool for every slot">✔ printable</span>`;
const n = p.unfilled.length;
return `<span class="print-badge no" title="${n} of ${p.slots.length} slots have no matching spool">needs ${n} colour${n === 1 ? '' : 's'}</span>`;
}
const totalSize = (m) => (m.files || []).reduce((s, f) => s + Number(f.size || 0), 0);
const fileCountLabel = (m) => `${m.files.length} file${m.files.length === 1 ? '' : 's'}`;
// Everything a search term can match, lower-cased. Cached per model object
// (objects are replaced whenever data reloads, which invalidates the cache).
const searchCache = new WeakMap();
function searchText(m) {
if (!searchCache.has(m)) {
searchCache.set(m, [
m.name, m.description, m.notes, m.license, sourceLabel(sourceKey(m)),
m.designer && m.designer.name,
...(m.tags || []),
...(m.projects || []).map((p) => p.name),
...(m.files || []).map((f) => f.originalName),
].filter(Boolean).join('\n').toLowerCase());
}
return searchCache.get(m);
}
// ---------------------------------------------------------------- rendering
function renderProjects() {
projectsList.innerHTML = projects.length
? projects.map((p) => `
<div class="project-item" data-scope="${p.id}">
<span class="project-name">${escapeHtml(p.name)}</span>
<span class="project-meta">
<button class="collection-action collection-rename" title="Rename" aria-label="Rename collection">&#9998;</button>
<button class="collection-action collection-delete" title="Delete" aria-label="Delete collection">&times;</button>
<span class="count">${p.model_count}</span>
</span>
</div>`).join('')
: '<p class="form-hint">No collections yet.</p>';
}
function renderTags() {
const all = knownTags();
for (const t of Object.keys(tagFilters)) if (!all.includes(t)) delete tagFilters[t];
const titles = {
include: 'Showing models with this tag — click to exclude it',
exclude: 'Hiding models with this tag — click to clear',
};
tagsList.innerHTML = all.length
? all.map((tag) => {
const state = tagFilters[tag];
return `<span class="tag${state ? ` ${state}` : ''}" data-tag="${escapeHtml(tag)}"
title="${titles[state] || 'Click to show only models with this tag'}">${escapeHtml(tag)}</span>`;
}).join('')
: '<p class="form-hint">No tags yet.</p>';
document.getElementById('clearTags').hidden = Object.keys(tagFilters).length === 0;
document.querySelectorAll('#tagMatch button').forEach((b) => {
b.classList.toggle('active', b.dataset.match === prefs.tagMatch);
});
}
// Format/source dropdowns list only what's in the library. A remembered choice
// that no longer matches anything is reset rather than silently hiding everything.
function renderFilterOptions() {
const formats = [...new Set(models.flatMap((m) => (m.files || []).map((f) => f.format)))].sort();
const sources = [...new Set(models.map(sourceKey))].sort();
fillSelect('filterFormat', 'Any format', formats.map((f) => [f, `.${f}`]), 'format');
fillSelect('filterSource', 'Any source', sources.map((s) => [s, sourceLabel(s)]), 'source');
}
function fillSelect(id, anyLabel, options, prefKey) {
if (prefs[prefKey] && !options.some(([value]) => value === prefs[prefKey])) {
prefs[prefKey] = '';
savePrefs();
}
const el = document.getElementById(id);
el.innerHTML = `<option value="">${anyLabel}</option>`
+ options.map(([value, label]) => `<option value="${escapeHtml(value)}">${escapeHtml(label)}</option>`).join('');
el.value = prefs[prefKey];
}
function scopeTitle() {
if (selectedScope === 'all') return 'All Models';
if (selectedScope === 'duplicates') return 'Duplicates';
if (selectedScope === 'trash') return 'Trash';
if (selectedScope === 'ai') return 'AI & automatic tags';
if (selectedScope === 'watch') return 'Watched collections';
if (selectedScope === 'filament') return 'Filament';
const p = projects.find((x) => String(x.id) === String(selectedScope));
return p ? p.name : 'Collection';
}
function updateScopeChrome() {
contentTitle.textContent = scopeTitle();
const panel = ['duplicates', 'ai', 'watch'].includes(selectedScope); // pages without the model grid
document.getElementById('filterBar').hidden = panel;
document.querySelector('.view-options').hidden = panel;
emptyTrashBtn.hidden = !(selectedScope === 'trash' && trashModels.length);
document.querySelectorAll('.project-item[data-scope]').forEach((el) => {
el.classList.toggle('active', el.dataset.scope === String(selectedScope));
});
document.querySelectorAll('.view-btn').forEach((b) => b.classList.toggle('active', b.dataset.view === prefs.view));
}
function renderModels() {
updateScopeChrome();
if (selectedScope === 'duplicates') { clearTimeout(aiPoll); renderDuplicates(); return; }
clearTimeout(duplicatePoll);
if (selectedScope === 'ai') { clearTimeout(watchPoll); renderAiPanel(); return; }
clearTimeout(aiPoll);
if (selectedScope === 'watch') { renderWatchPanel(); return; }
if (selectedScope === 'filament') { renderFilamentPanel(); return; }
clearTimeout(watchPoll);
const list = getFilteredModels();
resultCount.textContent = `${list.length} model${list.length === 1 ? '' : 's'}${searchNote()}`;
fileGrid.className = prefs.view === 'list' ? 'file-grid list-view' : 'file-grid';
if (list.length === 0) {
fileGrid.innerHTML = emptyStateHtml();
return;
}
if (!prefs.group) {
fileGrid.innerHTML = list.map(cardHtml).join('');
return;
}
const groups = new Map();
for (const m of list) {
const key = groupLabel(m);
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(m);
}
fileGrid.innerHTML = [...groups.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([label, ms]) => `<div class="group-header">${escapeHtml(label)} <span>${ms.length}</span></div>`
+ ms.map(cardHtml).join(''))
.join('');
}
function groupLabel(m) {
if (prefs.group === 'source') return sourceLabel(sourceKey(m));
const formats = [...new Set((m.files || []).map((f) => `.${f.format}`))].sort();
return formats.length ? formats.join(' + ') : 'No files';
}
function emptyStateHtml() {
const state = (title, text) => `<div class="empty-state"><h3>${title}</h3><p>${text}</p></div>`;
if (selectedScope === 'trash' && trashModels.length === 0) {
return state('Trash is empty', 'Models you delete wait here until you restore them or empty the trash.');
}
if (selectedScope === 'all' && models.length === 0) {
return state('No models yet', 'Upload files or import from a URL to get started');
}
return state('No models match', 'Try clearing the search, filters or tag selection.');
}
function thumbHtml(m) {
return m.thumbnailUrl ? `<img src="${m.thumbnailUrl}" alt="" loading="lazy" draggable="false">` : '<span>📦</span>';
}
function cardHtml(m) {
const trashed = Boolean(m.deleted_at);
const when = trashed ? `trashed ${formatDate(m.deleted_at)}` : formatDate(m.date_added);
return `
<div class="file-card" data-model-id="${m.id}"${CAN_DRAG_OUT && !trashed && m.files.length ? ' draggable="true" title="Drag out to save the file"' : ''}>
<div class="file-thumbnail">${thumbHtml(m)}</div>
<div class="file-info">
<div class="file-name">${escapeHtml(m.name)}</div>
${m.source_site ? `<span class="source-badge">${escapeHtml(m.source_site)}</span>` : ''}
${m.commercial_use === true ? '<span class="com-badge ok" title="Commercial use allowed">$ sellable</span>' : m.commercial_use === false ? '<span class="com-badge no" title="Non-commercial only">non-commercial</span>' : ''}
${printBadgeHtml(m)}
${m.description ? `<div class="file-description">${escapeHtml(m.description)}</div>` : ''}
${(m.tags && m.tags.length) ? `<div class="file-tags">${m.tags.map((t) => `<span class="file-tag">${escapeHtml(t)}</span>`).join('')}</div>` : ''}
<div class="file-meta">${fileCountLabel(m)} • ${formatFileSize(totalSize(m))} • ${when}</div>
${trashed ? `
<div class="card-actions">
<button class="btn btn-secondary btn-sm" data-action="restore">Restore</button>
<button class="btn btn-danger btn-sm" data-action="purge">Delete forever</button>
</div>` : ''}
</div>
</div>`;
}
const SORTS = {
newest: (a, b) => new Date(b.date_added) - new Date(a.date_added),
oldest: (a, b) => new Date(a.date_added) - new Date(b.date_added),
name: (a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }),
'name-desc': (a, b) => b.name.localeCompare(a.name, undefined, { numeric: true, sensitivity: 'base' }),
size: (a, b) => totalSize(b) - totalSize(a),
modified: (a, b) => new Date(b.date_modified || b.date_added) - new Date(a.date_modified || a.date_added),
};
function getFilteredModels() {
let result = selectedScope === 'trash' ? trashModels : models;
if (!SPECIAL_SCOPES.includes(selectedScope)) {
result = result.filter((m) => (m.projects || []).some((p) => String(p.id) === String(selectedScope)));
}
const include = Object.keys(tagFilters).filter((t) => tagFilters[t] === 'include');
const exclude = Object.keys(tagFilters).filter((t) => tagFilters[t] === 'exclude');
if (include.length) {
result = result.filter((m) => {
const tags = m.tags || [];
return prefs.tagMatch === 'all' ? include.every((t) => tags.includes(t)) : include.some((t) => tags.includes(t));
});
}
if (exclude.length) result = result.filter((m) => !exclude.some((t) => (m.tags || []).includes(t)));
if (prefs.format) result = result.filter((m) => (m.files || []).some((f) => f.format === prefs.format));
if (prefs.source) result = result.filter((m) => sourceKey(m) === prefs.source);
if (prefs.license) result = result.filter((m) => licenseKey(m) === prefs.license);
// Only models we've actually read filaments for can answer this either way.
if (prefs.printable) {
result = result.filter((m) => {
const p = printability(m);
return p.known && (prefs.printable === 'yes' ? p.ok : !p.ok);
});
}
const terms = searchInput.value.toLowerCase().split(/\s+/).filter(Boolean);
if (terms.length) {
const wordMatch = (m) => terms.every((t) => searchText(m).includes(t));
const meaning = meaningResults();
if (meaning) {
// Models close in meaning, plus anything matching the words; ranked by
// closeness, with word matches first.
const best = Math.max(0, ...meaning.values());
const cutoff = Math.max(MEANING_MIN_SCORE, best - MEANING_SPREAD);
const rank = (m) => (meaning.get(m.id) || 0) + (wordMatch(m) ? 1 : 0);
return result.filter((m) => wordMatch(m) || (meaning.get(m.id) || 0) >= cutoff).sort((a, b) => rank(b) - rank(a));
}
result = result.filter(wordMatch);
}
return [...result].sort(SORTS[prefs.sort] || SORTS.newest);
}
function updateCounts() {
document.getElementById('allFilesCount').textContent = models.length;
document.getElementById('aiBusy').hidden = !stats.aiRunning;
document.getElementById('watchCount').textContent = stats.watches ?? 0;
document.getElementById('trashCount').textContent = stats.trash ?? 0;
document.getElementById('duplicatesCount').textContent = stats.duplicateGroups ?? 0;
}
// ---------------------------------------------------------------- duplicates
async function renderDuplicates() {
fileGrid.className = 'dup-list';
resultCount.textContent = '';
if (!duplicateData) fileGrid.innerHTML = '<div class="empty-state"><p>Looking for duplicates…</p></div>';
try {
const res = await fetch('/api/duplicates');
if (!res.ok) throw new Error((await res.json()).error);
duplicateData = await res.json();
} catch (err) {
fileGrid.innerHTML = `<div class="empty-state"><h3>Couldn't check for duplicates</h3><p>${escapeHtml(err.message)}</p></div>`;
return;
}
if (selectedScope !== 'duplicates') return; // navigated away while loading
const { groups, pending } = duplicateData;
stats.duplicateGroups = groups.length;
updateCounts();
resultCount.textContent = `${groups.length} group${groups.length === 1 ? '' : 's'}`;
const pendingNote = pending
? `<div class="dup-pending">Fingerprinting ${pending} file${pending === 1 ? '' : 's'}… this list updates automatically.</div>`
: '';
fileGrid.innerHTML = pendingNote + (groups.length
? groups.map(duplicateGroupHtml).join('')
: '<div class="empty-state"><h3>No duplicates found</h3><p>Files are compared by content and by shape, so renamed or re-exported copies show up here too.</p></div>');
clearTimeout(duplicatePoll);
if (pending) duplicatePoll = setTimeout(() => { if (selectedScope === 'duplicates') renderDuplicates(); }, 4000);
}
function duplicateGroupHtml(g) {
const title = g.exact ? 'Exact copies' : 'Same shape';
const hint = g.exact
? 'These models share byte-for-byte identical files.'
: 'Geometry matches but the files differ (re-exported, converted, or possibly mirrored) — compare before trashing.';
return `
<section class="dup-group" data-signature="${escapeHtml(g.signature)}">
<header class="dup-head">
<div>
<h3>${title} · ${g.models.length} models</h3>
<p class="form-hint">${hint}</p>
</div>
<button class="btn btn-secondary btn-sm" data-action="dismiss">Not duplicates</button>
</header>
<div class="dup-cards">${g.models.map((m) => duplicateCardHtml(g, m)).join('')}</div>
</section>`;
}
function duplicateCardHtml(g, m) {
const recommended = m.id === g.recommendedId;
const unique = m.matchableFiles - m.matchedFiles;
return `
<div class="dup-card${recommended ? ' recommended' : ''}" data-model-id="${m.id}">
<div class="file-thumbnail">${thumbHtml(m)}</div>
<div class="file-info">
${recommended ? '<span class="rec-badge">Recommended keep</span>' : ''}
<div class="file-name" title="${escapeHtml(m.name)}">${escapeHtml(m.name)}</div>
<div class="file-meta">${escapeHtml(sourceLabel(sourceKey(m)))} • ${formatDate(m.date_added)}</div>
<div class="file-meta">${m.matchedFiles} of ${m.matchableFiles} files match${unique ? ` • <span class="warn">${unique} unique</span>` : ''}</div>
<button class="btn btn-primary btn-sm" data-action="keep">Keep this one</button>
</div>
</div>`;
}
async function handleKeepDuplicate(signature, keepId) {
const group = duplicateData && duplicateData.groups.find((g) => g.signature === signature);
if (!group) return;
const keep = group.models.find((m) => m.id === keepId);
const others = group.models.filter((m) => m.id !== keepId);
let message = `Keep "${keep.name}" and move ${others.length} other model${others.length === 1 ? '' : 's'} to the trash?\n\n`
+ 'Their tags, collections and notes are merged into the one you keep.';
const withUnique = others.filter((m) => m.matchableFiles > m.matchedFiles);
if (withUnique.length) {
message += `\n\nHeads up: ${withUnique.map((m) => `"${m.name}"`).join(', ')} also contain files that aren't duplicated — they go to the trash too.`;
}
if (!confirm(message)) return;
try {
const res = await fetch('/api/duplicates/resolve', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ keepId, trashIds: others.map((m) => m.id) }),
});
if (!res.ok) throw new Error((await res.json()).error);
await loadData();
} catch (err) {
alert('Could not resolve duplicates: ' + err.message);
}
}
async function handleDismissDuplicates(signature) {
const group = duplicateData && duplicateData.groups.find((g) => g.signature === signature);
if (!group) return;
try {
const res = await fetch('/api/duplicates/dismiss', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ modelIds: group.models.map((m) => m.id) }),
});
if (!res.ok) throw new Error((await res.json()).error);
await loadData();
} catch (err) {
alert('Could not dismiss: ' + err.message);
}
}
// ---------------------------------------------------------------- trash
async function restoreModel(id) {
try {
const res = await fetch(`/api/models/${id}/restore`, { method: 'POST' });
if (!res.ok) throw new Error((await res.json()).error);
await loadData();
return true;
} catch (err) {
alert('Restore failed: ' + err.message);
return false;
}
}
async function purgeModel(id) {
const model = findModel(id);
if (!confirm(`Permanently delete "${model ? model.name : 'this model'}" and its files?\n\nThis can't be undone.`)) return false;
try {
const res = await fetch(`/api/models/${id}?permanent=true`, { method: 'DELETE' });
if (!res.ok) throw new Error((await res.json()).error);
await loadData();
return true;
} catch (err) {
alert('Delete failed: ' + err.message);
return false;
}
}
async function handleEmptyTrash() {
const n = trashModels.length;
if (!n || !confirm(`Permanently delete ${n} model${n === 1 ? '' : 's'} in the trash and all their files?\n\nThis can't be undone.`)) return;
try {
const res = await fetch('/api/trash', { method: 'DELETE' });
if (!res.ok) throw new Error((await res.json()).error);
await loadData();
} catch (err) {
alert('Emptying the trash failed: ' + err.message);
}
}
// ---------------------------------------------------------------- events
function setupEventListeners() {
document.getElementById('uploadBtn').addEventListener('click', openUploadModal);
document.getElementById('newProjectBtn').addEventListener('click', openProjectModal);
document.getElementById('importBtn').addEventListener('click', openImportModal);
document.getElementById('settingsBtn').addEventListener('click', openSettingsModal);
document.getElementById('logoutBtn').addEventListener('click', handleLogout);
document.getElementById('settingsForm').addEventListener('submit', handleSaveSettings);
document.getElementById('cancelSettings').addEventListener('click', () => closeModal(settingsModal));
document.querySelectorAll('.clear-setting').forEach((btn) => {
btn.addEventListener('click', () => handleClearSetting(btn.dataset.setting));
});
document.querySelectorAll('.close-btn').forEach((btn) => {
btn.addEventListener('click', (e) => closeModal(e.target.closest('.modal')));
});
document.querySelectorAll('.modal').forEach((modal) => {
if (modal.id === 'setupModal') return; // must be finished or skipped, not backdrop-dismissed
modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(modal); });
});
document.getElementById('setupForm').addEventListener('submit', handleFinishSetup);
document.getElementById('skipSetup').addEventListener('click', handleSkipSetup);
document.getElementById('uploadForm').addEventListener('submit', handleUpload);
document.getElementById('cancelUpload').addEventListener('click', () => closeModal(uploadModal));
document.getElementById('projectForm').addEventListener('submit', handleCreateProject);
document.getElementById('cancelProject').addEventListener('click', () => closeModal(projectModal));
document.getElementById('importForm').addEventListener('submit', handleImport);
document.getElementById('cancelImport').addEventListener('click', () => closeModal(importModal));
document.getElementById('fileInput').addEventListener('change', (e) => {
const f = e.target.files[0];
const nameField = document.getElementById('fileName');
if (f && !nameField.value) nameField.value = f.name.replace(/\.[^.]+$/, '');
});
// Library views (All / Duplicates / Trash) and collections share one scope.
document.querySelector('.sidebar').addEventListener('click', (e) => {
const item = e.target.closest('.project-item[data-scope]');
if (!item) return;
const scope = item.dataset.scope;
if (e.target.closest('.collection-rename')) { handleRenameCollection(scope, item.querySelector('.project-name').textContent); return; }
if (e.target.closest('.collection-delete')) { handleDeleteCollection(scope, item.querySelector('.project-name').textContent); return; }
selectScope(scope);
});
tagsList.addEventListener('click', (e) => {
const el = e.target.closest('.tag');
if (!el) return;
const tag = el.dataset.tag;
if (!tagFilters[tag]) tagFilters[tag] = 'include';
else if (tagFilters[tag] === 'include') tagFilters[tag] = 'exclude';
else delete tagFilters[tag];
renderTags();
renderModels();
});
document.getElementById('tagMatch').addEventListener('click', (e) => {
const btn = e.target.closest('button[data-match]');
if (!btn) return;
prefs.tagMatch = btn.dataset.match;
savePrefs();
renderTags();
renderModels();
});
document.getElementById('clearTags').addEventListener('click', () => {
tagFilters = {};
renderTags();
renderModels();
});
searchInput.addEventListener('input', renderModels);
[['filterFormat', 'format'], ['filterSource', 'source'], ['filterLicense', 'license'],
['filterPrintable', 'printable'], ['sortBy', 'sort'], ['groupBy', 'group']]
.forEach(([id, key]) => {
const el = document.getElementById(id);
el.value = prefs[key] || ''; // a preference added later starts undefined
el.addEventListener('change', () => { prefs[key] = el.value; savePrefs(); renderModels(); });
});
// "/" jumps to search.
document.addEventListener('keydown', (e) => {
if (e.key !== '/' || e.metaKey || e.ctrlKey || e.altKey) return;
if (e.target.closest && e.target.closest('input, textarea, select, [contenteditable]')) return;
if (document.querySelector('.modal.active') || document.getElementById('filterBar').hidden) return;
e.preventDefault();
searchInput.focus();
});
document.querySelectorAll('.view-btn').forEach((btn) => {
btn.addEventListener('click', (e) => {
prefs.view = e.target.dataset.view;
savePrefs();
renderModels();
});
});
fileGrid.addEventListener('click', (e) => {
const card = e.target.closest('[data-model-id]');
const actionBtn = e.target.closest('[data-action]');
if (actionBtn) {
const group = actionBtn.closest('.dup-group');
switch (actionBtn.dataset.action) {
case 'restore': restoreModel(card.dataset.modelId); break;
case 'purge': purgeModel(card.dataset.modelId); break;
case 'keep': handleKeepDuplicate(group.dataset.signature, Number(card.dataset.modelId)); break;
case 'dismiss': handleDismissDuplicates(group.dataset.signature); break;
}
return;
}
if (card) openModelDetail(card.dataset.modelId);
});
emptyTrashBtn.addEventListener('click', handleEmptyTrash);
document.getElementById('deleteFileBtn').addEventListener('click', handleDeleteModel);
document.getElementById('restoreModelBtn').addEventListener('click', async () => {
if (await restoreModel(fileDetailModal.dataset.currentModelId)) closeModal(fileDetailModal);
});
document.getElementById('purgeModelBtn').addEventListener('click', async () => {
if (await purgeModel(fileDetailModal.dataset.currentModelId)) closeModal(fileDetailModal);
});
document.getElementById('detailNotes').addEventListener('input', queueNotesSave);
document.getElementById('detailNotes').addEventListener('blur', flushNotes);
document.getElementById('detailFileTabs').addEventListener('click', (e) => {
const tab = e.target.closest('.file-tab');
const item = tab && detailItems.find((i) => itemKey(i) === tab.dataset.key);
if (!item) return;
renderViewerTabs(itemKey(item));
loadViewer(item);
});
document.getElementById('detailPlates').addEventListener('click', (e) => {
const chip = e.target.closest('.plate-chip');
const item = chip && detailItems.find((i) => itemKey(i) === activeItemKey);
if (!item) return;
item.plate = chip.dataset.plate === 'all' ? null : Number(chip.dataset.plate);
loadViewer(item);
});
document.getElementById('searchMode').addEventListener('click', (e) => {
const btn = e.target.closest('button[data-mode]');
if (!btn) return;
prefs.searchMode = btn.dataset.mode;
savePrefs();
syncSearchMode();
renderModels();
searchInput.focus();
});
syncSearchMode();
document.getElementById('aiTagBtn').addEventListener('click', handleAiTagModel);
document.getElementById('tokenForm').addEventListener('submit', handleTokenSubmit);
document.getElementById('cancelToken').addEventListener('click', () => closeModal(tokenModal));
document.getElementById('detailRenamed').addEventListener('click', (e) => {
if (e.target.closest('#undoRenameBtn')) handleUndoRename();
});
document.getElementById('aiTestBtn').addEventListener('click', handleAiTest);
document.getElementById('aiProvider').addEventListener('change', () => {
// A new provider starts from its own defaults.
['aiModel', 'aiBaseUrl', 'aiPriceInput', 'aiPriceOutput'].forEach((id) => { document.getElementById(id).value = ''; });
syncAiSettingsHints();
});
fileGrid.addEventListener('click', handleAiPanelClick);
fileGrid.addEventListener('submit', (e) => {
if (e.target.id === 'aiStartForm') handleAiStart(e);
if (e.target.id === 'ruleForm') handleAddRule(e);
if (e.target.id === 'watchForm') handleAddWatch(e);
});
fileGrid.addEventListener('change', (e) => {
if (e.target.name === 'aiScope') updateAiEstimate();
if (e.target.id === 'aiDescribe') document.getElementById('aiOverwrite').disabled = !e.target.checked;
if (e.target.id === 'autotagEnabled') handleAutotagToggle(e.target.checked);
});
document.getElementById('openInSlicerBtn').addEventListener('click', () => {
const item = detailItems.find((i) => itemKey(i) === activeItemKey);
if (item) openInSlicer(item);
});
document.getElementById('changeSlicerBtn').addEventListener('click', () => openSlicerModal());
document.getElementById('chooseSlicerBtn').addEventListener('click', () => openSlicerModal());
document.getElementById('slicerForm').addEventListener('submit', handleSaveSlicer);
document.getElementById('slicerForm').addEventListener('change', syncSlicerForm);
document.getElementById('cancelSlicer').addEventListener('click', () => closeModal(slicerModal));
document.getElementById('slicerNotice').addEventListener('click', (e) => {
const btn = e.target.closest('[data-notice-action]');
if (btn && noticeActions[btn.dataset.noticeAction]) noticeActions[btn.dataset.noticeAction]();
});
fileGrid.addEventListener('dragstart', (e) => {
const card = e.target.closest('.file-card[draggable="true"]');
const target = card && modelDragTarget(findModel(card.dataset.modelId));
if (target) setDragOut(e.dataTransfer, target);
});
fileDetailModal.addEventListener('dragstart', (e) => {
const el = e.target.closest && e.target.closest('[data-drag-url]');
if (el) setDragOut(e.dataTransfer, { url: el.dataset.dragUrl, name: el.dataset.dragName });
});
document.getElementById('dragOutBtn').addEventListener('click', (e) => followLink(e.currentTarget.dataset.dragUrl));
document.getElementById('detailFileInfo').addEventListener('click', (e) => {
const slice = e.target.closest('[data-slice-kind]');
if (slice) {
openInSlicer(handoffItem(slice.dataset.sliceKind, Number(slice.dataset.sliceId), slice.dataset.archive));
return;
}
const btn = e.target.closest('[data-view-entry]');
if (!btn) return;
const archiveId = Number(btn.dataset.archive);
const entry = (archiveEntryCache.get(archiveId) || []).find((x) => String(x.id) === btn.dataset.viewEntry);
if (!entry) return;
openViewerItem({ kind: 'entry', id: entry.id, format: entry.format, name: entry.path.split('/').pop(), archiveId });
document.getElementById('detailViewer').scrollIntoView({ block: 'nearest', behavior: 'smooth' });
});
setupTagAutocomplete(document.getElementById('fileTags'));
setupTagAutocomplete(document.getElementById('editTags'));
document.getElementById('regenThumbBtn').addEventListener('click', handleRegenThumbnail);
document.getElementById('refreshLicenseBtn').addEventListener('click', handleRefreshMetadata);
document.getElementById('editModelBtn').addEventListener('click', openEditModal);
document.getElementById('downloadFilesBtn').addEventListener('click', handleDownloadFiles);
document.getElementById('editForm').addEventListener('submit', handleEditModel);
document.getElementById('cancelEdit').addEventListener('click', () => closeModal(editModal));
document.getElementById('addFilesBtn').addEventListener('click', () => document.getElementById('addFilesInput').click());
document.getElementById('addFilesInput').addEventListener('change', handleAddFiles);
}
async function selectScope(scope) {
selectedScope = scope;
if (scope === 'trash') await loadTrash();
renderModels();
}
// Tracks the file currently shown in the detail viewer (for thumbnail regen).
let currentDetailFile = null;
let currentDetailThumb = null; // model image, used as a viewer fallback
function closeModal(modal) {
modal.classList.remove('active');
if (modal === slicerModal) pendingHandoff = null;
if (modal === fileDetailModal) {
hideSlicerNotice();
flushNotes();
if (window.ModelViewer) window.ModelViewer.dispose();
}
}
// ---------------------------------------------------------------- notes
// Notes autosave shortly after typing stops (and on blur / close). The pending
// edit remembers its model, so switching models mid-edit can't misfile it.
let pendingNotes = null;
let notesTimer = null;
function setNotesStatus(text) {
document.getElementById('notesStatus').textContent = text;
}
function queueNotesSave() {
pendingNotes = { id: fileDetailModal.dataset.currentModelId, value: document.getElementById('detailNotes').value };
setNotesStatus('editing…');
clearTimeout(notesTimer);
notesTimer = setTimeout(flushNotes, 800);
}
async function flushNotes() {
clearTimeout(notesTimer);
const pending = pendingNotes;
pendingNotes = null;
if (!pending) return;
const model = findModel(pending.id);
if (model && (model.notes || '') === pending.value) { setNotesStatus(''); return; }
setNotesStatus('saving…');
try {
const res = await fetch(`/api/models/${pending.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ notes: pending.value }),
});
if (!res.ok) throw new Error((await res.json()).error);
replaceModel(await res.json());
if (fileDetailModal.dataset.currentModelId === String(pending.id)) setNotesStatus('saved');
} catch (err) {
setNotesStatus('not saved — ' + err.message);
}
}
// ---------------------------------------------------------------- modals
function openUploadModal() {
document.getElementById('fileProjects').innerHTML =
projects.map((p) => `<option value="${p.id}">${escapeHtml(p.name)}</option>`).join('');
uploadModal.classList.add('active');
}
function openProjectModal() {
document.getElementById('projectForm').reset();
projectModal.classList.add('active');
}
async function markSetupComplete() {
try {
await fetch('/api/setup/complete', { method: 'POST' });
config.setupComplete = true;
} catch { /* non-fatal */ }
}
async function handleFinishSetup(e) {
e.preventDefault();
const status = document.getElementById('setupStatus');
const payload = {};
document.querySelectorAll('#setupForm [data-setting]').forEach((input) => {
if (input.value.trim()) payload[input.dataset.setting] = input.value.trim();
});
try {
if (Object.keys(payload).length) {
const res = await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error((await res.json()).error);
}
await markSetupComplete();
closeModal(setupModal);
await loadData();
} catch (err) {
status.textContent = 'Could not save: ' + err.message;
}
}
async function handleSkipSetup() {
await markSetupComplete();
closeModal(setupModal);
}
async function openSettingsModal() {
document.getElementById('settingsForm').reset();
renderSettingsSlicer();
document.getElementById('settingsStatus').textContent = '';
try {
const settings = await (await fetch('/api/settings')).json();
document.querySelectorAll('.setting-status').forEach((el) => {
const s = settings[el.dataset.status];
if (!s) { el.textContent = '—'; return; }
el.textContent = s.set
? `set ${s.hint || ''}${s.source === 'env' ? ' (from .env — edit here to override)' : ''}`
: 'not set';
el.className = `setting-status ${s.set ? 'is-set' : 'is-unset'}`;
});
// Prefill non-secret fields (e.g. the FlareSolverr URL) with their current value.
document.querySelectorAll('#settingsForm [data-setting]').forEach((input) => {
const s = settings[input.dataset.setting];
if (s && !s.secret && s.value) input.value = s.value;
});
document.getElementById('aiTestResult').textContent = '';
await syncAiSettingsHints();
await renderLicenseRules();
} catch (err) {
document.getElementById('settingsStatus').textContent = 'Could not load settings: ' + err.message;
}
settingsModal.classList.add('active');
}
function openImportModal() {
document.getElementById('importForm').reset();
document.getElementById('importStatus').textContent = '';
const tvEnabled = config.importers && config.importers.thingiverse;
const flareEnabled = config.importers && config.importers.flaresolverr;
const mw = flareEnabled ? 'MakerWorld' : 'MakerWorld (needs FlareSolverr in Settings)';
document.getElementById('importHint').innerHTML = tvEnabled
? `Thingiverse imports files automatically. Printables, ${mw} &amp; other sites import the title, description &amp; preview image — then add the files you downloaded.`
: `Printables, ${mw} &amp; other sites import the title, description &amp; preview image — then add the files you downloaded. Add a Thingiverse token in Settings for automatic file downloads.`;
document.getElementById('importSubmit').disabled = false;
importModal.classList.add('active');
}
function openModelDetail(modelId) {
const model = findModel(modelId);
if (!model) return;
flushNotes(); // save any edit still pending for the previously open model
fileDetailModal.dataset.currentModelId = modelId;
currentDetailThumb = model.thumbnailUrl || null;
document.getElementById('detailFileName').textContent = model.name;
document.getElementById('detailDescription').textContent = model.description || 'No description';
document.getElementById('detailNotes').value = model.notes || '';
setNotesStatus('');
const trashed = Boolean(model.deleted_at);
document.getElementById('detailActions').hidden = trashed;
document.getElementById('trashActions').hidden = !trashed;
// Source
const src = document.getElementById('detailSource');
if (model.source_url) {
const designer = model.designer ? ` by ${escapeHtml(model.designer.name)}` : '';
src.innerHTML = `<a href="${escapeHtml(model.source_url)}" target="_blank" rel="noopener">${escapeHtml(model.source_site || 'link')}${designer}</a>`;
} else {
src.textContent = 'Manual upload';
}
renderDetailLicense(model);
renderRenamedNote(model);
document.getElementById('refreshLicenseBtn').style.display = model.source_url ? '' : 'none';
// "Download files from source" — only for sites we can auto-download from.
const autoDownloadSites = ['thingiverse', 'printables', 'makerworld'];
const dlBtn = document.getElementById('downloadFilesBtn');
if (model.source_url && autoDownloadSites.includes(model.source_site)) {
dlBtn.style.display = '';
dlBtn.textContent = model.files.length === 0 ? 'Download files from source' : 'Re-check source for files';
} else {
dlBtn.style.display = 'none';
}
// Tags / projects / print settings
renderDetailTags(model);
const ps = model.projects || [];
document.getElementById('detailProjects').innerHTML = ps.length
? ps.map((p) => `<div>${escapeHtml(p.name)}</div>`).join('') : 'Not in any collection';
const settings = model.print_settings || {};
const settingRows = Object.entries(settings).filter(([, v]) => v);
document.getElementById('detailPrintSettings').innerHTML = settingRows.length
? settingRows.map(([k, v]) => `<div><strong>${escapeHtml(k)}:</strong> ${escapeHtml(String(v))}</div>`).join('')
: 'No print settings';
// File list (+ a "download all as zip" action when there's more than one file)
const fileInfoEl = document.getElementById('detailFileInfo');
if (model.files.length === 0) {
const autoSite = ['thingiverse', 'printables', 'makerworld'].includes(model.source_site);
const hint = !model.source_url
? 'No files yet — use “Add files” below.'
: autoSite
? 'No files yet — use “Download files from source” below.'
: 'Manual source — download the files from the source page, then use “Add files” below.';
fileInfoEl.innerHTML = `<div class="lic-note">${hint}</div>`;
} else {
const downloadAll = model.files.length > 1
? `<a class="btn btn-secondary btn-sm download-all" href="/api/models/${model.id}/download">Download all ${model.files.length} files (.zip)</a>`
: '';
fileInfoEl.innerHTML = downloadAll + model.files.map((f) => `
<div class="file-row"${dragAttrs(`/api/files/${f.id}/raw`, f.originalName)}>
<span>${escapeHtml(f.originalName)} <em>(${formatFileSize(f.size)}${f.volume ? `, ${Number(f.volume).toFixed(2)} cm³` : ''})</em></span>
<span class="row-actions">
${sliceButtonHtml('file', f.id, f.format)}
<a href="/api/files/${f.id}/raw" download draggable="false">download</a>
</span>
</div>
${ARCHIVE_FORMATS.includes(f.format) ? `<div class="entry-list" data-entries-for="${f.id}"><div class="entry-head">Reading archive…</div></div>` : ''}`).join('');
model.files.filter((f) => ARCHIVE_FORMATS.includes(f.format)).forEach((f) => loadArchiveEntries(model.id, f.id));
}
renderProjectPanels(null);
// Viewer tabs: 3D files first, then pictures/PDFs/text (entries inside
// archives get a tab when opened from the file list).
const { RENDERABLE, DOCUMENT_FORMATS } = window.ModelViewer;
detailItems = [
...model.files.filter((f) => RENDERABLE.includes(f.format)),
...model.files.filter((f) => DOCUMENT_FORMATS.includes(f.format) && f.format !== 'mtl'),
].map((f) => ({ kind: 'file', id: f.id, format: f.format, name: f.originalName }));
renderViewerTabs(detailItems.length ? itemKey(detailItems[0]) : null);
fileDetailModal.classList.add('active');
currentDetailFile = null;
if (detailItems.length) {
loadViewer(detailItems[0]);
} else {
// A model that's only archives previews a file from inside once they're listed.
const hasArchive = model.files.some((f) => ARCHIVE_FORMATS.includes(f.format));
document.getElementById('regenThumbBtn').style.display = 'none';
window.ModelViewer.render(document.getElementById('detailViewer'), '', 'none', {
fallbackImage: currentDetailThumb,
message: hasArchive ? 'Opening a file from the archive…' : 'Nothing here can be previewed yet — add files below.',
});
}
}
// ---------------------------------------------------------------- viewer items
// An item is something the viewer can show: { kind: 'file' | 'entry', id, format, name, archiveId? }.
const ARCHIVE_FORMATS = ['zip', '7z', 'rar', 'tar', 'tgz'];
let detailItems = [];
let activeItemKey = null;
const archiveEntryCache = new Map(); // archive file id -> entries
const itemKey = (item) => `${item.kind}:${item.id}`;
function renderViewerTabs(activeKey) {
if (activeKey !== activeItemKey) hideSlicerNotice();
activeItemKey = activeKey;
renderSlicerBar();
document.getElementById('detailFileTabs').innerHTML = detailItems.map((item) => `
<button class="file-tab${itemKey(item) === activeKey ? ' active' : ''}" data-key="${itemKey(item)}"${dragAttrs(rawUrlFor(item), item.name)}>${escapeHtml(item.name)}</button>`).join('');
}
function openViewerItem(item) {
if (!detailItems.some((i) => itemKey(i) === itemKey(item))) detailItems.push(item);
renderViewerTabs(itemKey(item));
loadViewer(item);
}
// What the viewer should load: 3MF goes through the server's GLB conversion,
// documents are served inline, everything else raw.
function viewerSourceFor(item) {
const base = `/api/${item.kind === 'entry' ? 'entries' : 'files'}/${item.id}`;
if (item.format === '3mf') return { url: `${base}/glb${item.plate ? `?plate=${item.plate}` : ''}`, format: 'glb' };
if (window.ModelViewer.DOCUMENT_FORMATS.includes(item.format)) return { url: `${base}/raw?inline=1`, format: item.format };
return { url: `${base}/raw`, format: item.format };
}
// Finds files shipped alongside a model by name — OBJ materials, FBX/MTL
// textures. Entries of the same archive win, then the model's own files.
function resolverFor(model, archiveEntries = null) {
const byName = new Map();
const key = (name) => String(name).split(/[\\/]/).pop().toLowerCase();
const add = (name, url) => { if (!byName.has(key(name))) byName.set(key(name), url); };
(archiveEntries || []).forEach((e) => add(e.path, `/api/entries/${e.id}/raw`));
((model && model.files) || []).forEach((f) => add(f.originalName, `/api/files/${f.id}/raw`));
return (name) => byName.get(key(name)) || null;
}
// For a model with nothing to preview directly: open the most useful file
// inside its archive — a slicer project, then other 3D files, then pictures/PDFs.
const ENTRY_PREVIEW_ORDER = ['3mf', 'stl', 'obj', 'step', 'stp', 'fbx', '3dm', 'jpg', 'jpeg', 'png', 'webp', 'gif', 'pdf'];
function previewFirstEntry(archiveId, entries) {
const rank = (e) => (ENTRY_PREVIEW_ORDER.includes(e.format) ? ENTRY_PREVIEW_ORDER.indexOf(e.format) : Infinity);
const best = [...entries].sort((a, b) => rank(a) - rank(b))[0];
if (!best || rank(best) === Infinity) {
window.ModelViewer.render(document.getElementById('detailViewer'), '', 'none',
{ fallbackImage: currentDetailThumb, message: 'Nothing inside this archive can be previewed.' });
return;
}
openViewerItem({ kind: 'entry', id: best.id, format: best.format, name: best.path.split('/').pop(), archiveId });
}
async function loadArchiveEntries(modelId, fileId) {
const container = () => (fileDetailModal.dataset.currentModelId === String(modelId)
? document.querySelector(`[data-entries-for="${fileId}"]`) : null);
try {
const res = await fetch(`/api/files/${fileId}/entries`);
const data = await res.json();
if (!res.ok) throw new Error(data.error);
if (!container()) return;
if (data.status === 'pending') {
setTimeout(() => { if (container()) loadArchiveEntries(modelId, fileId); }, 2000);
return;
}
if (data.status === 'error') {
container().innerHTML = `<div class="entry-head">Couldn't read this archive: ${escapeHtml(data.error || 'unknown error')}</div>`;
return;
}
archiveEntryCache.set(fileId, data.entries);
const { PREVIEWABLE } = window.ModelViewer;
container().innerHTML = `<div class="entry-head">${data.entries.length} file${data.entries.length === 1 ? '' : 's'} inside</div>`
+ data.entries.map((e) => {
const name = e.path.split('/').pop();
const folder = e.path.replace(/^\.\//, '').slice(0, -name.length);
return `
<div class="entry-row"${dragAttrs(`/api/entries/${e.id}/raw`, name)}>
<span title="${escapeHtml(e.path)}">${escapeHtml(name)}${folder ? `<span class="entry-folder">${escapeHtml(folder)}</span>` : ''} <em>${formatFileSize(e.size)}</em></span>
<span class="row-actions">
${PREVIEWABLE.includes(e.format) && e.format !== 'mtl' ? `<button type="button" class="link-btn" data-view-entry="${e.id}" data-archive="${fileId}">view</button>` : ''}
${sliceButtonHtml('entry', e.id, e.format, fileId)}
<a href="/api/entries/${e.id}/raw" download draggable="false">download</a>
</span>
</div>`;
}).join('');
if (!detailItems.length) previewFirstEntry(fileId, data.entries);
} catch (err) {
if (container()) container().innerHTML = `<div class="entry-head">Couldn't list this archive: ${escapeHtml(err.message)}</div>`;
}
}
// ---------------------------------------------------------------- JSON helpers
async function getJson(url, options) {
const res = await fetch(url, options);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
return data;
}
function postJson(url, body = {}, method = 'POST') {
return getJson(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const money = (n) => (n == null ? '—' : n > 0 && n < 0.01 ? `$${n.toFixed(4)}` : `$${n.toFixed(2)}`);
const count = (n) => Number(n || 0).toLocaleString();
const plural = (n, word) => `${count(n)} ${word}${Number(n) === 1 ? '' : 's'}`;
// ---------------------------------------------------------------- search by meaning
// Meaning search asks the server, which ranks models with a local embedding model.
// Word matches show while it answers.
const MEANING_MIN_SCORE = 0.55;
const MEANING_SPREAD = 0.1; // how far below the best match results may go
let meaningSearch = { query: '', results: null, error: null };
let meaningPending = null;
let meaningTimer = null;
function syncSearchMode() {
document.querySelectorAll('#searchMode button').forEach((b) => b.classList.toggle('active', b.dataset.mode === prefs.searchMode));
searchInput.placeholder = prefs.searchMode === 'meaning'
? 'Describe what you’re looking for, e.g. “wall hook for headphones” ( / )'
: 'Search names, tags, notes, files, designers… ( / )';
}
function meaningResults() {
const q = searchInput.value.trim();
if (prefs.searchMode !== 'meaning' || !q) return null;
if (meaningSearch.query === q) return meaningSearch.results; // null after an error: words only
if (meaningPending !== q) {
meaningPending = q;
clearTimeout(meaningTimer);
meaningTimer = setTimeout(async () => {
try {
const data = await getJson(`/api/search?q=${encodeURIComponent(q)}`);
meaningSearch = { query: q, results: new Map(data.results.map((r) => [r.id, r.score])), error: null };
} catch (err) {
meaningSearch = { query: q, results: null, error: err.message };
}
meaningPending = null;
if (searchInput.value.trim() === q) renderModels();
}, 250);
}
return null;
}
function searchNote() {
const q = searchInput.value.trim();
if (prefs.searchMode !== 'meaning' || !q) return '';
if (meaningSearch.query !== q) return ' · searching by meaning…';
return meaningSearch.error ? ' · meaning search unavailable, matching words' : ' · by meaning';
}
// ---------------------------------------------------------------- AI & automatic tags
let aiPoll = null;
let aiState = { runs: [] };
let aiProviders = null;
const AI_KEY_LINKS = {
deepseek: 'https://platform.deepseek.com/api_keys',
openrouter: 'https://openrouter.ai/settings/keys',
openai: 'https://platform.openai.com/api-keys',
};
const RUN_STATUS = { running: 'Tagging', paused: 'Paused', stopped: 'Stopped', done: 'Finished', failed: 'Stopped' };
function renderDetailTags(model) {
const sources = model.tag_sources || {};
const titles = { ai: 'Added by AI', rule: 'Added by an automatic tag rule' };
document.getElementById('detailTags').innerHTML = (model.tags && model.tags.length)
? model.tags.map((t) => {
const source = sources[t];
return `<span class="tag${titles[source] ? ` tag-${source}` : ''}"${titles[source] ? ` title="${titles[source]}"` : ''}>${escapeHtml(t)}</span>`;
}).join('')
: 'No tags';
}
function renderRenamedNote(model) {
const el = document.getElementById('detailRenamed');
el.hidden = !model.original_name;
el.innerHTML = model.original_name
? `✦ Renamed by AI from “${escapeHtml(model.original_name)}”. <button type="button" class="link-btn" id="undoRenameBtn">Undo</button>`
: '';
}
async function handleUndoRename() {
const id = fileDetailModal.dataset.currentModelId;
const model = findModel(id);
if (!model || !model.original_name) return;
try {
const updated = await postJson(`/api/models/${id}`, { name: model.original_name }, 'PUT');
replaceModel(updated);
document.getElementById('detailFileName').textContent = updated.name;
renderRenamedNote(updated);
renderModels();
} catch (err) {
alert(`Couldn’t undo the rename: ${err.message}`);
}
}
async function syncAiSettingsHints() {
try {
if (!aiProviders) aiProviders = (await getJson('/api/ai/status')).providers;
} catch { return; }
const key = document.getElementById('aiProvider').value;
const preset = aiProviders[key] || {};
const prices = preset.prices || {};
document.getElementById('aiModel').placeholder = preset.model
|| (key === 'openrouter' ? 'provider/model, from openrouter.ai/models' : key === 'custom' ? 'e.g. llava' : 'Model name');
document.getElementById('aiBaseUrl').placeholder = preset.baseUrl || 'e.g. http://localhost:11434/v1';
document.getElementById('aiPriceInput').placeholder = prices.input != null ? String(prices.input) : 'optional';
document.getElementById('aiPriceOutput').placeholder = prices.output != null ? String(prices.output) : 'optional';
const link = document.getElementById('aiKeyLink');
link.hidden = !AI_KEY_LINKS[key];
if (AI_KEY_LINKS[key]) {
link.href = AI_KEY_LINKS[key];
link.textContent = `Get your ${preset.label} key.`;
}
}
async function handleAiTest() {
const out = document.getElementById('aiTestResult');
const btn = document.getElementById('aiTestBtn');
out.className = 'form-hint ai-test-result';
out.textContent = 'Saving and asking the AI to tag a sample model…';
btn.disabled = true;
try {
await postJson('/api/settings', settingsPayload(), 'PUT');
const r = await postJson('/api/ai/test');
out.classList.add('is-ok');
out.innerHTML = `✓ Working: <strong>${escapeHtml(r.model)}</strong> answered in ${(r.ms / 1000).toFixed(1)} s with
<em>${r.tags.map(escapeHtml).join(', ') || 'no tags'}</em>. Images: ${escapeHtml(r.images)}.
${r.cost != null ? `That cost ${money(r.cost)}.` : ''}`;
} catch (err) {
out.classList.add('is-error');
out.textContent = `✗ ${err.message}`;
} finally {
btn.disabled = false;
}
}
// Tags the open model on its own, then refreshes its tags and description.
async function handleAiTagModel() {
const id = Number(fileDetailModal.dataset.currentModelId);
const btn = document.getElementById('aiTagBtn');
btn.disabled = true;
btn.textContent = 'Tagging…';
try {
let run = await postJson('/api/ai/runs', { scope: 'ids', ids: [id] });
const started = Date.now();
while (run.status === 'running') {
if (Date.now() - started > 180000) throw new Error('It’s taking a while; follow it on the AI & auto-tags page.');
await sleep(1500);
run = await getJson(`/api/ai/runs/${run.id}`);
}
const item = (run.recent || []).find((i) => i.model_id === id);
if (run.error) throw new Error(run.error);
if (item && item.status === 'error') throw new Error(item.error);
await loadData();
const model = findModel(id);
if (model && String(fileDetailModal.dataset.currentModelId) === String(id)) {
renderDetailTags(model);
document.getElementById('detailDescription').textContent = model.description || 'No description';
document.getElementById('detailFileName').textContent = model.name;
renderRenamedNote(model);
}
} catch (err) {
alert(`AI tagging didn’t work: ${err.message}`);
} finally {
btn.disabled = false;
btn.textContent = '✦ Tag with AI';
}
}
function aiPanelHtml() {
return `
<section class="ai-card">
<div class="ai-card-head"><h3>AI tags &amp; descriptions</h3></div>
<p class="form-hint">Each model's name, file names<span id="aiImagesNote"></span> go to your AI provider, which
suggests tags and a short description. Tags are only ever added. Descriptions are filled in where empty,
unless you choose to replace them.</p>
<div id="aiSetup"></div>
<form id="aiStartForm" class="ai-start">
<div class="ai-scope">
<label><input type="radio" name="aiScope" value="untagged" checked> Models not tagged by AI yet
<span class="count-inline" id="aiUntaggedCount"></span></label>
<label><input type="radio" name="aiScope" value="all"> All models
<span class="count-inline" id="aiAllCount"></span></label>
</div>
<fieldset class="ai-tasks">
<legend>What to do</legend>
<label><input type="checkbox" id="aiDescribe" checked> Add tags and fill in empty descriptions</label>
<label class="ai-subtask"><input type="checkbox" id="aiOverwrite"> …and replace descriptions that are already filled in</label>
<label><input type="checkbox" id="aiRename"> Give models with unclear names a clearer one (undo on the model's page)</label>
<label><input type="checkbox" id="aiCollections"> Suggest collections for you to review</label>
</fieldset>
<label>Stop when the run has cost
<span class="money-input">$<input type="number" id="aiMaxCost" min="0" step="0.01" placeholder="no limit"></span></label>
<div class="ai-start-row">
<button type="submit" class="btn btn-primary" id="aiStartBtn">Start tagging</button>
<span id="aiEstimate" class="form-hint"></span>
</div>
</form>
<div id="aiRuns" class="ai-runs"></div>
</section>
<section class="ai-card">
<div class="ai-card-head"><h3>Suggested collections</h3></div>
<p class="form-hint">Ideas from runs with “Suggest collections” ticked. Nothing changes until you accept one.</p>
<div id="aiSuggestions" class="form-hint">Loading…</div>
</section>
<section class="ai-card">
<div class="ai-card-head"><h3>Search by meaning</h3></div>
<p class="form-hint">Switch the search box to <strong>Meaning</strong> to find models by what they are, so
“wall hook” also finds a “coat hanger bracket”. It runs on your server with a small built-in model:
free, and nothing is sent anywhere.</p>
<div id="aiSearch" class="form-hint"></div>
</section>
<section class="ai-card" id="aiRulesCard"></section>`;
}
function runHtml(run, detail) {
const processed = run.done + run.failed;
const pct = run.total ? Math.round((processed / run.total) * 100) : 0;
const options = run.options || {};
const cost = options.pricesKnown ? money(run.cost_usd) : 'cost unknown (add prices in Settings)';
const canStop = run.status === 'running' || run.status === 'paused';
// 'done' too: a run that finished with failures still has something to retry.
const canResume = ['paused', 'stopped', 'failed', 'done'].includes(run.status) && (processed < run.total || run.failed > 0);
const feed = detail && detail.recent.length ? `
<ul class="ai-feed">${detail.recent.map((i) => `
<li>
<button type="button" class="link-btn" data-open-model="${i.model_id}">${escapeHtml(i.name || `Model ${i.model_id}`)}</button>
${i.renamed_to ? `<span class="renamed-from">was “${escapeHtml(i.renamed_from)}”</span>` : ''}
${i.status === 'done'
? (i.tags || []).map((t) => `<span class="tag">${escapeHtml(t)}</span>`).join('')
+ ((i.collections || []).length ? `<span class="suggested-collections">→ ${i.collections.map(escapeHtml).join(', ')}</span>` : '')
: `<span class="ai-run-error">${escapeHtml(i.error || 'failed')}</span>`}
</li>`).join('')}
</ul>` : '';
// Failures are listed in full: the feed above only holds the last dozen items,
// so anything that failed early would otherwise be impossible to track down.
const failed = detail && (detail.failures || []).length ? `
<details class="ai-failures">
<summary>${plural(detail.failures.length, 'model')} failed</summary>
<ul class="ai-feed">${detail.failures.map((i) => `
<li>
<button type="button" class="link-btn" data-open-model="${i.model_id}">${escapeHtml(i.name || `Model ${i.model_id}`)}</button>
<span class="ai-run-error">${escapeHtml(i.error || 'failed')}</span>
</li>`).join('')}</ul>
</details>` : '';
return `
<div class="ai-run">
<div class="ai-run-head">
<span class="pill pill-${run.status}">${RUN_STATUS[run.status] || escapeHtml(run.status)}</span>
<span>${count(processed)} of ${plural(run.total, 'model')}${run.failed ? ` · ${count(run.failed)} failed` : ''}</span>
<span class="ai-run-meta">${escapeHtml(run.provider || '')} · ${escapeHtml(run.model || '')} · ${formatDate(run.created_at)}</span>
<span class="ai-run-actions">
${canStop ? `<button type="button" class="btn btn-secondary btn-sm" data-ai="stop" data-id="${run.id}">Stop</button>` : ''}
${canResume ? `<button type="button" class="btn btn-secondary btn-sm" data-ai="resume" data-id="${run.id}">${run.failed && processed >= run.total ? 'Retry failed' : 'Resume'}</button>` : ''}
</span>
</div>
<div class="progress"><span style="width:${pct}%"></span></div>
<div class="ai-run-stats">${count(run.prompt_tokens)} tokens in · ${count(run.completion_tokens)} out · ${cost}${options.maxCost ? ` (limit $${options.maxCost})` : ''}</div>
${run.error ? `<div class="ai-run-error">${escapeHtml(run.error)}</div>` : ''}
${run.note ? `<div class="form-hint">${escapeHtml(run.note)}</div>` : ''}
${feed}
${failed}
</div>`;
}
function autotagCardHtml(t) {
const groups = new Map();
for (const rule of t.builtIn) {
if (!groups.has(rule.group)) groups.set(rule.group, []);
groups.get(rule.group).push(rule);
}
return `
<div class="ai-card-head">
<h3>Automatic tags</h3>
<label class="switch"><input type="checkbox" id="autotagEnabled"${t.enabled ? ' checked' : ''}> ${t.enabled ? 'On' : 'Off'}</label>
</div>
<p class="form-hint">Adds a tag when whole words in a model's name, file names or archive contents match a rule:
“hook” matches <em>Wall_Hooks.stl</em> but not <em>facebook.stl</em>. Tags you add yourself are never changed.</p>
<div id="autotagPending" class="form-hint"></div>
${t.enabled ? '' : `
<div class="ai-callout">Automatic tagging is off. Tags it already added are kept.
<button type="button" class="btn btn-secondary btn-sm" data-ai="remove-rule-tags">Remove those tags</button></div>`}
<div class="rule-groups">${[...groups.entries()].map(([group, rules]) => `
<div class="rule-group">
<h4>${escapeHtml(group)}</h4>
<div class="rule-tags">${rules.map((r) => `<span class="tag" title="Matches: ${escapeHtml(r.phrases.join(', '))}">${escapeHtml(r.tag)}</span>`).join('')}</div>
</div>`).join('')}
</div>
<h4 class="rule-subhead">Your rules</h4>
<div class="rule-list">${t.rules.length ? t.rules.map((r) => `
<div class="rule-row">
<span><strong>${escapeHtml(r.phrase)}</strong> → <span class="tag">${escapeHtml(r.tag)}</span></span>
<button type="button" class="link-btn" data-ai="delete-rule" data-id="${r.id}">remove</button>
</div>`).join('') : '<p class="form-hint">None yet. Add a word to look for and the tag it should add.</p>'}
</div>
<form id="ruleForm" class="rule-form">
<input type="text" id="rulePhrase" placeholder="Word or phrase, e.g. benchy" aria-label="Word or phrase" required>
<span aria-hidden="true">→</span>
<input type="text" id="ruleTag" placeholder="Tag, e.g. calibration" aria-label="Tag to add" required>
<button type="submit" class="btn btn-secondary btn-sm">Add rule</button>
</form>
<div class="ai-start-row">
<button type="button" class="btn btn-secondary btn-sm" data-ai="reapply">Re-apply rules to every model</button>
</div>`;
}
// Builds the page once; later calls (polls, data reloads) only refresh the live
// parts so nothing you're typing is lost.
async function renderAiPanel() {
fileGrid.className = 'ai-panel';
resultCount.textContent = '';
if (document.getElementById('aiStartForm')) { refreshAiPanel(); return; }
fileGrid.innerHTML = '<div class="empty-state"><p>Loading…</p></div>';
try {
aiState.tagRules = await getJson('/api/autotag');
} catch (err) {
if (selectedScope === 'ai') fileGrid.innerHTML = `<div class="empty-state"><h3>Couldn’t load this page</h3><p>${escapeHtml(err.message)}</p></div>`;
return;
}
if (selectedScope !== 'ai') return;
fileGrid.innerHTML = aiPanelHtml();
renderRulesCard();
renderCollectionSuggestions();
await refreshAiPanel();
updateAiEstimate();
}
function renderRulesCard() {
const card = document.getElementById('aiRulesCard');
if (card && aiState.tagRules) card.innerHTML = autotagCardHtml(aiState.tagRules);
renderAiLive();
}
async function refreshAiPanel() {
clearTimeout(aiPoll);
if (selectedScope !== 'ai') return;
let status;
let runs;
let detail = null;
let details = {};
try {
[status, runs] = await Promise.all([getJson('/api/ai/status'), getJson('/api/ai/runs')]);
const focus = runs.find((r) => r.status === 'running') || runs[0];
if (focus) detail = await getJson(`/api/ai/runs/${focus.id}`);
if (detail) details[detail.id] = detail;
// Runs that failed something need their detail too, or the list of failures
// has nothing to draw: without this only the newest run ever gets one.
for (const r of runs.slice(0, 5)) {
if (r.failed > 0 && !details[r.id]) details[r.id] = await getJson(`/api/ai/runs/${r.id}`);
}
} catch {
aiPoll = setTimeout(refreshAiPanel, 5000);
return;
}
if (selectedScope !== 'ai' || !document.getElementById('aiRuns')) return;
const running = runs.some((r) => r.status === 'running');
const changed = aiState.expectChange || (aiState.runs || []).some((r) => r.status === 'running');
aiState = { ...aiState, status, runs, detail, details };
renderAiLive();
if (changed && !running) {
aiState.expectChange = false;
loadData(); // the run changed tags and descriptions
renderCollectionSuggestions();
}
if (running || status.search.pending || status.autotag.pending) {
aiPoll = setTimeout(refreshAiPanel, running ? 2000 : 4000);
}
}
function renderAiLive() {
const { status, runs = [], detail, details = {} } = aiState;
if (!status || !document.getElementById('aiRuns')) return;
const c = status.config;
document.getElementById('aiSetup').innerHTML = status.problem
? `<div class="ai-callout is-warning"><strong>Not set up yet.</strong> ${escapeHtml(status.problem)}
<button type="button" class="btn btn-primary btn-sm" data-ai="settings">Open Settings</button></div>`
: `<div class="ai-callout">Using <strong>${escapeHtml(c.label)}</strong> · <code>${escapeHtml(c.model)}</code> ·
thumbnails ${c.sendImages ? 'on' : 'off'}
<button type="button" class="btn-link" data-ai="settings">Change</button></div>`;
document.getElementById('aiImagesNote').textContent = c.sendImages ? ' and thumbnail' : '';
document.getElementById('aiUntaggedCount').textContent = count(status.counts.untagged);
document.getElementById('aiAllCount').textContent = count(status.counts.all);
const running = runs.some((r) => r.status === 'running');
const start = document.getElementById('aiStartBtn');
start.disabled = Boolean(status.problem) || running;
start.title = running ? 'A run is going: wait for it, or stop it' : '';
document.getElementById('aiRuns').innerHTML = runs.length
? runs.slice(0, 5).map((r) => runHtml(r, details[r.id] || (detail && detail.id === r.id ? detail : null))).join('')
: '<p class="form-hint">No runs yet.</p>';
const s = status.search;
document.getElementById('aiSearch').textContent = s.error
? `Indexing is paused: ${s.error}`
: `${plural(s.indexed, 'model')} indexed${s.pending ? `, ${count(s.pending)} to go…` : '.'}`;
const pending = document.getElementById('autotagPending');
if (pending) {
pending.textContent = status.autotag.enabled && status.autotag.pending
? `Checking ${plural(status.autotag.pending, 'model')}…` : '';
}
}
async function renderCollectionSuggestions() {
const el = document.getElementById('aiSuggestions');
if (!el) return;
let groups;
try {
groups = await getJson('/api/collection-suggestions');
} catch (err) {
el.textContent = `Couldn’t load suggestions: ${err.message}`;
return;
}
if (!document.getElementById('aiSuggestions')) return;
el.className = groups.length ? 'suggestion-list' : 'form-hint';
el.innerHTML = groups.length
? groups.map((g) => {
const n = plural(g.models.length, 'model');
return `
<div class="suggestion" data-suggestion="${escapeHtml(g.name)}">
<div class="suggestion-head">
<strong>${escapeHtml(g.name)}</strong>
<span class="form-hint">${g.project_id ? 'your collection' : 'new collection'} · ${n}</span>
<span class="suggestion-actions">
<button type="button" class="btn btn-primary btn-sm" data-ai="accept-suggestion">${g.project_id ? `Add ${n}` : `Create &amp; add ${n}`}</button>
<button type="button" class="btn btn-secondary btn-sm" data-ai="dismiss-suggestion">Dismiss</button>
</span>
</div>
<div class="suggestion-models">${g.models.map((m) => `<button type="button" class="link-btn" data-open-model="${m.id}">${escapeHtml(m.name)}</button>`).join(', ')}</div>
</div>`;
}).join('')
: 'No suggestions waiting. Tick “Suggest collections” when you start a run.';
}
async function updateAiEstimate() {
const el = document.getElementById('aiEstimate');
const choice = fileGrid.querySelector('input[name="aiScope"]:checked');
if (!el || !choice) return;
try {
const est = await postJson('/api/ai/estimate', { scope: choice.value });
el.textContent = !est.count
? 'Nothing to tag.'
: est.cost == null
? `${plural(est.count, 'model')}. Add prices in Settings to see what it will cost.`
: `Roughly ${money(est.cost)} for ${plural(est.count, 'model')}.`;
} catch {
el.textContent = '';
}
}
async function handleAiStart(e) {
e.preventDefault();
const scope = fileGrid.querySelector('input[name="aiScope"]:checked').value;
const maxCost = document.getElementById('aiMaxCost').value;
const checked = (id) => document.getElementById(id).checked;
if (!checked('aiDescribe') && !checked('aiRename') && !checked('aiCollections')) {
alert('Choose at least one thing for the AI to do.');
return;
}
const btn = document.getElementById('aiStartBtn');
btn.disabled = true;
try {
await postJson('/api/ai/runs', {
scope,
describe: checked('aiDescribe'),
overwriteDescriptions: checked('aiDescribe') && checked('aiOverwrite'),
renameUnclear: checked('aiRename'),
suggestCollections: checked('aiCollections'),
maxCost: maxCost ? Number(maxCost) : null,
});
aiState.expectChange = true;
await refreshAiPanel();
updateAiEstimate();
} catch (err) {
alert(err.message);
btn.disabled = false;
}
}
async function handleAiPanelClick(e) {
const open = e.target.closest('[data-open-model]');
if (open) { openModelDetail(Number(open.dataset.openModel)); return; }
const watchBtn = e.target.closest('[data-watch]');
if (watchBtn) { handleWatchAction(watchBtn); return; }
const btn = e.target.closest('[data-ai]');
if (!btn) return;
const id = btn.dataset.id;
try {
switch (btn.dataset.ai) {
case 'settings':
await openSettingsModal();
document.getElementById('aiSettings').scrollIntoView({ block: 'start' });
break;
case 'stop':
btn.disabled = true;
await postJson(`/api/ai/runs/${id}/stop`);
aiState.expectChange = true;
await refreshAiPanel();
break;
case 'resume':
btn.disabled = true;
await postJson(`/api/ai/runs/${id}/resume`);
await refreshAiPanel();
break;
case 'accept-suggestion': {
btn.disabled = true;
const result = await postJson('/api/collection-suggestions/accept', { name: btn.closest('[data-suggestion]').dataset.suggestion });
await loadData(); // the sidebar gains the collection
renderCollectionSuggestions();
resultCount.textContent = `Added ${plural(result.added, 'model')} to ${result.project.name}`;
break;
}
case 'dismiss-suggestion':
btn.disabled = true;
await postJson('/api/collection-suggestions/dismiss', { name: btn.closest('[data-suggestion]').dataset.suggestion });
renderCollectionSuggestions();
break;
case 'delete-rule':
aiState.tagRules = await getJson(`/api/autotag/rules/${id}`, { method: 'DELETE' });
renderRulesCard();
loadData();
break;
case 'reapply':
aiState.tagRules = await postJson('/api/autotag/reapply');
renderRulesCard();
loadData();
break;
case 'remove-rule-tags':
if (!confirm('Remove every tag that automatic tagging added? Tags you or the AI added stay.')) return;
aiState.tagRules = await postJson('/api/autotag', { enabled: false, removeTags: true }, 'PUT');
renderRulesCard();
loadData();
break;
}
} catch (err) {
alert(err.message);
btn.disabled = false;
}
}
async function handleAutotagToggle(enabled) {
try {
aiState.tagRules = await postJson('/api/autotag', { enabled }, 'PUT');
renderRulesCard();
loadData();
} catch (err) {
alert(err.message);
}
}
async function handleAddRule(e) {
e.preventDefault();
const phrase = document.getElementById('rulePhrase').value;
const tag = document.getElementById('ruleTag').value;
try {
aiState.tagRules = await postJson('/api/autotag/rules', { phrase, tag });
renderRulesCard();
loadData();
} catch (err) {
alert(err.message);
}
}
// ---------------------------------------------------------------- slicer hand-off
// A browser can't see which apps are installed, so the slicer is chosen once per
// browser. Slicers that accept an open-link download the file from a short-lived
// link; the others get an ordinary download to open from there.
const SLICEABLE = ['3mf', 'stl', 'obj', 'step', 'stp'];
const SLICERS = {
bambu: {
name: 'Bambu Studio', family: 'bambu', button: 'Open in Bambu Studio',
blurb: 'Opens the file straight in Bambu Studio.',
link: (url) => `bambustudioopen://${encodeURIComponent(url)}`,
hint: 'Bambu Studio asks before opening files that aren’t from MakerWorld; choose Yes.',
},
orca: {
name: 'OrcaSlicer', family: 'orca', button: 'Open in OrcaSlicer',
blurb: 'Opens the file straight in OrcaSlicer.',
link: (url) => `orcaslicer://open?file=${encodeURIComponent(url)}`,
},
prusa: {
name: 'PrusaSlicer', family: 'prusa', button: 'Download for PrusaSlicer',
blurb: 'Downloads the file. PrusaSlicer only opens links from Printables and Thingiverse.',
link: null,
},
cura: {
name: 'UltiMaker Cura', family: 'cura', button: 'Download for Cura',
blurb: 'Downloads the file. Cura can’t be opened from a link.',
link: null,
},
custom: {
name: 'Another slicer', family: null, button: 'Open in slicer',
blurb: 'Any slicer with its own open-link, using a template you enter.',
link: (url) => (prefs.slicerTemplate || '').replaceAll('{url}', encodeURIComponent(url)).replaceAll('{rawurl}', url),
},
download: {
name: 'Just download', family: null, button: 'Download file',
blurb: 'Saves the file so you can open it in any app yourself.',
link: null,
},
};
const SLICER_FAMILIES = { bambu: 'Bambu Studio', orca: 'OrcaSlicer', prusa: 'PrusaSlicer', cura: 'Cura' };
// Which slicer saved a 3MF, from its Application metadata ("BambuStudio-02.01.01.52").
function slicerFamily(application) {
const app = String(application || '').toLowerCase().replace(/[\s_-]/g, '');
if (app.startsWith('bambustudio')) return 'bambu';
if (app.startsWith('orcaslicer')) return 'orca';
if (app.startsWith('prusaslicer')) return 'prusa';
if (app.includes('cura')) return 'cura';
return null;
}
function validSlicerTemplate(template) {
return /^[a-z][a-z0-9+.-]*:/i.test(template)
&& !/^(javascript|data|vbscript|file|blob):/i.test(template)
&& /\{(raw)?url\}/.test(template);
}
function sliceButtonHtml(kind, id, format, archiveId = '') {
if (!SLICEABLE.includes(format)) return '';
return `<button type="button" class="link-btn" data-slice-kind="${kind}" data-slice-id="${id}" data-archive="${archiveId}" title="Open in your slicer">slicer</button>`;
}
// The viewer item for a file or entry, so project info already fetched is reused.
function handoffItem(kind, id, archiveId) {
const existing = detailItems.find((i) => i.kind === kind && i.id === id);
if (existing) return existing;
if (kind === 'entry') {
const entry = (archiveEntryCache.get(Number(archiveId)) || []).find((x) => x.id === id);
return entry && { kind, id, format: entry.format, name: entry.path.split('/').pop(), archiveId: Number(archiveId) };
}
const model = findModel(fileDetailModal.dataset.currentModelId);
const file = model && model.files.find((f) => f.id === id);
return file && { kind, id, format: file.format, name: file.originalName };
}
function renderSlicerBar() {
const item = detailItems.find((i) => itemKey(i) === activeItemKey);
const handle = document.getElementById('dragOutBtn');
handle.hidden = !(item && CAN_DRAG_OUT);
if (item) {
handle.dataset.dragUrl = rawUrlFor(item);
handle.dataset.dragName = item.name;
handle.title = `Drag ${item.name} onto your desktop or a folder to save it`;
}
const bar = document.getElementById('slicerBar');
bar.hidden = !(item && SLICEABLE.includes(item.format));
const slicer = SLICERS[prefs.slicer];
document.getElementById('openInSlicerBtn').textContent = slicer ? slicer.button : 'Open in slicer';
document.getElementById('changeSlicerBtn').textContent = slicer ? 'change slicer' : 'choose slicer';
}
function renderSettingsSlicer() {
const slicer = SLICERS[prefs.slicer];
document.getElementById('settingsSlicerName').textContent = slicer ? slicer.name : 'no slicer yet';
}
let pendingHandoff = null; // the file to open once a slicer has been chosen
let noticeActions = {};
function showSlicerNotice(text, actions = [], warning = false) {
const el = document.getElementById('slicerNotice');
noticeActions = {};
el.innerHTML = `<div>${escapeHtml(text)}</div>` + (actions.length
? `<div class="slicer-notice-actions">${actions.map((a, i) => {
noticeActions[i] = a.run;
return `<button type="button" class="btn ${a.primary ? 'btn-primary' : 'btn-secondary'} btn-sm" data-notice-action="${i}">${escapeHtml(a.label)}</button>`;
}).join('')}</div>`
: '');
el.classList.toggle('is-warning', warning);
el.hidden = false;
}
function hideSlicerNotice() {
const el = document.getElementById('slicerNotice');
el.hidden = true;
el.innerHTML = '';
noticeActions = {};
}
// Follows a link without leaving the page: the OS hands custom schemes to their
// app, and the file endpoints answer with a download.
function followLink(href) {
const a = document.createElement('a');
a.href = href;
a.hidden = true;
document.body.appendChild(a);
a.click();
a.remove();
}
const rawUrlFor = (item) => `/api/${item.kind === 'entry' ? 'entries' : 'files'}/${item.id}/raw`;
async function openInSlicer(item, { confirmed = false } = {}) {
if (!item) return;
const slicer = SLICERS[prefs.slicer];
if (!slicer) {
pendingHandoff = item;
openSlicerModal();
return;
}
// Project 3MFs carry the slicer that saved them; moving one to another slicer
// can lose plates, filaments or profiles, so ask first.
if (item.format === '3mf' && slicer.family && !confirmed) {
if (!item.project) {
const started = Date.now();
await loadProjectInfo(item);
// Launching an app needs a recent click; after a slow read, ask for another.
if (Date.now() - started > 1500) {
showSlicerNotice(`${item.name} is ready.`, [{ label: slicer.button, primary: true, run: () => openInSlicer(item) }]);
return;
}
}
const saved = slicerFamily(item.project && item.project.application);
if (saved && saved !== slicer.family) {
showSlicerNotice(
`${item.name} is a ${SLICER_FAMILIES[saved]} project. ${slicer.name} may not keep all of its settings (plates, filaments, print profiles).`,
[
{ label: `${slicer.button} anyway`, primary: true, run: () => openInSlicer(item, { confirmed: true }) },
{ label: 'Download instead', run: () => { hideSlicerNotice(); followLink(rawUrlFor(item)); } },
],
true,
);
return;
}
}
if (!slicer.link) {
followLink(rawUrlFor(item));
showSlicerNotice(`Downloading ${item.name}${slicer.family ? ` — open it in ${slicer.name} from your downloads` : ''}.`);
return;
}
try {
const res = await fetch('/api/handoff', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ kind: item.kind, id: item.id }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
followLink(slicer.link(new URL(data.path, window.location.origin).href));
showSlicerNotice(
`Opening ${item.name} in ${slicer.family ? slicer.name : 'your slicer'}… ${slicer.hint ? `${slicer.hint} ` : ''}Nothing happening? Check the slicer is installed and up to date.`,
[{ label: 'Download instead', run: () => { hideSlicerNotice(); followLink(rawUrlFor(item)); } }],
);
} catch (err) {
showSlicerNotice(`Couldn’t open this in your slicer: ${err.message}`);
}
}
function openSlicerModal() {
const current = prefs.slicer || '';
document.getElementById('slicerOptions').innerHTML = Object.entries(SLICERS).map(([key, s]) => `
<label class="slicer-option">
<input type="radio" name="slicer" value="${key}"${key === current ? ' checked' : ''}>
<span><strong>${escapeHtml(s.name)}</strong><em>${escapeHtml(s.blurb)}</em></span>
</label>`).join('');
document.getElementById('slicerTemplate').value = prefs.slicerTemplate || '';
document.getElementById('slicerFormError').textContent = '';
syncSlicerForm();
slicerModal.classList.add('active');
}
function syncSlicerForm() {
const choice = document.querySelector('#slicerOptions input:checked');
document.getElementById('slicerTemplateGroup').hidden = !choice || choice.value !== 'custom';
}
function handleSaveSlicer(e) {
e.preventDefault();
const choice = document.querySelector('#slicerOptions input:checked');
const error = document.getElementById('slicerFormError');
if (!choice) { error.textContent = 'Pick a slicer.'; return; }
const template = document.getElementById('slicerTemplate').value.trim();
if (choice.value === 'custom' && !validSlicerTemplate(template)) {
error.textContent = 'Enter an app link containing {url}, like myslicer://open?file={url}.';
return;
}
prefs.slicer = choice.value;
if (choice.value === 'custom') prefs.slicerTemplate = template;
savePrefs();
const next = pendingHandoff;
closeModal(slicerModal);
renderSlicerBar();
renderSettingsSlicer();
if (next) openInSlicer(next); // still inside this click, so the slicer may launch
}
// ---------------------------------------------------------------- drag-out
// Chromium browsers save a dragged element as a file when it carries DownloadURL
// ("type:name:url"); the download goes through the browser, session included.
// Safari and Firefox have no equivalent. Only Chromium has navigator.userAgentData.
const CAN_DRAG_OUT = 'userAgentData' in navigator;
// draggable + data for anything that drags out as a file.
function dragAttrs(url, name) {
return CAN_DRAG_OUT ? ` draggable="true" data-drag-url="${escapeHtml(url)}" data-drag-name="${escapeHtml(name)}"` : '';
}
function setDragOut(dataTransfer, { url, name }) {
dataTransfer.effectAllowed = 'copy';
dataTransfer.setData('DownloadURL', `application/octet-stream:${String(name).replace(/[:\\/]/g, '-')}:${new URL(url, window.location.origin).href}`);
}
// A model drags out as its only file, or as a zip of all of them.
function modelDragTarget(model) {
if (!model || !model.files.length) return null;
if (model.files.length === 1) return { url: `/api/files/${model.files[0].id}/raw`, name: model.files[0].originalName };
return { url: `/api/models/${model.id}/download`, name: `${(model.name || 'model').replace(/[^\w.-]+/g, '_')}.zip` };
}
// What the shelf says about this model, shown between Source and License. The
// Filaments section further down lists what the project uses; this one answers
// whether you can print it today.
function renderDetailPrintable(model) {
const section = document.getElementById('detailPrintableSection');
const el = document.getElementById('detailPrintable');
if (!section || !el) return;
const p = printability(model);
if (!p.known) {
el.innerHTML = '<div class="lic-note">This model doesn’t name its filaments — only Bambu Studio and OrcaSlicer projects do.</div>';
return;
}
const rows = p.slots.map((s, i) => `
<div class="slot-row">
<span class="spool-dot" style="background:${escapeHtml(s.colour || '#888888')}"></span>
<span>Slot ${i + 1} · ${escapeHtml(s.material)}</span>
<span class="form-hint">${escapeHtml(s.colour || 'any colour')}${(() => {
const named = s.colour ? paletteName(s.colour, s.material) : null;
return named ? ` · ${escapeHtml(named)}` : '';
})()}</span>
${s.filled
? `<span class="print-badge ok">${escapeHtml(s.by)}</span>`
: `<span class="print-badge no">none</span>${s.nearest
? `<span class="form-hint">nearest: ${escapeHtml(s.nearest.name)}</span>` : ''}`}
</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>`}</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>`;
}
function renderDetailLicense(model) {
renderDetailPrintable(model);
const com = model.commercial_use; // true / false / null
const badge = com === true
? '<span class="lic-badge ok">Commercial use allowed</span>'
: com === false
? '<span class="lic-badge no">Non-commercial only</span>'
: '<span class="lic-badge unknown">Commercial use unclear</span>';
document.getElementById('detailLicense').innerHTML =
`<div>${model.license ? escapeHtml(model.license) : 'Not specified'} ${badge}</div>` +
'<div class="lic-note">A guess from the license name — always confirm on the source page before selling.</div>';
}
function openEditModal() {
const id = fileDetailModal.dataset.currentModelId;
const model = findModel(id);
if (!model) return;
editModal.dataset.editId = id;
document.getElementById('editName').value = model.name || '';
document.getElementById('editDescription').value = model.description || '';
document.getElementById('editTags').value = (model.tags || []).join(', ');
document.getElementById('editLicense').value = model.license || '';
const ps = model.print_settings || {};
document.getElementById('editMaterial').value = ps.material || '';
document.getElementById('editLayerHeight').value = ps.layerHeight || '';
document.getElementById('editInfill').value = ps.infill || '';
document.getElementById('editSupports').value = ps.supports || '';
const current = new Set((model.projects || []).map((p) => String(p.id)));
document.getElementById('editProjects').innerHTML = projects
.map((p) => `<option value="${p.id}"${current.has(String(p.id)) ? ' selected' : ''}>${escapeHtml(p.name)}</option>`)
.join('');
editModal.classList.add('active');
}
async function handleEditModel(e) {
e.preventDefault();
const id = editModal.dataset.editId;
const body = {
name: document.getElementById('editName').value,
description: document.getElementById('editDescription').value,
license: document.getElementById('editLicense').value || null,
tags: splitTags(document.getElementById('editTags').value),
projects: Array.from(document.getElementById('editProjects').selectedOptions).map((o) => Number(o.value)),
print_settings: {
material: document.getElementById('editMaterial').value,
layerHeight: document.getElementById('editLayerHeight').value,
infill: document.getElementById('editInfill').value,
supports: document.getElementById('editSupports').value,
},
};
try {
const res = await fetch(`/api/models/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error((await res.json()).error);
closeModal(editModal);
await loadData();
openModelDetail(id); // refresh the (still-open) detail view
} catch (err) {
alert('Failed to save changes: ' + err.message);
}
}
async function handleDownloadFiles() {
const id = fileDetailModal.dataset.currentModelId;
const btn = document.getElementById('downloadFilesBtn');
btn.disabled = true;
btn.textContent = 'Downloading… (this can take a moment)';
try {
const res = await fetch(`/api/models/${id}/download-files`, { method: 'POST' });
const data = await res.json();
if (!res.ok && data.reason === 'makerworld_token') {
resetDownloadFilesBtn(id);
promptMakerworldToken(data.error, () => retryModelDownload(id));
return;
}
if (!res.ok) throw new Error(data.error);
await loadData();
openModelDetail(id);
if (data.added === 0) alert('No new files were found (the source returned nothing new).');
} catch (err) {
alert('Download failed: ' + err.message);
resetDownloadFilesBtn(id);
}
}
function resetDownloadFilesBtn(id) {
const btn = document.getElementById('downloadFilesBtn');
const model = findModel(id);
btn.disabled = false;
btn.textContent = model && model.files.length ? 'Re-check source for files' : 'Download files from source';
}
// ---------------------------------------------------------------- MakerWorld token prompt
// MakerWorld tokens expire. When one is refused, ask for a fresh one right away and
// retry what failed — a model's files, or a watched collection.
// retry: async () => ({ ok, message } | { ok: false, reason, error })
let tokenPromptRetry = null;
function promptMakerworldToken(reason, retry, action = 'Save & download files') {
tokenPromptRetry = retry;
document.getElementById('tokenSubmit').textContent = action;
document.getElementById('tokenReason').textContent = reason;
document.getElementById('tokenInput').value = '';
const status = document.getElementById('tokenStatus');
status.textContent = '';
status.classList.remove('is-error');
document.getElementById('tokenSubmit').disabled = false;
tokenModal.classList.add('active');
document.getElementById('tokenInput').focus();
}
async function handleTokenSubmit(e) {
e.preventDefault();
const token = document.getElementById('tokenInput').value.trim();
if (!token) return;
const retry = tokenPromptRetry;
const status = document.getElementById('tokenStatus');
const submit = document.getElementById('tokenSubmit');
const fail = (text) => {
status.classList.add('is-error');
status.textContent = text;
submit.disabled = false;
};
submit.disabled = true;
status.classList.remove('is-error');
status.textContent = 'Saving your token and trying again… this can take a minute.';
try {
await postJson('/api/settings', { makerworld_token: token }, 'PUT');
} catch (err) {
fail(`Couldn’t save the token: ${err.message}`);
return;
}
try {
const result = await retry();
if (!result.ok) {
fail(result.reason === 'makerworld_token'
? 'MakerWorld still didn’t accept that token. Check you copied the whole value of the token cookie while signed in, then try again.'
: `Your token is saved, but it didn’t work: ${result.error}`);
return;
}
status.textContent = result.message || 'Token saved.';
setTimeout(() => closeModal(tokenModal), 900);
} catch (err) {
fail(`Your token is saved, but it didn’t work: ${err.message}`);
}
}
// Retries a model's file download after a new token; used by the prompt.
async function retryModelDownload(id) {
const res = await fetch(`/api/models/${id}/download-files`, { method: 'POST' });
const data = await res.json().catch(() => ({}));
if (!res.ok) return { ok: false, reason: data.reason, error: data.error };
await loadData();
if (fileDetailModal.classList.contains('active') && fileDetailModal.dataset.currentModelId === String(id)) openModelDetail(id);
return { ok: true, message: data.added ? `Downloaded ${plural(data.added, 'file')}.` : 'Token saved. No new files were found.' };
}
// ---------------------------------------------------------------- filament
// The spools you have. What a model needs is read from its 3MF in the
// background, so "what can I print now" compares two lists rather than opening
// files. Everything here binds straight after rendering — no delegation — so a
// button that appears always has its handler.
function filamentPanelHtml() {
return `
<section class="ai-card">
<div class="ai-card-head"><h3>Filament on hand</h3></div>
<p class="form-hint">Add the spools you have. Models that came from a Bambu Studio or OrcaSlicer project
know which filaments they ask for, so Printhive can tell you what's printable without a trip to the shelf.
Colour isn't matched — a different colour still prints.</p>
<form id="spoolForm" class="watch-form">
<input type="text" id="spoolPick" list="spoolPickList" placeholder="Pick a Bambu filament…"
aria-label="Pick a filament from the maker's list" autocomplete="off">
<datalist id="spoolPickList"></datalist>
<input type="text" id="spoolMaterial" placeholder="Material (PLA, PETG…)" aria-label="Material" required>
<input type="text" id="spoolColour" placeholder="Colour name" aria-label="Colour">
<input type="color" id="spoolHex" value="#ffffff" aria-label="Colour swatch" title="The spool's colour — matched against what each slot asks for">
<input type="text" id="spoolBrand" placeholder="Brand" aria-label="Brand">
<input type="number" id="spoolRemaining" placeholder="Grams left" aria-label="Grams left" min="0" step="10">
<button type="submit" class="btn btn-primary" id="spoolSubmit">Add spool</button>
</form>
<div id="spoolStatus" class="form-hint"></div>
<div id="spoolList" class="watch-list"></div>
</section>`;
}
function spoolHtml(s) {
const left = s.remaining_g == null ? 'amount unknown' : `${count(s.remaining_g)} g left`;
const of = s.total_g ? ` of ${count(s.total_g)} g` : '';
return `
<div class="watch spool" data-spool-id="${s.id}">
<div class="watch-head">
<span class="spool-dot" style="background:${escapeHtml(s.colour_hex || '#888')}"></span>
<strong>${escapeHtml(s.material)}</strong>
<span>${escapeHtml(s.colour || '')}</span>
<span class="form-hint">${escapeHtml(s.brand || '')}${s.ams_slot ? ` · ${escapeHtml(s.ams_slot)}` : ''}</span>
<span class="watch-actions">
<label class="form-hint">grams left
<input type="number" class="spool-remaining" value="${s.remaining_g ?? ''}" min="0" step="10" aria-label="Grams left">
</label>
<button type="button" class="btn btn-secondary btn-sm" data-spool="remove">Remove</button>
</span>
</div>
<div class="watch-meta">${escapeHtml(left)}${escapeHtml(of)}</div>
</div>`;
}
async function renderFilamentPanel() {
fileGrid.className = 'ai-panel';
resultCount.textContent = '';
if (!document.getElementById('spoolForm')) {
fileGrid.innerHTML = filamentPanelHtml();
document.getElementById('spoolForm').addEventListener('submit', handleAddSpool);
// Picking a known filament fills in the rest, so nobody types hex by hand.
const pick = document.getElementById('spoolPick');
document.getElementById('spoolPickList').innerHTML = palette
.map((f) => `<option value="${escapeHtml(`${f.material} ${f.type} — ${f.name}${f.code ? ` (${f.code})` : ''}`)}"></option>`)
.join('');
pick.addEventListener('input', () => {
const chosen = palette.find((f) => `${f.material} ${f.type} — ${f.name}${f.code ? ` (${f.code})` : ''}` === pick.value);
if (!chosen) return;
document.getElementById('spoolMaterial').value = chosen.material;
document.getElementById('spoolColour').value = chosen.name;
document.getElementById('spoolHex').value = (chosen.colours[0] || '#ffffff').toLowerCase();
pick.dataset.code = chosen.code || '';
});
}
await refreshSpools();
}
async function refreshSpools() {
if (selectedScope !== 'filament') return;
const list = document.getElementById('spoolList');
if (!list) return;
let data;
try {
data = await getJson('/api/filament');
} catch (err) {
list.innerHTML = `<p class="form-hint">Couldn’t load your spools: ${escapeHtml(err.message)}</p>`;
return;
}
spools = data.spools; // cards and the filter judge against this
const spoolCount = document.getElementById('spoolCount');
if (spoolCount) spoolCount.textContent = data.spools.length;
list.innerHTML = data.spools.length
? data.spools.map(spoolHtml).join('')
+ (data.pending ? `<p class="form-hint">${plural(data.pending, 'model')} still to be read for filament.</p>` : '')
: '<p class="form-hint">No spools yet. Add what’s on the shelf above.</p>';
// Bound here, on the elements just drawn, so nothing depends on delegation.
for (const row of list.querySelectorAll('[data-spool-id]')) {
const id = Number(row.dataset.spoolId);
row.querySelector('[data-spool="remove"]').addEventListener('click', () => removeSpool(id, row));
row.querySelector('.spool-remaining').addEventListener('change', (e) => setSpoolRemaining(id, e.target.value));
}
}
async function handleAddSpool(e) {
e.preventDefault();
const status = document.getElementById('spoolStatus');
const submit = document.getElementById('spoolSubmit');
const material = document.getElementById('spoolMaterial').value.trim();
if (!material) return;
submit.disabled = true;
status.classList.remove('is-error');
status.textContent = '';
try {
const remaining = document.getElementById('spoolRemaining').value;
const pick = document.getElementById('spoolPick');
await postJson('/api/filament', {
material,
colour: document.getElementById('spoolColour').value.trim(),
colour_hex: document.getElementById('spoolHex').value,
brand: document.getElementById('spoolBrand').value.trim(),
remaining_g: remaining === '' ? null : Number(remaining),
code: (pick && pick.dataset.code) || null, // what the AMS reports, when picked from the list
});
document.getElementById('spoolForm').reset();
document.getElementById('spoolMaterial').focus();
await refreshSpools();
} catch (err) {
status.classList.add('is-error');
status.textContent = err.message;
} finally {
submit.disabled = false;
}
}
async function removeSpool(id, row) {
if (!confirm('Remove this spool?')) return;
try {
await getJson(`/api/filament/${id}`, { method: 'DELETE' });
await refreshSpools();
} catch (err) {
const status = document.getElementById('spoolStatus');
status.classList.add('is-error');
status.textContent = err.message;
if (row) row.classList.remove('is-busy');
}
}
async function setSpoolRemaining(id, value) {
const status = document.getElementById('spoolStatus');
try {
await postJson(`/api/filament/${id}`, { remaining_g: value === '' ? null : Number(value) }, 'PUT');
status.classList.remove('is-error');
status.textContent = 'Saved.';
await refreshSpools();
} catch (err) {
status.classList.add('is-error');
status.textContent = err.message;
}
}
// ---------------------------------------------------------------- watched collections
let watchPoll = null;
function watchPanelHtml() {
const intervals = [1, 3, 6, 12, 24, 48]
.map((h) => `<option value="${h}"${h === 6 ? ' selected' : ''}>${h === 1 ? 'every hour' : `every ${h} hours`}</option>`).join('');
return `
<section class="ai-card">
<div class="ai-card-head"><h3>Watch a MakerWorld collection</h3></div>
<p class="form-hint">Printhive checks the collection and imports models added to it, into a collection of the same
name here. Private collections work too, using your MakerWorld token. Models you remove there stay here.
A check brings in each model's page — name, description, picture, licence. Files are left for you to fetch with
“Get files”, unless you switch downloads on: MakerWorld refuses quick bursts, so a check that downloads spaces
them about 20 seconds apart and takes correspondingly longer.</p>
<form id="watchForm" class="watch-form">
<input type="url" id="watchUrl" placeholder="https://makerworld.com/en/collections/…" aria-label="Collection link" required>
<label>Check <select id="watchInterval" aria-label="How often to check">${intervals}</select></label>
<label><input type="checkbox" id="watchAi"> Tag new models with AI</label>
<label><input type="checkbox" id="watchFiles"> Download files too (about 20s per model)</label>
<button type="submit" class="btn btn-primary" id="watchSubmit">Watch collection</button>
</form>
<div id="watchStatus" class="form-hint"></div>
<div id="watchList" class="watch-list"></div>
</section>`;
}
const WATCH_STATUS = { ok: 'Up to date', checking: 'Checking…', error: 'Problem' };
// What went wrong with the last thing you asked a watch to do, shown on its row
// until the next attempt. Beats an alert() you can't read once it's dismissed.
const watchNotices = new Map();
function watchHtml(w) {
const when = w.last_checked_at ? `checked ${formatDate(w.last_checked_at)}` : 'not checked yet';
const listed = w.total != null && w.listed != null && w.listed < w.total
? ` · MakerWorld listed ${w.listed} of ${w.total}` : w.total != null ? ` · ${plural(w.total, 'model')} there` : '';
const recent = (w.recent || []).filter((i) => i.status !== 'existing');
return `
<div class="watch${w.enabled ? '' : ' is-paused'}" data-watch-id="${w.id}">
<div class="watch-head">
<a href="${escapeHtml(w.url)}" target="_blank" rel="noopener">${escapeHtml(w.title || 'MakerWorld collection')}</a>
<span class="pill pill-${w.last_status === 'error' ? 'failed' : w.last_status === 'checking' ? 'running' : 'done'}">
${w.enabled ? (WATCH_STATUS[w.last_status] || 'Waiting') : 'Paused'}</span>
<span class="form-hint">→ ${escapeHtml(w.project_name || 'no collection')}${w.ai_tag ? ' · AI tagging on' : ''}</span>
<span class="watch-actions">
<button type="button" class="btn btn-secondary btn-sm" data-watch="check">Check now</button>
<button type="button" class="btn btn-secondary btn-sm" data-watch="toggle">${w.enabled ? 'Pause' : 'Resume'}</button>
<button type="button" class="btn btn-secondary btn-sm" data-watch="filemode">${w.fetch_files ? 'Files: on' : 'Files: off'}</button>
<button type="button" class="btn btn-secondary btn-sm" data-watch="remove">Stop watching</button>
</span>
</div>
<div class="watch-meta">${escapeHtml(when)} · every ${w.interval_hours}h · ${plural(w.imported_count, 'model')} imported${w.partial_count ? ` · ${w.partial_count} still without files` : ''}${w.error_count ? ` · ${w.error_count} failed` : ''}${listed}</div>
${w.last_error ? `<div class="ai-run-error">${escapeHtml(w.last_error)}
${w.last_error_reason === 'makerworld_token' ? '<button type="button" class="btn btn-primary btn-sm" data-watch="token">Update token</button>' : ''}</div>` : ''}
${watchNotices.has(w.id) ? `<div class="ai-run-error">${escapeHtml(watchNotices.get(w.id))}</div>` : ''}
${recent.length ? `<div class="watch-recent">${recent.map((i) => `
<span>${i.model_id
? `<button type="button" class="link-btn" data-open-model="${i.model_id}">${escapeHtml(i.model_name || i.title || `Model ${i.model_id}`)}</button>`
: escapeHtml(i.title || i.design_id)}${i.error ? ` <span class="ai-run-error">${escapeHtml(i.error)}</span>` : ''}${(i.error || i.status === 'partial') && i.model_id
? ` <button type="button" class="link-btn" data-watch="files" data-model-id="${i.model_id}">Get files</button>` : ''}</span>`).join('')}</div>` : ''}
${(w.pending || []).length ? `<details class="watch-pending">
<summary>${plural(w.partial_count || w.pending.length, 'model')} still without files</summary>
<div class="watch-recent">${w.pending.map((i) => `
<span><button type="button" class="link-btn" data-open-model="${i.model_id}">${escapeHtml(i.title || `Model ${i.model_id}`)}</button>
<button type="button" class="link-btn" data-watch="files" data-model-id="${i.model_id}">Get files</button></span>`).join('')}</div>
</details>` : ''}
</div>`;
}
async function renderWatchPanel() {
fileGrid.className = 'ai-panel';
resultCount.textContent = '';
if (!document.getElementById('watchForm')) fileGrid.innerHTML = watchPanelHtml();
await refreshWatches();
}
async function refreshWatches() {
clearTimeout(watchPoll);
if (selectedScope !== 'watch') return;
let watches;
try {
watches = await getJson('/api/watches');
} catch (err) {
const list = document.getElementById('watchList');
if (list) list.innerHTML = `<p class="form-hint">Couldn’t load watched collections: ${escapeHtml(err.message)}</p>`;
return;
}
const list = document.getElementById('watchList');
if (!list || selectedScope !== 'watch') return;
list.innerHTML = watches.length
? watches.map(watchHtml).join('')
: '<p class="form-hint">Nothing watched yet. Paste a MakerWorld collection link above.</p>';
// Keep the page current even between checks: one may start from the schedule,
// another tab, or another browser. refreshWatches stops itself when you leave.
const checking = watches.some((w) => w.last_status === 'checking');
watchPoll = setTimeout(refreshWatches, checking ? 5000 : 20000);
}
async function handleAddWatch(e) {
e.preventDefault();
const status = document.getElementById('watchStatus');
const submit = document.getElementById('watchSubmit');
const url = document.getElementById('watchUrl').value.trim();
submit.disabled = true;
status.classList.remove('is-error');
status.textContent = 'Reading that collection…';
try {
const watch = await postJson('/api/watches', {
url,
aiTag: document.getElementById('watchAi').checked,
fetchFiles: document.getElementById('watchFiles').checked,
intervalHours: Number(document.getElementById('watchInterval').value),
});
document.getElementById('watchUrl').value = '';
status.textContent = `Watching “${watch.title}” — importing what's in it now.`;
await loadData();
refreshWatches();
} catch (err) {
status.classList.add('is-error');
status.textContent = err.message;
if (/token/i.test(err.message)) {
promptMakerworldToken(err.message, async () => {
try {
const watch = await postJson('/api/watches', {
url,
aiTag: document.getElementById('watchAi').checked,
fetchFiles: document.getElementById('watchFiles').checked,
intervalHours: Number(document.getElementById('watchInterval').value),
});
await loadData();
refreshWatches();
return { ok: true, message: `Watching “${watch.title}”.` };
} catch (retryErr) {
return { ok: false, reason: /token/i.test(retryErr.message) ? 'makerworld_token' : null, error: retryErr.message };
}
}, 'Save & watch collection');
}
} finally {
submit.disabled = false;
}
}
async function handleWatchAction(btn) {
const id = Number(btn.closest('[data-watch-id]').dataset.watchId);
const action = btn.dataset.watch;
btn.disabled = true;
watchNotices.delete(id); // this attempt replaces whatever the last one said
try {
if (action === 'check') {
await postJson(`/api/watches/${id}/check`);
} else if (action === 'toggle') {
const paused = btn.textContent.trim() === 'Pause';
await postJson(`/api/watches/${id}`, { enabled: !paused }, 'PUT');
} else if (action === 'remove') {
if (!confirm('Stop watching this collection?\n\nThe models it already imported are kept.')) { btn.disabled = false; return; }
await getJson(`/api/watches/${id}`, { method: 'DELETE' });
await loadData();
} else if (action === 'filemode') {
const on = btn.textContent.trim() === 'Files: on';
await postJson(`/api/watches/${id}`, { fetchFiles: !on }, 'PUT');
} else if (action === 'files') {
btn.textContent = 'Getting files…';
await postJson(`/api/models/${Number(btn.dataset.modelId)}/download-files`);
} else if (action === 'token') {
promptMakerworldToken('MakerWorld didn’t accept your token when checking this collection.',
() => retryWatchCheck(id), 'Save & check collection');
}
} catch (err) {
if (err.message && /token/i.test(err.message)) {
promptMakerworldToken(err.message, () => retryWatchCheck(id), 'Save & check collection');
} else {
watchNotices.set(id, err.message);
}
}
refreshWatches();
}
async function retryWatchCheck(id) {
const res = await fetch(`/api/watches/${id}/check`, { method: 'POST' });
const data = await res.json().catch(() => ({}));
if (!res.ok) return { ok: false, reason: data.reason, error: data.error };
refreshWatches();
return { ok: true, message: 'Token saved. Checking the collection now…' };
}
async function handleRefreshMetadata() {
const modelId = fileDetailModal.dataset.currentModelId;
const btn = document.getElementById('refreshLicenseBtn');
btn.disabled = true;
btn.textContent = 'Refreshing…';
try {
const res = await fetch(`/api/models/${modelId}/refresh-metadata`, { method: 'POST' });
if (!res.ok) throw new Error((await res.json()).error);
const updated = await res.json();
// update in-memory state, the license panel, and the card badge
replaceModel(updated);
renderDetailLicense(updated);
renderModels();
btn.textContent = 'Updated ✓';
setTimeout(() => { btn.textContent = 'Refresh from source'; }, 1500);
} catch (err) {
btn.textContent = 'Refresh failed';
console.warn('Refresh metadata failed:', err.message);
setTimeout(() => { btn.textContent = 'Refresh from source'; }, 1800);
} finally {
btn.disabled = false;
}
}
async function loadViewer(item) {
if (item.format === '3mf') {
await loadProjectInfo(item);
if (activeItemKey !== itemKey(item)) return; // another tab was opened meanwhile
}
renderProjectPanels(item);
const src = viewerSourceFor(item);
const model = findModel(fileDetailModal.dataset.currentModelId);
const resolve = resolverFor(model, item.kind === 'entry' ? archiveEntryCache.get(item.archiveId) : null);
const is3d = window.ModelViewer.MODEL_FORMATS.includes(src.format);
currentDetailFile = is3d ? { viewerUrl: src.url, viewerFormat: src.format, resolve } : null;
document.getElementById('regenThumbBtn').style.display = is3d ? '' : 'none';
window.ModelViewer.render(document.getElementById('detailViewer'), src.url, src.format,
{ fallbackImage: currentDetailThumb, resolve });
}
// Plates and filaments of a 3MF slicer project, fetched once per viewer item.
// Projects with several plates open on their first plate, like the slicer.
function loadProjectInfo(item) {
if (!item.projectPromise) {
item.projectPromise = fetch(`/api/${item.kind === 'entry' ? 'entries' : 'files'}/${item.id}/project`)
.then((res) => (res.ok ? res.json() : null))
.catch(() => null)
.then((project) => {
item.project = project || { plates: [], filaments: [] };
if (item.plate === undefined && item.project.plates.length > 1) item.plate = item.project.plates[0].index;
});
}
return item.projectPromise;
}
const HEX_COLOUR = /^#[0-9a-f]{6}([0-9a-f]{2})?$/i;
function renderProjectPanels(item) {
const project = item && item.format === '3mf' ? item.project : null;
const platesEl = document.getElementById('detailPlates');
if (!project || project.plates.length < 2) {
platesEl.hidden = true;
platesEl.innerHTML = '';
} else {
platesEl.hidden = false;
platesEl.innerHTML = project.plates.map((p) => `
<button type="button" class="plate-chip${item.plate === p.index ? ' active' : ''}" data-plate="${p.index}"${dragAttrs(rawUrlFor(item), item.name)}
title="${escapeHtml(p.name || `Plate ${p.index}`)} · ${p.objectCount} object${p.objectCount === 1 ? '' : 's'}">
${p.thumbnailUrl ? `<img src="${escapeHtml(p.thumbnailUrl)}" alt="">` : ''}
<span>${p.index}${p.name ? ` · ${escapeHtml(p.name)}` : ''}</span>
</button>`).join('')
+ `<button type="button" class="plate-chip${item.plate ? '' : ' active'}" data-plate="all"${dragAttrs(rawUrlFor(item), item.name)}><span>All plates</span></button>`;
}
const filaments = ((project && project.filaments) || [])
.map((f, i) => ({ ...f, slot: i + 1 }))
.filter((f) => HEX_COLOUR.test(f.colour || ''));
document.getElementById('detailFilamentsSection').hidden = filaments.length === 0;
document.getElementById('detailFilaments').innerHTML = filaments.map((f) => `
<div class="filament">
<span class="swatch" style="background:${f.colour.slice(0, 7)}"></span>
<span>Slot ${f.slot}${f.type ? ` · ${escapeHtml(f.type)}` : ''}</span>
<em>${f.colour.slice(0, 7).toUpperCase()}</em>
</div>`).join('');
}
function updateCardThumbnail(modelId, thumbnailUrl) {
const m = findModel(modelId);
if (m) m.thumbnailUrl = thumbnailUrl;
const thumb = fileGrid.querySelector(`[data-model-id="${modelId}"] .file-thumbnail`);
if (thumb && thumbnailUrl) thumb.innerHTML = `<img src="${thumbnailUrl}" alt="" loading="lazy">`;
}
async function handleRegenThumbnail() {
const modelId = fileDetailModal.dataset.currentModelId;
if (!currentDetailFile) { alert('No previewable file to render.'); return; }
const btn = document.getElementById('regenThumbBtn');
btn.disabled = true;
btn.textContent = 'Rendering…';
try {
const image = await window.ModelViewer.snapshot(currentDetailFile.viewerUrl, currentDetailFile.viewerFormat, 512,
{ resolve: currentDetailFile.resolve });
const res = await fetch(`/api/models/${modelId}/thumbnail`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image }),
});
if (!res.ok) throw new Error((await res.json()).error);
const updated = await res.json();
updateCardThumbnail(modelId, updated.thumbnailUrl);
thumbAttempted.add(Number(modelId));
btn.textContent = 'Done ✓';
setTimeout(() => { btn.textContent = 'Regenerate thumbnail'; }, 1500);
} catch (err) {
console.warn('Thumbnail regen failed:', err.message);
btn.textContent = "Can't render this file";
setTimeout(() => { btn.textContent = 'Regenerate thumbnail'; }, 1800);
} finally {
btn.disabled = false;
}
}
async function handleAddFiles(e) {
const input = e.target;
if (!input.files.length) return;
const modelId = fileDetailModal.dataset.currentModelId;
const formData = new FormData();
Array.from(input.files).forEach((f) => formData.append('files', f));
try {
const res = await fetch(`/api/models/${modelId}/files`, { method: 'POST', body: formData });
if (!res.ok) throw new Error((await res.json()).error);
input.value = '';
await loadData();
openModelDetail(modelId); // reopen with the new files
} catch (err) {
alert('Failed to add files: ' + err.message);
}
}
// ---------------------------------------------------------------- handlers
async function handleUpload(e) {
e.preventDefault();
const formData = new FormData();
const input = document.getElementById('fileInput');
Array.from(input.files).forEach((f) => formData.append('files', f));
formData.append('name', document.getElementById('fileName').value);
formData.append('description', document.getElementById('fileDescription').value);
formData.append('tags', JSON.stringify(splitTags(document.getElementById('fileTags').value)));
formData.append('projects', JSON.stringify(
Array.from(document.getElementById('fileProjects').selectedOptions).map((o) => Number(o.value))));
formData.append('printSettings', JSON.stringify({
material: document.getElementById('printMaterial').value,
layerHeight: document.getElementById('printLayerHeight').value,
infill: document.getElementById('printInfill').value,
supports: document.getElementById('printSupports').value,
}));
try {
const res = await fetch('/api/models', { method: 'POST', body: formData });
if (res.ok) {
closeModal(uploadModal);
document.getElementById('uploadForm').reset();
await loadData();
} else {
alert('Upload failed: ' + (await res.json()).error);
}
} catch (err) {
alert('Upload failed: ' + err.message);
}
}
async function handleImport(e) {
e.preventDefault();
const status = document.getElementById('importStatus');
const submit = document.getElementById('importSubmit');
status.textContent = 'Importing… fetching metadata and downloading files.';
submit.disabled = true;
try {
const res = await fetch('/api/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: document.getElementById('importUrl').value }),
});
const data = await res.json();
if (res.ok) {
await loadData();
if (data.metadataOnly && data.filesErrorReason === 'makerworld_token') {
// Details imported, files refused: offer to fix the token and fetch them now.
closeModal(importModal);
openModelDetail(data.id);
promptMakerworldToken(`Imported “${data.name}”, but its files couldn’t be downloaded: ${data.filesError}`,
() => retryModelDownload(data.id));
} else if (data.metadataOnly) {
status.textContent = data.filesError
? `Imported "${data.name}", but its files couldn’t be downloaded: ${data.filesError}`
: data.metadataFetched
? `Imported "${data.name}". Opening it so you can add the downloaded files…`
: `Couldn't read that page (the site blocks automated access), so I created a stub named "${data.name}". Opening it so you can add files…`;
// Give a download problem time to be read before moving on.
setTimeout(() => { closeModal(importModal); openModelDetail(data.id); }, data.filesError ? 6000 : 1200);
} else {
status.textContent = `Imported "${data.name}" with ${data.files.length} file(s).`;
setTimeout(() => closeModal(importModal), 900);
}
} else if (res.status === 409) {
status.textContent = 'That model is already in your library.';
} else {
status.textContent = 'Import failed: ' + data.error;
}
} catch (err) {
status.textContent = 'Import failed: ' + err.message;
} finally {
submit.disabled = false;
}
}
async function renderLicenseRules() {
const wrap = document.getElementById('licenseRules');
let rules;
try { rules = await (await fetch('/api/licenses')).json(); }
catch { wrap.textContent = 'Could not load licenses.'; return; }
if (!rules.length) { wrap.innerHTML = '<p class="form-hint">No licenses yet — import some models.</p>'; return; }
const opt = (v, sel) => `<option value="${v}"${sel ? ' selected' : ''}>`;
wrap.innerHTML = rules.map((r) => {
const cur = r.commercial === true ? 'true' : r.commercial === false ? 'false' : '';
return `<div class="license-row">
<span class="license-name" title="${escapeHtml(r.name)}">${escapeHtml(r.name)} <em>(${r.model_count})</em></span>
<select class="license-select" data-license="${escapeHtml(r.name)}">
${opt('true', cur === 'true')}Sellable</option>
${opt('false', cur === 'false')}Non-commercial</option>
${opt('', cur === '')}Unknown</option>
</select>
</div>`;
}).join('');
wrap.querySelectorAll('.license-select').forEach((sel) => {
sel.addEventListener('change', () => handleLicenseChange(sel.dataset.license, sel.value));
});
}
async function handleLicenseChange(name, value) {
const commercial = value === 'true' ? true : value === 'false' ? false : null;
try {
const res = await fetch('/api/licenses', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, commercial }),
});
if (!res.ok) throw new Error((await res.json()).error);
await loadData(); // model badges reflect the new policy
} catch (err) {
alert('Could not update license: ' + err.message);
}
}
async function handleSaveSettings(e) {
e.preventDefault();
const status = document.getElementById('settingsStatus');
const payload = settingsPayload();
if (Object.keys(payload).length === 0) {
status.textContent = 'Nothing to save.';
return;
}
try {
const res = await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error((await res.json()).error);
status.textContent = 'Saved.';
await loadData(); // refresh config so import button reflects new token
setTimeout(() => closeModal(settingsModal), 600);
} catch (err) {
status.textContent = 'Save failed: ' + err.message;
}
}
// Only fields you typed into are sent (blank = leave unchanged), except
// data-clear-blank ones, where blank means "use the default".
function settingsPayload() {
const payload = {};
document.querySelectorAll('#settingsForm [data-setting]').forEach((input) => {
const value = input.value.trim();
if (value) payload[input.dataset.setting] = value;
else if ('clearBlank' in input.dataset) payload[input.dataset.setting] = null;
});
return payload;
}
async function handleClearSetting(key) {
if (!confirm('Clear this value?')) return;
const status = document.getElementById('settingsStatus');
try {
const res = await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [key]: null }),
});
if (!res.ok) throw new Error((await res.json()).error);
status.textContent = 'Cleared.';
await loadData();
await openSettingsModal();
} catch (err) {
status.textContent = 'Clear failed: ' + err.message;
}
}
async function handleRenameCollection(id, currentName) {
const name = prompt('Rename collection:', currentName);
if (name === null) return;
const trimmed = name.trim();
if (!trimmed || trimmed === currentName) return;
try {
const res = await fetch(`/api/projects/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: trimmed }),
});
if (!res.ok) throw new Error((await res.json()).error);
await loadData();
} catch (err) {
alert('Failed to rename collection: ' + err.message);
}
}
async function handleDeleteCollection(id, name) {
if (!confirm(`Delete the collection "${name}"?\n\nThe models in it are kept — only the collection is removed.`)) return;
try {
const res = await fetch(`/api/projects/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error((await res.json()).error);
if (String(selectedScope) === String(id)) selectedScope = 'all';
await loadData();
} catch (err) {
alert('Failed to delete collection: ' + err.message);
}
}
async function handleCreateProject(e) {
e.preventDefault();
try {
const res = await fetch('/api/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: document.getElementById('projectName').value,
description: document.getElementById('projectDescription').value,
}),
});
if (res.ok) {
closeModal(projectModal);
document.getElementById('projectForm').reset();
await loadData();
} else {
alert('Failed to create collection:' + (await res.json()).error);
}
} catch (err) {
alert('Failed to create collection:' + err.message);
}
}
async function handleDeleteModel() {
const id = fileDetailModal.dataset.currentModelId;
if (!id || !confirm('Move this model to the trash?\n\nYou can restore it from Trash until the trash is emptied.')) return;
try {
const res = await fetch(`/api/models/${id}`, { method: 'DELETE' });
if (res.ok) {
closeModal(fileDetailModal);
await loadData();
} else {
alert('Failed to move to trash: ' + (await res.json()).error);
}
} catch (err) {
alert('Failed to move to trash: ' + err.message);
}
}
// ---------------------------------------------------------------- utils
// All distinct tags currently in the library, sorted.
function knownTags() {
const set = new Set();
models.forEach((m) => (m.tags || []).forEach((t) => set.add(t)));
return [...set].sort((a, b) => a.localeCompare(b));
}
// Autocomplete for a comma-separated tag input: suggests existing tags that
// match the token currently being typed (after the last comma).
function setupTagAutocomplete(input) {
if (!input) return;
const box = document.createElement('div');
box.className = 'tag-suggest';
box.style.display = 'none';
input.parentNode.style.position = 'relative';
input.parentNode.appendChild(box);
let active = -1;
const parts = () => input.value.split(',');
const currentToken = () => parts().pop().trim().toLowerCase();
const hide = () => { box.style.display = 'none'; active = -1; };
function choose(tag) {
const p = parts().map((x) => x.trim());
p[p.length - 1] = tag;
input.value = p.join(', ') + ', ';
hide();
input.focus();
}
function render() {
const tok = currentToken();
if (!tok) return hide();
const taken = new Set(parts().map((x) => x.trim().toLowerCase()));
const matches = knownTags().filter((t) => t.toLowerCase().includes(tok) && !taken.has(t.toLowerCase())).slice(0, 8);
if (!matches.length) return hide();
active = -1;
box.innerHTML = matches.map((t) => `<div class="tag-suggest-item" data-tag="${escapeHtml(t)}">${escapeHtml(t)}</div>`).join('');
box.style.top = `${input.offsetTop + input.offsetHeight + 2}px`;
box.style.left = `${input.offsetLeft}px`;
box.style.width = `${input.offsetWidth}px`;
box.style.display = '';
}
input.addEventListener('input', render);
input.addEventListener('focus', render);
input.addEventListener('blur', () => setTimeout(hide, 150));
input.addEventListener('keydown', (e) => {
const items = [...box.querySelectorAll('.tag-suggest-item')];
if (box.style.display === 'none' || !items.length) return;
if (e.key === 'ArrowDown') { e.preventDefault(); active = (active + 1) % items.length; }
else if (e.key === 'ArrowUp') { e.preventDefault(); active = (active - 1 + items.length) % items.length; }
else if (e.key === 'Enter' && active >= 0) { e.preventDefault(); choose(items[active].dataset.tag); return; }
else if (e.key === 'Escape') { hide(); return; }
else return;
items.forEach((it, i) => it.classList.toggle('active', i === active));
});
box.addEventListener('mousedown', (e) => {
const it = e.target.closest('.tag-suggest-item');
if (it) { e.preventDefault(); choose(it.dataset.tag); }
});
}
function splitTags(value) {
return value.split(',').map((t) => t.trim()).filter(Boolean);
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text == null ? '' : text;
return div.innerHTML;
}
function formatFileSize(bytes) {
bytes = Number(bytes) || 0;
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
}
function formatDate(dateString) {
return new Date(dateString).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
}