mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-18 20:59:55 -05:00
fix(extension): send devmodelname and match makerworld button design
This commit is contained in:
1 parent
2380c566e5
commit
955513c5c5
5 files changed
+187
-59
No files matched your search
+4
-4
@@ -107,8 +107,8 @@ As a developer-mode "unpacked" extension, until the Chrome Web Store listing is
|
||||
|
||||
MakerWorld's files are Bambu Studio projects, which PrusaSlicer, Cura, Anycubic Slicer Next,
|
||||
Creality Print, Elegoo Slicer and Snapmaker Orca often open without their colors or print settings.
|
||||
On a MakerWorld model page, the extension adds a **Download normalized** button next to MakerWorld's
|
||||
own Download button. Clicking it downloads the selected print profile's file, converts it, and saves
|
||||
On a MakerWorld model page, the extension adds a **Download normalized** button under MakerWorld's
|
||||
own "Open in Bambu Studio" (or Download) button. Clicking it downloads the selected print profile's file, converts it, and saves
|
||||
a `<name>-normalized.3mf` that keeps painted colors, filament colors, plates and the designer's
|
||||
settings (walls, layer height, infill, supports). Multi-part objects may be merged into one,
|
||||
per-object overrides and layer color changes are dropped, and you pick your own printer profile.
|
||||
@@ -120,8 +120,8 @@ Hover the bulb on the button for the same summary.
|
||||
- **Where it runs:** entirely in your browser, the same conversion Thingport's "Open normalized"
|
||||
does on the server. The file goes nowhere but your downloads folder.
|
||||
- **Limits:** you need to be signed in to MakerWorld, as for its own Download button, and files over
|
||||
50 MB are too big to convert in the browser -- the button says so. It's found by its label, so it
|
||||
only appears while MakerWorld is in English.
|
||||
50 MB are too big to convert in the browser -- the button says so. MakerWorld's button is found by
|
||||
its label, so it only appears while MakerWorld is in English.
|
||||
|
||||
## Setup
|
||||
|
||||
|
||||
@@ -5,11 +5,25 @@
|
||||
import type { ResolvedDownload } from "../../shared/messages";
|
||||
import { send } from "../../shared/messages";
|
||||
import { parseMakerworldModelUrl } from "../../shared/urls";
|
||||
import { makerworldDesignForImport, pickMakerworldInstanceId, readMakerworldDesignForPage } from "./pageData";
|
||||
import {
|
||||
loadMakerworldDesignForPage,
|
||||
makerworldDesignForImport,
|
||||
pickMakerworldInstanceId,
|
||||
readMakerworldDesignForPage,
|
||||
type MakerworldDesign,
|
||||
} from "./pageData";
|
||||
|
||||
// fetch() has no timeout, and some endpoints hold an interactive challenge open indefinitely.
|
||||
const MAKERWORLD_API_FETCH_TIMEOUT_MS = 8000;
|
||||
|
||||
// What MakerWorld's own web client sends with its API calls; it answers 403 without them.
|
||||
const MAKERWORLD_CLIENT_HEADERS = {
|
||||
"X-BBL-Client-Name": "MakerWorld",
|
||||
"X-BBL-Client-Type": "web",
|
||||
"X-BBL-Client-Version": "00.00.00.01",
|
||||
"X-BBL-App-Source": "makerworld",
|
||||
};
|
||||
|
||||
/** {url|downloadUrl|download_url}, optionally nested under `data`. */
|
||||
function extractDownloadUrl(data: unknown): string | null {
|
||||
if (!data || typeof data !== "object" || Array.isArray(data)) return null;
|
||||
@@ -29,7 +43,7 @@ async function fetchMakerworldApiJson(url: string, nonce: string | null): Promis
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), MAKERWORLD_API_FETCH_TIMEOUT_MS);
|
||||
try {
|
||||
const headers: Record<string, string> = { Accept: "application/json" };
|
||||
const headers: Record<string, string> = { Accept: "application/json", ...MAKERWORLD_CLIENT_HEADERS };
|
||||
if (nonce) headers["X-Nonce"] = nonce;
|
||||
const res = await fetch(url, { headers, signal: controller.signal });
|
||||
if (!res.ok) return null;
|
||||
@@ -42,14 +56,11 @@ async function fetchMakerworldApiJson(url: string, nonce: string | null): Promis
|
||||
}
|
||||
|
||||
/** Matched by text (class names are build hashes), visible elements only. */
|
||||
export function isDownloadButton(el: Element): el is HTMLElement {
|
||||
if (!/^download$/i.test((el.textContent || "").trim())) return false;
|
||||
return (el as HTMLElement).offsetParent !== null; // hidden (display:none or detached)
|
||||
}
|
||||
|
||||
export function findDownloadButton(): HTMLElement | null {
|
||||
function findDownloadButton(): HTMLElement | null {
|
||||
for (const el of document.querySelectorAll<HTMLElement>("button, a")) {
|
||||
if (isDownloadButton(el)) return el;
|
||||
if (!/^download$/i.test((el.textContent || "").trim())) continue;
|
||||
if (el.offsetParent === null) continue; // hidden (display:none or detached)
|
||||
return el;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -66,19 +77,31 @@ 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));
|
||||
/** The printer the profile was made for, which MakerWorld's own download request names. */
|
||||
function instanceDevModelName(design: MakerworldDesign, instanceId: string): string {
|
||||
const instance = design.instances?.find((inst) => inst && String(inst.id) === instanceId) as
|
||||
Record<string, unknown> | undefined;
|
||||
const extension = instance?.extention as { modelInfo?: { compatibility?: { devModelName?: unknown } } } | undefined;
|
||||
const name = extension?.modelInfo?.compatibility?.devModelName;
|
||||
return typeof name === "string" ? name : "";
|
||||
}
|
||||
|
||||
/** Sent exactly as MakerWorld's own "Download 3MF" sends it. */
|
||||
async function fetchInstanceDownloadUrl(design: MakerworldDesign, instanceId: string): Promise<string | null> {
|
||||
const devModelName = encodeURIComponent(instanceDevModelName(design, instanceId));
|
||||
const apiUrl = `https://makerworld.com/api/v1/design-service/instance/${instanceId}/f3mf?type=download&fileType=&devModelName=${devModelName}`;
|
||||
return extractDownloadUrl(await fetchMakerworldApiJson(apiUrl, null));
|
||||
}
|
||||
|
||||
/** Same two steps as the backend: instance-scoped endpoint, then model-scoped. */
|
||||
async function resolveFromPageApi(pageUrl: string): Promise<ResolvedDownload | null> {
|
||||
const page = readMakerworldDesignForPage(pageUrl);
|
||||
// Fetched when the tab's own page data is stale, i.e. after clicking through MakerWorld.
|
||||
const page = await loadMakerworldDesignForPage(pageUrl);
|
||||
if (!page) return null; // the backend resolves from the URL
|
||||
const { design, nonce } = page;
|
||||
const instanceId = pickMakerworldInstanceId(design, page.requestedInstanceId);
|
||||
if (instanceId) {
|
||||
const downloadUrl = await fetchInstanceDownloadUrl(instanceId, nonce);
|
||||
const downloadUrl = await fetchInstanceDownloadUrl(design, instanceId);
|
||||
if (downloadUrl) return { downloadUrl, instanceId, design: makerworldDesignForImport(design) };
|
||||
}
|
||||
// Model-level, i.e. the default profile's file.
|
||||
@@ -111,8 +134,8 @@ export async function resolveMakerworldProfileDownload(
|
||||
pageUrl: string,
|
||||
instanceId: string,
|
||||
): Promise<ResolvedDownload | null> {
|
||||
const page = readMakerworldDesignForPage(pageUrl);
|
||||
const page = await loadMakerworldDesignForPage(pageUrl);
|
||||
if (!page) return null;
|
||||
const downloadUrl = await fetchInstanceDownloadUrl(instanceId, page.nonce).catch(() => null);
|
||||
const downloadUrl = await fetchInstanceDownloadUrl(page.design, instanceId).catch(() => null);
|
||||
return downloadUrl ? { downloadUrl, instanceId, design: makerworldDesignForImport(page.design) } : null;
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
// "Download normalized" next to MakerWorld's own Download button, for people whose slicer mis-reads
|
||||
// "Download normalized" next to MakerWorld's own download/open button, for people whose slicer mis-reads
|
||||
// Bambu Studio projects: not linked to a Thingport instance at all, or linked with such a slicer
|
||||
// picked. The file is downloaded and converted in this tab; it never goes anywhere else.
|
||||
|
||||
import { createIcon } from "../../shared/icon";
|
||||
import { NORMALIZE_3MF_SLICERS } from "../../shared/slicers";
|
||||
import { api } from "../runtime";
|
||||
import css from "../styles/normalized.scss?inline";
|
||||
import { findDownloadButton, isDownloadButton, resolveMakerworldDownloadUrl } from "./downloadResolver";
|
||||
import { resolveMakerworldDownloadUrl } from "./downloadResolver";
|
||||
import { normalizeBambu3mf } from "./normalize";
|
||||
|
||||
// Converting holds the file, its unzipped model XML and the result in memory at once.
|
||||
@@ -32,6 +33,7 @@ function bulbIcon(): Element {
|
||||
|
||||
let host: HTMLElement | null = null;
|
||||
let observer: MutationObserver | null = null;
|
||||
let themeObserver: MutationObserver | null = null;
|
||||
let bubbleTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
function explanation(slicerLabel: string | null): string {
|
||||
@@ -143,11 +145,13 @@ function buildHost(pageUrl: string, slicerLabel: string | null): HTMLElement {
|
||||
const button = element("button", "tg-nd");
|
||||
button.type = "button";
|
||||
button.setAttribute("aria-describedby", "tg-nd-info");
|
||||
const icon = element("span", "tg-nd__info");
|
||||
icon.append(bulbIcon());
|
||||
const logo = element("span", "tg-nd__logo");
|
||||
logo.append(createIcon());
|
||||
const label = element("span", "tg-nd__label");
|
||||
label.textContent = LABEL;
|
||||
button.append(icon, label);
|
||||
const icon = element("span", "tg-nd__info");
|
||||
icon.append(bulbIcon());
|
||||
button.append(logo, label, icon);
|
||||
const bubble = element("div", "tg-nd-bubble");
|
||||
bubble.id = "tg-nd-info";
|
||||
bubble.setAttribute("role", "tooltip");
|
||||
@@ -163,7 +167,9 @@ function buildHost(pageUrl: string, slicerLabel: string | null): HTMLElement {
|
||||
const rect = button.getBoundingClientRect();
|
||||
const left = Math.min(Math.max(8, rect.left), window.innerWidth - bubble.offsetWidth - 8);
|
||||
bubble.style.left = `${left}px`;
|
||||
bubble.style.top = `${rect.bottom + 8}px`;
|
||||
// Above when there's no room below, e.g. in MakerWorld's bottom-stuck action bar.
|
||||
const below = rect.bottom + 8 + bubble.offsetHeight <= window.innerHeight;
|
||||
bubble.style.top = `${below ? rect.bottom + 8 : Math.max(8, rect.top - 8 - bubble.offsetHeight)}px`;
|
||||
if (error) bubbleTimer = setTimeout(hideBubble, ERROR_SHOW_MS);
|
||||
};
|
||||
const hideBubble = () => {
|
||||
@@ -197,43 +203,100 @@ function buildHost(pageUrl: string, slicerLabel: string | null): HTMLElement {
|
||||
return el;
|
||||
}
|
||||
|
||||
/** Keeps the button right after MakerWorld's, which can render late or be re-rendered away. */
|
||||
// MakerWorld's main action, matched by label (class names are build hashes): usually the "Open in
|
||||
// Bambu Studio" split button, whose menu holds "Download 3MF" and "Download STL/CAD Files".
|
||||
const ACTION_LABELS = [/^open in .+/i, /^download\b/i];
|
||||
const MAX_LABEL_LENGTH = 40;
|
||||
|
||||
function findActionLabel(): HTMLElement | null {
|
||||
const candidates = [...document.querySelectorAll<HTMLElement>("button, a, span, div")].filter((el) => {
|
||||
if (el.childElementCount > 2 || el.offsetParent === null) return false;
|
||||
const text = (el.textContent || "").trim();
|
||||
return text.length <= MAX_LABEL_LENGTH && ACTION_LABELS.some((label) => label.test(text));
|
||||
});
|
||||
// Innermost, so wrappers repeating the same text don't win.
|
||||
const leaves = candidates.filter((el) => !candidates.some((other) => other !== el && el.contains(other)));
|
||||
for (const label of ACTION_LABELS) {
|
||||
const match = leaves.find((el) => label.test((el.textContent || "").trim()));
|
||||
if (match) return match;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The clickable element around the label, then the outermost wrapper that's still just the
|
||||
* control (e.g. both halves of the split button). */
|
||||
function controlBox(label: HTMLElement): HTMLElement {
|
||||
let box = label.closest<HTMLElement>("button, a, [role=button]") ?? label;
|
||||
// The cursor is inherited, so the control is the outermost element that still has it.
|
||||
while (
|
||||
box.parentElement &&
|
||||
box.parentElement !== document.body &&
|
||||
getComputedStyle(box.parentElement).cursor === "pointer"
|
||||
)
|
||||
box = box.parentElement;
|
||||
const { width, height } = box.getBoundingClientRect();
|
||||
for (let parent = box.parentElement; parent && parent !== document.body; parent = parent.parentElement) {
|
||||
const rect = parent.getBoundingClientRect();
|
||||
// Grows only by the split button's arrow, never by a whole extra row.
|
||||
if (Math.abs(rect.height - height) > 1 || rect.width > width * 1.5) break;
|
||||
box = parent;
|
||||
}
|
||||
return box;
|
||||
}
|
||||
|
||||
let anchor: HTMLElement | null = null;
|
||||
|
||||
/** Keeps the button next to MakerWorld's, which can render late or be re-rendered away. */
|
||||
function place(): void {
|
||||
if (!host) return;
|
||||
const anchor = host.previousElementSibling;
|
||||
if (host.isConnected && anchor && isDownloadButton(anchor)) return;
|
||||
const button = findDownloadButton();
|
||||
if (!button) {
|
||||
if (host.isConnected && anchor?.isConnected && anchor.offsetParent !== null && host.previousElementSibling === anchor)
|
||||
return;
|
||||
const label = findActionLabel();
|
||||
if (!label) {
|
||||
host.remove();
|
||||
anchor = null;
|
||||
return;
|
||||
}
|
||||
button.after(host);
|
||||
// A flex/grid gap already spaces it; the margin is for plain inline layouts.
|
||||
const gap = getComputedStyle(button.parentElement!).columnGap;
|
||||
host.style.marginLeft = gap && gap !== "normal" && gap !== "0px" ? "0" : "";
|
||||
anchor = controlBox(label);
|
||||
anchor.after(host);
|
||||
// Beside it in a row, otherwise underneath at full width; a gap already spaces it.
|
||||
const parent = getComputedStyle(anchor.parentElement!);
|
||||
const inRow = /flex/.test(parent.display) && parent.flexDirection.startsWith("row");
|
||||
const gap = inRow ? parent.columnGap : parent.rowGap;
|
||||
host.toggleAttribute("data-below", !inRow);
|
||||
host.toggleAttribute("data-gapped", Boolean(gap) && gap !== "normal" && gap !== "0px");
|
||||
}
|
||||
|
||||
function mount(pageUrl: string, slicerLabel: string | null): void {
|
||||
unmountNormalizedDownload();
|
||||
host = buildHost(pageUrl, slicerLabel);
|
||||
let scheduled = false;
|
||||
observer = new MutationObserver(() => {
|
||||
const schedule = () => {
|
||||
if (scheduled) return;
|
||||
scheduled = true;
|
||||
requestAnimationFrame(() => {
|
||||
scheduled = false;
|
||||
place();
|
||||
});
|
||||
});
|
||||
};
|
||||
observer = new MutationObserver(schedule);
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
// MakerWorld sets its theme as a class on <html>, which the shadow root's CSS can't see.
|
||||
const syncTheme = () => host?.toggleAttribute("data-dark", document.documentElement.classList.contains("dark"));
|
||||
themeObserver = new MutationObserver(syncTheme);
|
||||
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
|
||||
syncTheme();
|
||||
place();
|
||||
}
|
||||
|
||||
export function unmountNormalizedDownload(): void {
|
||||
observer?.disconnect();
|
||||
observer = null;
|
||||
themeObserver?.disconnect();
|
||||
themeObserver = null;
|
||||
host?.remove();
|
||||
host = null;
|
||||
anchor = null;
|
||||
clearTimeout(bubbleTimer);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// The "Download normalized" button next to MakerWorld's own Download button (see
|
||||
// The "Download normalized" button next to MakerWorld's own download/open button (see
|
||||
// makerworld/normalizedDownload.ts). Its own shadow root, so it sits in the page's layout without
|
||||
// sharing styles either way. Warning-colored like the web app's "Open normalized" button.
|
||||
// sharing styles either way.
|
||||
@use "../../styles/tokens" as t;
|
||||
@use "../../styles/mixins" as m;
|
||||
|
||||
@@ -12,24 +12,50 @@
|
||||
margin-left: t.$space-2;
|
||||
}
|
||||
|
||||
// Under a full-width button, like MakerWorld's split "Open in Bambu Studio" one.
|
||||
:host([data-below]) {
|
||||
display: flex;
|
||||
margin: t.$space-3 0 0;
|
||||
}
|
||||
|
||||
:host([data-gapped]) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
// MakerWorld's primary action button (its "Open in Bambu Studio" split button), copied as-is.
|
||||
.tg-nd {
|
||||
@include m.isolated(inline-flex);
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 36px;
|
||||
padding: 6px 14px 6px 10px;
|
||||
border: 1.5px solid t.c("warning");
|
||||
border-radius: t.$radius-md;
|
||||
background: t.c("surface");
|
||||
color: t.c("warning");
|
||||
font-weight: 600;
|
||||
gap: t.$space-2;
|
||||
min-height: 48px;
|
||||
padding: 0 16px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: #00b800;
|
||||
color: #ffffff;
|
||||
font:
|
||||
700 16px/24px "Open Sans",
|
||||
system-ui,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
sans-serif;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
transition: filter 0.15s ease;
|
||||
@include m.focus-ring;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: t.c("warning-soft");
|
||||
filter: brightness(0.94);
|
||||
}
|
||||
|
||||
// MakerWorld's dark theme: a lighter green with dark text.
|
||||
:host([data-dark]) & {
|
||||
background: #08bf08;
|
||||
color: #242424;
|
||||
}
|
||||
|
||||
:host([data-below]) & {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
@@ -37,22 +63,42 @@
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
&__label {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__logo,
|
||||
&__info {
|
||||
display: inline-flex;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
fill: currentColor;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
// The logo takes the text color, so it shows on any button color.
|
||||
&__logo path {
|
||||
fill: currentColor;
|
||||
}
|
||||
&__logo path[stroke] {
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
}
|
||||
|
||||
&__info svg {
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
&__spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin: 2px;
|
||||
box-sizing: border-box;
|
||||
border: 2px solid currentColor;
|
||||
border-right-color: transparent;
|
||||
|
||||
@@ -41,8 +41,6 @@ $palette-light: (
|
||||
"hover": rgba(0, 0, 0, 0.06),
|
||||
"danger": #d32f2f,
|
||||
"danger-soft": rgba(211, 47, 47, 0.08),
|
||||
"warning": #ed6c02,
|
||||
"warning-soft": rgba(237, 108, 2, 0.08),
|
||||
"switch-off": #c4c4c4,
|
||||
"backdrop": rgba(0, 0, 0, 0.5),
|
||||
"backdrop-strong": rgba(0, 0, 0, 0.75),
|
||||
@@ -64,8 +62,6 @@ $palette-dark: map.merge(
|
||||
"hover": rgba(255, 255, 255, 0.08),
|
||||
"danger": #ef6b6b,
|
||||
"danger-soft": rgba(239, 107, 107, 0.12),
|
||||
"warning": #ffa726,
|
||||
"warning-soft": rgba(255, 167, 38, 0.12),
|
||||
"switch-off": #555555,
|
||||
"shadow": rgba(0, 0, 0, 0.6),
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user