From fc612b30c0dd7e8fe6df1801d67e790e420ac1c8 Mon Sep 17 00:00:00 2001 From: Tautvydas Derzinskas Date: Mon, 28 Sep 2026 01:10:09 +0200 Subject: [PATCH] feat(extension): choose which MakerWorld print profiles to import On a MakerWorld model page with more than one print profile, the import panel offers "Print profiles": the one from the link (the default), all designer print profiles, or designer and community print profiles. Each profile's file is resolved in the page and imported in turn, added to the same model, with progress shown and a summary at the end. --- .../content/makerworld/downloadResolver.ts | 18 ++- extension/src/content/makerworld/pageData.ts | 23 +++- extension/src/content/panels/single.ts | 103 +++++++++++++++++- 3 files changed, 138 insertions(+), 6 deletions(-) diff --git a/extension/src/content/makerworld/downloadResolver.ts b/extension/src/content/makerworld/downloadResolver.ts index 8c3a101..4a1f339 100644 --- a/extension/src/content/makerworld/downloadResolver.ts +++ b/extension/src/content/makerworld/downloadResolver.ts @@ -78,6 +78,11 @@ async function captureViaRealClick(): Promise { return res && res.ok ? res.data : null; } +async function fetchInstanceDownloadUrl(instanceId: string, nonce: string | null): Promise { + const apiUrl = `https://makerworld.com/api/v1/design-service/instance/${instanceId}/f3mf?type=download&fileType=`; + return extractDownloadUrl(await fetchMakerworldApiJson(apiUrl, nonce)); +} + /** Mirrors the backend's two-step resolution (importResolvers.ts's resolveMakerworldDownloadUrl) * -- instance-scoped endpoint first, model-scoped as a fallback -- run from the page instead. */ async function resolveFromPageApi(pageUrl: string): Promise { @@ -86,8 +91,7 @@ async function resolveFromPageApi(pageUrl: string): Promise null); } + +/** One particular print profile's file, resolved from the page like the rest (for importing + * several profiles -- the page's Download button only ever gives the selected one). Null when it + * can't be; the backend then resolves that profile from a #profileId- link itself. */ +export async function resolveMakerworldProfileDownload(pageUrl: string, instanceId: string): Promise { + const page = readMakerworldDesignForPage(pageUrl); + if (!page) return null; + const downloadUrl = await fetchInstanceDownloadUrl(instanceId, page.nonce).catch(() => null); + return downloadUrl ? { downloadUrl, instanceId } : null; +} diff --git a/extension/src/content/makerworld/pageData.ts b/extension/src/content/makerworld/pageData.ts index 1f9b499..bd9ec4e 100644 --- a/extension/src/content/makerworld/pageData.ts +++ b/extension/src/content/makerworld/pageData.ts @@ -2,12 +2,18 @@ import { parseMakerworldModelUrl } from "../../shared/urls"; -export type MakerworldInstance = { id?: string | number; title?: string }; +export type MakerworldCreator = { uid?: string | number; name?: string }; +export type MakerworldInstance = { id?: string | number; title?: string; instanceCreator?: MakerworldCreator }; export type MakerworldDesign = { id?: string | number; defaultInstanceId?: string | number; + designCreator?: MakerworldCreator; instances?: MakerworldInstance[]; }; + +/** Which print profiles an import takes -- the panel's "Print profiles" choice. Same meaning as the + * backend's MakerworldProfileScope. */ +export type MakerworldProfileScope = "url" | "designer" | "all"; export type MakerworldPage = { design: MakerworldDesign; nonce: string | null; requestedInstanceId: string | null }; function readNextData(): unknown { @@ -57,6 +63,21 @@ export function pickMakerworldInstanceId(design: MakerworldDesign, requestedInst return instances.length ? String(instances[0].id) : null; } +/** Profile ids to import for `scope` -- mirrors the backend's selectMakerworldProfiles: the + * link's profile (else the default) first, then the designer's own profiles (a profile whose + * instanceCreator is the design's designCreator), or every profile. */ +export function makerworldProfileIds(design: MakerworldDesign, scope: MakerworldProfileScope, requestedInstanceId: string | null): string[] { + const primary = pickMakerworldInstanceId(design, requestedInstanceId); + if (!primary) return []; + if (scope === "url") return [primary]; + const designerUid = design.designCreator?.uid != null ? String(design.designCreator.uid) : null; + const instances = Array.isArray(design.instances) ? design.instances.filter((inst) => inst && inst.id != null) : []; + const wanted = instances + .filter((inst) => scope === "all" || (designerUid !== null && inst.instanceCreator?.uid != null && String(inst.instanceCreator.uid) === designerUid)) + .map((inst) => String(inst.id)); + return [primary, ...wanted.filter((id) => id !== primary)]; +} + /** The title of the print profile named in the page URL's hash -- null when there's no hash or * the page data is stale (see readMakerworldDesignForPage). */ export function currentMakerworldProfileTitle(pageUrl: string): string | null { diff --git a/extension/src/content/panels/single.ts b/extension/src/content/panels/single.ts index 55da330..58c3d80 100644 --- a/extension/src/content/panels/single.ts +++ b/extension/src/content/panels/single.ts @@ -5,10 +5,15 @@ import type { InspectResult, ZipEntriesResult } from "../../shared/api"; import { request } from "../../shared/messages"; import { ctx } from "../context"; -import { resolveMakerworldDownloadUrl } from "../makerworld/downloadResolver"; -import { currentMakerworldProfileTitle } from "../makerworld/pageData"; +import { resolveMakerworldDownloadUrl, resolveMakerworldProfileDownload } from "../makerworld/downloadResolver"; +import { + currentMakerworldProfileTitle, + makerworldProfileIds, + readMakerworldDesignForPage, + type MakerworldProfileScope, +} from "../makerworld/pageData"; import { api, escapeHtml } from "../runtime"; -import { onPanelAction, panelQueryAll, renderPanel } from "../shell"; +import { onPanelAction, panelQuery, panelQueryAll, renderPanel } from "../shell"; import { collectionPickerHtml, selectedCollectionId } from "./collectionPicker"; import { errorHtml, statusHtml, successHtml } from "./results"; @@ -24,6 +29,40 @@ function guessPageTitle(): string | null { return og || document.title.trim() || null; } +// Between importing one print profile and the next: the resolution requests come from the page +// itself, but several in a quick burst still read as automated to MakerWorld. +const PROFILE_GAP_MS = 2000; + +/** The "Print profiles" choice, on a MakerWorld model page with more than one profile: the link's + * own (the default), every profile the designer uploaded, or those plus the community's. */ +function profilesPickerHtml(): string { + const { url, classification } = ctx(); + if (classification.provider !== "makerworld" || classification.type !== "model") return ""; + const page = readMakerworldDesignForPage(url); + if (!page) return ""; + const all = makerworldProfileIds(page.design, "all", page.requestedInstanceId).length; + if (all < 2) return ""; + const designer = makerworldProfileIds(page.design, "designer", page.requestedInstanceId).length; + return ` + + + `; +} + +function selectedProfileScope(): MakerworldProfileScope { + const value = panelQuery("#tg-profiles")?.value; + return value === "designer" || value === "all" ? value : "url"; +} + +/** `url` pointing at one particular print profile. */ +function profileUrl(url: string, instanceId: string): string { + return `${url.split("#")[0]}#profileId-${instanceId}`; +} + function importHeading(): string { const { title } = ctx(); return title ? `Import "${escapeHtml(title)}"` : "Import this model"; @@ -61,6 +100,7 @@ export async function loadSingleItem(): Promise { if (zipFilename === null) { renderPanel(`
${importHeading()}
+ ${profilesPickerHtml()} ${await collectionPickerHtml()} `); @@ -94,6 +134,7 @@ function addProfileHtml(): string { return `
In your library
${hint}
+ ${profilesPickerHtml()} Open model in Thingport `; @@ -134,6 +175,11 @@ async function runDirectImport(opts?: { entries?: string[] }): Promise { // Captured up front: if the page navigates (an SPA route change) while the import is in flight, // the context is cleared from under this still-running function (see unmount in index.ts). const collectionId = selectedCollectionId(); + const scope = selectedProfileScope(); + if (scope !== "url") { + await runProfilesImport(scope, collectionId); + return; + } const { url, instanceUrl, classification, title } = ctx(); renderPanel(statusHtml("Importing…")); // Only meaningful for a MakerWorld model page -- see downloadResolver.ts for why resolving it @@ -158,3 +204,54 @@ async function runDirectImport(opts?: { entries?: string[] }): Promise { renderPanel(errorHtml(err)); } } + +/** Imports several print profiles of the MakerWorld model on this page, one after another: the + * first creates the model (or finds it in the library), each later one is added to it as another + * file -- the backend skips any it already has. Each profile's file is resolved in the page, as + * for a single import. Stops early on a MakerWorld CAPTCHA, which would fail every later one. */ +async function runProfilesImport(scope: MakerworldProfileScope, collectionId: string | null): Promise { + const { url, instanceUrl, title } = ctx(); + const page = readMakerworldDesignForPage(url); + const ids = page ? makerworldProfileIds(page.design, scope, page.requestedInstanceId) : []; + if (!ids.length) { + renderPanel(errorHtml(new Error("Couldn't read this model's print profiles. Reload the page and try again."))); + return; + } + + let added = 0; + let already = 0; + let failed = 0; + let lastError: unknown = null; + let printId: string | null = null; + for (const [index, instanceId] of ids.entries()) { + renderPanel(statusHtml(`Importing print profile ${index + 1} of ${ids.length}…`)); + if (index > 0) await new Promise((resolve) => setTimeout(resolve, PROFILE_GAP_MS)); + // The first is the link's own profile: the page's real Download button gives exactly that one. + const resolved = + index === 0 + ? await resolveMakerworldDownloadUrl(url).catch(() => null) + : await resolveMakerworldProfileDownload(url, instanceId).catch(() => null); + try { + const print = await request("IMPORT_SINGLE", { url: index === 0 ? url : profileUrl(url, instanceId), collectionId, resolved, title }); + printId = print?.id ?? printId; + if (print?.import_outcome === "already_imported") already++; + else added++; + } catch (err) { + failed++; + lastError = err; + if (err instanceof Error && /captcha/i.test(err.message)) { + failed += ids.length - index - 1; + break; + } + } + } + + if (!printId) { + renderPanel(errorHtml(lastError ?? new Error("Import failed"))); + return; + } + const parts = [`${added} print profile${added === 1 ? "" : "s"} imported`]; + if (already) parts.push(`${already} already on the model`); + if (failed) parts.push(`${failed} failed`); + renderPanel(successHtml(`${instanceUrl}/models/${printId}`, `${parts.join(", ")}.`, failed ? "Partly imported" : undefined)); +}