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 d056310..a4f32e4 100644 --- a/lib/importers/makerworld.js +++ b/lib/importers/makerworld.js @@ -35,10 +35,21 @@ async function resolveDownload(flaresolverrUrl, instanceId, token) { }); } +// 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 { message: 'MakerWorld didn’t accept your auth token (it has probably expired).', tokenRejected: true }; + } + 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"). function parseLicense(html) { const m = html.match(/"license"\s*:\s*"([^"]+)"/); return m ? m[1] : null; } -module.exports = { parseInstances, resolveDownload, parseLicense }; +module.exports = { parseInstances, resolveDownload, downloadProblem, parseLicense }; diff --git a/public/app.js b/public/app.js index 56fd91a..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,11 +2324,19 @@ async function handleImport(e) { const data = await res.json(); if (res.ok) { await loadData(); - if (data.metadataOnly) { - status.textContent = 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…`; - setTimeout(() => { closeModal(importModal); openModelDetail(data.id); }, 1200); + 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 + ? `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); diff --git a/public/index.html b/public/index.html index 8487601..31198e7 100644 --- a/public/index.html +++ b/public/index.html @@ -320,8 +320,10 @@ placeholder="Bearer token from your logged-in session" autocomplete="off">

Status: —. - Lets Printhive download files from MakerWorld using your account. Paste the bearer - token from your logged-in browser session (see instructions in chat). + Lets Printhive download files from MakerWorld using your account. In a browser signed in to + makerworld.com, open the developer tools (Application / Storage → Cookies → makerworld.com) + and copy the value of the token cookie. It expires after a while: paste a new one if + MakerWorld downloads stop working.

@@ -547,6 +549,36 @@ + + +