mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-21 22:22:24 -05:00
fix: Makerworld import issues + allowing import different profiles
This commit is contained in:
1 parent
e5eb3f9818
commit
303ab918ea
16 files changed
+559
-92
No files matched your search
@@ -0,0 +1,6 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Plate" ADD COLUMN "sourceInstanceId" TEXT,
|
||||
ADD COLUMN "contentSha256" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Plate_printId_sourceInstanceId_key" ON "Plate"("printId", "sourceInstanceId");
|
||||
@@ -343,19 +343,29 @@ model AuthorLink {
|
||||
}
|
||||
|
||||
model Plate {
|
||||
id String @id @default(cuid())
|
||||
printId String
|
||||
print Print @relation(fields: [printId], references: [id], onDelete: Cascade)
|
||||
position Int
|
||||
filename String
|
||||
mime String
|
||||
size Int @default(0)
|
||||
storagePath String @unique
|
||||
sourcePath String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id String @id @default(cuid())
|
||||
printId String
|
||||
print Print @relation(fields: [printId], references: [id], onDelete: Cascade)
|
||||
position Int
|
||||
filename String
|
||||
mime String
|
||||
size Int @default(0)
|
||||
storagePath String @unique
|
||||
sourcePath String?
|
||||
// The MakerWorld print profile ("instance") this file was imported from -- one Print per
|
||||
// MakerWorld design, one Plate per imported profile, since each profile's 3MF carries its own
|
||||
// print settings and can't be merged. Null for everything else, and for plates imported before
|
||||
// this was tracked (see importService.ts's addMakerworldProfileToPrint for how those get adopted).
|
||||
sourceInstanceId String?
|
||||
// SHA-256 (hex) of the file, computed lazily and only when needed: matching a freshly
|
||||
// downloaded MakerWorld profile against untagged plates (addMakerworldProfileToPrint). Plate
|
||||
// files are never rewritten in place, so once set it stays valid.
|
||||
contentSha256 String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([printId, position])
|
||||
@@unique([printId, sourceInstanceId])
|
||||
@@index([printId])
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ const importRequestSchema = z.object({
|
||||
// fallback) entirely, since those are the only two call sites that can trip MakerWorld's
|
||||
// CAPTCHA and its 2-hour blanket cooloff (see makerworldCaptcha.ts).
|
||||
resolved_download_url: z.string().nullable().optional(),
|
||||
resolved_instance_id: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
// The frontend normally sends the browser's own locally-stored MakerWorld cookie on every
|
||||
@@ -70,8 +71,12 @@ router.post(
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = await withStoredMakerworldCookie(req.userId!, parseBody(importRequestSchema, req.body));
|
||||
const url = await normalizeImportUrl(body.url);
|
||||
const { print, plates, author, previewImages } = await importPrintFromUrl(req.userId!, url, body);
|
||||
res.json(toPrintOut(print, plates, [], null, author, previewImages));
|
||||
const result = await importPrintFromUrl(req.userId!, url, body);
|
||||
const { print, plates, author, previewImages } = result;
|
||||
// Lets a client tell "added this MakerWorld profile to a model you already had" apart from
|
||||
// a fresh import and from a no-op (see importService.ts's addMakerworldProfileToPrint).
|
||||
const importOutcome = result.alreadyImported ? "already_imported" : result.profileAdded ? "profile_added" : "created";
|
||||
res.json({ ...toPrintOut(print, plates, [], null, author, previewImages), import_outcome: importOutcome });
|
||||
void createLog({ userId: req.userId!, action: "model_imported", targetId: print.id, details: { name: print.name, url } });
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -471,8 +471,16 @@ export type ImportedPageMetadata = {
|
||||
* resolveCategoryIdByCategory). Empty/null when the resolver doesn't expose categories. */
|
||||
siteCategoryIds: number[];
|
||||
categorySite: "makerworld" | "thingiverse" | "printables" | null;
|
||||
/** Which MakerWorld print profile the resolved download belongs to, when known -- lets
|
||||
* importPrintFromUrl add a second profile of an already-imported design as its own plate
|
||||
* instead of treating it as a duplicate. Unset for every other provider. */
|
||||
makerworldProfile?: MakerworldProfileRef;
|
||||
};
|
||||
|
||||
/** `instanceId` is the profile the downloaded file came from; null when the resolver couldn't
|
||||
* tell. */
|
||||
export type MakerworldProfileRef = { instanceId: string | null };
|
||||
|
||||
export type ImportedAuthorInfo = {
|
||||
provider: string;
|
||||
externalId: string;
|
||||
@@ -521,9 +529,12 @@ export function extractPageMetadata(html: string, pageHost: string): ImportedPag
|
||||
return meta;
|
||||
}
|
||||
|
||||
function makerworldInstanceIdFromNextData(data: unknown): string | null {
|
||||
/** Same precedence as makerworldCloudApi.ts's resolveMakerworldViaCloudApi: the profile named
|
||||
* in the URL hash (only if this design actually has it), then the default, then the first. */
|
||||
function makerworldInstanceIdFromNextData(data: unknown, requestedInstanceId: string | null): string | null {
|
||||
const design = getPath(data, "props", "pageProps", "design") as Record<string, unknown> | undefined;
|
||||
if (!design) return null;
|
||||
if (requestedInstanceId && makerworldDesignHasInstance(design, requestedInstanceId)) return requestedInstanceId;
|
||||
const defaultInstance = design.defaultInstanceId;
|
||||
if (defaultInstance) return String(defaultInstance);
|
||||
const instances = design.instances;
|
||||
@@ -537,6 +548,14 @@ function makerworldInstanceIdFromNextData(data: unknown): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function makerworldDesignHasInstance(design: Record<string, unknown>, instanceId: string): boolean {
|
||||
const instances = design.instances;
|
||||
if (!Array.isArray(instances)) return false;
|
||||
return instances.some(
|
||||
(inst) => inst && typeof inst === "object" && String((inst as Record<string, unknown>).id) === instanceId,
|
||||
);
|
||||
}
|
||||
|
||||
function makerworldNonceFromNextData(data: unknown): string | null {
|
||||
const nonce = getPath(data, "props", "pageProps", "x-nonce");
|
||||
return typeof nonce === "string" && nonce.trim() ? nonce : null;
|
||||
@@ -572,32 +591,44 @@ async function fetchMakerworldModelDownloadUrl(
|
||||
return data ? extractDownloadUrlFromResponse(data, apiUrl) : null;
|
||||
}
|
||||
|
||||
/** `requestedInstanceId` is the print profile from the page URL's #profileId-… hash, passed in
|
||||
* separately because `pageUrl` here is the fetch response's URL, which never carries a hash.
|
||||
* Every fallback past the instance-scoped endpoint is model-level, i.e. the default profile's
|
||||
* file, so the returned profile says so. */
|
||||
export async function resolveMakerworldDownloadUrl(
|
||||
html: string,
|
||||
pageUrl: string,
|
||||
makerworldCookie: string | null,
|
||||
): Promise<string | null> {
|
||||
requestedInstanceId: string | null = null,
|
||||
): Promise<{ downloadUrl: string; profile: MakerworldProfileRef } | null> {
|
||||
const nextData = extractNextDataJson(html);
|
||||
let designId: string | null = null;
|
||||
let nonce: string | null = null;
|
||||
let instanceId: string | null = null;
|
||||
let defaultInstanceId: string | null = null;
|
||||
const asDefault = (downloadUrl: string) => ({ downloadUrl, profile: { instanceId: defaultInstanceId } });
|
||||
if (nextData) {
|
||||
const url = findDownloadUrlInJson(nextData, pageUrl);
|
||||
if (url) return url;
|
||||
instanceId = makerworldInstanceIdFromNextData(nextData, requestedInstanceId);
|
||||
defaultInstanceId = makerworldInstanceIdFromNextData(nextData, null);
|
||||
// A generic URL found in the page data belongs to whichever profile the page embedded (the
|
||||
// default), so it's only trusted when no other profile was asked for.
|
||||
if (!requestedInstanceId || instanceId !== requestedInstanceId) {
|
||||
const url = findDownloadUrlInJson(nextData, pageUrl);
|
||||
if (url) return asDefault(url);
|
||||
}
|
||||
designId = makerworldDesignIdFromNextData(nextData);
|
||||
nonce = makerworldNonceFromNextData(nextData);
|
||||
instanceId = makerworldInstanceIdFromNextData(nextData);
|
||||
}
|
||||
|
||||
if (instanceId) {
|
||||
const url = await fetchMakerworldInstanceDownloadUrl(instanceId, pageUrl, makerworldCookie);
|
||||
if (url) return url;
|
||||
if (url) return { downloadUrl: url, profile: { instanceId } };
|
||||
}
|
||||
|
||||
const modelId = designId || makerworldModelIdFromUrl(pageUrl);
|
||||
if (!modelId) return null;
|
||||
const url = await fetchMakerworldModelDownloadUrl(modelId, pageUrl, nonce, makerworldCookie);
|
||||
if (url) return url;
|
||||
if (url) return asDefault(url);
|
||||
|
||||
const apiCandidates = [
|
||||
`https://makerworld.com/api/v1/models/${modelId}`,
|
||||
@@ -609,7 +640,7 @@ export async function resolveMakerworldDownloadUrl(
|
||||
const data = await fetchJsonFromUrl(apiUrl, pageUrl);
|
||||
if (!data) continue;
|
||||
const found = findDownloadUrlInJson(data, apiUrl);
|
||||
if (found) return found;
|
||||
if (found) return asDefault(found);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ import {
|
||||
} from "./printablesApi";
|
||||
import { getThingiverseAccessToken } from "./settingsService";
|
||||
import { upsertAuthorFromImport } from "./authorService";
|
||||
import { createPrint, type NewPlateInput, type PrintMetaInput } from "./printCreation";
|
||||
import { addPlatesToPrint, createPrint, resolvePlateFilePath, type NewPlateInput, type PrintMetaInput } from "./printCreation";
|
||||
import { plateThumbExists, saveThumbFromBytes } from "./printService";
|
||||
import { addPreviewImage } from "./previewImageService";
|
||||
import { prisma } from "../db";
|
||||
@@ -70,6 +70,9 @@ export type ImportRequestBody = ImportCookies & {
|
||||
* in that page's context, so real cookies attach automatically). When present, openImportResponse
|
||||
* skips both of its own resolution paths for this url -- see its use there. */
|
||||
resolved_download_url?: string | null;
|
||||
/** Sent alongside resolved_download_url: the MakerWorld profile that URL downloads -- see
|
||||
* ImportedPageMetadata.makerworldProfile. Null when the extension couldn't tell. */
|
||||
resolved_instance_id?: string | null;
|
||||
/** Internal only -- never comes from the request body/schema. Set by runCollectionImportJob
|
||||
* on each per-design body it builds for a MakerWorld collection batch import, and read
|
||||
* wherever a MakerWorld-bound call happens along this whole chain (tryMakerworldCloudApi,
|
||||
@@ -268,6 +271,7 @@ export async function openImportResponse(
|
||||
author: extracted.author ?? inheritedMeta.author,
|
||||
siteCategoryIds: extracted.siteCategoryIds.length ? extracted.siteCategoryIds : inheritedMeta.siteCategoryIds,
|
||||
categorySite: extracted.categorySite ?? inheritedMeta.categorySite,
|
||||
makerworldProfile: inheritedMeta.makerworldProfile,
|
||||
};
|
||||
if (pageHost.endsWith("makerworld.com")) {
|
||||
// Same short-circuit as the cloud API above -- resolveMakerworldDownloadUrl's own
|
||||
@@ -275,9 +279,15 @@ export async function openImportResponse(
|
||||
// CAPTCHA cooloff, so a client-supplied resolution skips it rather than resolving twice.
|
||||
if (body.resolved_download_url) {
|
||||
downloadUrl = body.resolved_download_url;
|
||||
resolvedMeta.makerworldProfile = { instanceId: body.resolved_instance_id ?? null };
|
||||
} else {
|
||||
if (!makerworldCookie) makerworldCookie = resolveMakerworldCookie(body);
|
||||
downloadUrl = await resolveMakerworldDownloadUrl(html, finalUrl, makerworldCookie);
|
||||
const requestedInstanceId = parseMakerworldModelUrl(validatedUrl)?.requestedInstanceId ?? null;
|
||||
const resolved = await resolveMakerworldDownloadUrl(html, finalUrl, makerworldCookie, requestedInstanceId);
|
||||
if (resolved) {
|
||||
downloadUrl = resolved.downloadUrl;
|
||||
resolvedMeta.makerworldProfile = resolved.profile;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!downloadUrl) {
|
||||
@@ -329,7 +339,15 @@ export async function downloadImportToTemp(
|
||||
url: string,
|
||||
body: ImportRequestBody,
|
||||
): Promise<{ tempPath: string; filename: string; mime: string; meta: ImportedPageMetadata }> {
|
||||
const { response, finalUrl, meta } = await openImportResponse(url, body);
|
||||
return saveImportResponseToTemp(await openImportResponse(url, body), body);
|
||||
}
|
||||
|
||||
/** The second half of downloadImportToTemp, split out so a caller can look at the resolved
|
||||
* metadata first and skip downloading the body entirely (see addMakerworldProfileToPrint). */
|
||||
async function saveImportResponseToTemp(
|
||||
{ response, finalUrl, meta }: OpenImportResult,
|
||||
body: ImportRequestBody,
|
||||
): Promise<{ tempPath: string; filename: string; mime: string; meta: ImportedPageMetadata }> {
|
||||
const contentLength = response.headers.get("content-length");
|
||||
if (contentLength && Number(contentLength) > IMPORT_MAX_BYTES) {
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
@@ -470,7 +488,16 @@ export function identifySourceModel(url: string): { provider: string; externalId
|
||||
return null;
|
||||
}
|
||||
|
||||
export type ImportStatus = { recognized: boolean; already_imported: boolean; print_id: string | null };
|
||||
/** `state` refines already_imported for a MakerWorld URL naming a print profile, where the model
|
||||
* can be in the library without that profile's file: "profile_missing" when every plate on the
|
||||
* print is a known, different profile, "profile_unknown" when some plates predate profile
|
||||
* tracking and might be it. already_imported stays for older extension versions. */
|
||||
export type ImportStatus = {
|
||||
recognized: boolean;
|
||||
already_imported: boolean;
|
||||
print_id: string | null;
|
||||
state: "not_imported" | "imported" | "profile_missing" | "profile_unknown";
|
||||
};
|
||||
|
||||
/** Cheap "is this page importable, and have I already imported it" check -- unlike
|
||||
* inspectImportLink, this never fetches the provider's page itself, so it's safe to call on
|
||||
@@ -480,12 +507,22 @@ export type ImportStatus = { recognized: boolean; already_imported: boolean; pri
|
||||
* sense for a single model, never a listing page. */
|
||||
export async function checkImportStatus(userId: string, url: string): Promise<ImportStatus> {
|
||||
const source = identifySourceModel(url);
|
||||
if (!source) return { recognized: false, already_imported: false, print_id: null };
|
||||
if (!source) return { recognized: false, already_imported: false, print_id: null, state: "not_imported" };
|
||||
const print = await prisma.print.findFirst({
|
||||
where: { userId, sourceProvider: source.provider, sourceExternalId: source.externalId },
|
||||
select: { id: true },
|
||||
select: { id: true, plates: { select: { sourceInstanceId: true } } },
|
||||
});
|
||||
return { recognized: true, already_imported: Boolean(print), print_id: print?.id ?? null };
|
||||
if (!print) return { recognized: true, already_imported: false, print_id: null, state: "not_imported" };
|
||||
// A MakerWorld URL naming a specific profile only counts as imported once that profile's file
|
||||
// is on the print (see addMakerworldProfileToPrint). Without a hash there's no way to tell
|
||||
// which profile is meant short of asking MakerWorld -- not worth it on every page view -- so
|
||||
// any imported profile counts.
|
||||
const requestedInstanceId = source.provider === "makerworld" ? parseMakerworldModelUrl(url)?.requestedInstanceId : null;
|
||||
if (requestedInstanceId && !print.plates.some((plate) => plate.sourceInstanceId === requestedInstanceId)) {
|
||||
const state = print.plates.some((plate) => plate.sourceInstanceId == null) ? "profile_unknown" : "profile_missing";
|
||||
return { recognized: true, already_imported: false, print_id: print.id, state };
|
||||
}
|
||||
return { recognized: true, already_imported: true, print_id: print.id, state: "imported" };
|
||||
}
|
||||
|
||||
/** The inverse of identifySourceModel above -- rebuilds the original model page URL from the
|
||||
@@ -517,6 +554,94 @@ async function findExistingImportedPrint(
|
||||
return { print, plates, author: print.author, previewImages };
|
||||
}
|
||||
|
||||
type ExistingImportedPrint = NonNullable<Awaited<ReturnType<typeof findExistingImportedPrint>>>;
|
||||
|
||||
async function sha256OfFile(filePath: string): Promise<string> {
|
||||
const hash = crypto.createHash("sha256");
|
||||
for await (const chunk of fsSync.createReadStream(filePath)) hash.update(chunk as Buffer);
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
/** A plate's stored contentSha256, computing (and storing) it on first use. Null when its file
|
||||
* can't be read. */
|
||||
async function plateContentSha256(plate: Plate): Promise<string | null> {
|
||||
if (plate.contentSha256) return plate.contentSha256;
|
||||
const filePath = resolvePlateFilePath(plate);
|
||||
if (!filePath || !fsSync.existsSync(filePath)) return null;
|
||||
const sha = await sha256OfFile(filePath);
|
||||
await prisma.plate.update({ where: { id: plate.id }, data: { contentSha256: sha } });
|
||||
return sha;
|
||||
}
|
||||
|
||||
/** Re-import of a MakerWorld design that's already a Print: each print profile has its own 3MF
|
||||
* (with its own print settings, so they can't be merged into one file), so a profile not on the
|
||||
* print yet is added as another plate rather than treated as a duplicate. The print's own
|
||||
* metadata/images are left as they are.
|
||||
*
|
||||
* Plates without a sourceInstanceId (imported before profiles were tracked, or uploaded by hand)
|
||||
* could be any profile, so the downloaded file is compared against them by SHA-256 first: a
|
||||
* match tags that plate instead of adding a copy. Once a print's plates are all tagged, this
|
||||
* never downloads anything for a profile it already has. */
|
||||
async function addMakerworldProfileToPrint(
|
||||
existing: ExistingImportedPrint,
|
||||
url: string,
|
||||
body: ImportRequestBody,
|
||||
): Promise<ExistingImportedPrint & { alreadyImported: boolean; profileAdded?: boolean }> {
|
||||
const alreadyImported = { ...existing, alreadyImported: true };
|
||||
const hasProfile = (instanceId: string) => existing.plates.some((plate) => plate.sourceInstanceId === instanceId);
|
||||
|
||||
// Checked before any MakerWorld request: no hash (and no profile from the extension) means
|
||||
// "whichever profile is the default", which can only be told apart from what's already
|
||||
// imported by asking MakerWorld -- and a collection re-import runs this for every design it
|
||||
// already has, where that would mean one extra (CAPTCHA-prone) lookup per design.
|
||||
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;
|
||||
// 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)) {
|
||||
await opened.response.body?.cancel().catch(() => undefined);
|
||||
return alreadyImported;
|
||||
}
|
||||
|
||||
const { tempPath, filename, mime } = await saveImportResponseToTemp(opened, body);
|
||||
try {
|
||||
const untagged = existing.plates.filter((plate) => plate.sourceInstanceId == null);
|
||||
if (untagged.length) {
|
||||
const downloadedSha = await sha256OfFile(tempPath);
|
||||
for (const plate of untagged) {
|
||||
if ((await plateContentSha256(plate)) !== downloadedSha) continue;
|
||||
try {
|
||||
await prisma.plate.update({ where: { id: plate.id }, data: { sourceInstanceId: instanceId } });
|
||||
} catch (err) {
|
||||
// Race guard: a concurrent import of this same profile tagged/added it first.
|
||||
if (!isUniqueConstraintError(err)) throw err;
|
||||
}
|
||||
return { ...alreadyImported, plates: await platesOf(existing.print.id) };
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await addPlatesToPrint(existing.print.userId, existing.print.id, [
|
||||
{ filename, mime, tempFilePath: tempPath, sourceInstanceId: instanceId },
|
||||
]);
|
||||
} catch (err) {
|
||||
// Race guard: a concurrent import of this same profile added it first.
|
||||
if (isUniqueConstraintError(err)) return alreadyImported;
|
||||
throw err;
|
||||
}
|
||||
return { ...existing, plates: await platesOf(existing.print.id), alreadyImported: false, profileAdded: true };
|
||||
} finally {
|
||||
if (fsSync.existsSync(tempPath)) await fs.rm(tempPath, { force: true }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function platesOf(printId: string): Promise<Plate[]> {
|
||||
return prisma.plate.findMany({ where: { printId }, orderBy: { position: "asc" } });
|
||||
}
|
||||
|
||||
/** Bulk version of findExistingImportedPrint's lookup -- used by the collection/likes ".../entries"
|
||||
* routes to flag which entries in a listing the user already has, *before* they pick what to
|
||||
* import, instead of only finding out one by one as each import attempt hits the unique
|
||||
@@ -745,10 +870,20 @@ export async function importPrintFromUrl(
|
||||
userId: string,
|
||||
url: string,
|
||||
body: ImportRequestBody,
|
||||
): Promise<{ print: Print; plates: Plate[]; author: Author | null; previewImages: PreviewImage[]; alreadyImported: boolean }> {
|
||||
): Promise<{
|
||||
print: Print;
|
||||
plates: Plate[];
|
||||
author: Author | null;
|
||||
previewImages: PreviewImage[];
|
||||
alreadyImported: boolean;
|
||||
/** Set when an existing MakerWorld print gained another profile's file (see
|
||||
* addMakerworldProfileToPrint) rather than being created. */
|
||||
profileAdded?: boolean;
|
||||
}> {
|
||||
const source = identifySourceModel(url);
|
||||
if (source) {
|
||||
const existing = await findExistingImportedPrint(userId, source);
|
||||
if (existing && source.provider === "makerworld") return addMakerworldProfileToPrint(existing, url, body);
|
||||
if (existing) return { ...existing, alreadyImported: true };
|
||||
}
|
||||
|
||||
@@ -778,7 +913,7 @@ export async function importPrintFromUrl(
|
||||
let result: { print: Print; plates: Plate[] };
|
||||
try {
|
||||
result = await createPrint(userId, printMeta, path.parse(filename).name, [
|
||||
{ filename, mime, tempFilePath: tempPath },
|
||||
{ filename, mime, tempFilePath: tempPath, sourceInstanceId: meta.makerworldProfile?.instanceId ?? null },
|
||||
]);
|
||||
} catch (err) {
|
||||
// Race guard: another concurrent import of the same source model won between our
|
||||
|
||||
@@ -374,6 +374,7 @@ export async function resolveMakerworldViaCloudApi(
|
||||
author,
|
||||
siteCategoryIds,
|
||||
categorySite: siteCategoryIds.length ? "makerworld" : null,
|
||||
makerworldProfile: { instanceId: selected.id != null ? String(selected.id) : null },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -30,6 +30,8 @@ export type NewPlateInput = {
|
||||
tempFilePath?: string; // moved (renamed) into managed storage, source is deleted
|
||||
copyFromPath?: string; // copied into managed storage, source left intact
|
||||
sourcePath?: string; // no-copy: file stays at this path, storagePath is still rendered/recorded
|
||||
/** The MakerWorld profile this file was imported from -- see Plate.sourceInstanceId. */
|
||||
sourceInstanceId?: string | null;
|
||||
};
|
||||
|
||||
export type PrintMetaInput = {
|
||||
@@ -137,17 +139,26 @@ async function createPlateAtPosition(
|
||||
const size = await resolveSize(input, effectivePath);
|
||||
const mime = input.mime || guessMimeFromPath(desiredFilename) || "application/octet-stream";
|
||||
|
||||
const record = await prisma.plate.create({
|
||||
data: {
|
||||
printId: print.id,
|
||||
position,
|
||||
filename: desiredFilename,
|
||||
mime,
|
||||
size,
|
||||
storagePath,
|
||||
sourcePath: input.sourcePath ?? null,
|
||||
},
|
||||
});
|
||||
let record: Plate;
|
||||
try {
|
||||
record = await prisma.plate.create({
|
||||
data: {
|
||||
printId: print.id,
|
||||
position,
|
||||
filename: desiredFilename,
|
||||
mime,
|
||||
size,
|
||||
storagePath,
|
||||
sourcePath: input.sourcePath ?? null,
|
||||
sourceInstanceId: input.sourceInstanceId ?? null,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
// e.g. a concurrent import of the same MakerWorld profile winning the (printId,
|
||||
// sourceInstanceId) unique index -- don't leave the file we just placed orphaned in storage.
|
||||
if (effectivePath && !input.sourcePath) await fs.rm(effectivePath, { force: true }).catch(() => undefined);
|
||||
throw err;
|
||||
}
|
||||
|
||||
await thumbnailAndSniff(record.id, desiredFilename, mime, effectivePath);
|
||||
return { record, effectivePath };
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import request from "supertest";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { createApp } from "../src/app";
|
||||
import { prisma } from "../src/db";
|
||||
import { identifySourceModel, importPrintFromUrl } from "../src/services/importService";
|
||||
import { checkImportStatus, identifySourceModel, importPrintFromUrl } from "../src/services/importService";
|
||||
import { createJob, getActiveJob, updateJob } from "../src/services/importJobService";
|
||||
import { createNotification, listNotifications, markAllRead } from "../src/services/notificationService";
|
||||
|
||||
@@ -97,6 +97,141 @@ describe("import dedup", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Distinct bytes per MakerWorld profile, like the real thing -- what the SHA-256 match keys on.
|
||||
function profileFileContents(instanceId: string) {
|
||||
return `solid profile-${instanceId} endsolid`;
|
||||
}
|
||||
|
||||
describe("MakerWorld print profiles", () => {
|
||||
// One design, several print profiles ("instances"), each with its own 3MF -- see
|
||||
// importService.ts's addMakerworldProfileToPrint. Every import here goes through the extension's
|
||||
// pre-resolved path (resolved_download_url + profile ids), so the only fetches are the model page
|
||||
// itself and the file; both are intercepted.
|
||||
const designId = "888777";
|
||||
const pageUrl = `https://makerworld.com/en/models/${designId}-profile-slug`;
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
function mockMakerworldFetch() {
|
||||
const fetchMock = vi.fn<(input: RequestInfo | URL) => Promise<Response>>(async (input) => {
|
||||
const url = String(input);
|
||||
if (url.startsWith("https://makerworld.com/en/models/")) {
|
||||
return new Response("<html><head><title>Profile Test</title></head><body></body></html>", {
|
||||
headers: { "content-type": "text/html; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
const file = url.match(/^https:\/\/makerworld\.com\/files\/profile-(\w+)\.stl$/);
|
||||
if (file) {
|
||||
return new Response(profileFileContents(file[1]), { headers: { "content-type": "application/octet-stream" } });
|
||||
}
|
||||
throw new Error(`Unexpected fetch to ${url}`);
|
||||
});
|
||||
global.fetch = fetchMock as unknown as typeof fetch;
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
function importProfile(url: string, instanceId: string | null) {
|
||||
return importPrintFromUrl(userId, url, {
|
||||
url,
|
||||
tags: [],
|
||||
resolved_download_url: `https://makerworld.com/files/profile-${instanceId ?? "unknown"}.stl`,
|
||||
resolved_instance_id: instanceId,
|
||||
});
|
||||
}
|
||||
|
||||
async function createLegacyPrint(contents: string) {
|
||||
const uploadRes = await request(app).post("/api/upload").set(auth()).attach("files", tmpFile("legacy-profile.stl", contents));
|
||||
expect(uploadRes.status).toBe(200);
|
||||
const printId = uploadRes.body.prints[0].id as string;
|
||||
await prisma.print.update({ where: { id: printId }, data: { sourceProvider: "makerworld", sourceExternalId: designId } });
|
||||
return printId;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
global.fetch = originalFetch;
|
||||
const prints = await prisma.print.findMany({ where: { userId, sourceProvider: "makerworld", sourceExternalId: designId } });
|
||||
for (const print of prints) await request(app).delete(`/api/print/${print.id}`).set(auth());
|
||||
});
|
||||
|
||||
it("adds a second profile as another plate on the same print, and skips a profile it already has", async () => {
|
||||
mockMakerworldFetch();
|
||||
const first = await importProfile(pageUrl, "100");
|
||||
expect(first.alreadyImported).toBe(false);
|
||||
expect(first.plates.map((plate) => plate.sourceInstanceId)).toEqual(["100"]);
|
||||
|
||||
const second = await importProfile(`${pageUrl}#profileId-200`, "200");
|
||||
expect(second.alreadyImported).toBe(false);
|
||||
expect(second.print.id).toBe(first.print.id);
|
||||
expect(second.plates.map((plate) => plate.sourceInstanceId)).toEqual(["100", "200"]);
|
||||
|
||||
const fetchMock = mockMakerworldFetch();
|
||||
const again = await importProfile(`${pageUrl}#profileId-200`, "200");
|
||||
expect(again.alreadyImported).toBe(true);
|
||||
expect(again.plates).toHaveLength(2);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports what POST /import did via import_outcome", async () => {
|
||||
mockMakerworldFetch();
|
||||
const post = (url: string, instanceId: string) =>
|
||||
request(app)
|
||||
.post("/api/import")
|
||||
.set(auth())
|
||||
.send({ url, resolved_download_url: `https://makerworld.com/files/profile-${instanceId}.stl`, resolved_instance_id: instanceId });
|
||||
|
||||
expect((await post(pageUrl, "100")).body.import_outcome).toBe("created");
|
||||
expect((await post(`${pageUrl}#profileId-200`, "200")).body.import_outcome).toBe("profile_added");
|
||||
expect((await post(`${pageUrl}#profileId-200`, "200")).body.import_outcome).toBe("already_imported");
|
||||
});
|
||||
|
||||
it("treats a bare model URL as already imported without asking MakerWorld", async () => {
|
||||
mockMakerworldFetch();
|
||||
await importProfile(pageUrl, "100");
|
||||
|
||||
const fetchMock = mockMakerworldFetch();
|
||||
const result = await importPrintFromUrl(userId, pageUrl, { url: pageUrl, tags: [] });
|
||||
expect(result.alreadyImported).toBe(true);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports import status per profile when the URL names one", async () => {
|
||||
expect((await checkImportStatus(userId, `${pageUrl}#profileId-200`)).state).toBe("not_imported");
|
||||
|
||||
mockMakerworldFetch();
|
||||
await importProfile(`${pageUrl}#profileId-200`, "200");
|
||||
|
||||
const imported = await checkImportStatus(userId, `${pageUrl}#profileId-200`);
|
||||
expect(imported).toMatchObject({ already_imported: true, state: "imported" });
|
||||
const missing = await checkImportStatus(userId, `${pageUrl}#profileId-300`);
|
||||
expect(missing).toMatchObject({ already_imported: false, state: "profile_missing", print_id: imported.print_id });
|
||||
expect((await checkImportStatus(userId, pageUrl)).state).toBe("imported");
|
||||
});
|
||||
|
||||
it("reports a profile as unknown while the print has plates from before profiles were tracked", async () => {
|
||||
await createLegacyPrint("solid anything endsolid");
|
||||
const status = await checkImportStatus(userId, `${pageUrl}#profileId-300`);
|
||||
expect(status).toMatchObject({ already_imported: false, state: "profile_unknown" });
|
||||
expect((await checkImportStatus(userId, pageUrl)).state).toBe("imported");
|
||||
});
|
||||
|
||||
it("tags an untagged plate whose file matches the downloaded profile instead of adding a copy", async () => {
|
||||
// Deliberately a non-default profile -- older imports could come from any profile.
|
||||
const printId = await createLegacyPrint(profileFileContents("200"));
|
||||
|
||||
mockMakerworldFetch();
|
||||
const other = await importProfile(`${pageUrl}#profileId-100`, "100");
|
||||
expect(other.alreadyImported).toBe(false);
|
||||
expect(other.profileAdded).toBe(true);
|
||||
expect(other.plates.map((plate) => plate.sourceInstanceId)).toEqual([null, "100"]);
|
||||
|
||||
const matched = await importProfile(`${pageUrl}#profileId-200`, "200");
|
||||
expect(matched.alreadyImported).toBe(true);
|
||||
expect(matched.print.id).toBe(printId);
|
||||
expect(matched.plates.map((plate) => plate.sourceInstanceId)).toEqual(["200", "100"]);
|
||||
// The hash is stored on first use, so later comparisons don't re-read the file.
|
||||
expect(matched.plates[0].contentSha256).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("import job lock", () => {
|
||||
it("blocks a new batch import while one is already running, and reports it via the active/job endpoints", async () => {
|
||||
const job = await createJob(userId, "COLLECTION", {
|
||||
|
||||
+15
-7
@@ -171,15 +171,23 @@ async function pollJobToCompletion(jobId) {
|
||||
* the import (and any collection filing) runs to completion regardless of what the calling page
|
||||
* does next -- only the reply back to a since-destroyed content script can get lost, never the
|
||||
* work itself. */
|
||||
async function handleImportSingle({ url, entries, collectionId, resolvedDownloadUrl }) {
|
||||
async function handleImportSingle({ url, entries, collectionId, resolved }) {
|
||||
// For a MakerWorld model, content.js resolves the actual download URL itself, straight from
|
||||
// the live page (see its resolveMakerworldDownloadUrlFromPage) -- passing it through as
|
||||
// resolved_download_url lets the backend skip its own resolution entirely (both its
|
||||
// api.bambulab.com cloud-API call and its api/v1 HTML-scrape fallback), which are the only two
|
||||
// places able to trip MakerWorld's CAPTCHA and the resulting 2-hour account-wide lockout. Left
|
||||
// undefined/null for anything else, or when the page-side resolution didn't find one -- the
|
||||
// backend then falls back to resolving it exactly as before.
|
||||
const extra = resolvedDownloadUrl ? { resolved_download_url: resolvedDownloadUrl } : null;
|
||||
// backend then falls back to resolving it exactly as before. The profile ids tell the backend
|
||||
// which MakerWorld print profile that file is, so importing another profile of a model that's
|
||||
// already in the library adds it as a second file instead of being skipped as a duplicate.
|
||||
const extra =
|
||||
resolved && resolved.downloadUrl
|
||||
? {
|
||||
resolved_download_url: resolved.downloadUrl,
|
||||
resolved_instance_id: resolved.instanceId || null,
|
||||
}
|
||||
: null;
|
||||
let print;
|
||||
if (entries) {
|
||||
const { job_id } = await apiCall("POST", "/import/zip", { url, entries, ...extra });
|
||||
@@ -385,16 +393,16 @@ async function advanceMakerworldJob(tabId) {
|
||||
// This matters most right here, not just for a plain single-model import: it's this per-model
|
||||
// loop that fires MakerWorld resolution calls back-to-back, exactly the pattern that trips its
|
||||
// CAPTCHA. Best-effort -- if the content script isn't ready yet or the page structure doesn't
|
||||
// match, resolvedDownloadUrl stays null and the backend just resolves it the old way.
|
||||
let resolvedDownloadUrl = null;
|
||||
// match, `resolved` stays null and the backend just resolves it the old way.
|
||||
let resolved = null;
|
||||
try {
|
||||
const res = await withTimeout(chrome.tabs.sendMessage(tabId, { type: "RESOLVE_MAKERWORLD_DOWNLOAD_URL" }), MAKERWORLD_DOWNLOAD_RESOLVE_TIMEOUT_MS);
|
||||
if (res && res.ok) resolvedDownloadUrl = res.downloadUrl;
|
||||
if (res && res.ok) resolved = res.resolved;
|
||||
} catch {
|
||||
// No listener yet, or the tab navigated away already -- fine, see above.
|
||||
}
|
||||
try {
|
||||
await handleImportSingle({ url: currentUrl, collectionId: job.collectionId, resolvedDownloadUrl });
|
||||
await handleImportSingle({ url: currentUrl, collectionId: job.collectionId, resolved });
|
||||
} catch (err) {
|
||||
// A manual "Import next" (see handleForceAdvanceMakerworldJob) may have already moved the job
|
||||
// past this step while this request was still in flight -- if so, this failure is stale and
|
||||
|
||||
+5
-1
@@ -43,7 +43,11 @@ function thingportIsMakerworldModelUrl(url) {
|
||||
}
|
||||
if (!parsed.hostname.toLowerCase().endsWith("makerworld.com")) return null;
|
||||
const m = parsed.pathname.match(/\/models?\/(\d+)/i);
|
||||
return m ? { designId: m[1] } : null;
|
||||
if (!m) return null;
|
||||
// The print profile picked on the page, if any (e.g. #profileId-123456) -- same as the
|
||||
// backend's parseMakerworldModelUrl.
|
||||
const hashMatch = parsed.hash.match(/profileid-(\d+)/i);
|
||||
return { designId: m[1], requestedInstanceId: hashMatch ? hashMatch[1] : null };
|
||||
}
|
||||
|
||||
/** Mirrors the backend's buildImportSourceUrl (importService.ts) for MakerWorld -- lets the
|
||||
|
||||
@@ -20,6 +20,24 @@
|
||||
}
|
||||
.tg-fab:hover { transform: scale(1.06); }
|
||||
.tg-fab img { width: 34px; height: 34px; pointer-events: none; }
|
||||
/* The model's already in the library, only this page's MakerWorld print profile might not be --
|
||||
see content.js's addProfileHtml. A checkmark badge rather than a different icon, so it still
|
||||
reads as Thingport but not as "you don't have this yet". */
|
||||
.tg-fab-in-library::after {
|
||||
content: "\2713";
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
right: -2px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #ffffff;
|
||||
box-sizing: border-box;
|
||||
background: #00b800;
|
||||
color: #ffffff;
|
||||
font: 700 11px/16px -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* `all: initial` below resets `display` to its CSS-initial value ("inline") as an *author* rule,
|
||||
which otherwise beats the UA stylesheet's `[hidden] { display: none }` regardless of the
|
||||
|
||||
+120
-36
@@ -48,8 +48,8 @@
|
||||
await injectStyles(shadowRoot);
|
||||
|
||||
const button = document.createElement("button");
|
||||
button.className = "tg-fab";
|
||||
button.setAttribute("aria-label", "Import to Thingport");
|
||||
button.className = context.library ? "tg-fab tg-fab-in-library" : "tg-fab";
|
||||
button.setAttribute("aria-label", context.library ? "Add this print profile to Thingport" : "Import to Thingport");
|
||||
button.innerHTML = `<img src="${ICON_URL}" alt="" />`;
|
||||
button.addEventListener("click", togglePanel);
|
||||
shadowRoot.appendChild(button);
|
||||
@@ -141,6 +141,14 @@
|
||||
}
|
||||
|
||||
async function loadSingleItem() {
|
||||
// Nothing inspect would tell us matters here (a MakerWorld profile is always a single 3MF),
|
||||
// and inspecting would make the backend resolve the download from MakerWorld just to show
|
||||
// the panel.
|
||||
if (context.library) {
|
||||
renderPanel(addProfileHtml());
|
||||
bindImportButton(() => runDirectImport());
|
||||
return;
|
||||
}
|
||||
renderPanel(`<div class="tg-status">Checking link…</div>`);
|
||||
const { provider, type } = context.classification;
|
||||
// Printables' generic page-fetch flow is Cloudflare-gated (inspectImportLink would just fail)
|
||||
@@ -183,6 +191,35 @@
|
||||
`;
|
||||
}
|
||||
|
||||
/** 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 (own print settings), stored as its own file on the
|
||||
* one model. No collection picker: the model's collections are already whatever they are. */
|
||||
function addProfileHtml() {
|
||||
const { state, printId } = context.library;
|
||||
const profileName = currentMakerworldProfileTitle();
|
||||
const profileLabel = profileName ? `the "${escapeHtml(profileName)}" profile` : "this print profile";
|
||||
const hint =
|
||||
state === "profile_missing"
|
||||
? `You already have this model. Add ${profileLabel} as another file on it?`
|
||||
: `This model is in your library. Add ${profileLabel} if you don't have it yet -- if one of the model's files already is this profile, nothing is downloaded twice.`;
|
||||
const modelLink = printId ? `${context.instanceUrl}/models/${printId}` : `${context.instanceUrl}/models`;
|
||||
return `
|
||||
<div class="tg-title">In your library</div>
|
||||
<div class="tg-hint">${hint}</div>
|
||||
<button class="tg-btn" data-action="import">Add profile</button>
|
||||
<a class="tg-btn tg-btn-secondary tg-link" href="${escapeHtml(modelLink)}" target="_blank" rel="noopener noreferrer">Open model in Thingport</a>
|
||||
`;
|
||||
}
|
||||
|
||||
/** The title of the print profile named in this page's URL hash, read from the page's own data
|
||||
* -- null when there's no hash or the page data is stale (see readMakerworldDesignForPage). */
|
||||
function currentMakerworldProfileTitle() {
|
||||
const page = readMakerworldDesignForPage(context.url);
|
||||
if (!page || !page.requestedInstanceId || !Array.isArray(page.design.instances)) return null;
|
||||
const instance = page.design.instances.find((inst) => inst && String(inst.id) === page.requestedInstanceId);
|
||||
return instance && typeof instance.title === "string" && instance.title.trim() ? instance.title.trim() : null;
|
||||
}
|
||||
|
||||
async function zipChoiceHtml(filename) {
|
||||
const collectionHtml = await collectionOptionsHtml();
|
||||
return `
|
||||
@@ -250,9 +287,9 @@
|
||||
// `classification` (captured above, before any await) rather than `context.classification`
|
||||
// afterward, for the same reason `url`/`instanceUrl` are destructured up front: an SPA nav
|
||||
// mid-flight nulls `context` out from under this still-running function.
|
||||
const resolvedDownloadUrl =
|
||||
const resolved =
|
||||
classification.provider === "makerworld" && classification.type === "model"
|
||||
? await resolveMakerworldDownloadUrl().catch(() => null)
|
||||
? await resolveMakerworldDownloadUrl(url).catch(() => null)
|
||||
: null;
|
||||
try {
|
||||
// One message, not two -- import (and, per collectionId, filing the result into a
|
||||
@@ -260,8 +297,15 @@
|
||||
// whether this tab/page is still around by the time it finishes (see background.js's
|
||||
// handleImportSingle for why that matters: a content script's own execution ends the
|
||||
// moment the page navigates or fully reloads, but the service worker doesn't).
|
||||
const print = await call("IMPORT_SINGLE", { url, entries: opts && opts.entries, collectionId, resolvedDownloadUrl }).then(unwrap);
|
||||
renderPanel(successHtml(print ? `${instanceUrl}/models/${print.id}` : `${instanceUrl}/models`));
|
||||
const print = await call("IMPORT_SINGLE", { url, entries: opts && opts.entries, collectionId, resolved }).then(unwrap);
|
||||
const link = print ? `${instanceUrl}/models/${print.id}` : `${instanceUrl}/models`;
|
||||
if (print && print.import_outcome === "profile_added") {
|
||||
renderPanel(successHtml(link, "Added this print profile's file to the model you already had.", "Profile added"));
|
||||
} else if (print && print.import_outcome === "already_imported") {
|
||||
renderPanel(successHtml(link, "This print profile's file was already on the model -- nothing new was added.", "Already in your library"));
|
||||
} else {
|
||||
renderPanel(successHtml(link));
|
||||
}
|
||||
} catch (err) {
|
||||
renderPanel(errorHtml(err));
|
||||
}
|
||||
@@ -376,41 +420,74 @@
|
||||
|
||||
/** The single entry point every caller below uses: tries the real-click capture first (far
|
||||
* higher fidelity, since it's not a guess), falling back to reconstructing the API call
|
||||
* ourselves only if that didn't pan out. */
|
||||
async function resolveMakerworldDownloadUrl() {
|
||||
* ourselves only if that didn't pan out. `pageUrl` is the model page the caller means to
|
||||
* import, captured before any await -- see readMakerworldDesignForPage for why it's needed.
|
||||
* Resolves to { downloadUrl, instanceId } (the MakerWorld print profile the file belongs to,
|
||||
* null when unknown -- the backend uses it to add a second profile of an already-imported
|
||||
* model as its own file), or null. */
|
||||
async function resolveMakerworldDownloadUrl(pageUrl) {
|
||||
const viaClick = await captureDownloadUrlViaRealClick().catch(() => null);
|
||||
if (viaClick) return viaClick;
|
||||
return resolveMakerworldDownloadUrlFromPage().catch(() => null);
|
||||
if (viaClick) {
|
||||
// The page's own button downloads whichever profile the page has selected, which follows
|
||||
// the URL hash. Without trustworthy page data, the hash is all there is to go on.
|
||||
const page = readMakerworldDesignForPage(pageUrl);
|
||||
if (!page) {
|
||||
const parsed = thingportIsMakerworldModelUrl(pageUrl);
|
||||
return { downloadUrl: viaClick, instanceId: (parsed && parsed.requestedInstanceId) || null };
|
||||
}
|
||||
return { downloadUrl: viaClick, instanceId: pickMakerworldInstanceId(page.design, page.requestedInstanceId) };
|
||||
}
|
||||
return resolveMakerworldDownloadUrlFromPage(pageUrl).catch(() => null);
|
||||
}
|
||||
|
||||
/** The page's own 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. */
|
||||
function readMakerworldDesignForPage(pageUrl) {
|
||||
const nextData = readMakerworldNextData();
|
||||
if (!nextData) return null;
|
||||
const design = getPath(nextData, "props", "pageProps", "design");
|
||||
if (!design || typeof design !== "object") return null;
|
||||
const expected = thingportIsMakerworldModelUrl(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,
|
||||
};
|
||||
}
|
||||
|
||||
/** Same precedence as the backend's resolveMakerworldViaCloudApi: the requested profile (only
|
||||
* if this design actually has it), then the design's default, then the first one. */
|
||||
function pickMakerworldInstanceId(design, requestedInstanceId) {
|
||||
const instances = Array.isArray(design.instances) ? design.instances.filter((inst) => inst && inst.id) : [];
|
||||
if (requestedInstanceId && instances.some((inst) => String(inst.id) === requestedInstanceId)) return requestedInstanceId;
|
||||
if (design.defaultInstanceId) return String(design.defaultInstanceId);
|
||||
return instances.length ? String(instances[0].id) : null;
|
||||
}
|
||||
|
||||
/** Mirrors the backend's own two-step MakerWorld resolution (importResolvers.ts's
|
||||
* resolveMakerworldDownloadUrl) -- instance-scoped endpoint first, model-scoped as a fallback
|
||||
* -- just run from the page itself instead of the server. Fallback only -- see
|
||||
* resolveMakerworldDownloadUrl above, which tries the real-click capture first. */
|
||||
async function resolveMakerworldDownloadUrlFromPage() {
|
||||
const nextData = readMakerworldNextData();
|
||||
if (!nextData) return null;
|
||||
const design = getPath(nextData, "props", "pageProps", "design");
|
||||
if (!design || typeof design !== "object") return null;
|
||||
const nonce = getPath(nextData, "props", "pageProps", "x-nonce");
|
||||
const validNonce = typeof nonce === "string" && nonce.trim() ? nonce : null;
|
||||
|
||||
let instanceId = design.defaultInstanceId ? String(design.defaultInstanceId) : null;
|
||||
if (!instanceId && Array.isArray(design.instances)) {
|
||||
const first = design.instances.find((inst) => inst && inst.id);
|
||||
if (first) instanceId = String(first.id);
|
||||
}
|
||||
async function resolveMakerworldDownloadUrlFromPage(pageUrl) {
|
||||
// Stale or missing page data -- let the backend resolve from the URL instead.
|
||||
const page = readMakerworldDesignForPage(pageUrl);
|
||||
if (!page) return null;
|
||||
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 data = await fetchMakerworldApiJson(apiUrl, validNonce);
|
||||
const url = extractDownloadUrlFromJson(data);
|
||||
if (url) return url;
|
||||
const downloadUrl = extractDownloadUrlFromJson(await fetchMakerworldApiJson(apiUrl, nonce));
|
||||
if (downloadUrl) return { downloadUrl, instanceId };
|
||||
}
|
||||
|
||||
const modelId = design.id ? String(design.id) : null;
|
||||
if (!modelId) return null;
|
||||
const apiUrl = `https://makerworld.com/api/v1/models/${modelId}/download`;
|
||||
return extractDownloadUrlFromJson(await fetchMakerworldApiJson(apiUrl, validNonce));
|
||||
// Model-level, i.e. the default profile's file.
|
||||
const apiUrl = `https://makerworld.com/api/v1/models/${String(design.id)}/download`;
|
||||
const downloadUrl = extractDownloadUrlFromJson(await fetchMakerworldApiJson(apiUrl, nonce));
|
||||
return downloadUrl ? { downloadUrl, instanceId: pickMakerworldInstanceId(design, null) } : null;
|
||||
}
|
||||
|
||||
// -- Batch flow (a collection/Likes listing page) ---------------------------------------------
|
||||
@@ -707,9 +784,9 @@
|
||||
|
||||
// -- Shared result states -----------------------------------------------------------------
|
||||
|
||||
function successHtml(link, note) {
|
||||
function successHtml(link, note, title = "Imported!") {
|
||||
return `
|
||||
<div class="tg-title">Imported!</div>
|
||||
<div class="tg-title">${escapeHtml(title)}</div>
|
||||
${note ? `<div class="tg-hint">${escapeHtml(note)}</div>` : ""}
|
||||
<a class="tg-btn tg-link" href="${escapeHtml(link)}" target="_blank" rel="noopener noreferrer">Open in Thingport</a>
|
||||
`;
|
||||
@@ -833,6 +910,10 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Set when the model is already in the library but this page's MakerWorld print profile might
|
||||
// not be (see the backend's checkImportStatus) -- the icon and panel then offer to add the
|
||||
// profile's file to the existing model rather than presenting it as a new import.
|
||||
let library = null;
|
||||
if (classification.kind === "single") {
|
||||
try {
|
||||
const status = await api("GET", `/import/status?url=${encodeURIComponent(location.href)}`);
|
||||
@@ -841,6 +922,9 @@
|
||||
reportTabIconState(false);
|
||||
return;
|
||||
}
|
||||
if (status.state === "profile_missing" || status.state === "profile_unknown") {
|
||||
library = { state: status.state, printId: status.print_id };
|
||||
}
|
||||
} catch {
|
||||
// If the status check fails (instance unreachable, bad credentials, etc.) still show the
|
||||
// icon -- the panel's own error state will surface the real problem when they try it.
|
||||
@@ -848,7 +932,7 @@
|
||||
}
|
||||
if (myToken !== initToken) return;
|
||||
|
||||
context = { url: location.href, instanceUrl: stateRes.data.instanceUrl, classification };
|
||||
context = { url: location.href, instanceUrl: stateRes.data.instanceUrl, classification, library };
|
||||
await mount();
|
||||
if (myToken !== initToken) return;
|
||||
reportTabIconState(true);
|
||||
@@ -914,9 +998,9 @@
|
||||
// back-to-back resolution pattern most likely to trip MakerWorld's CAPTCHA.
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (!message || message.type !== "RESOLVE_MAKERWORLD_DOWNLOAD_URL") return undefined;
|
||||
resolveMakerworldDownloadUrl()
|
||||
.then((downloadUrl) => sendResponse({ ok: true, downloadUrl }))
|
||||
.catch(() => sendResponse({ ok: true, downloadUrl: null }));
|
||||
resolveMakerworldDownloadUrl(location.href)
|
||||
.then((resolved) => sendResponse({ ok: true, resolved }))
|
||||
.catch(() => sendResponse({ ok: true, resolved: null }));
|
||||
return true; // keep the message channel open for the async sendResponse above
|
||||
});
|
||||
|
||||
|
||||
@@ -64,8 +64,12 @@ type ImportLinkPayload = {
|
||||
makerworld_cookie?: string;
|
||||
};
|
||||
|
||||
/** What POST /import did -- "profile_added" when a MakerWorld model already in the library
|
||||
* gained another print profile's file instead of a new model being created. */
|
||||
export type ImportOutcome = "created" | "profile_added" | "already_imported";
|
||||
|
||||
export const importsApi = {
|
||||
fromLink: async (payload: ImportLinkPayload): Promise<Print> => {
|
||||
fromLink: async (payload: ImportLinkPayload): Promise<Print & { import_outcome?: ImportOutcome }> => {
|
||||
const res = await fetch(`${apiBase()}/import`, {
|
||||
method: "POST",
|
||||
headers: authHeaders({ "Content-Type": "application/json" }),
|
||||
|
||||
@@ -2,7 +2,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 } from "../../api/imports";
|
||||
import { importsApi, type ImportOutcome } from "../../api/imports";
|
||||
import { printsApi, type Print } from "../../api/prints";
|
||||
import { entriesFromFileList, uploadEntriesToCategory } from "../../utils/uploadTree";
|
||||
import { buildUploadEntriesFromZip, isZipFile, readZipEntries } from "../../utils/zipUtils";
|
||||
@@ -183,6 +183,17 @@ export function useUploadImport({ onUploaded, categoryId, makerworldCookie, onUn
|
||||
/>
|
||||
);
|
||||
|
||||
const showImportedToast = (imported: Print & { import_outcome?: ImportOutcome }) => {
|
||||
const name = imported.title || imported.name;
|
||||
const key =
|
||||
imported.import_outcome === "profile_added"
|
||||
? "uploadBar.profileAdded"
|
||||
: imported.import_outcome === "already_imported"
|
||||
? "uploadBar.alreadyInLibrary"
|
||||
: "uploadBar.imported";
|
||||
showToast({ message: t(key, { name }) });
|
||||
};
|
||||
|
||||
const submitImport = async (rawUrl: string) => {
|
||||
const url = rawUrl.trim();
|
||||
if (!url) return;
|
||||
@@ -290,7 +301,7 @@ export function useUploadImport({ onUploaded, categoryId, makerworldCookie, onUn
|
||||
// Both always resolve to several files; skip straight past the inspect/zip-picker steps
|
||||
// -- the backend already splits them into plates automatically.
|
||||
const imported = await importsApi.fromLink(payload);
|
||||
showToast({ message: t("uploadBar.imported", { name: imported.title || imported.name }) });
|
||||
showImportedToast(imported);
|
||||
onUploaded();
|
||||
openForViewing(imported);
|
||||
return;
|
||||
@@ -299,7 +310,7 @@ export function useUploadImport({ onUploaded, categoryId, makerworldCookie, onUn
|
||||
const inspect = await importsApi.inspectLink(payload);
|
||||
if (!inspect.is_zip) {
|
||||
const imported = await importsApi.fromLink(payload);
|
||||
showToast({ message: t("uploadBar.imported", { name: imported.title || imported.name }) });
|
||||
showImportedToast(imported);
|
||||
onUploaded();
|
||||
openForViewing(imported);
|
||||
return;
|
||||
@@ -310,7 +321,7 @@ export function useUploadImport({ onUploaded, categoryId, makerworldCookie, onUn
|
||||
onImportAsZip: async () => {
|
||||
try {
|
||||
const imported = await importsApi.fromLink(payload);
|
||||
showToast({ message: t("uploadBar.imported", { name: imported.title || imported.name }) });
|
||||
showImportedToast(imported);
|
||||
onUploaded();
|
||||
openForViewing(imported);
|
||||
} catch (err) {
|
||||
|
||||
@@ -66,6 +66,8 @@
|
||||
"multiplateImportFailedWithMessage": "Multi-plate import ({{message}})",
|
||||
"multiplateImportFailed": "Multi-plate import",
|
||||
"imported": "Imported \"{{name}}\"",
|
||||
"profileAdded": "Added a print profile to \"{{name}}\"",
|
||||
"alreadyInLibrary": "\"{{name}}\" is already in your library",
|
||||
"uploaded": "Uploaded \"{{name}}\"",
|
||||
"uploadedMultiple": "Uploaded {{count}} models"
|
||||
},
|
||||
|
||||
@@ -66,6 +66,8 @@
|
||||
"multiplateImportFailedWithMessage": "Kelių plokščių importas ({{message}})",
|
||||
"multiplateImportFailed": "Kelių plokščių importas",
|
||||
"imported": "Importuota „{{name}}“",
|
||||
"profileAdded": "Pridėtas spausdinimo profilis prie „{{name}}“",
|
||||
"alreadyInLibrary": "„{{name}}“ jau yra jūsų bibliotekoje",
|
||||
"uploaded": "Įkelta „{{name}}“",
|
||||
"uploadedMultiple": "Įkelta {{count}} modelių"
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user