mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-19 05:10:01 -05:00
feat: printables importing
This commit is contained in:
1 parent
58c773b955
commit
47a0dddf93
13 files changed
+1053
-236
No files matched your search
@@ -19,12 +19,14 @@ import {
|
||||
parseThingiverseCollectionUrl,
|
||||
parseThingiverseLikesUrl,
|
||||
} from "../services/thingiverseApi";
|
||||
import { fetchPrintablesCollectionEntries, parsePrintablesCollectionUrl } from "../services/printablesApi";
|
||||
import { getThingiverseAccessToken } from "../services/settingsService";
|
||||
import { getUserMakerworldCookie } from "../services/makerworldCookieService";
|
||||
import { listZipEntries } from "../services/zipService";
|
||||
import { createJob, getActiveJob, getJob } from "../services/importJobService";
|
||||
import {
|
||||
runCollectionImportJob,
|
||||
runPrintablesCollectionImportJob,
|
||||
runThingiverseCollectionImportJob,
|
||||
runThingiverseLikesImportJob,
|
||||
runZipImportJob,
|
||||
@@ -218,6 +220,36 @@ router.post(
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/import/printables-collection/entries",
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = parseBody(importRequestSchema, req.body);
|
||||
const url = await normalizeImportUrl(body.url);
|
||||
const parsed = parsePrintablesCollectionUrl(url);
|
||||
if (!parsed) throw new HttpError(400, "Not a Printables Collection URL");
|
||||
|
||||
const listing = await fetchPrintablesCollectionEntries(parsed.collectionId, url);
|
||||
if (!listing.entries.length) throw new HttpError(400, "Could not load this collection's models");
|
||||
|
||||
const alreadyImported = await findImportedExternalIds(
|
||||
req.userId!,
|
||||
"printables",
|
||||
listing.entries.map((e) => e.modelId),
|
||||
);
|
||||
res.json({
|
||||
title: listing.title,
|
||||
total: listing.entries.length,
|
||||
truncated: listing.truncated,
|
||||
entries: listing.entries.map((e) => ({
|
||||
design_id: e.modelId,
|
||||
title: e.title,
|
||||
cover: e.cover,
|
||||
already_imported: alreadyImported.has(e.modelId),
|
||||
})),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
// ---- Background batch imports (MakerWorld/Thingiverse collections, Thingiverse Likes, zip) ----
|
||||
//
|
||||
// All three of these can involve downloading dozens to hundreds of files, which used to happen
|
||||
@@ -288,6 +320,26 @@ router.post(
|
||||
}),
|
||||
);
|
||||
|
||||
const printablesCollectionImportRequestSchema = importRequestSchema.extend({ model_ids: z.array(z.string()).min(1) });
|
||||
|
||||
router.post(
|
||||
"/import/printables-collection",
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = parseBody(printablesCollectionImportRequestSchema, req.body);
|
||||
await assertNoActiveJob(req.userId!);
|
||||
const url = await normalizeImportUrl(body.url);
|
||||
const parsed = parsePrintablesCollectionUrl(url);
|
||||
if (!parsed) throw new HttpError(400, "Not a Printables Collection URL");
|
||||
const job = await createJob(req.userId!, "COLLECTION", {
|
||||
sourceUrl: url,
|
||||
provider: "printables",
|
||||
total: body.model_ids.length,
|
||||
});
|
||||
void runPrintablesCollectionImportJob(job.id, req.userId!, { ...body, url, collectionId: parsed.collectionId });
|
||||
res.status(202).json({ job_id: job.id });
|
||||
}),
|
||||
);
|
||||
|
||||
const zipExtractRequestSchema = importRequestSchema.extend({ entries: z.array(z.string()) });
|
||||
|
||||
router.post(
|
||||
|
||||
@@ -13,6 +13,7 @@ import { upsertAuthorFromImport } from "./authorService";
|
||||
import { extractZipEntriesToPrints } from "./zipService";
|
||||
import { fetchThingiverseCollectionTitle } from "./thingiverseApi";
|
||||
import { getThingiverseAccessToken } from "./settingsService";
|
||||
import { fetchPrintablesCollectionTitle } from "./printablesApi";
|
||||
import { createLog } from "./auditLog";
|
||||
import { HttpError } from "../utils/fileUtils";
|
||||
|
||||
@@ -31,6 +32,7 @@ type CollectionImportJobBody = ImportRequestBody & { design_ids: string[] };
|
||||
type ZipImportJobBody = ImportRequestBody & { entries: string[] };
|
||||
type ThingiverseLikesImportJobBody = ImportRequestBody & { thing_ids: string[]; username: string };
|
||||
type ThingiverseCollectionImportJobBody = ImportRequestBody & { thing_ids: string[]; collectionId: string };
|
||||
type PrintablesCollectionImportJobBody = ImportRequestBody & { model_ids: string[]; collectionId: string };
|
||||
|
||||
// Distinguishes *why* a single design failed, so a batch of many failures reads as one clear
|
||||
// cause instead of an opaque "N failed":
|
||||
@@ -310,6 +312,88 @@ export async function runThingiverseCollectionImportJob(
|
||||
);
|
||||
}
|
||||
|
||||
/** Runs a named Printables Collection's batch import in the background -- see
|
||||
* routes/imports.ts's POST /import/printables-collection. Mirrors
|
||||
* runThingiverseThingsImportJob's shape (own resolver, same sequential pacing, files successful
|
||||
* imports into a PrintStash Collection named after the real Printables Collection name) but
|
||||
* simpler: Printables needs no access token, so there's no equivalent "isn't configured for this
|
||||
* instance" failure mode to handle up front. */
|
||||
export async function runPrintablesCollectionImportJob(jobId: string, userId: string, body: PrintablesCollectionImportJobBody): Promise<void> {
|
||||
try {
|
||||
let imported = 0;
|
||||
let alreadyInLibrary = 0;
|
||||
let processed = 0;
|
||||
let unavailable = 0;
|
||||
let rateLimited = 0;
|
||||
const failed: string[] = [];
|
||||
const successPrintIds: string[] = [];
|
||||
|
||||
await mapWithConcurrency(body.model_ids, COLLECTION_IMPORT_CONCURRENCY, async (modelId, index) => {
|
||||
const modelUrl = `https://www.printables.com/model/${modelId}`;
|
||||
const itemBody: ImportRequestBody = {
|
||||
url: modelUrl,
|
||||
notes: body.notes ?? null,
|
||||
tags: body.tags ?? [],
|
||||
folder_id: body.folder_id ?? null,
|
||||
};
|
||||
try {
|
||||
const { print, alreadyImported } = await importPrintFromUrl(userId, modelUrl, itemBody);
|
||||
successPrintIds.push(print.id);
|
||||
if (alreadyImported) alreadyInLibrary++;
|
||||
else imported++;
|
||||
} catch (err) {
|
||||
failed.push(modelId);
|
||||
const reason = classifyImportFailure(err);
|
||||
if (reason === "unavailable") unavailable++;
|
||||
else if (reason === "rateLimited") rateLimited++;
|
||||
} finally {
|
||||
processed++;
|
||||
await updateJob(jobId, { processed, imported, alreadyInLibrary, failedCount: failed.length }).catch(() => undefined);
|
||||
}
|
||||
if (index < body.model_ids.length - 1) await sleep(IMPORT_COLLECTION_DELAY_MS);
|
||||
});
|
||||
|
||||
let resultCollectionId: string | null = null;
|
||||
const collectionTitle = (await fetchPrintablesCollectionTitle(body.collectionId)) ?? `Printables Collection ${body.collectionId}`;
|
||||
if (successPrintIds.length) {
|
||||
const collection = await findOrCreateCollectionByName(userId, collectionTitle);
|
||||
await addPrintsToCollection(collection.id, successPrintIds);
|
||||
resultCollectionId = collection.id;
|
||||
}
|
||||
|
||||
await updateJob(jobId, {
|
||||
status: "DONE",
|
||||
sourceLabel: collectionTitle,
|
||||
resultCollectionId,
|
||||
processed,
|
||||
imported,
|
||||
alreadyInLibrary,
|
||||
failedCount: failed.length,
|
||||
});
|
||||
void createLog({
|
||||
userId,
|
||||
action: "import_completed",
|
||||
targetId: resultCollectionId,
|
||||
details: { provider: "printables", sourceLabel: collectionTitle, imported, alreadyInLibrary, failed: failed.length },
|
||||
});
|
||||
|
||||
const bodyParts: string[] = [];
|
||||
if (alreadyInLibrary) bodyParts.push(`${alreadyInLibrary} already in your library`);
|
||||
const otherFailed = failed.length - unavailable - rateLimited;
|
||||
if (unavailable) bodyParts.push(`${unavailable} unavailable (private, deleted, or removed)`);
|
||||
if (rateLimited) bodyParts.push(`${rateLimited} rate-limited by Printables — wait a while, then retry`);
|
||||
if (otherFailed) bodyParts.push(`${otherFailed} failed`);
|
||||
await createNotification(userId, {
|
||||
title: `Imported ${imported} of ${body.model_ids.length} models from Printables`,
|
||||
body: bodyParts.length ? `From "${collectionTitle}" — ${bodyParts.join(", ")}.` : `From "${collectionTitle}".`,
|
||||
externalUrl: body.url,
|
||||
internalPath: resultCollectionId ? `/models/collections/${resultCollectionId}` : null,
|
||||
});
|
||||
} catch (err) {
|
||||
await markJobFailed(jobId, err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs a remote zip's selected-entries batch import in the background -- see
|
||||
* routes/imports.ts's POST /import/zip. */
|
||||
export async function runZipImportJob(jobId: string, userId: string, body: ZipImportJobBody): Promise<void> {
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
IMPORT_TIMEOUT_SECONDS,
|
||||
IMPORT_USER_AGENT,
|
||||
} from "../config";
|
||||
import { HttpError, isJsonContentType } from "../utils/fileUtils";
|
||||
import { isJsonContentType } from "../utils/fileUtils";
|
||||
import {
|
||||
extractJsonFromBrowserBody,
|
||||
fetchViaFlaresolverr,
|
||||
@@ -81,27 +81,9 @@ function makerworldApiHeaders(referer?: string | null, nonce?: string | null, co
|
||||
return headers;
|
||||
}
|
||||
|
||||
export function isPrintablesPageHost(host: string): boolean {
|
||||
const lowered = (host || "").toLowerCase();
|
||||
return lowered === "printables.com" || lowered === "www.printables.com";
|
||||
}
|
||||
|
||||
export function printablesHtmlHeaders(referer?: string | null): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
"User-Agent": IMPORT_BROWSER_USER_AGENT,
|
||||
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
"Cache-Control": "no-cache",
|
||||
Pragma: "no-cache",
|
||||
"Upgrade-Insecure-Requests": "1",
|
||||
};
|
||||
if (referer) headers.Referer = referer;
|
||||
return headers;
|
||||
}
|
||||
|
||||
// Thingiverse import no longer goes through this page-scraping resolver at all -- see
|
||||
// thingiverseApi.ts, which talks to the official api.thingiverse.com Developer API instead
|
||||
// (a plain unauthenticated www.thingiverse.com fetch turned out to be Cloudflare-gated).
|
||||
// Neither Thingiverse nor Printables import goes through this page-scraping resolver at all --
|
||||
// see thingiverseApi.ts / printablesApi.ts, which talk to their own official/public JSON APIs
|
||||
// instead (a plain unauthenticated fetch of either site's own HTML pages is Cloudflare-gated).
|
||||
|
||||
// -- Small bounded fetch helpers (used only for resolver-side HTML/JSON probes; the actual
|
||||
// model-file download streams straight to disk in importService.ts, not through here). ------
|
||||
@@ -181,21 +163,6 @@ async function fetchCappedBuffer(
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseJsonErrorMessage(raw: Buffer): string | null {
|
||||
try {
|
||||
const data = JSON.parse(raw.toString("utf-8"));
|
||||
if (data && typeof data === "object" && !Array.isArray(data)) {
|
||||
for (const key of ["error", "detail", "message"]) {
|
||||
const value = (data as Record<string, unknown>)[key];
|
||||
if (typeof value === "string" && value.trim()) return value.trim();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function fetchJsonFromUrl(
|
||||
url: string,
|
||||
referer?: string | null,
|
||||
@@ -382,7 +349,7 @@ function extractDownloadUrlFromResponse(data: unknown, baseUrl: string): string
|
||||
|
||||
// -- MakerWorld --------------------------------------------------------------------------------
|
||||
|
||||
function extractNextDataJson(html: string): unknown | null {
|
||||
export function extractNextDataJson(html: string): unknown | null {
|
||||
const match = html.match(/<script[^>]+id=["']__NEXT_DATA__["'][^>]*>([\s\S]*?)<\/script>/i);
|
||||
if (!match) return null;
|
||||
const raw = (match[1] || "").trim();
|
||||
@@ -647,182 +614,5 @@ export async function resolveMakerworldDownloadUrl(
|
||||
return null;
|
||||
}
|
||||
|
||||
// -- Printables --------------------------------------------------------------------------------
|
||||
|
||||
function printablesModelIdFromUrl(url: string): string | null {
|
||||
try {
|
||||
const match = new URL(url).pathname.match(/\/model\/(\d+)/);
|
||||
return match ? match[1] : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPrintablesGraphql(query: string, variables: Record<string, unknown>, referer: string | null): Promise<unknown | null> {
|
||||
const headers: Record<string, string> = {
|
||||
"User-Agent": IMPORT_BROWSER_USER_AGENT,
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
Origin: "https://www.printables.com",
|
||||
};
|
||||
if (referer) headers.Referer = referer;
|
||||
const result = await fetchCappedBuffer("https://api.printables.com/graphql/", headers, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ query, variables: variables || {} }),
|
||||
});
|
||||
if (!result) return null;
|
||||
if (result.status >= 400) {
|
||||
const message = parseJsonErrorMessage(result.buffer);
|
||||
if ([401, 403, 429].includes(result.status)) {
|
||||
throw new HttpError(result.status, message || "Printables request failed");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (result.buffer.length > IMPORT_HTML_MAX_BYTES) return null;
|
||||
try {
|
||||
return JSON.parse(result.buffer.toString("utf-8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parsePrintablesErrorMessages(errors: unknown): string | null {
|
||||
if (!Array.isArray(errors)) return null;
|
||||
const messages: string[] = [];
|
||||
for (const err of errors) {
|
||||
if (!err || typeof err !== "object") continue;
|
||||
const values = (err as Record<string, unknown>).messages;
|
||||
if (Array.isArray(values)) {
|
||||
for (const v of values) if (typeof v === "string" && v.trim()) messages.push(v.trim());
|
||||
} else if (typeof values === "string" && values.trim()) {
|
||||
messages.push(values.trim());
|
||||
}
|
||||
}
|
||||
return messages.length ? messages.join(", ") : null;
|
||||
}
|
||||
|
||||
function selectBestDownloadLink(links: string[]): string | null {
|
||||
const candidates = links.filter((l) => isAllowedDownloadCandidate(l));
|
||||
if (!candidates.length) return null;
|
||||
candidates.sort((a, b) => scoreDownloadUrl(b) - scoreDownloadUrl(a));
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
function extractPrintablesIds(items: unknown): string[] {
|
||||
if (!Array.isArray(items)) return [];
|
||||
const ids: string[] = [];
|
||||
for (const item of items) {
|
||||
if (item && typeof item === "object" && (item as Record<string, unknown>).id !== undefined) {
|
||||
ids.push(String((item as Record<string, unknown>).id));
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
async function fetchPrintablesModelMeta(printId: string, pageUrl: string): Promise<Record<string, unknown> | null> {
|
||||
const query = `
|
||||
query ($id: ID!) {
|
||||
print(id: $id) {
|
||||
id
|
||||
downloadPacks { id fileType }
|
||||
stls { id name }
|
||||
}
|
||||
}
|
||||
`;
|
||||
const data = (await fetchPrintablesGraphql(query, { id: printId }, pageUrl)) as
|
||||
| { data?: { print?: Record<string, unknown> } }
|
||||
| null;
|
||||
return data?.data?.print ?? null;
|
||||
}
|
||||
|
||||
async function fetchPrintablesDownloadOutput(
|
||||
printId: string,
|
||||
variables: Record<string, unknown>,
|
||||
pageUrl: string,
|
||||
): Promise<Record<string, unknown> | null> {
|
||||
const mutation = `
|
||||
mutation ($printId: ID!, $source: DownloadSourceEnum!, $fileType: DownloadFileTypeEnum, $id: ID, $files: [DownloadFileInput!]) {
|
||||
getDownloadLink(printId: $printId, source: $source, fileType: $fileType, id: $id, files: $files) {
|
||||
ok
|
||||
errors { field messages code }
|
||||
output { link files { id link fileType } }
|
||||
}
|
||||
}
|
||||
`;
|
||||
const data = (await fetchPrintablesGraphql(mutation, variables, pageUrl)) as
|
||||
| { data?: { getDownloadLink?: Record<string, unknown> } }
|
||||
| null;
|
||||
const result = data?.data?.getDownloadLink;
|
||||
if (!result) return null;
|
||||
if (result.ok === false) {
|
||||
const message = parsePrintablesErrorMessages(result.errors);
|
||||
if (message) throw new HttpError(400, message);
|
||||
return null;
|
||||
}
|
||||
const output = result.output;
|
||||
return output && typeof output === "object" ? (output as Record<string, unknown>) : null;
|
||||
}
|
||||
|
||||
function collectPrintablesLinks(output: Record<string, unknown> | null): string[] {
|
||||
if (!output) return [];
|
||||
const links: string[] = [];
|
||||
if (typeof output.link === "string") links.push(output.link);
|
||||
const files = output.files;
|
||||
if (Array.isArray(files)) {
|
||||
for (const entry of files) {
|
||||
if (entry && typeof entry === "object" && typeof (entry as Record<string, unknown>).link === "string") {
|
||||
links.push((entry as Record<string, unknown>).link as string);
|
||||
}
|
||||
}
|
||||
}
|
||||
return links;
|
||||
}
|
||||
|
||||
export async function resolvePrintablesDownloadUrl(pageUrl: string): Promise<string | null> {
|
||||
const printId = printablesModelIdFromUrl(pageUrl);
|
||||
if (!printId) return null;
|
||||
const meta = await fetchPrintablesModelMeta(printId, pageUrl);
|
||||
if (!meta) return null;
|
||||
|
||||
const packs = meta.downloadPacks;
|
||||
let packId: string | null = null;
|
||||
if (Array.isArray(packs)) {
|
||||
for (const pack of packs) {
|
||||
if (pack && typeof pack === "object" && (pack as Record<string, unknown>).fileType === "MODEL_FILES" && (pack as Record<string, unknown>).id) {
|
||||
packId = String((pack as Record<string, unknown>).id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!packId && packs.length) {
|
||||
for (const pack of packs) {
|
||||
if (pack && typeof pack === "object" && (pack as Record<string, unknown>).id) {
|
||||
packId = String((pack as Record<string, unknown>).id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (packId) {
|
||||
const output = await fetchPrintablesDownloadOutput(
|
||||
printId,
|
||||
{ printId, source: "model_detail", fileType: "pack", id: packId },
|
||||
pageUrl,
|
||||
);
|
||||
const best = selectBestDownloadLink(collectPrintablesLinks(output));
|
||||
if (best) return best;
|
||||
}
|
||||
|
||||
const stlIds = extractPrintablesIds(meta.stls);
|
||||
if (stlIds.length) {
|
||||
const output = await fetchPrintablesDownloadOutput(
|
||||
printId,
|
||||
{ printId, source: "model_detail", files: [{ fileType: "stl", ids: stlIds }] },
|
||||
pageUrl,
|
||||
);
|
||||
const best = selectBestDownloadLink(collectPrintablesLinks(output));
|
||||
if (best) return best;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
// Printables import also no longer goes through this generic resolver -- see printablesApi.ts,
|
||||
// which talks to the public api.printables.com GraphQL endpoint directly.
|
||||
@@ -19,12 +19,9 @@ import {
|
||||
emptyImportedPageMetadata,
|
||||
extractPageMetadata,
|
||||
findDownloadUrl,
|
||||
isPrintablesPageHost,
|
||||
makerworldHtmlHeaders,
|
||||
printablesHtmlHeaders,
|
||||
resolveMakerworldCookie,
|
||||
resolveMakerworldDownloadUrl,
|
||||
resolvePrintablesDownloadUrl,
|
||||
type ImportCookies,
|
||||
type ImportedPageMetadata,
|
||||
} from "./importResolvers";
|
||||
@@ -42,6 +39,12 @@ import {
|
||||
ThingiverseRateLimitError,
|
||||
type ThingiversePlateFile,
|
||||
} from "./thingiverseApi";
|
||||
import {
|
||||
parsePrintablesModelUrl,
|
||||
resolvePrintablesDownloadLinks,
|
||||
resolvePrintablesModel,
|
||||
type PrintablesPlateFile,
|
||||
} from "./printablesApi";
|
||||
import { getThingiverseAccessToken } from "./settingsService";
|
||||
import { upsertAuthorFromImport } from "./authorService";
|
||||
import { createPrint, type NewPlateInput, type PrintMetaInput } from "./printCreation";
|
||||
@@ -221,8 +224,6 @@ export async function openImportResponse(
|
||||
if (host.endsWith("makerworld.com")) {
|
||||
makerworldCookie = resolveMakerworldCookie(body);
|
||||
Object.assign(headers, makerworldHtmlHeaders(referer || validatedUrl, makerworldCookie));
|
||||
} else if (isPrintablesPageHost(host)) {
|
||||
Object.assign(headers, printablesHtmlHeaders(referer || validatedUrl));
|
||||
}
|
||||
if (referer) headers.Referer = referer;
|
||||
|
||||
@@ -264,9 +265,6 @@ export async function openImportResponse(
|
||||
if (!makerworldCookie) makerworldCookie = resolveMakerworldCookie(body);
|
||||
downloadUrl = await resolveMakerworldDownloadUrl(html, finalUrl, makerworldCookie);
|
||||
}
|
||||
if (!downloadUrl && isPrintablesPageHost(pageHost)) {
|
||||
downloadUrl = await resolvePrintablesDownloadUrl(finalUrl);
|
||||
}
|
||||
if (!downloadUrl) {
|
||||
downloadUrl = findDownloadUrl(html, finalUrl);
|
||||
}
|
||||
@@ -452,6 +450,8 @@ export function identifySourceModel(url: string): { provider: string; externalId
|
||||
if (makerworld) return { provider: "makerworld", externalId: makerworld.designId };
|
||||
const thingiverse = parseThingiverseThingUrl(url);
|
||||
if (thingiverse) return { provider: "thingiverse", externalId: thingiverse.thingId };
|
||||
const printables = parsePrintablesModelUrl(url);
|
||||
if (printables) return { provider: "printables", externalId: printables.modelId };
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -464,6 +464,7 @@ export function buildImportSourceUrl(provider: string | null, externalId: string
|
||||
if (!provider || !externalId) return null;
|
||||
if (provider === "makerworld") return `https://makerworld.com/en/models/${externalId}`;
|
||||
if (provider === "thingiverse") return `https://www.thingiverse.com/thing:${externalId}`;
|
||||
if (provider === "printables") return `https://www.printables.com/model/${externalId}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -497,12 +498,15 @@ export async function findImportedExternalIds(userId: string, provider: string,
|
||||
return new Set(prints.map((p) => p.sourceExternalId).filter((id): id is string => id !== null));
|
||||
}
|
||||
|
||||
const THINGIVERSE_PLATE_EXTS = new Set([...IMPORT_ALLOWED_EXTS].filter((ext) => ext !== ".zip"));
|
||||
// Shared by both multi-plate providers (Thingiverse's zip_data.files, Printables' resolved
|
||||
// download links) to filter their bundled file list down to actual model files.
|
||||
const MULTI_FILE_PLATE_EXTS = new Set([...IMPORT_ALLOWED_EXTS].filter((ext) => ext !== ".zip"));
|
||||
|
||||
type PlainDownloadResult = { input: NewPlateInput } | { rateLimited: true } | null;
|
||||
|
||||
/** Downloads one plain, unauthenticated file URL (a Thingiverse CDN asset -- see
|
||||
* thingiverseApi.ts's zip_data.files) to a temp file. Best-effort: returns null instead of
|
||||
/** Downloads one plain, unauthenticated file URL (a Thingiverse CDN asset, or a Printables
|
||||
* resolved download link -- see thingiverseApi.ts's zip_data.files / printablesApi.ts's
|
||||
* resolvePrintablesDownloadLinks) to a temp file. Best-effort: returns null instead of
|
||||
* throwing, so one unreachable file among several doesn't fail the whole Thing import -- except
|
||||
* a 429 (Cloudflare rate-limit challenge, same as api.thingiverse.com can return -- see
|
||||
* ThingiverseRateLimitError), which is reported back distinctly since the caller needs to know
|
||||
@@ -579,7 +583,7 @@ async function importThingiverseThing(
|
||||
sourceExternalId: source.externalId,
|
||||
};
|
||||
|
||||
const modelFiles = plateFiles.filter((f: ThingiversePlateFile) => THINGIVERSE_PLATE_EXTS.has(path.extname(f.name).toLowerCase()));
|
||||
const modelFiles = plateFiles.filter((f: ThingiversePlateFile) => MULTI_FILE_PLATE_EXTS.has(path.extname(f.name).toLowerCase()));
|
||||
const downloadResults = await Promise.all(modelFiles.map((f: ThingiversePlateFile) => downloadPlainFileToTemp(f.url, f.name)));
|
||||
const downloaded = downloadResults
|
||||
.filter((result): result is { input: NewPlateInput } => result !== null && "input" in result)
|
||||
@@ -624,6 +628,82 @@ async function importThingiverseThing(
|
||||
}
|
||||
}
|
||||
|
||||
/** Printables import path: mirrors importThingiverseThing (own resolver, own multi-plate
|
||||
* handling, bypasses openImportResponse's generic HTML-scraping chain entirely -- see
|
||||
* printablesApi.ts for why: www.printables.com is Cloudflare-gated, the public
|
||||
* api.printables.com GraphQL endpoint resolves everything needed directly, no auth required). */
|
||||
async function importPrintablesModel(
|
||||
userId: string,
|
||||
source: { provider: string; externalId: string },
|
||||
body: ImportRequestBody,
|
||||
): Promise<{ print: Print; plates: Plate[]; author: Author | null; previewImages: PreviewImage[]; alreadyImported: boolean }> {
|
||||
const resolved = await resolvePrintablesModel(source.externalId);
|
||||
if (!resolved) {
|
||||
throw new HttpError(404, "This Printables model could not be found, or isn't public.");
|
||||
}
|
||||
const { meta, plateFiles, galleryImages } = resolved;
|
||||
|
||||
const modelFiles = plateFiles.filter((f: PrintablesPlateFile) => MULTI_FILE_PLATE_EXTS.has(path.extname(f.name).toLowerCase()));
|
||||
if (!modelFiles.length) {
|
||||
throw new HttpError(400, "This Printables model has no downloadable model files (only sliced/print-ready files, if any).");
|
||||
}
|
||||
const downloadLinks = await resolvePrintablesDownloadLinks(source.externalId, modelFiles.map((f) => f.id));
|
||||
const downloadResults = await Promise.all(
|
||||
modelFiles
|
||||
.map((f) => ({ file: f, link: downloadLinks.get(f.id) }))
|
||||
.filter((entry): entry is { file: PrintablesPlateFile; link: string } => Boolean(entry.link))
|
||||
.map((entry) => downloadPlainFileToTemp(entry.link, entry.file.name)),
|
||||
);
|
||||
const downloaded = downloadResults
|
||||
.filter((result): result is { input: NewPlateInput } => result !== null && "input" in result)
|
||||
.map((result) => result.input);
|
||||
if (!downloaded.length) {
|
||||
throw new HttpError(400, "None of this model's files could be downloaded.");
|
||||
}
|
||||
|
||||
const author = await upsertAuthorFromImport(meta.author ?? null);
|
||||
const folderId =
|
||||
body.folder_id ?? (await resolveFolderIdByCategory(userId, meta.categorySite ?? null, meta.siteCategoryIds ?? []));
|
||||
const printMeta: PrintMetaInput = {
|
||||
title: body.title ?? meta.title ?? null,
|
||||
notes: body.notes ?? meta.description ?? null,
|
||||
tags: body.tags && body.tags.length ? body.tags : (meta.tags ?? []),
|
||||
folderId,
|
||||
creator: meta.creator ?? null,
|
||||
authorId: author?.id ?? null,
|
||||
sourceProvider: source.provider,
|
||||
sourceExternalId: source.externalId,
|
||||
};
|
||||
|
||||
try {
|
||||
let result: { print: Print; plates: Plate[] };
|
||||
try {
|
||||
result = await createPrint(userId, printMeta, meta.title || `printables-${source.externalId}`, downloaded);
|
||||
} catch (err) {
|
||||
// Race guard: another concurrent import of the same source model won between our
|
||||
// dedup check above and this create -- treat it the same as finding it up front.
|
||||
if (isUniqueConstraintError(err)) {
|
||||
const existing = await findExistingImportedPrint(userId, source);
|
||||
if (existing) return { ...existing, alreadyImported: true };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const gallery = galleryImages.map((img) => ({ url: img.url, filename: img.name }));
|
||||
await attachImportedPreviewImages(result.print.id, result.plates[0]?.id, meta.previewImageUrl ?? null, gallery);
|
||||
const previewImages = await prisma.previewImage.findMany({
|
||||
where: { printId: result.print.id },
|
||||
orderBy: { position: "asc" },
|
||||
});
|
||||
return { ...result, author, previewImages, alreadyImported: false };
|
||||
} finally {
|
||||
for (const input of downloaded) {
|
||||
if (input.tempFilePath && fsSync.existsSync(input.tempFilePath)) {
|
||||
await fs.rm(input.tempFilePath, { force: true }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Downloads a URL and creates a Print from it (POST /import) -- one plate for most sources, or
|
||||
* several when the source is a Thingiverse Thing (see importThingiverseThing). Returns
|
||||
* `alreadyImported: true` (and the existing print, left untouched) instead of re-downloading
|
||||
@@ -642,6 +722,9 @@ export async function importPrintFromUrl(
|
||||
if (source?.provider === "thingiverse") {
|
||||
return importThingiverseThing(userId, source, body);
|
||||
}
|
||||
if (source?.provider === "printables") {
|
||||
return importPrintablesModel(userId, source, body);
|
||||
}
|
||||
|
||||
const { tempPath, filename, mime, meta } = await downloadImportToTemp(url, body);
|
||||
const author = await upsertAuthorFromImport(meta.author);
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
import { IMPORT_BROWSER_USER_AGENT, IMPORT_HTML_MAX_BYTES, IMPORT_TIMEOUT_SECONDS } from "../config";
|
||||
import { HttpError } from "../utils/fileUtils";
|
||||
import { extractNextDataJson, htmlToPlainText, type ImportedAuthorInfo, type ImportedPageMetadata } from "./importResolvers";
|
||||
import { fetchViaFlaresolverr, isFlaresolverrEnabled } from "./flaresolverr";
|
||||
|
||||
// The public api.printables.com GraphQL endpoint -- unlike www.printables.com (Cloudflare-gated,
|
||||
// confirmed via a plain fetch returning its "Just a moment..." challenge page), api.printables.com
|
||||
// answers a bare unauthenticated POST directly, confirmed live against a real model id. No cookie,
|
||||
// no bearer token, no FlareSolverr proxying needed for any of the requests below.
|
||||
const PRINTABLES_GRAPHQL_URL = "https://api.printables.com/graphql/";
|
||||
const PRINTABLES_MEDIA_BASE = "https://media.printables.com/";
|
||||
const API_TIMEOUT_MS = IMPORT_TIMEOUT_SECONDS * 1000;
|
||||
const PRINTABLES_PROVIDER = "printables";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function mediaUrl(filePath: unknown): string | null {
|
||||
return typeof filePath === "string" && filePath.trim() ? `${PRINTABLES_MEDIA_BASE}${filePath.trim()}` : null;
|
||||
}
|
||||
|
||||
export function parsePrintablesModelUrl(url: string): { modelId: string } | null {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
if (host !== "printables.com" && host !== "www.printables.com") return null;
|
||||
const m = parsed.pathname.match(/\/model\/(\d+)/i);
|
||||
return m ? { modelId: m[1] } : null;
|
||||
}
|
||||
|
||||
async function fetchPrintablesGraphql(query: string, variables: Record<string, unknown>): Promise<unknown | null> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), API_TIMEOUT_MS);
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(PRINTABLES_GRAPHQL_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"User-Agent": IMPORT_BROWSER_USER_AGENT,
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
Origin: "https://www.printables.com",
|
||||
},
|
||||
body: JSON.stringify({ query, variables }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
const text = await res.text();
|
||||
if (res.status === 429) throw new HttpError(429, "Printables rate-limited this request. Wait a bit and try again.");
|
||||
if (!res.ok) return null;
|
||||
if (text.length > IMPORT_HTML_MAX_BYTES) return null;
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parsePrintablesErrorMessages(errors: unknown): string | null {
|
||||
if (!Array.isArray(errors)) return null;
|
||||
const messages: string[] = [];
|
||||
for (const err of errors) {
|
||||
if (!err || typeof err !== "object") continue;
|
||||
const values = (err as Record<string, unknown>).messages;
|
||||
if (Array.isArray(values)) {
|
||||
for (const v of values) if (typeof v === "string" && v.trim()) messages.push(v.trim());
|
||||
}
|
||||
}
|
||||
return messages.length ? messages.join(", ") : null;
|
||||
}
|
||||
|
||||
const MODEL_QUERY = `
|
||||
query ($id: ID!) {
|
||||
print(id: $id) {
|
||||
id
|
||||
name
|
||||
description
|
||||
user { id handle publicUsername avatarFilePath }
|
||||
image { filePath }
|
||||
images { filePath }
|
||||
tags { name }
|
||||
category { id name }
|
||||
stls { id name }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const DOWNLOAD_LINK_MUTATION = `
|
||||
mutation ($printId: ID!, $source: DownloadSourceEnum!, $files: [DownloadFileInput!]) {
|
||||
getDownloadLink(printId: $printId, source: $source, files: $files) {
|
||||
ok
|
||||
errors { field messages code }
|
||||
output { files { id link } }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
function extractAuthor(user: Record<string, unknown>): ImportedAuthorInfo | null {
|
||||
const externalId = user.id != null ? String(user.id) : null;
|
||||
if (!externalId) return null;
|
||||
const handle = typeof user.handle === "string" && user.handle.trim() ? user.handle.trim() : null;
|
||||
const name =
|
||||
(typeof user.publicUsername === "string" && user.publicUsername.trim() && user.publicUsername.trim()) || handle;
|
||||
return {
|
||||
provider: PRINTABLES_PROVIDER,
|
||||
externalId,
|
||||
name,
|
||||
handle,
|
||||
bio: null,
|
||||
bioTranslated: null,
|
||||
links: handle ? [`https://www.printables.com/@${handle}`] : [],
|
||||
avatarUrl: mediaUrl(user.avatarFilePath),
|
||||
backgroundUrl: null,
|
||||
};
|
||||
}
|
||||
|
||||
export type PrintablesPlateFile = { name: string; id: string };
|
||||
export type PrintablesGalleryImage = { name: string; url: string };
|
||||
|
||||
export type PrintablesModelResolution = {
|
||||
meta: Partial<ImportedPageMetadata>;
|
||||
/** The model's actual model files (`.3mf`/`.stl`/`.step`/etc, per the `stls` bucket -- despite
|
||||
* the name, Printables uses it for every generic model-file upload, not literally STL-only).
|
||||
* Deliberately excludes the `gcodes`/`slas` buckets: those are pre-sliced, printer/material-
|
||||
* specific output, not the kind of generic model file the other providers import either. Each
|
||||
* still needs its actual download link resolved separately (see resolvePrintablesDownloadLinks
|
||||
* below) -- the id here is Printables' file id, not yet a URL. */
|
||||
plateFiles: PrintablesPlateFile[];
|
||||
/** The model's photo gallery (includes the cover image too) -- fed to
|
||||
* attachImportedPreviewImages as extra gallery images alongside the cover thumbnail, same as
|
||||
* ThingiverseThingResolution.galleryImages. */
|
||||
galleryImages: PrintablesGalleryImage[];
|
||||
};
|
||||
|
||||
/** Resolves a Printables model's metadata (title, description, tags, author, category, cover +
|
||||
* gallery images) and its file list (ids + names only -- see resolvePrintablesDownloadLinks for
|
||||
* turning those into actual download URLs) from the public GraphQL API. Returns null for a
|
||||
* model that doesn't exist / isn't public. */
|
||||
export async function resolvePrintablesModel(modelId: string): Promise<PrintablesModelResolution | null> {
|
||||
const data = (await fetchPrintablesGraphql(MODEL_QUERY, { id: modelId })) as
|
||||
| { data?: { print?: Record<string, unknown> } }
|
||||
| null;
|
||||
const model = data?.data?.print;
|
||||
if (!isRecord(model)) return null;
|
||||
|
||||
const meta: Partial<ImportedPageMetadata> = {};
|
||||
if (typeof model.name === "string" && model.name.trim()) meta.title = model.name.trim();
|
||||
if (typeof model.description === "string" && model.description.trim()) {
|
||||
const plainText = htmlToPlainText(model.description);
|
||||
if (plainText) meta.description = plainText;
|
||||
}
|
||||
if (Array.isArray(model.tags)) {
|
||||
const tags = model.tags
|
||||
.map((t) => (isRecord(t) && typeof t.name === "string" ? t.name.trim() : null))
|
||||
.filter((t): t is string => Boolean(t));
|
||||
if (tags.length) meta.tags = tags;
|
||||
}
|
||||
|
||||
const cover = isRecord(model.image) ? mediaUrl(model.image.filePath) : null;
|
||||
if (cover) meta.previewImageUrl = cover;
|
||||
const galleryImages: PrintablesGalleryImage[] = Array.isArray(model.images)
|
||||
? model.images
|
||||
.map((img, idx) => (isRecord(img) ? { name: `image-${idx}.jpg`, url: mediaUrl(img.filePath) } : null))
|
||||
.filter((img): img is PrintablesGalleryImage & { url: string } => Boolean(img?.url))
|
||||
: [];
|
||||
|
||||
if (isRecord(model.user)) {
|
||||
const author = extractAuthor(model.user);
|
||||
if (author) {
|
||||
meta.author = author;
|
||||
meta.creator = author.name;
|
||||
}
|
||||
}
|
||||
|
||||
if (isRecord(model.category) && model.category.id != null) {
|
||||
const categoryId = Number(model.category.id);
|
||||
if (Number.isFinite(categoryId)) {
|
||||
meta.siteCategoryIds = [categoryId];
|
||||
meta.categorySite = PRINTABLES_PROVIDER;
|
||||
}
|
||||
}
|
||||
|
||||
const plateFiles: PrintablesPlateFile[] = Array.isArray(model.stls)
|
||||
? model.stls
|
||||
.filter((f): f is Record<string, unknown> => isRecord(f) && typeof f.name === "string" && f.id != null)
|
||||
.map((f) => ({ name: f.name as string, id: String(f.id) }))
|
||||
: [];
|
||||
|
||||
return { meta, plateFiles, galleryImages };
|
||||
}
|
||||
|
||||
/** Resolves the real, time-limited download link for each of a model's files (Printables never
|
||||
* exposes a static/direct URL for a model file -- every download has to go through this mutation
|
||||
* first) in one batched call. Best-effort per file: a file missing from the response just won't
|
||||
* become a plate, same as importThingiverseThing's tolerance for individual unreachable files. */
|
||||
export async function resolvePrintablesDownloadLinks(
|
||||
modelId: string,
|
||||
fileIds: string[],
|
||||
): Promise<Map<string, string>> {
|
||||
const links = new Map<string, string>();
|
||||
if (!fileIds.length) return links;
|
||||
|
||||
const data = (await fetchPrintablesGraphql(DOWNLOAD_LINK_MUTATION, {
|
||||
printId: modelId,
|
||||
source: "model_detail",
|
||||
files: [{ fileType: "stl", ids: fileIds }],
|
||||
})) as { data?: { getDownloadLink?: Record<string, unknown> } } | null;
|
||||
|
||||
const result = data?.data?.getDownloadLink;
|
||||
if (!isRecord(result)) return links;
|
||||
if (result.ok === false) {
|
||||
const message = parsePrintablesErrorMessages(result.errors);
|
||||
throw new HttpError(400, message || "Printables rejected the download request");
|
||||
}
|
||||
const output = result.output;
|
||||
const files = isRecord(output) ? output.files : null;
|
||||
if (Array.isArray(files)) {
|
||||
for (const entry of files) {
|
||||
if (isRecord(entry) && entry.id != null && typeof entry.link === "string" && entry.link.trim()) {
|
||||
links.set(String(entry.id), entry.link.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
return links;
|
||||
}
|
||||
|
||||
// -- Collections ---------------------------------------------------------------------------
|
||||
|
||||
/** A user-curated, named "Collection" -- Printables' bookmark mechanism, one per URL like
|
||||
* `printables.com/@handle/collections/{id}`. The `@handle` segment is cosmetic (only the
|
||||
* numeric id is used against the API), same as Thingiverse's own Collection URLs. */
|
||||
export function parsePrintablesCollectionUrl(url: string): { collectionId: string } | null {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
if (host !== "printables.com" && host !== "www.printables.com") return null;
|
||||
const m = parsed.pathname.match(/\/collections\/(\d+)/i);
|
||||
return m ? { collectionId: m[1] } : null;
|
||||
}
|
||||
|
||||
export type PrintablesCollectionEntry = { modelId: string; title: string; cover: string | null };
|
||||
|
||||
const COLLECTION_QUERY = `
|
||||
query ($id: ID!) {
|
||||
collection(id: $id) {
|
||||
id
|
||||
name
|
||||
printsCount
|
||||
thumbnails11 { id slug image { filePath } }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/** Printables has no human-readable name on a thumbnail-only listing (ThumbnailPrintType has no
|
||||
* `name` field, only `slug`) -- turns "prusa-core-one-nozzle-wiper-remix" into "Prusa core one
|
||||
* nozzle wiper remix" so the collection picker shows something readable instead of a raw slug. */
|
||||
function titleFromSlug(slug: string): string {
|
||||
const words = slug.split("-").filter(Boolean);
|
||||
if (!words.length) return slug;
|
||||
return words[0].charAt(0).toUpperCase() + words[0].slice(1) + (words.length > 1 ? " " + words.slice(1).join(" ") : "");
|
||||
}
|
||||
|
||||
function entryFromThumbnail(item: unknown): PrintablesCollectionEntry | null {
|
||||
if (!isRecord(item) || item.id == null || typeof item.slug !== "string" || !item.slug.trim()) return null;
|
||||
const cover = isRecord(item.image) ? mediaUrl(item.image.filePath) : null;
|
||||
return { modelId: String(item.id), title: titleFromSlug(item.slug.trim()), cover };
|
||||
}
|
||||
|
||||
const COLLECTION_SCRAPE_MAX_ENTRIES = 300;
|
||||
|
||||
/** Best-effort full listing for a collection bigger than the public API's hard 11-item preview
|
||||
* cap (see fetchPrintablesCollectionEntries) -- renders the actual collection page through
|
||||
* FlareSolverr (the same Cloudflare-bypass path already used elsewhere in this codebase) and
|
||||
* walks its embedded __NEXT_DATA__ payload for print-shaped objects ({id, slug, image} --
|
||||
* matching the exact PrintType/ThumbnailPrintType shape confirmed against the live GraphQL
|
||||
* schema), rather than relying on any specific, unconfirmed path into that JSON -- Next.js's
|
||||
* SSR payload shape isn't part of any public contract and could shift at any time.
|
||||
* Returns null (never throws) on anything short of a clean, useful result: FlareSolverr not
|
||||
* configured, the fetch failing, no __NEXT_DATA__ found, or nothing print-shaped inside it --
|
||||
* every case the caller falls back to the always-available 11-item preview for instead. */
|
||||
async function scrapeFullPrintablesCollection(pageUrl: string): Promise<PrintablesCollectionEntry[] | null> {
|
||||
if (!isFlaresolverrEnabled()) return null;
|
||||
const solved = await fetchViaFlaresolverr(pageUrl);
|
||||
if (!solved || !solved.body) return null;
|
||||
const nextData = extractNextDataJson(solved.body);
|
||||
if (!nextData) return null;
|
||||
|
||||
const found = new Map<string, PrintablesCollectionEntry>();
|
||||
const stack: unknown[] = [nextData];
|
||||
let visited = 0;
|
||||
while (stack.length && visited < 50000 && found.size < COLLECTION_SCRAPE_MAX_ENTRIES) {
|
||||
const node = stack.pop();
|
||||
visited++;
|
||||
if (Array.isArray(node)) {
|
||||
stack.push(...node);
|
||||
continue;
|
||||
}
|
||||
if (!isRecord(node)) continue;
|
||||
const entry = entryFromThumbnail(node);
|
||||
if (entry && !found.has(entry.modelId)) found.set(entry.modelId, entry);
|
||||
stack.push(...Object.values(node));
|
||||
}
|
||||
return found.size ? Array.from(found.values()) : null;
|
||||
}
|
||||
|
||||
const COLLECTION_TITLE_QUERY = `query ($id: ID!) { collection(id: $id) { id name } }`;
|
||||
|
||||
/** Cheap, title-only fetch -- used by the job runner once the batch import is done (see
|
||||
* importJobRunner.ts's runPrintablesCollectionImportJob), so the PrintStash Collection it files
|
||||
* results into doesn't require re-running the (potentially FlareSolverr-backed) full listing. */
|
||||
export async function fetchPrintablesCollectionTitle(collectionId: string): Promise<string | null> {
|
||||
const data = (await fetchPrintablesGraphql(COLLECTION_TITLE_QUERY, { id: collectionId })) as
|
||||
| { data?: { collection?: Record<string, unknown> } }
|
||||
| null;
|
||||
const name = data?.data?.collection?.name;
|
||||
return typeof name === "string" && name.trim() ? name.trim() : null;
|
||||
}
|
||||
|
||||
/** Resolves a Printables Collection's title and model list. The public GraphQL API only ever
|
||||
* exposes up to 11 models per collection (`thumbnails11` -- confirmed live: it takes no
|
||||
* limit/offset/cursor argument, and there is no separate paginated "list every model in this
|
||||
* collection" operation anywhere in Printables' own site traffic either) -- for anything bigger,
|
||||
* this falls back to best-effort page-scraping via FlareSolverr (see
|
||||
* scrapeFullPrintablesCollection). `truncated` reflects whichever source ultimately won: false
|
||||
* only when every model in the collection was actually returned. */
|
||||
export async function fetchPrintablesCollectionEntries(
|
||||
collectionId: string,
|
||||
pageUrl: string,
|
||||
): Promise<{ title: string | null; entries: PrintablesCollectionEntry[]; truncated: boolean }> {
|
||||
const data = (await fetchPrintablesGraphql(COLLECTION_QUERY, { id: collectionId })) as
|
||||
| { data?: { collection?: Record<string, unknown> } }
|
||||
| null;
|
||||
const collection = data?.data?.collection;
|
||||
if (!isRecord(collection)) return { title: null, entries: [], truncated: false };
|
||||
|
||||
const title = typeof collection.name === "string" && collection.name.trim() ? collection.name.trim() : null;
|
||||
const printsCount = typeof collection.printsCount === "number" ? collection.printsCount : null;
|
||||
const preview = Array.isArray(collection.thumbnails11)
|
||||
? collection.thumbnails11.map(entryFromThumbnail).filter((e): e is PrintablesCollectionEntry => e !== null)
|
||||
: [];
|
||||
|
||||
if (printsCount === null || preview.length >= printsCount) {
|
||||
return { title, entries: preview, truncated: false };
|
||||
}
|
||||
|
||||
const scraped = await scrapeFullPrintablesCollection(pageUrl);
|
||||
if (scraped && scraped.length > preview.length) {
|
||||
return { title, entries: scraped, truncated: scraped.length < printsCount };
|
||||
}
|
||||
return { title, entries: preview, truncated: true };
|
||||
}
|
||||
@@ -51,6 +51,17 @@ describe("identifySourceModel", () => {
|
||||
it("returns null for a MakerWorld URL that isn't a model page", () => {
|
||||
expect(identifySourceModel("https://makerworld.com/en/collections/12345-name")).toBeNull();
|
||||
});
|
||||
|
||||
it("extracts a provider + model id from a Printables model URL", () => {
|
||||
expect(identifySourceModel("https://www.printables.com/model/1786545-strong-garden-hose-holder")).toEqual({
|
||||
provider: "printables",
|
||||
externalId: "1786545",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for a Printables URL that isn't a model page", () => {
|
||||
expect(identifySourceModel("https://www.printables.com/@s0ren")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("import dedup", () => {
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import request from "supertest";
|
||||
import { createApp } from "../src/app";
|
||||
import { fetchPrintablesCollectionEntries, parsePrintablesCollectionUrl } from "../src/services/printablesApi";
|
||||
import { prisma } from "../src/db";
|
||||
|
||||
// Exercises the Printables Collection listing path against the real shape confirmed live
|
||||
// against api.printables.com's public GraphQL endpoint (see printablesApi.ts): `collection(id)`
|
||||
// exposes name/printsCount/thumbnails11, but thumbnails11 is a hard-capped preview (confirmed:
|
||||
// it takes no limit/offset/cursor argument) -- collections at or under 11 models resolve fully
|
||||
// from that alone; bigger ones would need the FlareSolverr scrape fallback, which is not
|
||||
// exercised here since FLARESOLVERR_URL is unset in the test environment (isFlaresolverrEnabled
|
||||
//() false), matching how a real instance without FlareSolverr configured behaves: falls back to
|
||||
// the capped preview with `truncated: true` rather than failing.
|
||||
|
||||
const COLLECTION_ID = "2348006";
|
||||
const COLLECTION_TITLE = "Printing for printing's sake";
|
||||
|
||||
function jsonResponse(status: number, body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
|
||||
function thumbnail(id: number) {
|
||||
return { id, slug: `collected-model-${id}`, image: { filePath: `media/prints/${id}/title.jpg` } };
|
||||
}
|
||||
|
||||
function collectionGraphqlResponse(printsCount: number, thumbnailCount: number) {
|
||||
return jsonResponse(200, {
|
||||
data: {
|
||||
collection: {
|
||||
id: COLLECTION_ID,
|
||||
name: COLLECTION_TITLE,
|
||||
printsCount,
|
||||
thumbnails11: Array.from({ length: thumbnailCount }, (_, i) => thumbnail(i + 1)),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("parsePrintablesCollectionUrl", () => {
|
||||
it("recognizes a Collection page, with or without the @handle prefix", () => {
|
||||
expect(parsePrintablesCollectionUrl(`https://www.printables.com/@joshuaargh/collections/${COLLECTION_ID}`)).toEqual({
|
||||
collectionId: COLLECTION_ID,
|
||||
});
|
||||
expect(parsePrintablesCollectionUrl(`https://www.printables.com/collections/${COLLECTION_ID}`)).toEqual({
|
||||
collectionId: COLLECTION_ID,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a single model URL, a profile page, and unrelated hosts", () => {
|
||||
expect(parsePrintablesCollectionUrl("https://www.printables.com/model/1786545-strong-garden-hose-holder")).toBeNull();
|
||||
expect(parsePrintablesCollectionUrl("https://www.printables.com/@joshuaargh")).toBeNull();
|
||||
expect(parsePrintablesCollectionUrl(`https://example.com/collections/${COLLECTION_ID}`)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchPrintablesCollectionEntries", () => {
|
||||
const originalFetch = global.fetch;
|
||||
const pageUrl = `https://www.printables.com/@joshuaargh/collections/${COLLECTION_ID}`;
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("resolves fully from the API alone when the collection is at or under the 11-item preview cap", async () => {
|
||||
global.fetch = vi.fn<(input: RequestInfo | URL) => Promise<Response>>(async () => collectionGraphqlResponse(5, 5)) as unknown as typeof fetch;
|
||||
|
||||
const listing = await fetchPrintablesCollectionEntries(COLLECTION_ID, pageUrl);
|
||||
|
||||
expect(listing.title).toBe(COLLECTION_TITLE);
|
||||
expect(listing.truncated).toBe(false);
|
||||
expect(listing.entries).toEqual([
|
||||
{ modelId: "1", title: "Collected model 1", cover: "https://media.printables.com/media/prints/1/title.jpg" },
|
||||
{ modelId: "2", title: "Collected model 2", cover: "https://media.printables.com/media/prints/2/title.jpg" },
|
||||
{ modelId: "3", title: "Collected model 3", cover: "https://media.printables.com/media/prints/3/title.jpg" },
|
||||
{ modelId: "4", title: "Collected model 4", cover: "https://media.printables.com/media/prints/4/title.jpg" },
|
||||
{ modelId: "5", title: "Collected model 5", cover: "https://media.printables.com/media/prints/5/title.jpg" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to the 11-item preview, flagged truncated, when the collection is bigger and no scrape path is available", async () => {
|
||||
global.fetch = vi.fn<(input: RequestInfo | URL) => Promise<Response>>(async () => collectionGraphqlResponse(34, 11)) as unknown as typeof fetch;
|
||||
|
||||
const listing = await fetchPrintablesCollectionEntries(COLLECTION_ID, pageUrl);
|
||||
|
||||
expect(listing.title).toBe(COLLECTION_TITLE);
|
||||
expect(listing.truncated).toBe(true);
|
||||
expect(listing.entries).toHaveLength(11);
|
||||
});
|
||||
|
||||
it("returns an empty listing for a collection that doesn't exist", async () => {
|
||||
global.fetch = vi.fn<(input: RequestInfo | URL) => Promise<Response>>(async () => jsonResponse(200, { data: { collection: null } })) as unknown as typeof fetch;
|
||||
|
||||
const listing = await fetchPrintablesCollectionEntries("999999999999", pageUrl);
|
||||
|
||||
expect(listing.title).toBeNull();
|
||||
expect(listing.entries).toEqual([]);
|
||||
expect(listing.truncated).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /import/printables-collection/entries", () => {
|
||||
const app = createApp();
|
||||
let token: string;
|
||||
let userId: string;
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
beforeAll(async () => {
|
||||
const email = `printables-collection-route-test-${Date.now()}@example.com`;
|
||||
const res = await request(app)
|
||||
.post("/api/register")
|
||||
.send({ displayName: "Printables Collection Route Test", email, password: "password123" });
|
||||
if (res.status !== 200) {
|
||||
throw new Error(`Failed to register during test setup: ${res.status} ${JSON.stringify(res.body)}`);
|
||||
}
|
||||
token = res.body.token;
|
||||
userId = res.body.user.id;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("lists a collection's models as importable entries, with the real collection name as title", async () => {
|
||||
global.fetch = vi.fn<(input: RequestInfo | URL) => Promise<Response>>(async () => collectionGraphqlResponse(1, 1)) as unknown as typeof fetch;
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/import/printables-collection/entries")
|
||||
.set("Authorization", `Bearer ${token}`)
|
||||
.send({ url: `https://www.printables.com/@joshuaargh/collections/${COLLECTION_ID}`, tags: [] });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.title).toBe(COLLECTION_TITLE);
|
||||
expect(res.body.entries).toEqual([
|
||||
{ design_id: "1", title: "Collected model 1", cover: "https://media.printables.com/media/prints/1/title.jpg", already_imported: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("flags an entry already in the user's library instead of letting it be re-selected", async () => {
|
||||
await prisma.print.create({
|
||||
data: {
|
||||
userId,
|
||||
name: "Already imported",
|
||||
nameNormalized: "already imported",
|
||||
sourceProvider: "printables",
|
||||
sourceExternalId: "1",
|
||||
},
|
||||
});
|
||||
global.fetch = vi.fn<(input: RequestInfo | URL) => Promise<Response>>(async () => collectionGraphqlResponse(1, 1)) as unknown as typeof fetch;
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/import/printables-collection/entries")
|
||||
.set("Authorization", `Bearer ${token}`)
|
||||
.send({ url: `https://www.printables.com/@joshuaargh/collections/${COLLECTION_ID}`, tags: [] });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.entries).toEqual([
|
||||
{ design_id: "1", title: "Collected model 1", cover: "https://media.printables.com/media/prints/1/title.jpg", already_imported: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects a URL that isn't a Printables Collection page", async () => {
|
||||
const res = await request(app)
|
||||
.post("/api/import/printables-collection/entries")
|
||||
.set("Authorization", `Bearer ${token}`)
|
||||
.send({ url: "https://www.printables.com/@joshuaargh", tags: [] });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import request from "supertest";
|
||||
import { createApp } from "../src/app";
|
||||
import { importPrintFromUrl } from "../src/services/importService";
|
||||
import { prisma } from "../src/db";
|
||||
|
||||
// Exercises importPrintFromUrl's Printables path end to end against the real shapes confirmed
|
||||
// live against api.printables.com's public GraphQL endpoint (see printablesApi.ts): one query
|
||||
// for the model's metadata + file list, one mutation to resolve each file's real download link.
|
||||
// Every actual HTTP fetch is intercepted; no DNS/network needed. No auth/cookie involved --
|
||||
// Printables' GraphQL API is public for public models, unlike MakerWorld/Thingiverse.
|
||||
|
||||
const MODEL_ID = "1786545";
|
||||
const MODEL_URL = `https://www.printables.com/model/${MODEL_ID}-strong-garden-hose-holder`;
|
||||
const CATEGORY_ID = 42;
|
||||
const FILE_ID = "7449061";
|
||||
|
||||
const ONE_PIXEL_PNG_BASE64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=";
|
||||
|
||||
const MODEL_RESPONSE = {
|
||||
data: {
|
||||
print: {
|
||||
id: MODEL_ID,
|
||||
name: "Strong garden hose holder",
|
||||
description: "<h4>Mounting</h4><p>4 x M5 or M6 screws</p>",
|
||||
user: { id: "4584310", handle: "s0ren", publicUsername: "s0ren", avatarFilePath: "media/auth/avatars/a.png" },
|
||||
image: { filePath: "media/prints/cover.jpg" },
|
||||
images: [{ filePath: "media/prints/cover.jpg" }, { filePath: "media/prints/gallery-1.jpg" }],
|
||||
tags: [{ name: "garden" }, { name: "hose" }],
|
||||
category: { id: CATEGORY_ID, name: "Outdoor & Garden" },
|
||||
stls: [{ id: FILE_ID, name: "garden-hose-holder.3mf" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const DOWNLOAD_LINK_RESPONSE = {
|
||||
data: {
|
||||
getDownloadLink: {
|
||||
ok: true,
|
||||
errors: null,
|
||||
output: { files: [{ id: FILE_ID, link: "https://files.printables.com/media/prints/garden-hose-holder.3mf" }] },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function jsonResponse(status: number, body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
|
||||
function pngResponse(): Response {
|
||||
return new Response(Buffer.from(ONE_PIXEL_PNG_BASE64, "base64"), { status: 200, headers: { "content-type": "image/png" } });
|
||||
}
|
||||
|
||||
function threeMfResponse(): Response {
|
||||
return new Response("fake-3mf-bytes", { status: 200, headers: { "content-type": "application/octet-stream" } });
|
||||
}
|
||||
|
||||
function mockPrintablesFetch(overrides?: { modelStatus?: number; modelBody?: unknown }) {
|
||||
return vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(async (input, init) => {
|
||||
const url = String(input);
|
||||
if (url === "https://api.printables.com/graphql/") {
|
||||
const body = JSON.parse(String(init?.body ?? "{}"));
|
||||
if (typeof body.query === "string" && body.query.includes("getDownloadLink")) {
|
||||
return jsonResponse(200, DOWNLOAD_LINK_RESPONSE);
|
||||
}
|
||||
return jsonResponse(overrides?.modelStatus ?? 200, overrides?.modelBody ?? MODEL_RESPONSE);
|
||||
}
|
||||
if (url === "https://files.printables.com/media/prints/garden-hose-holder.3mf") return threeMfResponse();
|
||||
if (url === "https://media.printables.com/media/prints/cover.jpg") return pngResponse();
|
||||
if (url === "https://media.printables.com/media/prints/gallery-1.jpg") return pngResponse();
|
||||
throw new Error(`Unexpected fetch to ${url}`);
|
||||
}) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
describe("importPrintFromUrl -- Printables", () => {
|
||||
const app = createApp();
|
||||
let userId: string;
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
beforeAll(async () => {
|
||||
const email = `printables-import-test-${Date.now()}@example.com`;
|
||||
const res = await request(app)
|
||||
.post("/api/register")
|
||||
.send({ displayName: "Printables Import Test", email, password: "password123" });
|
||||
if (res.status !== 200) {
|
||||
throw new Error(`Failed to register during test setup: ${res.status} ${JSON.stringify(res.body)}`);
|
||||
}
|
||||
userId = res.body.user.id;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns a clear error when the model doesn't exist / isn't public", async () => {
|
||||
global.fetch = mockPrintablesFetch({ modelBody: { data: { print: null } } });
|
||||
await expect(
|
||||
importPrintFromUrl(userId, MODEL_URL, { url: MODEL_URL, tags: [] }),
|
||||
).rejects.toThrow(/could not be found/i);
|
||||
});
|
||||
|
||||
it("imports a model's files as plates, matches category, and attaches metadata + images", async () => {
|
||||
const folder = await prisma.folder.create({
|
||||
data: { userId, name: "Printables Tests", tags: [], printablesCatIds: [1, CATEGORY_ID, 2] },
|
||||
});
|
||||
|
||||
global.fetch = mockPrintablesFetch();
|
||||
|
||||
const result = await importPrintFromUrl(userId, MODEL_URL, { url: MODEL_URL, tags: [] });
|
||||
|
||||
expect(result.alreadyImported).toBe(false);
|
||||
expect(result.print.title).toBe("Strong garden hose holder");
|
||||
expect(result.print.creator).toBe("s0ren");
|
||||
expect(result.print.sourceProvider).toBe("printables");
|
||||
expect(result.print.sourceExternalId).toBe(MODEL_ID);
|
||||
expect(result.print.notes).toContain("Mounting");
|
||||
expect(result.print.tags.toSorted()).toEqual(["garden", "hose"]);
|
||||
expect(result.print.folderId).toBe(folder.id);
|
||||
|
||||
expect(result.plates.map((p) => p.filename)).toEqual(["garden-hose-holder.3mf"]);
|
||||
|
||||
expect(result.author).toBeTruthy();
|
||||
expect(result.author?.name).toBe("s0ren");
|
||||
expect(result.author?.provider).toBe("printables");
|
||||
expect(result.author?.externalId).toBe("4584310");
|
||||
|
||||
// Cover plus the one other gallery image -- both real, decodable PNGs.
|
||||
expect(result.previewImages.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("dedupes a re-import of the same model instead of hitting the API again", async () => {
|
||||
const fetchMock = vi.fn<(input: RequestInfo | URL) => Promise<Response>>(async () => {
|
||||
throw new Error("No network call should happen for an already-imported model");
|
||||
});
|
||||
global.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const result = await importPrintFromUrl(userId, MODEL_URL, { url: MODEL_URL, tags: [] });
|
||||
|
||||
expect(result.alreadyImported).toBe(true);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -237,6 +237,42 @@ export const importsApi = {
|
||||
return res.json();
|
||||
},
|
||||
|
||||
listPrintablesCollectionEntries: async (payload: ImportLinkPayload): Promise<ImportCollectionEntriesResult> => {
|
||||
const res = await fetch(`${apiBase()}/import/printables-collection/entries`, {
|
||||
method: "POST",
|
||||
headers: authHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (res.status === 401) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
if (!res.ok) {
|
||||
const message = await readErrorMessage(res, "Could not load this collection");
|
||||
throw new Error(message);
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
|
||||
/** Registers a background job for the selected models and returns immediately -- see
|
||||
* ImportJobContext.startPrintablesCollectionImport, which follows up with the actual polling.
|
||||
* Every successful import lands in a PrintStash Collection named after the real Printables
|
||||
* Collection name. */
|
||||
fromPrintablesCollection: async (payload: ImportLinkPayload & { model_ids: string[] }): Promise<{ job_id: string }> => {
|
||||
const res = await fetch(`${apiBase()}/import/printables-collection`, {
|
||||
method: "POST",
|
||||
headers: authHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (res.status === 401) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
if (!res.ok) {
|
||||
const message = await readErrorMessage(res, "Printables Collection import failed");
|
||||
throw new Error(message);
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
|
||||
getActiveImportJob: async (): Promise<ImportJob | null> => {
|
||||
const res = await fetch(`${apiBase()}/import/jobs/active`, { headers: authHeaders() });
|
||||
if (res.status === 401) throw new UnauthorizedError();
|
||||
|
||||
@@ -7,6 +7,7 @@ type StartCollectionImportPayload = Parameters<typeof importsApi.fromCollection>
|
||||
type StartZipImportPayload = Parameters<typeof importsApi.zipFromLink>[0];
|
||||
type StartThingiverseLikesImportPayload = Parameters<typeof importsApi.fromThingiverseLikes>[0];
|
||||
type StartThingiverseCollectionImportPayload = Parameters<typeof importsApi.fromThingiverseCollection>[0];
|
||||
type StartPrintablesCollectionImportPayload = Parameters<typeof importsApi.fromPrintablesCollection>[0];
|
||||
|
||||
type ImportJobContextValue = {
|
||||
/** Non-null exactly while a batch import (MakerWorld collection, a Thingiverse Collection or
|
||||
@@ -18,6 +19,7 @@ type ImportJobContextValue = {
|
||||
startZipImport: (payload: StartZipImportPayload) => Promise<void>;
|
||||
startThingiverseLikesImport: (payload: StartThingiverseLikesImportPayload) => Promise<void>;
|
||||
startThingiverseCollectionImport: (payload: StartThingiverseCollectionImportPayload) => Promise<void>;
|
||||
startPrintablesCollectionImport: (payload: StartPrintablesCollectionImportPayload) => Promise<void>;
|
||||
};
|
||||
|
||||
const ImportJobContext = createContext<ImportJobContextValue | null>(null);
|
||||
@@ -114,6 +116,13 @@ export function ImportJobProvider({
|
||||
startPolling(job_id);
|
||||
}, [startPolling]);
|
||||
|
||||
const startPrintablesCollectionImport = useCallback(async (payload: StartPrintablesCollectionImportPayload) => {
|
||||
const { job_id } = await importsApi.fromPrintablesCollection(payload);
|
||||
const job = await importsApi.getImportJob(job_id);
|
||||
setActiveJob(job);
|
||||
startPolling(job_id);
|
||||
}, [startPolling]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
activeJob,
|
||||
@@ -122,8 +131,16 @@ export function ImportJobProvider({
|
||||
startZipImport,
|
||||
startThingiverseLikesImport,
|
||||
startThingiverseCollectionImport,
|
||||
startPrintablesCollectionImport,
|
||||
}),
|
||||
[activeJob, startCollectionImport, startZipImport, startThingiverseLikesImport, startThingiverseCollectionImport],
|
||||
[
|
||||
activeJob,
|
||||
startCollectionImport,
|
||||
startZipImport,
|
||||
startThingiverseLikesImport,
|
||||
startThingiverseCollectionImport,
|
||||
startPrintablesCollectionImport,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -68,6 +68,36 @@ function isThingiverseCollectionUrl(url: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/** A user-curated, named Printables Collection (`printables.com/@handle/collections/{id}`) --
|
||||
* the site's bookmark mechanism, one page listing many models rather than a single model page.
|
||||
* Routed to the same collection picker MakerWorld/Thingiverse collections use. */
|
||||
function isPrintablesCollectionUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url.includes("://") ? url : `https://${url}`);
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
if (host !== "printables.com" && host !== "www.printables.com") return false;
|
||||
return /\/collections\/\d+/i.test(parsed.pathname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** A Printables model import goes through its own backend path entirely (see
|
||||
* importService.ts's importPrintablesModel) rather than the generic inspect/zip-picker flow --
|
||||
* skip straight to a plain import call, same reasoning as isThingiverseThingUrl above (and
|
||||
* necessary here too: www.printables.com is Cloudflare-gated, so the generic inspect flow
|
||||
* couldn't resolve one of these URLs anyway). */
|
||||
function isPrintablesModelUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url.includes("://") ? url : `https://${url}`);
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
if (host !== "printables.com" && host !== "www.printables.com") return false;
|
||||
return /\/model\/\d+/i.test(parsed.pathname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Every dropped/picked entry's relativePath equals its bare filename when the
|
||||
// selection has no folder structure. A webkitdirectory folder pick always
|
||||
// prefixes relativePath with the folder name, so this only ever fires for a
|
||||
@@ -95,7 +125,13 @@ export function useUploadImport({ onUploaded, folderId, makerworldCookie, onUnau
|
||||
const zipPrompt = useZipImportPrompt();
|
||||
const collectionPrompt = useCollectionImportPrompt();
|
||||
const importModePrompt = useImportModePrompt();
|
||||
const { startCollectionImport, startZipImport, startThingiverseLikesImport, startThingiverseCollectionImport } = useImportJob();
|
||||
const {
|
||||
startCollectionImport,
|
||||
startZipImport,
|
||||
startThingiverseLikesImport,
|
||||
startThingiverseCollectionImport,
|
||||
startPrintablesCollectionImport,
|
||||
} = useImportJob();
|
||||
const isBusy = uploading || importing || zipPrompt.isOpen || collectionPrompt.isOpen || importModePrompt.isOpen;
|
||||
|
||||
const uploadFlatAsMultiplate = async (files: File[]) => {
|
||||
@@ -297,9 +333,36 @@ export function useUploadImport({ onUploaded, folderId, makerworldCookie, onUnau
|
||||
return;
|
||||
}
|
||||
|
||||
if (isThingiverseThingUrl(url)) {
|
||||
// A Thing always resolves to a zip; skip straight past the inspect/zip-picker steps --
|
||||
// the backend already splits it into plates automatically.
|
||||
if (isPrintablesCollectionUrl(url)) {
|
||||
setImporting(false);
|
||||
await collectionPrompt.prompt({
|
||||
label: url,
|
||||
loadEntries: async () => {
|
||||
try {
|
||||
return await importsApi.listPrintablesCollectionEntries(payload);
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedError) onUnauthorized?.();
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
onImportSelected: async (modelIds: string[]) => {
|
||||
try {
|
||||
await startPrintablesCollectionImport({ ...payload, model_ids: modelIds });
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedError) {
|
||||
onUnauthorized?.();
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isThingiverseThingUrl(url) || isPrintablesModelUrl(url)) {
|
||||
// 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 }) });
|
||||
onUploaded();
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
export type ImportProviderInfo = { label: string; color: string };
|
||||
|
||||
export const IMPORT_PROVIDER_INFO: Record<string, ImportProviderInfo> = {
|
||||
makerworld: { label: "MakerWorld", color: "#F07745" },
|
||||
makerworld: { label: "MakerWorld", color: "#00B800" },
|
||||
thingiverse: { label: "Thingiverse", color: "#2B78FE" },
|
||||
printables: { label: "Printables", color: "#FA6831" },
|
||||
};
|
||||
|
||||
export function importProviderInfo(provider: string | null | undefined): ImportProviderInfo | null {
|
||||
|
||||
@@ -44,7 +44,7 @@ function InstallSteps({ steps }: { steps: InstallStep[] }) {
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
{steps.map((step, i) => (
|
||||
<Stack key={i} direction="row" spacing={1.5}>
|
||||
<Stack key={step.text} direction="row" spacing={1.5}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 22,
|
||||
|
||||
Reference in new issue
Block a user