fix(extension): show print profile choices after navigating within MakerWorld

MakerWorld only writes its page data (__NEXT_DATA__) on a full page load, so
after clicking through to another model it still describes the first one, and
the import panel had no profile list to offer. The extension now fetches the
current model's page for its data when the embedded data is stale, and keeps it
for that model while the tab is open, which also restores per-profile download
resolution after in-app navigation.

The "Print profiles" options now depend on the model's profiles: "all designer
print profiles" only when the designer uploaded more than one, and "designer &
community" only when there are community profiles. The first option is renamed
"Currently selected print profile".
This commit is contained in:
Tautvydas Derzinskas committed 2026-09-28 01:29:48 +02:00
1 parent fc612b30c0
commit 484efff1c9
2 files changed
+62 -27

No files matched your search

+46 -12
View File
@@ -35,23 +35,57 @@ function getPath(obj: unknown, ...keys: string[]): unknown {
return current;
}
/** The design and nonce out of a __NEXT_DATA__ blob, if it's for `designId`. */
function designFromNextData(nextData: unknown, designId: string): Omit<MakerworldPage, "requestedInstanceId"> | null {
const design = getPath(nextData, "props", "pageProps", "design") as MakerworldDesign | undefined;
if (!design || typeof design !== "object" || design.id == null || String(design.id) !== designId) return null;
const nonce = getPath(nextData, "props", "pageProps", "x-nonce");
return { design, nonce: typeof nonce === "string" && nonce.trim() ? nonce : null };
}
// Page data fetched for models reached by a client-side route change (see
// loadMakerworldDesignForPage), by design id -- for this tab's lifetime.
const fetchedDesigns = new Map<string, Omit<MakerworldPage, "requestedInstanceId">>();
/** The page's MakerWorld design data, or null if missing -- or stale: Next.js only writes
* __NEXT_DATA__ on a full page load, so after a client-side route change from one model to
* another it still describes the first-loaded design, and resolving from it would pair this
* page's metadata with that other model's 3MF. */
* page's metadata with that other model's 3MF. Then the model's own page data, if
* loadMakerworldDesignForPage has fetched it. */
export function readMakerworldDesignForPage(pageUrl: string): MakerworldPage | null {
const nextData = readNextData();
if (!nextData) return null;
const design = getPath(nextData, "props", "pageProps", "design") as MakerworldDesign | undefined;
if (!design || typeof design !== "object") return null;
const expected = parseMakerworldModelUrl(pageUrl);
if (!expected || design.id == null || String(design.id) !== expected.designId) return null;
const nonce = getPath(nextData, "props", "pageProps", "x-nonce");
return {
design,
nonce: typeof nonce === "string" && nonce.trim() ? nonce : null,
requestedInstanceId: expected.requestedInstanceId,
};
if (!expected) return null;
const found = designFromNextData(readNextData(), expected.designId) ?? fetchedDesigns.get(expected.designId) ?? null;
return found ? { ...found, requestedInstanceId: expected.requestedInstanceId } : null;
}
const PAGE_FETCH_TIMEOUT_MS = 10000;
/** readMakerworldDesignForPage, fetching the model's own page when the one in the tab is stale --
* i.e. when the user got here by clicking through MakerWorld, which is the usual way. The same
* request a reload would make (same origin, the user's own session), made once per model: without
* it there's no profile list to offer and nothing to resolve downloads from. */
export async function loadMakerworldDesignForPage(pageUrl: string): Promise<MakerworldPage | null> {
const current = readMakerworldDesignForPage(pageUrl);
if (current) return current;
const expected = parseMakerworldModelUrl(pageUrl);
if (!expected) return null;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), PAGE_FETCH_TIMEOUT_MS);
try {
const res = await fetch(pageUrl.split("#")[0], { headers: { Accept: "text/html" }, signal: controller.signal });
if (!res.ok) return null;
const html = await res.text();
const el = new DOMParser().parseFromString(html, "text/html").getElementById("__NEXT_DATA__");
const found = el?.textContent ? designFromNextData(JSON.parse(el.textContent), expected.designId) : null;
if (!found) return null;
fetchedDesigns.set(expected.designId, found);
return { ...found, requestedInstanceId: expected.requestedInstanceId };
} catch {
return null;
} finally {
clearTimeout(timeout);
}
}
/** Same precedence as the backend's resolveMakerworldViaCloudApi: the requested profile (only if
+16 -15
View File
@@ -8,8 +8,8 @@ import { ctx } from "../context";
import { resolveMakerworldDownloadUrl, resolveMakerworldProfileDownload } from "../makerworld/downloadResolver";
import {
currentMakerworldProfileTitle,
loadMakerworldDesignForPage,
makerworldProfileIds,
readMakerworldDesignForPage,
type MakerworldProfileScope,
} from "../makerworld/pageData";
import { api, escapeHtml } from "../runtime";
@@ -33,23 +33,24 @@ function guessPageTitle(): string | null {
// 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 {
/** The "Print profiles" choice, on a MakerWorld model page with more than one profile: the one
* selected on the page (the default -- the web app calls it "Print profile from the link"),
* then whichever of "all the designer's" and "the designer's and the community's" would import
* more than that -- the latter only when there are community ones. */
async function profilesPickerHtml(): Promise<string> {
const { url, classification } = ctx();
if (classification.provider !== "makerworld" || classification.type !== "model") return "";
const page = readMakerworldDesignForPage(url);
const page = await loadMakerworldDesignForPage(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;
const options = [`<option value="url">Currently selected print profile</option>`];
if (designer > 1) options.push(`<option value="designer">All designer print profiles (${designer})</option>`);
if (all > designer) options.push(`<option value="all">Designer &amp; community print profiles (${all})</option>`);
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>
<select id="tg-profiles" class="tg-select">${options.join("")}</select>
`;
}
@@ -72,7 +73,7 @@ export async function loadSingleItem(): Promise<void> {
// Nothing inspect would tell us matters for an add-profile (a MakerWorld profile is always a
// single 3MF), and inspecting would make the backend resolve the download just to show the panel.
if (ctx().library) {
renderPanel(addProfileHtml());
renderPanel(addProfileHtml(await profilesPickerHtml()));
onPanelAction("import", () => void runDirectImport());
return;
}
@@ -100,7 +101,7 @@ export async function loadSingleItem(): Promise<void> {
if (zipFilename === null) {
renderPanel(`
<div class="tg-title">${importHeading()}</div>
${profilesPickerHtml()}
${await profilesPickerHtml()}
${await collectionPickerHtml()}
<button class="tg-btn" type="button" data-action="import">Import</button>
`);
@@ -122,7 +123,7 @@ export async function loadSingleItem(): Promise<void> {
/** The model's already in the library, but this page's print profile isn't known to be -- each
* MakerWorld profile has its own 3MF, stored as its own file on the one model. No collection
* picker: the model's collections are already whatever they are. */
function addProfileHtml(): string {
function addProfileHtml(profilesPicker: string): string {
const { library, url, instanceUrl } = ctx();
const profileName = currentMakerworldProfileTitle(url);
const profileLabel = profileName ? `the "${escapeHtml(profileName)}" profile` : "this print profile";
@@ -134,7 +135,7 @@ function addProfileHtml(): string {
return `
<div class="tg-title">In your library</div>
<div class="tg-hint">${hint}</div>
${profilesPickerHtml()}
${profilesPicker}
<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>
`;
@@ -211,7 +212,7 @@ async function runDirectImport(opts?: { entries?: string[] }): Promise<void> {
* 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 page = await loadMakerworldDesignForPage(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.")));