mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-19 05:10:01 -05:00
feat: allow import thingiverse likes & collections
This commit is contained in:
1 parent
f9cb716394
commit
ac32d03071
9 files changed
+1043
-10
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user