diff --git a/README.md b/README.md index 6ffa66d..a1d61df 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ A self-hosted web app for organizing your 3D-printing files (STL, 3MF, OBJ, FBX, - **Import from URL**: - **Thingiverse** — downloads files automatically via the official API (needs a free app token). - **Printables** — downloads files automatically via its public GraphQL API (no auth needed for free models). - - **MakerWorld** — downloads files via FlareSolverr (solves Cloudflare) + your account token. + - **MakerWorld** — downloads files via FlareSolverr (solves Cloudflare) + your account token. If files can't be downloaded the import says why, and when MakerWorld refuses the token (they expire) Printhive asks for a fresh one and retries. - **cubee3d (Hive)** and any other URL — imports title/description/preview image; you add the files manually. - **In-browser 3D viewer** (Three.js) for STL, OBJ (with its MTL colours/textures), FBX (with textures), STEP/STP (OpenCascade WASM) and Rhino 3DM, and for 3MF via a server-side **trimesh GLB conversion** that streams even huge Bambu/MakerWorld projects. - **Slicer projects plate by plate** — Bambu Studio / OrcaSlicer 3MFs open on plate 1 with the slicer's plate images as a switcher, parts shown in their filament colours, and a Filaments list; negative/modifier parts are hidden. diff --git a/lib/importers/makerworld.js b/lib/importers/makerworld.js index a81eed2..a4f32e4 100644 --- a/lib/importers/makerworld.js +++ b/lib/importers/makerworld.js @@ -35,15 +35,15 @@ async function resolveDownload(flaresolverrUrl, instanceId, token) { }); } -// Why a download-link answer came back without a link, in words you can act on; -// null when it has one. Not logged in: {"code":1,"error":"Please log in to download models."} +// Why a download-link answer came back without a link: { message, tokenRejected }, +// or null when it has one. Not logged in: {"code":1,"error":"Please log in to download models."} function downloadProblem(answer) { if (answer && answer.url) return null; const detail = String((answer && (answer.error || answer.message || answer.msg)) || '').trim(); if (/log ?in|sign ?in|unauthori[sz]ed|token|expired/i.test(detail)) { - return 'MakerWorld didn’t accept your auth token (it has probably expired). Paste a fresh one in Settings → MakerWorld auth token, then use “Download files from source”.'; + return { message: 'MakerWorld didn’t accept your auth token (it has probably expired).', tokenRejected: true }; } - return `MakerWorld didn’t give a download link${detail ? `: ${detail}` : ''}`; + return { message: `MakerWorld didn’t give a download link${detail ? `: ${detail}` : ''}`, tokenRejected: false }; } // Pull the license string out of the page data (e.g. "MakerWorld Exclusive License"). diff --git a/public/app.js b/public/app.js index 72da659..6b5075c 100644 --- a/public/app.js +++ b/public/app.js @@ -30,6 +30,7 @@ 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(); @@ -767,6 +768,8 @@ function setupEventListeners() { }); 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(); }); @@ -2055,13 +2058,83 @@ async function handleDownloadFiles() { 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(id, data.error); + 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); - btn.disabled = false; + 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 the download with it. +let tokenPromptModelId = null; + +function promptMakerworldToken(modelId, reason) { + tokenPromptModelId = modelId; + 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 id = tokenPromptModelId; + 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 downloading the files… 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 res = await fetch(`/api/models/${id}/download-files`, { method: 'POST' }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + fail(data.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 the download failed: ${data.error}`); + return; + } + status.textContent = data.added ? `Downloaded ${plural(data.added, 'file')}.` : 'Token saved. No new files were found.'; + await loadData(); + setTimeout(() => { + closeModal(tokenModal); + if (fileDetailModal.classList.contains('active') && fileDetailModal.dataset.currentModelId === String(id)) openModelDetail(id); + }, 900); + } catch (err) { + fail(`Your token is saved, but the download failed: ${err.message}`); } } @@ -2251,7 +2324,12 @@ async function handleImport(e) { const data = await res.json(); if (res.ok) { await loadData(); - if (data.metadataOnly) { + 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(data.id, `Imported “${data.name}”, but its files couldn’t be downloaded: ${data.filesError}`); + } else if (data.metadataOnly) { status.textContent = data.filesError ? `Imported "${data.name}", but its files couldn’t be downloaded: ${data.filesError}` : data.metadataFetched diff --git a/public/index.html b/public/index.html index 23dc9ce..31198e7 100644 --- a/public/index.html +++ b/public/index.html @@ -549,6 +549,36 @@ + +