feat: allow import thingiverse likes & collections

This commit is contained in:
Tautvydas Derzinskas committed 2026-09-08 20:11:09 +02:00
1 parent f9cb716394
commit ac32d03071
9 files changed
+1043 -10

No files matched your search

+111 -3
View File
@@ -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(
+133
View File
@@ -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<string>,
sourceLabel: (collectionTitle: string) => string,
): Promise<void> {
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<void> {
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<void> {
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<void> {
+105
View File
@@ -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<unknown> {
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<string | null> {
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;
}
+240 -2
View File
@@ -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<typeof import("../src/services/thingiverseApi")>();
return { ...actual, fetchThingiverseCollectionTitle: vi.fn<typeof actual.fetchThingiverseCollectionTitle>() };
});
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();
});
});
+138
View File
@@ -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<Response>>(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<Response>>(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<Response>>(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);
});
});
+131
View File
@@ -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<Response>>(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<Response>>(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<Response>>(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<Response>>(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);
});
});
+71
View File
@@ -164,6 +164,77 @@ export const importsApi = {
return res.json();
},
listThingiverseLikesEntries: async (payload: ImportLinkPayload): Promise<ImportCollectionEntriesResult> => {
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<ImportCollectionEntriesResult> => {
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<ImportJob | null> => {
const res = await fetch(`${apiBase()}/import/jobs/active`, { headers: authHeaders() });
if (res.status === 401) throw new UnauthorizedError();
@@ -5,14 +5,19 @@ import { UnauthorizedError } from "../../api/client";
type StartCollectionImportPayload = Parameters<typeof importsApi.fromCollection>[0];
type StartZipImportPayload = Parameters<typeof importsApi.zipFromLink>[0];
type StartThingiverseLikesImportPayload = Parameters<typeof importsApi.fromThingiverseLikes>[0];
type StartThingiverseCollectionImportPayload = Parameters<typeof importsApi.fromThingiverseCollection>[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<void>;
startZipImport: (payload: StartZipImportPayload) => Promise<void>;
startThingiverseLikesImport: (payload: StartThingiverseLikesImportPayload) => Promise<void>;
startThingiverseCollectionImport: (payload: StartThingiverseCollectionImportPayload) => Promise<void>;
};
const ImportJobContext = createContext<ImportJobContextValue | null>(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 (
@@ -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.