// 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) {
// Seventeen PLA filaments are #000000. Whichever matched first would win, so
// prefer the everyday product — Basic, then Matte — over specialist ones.
const rank = (type) => (type === 'Basic' ? 0 : type === 'Matte' ? 1 : 2);
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) continue;
const better = !best || rank(f.type) < rank(best.type) || (rank(f.type) === rank(best.type) && d < best.d);
if (better) 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 `✔ printable`;
const n = p.unfilled.length;
return `needs ${n} colour${n === 1 ? '' : 's'}`;
}
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) => `
${escapeHtml(p.name)}${p.model_count}
`).join('')
: '
No collections yet.
';
}
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 `${escapeHtml(tag)}`;
}).join('')
: '
No tags yet.
';
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 = ``
+ options.map(([value, label]) => ``).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]) => `
`;
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 ? `` : '📦';
}
function cardHtml(m) {
const trashed = Boolean(m.deleted_at);
const when = trashed ? `trashed ${formatDate(m.deleted_at)}` : formatDate(m.date_added);
return `
`).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 = `
`;
}).join('');
if (!detailItems.length) previewFirstEntry(fileId, data.entries);
} catch (err) {
if (container()) container().innerHTML = `
Couldn't list this archive: ${escapeHtml(err.message)}
`;
}
}
// ---------------------------------------------------------------- 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 `${escapeHtml(t)}`;
}).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)}”. `
: '';
}
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: ${escapeHtml(r.model)} answered in ${(r.ms / 1000).toFixed(1)} s with
${r.tags.map(escapeHtml).join(', ') || 'no tags'}. 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 `
AI tags & descriptions
Each model's name, file names 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.
Suggested collections
Ideas from runs with “Suggest collections” ticked. Nothing changes until you accept one.
Loading…
Search by meaning
Switch the search box to Meaning 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.
`;
}
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 ? `
` : '';
// 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 ? `
${plural(detail.failures.length, 'model')} failed
${count(run.prompt_tokens)} tokens in · ${count(run.completion_tokens)} out · ${cost}${options.maxCost ? ` (limit $${options.maxCost})` : ''}
${run.error ? `
${escapeHtml(run.error)}
` : ''}
${run.note ? `
${escapeHtml(run.note)}
` : ''}
${feed}
${failed}
`;
}
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 `
Automatic tags
Adds a tag when whole words in a model's name, file names or archive contents match a rule:
“hook” matches Wall_Hooks.stl but not facebook.stl. Tags you add yourself are never changed.
${t.enabled ? '' : `
Automatic tagging is off. Tags it already added are kept.
None yet. Add a word to look for and the tag it should add.
'}
`;
}
// 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 = '
`
: '');
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]) => `
`).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 = '
This model doesn’t name its filaments — only Bambu Studio and OrcaSlicer projects do.
A guess from the license name — always confirm on the source page before selling.
';
}
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) => ``)
.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 `
Filament on hand
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.
`;
}
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 `
`;
}
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) => ``)
.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 = `
Couldn’t load your spools: ${escapeHtml(err.message)}
`;
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 ? `
${plural(data.pending, 'model')} still to be read for filament.
` : '')
: '
No spools yet. Add what’s on the shelf above.
';
// 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) => ``).join('');
return `
Watch a MakerWorld collection
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.
`;
}
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 `