mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-21 06:09:56 -05:00
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:
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';
|
||||
@@ -251,6 +251,7 @@ model CollectionItem {
|
||||
enum ImportJobType {
|
||||
COLLECTION
|
||||
ZIP
|
||||
PROFILES
|
||||
}
|
||||
|
||||
enum ImportJobStatus {
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 }> => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user