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.
This commit is contained in:
Tautvydas Derzinskas committed 2026-09-28 01:10:09 +02:00
1 parent 101aa03f9c
commit fc612b30c0
3 files changed
+138 -6

No files matched your search

@@ -78,6 +78,11 @@ async function captureViaRealClick(): Promise<string | null> {
return res && res.ok ? res.data : null;
}
async function fetchInstanceDownloadUrl(instanceId: string, nonce: string | null): Promise<string | null> {
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<ResolvedDownload | null> {
@@ -86,8 +91,7 @@ async function resolveFromPageApi(pageUrl: string): Promise<ResolvedDownload | n
const { design, nonce } = page;
const instanceId = pickMakerworldInstanceId(design, page.requestedInstanceId);
if (instanceId) {
const apiUrl = `https://makerworld.com/api/v1/design-service/instance/${instanceId}/f3mf?type=download&fileType=`;
const downloadUrl = extractDownloadUrl(await fetchMakerworldApiJson(apiUrl, nonce));
const downloadUrl = await fetchInstanceDownloadUrl(instanceId, nonce);
if (downloadUrl) return { downloadUrl, instanceId };
}
// Model-level, i.e. the default profile's file.
@@ -110,3 +114,13 @@ export async function resolveMakerworldDownloadUrl(pageUrl: string): Promise<Res
}
return resolveFromPageApi(pageUrl).catch(() => 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<ResolvedDownload | null> {
const page = readMakerworldDesignForPage(pageUrl);
if (!page) return null;
const downloadUrl = await fetchInstanceDownloadUrl(instanceId, page.nonce).catch(() => null);
return downloadUrl ? { downloadUrl, instanceId } : null;
}
+22 -1
View File
@@ -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 {
+100 -3
View File
@@ -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 `
<label class="tg-label" for="tg-profiles">Print profiles</label>
<select id="tg-profiles" class="tg-select">
<option value="url">Print profile from the link</option>
<option value="designer">All designer print profiles (${designer})</option>
<option value="all">Designer &amp; community print profiles (${all})</option>
</select>
`;
}
function selectedProfileScope(): MakerworldProfileScope {
const value = panelQuery<HTMLSelectElement>("#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<void> {
if (zipFilename === null) {
renderPanel(`
<div class="tg-title">${importHeading()}</div>
${profilesPickerHtml()}
${await collectionPickerHtml()}
<button class="tg-btn" type="button" data-action="import">Import</button>
`);
@@ -94,6 +134,7 @@ function addProfileHtml(): string {
return `
<div class="tg-title">In your library</div>
<div class="tg-hint">${hint}</div>
${profilesPickerHtml()}
<button class="tg-btn" type="button" data-action="import">Add profile</button>
<a class="tg-btn tg-btn--secondary" href="${escapeHtml(modelLink)}" target="_blank" rel="noopener noreferrer">Open model in Thingport</a>
`;
@@ -134,6 +175,11 @@ async function runDirectImport(opts?: { entries?: string[] }): Promise<void> {
// 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<void> {
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<void> {
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));
}