feat: import all designer or all MakerWorld print profiles of a model

The import dialog shows a "Print profiles" choice for a MakerWorld model link:
the profile from the link (the default, a plain import as before), every
profile its designer uploaded, or every profile including community ones. A
profile is the designer's when its instanceCreator is the design's
designCreator. The latter two run as a background job (new PROFILES job type,
POST /import/makerworld-profiles) that imports one profile at a time, paced
like a collection import: the link's or default profile creates the model,
each later one is added to it as another file, and it stops early on a
MakerWorld CAPTCHA or rejected login.

Adding a profile whose download the extension already resolved now fetches
just the file instead of the MakerWorld model page first.
This commit is contained in:
Tautvydas Derzinskas committed 2026-09-28 01:10:07 +02:00
1 parent 2a3434f126
commit 101aa03f9c
15 files changed
+492 -14

No files matched your search

@@ -0,0 +1,3 @@
-- A background import of several print profiles of one MakerWorld model (see
-- importJobRunner.ts's runMakerworldProfilesImportJob).
ALTER TYPE "ImportJobType" ADD VALUE 'PROFILES';
+1
View File
@@ -251,6 +251,7 @@ model CollectionItem {
enum ImportJobType {
COLLECTION
ZIP
PROFILES
}
enum ImportJobStatus {
+1 -1
View File
@@ -392,7 +392,7 @@ export function toCategoryOut(category: Category): CategoryOut {
export type ImportJobOut = {
id: string;
type: "COLLECTION" | "ZIP";
type: "COLLECTION" | "ZIP" | "PROFILES";
status: "RUNNING" | "DONE" | "ERROR";
source_url: string;
source_label: string | null;
+26 -1
View File
@@ -10,7 +10,12 @@ import { asyncHandler } from "../utils/asyncHandler";
import { requireCaptcha } from "../services/captchaService";
import { checkImportStatus, downloadImportToTemp, findImportedExternalIds, importPrintFromUrl, inspectImportLink } from "../services/importService";
import { resolveMakerworldCookie } from "../services/importResolvers";
import { extractMakerworldBearerToken, MakerworldAuthError, MakerworldCaptchaError } from "../services/makerworldCloudApi";
import {
extractMakerworldBearerToken,
MakerworldAuthError,
MakerworldCaptchaError,
parseMakerworldModelUrl,
} from "../services/makerworldCloudApi";
import { fetchMakerworldCollectionEntries, fetchMakerworldCollectionTitle, parseMakerworldCollectionUrl } from "../services/makerworldCollections";
import { IMPORT_MAKERWORLD_CALL_DELAY_MS } from "../config";
import {
@@ -27,6 +32,7 @@ import { listZipEntries } from "../services/zipService";
import { createJob, getActiveJob, getJob } from "../services/importJobService";
import {
runCollectionImportJob,
runMakerworldProfilesImportJob,
runPrintablesCollectionImportJob,
runThingiverseCollectionImportJob,
runThingiverseLikesImportJob,
@@ -294,6 +300,25 @@ async function assertNoActiveJob(userId: string): Promise<void> {
if (active) throw new HttpError(409, "An import is already in progress");
}
// Several print profiles of one MakerWorld model -- all its designer's, or all of them (see
// importJobRunner.ts's runMakerworldProfilesImportJob). The import dialog's "Print profiles"
// choice; "just the link's profile" is a plain POST /import.
const makerworldProfilesImportRequestSchema = importRequestSchema.extend({ scope: z.enum(["designer", "all"]) });
router.post(
"/import/makerworld-profiles",
requireCaptcha("import"),
asyncHandler(async (req, res) => {
const body = await withStoredMakerworldCookie(req.userId!, parseBody(makerworldProfilesImportRequestSchema, req.body));
await assertNoActiveJob(req.userId!);
const url = await normalizeImportUrl(body.url);
if (!parseMakerworldModelUrl(url)) throw new HttpError(400, "Not a MakerWorld model link");
const job = await createJob(req.userId!, "PROFILES", { sourceUrl: url, provider: "makerworld" });
void runMakerworldProfilesImportJob(job.id, req.userId!, { ...body, url });
res.status(202).json({ job_id: job.id });
}),
);
const collectionImportRequestSchema = importRequestSchema.extend({ design_ids: z.array(z.string()).min(1) });
router.post(
+86 -2
View File
@@ -6,9 +6,15 @@ import { updateJob } from "./importJobService";
import { createNotification } from "./notificationService";
import { addPrintsToCollection, findOrCreateCollectionByName } from "./collectionService";
import { resolveMakerworldCookie } from "./importResolvers";
import { extractMakerworldBearerToken } from "./makerworldCloudApi";
import { decodeHtmlEntities } from "./importResolvers";
import {
extractMakerworldBearerToken,
parseMakerworldModelUrl,
selectMakerworldProfiles,
type MakerworldProfileScope,
} from "./makerworldCloudApi";
import { fetchMakerworldCollectionTitle, parseMakerworldCollectionUrl } from "./makerworldCollections";
import { downloadImportToTemp, importPrintFromUrl, type ImportRequestBody } from "./importService";
import { downloadImportToTemp, fetchMakerworldDesignForImport, importPrintFromUrl, type ImportRequestBody } from "./importService";
import { upsertAuthorFromImport } from "./authorService";
import { extractZipEntriesToPrints } from "./zipService";
import { fetchThingiverseCollectionTitle } from "./thingiverseApi";
@@ -33,6 +39,7 @@ type ZipImportJobBody = ImportRequestBody & { entries: string[] };
type ThingiverseLikesImportJobBody = ImportRequestBody & { thing_ids: string[]; username: string };
type ThingiverseCollectionImportJobBody = ImportRequestBody & { thing_ids: string[]; collectionId: string };
type PrintablesCollectionImportJobBody = ImportRequestBody & { model_ids: string[]; collectionId: string };
type MakerworldProfilesImportJobBody = ImportRequestBody & { scope: Exclude<MakerworldProfileScope, "url"> };
// Distinguishes *why* a single design failed, so a batch of many failures reads as one clear
// cause instead of an opaque "N failed":
@@ -64,6 +71,83 @@ async function markJobFailed(jobId: string, err: unknown): Promise<void> {
await updateJob(jobId, { status: "ERROR", errorMessage: message }).catch(() => undefined);
}
/** Imports several print profiles of one MakerWorld model in the background -- every profile its
* designer uploaded, or every profile (see makerworldCloudApi.ts's selectMakerworldProfiles) --
* as one model with a file per profile: the first creates it, each later one is added to it
* (importService.ts's addMakerworldProfileToPrint, which skips profiles it already has). Paced
* like a collection import, one profile at a time; see routes/imports.ts's
* POST /import/makerworld-profiles. */
export async function runMakerworldProfilesImportJob(jobId: string, userId: string, body: MakerworldProfilesImportJobBody): Promise<void> {
try {
const parsed = parseMakerworldModelUrl(body.url);
if (!parsed) throw new HttpError(400, "Not a MakerWorld model link");
const design = await fetchMakerworldDesignForImport(parsed.designId, resolveMakerworldCookie(body), IMPORT_MAKERWORLD_CALL_DELAY_MS);
if (!design) throw new HttpError(400, "Couldn't read this model's print profiles from MakerWorld");
const profileIds = selectMakerworldProfiles(design, body.scope, parsed.requestedInstanceId);
if (!profileIds.length) throw new HttpError(400, "This model has no print profiles to import");
const title = typeof design.title === "string" && design.title.trim() ? decodeHtmlEntities(design.title.trim()) : null;
await updateJob(jobId, { total: profileIds.length, sourceLabel: title });
let processed = 0;
let imported = 0;
let alreadyInLibrary = 0;
let failed = 0;
let stopReason: "rateLimited" | "auth" | null = null;
let printId: string | null = null;
for (const profileId of profileIds) {
const profileUrl = `https://makerworld.com/en/models/${parsed.designId}#profileId-${profileId}`;
if (stopReason) {
failed++;
} else {
try {
const result = await importPrintFromUrl(userId, profileUrl, {
...body,
url: profileUrl,
makerworldPaceMs: IMPORT_MAKERWORLD_CALL_DELAY_MS,
});
printId = result.print.id;
if (result.alreadyImported) alreadyInLibrary++;
else imported++;
} catch (err) {
failed++;
// Every later profile would fail the same way -- stop asking MakerWorld.
const reason = classifyImportFailure(err);
if (reason === "rateLimited" || reason === "auth") stopReason = reason;
}
}
processed++;
await updateJob(jobId, { processed, imported, alreadyInLibrary, failedCount: failed }).catch(() => undefined);
}
await updateJob(jobId, { status: "DONE", resultPrintId: printId, processed, imported, alreadyInLibrary, failedCount: failed });
void createLog({
userId,
action: "import_completed",
targetId: printId,
details: { provider: "makerworld", sourceLabel: title, imported, alreadyInLibrary, failed },
});
const bodyParts: string[] = [];
if (alreadyInLibrary) bodyParts.push(`${alreadyInLibrary} already on the model`);
if (stopReason === "rateLimited") {
bodyParts.push(`the rest blocked by a MakerWorld CAPTCHA challenge — this usually clears in 1-4 hours, then import the model again to add the missing profiles`);
} else if (stopReason === "auth") {
bodyParts.push(`the rest failed because your MakerWorld session expired — update the cookie in Settings and import again`);
} else if (failed) {
bodyParts.push(`${failed} failed`);
}
const label = title ? `"${title}"` : "a MakerWorld model";
await createNotification(userId, {
title: `Imported ${imported} of ${profileIds.length} print profiles from MakerWorld`,
body: bodyParts.length ? `Of ${label} — ${bodyParts.join(", ")}.` : `Of ${label}.`,
externalUrl: body.url,
internalPath: printId ? `/models/${printId}` : null,
});
} catch (err) {
await markJobFailed(jobId, err);
}
}
/** Runs a MakerWorld collection's batch import in the background -- see routes/imports.ts's
* POST /import/collection, which creates the ImportJob row and kicks this off without awaiting
* it. Mirrors the per-design import logic that used to live inline in that route handler. */
+46 -2
View File
@@ -17,6 +17,7 @@ import { maybeSleep, sleep } from "../utils/concurrency";
import { fetchViaFlaresolverr, isFlaresolverrEnabled, looksLikeCloudflareBlock, shouldProxyHost } from "./flaresolverr";
import {
emptyImportedPageMetadata,
extractNextDataJson,
extractPageMetadata,
findDownloadUrl,
makerworldHtmlHeaders,
@@ -34,6 +35,7 @@ import {
parseMakerworldModelUrl,
resolveMakerworldViaCloudApi,
completeMakerworldAuthor,
fetchMakerworldDesign,
} from "./makerworldCloudApi";
import {
parseThingiverseThingUrl,
@@ -168,6 +170,42 @@ async function fetchWithGuard(url: string, headers: Record<string, string>): Pro
export type OpenImportResult = { response: Response; finalUrl: string; meta: ImportedPageMetadata };
/** A MakerWorld design's own data (title, creator, print profiles), as its model page embeds it in
* __NEXT_DATA__ -- the fallback to makerworldCloudApi.ts's fetchMakerworldDesign when there's no
* MakerWorld login to use its API. The page is often behind Cloudflare's challenge for a server
* (FlareSolverr gets through); null when it can't be read. */
async function fetchMakerworldPageDesign(
designId: string,
cookie: string | null,
paceMs?: number,
): Promise<Record<string, unknown> | null> {
if (makerworldCaptchaCooloffActive()) throw new MakerworldCaptchaError();
const url = `https://makerworld.com/en/models/${designId}`;
await maybeSleep(paceMs);
let res: Response;
try {
res = await fetchWithGuard(url, { "User-Agent": IMPORT_USER_AGENT, Accept: "*/*", ...makerworldHtmlHeaders(url, cookie) });
} catch {
return null;
}
if (!isHtmlContentType(res.headers.get("content-type") || "")) return null;
const { buffer } = await readCapped(res, IMPORT_HTML_MAX_BYTES);
const nextData = extractNextDataJson(buffer.toString("utf-8"));
const design = (nextData as { props?: { pageProps?: { design?: unknown } } } | null)?.props?.pageProps?.design;
return design && typeof design === "object" && !Array.isArray(design) ? (design as Record<string, unknown>) : null;
}
/** A MakerWorld design's data for choosing which print profiles to import -- through MakerWorld's
* API with the user's MakerWorld login, else from the model page. */
export async function fetchMakerworldDesignForImport(
designId: string,
cookie: string | null,
paceMs?: number,
): Promise<Record<string, unknown> | null> {
const bearer = extractMakerworldBearerToken(cookie);
return bearer ? fetchMakerworldDesign(designId, bearer, paceMs) : fetchMakerworldPageDesign(designId, cookie, paceMs);
}
/** A MakerWorld design's author from its model page -- the fallback to
* makerworldCloudApi.ts's fetchMakerworldDesignAuthor when there's no MakerWorld login to use its
* API. The page is often behind Cloudflare's challenge for a server (FlareSolverr gets through);
@@ -626,8 +664,14 @@ async function addMakerworldProfileToPrint(
const wanted = body.resolved_instance_id ?? parseMakerworldModelUrl(url)?.requestedInstanceId ?? null;
if (!wanted || hasProfile(wanted)) return alreadyImported;
const opened = await openImportResponse(url, body);
const instanceId = opened.meta.makerworldProfile?.instanceId ?? null;
// A download the extension already resolved in the page is fetched as it is: the model page
// the full import path would fetch first only carries metadata the model already has, and one
// extra MakerWorld page request per profile is the kind of burst that trips its CAPTCHA.
const presolved = Boolean(body.resolved_download_url && body.resolved_instance_id);
const opened = presolved
? await openImportResponse(body.resolved_download_url!, { ...body, resolved_download_url: null }, url)
: await openImportResponse(url, body);
const instanceId = presolved ? body.resolved_instance_id! : (opened.meta.makerworldProfile?.instanceId ?? null);
// Unknown profile (can't dedupe it), or the resolver fell back to one already on the print
// (e.g. the hash named a profile this design doesn't have, so it resolved the default).
if (!instanceId || hasProfile(instanceId)) {
+54 -2
View File
@@ -278,6 +278,59 @@ export async function fetchMakerworldDesignAuthor(
bearerToken: string,
paceMs?: number,
): Promise<ImportedAuthorInfo | null> {
const design = await fetchMakerworldDesign(designId, bearerToken, paceMs);
return design ? completeMakerworldAuthor(makerworldAuthorFromDesignCreator(design.designCreator), paceMs) : null;
}
/** Which of a MakerWorld design's print profiles ("instances") an import takes: the one the link
* names (else the design's default) -- what a single import always did -- every profile the
* designer uploaded, or every profile including community-uploaded ones. */
export type MakerworldProfileScope = "url" | "designer" | "all";
function instanceIdOf(inst: Record<string, unknown>): string {
return String(inst.id);
}
/** Instance ids to import for `scope`, from a design's own data (the api.bambulab.com design, or
* the model page's __NEXT_DATA__ -- the same shape). A profile is the designer's when its
* `instanceCreator` is the design's `designCreator` (confirmed live: `isOfficial` and `isDefault`
* say nothing about who uploaded it). The link's profile, else the default, comes first, so it's
* what creates the model -- later ones are added to it as further files. */
export function selectMakerworldProfiles(
design: Record<string, unknown>,
scope: MakerworldProfileScope,
requestedInstanceId: string | null,
): string[] {
const instances = Array.isArray(design.instances) ? design.instances.filter(isRecord).filter((inst) => inst.id != null) : [];
const ids = instances.map(instanceIdOf);
const defaultId = design.defaultInstanceId != null ? String(design.defaultInstanceId) : null;
const primary =
(requestedInstanceId && ids.includes(requestedInstanceId) ? requestedInstanceId : null) ??
(defaultId && ids.includes(defaultId) ? defaultId : null) ??
ids[0] ??
null;
if (!primary) return [];
if (scope === "url") return [primary];
const designerUid = isRecord(design.designCreator) && design.designCreator.uid != null ? String(design.designCreator.uid) : null;
const wanted = instances
.filter((inst) => {
if (scope === "all") return true;
const creator = isRecord(inst.instanceCreator) ? inst.instanceCreator : null;
return designerUid !== null && creator?.uid != null && String(creator.uid) === designerUid;
})
.map(instanceIdOf);
return [primary, ...wanted.filter((id) => id !== primary)];
}
/** A design's own data (title, creator, print profiles) through api.bambulab.com, without
* resolving any download. Null when it can't be read; throws MakerworldCaptchaError /
* MakerworldAuthError like resolveMakerworldViaCloudApi. */
export async function fetchMakerworldDesign(
designId: string,
bearerToken: string,
paceMs?: number,
): Promise<Record<string, unknown> | null> {
if (makerworldCaptchaCooloffActive()) throw new MakerworldCaptchaError();
await maybeSleep(paceMs);
const result = await fetchCloudJson(`${DESIGN_API_BASE}/design/${designId}`, bearerToken);
@@ -287,8 +340,7 @@ export async function fetchMakerworldDesignAuthor(
throw new MakerworldCaptchaError();
}
if (result.status === 401 || result.status === 403) throw new MakerworldAuthError();
if (result.status !== 200 || !isRecord(result.data)) return null;
return completeMakerworldAuthor(makerworldAuthorFromDesignCreator(result.data.designCreator), paceMs);
return result.status === 200 && isRecord(result.data) ? result.data : null;
}
export type MakerworldGalleryImage = { url: string; filename: string };
@@ -0,0 +1,186 @@
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import request from "supertest";
// Pacing between MakerWorld requests (5s in production) and DNS: neither wanted here. config.ts
// reads the pace when it loads, so before any import.
vi.hoisted(() => {
process.env.IMPORT_MAKERWORLD_CALL_DELAY_MS = "0";
});
vi.mock("node:dns/promises", () => ({
default: {
resolve4: async () => ["93.184.216.34"],
resolve6: async () => {
throw new Error("no AAAA");
},
},
}));
import { createApp } from "../src/app";
import { prisma } from "../src/db";
import { selectMakerworldProfiles } from "../src/services/makerworldCloudApi";
// "Print profiles" in the import dialog / the extension panel: the link's profile (a plain
// import), every profile the designer uploaded, or every profile including community ones.
const app = createApp();
const stamp = Date.now();
let token: string;
const auth = () => ({ Authorization: `Bearer ${token}` });
const originalFetch = global.fetch;
function json(body: unknown): Response {
return new Response(JSON.stringify(body), { headers: { "content-type": "application/json" } });
}
const DESIGNER = { uid: 1692606088, name: "Deus Cat" };
/** Like MakerWorld's "Articulated Phoenix": two profiles by its designer, two by the community. */
function design(designId: string) {
return {
id: Number(designId),
modelId: `US${designId}`,
title: "Articulated Phoenix",
summary: "<p>A phoenix.</p>",
tags: ["phoenix"],
coverUrl: "https://makerworld.bblmw.com/phoenix/cover.jpg",
designCreator: DESIGNER,
defaultInstanceId: 2,
instances: [
{ id: 1, profileId: 101, title: "Single-colour version", instanceCreator: DESIGNER },
{ id: 2, profileId: 102, title: "multi-colored version", instanceCreator: DESIGNER },
{ id: 3, profileId: 103, title: "Tail Color", instanceCreator: { uid: 740193827, name: "BlomsD" } },
{ id: 4, profileId: 104, title: "0.16mm layer", instanceCreator: { uid: 356629145, name: "gekkoace" } },
],
};
}
describe("selectMakerworldProfiles", () => {
const d = design("1");
it("takes the link's profile, else the default -- a plain single import", () => {
expect(selectMakerworldProfiles(d, "url", "3")).toEqual(["3"]);
expect(selectMakerworldProfiles(d, "url", null)).toEqual(["2"]);
expect(selectMakerworldProfiles(d, "url", "999")).toEqual(["2"]);
});
it("takes the designer's own profiles, the link's (or default) one first", () => {
expect(selectMakerworldProfiles(d, "designer", null)).toEqual(["2", "1"]);
// A community profile in the link still comes first -- it's what the user was looking at.
expect(selectMakerworldProfiles(d, "designer", "4")).toEqual(["4", "1", "2"]);
});
it("takes every profile", () => {
expect(selectMakerworldProfiles(d, "all", null)).toEqual(["2", "1", "3", "4"]);
});
});
describe("importing several MakerWorld print profiles", () => {
beforeAll(async () => {
const res = await request(app)
.post("/api/register")
.send({ displayName: "Profiles Import Test", email: `mw-profiles-${stamp}@example.com`, password: "password123" });
token = res.body.token;
});
afterEach(() => {
global.fetch = originalFetch;
});
/** MakerWorld's API as a logged-in import sees it; each profile's file has its own contents. */
function mockMakerworld(designId: string) {
const fetched: string[] = [];
global.fetch = vi.fn<(input: RequestInfo | URL) => Promise<Response>>(async (input) => {
const url = String(input);
fetched.push(url);
if (url === `https://api.bambulab.com/v1/design-service/design/${designId}`) return json(design(designId));
const profile = url.match(/^https:\/\/api\.bambulab\.com\/v1\/iot-service\/api\/user\/profile\/(\d+)/);
if (profile) return json({ message: "success", url: `https://s3.example.com/phoenix-${profile[1]}.stl`, filename: `phoenix-${profile[1]}.stl` });
const file = url.match(/^https:\/\/s3\.example\.com\/phoenix-(\d+)\.stl$/);
if (file) return new Response(`solid phoenix-${file[1]}\nendsolid\n`, { headers: { "content-type": "application/octet-stream" } });
if (url === "https://makerworld.bblmw.com/phoenix/cover.jpg") {
return new Response(Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", "base64"), {
headers: { "content-type": "image/png" },
});
}
return new Response("not found", { status: 404 }); // e.g. the author profile, behind Cloudflare
}) as unknown as typeof fetch;
return fetched;
}
async function runJob(designId: string, scope: "designer" | "all") {
const start = await request(app)
.post("/api/import/makerworld-profiles")
.set(auth())
.send({ url: `https://makerworld.com/en/models/${designId}-articulated-phoenix`, scope, makerworld_cookie: "token=test-bearer" });
expect(start.status).toBe(202);
let job = (await request(app).get(`/api/import/jobs/${start.body.job_id}`).set(auth())).body;
for (let i = 0; i < 100 && job.status === "RUNNING"; i++) {
await new Promise((resolve) => setTimeout(resolve, 100));
job = (await request(app).get(`/api/import/jobs/${start.body.job_id}`).set(auth())).body;
}
return job;
}
it("imports every designer profile as one model with a file per profile", async () => {
const designId = String(stamp % 1_000_000_000);
mockMakerworld(designId);
const job = await runJob(designId, "designer");
expect(job).toMatchObject({ status: "DONE", type: "PROFILES", total: 2, imported: 2, failed_count: 0, source_label: "Articulated Phoenix" });
const print = await prisma.print.findUniqueOrThrow({ where: { id: job.result_print_id }, include: { plates: { orderBy: { position: "asc" } } } });
// The default profile first (it created the model), then the designer's other one.
expect(print.plates.map((p) => [p.sourceInstanceId, p.filename])).toEqual([
["2", "phoenix-102.stl"],
["1", "phoenix-101.stl"],
]);
});
it("adds the community profiles to a model that already has the designer's", async () => {
const designId = String(stamp % 1_000_000_000);
mockMakerworld(designId);
const job = await runJob(designId, "all");
expect(job).toMatchObject({ status: "DONE", total: 4, imported: 2, already_in_library: 2, failed_count: 0 });
const plates = await prisma.plate.findMany({ where: { printId: job.result_print_id } });
expect(plates.map((p) => p.sourceInstanceId).toSorted()).toEqual(["1", "2", "3", "4"]);
});
it("refuses a link that isn't a MakerWorld model", async () => {
const res = await request(app).post("/api/import/makerworld-profiles").set(auth()).send({ url: "https://www.printables.com/model/1-x", scope: "all" });
expect(res.status).toBe(400);
});
});
describe("adding a profile the extension already resolved", () => {
afterEach(() => {
global.fetch = originalFetch;
});
it("fetches just the file, not the MakerWorld model page again", async () => {
const designId = String((stamp % 1_000_000_000) + 50);
const fetched: string[] = [];
global.fetch = vi.fn<(input: RequestInfo | URL) => Promise<Response>>(async (input) => {
const url = String(input);
fetched.push(url);
if (url.startsWith(`https://makerworld.com/en/models/${designId}`)) {
return new Response("<html><head><title>Phoenix</title></head><body></body></html>", { headers: { "content-type": "text/html" } });
}
const file = url.match(/^https:\/\/s3\.example\.com\/ext-(\d+)\.stl$/);
if (file) return new Response(`solid ext-${file[1]}\nendsolid\n`, { headers: { "content-type": "application/octet-stream" } });
return new Response("not found", { status: 404 });
}) as unknown as typeof fetch;
const importProfile = (instanceId: string) =>
request(app)
.post("/api/import")
.set({ ...auth(), "X-Thingport-Client": "grab" })
.send({
url: `https://makerworld.com/en/models/${designId}-phoenix`,
resolved_download_url: `https://s3.example.com/ext-${instanceId}.stl`,
resolved_instance_id: instanceId,
});
expect((await importProfile("11")).status).toBe(200);
const pageFetchesAfterFirst = fetched.filter((u) => u.startsWith("https://makerworld.com/en/models/")).length;
const second = await importProfile("12");
expect(second.status).toBe(200);
expect(second.body.import_outcome).toBe("profile_added");
expect(fetched.filter((u) => u.startsWith("https://makerworld.com/en/models/")).length).toBe(pageFetchesAfterFirst);
});
});
+20 -1
View File
@@ -27,7 +27,11 @@ export type ImportCollectionEntriesResult = {
entries: ImportCollectionEntry[];
};
export type ImportJobType = "COLLECTION" | "ZIP";
export type ImportJobType = "COLLECTION" | "ZIP" | "PROFILES";
/** Which of a MakerWorld model's print profiles an import takes -- see the import dialog's "Print
* profiles" choice and the backend's selectMakerworldProfiles. */
export type MakerworldProfileScope = "url" | "designer" | "all";
export type ImportJobStatus = "RUNNING" | "DONE" | "ERROR";
/** A batch import (MakerWorld collection, or a remote zip's selected entries) running in the
@@ -161,6 +165,21 @@ export const importsApi = {
return res.json();
},
/** Several print profiles of one MakerWorld model, as a background job -- see
* ImportJobContext.startMakerworldProfilesImport. ("url" is a plain fromLink import.) */
fromMakerworldProfiles: async (
payload: ImportLinkPayload & { scope: Exclude<MakerworldProfileScope, "url"> },
): Promise<{ job_id: string }> => {
const res = await fetch(`${apiBase()}/import/makerworld-profiles`, {
method: "POST",
headers: authHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify(payload),
});
if (res.status === 401) throw new UnauthorizedError();
if (!res.ok) throw new Error(await readErrorMessage(res, "Import failed"));
return res.json();
},
/** Registers a background job for the selected designs and returns immediately -- see
* ImportJobContext.startCollectionImport, which follows up with the actual polling. */
fromCollection: async (payload: ImportLinkPayload & { design_ids: string[] }): Promise<{ job_id: string }> => {
+25 -1
View File
@@ -30,8 +30,10 @@ import {
detectImportProvider,
IMPORT_LINK_EXAMPLES,
isMakerworldCollectionUrl,
isMakerworldModelUrl,
type ImportProviderKey,
} from "../../utils/importLinkDetection";
import type { MakerworldProfileScope } from "../../api/imports";
const IMPORT_PROVIDERS: ImportProviderKey[] = ["makerworld", "thingiverse", "printables"];
@@ -57,6 +59,8 @@ export default function AddMenu({ categoryId, makerworldCookie, onUploaded, onUn
const [importOpen, setImportOpen] = React.useState(false);
const [linkValue, setLinkValue] = React.useState("");
const [exampleProvider, setExampleProvider] = React.useState<ImportProviderKey | null>(null);
// Only offered for a MakerWorld model link; everything else imports its one thing.
const [profileScope, setProfileScope] = React.useState<MakerworldProfileScope>("url");
const { isImporting } = useImportJob();
const upload = useUploadImport({ categoryId, makerworldCookie, onUploaded, onUnauthorized });
const captchaSettings = useCaptchaSettings();
@@ -65,6 +69,7 @@ export default function AddMenu({ categoryId, makerworldCookie, onUploaded, onUn
const detectedProvider = detectImportProvider(linkValue);
const isBlockedCollection = isMakerworldCollectionUrl(linkValue);
const isMakerworldModel = isMakerworldModelUrl(linkValue);
const closeMenu = () => setAnchorEl(null);
@@ -77,6 +82,7 @@ export default function AddMenu({ categoryId, makerworldCookie, onUploaded, onUn
closeMenu();
setLinkValue("");
setExampleProvider(null);
setProfileScope("url");
setImportOpen(true);
};
@@ -89,7 +95,7 @@ export default function AddMenu({ categoryId, makerworldCookie, onUploaded, onUn
if (!linkValue.trim() || isBlockedCollection) return;
if (needsCaptcha && !captcha?.captcha_answer.trim()) return;
// The dialog closes either way; reopening it shows a fresh captcha (each one works once).
await upload.submitImport(linkValue, needsCaptcha ? captcha : null);
await upload.submitImport(linkValue, needsCaptcha ? captcha : null, isMakerworldModel ? profileScope : "url");
setImportOpen(false);
};
@@ -179,6 +185,24 @@ export default function AddMenu({ categoryId, makerworldCookie, onUploaded, onUn
placeholder={t("uploadBar.linkPlaceholder") ?? undefined}
disabled={upload.importing}
/>
{isMakerworldModel && (
<TextField
select
fullWidth
size="small"
margin="dense"
label={t("addMenu.profilesLabel")}
value={profileScope}
onChange={e => setProfileScope(e.target.value as MakerworldProfileScope)}
disabled={upload.importing}
helperText={profileScope === "url" ? t("addMenu.profilesHelpUrl") : t("addMenu.profilesHelpMany")}
sx={{ mt: 1.5 }}
>
<MenuItem value="url">{t("addMenu.profilesUrl")}</MenuItem>
<MenuItem value="designer">{t("addMenu.profilesDesigner")}</MenuItem>
<MenuItem value="all">{t("addMenu.profilesAll")}</MenuItem>
</TextField>
)}
{needsCaptcha && (
<Box sx={{ mt: 2 }}>
<CaptchaField onChange={setCaptcha} disabled={upload.importing} />
@@ -9,6 +9,7 @@ type StartZipImportPayload = Parameters<typeof importsApi.zipFromLink>[0];
type StartThingiverseLikesImportPayload = Parameters<typeof importsApi.fromThingiverseLikes>[0];
type StartThingiverseCollectionImportPayload = Parameters<typeof importsApi.fromThingiverseCollection>[0];
type StartPrintablesCollectionImportPayload = Parameters<typeof importsApi.fromPrintablesCollection>[0];
type StartMakerworldProfilesImportPayload = Parameters<typeof importsApi.fromMakerworldProfiles>[0];
type ImportJobContextValue = {
/** Non-null exactly while a batch import (MakerWorld collection, a Thingiverse Collection or
@@ -21,6 +22,7 @@ type ImportJobContextValue = {
startThingiverseLikesImport: (payload: StartThingiverseLikesImportPayload) => Promise<void>;
startThingiverseCollectionImport: (payload: StartThingiverseCollectionImportPayload) => Promise<void>;
startPrintablesCollectionImport: (payload: StartPrintablesCollectionImportPayload) => Promise<void>;
startMakerworldProfilesImport: (payload: StartMakerworldProfilesImportPayload) => Promise<void>;
};
const ImportJobContext = createContext<ImportJobContextValue | null>(null);
@@ -142,6 +144,13 @@ export function ImportJobProvider({
startPolling(job_id);
}, [startPolling]);
const startMakerworldProfilesImport = useCallback(async (payload: StartMakerworldProfilesImportPayload) => {
const { job_id } = await importsApi.fromMakerworldProfiles(payload);
const job = await importsApi.getImportJob(job_id);
setActiveJob(job);
startPolling(job_id);
}, [startPolling]);
const value = useMemo(
() => ({
activeJob,
@@ -151,6 +160,7 @@ export function ImportJobProvider({
startThingiverseLikesImport,
startThingiverseCollectionImport,
startPrintablesCollectionImport,
startMakerworldProfilesImport,
}),
[
activeJob,
@@ -159,6 +169,7 @@ export function ImportJobProvider({
startThingiverseLikesImport,
startThingiverseCollectionImport,
startPrintablesCollectionImport,
startMakerworldProfilesImport,
],
);
@@ -3,7 +3,7 @@ import React, { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { UnauthorizedError } from "../../api/client";
import { importsApi, type ImportOutcome } from "../../api/imports";
import { importsApi, type ImportOutcome, type MakerworldProfileScope } from "../../api/imports";
import { printsApi, type Print } from "../../api/prints";
import { entriesFromFileList, uploadEntriesToCategory } from "../../utils/uploadTree";
import { buildUploadEntriesFromZip, isZipFile, readZipEntries } from "../../utils/zipUtils";
@@ -13,6 +13,7 @@ import { useImportModePrompt, type ImportMode } from "./ImportModeModal";
import { useImportJob } from "../Layout/ImportJobContext";
import { useToast } from "../ToastProvider";
import {
isMakerworldModelUrl,
isMakerworldCollectionUrl,
isPrintablesCollectionUrl,
isPrintablesModelUrl,
@@ -54,6 +55,7 @@ export function useUploadImport({ onUploaded, categoryId, makerworldCookie, onUn
startThingiverseLikesImport,
startThingiverseCollectionImport,
startPrintablesCollectionImport,
startMakerworldProfilesImport,
} = useImportJob();
const isBusy = uploading || importing || zipPrompt.isOpen || collectionPrompt.isOpen || importModePrompt.isOpen;
@@ -195,7 +197,10 @@ export function useUploadImport({ onUploaded, categoryId, makerworldCookie, onUn
showToast({ message: t(key, { name }) });
};
const submitImport = async (rawUrl: string, captcha?: CaptchaAnswer | null) => {
/** `profileScope`: for a MakerWorld model link, which of its print profiles to import (the
* import dialog's "Print profiles" choice) -- anything beyond the link's own runs as a
* background job. */
const submitImport = async (rawUrl: string, captcha?: CaptchaAnswer | null, profileScope: MakerworldProfileScope = "url") => {
const url = rawUrl.trim();
if (!url) return;
setImporting(true);
@@ -218,6 +223,11 @@ export function useUploadImport({ onUploaded, categoryId, makerworldCookie, onUn
return;
}
if (profileScope !== "url" && isMakerworldModelUrl(url)) {
await startMakerworldProfilesImport({ ...payload, scope: profileScope });
return;
}
if (isThingiverseLikesUrl(url)) {
setImporting(false);
await collectionPrompt.prompt({
+7 -1
View File
@@ -177,7 +177,13 @@
"exampleModel": "Model",
"exampleCollection": "Collection",
"makerworldCollectionExtensionOnly": "requires Thingport Grab",
"makerworldCollectionBlocked": "MakerWorld collections can only be imported using the Thingport Grab browser extension, not this dialog. Paste a single model link instead, or use Thingport Grab to bulk-import a whole collection."
"makerworldCollectionBlocked": "MakerWorld collections can only be imported using the Thingport Grab browser extension, not this dialog. Paste a single model link instead, or use Thingport Grab to bulk-import a whole collection.",
"profilesLabel": "Print profiles",
"profilesUrl": "Print profile from the link",
"profilesDesigner": "All designer print profiles",
"profilesAll": "Designer and community print profiles",
"profilesHelpUrl": "The profile the link points to, or the model's default one. Other profiles can be added later.",
"profilesHelpMany": "Each profile becomes its own file on the model. They're imported one at a time in the background, which can take a few minutes."
},
"userMenu": {
"theme": "Theme",
+7 -1
View File
@@ -177,7 +177,13 @@
"exampleModel": "Modelis",
"exampleCollection": "Kolekcija",
"makerworldCollectionExtensionOnly": "reikalingas Thingport Grab",
"makerworldCollectionBlocked": "MakerWorld kolekcijas galima importuoti tik naudojant Thingport Grab naršyklės plėtinį, ne šį langą. Įklijuokite vieno modelio nuorodą arba naudokite Thingport Grab visai kolekcijai importuoti."
"makerworldCollectionBlocked": "MakerWorld kolekcijas galima importuoti tik naudojant Thingport Grab naršyklės plėtinį, ne šį langą. Įklijuokite vieno modelio nuorodą arba naudokite Thingport Grab visai kolekcijai importuoti.",
"profilesLabel": "Spausdinimo profiliai",
"profilesUrl": "Spausdinimo profilis iš nuorodos",
"profilesDesigner": "Visi dizainerio spausdinimo profiliai",
"profilesAll": "Dizainerio ir bendruomenės spausdinimo profiliai",
"profilesHelpUrl": "Profilis, į kurį rodo nuoroda, arba numatytasis modelio profilis. Kitus profilius galima pridėti vėliau.",
"profilesHelpMany": "Kiekvienas profilis tampa atskiru modelio failu. Jie importuojami po vieną fone, todėl tai gali užtrukti kelias minutes."
},
"userMenu": {
"theme": "Tema",
@@ -34,6 +34,13 @@ export function isMakerworldCollectionUrl(url: string): boolean {
return Boolean(parsed && parsed.hostname.toLowerCase().endsWith("makerworld.com") && /\/collections\/\d+/i.test(parsed.pathname));
}
/** A single MakerWorld model page (`/en/models/{id}-{slug}`, optionally `#profileId-…`) -- the
* kind of link whose print profiles the import dialog lets you choose between. */
export function isMakerworldModelUrl(url: string): boolean {
const parsed = parseUrl(url);
return Boolean(parsed && parsed.hostname.toLowerCase().endsWith("makerworld.com") && /\/models?\/\d+/i.test(parsed.pathname));
}
/** A Thingiverse Thing import goes through its own backend path entirely (see
* importService.ts's importThingiverseThing) rather than the generic inspect/zip-picker flow --
* skip straight to a plain import call so the zip-entry picker (meant for arbitrary remote