diff --git a/backend/src/routes/imports.ts b/backend/src/routes/imports.ts index 4a44210..3f01775 100644 --- a/backend/src/routes/imports.ts +++ b/backend/src/routes/imports.ts @@ -11,9 +11,22 @@ import { downloadImportToTemp, importPrintFromUrl, inspectImportLink } from "../ import { resolveMakerworldCookie } from "../services/importResolvers"; import { extractMakerworldBearerToken } from "../services/makerworldCloudApi"; import { fetchMakerworldCollectionEntries, fetchMakerworldCollectionTitle, parseMakerworldCollectionUrl } from "../services/makerworldCollections"; +import { + fetchThingiverseCollectionThings, + fetchThingiverseCollectionTitle, + fetchThingiverseUserLikes, + parseThingiverseCollectionUrl, + parseThingiverseLikesUrl, +} from "../services/thingiverseApi"; +import { getThingiverseAccessToken } from "../services/settingsService"; import { listZipEntries } from "../services/zipService"; import { createJob, getActiveJob, getJob } from "../services/importJobService"; -import { runCollectionImportJob, runZipImportJob } from "../services/importJobRunner"; +import { + runCollectionImportJob, + runThingiverseCollectionImportJob, + runThingiverseLikesImportJob, + runZipImportJob, +} from "../services/importJobRunner"; import { toImportJobOut, toPrintOut } from "../dto"; const router = Router(); @@ -90,9 +103,66 @@ router.post( }), ); -// ---- Background batch imports (MakerWorld collection / remote zip) --------------------------- +router.post( + "/import/thingiverse-likes/entries", + asyncHandler(async (req, res) => { + const body = parseBody(importRequestSchema, req.body); + const url = await normalizeImportUrl(body.url); + const parsed = parseThingiverseLikesUrl(url); + if (!parsed) throw new HttpError(400, "Not a Thingiverse Likes URL"); + const accessToken = await getThingiverseAccessToken(); + if (!accessToken) { + throw new HttpError( + 503, + "Thingiverse import isn't configured for this instance yet -- ask an admin to add an Access Token in Admin Settings.", + ); + } + + const listing = await fetchThingiverseUserLikes(parsed.username, accessToken); + if (!listing.entries.length) throw new HttpError(400, "Could not load this user's likes -- check the username and try again"); + + res.json({ + title: `${parsed.username}'s Thingiverse Likes`, + total: listing.entries.length, + truncated: listing.truncated, + entries: listing.entries.map((e) => ({ design_id: e.thingId, title: e.title, cover: e.cover })), + }); + }), +); + +router.post( + "/import/thingiverse-collection/entries", + asyncHandler(async (req, res) => { + const body = parseBody(importRequestSchema, req.body); + const url = await normalizeImportUrl(body.url); + const parsed = parseThingiverseCollectionUrl(url); + if (!parsed) throw new HttpError(400, "Not a Thingiverse Collection URL"); + const accessToken = await getThingiverseAccessToken(); + if (!accessToken) { + throw new HttpError( + 503, + "Thingiverse import isn't configured for this instance yet -- ask an admin to add an Access Token in Admin Settings.", + ); + } + + const [title, listing] = await Promise.all([ + fetchThingiverseCollectionTitle(parsed.collectionId, accessToken), + fetchThingiverseCollectionThings(parsed.collectionId, accessToken), + ]); + if (!listing.entries.length) throw new HttpError(400, "Could not load this collection's models"); + + res.json({ + title, + total: listing.entries.length, + truncated: listing.truncated, + entries: listing.entries.map((e) => ({ design_id: e.thingId, title: e.title, cover: e.cover })), + }); + }), +); + +// ---- Background batch imports (MakerWorld/Thingiverse collections, Thingiverse Likes, zip) ---- // -// Both of these can involve downloading dozens to hundreds of files, which used to happen +// All three of these can involve downloading dozens to hundreds of files, which used to happen // synchronously inside the request -- long enough to run past reverse-proxy read timeouts with // no feedback. They now just register an ImportJob and return immediately; the actual work runs // in the background (importJobRunner.ts) and is polled via GET /import/jobs/:id. At most one @@ -122,6 +192,44 @@ router.post( }), ); +const thingiverseLikesImportRequestSchema = importRequestSchema.extend({ thing_ids: z.array(z.string()).min(1) }); + +router.post( + "/import/thingiverse-likes", + asyncHandler(async (req, res) => { + const body = parseBody(thingiverseLikesImportRequestSchema, req.body); + await assertNoActiveJob(req.userId!); + const url = await normalizeImportUrl(body.url); + const parsed = parseThingiverseLikesUrl(url); + if (!parsed) throw new HttpError(400, "Not a Thingiverse Likes URL"); + const job = await createJob(req.userId!, "COLLECTION", { + sourceUrl: url, + provider: "thingiverse", + total: body.thing_ids.length, + }); + void runThingiverseLikesImportJob(job.id, req.userId!, { ...body, url, username: parsed.username }); + res.status(202).json({ job_id: job.id }); + }), +); + +router.post( + "/import/thingiverse-collection", + asyncHandler(async (req, res) => { + const body = parseBody(thingiverseLikesImportRequestSchema, req.body); + await assertNoActiveJob(req.userId!); + const url = await normalizeImportUrl(body.url); + const parsed = parseThingiverseCollectionUrl(url); + if (!parsed) throw new HttpError(400, "Not a Thingiverse Collection URL"); + const job = await createJob(req.userId!, "COLLECTION", { + sourceUrl: url, + provider: "thingiverse", + total: body.thing_ids.length, + }); + void runThingiverseCollectionImportJob(job.id, req.userId!, { ...body, url, collectionId: parsed.collectionId }); + res.status(202).json({ job_id: job.id }); + }), +); + const zipExtractRequestSchema = importRequestSchema.extend({ entries: z.array(z.string()) }); router.post( diff --git a/backend/src/services/importJobRunner.ts b/backend/src/services/importJobRunner.ts index 91165c9..978c456 100644 --- a/backend/src/services/importJobRunner.ts +++ b/backend/src/services/importJobRunner.ts @@ -11,6 +11,8 @@ import { fetchMakerworldCollectionTitle, parseMakerworldCollectionUrl } from "./ import { downloadImportToTemp, importPrintFromUrl, type ImportRequestBody } from "./importService"; import { upsertAuthorFromImport } from "./authorService"; import { extractZipEntriesToPrints } from "./zipService"; +import { fetchThingiverseCollectionTitle } from "./thingiverseApi"; +import { getThingiverseAccessToken } from "./settingsService"; import { HttpError } from "../utils/fileUtils"; // Deliberately sequential (not a handful in parallel) with a pacing gap between requests -- @@ -24,6 +26,8 @@ const COLLECTION_IMPORT_CONCURRENCY = 1; type CollectionImportJobBody = ImportRequestBody & { design_ids: string[] }; type ZipImportJobBody = ImportRequestBody & { entries: string[] }; +type ThingiverseLikesImportJobBody = ImportRequestBody & { thing_ids: string[]; username: string }; +type ThingiverseCollectionImportJobBody = ImportRequestBody & { thing_ids: string[]; collectionId: string }; // Distinguishes *why* a single design failed, so a batch of many failures reads as one clear // cause instead of an opaque "N failed": @@ -151,6 +155,135 @@ export async function runCollectionImportJob(jobId: string, userId: string, body } } +/** Shared runner behind both Thingiverse batch import kinds (Likes and named Collections -- + * see the two exported wrappers below): resolves the Access Token once, imports every Thing id + * with the same pacing/concurrency as the MakerWorld collection job above (same reasoning: a + * burst of requests reads as automated traffic, worth avoiding even though Thingiverse's + * official API hasn't shown the same anti-abuse behavior MakerWorld's has), then files every + * successful import into `collectionTitle` (created on first use, reused on every later import + * that resolves to the same title -- e.g. every Likes import shares one "Thingiverse Likes" + * collection; a named Thingiverse Collection gets/reuses a PrintStash Collection of that same + * name). `resolveCollectionTitle` runs after the per-item loop (not before) so a Collection's + * real name -- an extra API call -- is only fetched once real work has actually happened. */ +async function runThingiverseThingsImportJob( + jobId: string, + userId: string, + body: ImportRequestBody & { thing_ids: string[] }, + resolveCollectionTitle: (accessToken: string) => Promise, + sourceLabel: (collectionTitle: string) => string, +): Promise { + try { + const accessToken = await getThingiverseAccessToken(); + if (!accessToken) { + throw new HttpError( + 503, + "Thingiverse import isn't configured for this instance yet -- ask an admin to add an Access Token in Admin Settings.", + ); + } + + let imported = 0; + let alreadyInLibrary = 0; + let processed = 0; + let unavailable = 0; + let authFailed = 0; + const failed: string[] = []; + const successPrintIds: string[] = []; + + await mapWithConcurrency(body.thing_ids, COLLECTION_IMPORT_CONCURRENCY, async (thingId, index) => { + const thingUrl = `https://www.thingiverse.com/thing:${thingId}`; + const itemBody: ImportRequestBody = { + url: thingUrl, + notes: body.notes ?? null, + tags: body.tags ?? [], + folder_id: body.folder_id ?? null, + }; + try { + const { print, alreadyImported } = await importPrintFromUrl(userId, thingUrl, itemBody); + successPrintIds.push(print.id); + if (alreadyImported) alreadyInLibrary++; + else imported++; + } catch (err) { + failed.push(thingId); + const reason = classifyImportFailure(err); + if (reason === "unavailable") unavailable++; + else if (reason === "auth") authFailed++; + } finally { + processed++; + await updateJob(jobId, { processed, imported, alreadyInLibrary, failedCount: failed.length }).catch(() => undefined); + } + if (index < body.thing_ids.length - 1) await sleep(IMPORT_COLLECTION_DELAY_MS); + }); + + let resultCollectionId: string | null = null; + const collectionTitle = await resolveCollectionTitle(accessToken); + if (successPrintIds.length) { + const collection = await findOrCreateCollectionByName(userId, collectionTitle); + await addPrintsToCollection(collection.id, successPrintIds); + resultCollectionId = collection.id; + } + + await updateJob(jobId, { + status: "DONE", + sourceLabel: collectionTitle, + resultCollectionId, + processed, + imported, + alreadyInLibrary, + failedCount: failed.length, + }); + + const bodyParts: string[] = []; + if (alreadyInLibrary) bodyParts.push(`${alreadyInLibrary} already in your library`); + const otherFailed = failed.length - unavailable - authFailed; + if (unavailable) bodyParts.push(`${unavailable} unavailable (private, deleted, or hidden)`); + if (authFailed) bodyParts.push(`${authFailed} failed because the configured Access Token was rejected`); + if (otherFailed) bodyParts.push(`${otherFailed} failed`); + const label = sourceLabel(collectionTitle); + await createNotification(userId, { + title: `Imported ${imported} of ${body.thing_ids.length} models from Thingiverse`, + body: bodyParts.length ? `From ${label} — ${bodyParts.join(", ")}.` : `From ${label}.`, + externalUrl: body.url, + internalPath: resultCollectionId ? `/models/collections/${resultCollectionId}` : null, + }); + } catch (err) { + await markJobFailed(jobId, err); + } +} + +/** Runs a Thingiverse user's automatic "Likes" batch import in the background -- see + * routes/imports.ts's POST /import/thingiverse-likes. Every successfully imported Thing lands in + * a shared "Thingiverse Likes" collection (one bucket for "things liked on Thingiverse", reused + * across every user's likes import -- deliberately, matching how the feature was asked for). */ +export async function runThingiverseLikesImportJob(jobId: string, userId: string, body: ThingiverseLikesImportJobBody): Promise { + await runThingiverseThingsImportJob( + jobId, + userId, + body, + async () => "Thingiverse Likes", + () => `@${body.username}'s Likes`, + ); +} + +/** Runs a named Thingiverse Collection's batch import in the background -- see + * routes/imports.ts's POST /import/thingiverse-collection. Unlike Likes, a Collection has a real + * user-given name (fetchThingiverseCollectionTitle) -- successful imports land in a PrintStash + * Collection of that same name, created on first use and reused if the same Thingiverse + * Collection is ever imported again. */ +export async function runThingiverseCollectionImportJob( + jobId: string, + userId: string, + body: ThingiverseCollectionImportJobBody, +): Promise { + await runThingiverseThingsImportJob( + jobId, + userId, + body, + async (accessToken) => + (await fetchThingiverseCollectionTitle(body.collectionId, accessToken)) ?? `Thingiverse Collection ${body.collectionId}`, + (collectionTitle) => `"${collectionTitle}"`, + ); +} + /** Runs a remote zip's selected-entries batch import in the background -- see * routes/imports.ts's POST /import/zip. */ export async function runZipImportJob(jobId: string, userId: string, body: ZipImportJobBody): Promise { diff --git a/backend/src/services/thingiverseApi.ts b/backend/src/services/thingiverseApi.ts index d9e06d1..2c0b9c6 100644 --- a/backend/src/services/thingiverseApi.ts +++ b/backend/src/services/thingiverseApi.ts @@ -42,6 +42,22 @@ export function parseThingiverseThingUrl(url: string): { thingId: string } | nul return m2 ? { thingId: m2[1] } : null; } +/** A user's own "Likes" page (`thingiverse.com/{username}/likes`) is how a lot of people keep a + * personal collection of prints worth making -- the site's own bookmark/save mechanism. Distinct + * from parseThingiverseThingUrl above: this identifies the *listing* page, not a single Thing. */ +export function parseThingiverseLikesUrl(url: string): { username: string } | null { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return null; + } + const host = parsed.hostname.toLowerCase(); + if (host !== "thingiverse.com" && host !== "www.thingiverse.com") return null; + const m = parsed.pathname.match(/^\/([^/]+)\/likes\/?$/i); + return m ? { username: m[1] } : null; +} + async function fetchThingiverseApiJson(path: string, accessToken: string): Promise { const url = `${THINGIVERSE_API_BASE}${path}${path.includes("?") ? "&" : "?"}access_token=${encodeURIComponent(accessToken)}`; const controller = new AbortController(); @@ -166,3 +182,92 @@ export async function resolveThingiverseThing(thingId: string, accessToken: stri return { meta, plateFiles, galleryImages }; } + +export type ThingiverseThingSummary = { thingId: string; title: string; cover: string | null }; + +const LISTING_PAGE_SIZE = 30; +const LISTING_MAX_ENTRIES = 300; + +/** Shared pager for any api.thingiverse.com endpoint that returns a plain JSON array of Thing + * summaries, page by page (`GET .../likes`, `GET /collections/{id}/things`, ... -- confirmed + * identical item shape for both live). Stops on a short/empty page or maxItems, whichever comes + * first; maxItems is a safety cap against a pathological/huge list, not a UX limit, mirroring + * fetchMakerworldCollectionEntries's same cap for MakerWorld collections. List entries carry no + * zip_data/categories/description -- that's fetched per-item at actual import time via + * resolveThingiverseThing, same as it already is for a single pasted Thing URL. */ +async function paginateThingiverseThings( + pathForPage: (page: number) => string, + accessToken: string, + maxItems: number, +): Promise<{ entries: ThingiverseThingSummary[]; truncated: boolean }> { + const entries: ThingiverseThingSummary[] = []; + let page = 1; + for (;;) { + const data = await fetchThingiverseApiJson(pathForPage(page), accessToken); + if (!Array.isArray(data) || !data.length) break; + for (const item of data) { + if (!isRecord(item) || item.id == null) continue; + const name = typeof item.name === "string" && item.name.trim() ? item.name.trim() : `Thing ${item.id}`; + const cover = + (typeof item.thumbnail === "string" && item.thumbnail.trim() && item.thumbnail) || + (typeof item.preview_image === "string" && item.preview_image.trim() && item.preview_image) || + null; + entries.push({ thingId: String(item.id), title: name, cover }); + if (entries.length >= maxItems) break; + } + if (data.length < LISTING_PAGE_SIZE || entries.length >= maxItems) break; + page += 1; + } + return { entries, truncated: entries.length >= maxItems }; +} + +export async function fetchThingiverseUserLikes( + username: string, + accessToken: string, + maxItems: number = LISTING_MAX_ENTRIES, +): Promise<{ entries: ThingiverseThingSummary[]; truncated: boolean }> { + return paginateThingiverseThings( + (page) => `/users/${encodeURIComponent(username)}/likes?page=${page}&per_page=${LISTING_PAGE_SIZE}`, + accessToken, + maxItems, + ); +} + +/** A user-curated, named "Collection" -- the site's other bookmark mechanism besides the + * automatic per-account "Likes" list (see fetchThingiverseUserLikes above). Unlike Likes, a + * Collection has a real user-given name (fetchThingiverseCollectionTitle below), which is what + * lets the import land in a PrintStash Collection named after it instead of a generic bucket. */ +export async function fetchThingiverseCollectionThings( + collectionId: string, + accessToken: string, + maxItems: number = LISTING_MAX_ENTRIES, +): Promise<{ entries: ThingiverseThingSummary[]; truncated: boolean }> { + return paginateThingiverseThings( + (page) => `/collections/${encodeURIComponent(collectionId)}/things?page=${page}&per_page=${LISTING_PAGE_SIZE}`, + accessToken, + maxItems, + ); +} + +export async function fetchThingiverseCollectionTitle(collectionId: string, accessToken: string): Promise { + const data = await fetchThingiverseApiJson(`/collections/${encodeURIComponent(collectionId)}`, accessToken); + return isRecord(data) && typeof data.name === "string" && data.name.trim() ? data.name.trim() : null; +} + +/** A user's own Collection page (`thingiverse.com/{username}/collections/{id}` or + * `.../collections/{id}/things`) -- distinct from parseThingiverseLikesUrl (the automatic Likes + * list) and parseThingiverseThingUrl (a single Thing). Only the numeric id is needed for the API + * calls above; the username in the URL is cosmetic (Thingiverse doesn't validate it matches the + * collection's actual owner when resolving by id). */ +export function parseThingiverseCollectionUrl(url: string): { collectionId: string } | null { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return null; + } + const host = parsed.hostname.toLowerCase(); + if (host !== "thingiverse.com" && host !== "www.thingiverse.com") return null; + const m = parsed.pathname.match(/\/collections\/(\d+)/i); + return m ? { collectionId: m[1] } : null; +} diff --git a/backend/tests/importJobRunner.test.ts b/backend/tests/importJobRunner.test.ts index 8400632..19b62b1 100644 --- a/backend/tests/importJobRunner.test.ts +++ b/backend/tests/importJobRunner.test.ts @@ -1,4 +1,4 @@ -import { beforeAll, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import request from "supertest"; import { createApp } from "../src/app"; import { createJob, getJob } from "../src/services/importJobService"; @@ -21,8 +21,22 @@ vi.mock("../src/config", async (importOriginal) => { return { ...actual, IMPORT_COLLECTION_DELAY_MS: 0 }; }); +// runThingiverseCollectionImportJob resolves the real Collection name over the network -- mock +// just that one export so the collection-naming behavior can be asserted deterministically. +vi.mock("../src/services/thingiverseApi", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, fetchThingiverseCollectionTitle: vi.fn() }; +}); + import { importPrintFromUrl } from "../src/services/importService"; -import { runCollectionImportJob } from "../src/services/importJobRunner"; +import { + runCollectionImportJob, + runThingiverseCollectionImportJob, + runThingiverseLikesImportJob, +} from "../src/services/importJobRunner"; +import { fetchThingiverseCollectionTitle } from "../src/services/thingiverseApi"; +import { setThingiverseAccessToken } from "../src/services/settingsService"; +import { prisma } from "../src/db"; const app = createApp(); let userId: string; @@ -222,3 +236,227 @@ describe("runCollectionImportJob", () => { ); }); }); + +describe("runThingiverseLikesImportJob", () => { + afterEach(async () => { + await setThingiverseAccessToken(null); + }); + + it("fails clearly when no Access Token is configured, without touching the job's other bookkeeping", async () => { + const job = await createJob(userId, "COLLECTION", { + sourceUrl: "https://www.thingiverse.com/someuser/likes", + provider: "thingiverse", + total: 1, + }); + + await runThingiverseLikesImportJob(job.id, userId, { + url: "https://www.thingiverse.com/someuser/likes", + username: "someuser", + thing_ids: ["1"], + tags: [], + }); + + const finished = await getJob(job.id, userId); + expect(finished?.status).toBe("ERROR"); + expect(finished?.errorMessage).toMatch(/isn't configured/i); + }); + + it("imports every liked Thing into a shared 'Thingiverse Likes' collection, one at a time with pacing", async () => { + await setThingiverseAccessToken("test-token"); + let active = 0; + let maxActive = 0; + const mockedImport = vi.mocked(importPrintFromUrl); + mockedImport.mockImplementation(async (_userId: string, url: string) => { + active++; + maxActive = Math.max(maxActive, active); + await new Promise((resolve) => setTimeout(resolve, 10)); + active--; + // addPrintsToCollection enforces a real FK to Print, so the collection-linking half of + // this test needs an actual row, not just a fabricated id string. + const print = await prisma.print.create({ + data: { userId, name: `Liked ${url}`, nameNormalized: `liked ${url}`.toLowerCase() }, + }); + return { + print, + plates: [], + author: null, + previewImages: [], + alreadyImported: false, + }; + }); + + const job = await createJob(userId, "COLLECTION", { + sourceUrl: "https://www.thingiverse.com/someuser/likes", + provider: "thingiverse", + total: 3, + }); + + await runThingiverseLikesImportJob(job.id, userId, { + url: "https://www.thingiverse.com/someuser/likes", + username: "someuser", + thing_ids: ["101", "102", "103"], + tags: [], + }); + + expect(maxActive).toBe(1); + const finished = await getJob(job.id, userId); + expect(finished?.status).toBe("DONE"); + expect(finished?.imported).toBe(3); + expect(finished?.resultCollectionId).toBeTruthy(); + + const collection = await prisma.collection.findFirst({ where: { userId, name: "Thingiverse Likes" } }); + expect(collection?.id).toBe(finished?.resultCollectionId); + const itemCount = await prisma.collectionItem.count({ where: { collectionId: collection!.id } }); + expect(itemCount).toBe(3); + + const { items } = await listNotifications(userId); + const notification = items.find((n) => n.title === "Imported 3 of 3 models from Thingiverse"); + expect(notification).toBeTruthy(); + expect(notification!.body).toBe("From @someuser's Likes."); + }); + + it("reuses the same 'Thingiverse Likes' collection across separate likes imports", async () => { + await setThingiverseAccessToken("test-token"); + const mockedImport = vi.mocked(importPrintFromUrl); + mockedImport.mockImplementation(async (_userId: string, url: string) => ({ + print: await prisma.print.create({ + data: { userId, name: `Liked again ${url}`, nameNormalized: `liked again ${url}`.toLowerCase() }, + }), + plates: [], + author: null, + previewImages: [], + alreadyImported: false, + })); + + const job1 = await createJob(userId, "COLLECTION", { + sourceUrl: "https://www.thingiverse.com/userA/likes", + provider: "thingiverse", + total: 1, + }); + await runThingiverseLikesImportJob(job1.id, userId, { + url: "https://www.thingiverse.com/userA/likes", + username: "userA", + thing_ids: ["201"], + tags: [], + }); + + const job2 = await createJob(userId, "COLLECTION", { + sourceUrl: "https://www.thingiverse.com/userB/likes", + provider: "thingiverse", + total: 1, + }); + await runThingiverseLikesImportJob(job2.id, userId, { + url: "https://www.thingiverse.com/userB/likes", + username: "userB", + thing_ids: ["202"], + tags: [], + }); + + const finished1 = await getJob(job1.id, userId); + const finished2 = await getJob(job2.id, userId); + expect(finished1?.resultCollectionId).toBe(finished2?.resultCollectionId); + + const collections = await prisma.collection.findMany({ where: { userId, name: "Thingiverse Likes" } }); + expect(collections.length).toBe(1); + }); +}); + +describe("runThingiverseCollectionImportJob", () => { + afterEach(async () => { + await setThingiverseAccessToken(null); + vi.mocked(fetchThingiverseCollectionTitle).mockReset(); + }); + + it("files successful imports into a PrintStash Collection named after the real Thingiverse Collection", async () => { + await setThingiverseAccessToken("test-token"); + vi.mocked(fetchThingiverseCollectionTitle).mockResolvedValue("Things to Make"); + const mockedImport = vi.mocked(importPrintFromUrl); + mockedImport.mockImplementation(async (_userId: string, url: string) => ({ + print: await prisma.print.create({ + data: { userId, name: `Collected ${url}`, nameNormalized: `collected ${url}`.toLowerCase() }, + }), + plates: [], + author: null, + previewImages: [], + alreadyImported: false, + })); + + const job = await createJob(userId, "COLLECTION", { + sourceUrl: "https://www.thingiverse.com/Derzinskas/collections/40781574/things", + provider: "thingiverse", + total: 2, + }); + + await runThingiverseCollectionImportJob(job.id, userId, { + url: "https://www.thingiverse.com/Derzinskas/collections/40781574/things", + collectionId: "40781574", + thing_ids: ["301", "302"], + tags: [], + }); + + const finished = await getJob(job.id, userId); + expect(finished?.status).toBe("DONE"); + expect(finished?.imported).toBe(2); + expect(finished?.sourceLabel).toBe("Things to Make"); + + const collection = await prisma.collection.findFirst({ where: { userId, name: "Things to Make" } }); + expect(collection?.id).toBe(finished?.resultCollectionId); + + const { items } = await listNotifications(userId); + const notification = items.find((n) => n.title === "Imported 2 of 2 models from Thingiverse"); + expect(notification).toBeTruthy(); + expect(notification!.body).toBe('From "Things to Make".'); + }); + + it("falls back to a generic name when the Collection's real name can't be resolved", async () => { + await setThingiverseAccessToken("test-token"); + vi.mocked(fetchThingiverseCollectionTitle).mockResolvedValue(null); + const mockedImport = vi.mocked(importPrintFromUrl); + mockedImport.mockImplementation(async (_userId: string, url: string) => ({ + print: await prisma.print.create({ + data: { userId, name: `Collected fallback ${url}`, nameNormalized: `collected fallback ${url}`.toLowerCase() }, + }), + plates: [], + author: null, + previewImages: [], + alreadyImported: false, + })); + + const job = await createJob(userId, "COLLECTION", { + sourceUrl: "https://www.thingiverse.com/someone/collections/40781575/things", + provider: "thingiverse", + total: 1, + }); + + await runThingiverseCollectionImportJob(job.id, userId, { + url: "https://www.thingiverse.com/someone/collections/40781575/things", + collectionId: "40781575", + thing_ids: ["401"], + tags: [], + }); + + const finished = await getJob(job.id, userId); + expect(finished?.status).toBe("DONE"); + expect(finished?.sourceLabel).toBe("Thingiverse Collection 40781575"); + }); + + it("fails clearly when no Access Token is configured", async () => { + const job = await createJob(userId, "COLLECTION", { + sourceUrl: "https://www.thingiverse.com/someone/collections/1/things", + provider: "thingiverse", + total: 1, + }); + + await runThingiverseCollectionImportJob(job.id, userId, { + url: "https://www.thingiverse.com/someone/collections/1/things", + collectionId: "1", + thing_ids: ["1"], + tags: [], + }); + + const finished = await getJob(job.id, userId); + expect(finished?.status).toBe("ERROR"); + expect(finished?.errorMessage).toMatch(/isn't configured/i); + expect(fetchThingiverseCollectionTitle).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/tests/thingiverseCollection.test.ts b/backend/tests/thingiverseCollection.test.ts new file mode 100644 index 0000000..32ee4f1 --- /dev/null +++ b/backend/tests/thingiverseCollection.test.ts @@ -0,0 +1,138 @@ +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import request from "supertest"; +import { createApp } from "../src/app"; +import { + fetchThingiverseCollectionThings, + fetchThingiverseCollectionTitle, + parseThingiverseCollectionUrl, +} from "../src/services/thingiverseApi"; +import { setThingiverseAccessToken } from "../src/services/settingsService"; + +const ACCESS_TOKEN = "test-access-token"; +const COLLECTION_ID = "40781574"; +const COLLECTION_TITLE = "Things to Make"; + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); +} + +function thingEntry(id: number) { + return { id, name: `Collected Thing ${id}`, thumbnail: `https://cdn.thingiverse.com/assets/test/${id}.jpg` }; +} + +describe("parseThingiverseCollectionUrl", () => { + it("recognizes a Collection page, with or without the trailing /things", () => { + expect(parseThingiverseCollectionUrl(`https://www.thingiverse.com/Derzinskas/collections/${COLLECTION_ID}/things`)).toEqual({ + collectionId: COLLECTION_ID, + }); + expect(parseThingiverseCollectionUrl(`https://www.thingiverse.com/Derzinskas/collections/${COLLECTION_ID}`)).toEqual({ + collectionId: COLLECTION_ID, + }); + }); + + it("rejects a Likes page, a single Thing, and unrelated hosts", () => { + expect(parseThingiverseCollectionUrl("https://www.thingiverse.com/Derzinskas/likes")).toBeNull(); + expect(parseThingiverseCollectionUrl("https://www.thingiverse.com/thing:763622")).toBeNull(); + expect(parseThingiverseCollectionUrl(`https://example.com/Derzinskas/collections/${COLLECTION_ID}`)).toBeNull(); + }); +}); + +describe("fetchThingiverseCollectionThings / fetchThingiverseCollectionTitle", () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it("lists a collection's Things and reads its real name", async () => { + global.fetch = vi.fn<(input: RequestInfo | URL) => Promise>(async (input) => { + const url = String(input); + if (url.startsWith(`https://api.thingiverse.com/collections/${COLLECTION_ID}/things`)) { + return jsonResponse(200, [thingEntry(1), thingEntry(2)]); + } + if (url.startsWith(`https://api.thingiverse.com/collections/${COLLECTION_ID}?`)) { + return jsonResponse(200, { id: Number(COLLECTION_ID), name: COLLECTION_TITLE }); + } + throw new Error(`Unexpected fetch to ${url}`); + }) as unknown as typeof fetch; + + const [title, listing] = await Promise.all([ + fetchThingiverseCollectionTitle(COLLECTION_ID, ACCESS_TOKEN), + fetchThingiverseCollectionThings(COLLECTION_ID, ACCESS_TOKEN), + ]); + + expect(title).toBe(COLLECTION_TITLE); + expect(listing.entries).toEqual([ + { thingId: "1", title: "Collected Thing 1", cover: "https://cdn.thingiverse.com/assets/test/1.jpg" }, + { thingId: "2", title: "Collected Thing 2", cover: "https://cdn.thingiverse.com/assets/test/2.jpg" }, + ]); + }); + + it("returns null for a collection with no name (or that doesn't exist)", async () => { + global.fetch = vi.fn<(input: RequestInfo | URL) => Promise>(async () => jsonResponse(404, { error: "not found" })) as unknown as typeof fetch; + const title = await fetchThingiverseCollectionTitle("999999999", ACCESS_TOKEN); + expect(title).toBeNull(); + }); +}); + +describe("POST /import/thingiverse-collection/entries", () => { + const app = createApp(); + let token: string; + + const originalFetch = global.fetch; + + beforeAll(async () => { + const email = `thingiverse-collection-route-test-${Date.now()}@example.com`; + const res = await request(app) + .post("/api/register") + .send({ displayName: "Collection Route Test", email, password: "password123" }); + if (res.status !== 200) { + throw new Error(`Failed to register during test setup: ${res.status} ${JSON.stringify(res.body)}`); + } + token = res.body.token; + }); + + afterEach(async () => { + global.fetch = originalFetch; + vi.restoreAllMocks(); + await setThingiverseAccessToken(null); + }); + + it("lists a collection's Things as importable entries, with the real collection name as title", async () => { + await setThingiverseAccessToken(ACCESS_TOKEN); + global.fetch = vi.fn<(input: RequestInfo | URL) => Promise>(async (input) => { + const url = String(input); + if (url.includes("/things?")) return jsonResponse(200, [thingEntry(1)]); + return jsonResponse(200, { id: Number(COLLECTION_ID), name: COLLECTION_TITLE }); + }) as unknown as typeof fetch; + + const res = await request(app) + .post("/api/import/thingiverse-collection/entries") + .set("Authorization", `Bearer ${token}`) + .send({ url: `https://www.thingiverse.com/Derzinskas/collections/${COLLECTION_ID}/things`, tags: [] }); + + expect(res.status).toBe(200); + expect(res.body.title).toBe(COLLECTION_TITLE); + expect(res.body.entries).toEqual([ + { design_id: "1", title: "Collected Thing 1", cover: "https://cdn.thingiverse.com/assets/test/1.jpg" }, + ]); + }); + + it("rejects a URL that isn't a Thingiverse Collection page", async () => { + await setThingiverseAccessToken(ACCESS_TOKEN); + const res = await request(app) + .post("/api/import/thingiverse-collection/entries") + .set("Authorization", `Bearer ${token}`) + .send({ url: "https://www.thingiverse.com/Derzinskas/likes", tags: [] }); + expect(res.status).toBe(400); + }); + + it("fails with a clear 503 when no Access Token is configured", async () => { + const res = await request(app) + .post("/api/import/thingiverse-collection/entries") + .set("Authorization", `Bearer ${token}`) + .send({ url: `https://www.thingiverse.com/Derzinskas/collections/${COLLECTION_ID}/things`, tags: [] }); + expect(res.status).toBe(503); + }); +}); diff --git a/backend/tests/thingiverseLikes.test.ts b/backend/tests/thingiverseLikes.test.ts new file mode 100644 index 0000000..c989c6f --- /dev/null +++ b/backend/tests/thingiverseLikes.test.ts @@ -0,0 +1,131 @@ +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import request from "supertest"; +import { createApp } from "../src/app"; +import { fetchThingiverseUserLikes, parseThingiverseLikesUrl } from "../src/services/thingiverseApi"; +import { setThingiverseAccessToken } from "../src/services/settingsService"; + +const ACCESS_TOKEN = "test-access-token"; +const USERNAME = "Derzinskas"; + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); +} + +function likeEntry(id: number) { + return { id, name: `Liked Thing ${id}`, thumbnail: `https://cdn.thingiverse.com/assets/test/${id}.jpg` }; +} + +describe("parseThingiverseLikesUrl", () => { + it("recognizes a user's Likes page", () => { + expect(parseThingiverseLikesUrl("https://www.thingiverse.com/Derzinskas/likes")).toEqual({ username: "Derzinskas" }); + expect(parseThingiverseLikesUrl("https://thingiverse.com/someone/likes/")).toEqual({ username: "someone" }); + }); + + it("rejects a single Thing URL and unrelated hosts", () => { + expect(parseThingiverseLikesUrl("https://www.thingiverse.com/thing:763622")).toBeNull(); + expect(parseThingiverseLikesUrl("https://example.com/someone/likes")).toBeNull(); + }); +}); + +describe("fetchThingiverseUserLikes", () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it("pages through multiple pages until a short page signals the end", async () => { + global.fetch = vi.fn<(input: RequestInfo | URL) => Promise>(async (input) => { + const url = String(input); + const pageMatch = url.match(/page=(\d+)/); + const page = pageMatch ? Number(pageMatch[1]) : 1; + if (page === 1) return jsonResponse(200, Array.from({ length: 30 }, (_, i) => likeEntry(i + 1))); + if (page === 2) return jsonResponse(200, Array.from({ length: 5 }, (_, i) => likeEntry(31 + i))); + throw new Error(`Unexpected page ${page}`); + }) as unknown as typeof fetch; + + const result = await fetchThingiverseUserLikes(USERNAME, ACCESS_TOKEN); + expect(result.entries.length).toBe(35); + expect(result.truncated).toBe(false); + expect(result.entries[0]).toEqual({ thingId: "1", title: "Liked Thing 1", cover: "https://cdn.thingiverse.com/assets/test/1.jpg" }); + }); + + it("stops at maxItems and reports truncated", async () => { + global.fetch = vi.fn<(input: RequestInfo | URL) => Promise>(async () => + jsonResponse(200, Array.from({ length: 30 }, (_, i) => likeEntry(i + 1))), + ) as unknown as typeof fetch; + + const result = await fetchThingiverseUserLikes(USERNAME, ACCESS_TOKEN, 10); + expect(result.entries.length).toBe(10); + expect(result.truncated).toBe(true); + }); + + it("returns an empty result for a user with no likes (or who doesn't exist)", async () => { + global.fetch = vi.fn<(input: RequestInfo | URL) => Promise>(async () => jsonResponse(200, [])) as unknown as typeof fetch; + + const result = await fetchThingiverseUserLikes(USERNAME, ACCESS_TOKEN); + expect(result.entries).toEqual([]); + expect(result.truncated).toBe(false); + }); +}); + +describe("POST /import/thingiverse-likes/entries", () => { + const app = createApp(); + let token: string; + const originalFetch = global.fetch; + + beforeAll(async () => { + const email = `thingiverse-likes-route-test-${Date.now()}@example.com`; + const res = await request(app) + .post("/api/register") + .send({ displayName: "Likes Route Test", email, password: "password123" }); + if (res.status !== 200) { + throw new Error(`Failed to register during test setup: ${res.status} ${JSON.stringify(res.body)}`); + } + token = res.body.token; + }); + + afterEach(async () => { + global.fetch = originalFetch; + vi.restoreAllMocks(); + await setThingiverseAccessToken(null); + }); + + it("fails with a clear 503 when no Access Token is configured", async () => { + const res = await request(app) + .post("/api/import/thingiverse-likes/entries") + .set("Authorization", `Bearer ${token}`) + .send({ url: `https://www.thingiverse.com/${USERNAME}/likes`, tags: [] }); + expect(res.status).toBe(503); + expect(res.body.detail).toMatch(/isn't configured/i); + }); + + it("lists a user's liked Things as importable entries", async () => { + await setThingiverseAccessToken(ACCESS_TOKEN); + global.fetch = vi.fn<(input: RequestInfo | URL) => Promise>(async () => + jsonResponse(200, [likeEntry(1), likeEntry(2)]), + ) as unknown as typeof fetch; + + const res = await request(app) + .post("/api/import/thingiverse-likes/entries") + .set("Authorization", `Bearer ${token}`) + .send({ url: `https://www.thingiverse.com/${USERNAME}/likes`, tags: [] }); + + expect(res.status).toBe(200); + expect(res.body.title).toBe(`${USERNAME}'s Thingiverse Likes`); + expect(res.body.entries).toEqual([ + { design_id: "1", title: "Liked Thing 1", cover: "https://cdn.thingiverse.com/assets/test/1.jpg" }, + { design_id: "2", title: "Liked Thing 2", cover: "https://cdn.thingiverse.com/assets/test/2.jpg" }, + ]); + }); + + it("rejects a URL that isn't a Thingiverse Likes page", async () => { + await setThingiverseAccessToken(ACCESS_TOKEN); + const res = await request(app) + .post("/api/import/thingiverse-likes/entries") + .set("Authorization", `Bearer ${token}`) + .send({ url: "https://www.thingiverse.com/thing:763622", tags: [] }); + expect(res.status).toBe(400); + }); +}); diff --git a/frontend/src/api/imports.ts b/frontend/src/api/imports.ts index 1da897a..0174f7e 100644 --- a/frontend/src/api/imports.ts +++ b/frontend/src/api/imports.ts @@ -164,6 +164,77 @@ export const importsApi = { return res.json(); }, + listThingiverseLikesEntries: async (payload: ImportLinkPayload): Promise => { + const res = await fetch(`${apiBase()}/import/thingiverse-likes/entries`, { + method: "POST", + headers: authHeaders({ "Content-Type": "application/json" }), + body: JSON.stringify(payload), + }); + if (res.status === 401) { + throw new UnauthorizedError(); + } + if (!res.ok) { + const message = await readErrorMessage(res, "Could not load this user's likes"); + throw new Error(message); + } + return res.json(); + }, + + /** Registers a background job for the selected Things and returns immediately -- see + * ImportJobContext.startThingiverseLikesImport, which follows up with the actual polling. + * Every successful import lands in a shared "Thingiverse Likes" collection. */ + fromThingiverseLikes: async (payload: ImportLinkPayload & { thing_ids: string[] }): Promise<{ job_id: string }> => { + const res = await fetch(`${apiBase()}/import/thingiverse-likes`, { + method: "POST", + headers: authHeaders({ "Content-Type": "application/json" }), + body: JSON.stringify(payload), + }); + if (res.status === 401) { + throw new UnauthorizedError(); + } + if (!res.ok) { + const message = await readErrorMessage(res, "Thingiverse Likes import failed"); + throw new Error(message); + } + return res.json(); + }, + + listThingiverseCollectionEntries: async (payload: ImportLinkPayload): Promise => { + const res = await fetch(`${apiBase()}/import/thingiverse-collection/entries`, { + method: "POST", + headers: authHeaders({ "Content-Type": "application/json" }), + body: JSON.stringify(payload), + }); + if (res.status === 401) { + throw new UnauthorizedError(); + } + if (!res.ok) { + const message = await readErrorMessage(res, "Could not load this collection"); + throw new Error(message); + } + return res.json(); + }, + + /** Registers a background job for the selected Things and returns immediately -- see + * ImportJobContext.startThingiverseCollectionImport, which follows up with the actual + * polling. Every successful import lands in a PrintStash Collection named after the real + * Thingiverse Collection name. */ + fromThingiverseCollection: async (payload: ImportLinkPayload & { thing_ids: string[] }): Promise<{ job_id: string }> => { + const res = await fetch(`${apiBase()}/import/thingiverse-collection`, { + method: "POST", + headers: authHeaders({ "Content-Type": "application/json" }), + body: JSON.stringify(payload), + }); + if (res.status === 401) { + throw new UnauthorizedError(); + } + if (!res.ok) { + const message = await readErrorMessage(res, "Thingiverse Collection import failed"); + throw new Error(message); + } + return res.json(); + }, + getActiveImportJob: async (): Promise => { const res = await fetch(`${apiBase()}/import/jobs/active`, { headers: authHeaders() }); if (res.status === 401) throw new UnauthorizedError(); diff --git a/frontend/src/components/Layout/ImportJobContext.tsx b/frontend/src/components/Layout/ImportJobContext.tsx index e5127d8..614d745 100644 --- a/frontend/src/components/Layout/ImportJobContext.tsx +++ b/frontend/src/components/Layout/ImportJobContext.tsx @@ -5,14 +5,19 @@ import { UnauthorizedError } from "../../api/client"; type StartCollectionImportPayload = Parameters[0]; type StartZipImportPayload = Parameters[0]; +type StartThingiverseLikesImportPayload = Parameters[0]; +type StartThingiverseCollectionImportPayload = Parameters[0]; type ImportJobContextValue = { - /** Non-null exactly while a batch import (MakerWorld collection or remote-zip) is running -- - * drives the global progress bar and disables the Add/Import/Upload menu app-wide. */ + /** Non-null exactly while a batch import (MakerWorld collection, a Thingiverse Collection or + * Likes list, or remote-zip) is running -- drives the global progress bar and disables the + * Add/Import/Upload menu app-wide. */ activeJob: ImportJob | null; isImporting: boolean; startCollectionImport: (payload: StartCollectionImportPayload) => Promise; startZipImport: (payload: StartZipImportPayload) => Promise; + startThingiverseLikesImport: (payload: StartThingiverseLikesImportPayload) => Promise; + startThingiverseCollectionImport: (payload: StartThingiverseCollectionImportPayload) => Promise; }; const ImportJobContext = createContext(null); @@ -95,9 +100,30 @@ export function ImportJobProvider({ startPolling(job_id); }, [startPolling]); + const startThingiverseLikesImport = useCallback(async (payload: StartThingiverseLikesImportPayload) => { + const { job_id } = await importsApi.fromThingiverseLikes(payload); + const job = await importsApi.getImportJob(job_id); + setActiveJob(job); + startPolling(job_id); + }, [startPolling]); + + const startThingiverseCollectionImport = useCallback(async (payload: StartThingiverseCollectionImportPayload) => { + const { job_id } = await importsApi.fromThingiverseCollection(payload); + const job = await importsApi.getImportJob(job_id); + setActiveJob(job); + startPolling(job_id); + }, [startPolling]); + const value = useMemo( - () => ({ activeJob, isImporting: activeJob !== null, startCollectionImport, startZipImport }), - [activeJob, startCollectionImport, startZipImport], + () => ({ + activeJob, + isImporting: activeJob !== null, + startCollectionImport, + startZipImport, + startThingiverseLikesImport, + startThingiverseCollectionImport, + }), + [activeJob, startCollectionImport, startZipImport, startThingiverseLikesImport, startThingiverseCollectionImport], ); return ( diff --git a/frontend/src/components/uploads/useUploadImport.tsx b/frontend/src/components/uploads/useUploadImport.tsx index 8c84520..69a97bd 100644 --- a/frontend/src/components/uploads/useUploadImport.tsx +++ b/frontend/src/components/uploads/useUploadImport.tsx @@ -38,6 +38,35 @@ function isThingiverseThingUrl(url: string): boolean { } } +/** A Thingiverse user's own "Likes" page (`thingiverse.com/{username}/likes`) -- the site's own + * bookmark/save mechanism many people use to collect prints worth making. Lists many Things + * rather than being one Thing page, so route it to the same collection picker MakerWorld + * collections use. */ +function isThingiverseLikesUrl(url: string): boolean { + try { + const parsed = new URL(url.includes("://") ? url : `https://${url}`); + const host = parsed.hostname.toLowerCase(); + if (host !== "thingiverse.com" && host !== "www.thingiverse.com") return false; + return /^\/[^/]+\/likes\/?$/i.test(parsed.pathname); + } catch { + return false; + } +} + +/** A user-curated, named Thingiverse Collection (`thingiverse.com/{username}/collections/{id}`, + * optionally with a trailing `/things`) -- the site's other bookmark mechanism besides the + * automatic Likes list above. Also routed to the collection picker. */ +function isThingiverseCollectionUrl(url: string): boolean { + try { + const parsed = new URL(url.includes("://") ? url : `https://${url}`); + const host = parsed.hostname.toLowerCase(); + if (host !== "thingiverse.com" && host !== "www.thingiverse.com") return false; + return /\/collections\/\d+/i.test(parsed.pathname); + } catch { + return false; + } +} + // Every dropped/picked entry's relativePath equals its bare filename when the // selection has no folder structure. A webkitdirectory folder pick always // prefixes relativePath with the folder name, so this only ever fires for a @@ -64,7 +93,7 @@ export function useUploadImport({ onUploaded, folderId, makerworldCookie, onUnau const zipPrompt = useZipImportPrompt(); const collectionPrompt = useCollectionImportPrompt(); const importModePrompt = useImportModePrompt(); - const { startCollectionImport, startZipImport } = useImportJob(); + const { startCollectionImport, startZipImport, startThingiverseLikesImport, startThingiverseCollectionImport } = useImportJob(); const isBusy = uploading || importing || zipPrompt.isOpen || collectionPrompt.isOpen || importModePrompt.isOpen; const uploadFlatAsMultiplate = async (files: File[]) => { @@ -212,6 +241,60 @@ export function useUploadImport({ onUploaded, folderId, makerworldCookie, onUnau return; } + if (isThingiverseLikesUrl(url)) { + setImporting(false); + await collectionPrompt.prompt({ + label: url, + loadEntries: async () => { + try { + return await importsApi.listThingiverseLikesEntries(payload); + } catch (err) { + if (err instanceof UnauthorizedError) onUnauthorized?.(); + throw err; + } + }, + onImportSelected: async (thingIds: string[]) => { + try { + await startThingiverseLikesImport({ ...payload, thing_ids: thingIds }); + } catch (err) { + if (err instanceof UnauthorizedError) { + onUnauthorized?.(); + return; + } + throw err; + } + }, + }); + return; + } + + if (isThingiverseCollectionUrl(url)) { + setImporting(false); + await collectionPrompt.prompt({ + label: url, + loadEntries: async () => { + try { + return await importsApi.listThingiverseCollectionEntries(payload); + } catch (err) { + if (err instanceof UnauthorizedError) onUnauthorized?.(); + throw err; + } + }, + onImportSelected: async (thingIds: string[]) => { + try { + await startThingiverseCollectionImport({ ...payload, thing_ids: thingIds }); + } catch (err) { + if (err instanceof UnauthorizedError) { + onUnauthorized?.(); + return; + } + throw err; + } + }, + }); + return; + } + if (isThingiverseThingUrl(url)) { // A Thing always resolves to a zip; skip straight past the inspect/zip-picker steps -- // the backend already splits it into plates automatically.