feat: import author details from makerworld

This commit is contained in:
Tautvydas Derzinskas committed 2026-09-07 18:26:24 +02:00
1 parent f04ca0c8b8
commit b16c27bc1e
13 files changed
+359 -20

No files matched your search

@@ -0,0 +1,29 @@
-- AlterTable
ALTER TABLE "Print" ADD COLUMN "authorId" TEXT;
-- CreateTable
CREATE TABLE "Author" (
"id" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"externalId" TEXT NOT NULL,
"name" TEXT,
"handle" TEXT,
"bio" TEXT,
"bioTranslated" TEXT,
"links" TEXT[] DEFAULT ARRAY[]::TEXT[],
"avatarUrl" TEXT,
"backgroundUrl" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Author_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Author_provider_externalId_key" ON "Author"("provider", "externalId");
-- CreateIndex
CREATE INDEX "Print_authorId_idx" ON "Print"("authorId");
-- AddForeignKey
ALTER TABLE "Print" ADD CONSTRAINT "Print_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "Author"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+26
View File
@@ -33,6 +33,8 @@ model Print {
tags String[] @default([])
folderId String?
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
authorId String?
author Author? @relation(fields: [authorId], references: [id], onDelete: SetNull)
sourcePath String?
plates Plate[]
files PrintFile[] @relation("PrintFiles")
@@ -45,6 +47,30 @@ model Print {
@@unique([folderId, nameNormalized])
@@index([folderId, name])
@@index([tags], type: Gin)
@@index([authorId])
}
/// A creator/designer on some source platform (MakerWorld today; Printables/Thingiverse etc.
/// later). `id` is provider-prefixed ("makerworld:3582000380") rather than a generated cuid so
/// re-importing a model from the same author upserts the same row instead of creating a
/// duplicate. Print.creator stays as the plain display string for backward compatibility and
/// non-provider sources; this table is the richer record clickable author pages will use.
model Author {
id String @id
provider String
externalId String
name String?
handle String?
bio String?
bioTranslated String?
links String[] @default([])
avatarUrl String?
backgroundUrl String?
prints Print[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([provider, externalId])
}
model Plate {
+32 -1
View File
@@ -1,8 +1,36 @@
import fs from "node:fs";
import type { Folder, Plate, Print, PrintFile } from "@prisma/client";
import type { Author, Folder, Plate, Print, PrintFile } from "@prisma/client";
import { plateThumbExists, plateThumbPath } from "./services/printService";
import { preparedFilename } from "./services/preparedPrint";
export type AuthorOut = {
id: string;
provider: string;
external_id: string;
name: string | null;
handle: string | null;
bio: string | null;
bio_translated: string | null;
links: string[];
avatar_url: string | null;
background_url: string | null;
};
export function toAuthorOut(author: Author): AuthorOut {
return {
id: author.id,
provider: author.provider,
external_id: author.externalId,
name: author.name,
handle: author.handle,
bio: author.bio,
bio_translated: author.bioTranslated,
links: author.links,
avatar_url: author.avatarUrl,
background_url: author.backgroundUrl,
};
}
export type PreparedPrintOut = {
printer?: string | null;
material?: string | null;
@@ -38,6 +66,7 @@ export type PrintOut = {
title: string | null;
notes: string | null;
creator: string | null;
author: AuthorOut | null;
collection: string | null;
tags: string[];
folder_id: string | null;
@@ -103,6 +132,7 @@ export function toPrintOut(
plates: Plate[],
files: PrintFile[],
preparedFile: PrintFile | null,
author?: Author | null,
): PrintOut {
const sortedPlates = plates.toSorted((a, b) => a.position - b.position);
const plateOuts = sortedPlates.map((p) => toPlateOut(print.id, p));
@@ -149,6 +179,7 @@ export function toPrintOut(
title: print.title,
notes: print.notes,
creator: print.creator,
author: author ? toAuthorOut(author) : null,
collection: print.collection,
tags: print.tags,
folder_id: print.folderId,
+9 -5
View File
@@ -9,6 +9,7 @@ import { parseBody } from "../utils/validate";
import { asyncHandler } from "../utils/asyncHandler";
import { downloadImportToTemp, importPrintFromUrl, inspectImportLink, type ImportRequestBody } from "../services/importService";
import { resolveMakerworldCookie } from "../services/importResolvers";
import { upsertAuthorFromImport } from "../services/authorService";
import { extractMakerworldBearerToken } from "../services/makerworldCloudApi";
import {
fetchMakerworldCollectionEntries,
@@ -37,8 +38,8 @@ router.post(
asyncHandler(async (req, res) => {
const body = parseBody(importRequestSchema, req.body);
const url = await normalizeImportUrl(body.url);
const { print, plates } = await importPrintFromUrl(url, body);
res.json(toPrintOut(print, plates, [], null));
const { print, plates, author } = await importPrintFromUrl(url, body);
res.json(toPrintOut(print, plates, [], null, author));
}),
);
@@ -78,15 +79,18 @@ router.post(
const { tempPath, filename, meta } = await downloadImportToTemp(url, body);
try {
if (path.extname(filename).toLowerCase() !== ".zip") throw new HttpError(415, "Imported file is not a zip");
const author = await upsertAuthorFromImport(meta.author);
const { prints, failed } = await extractZipEntriesToPrints(tempPath, body.entries, {
title: body.title ?? meta.title,
notes: body.notes ?? meta.description,
tags: body.tags && body.tags.length ? body.tags : meta.tags,
folderId: body.folder_id,
creator: meta.creator,
authorId: author?.id ?? null,
previewImageUrl: meta.previewImageUrl,
galleryImages: meta.galleryImages,
});
res.json({ prints: prints.map((p) => toPrintOut(p, p.plates, [], null)), failed });
res.json({ prints: prints.map((p) => toPrintOut(p, p.plates, [], null, author)), failed });
} finally {
await fs.rm(tempPath, { force: true }).catch(() => undefined);
}
@@ -154,8 +158,8 @@ router.post(
thingiverse_cookie: body.thingiverse_cookie,
};
try {
const { print, plates } = await importPrintFromUrl(modelUrl, itemBody);
return { ok: true as const, print: toPrintOut(print, plates, [], null) };
const { print, plates, author } = await importPrintFromUrl(modelUrl, itemBody);
return { ok: true as const, print: toPrintOut(print, plates, [], null, author) };
} catch {
return { ok: false as const, designId };
}
+5 -2
View File
@@ -115,7 +115,10 @@ router.get(
}
const where = buildPrintWhere(req);
const prints = await prisma.print.findMany({ where, include: { plates: { orderBy: { position: "asc" } } } });
const prints = await prisma.print.findMany({
where,
include: { plates: { orderBy: { position: "asc" } }, author: true },
});
const printIds = prints.map((p) => p.id);
const files = printIds.length ? await prisma.printFile.findMany({ where: { printId: { in: printIds } } }) : [];
const filesByPrint = new Map<string, typeof files>();
@@ -147,7 +150,7 @@ router.get(
const out = paged.map((p) => {
const printFiles = filesByPrint.get(p.id) ?? [];
const preparedFile = p.preparedFileId ? printFiles.find((f) => f.id === p.preparedFileId) ?? null : null;
return toPrintOut(p, p.plates, printFiles, preparedFile);
return toPrintOut(p, p.plates, printFiles, preparedFile, p.author);
});
res.json(out);
}),
+45
View File
@@ -0,0 +1,45 @@
import { prisma } from "../db";
import type { ImportedAuthorInfo } from "./importResolvers";
import type { Author } from "@prisma/client";
export function buildAuthorId(provider: string, externalId: string): string {
return `${provider}:${externalId}`;
}
/** Upserts an Author row from resolved import metadata and returns the full record (not just
* its id) so callers can build an immediate API response without a second fetch. Always
* refreshes the stored fields (bio/avatar/etc. can change between imports), so re-importing a
* model from an already-known author keeps that author's record current rather than stale from
* first import. Never throws -- a broken author fetch shouldn't fail the print import itself. */
export async function upsertAuthorFromImport(info: ImportedAuthorInfo | null): Promise<Author | null> {
if (!info) return null;
const id = buildAuthorId(info.provider, info.externalId);
try {
return await prisma.author.upsert({
where: { id },
create: {
id,
provider: info.provider,
externalId: info.externalId,
name: info.name,
handle: info.handle,
bio: info.bio,
bioTranslated: info.bioTranslated,
links: info.links,
avatarUrl: info.avatarUrl,
backgroundUrl: info.backgroundUrl,
},
update: {
name: info.name,
handle: info.handle,
bio: info.bio,
bioTranslated: info.bioTranslated,
links: info.links,
avatarUrl: info.avatarUrl,
backgroundUrl: info.backgroundUrl,
},
});
} catch {
return null;
}
}
+29 -1
View File
@@ -526,10 +526,38 @@ export type ImportedPageMetadata = {
* (e.g. from the MakerWorld cloud API's own response) rather than needing to guess one from
* the download URL's path or a Content-Disposition header. */
filename: string | null;
/** The page's photo gallery, if any -- distinct from previewImageUrl (the single cover
* image used as the plate thumbnail). Everything here is meant to be attached as supporting
* files instead. */
galleryImages: { url: string; filename: string }[];
/** The richer, structured creator record (for the Author table) -- distinct from `creator`,
* which stays a plain display string for backward compatibility and non-provider sources. */
author: ImportedAuthorInfo | null;
};
export type ImportedAuthorInfo = {
provider: string;
externalId: string;
name: string | null;
handle: string | null;
bio: string | null;
bioTranslated: string | null;
links: string[];
avatarUrl: string | null;
backgroundUrl: string | null;
};
export function emptyImportedPageMetadata(): ImportedPageMetadata {
return { title: null, tags: [], description: null, creator: null, previewImageUrl: null, filename: null };
return {
title: null,
tags: [],
description: null,
creator: null,
previewImageUrl: null,
filename: null,
galleryImages: [],
author: null,
};
}
/** Best-effort metadata for a landing page, used to fill in the Print when the caller didn't
+53 -3
View File
@@ -36,9 +36,11 @@ import {
parseMakerworldModelUrl,
resolveMakerworldViaCloudApi,
} from "./makerworldCloudApi";
import { upsertAuthorFromImport } from "./authorService";
import { createPrint, type PrintMetaInput } from "./printCreation";
import { plateThumbExists, saveThumbFromBytes } from "./printService";
import type { Plate, Print } from "@prisma/client";
import { saveFileFromTemp } from "./printFileService";
import type { Author, Plate, Print } from "@prisma/client";
export type ImportRequestBody = ImportCookies & {
url: string;
@@ -229,6 +231,8 @@ export async function openImportResponse(
creator: extracted.creator ?? inheritedMeta.creator,
previewImageUrl: extracted.previewImageUrl ?? inheritedMeta.previewImageUrl,
filename: extracted.filename ?? inheritedMeta.filename,
galleryImages: extracted.galleryImages.length ? extracted.galleryImages : inheritedMeta.galleryImages,
author: extracted.author ?? inheritedMeta.author,
};
if (pageHost.endsWith("makerworld.com")) {
if (!makerworldCookie) makerworldCookie = resolveMakerworldCookie(body);
@@ -353,23 +357,69 @@ export async function applyCoverThumbnailIfMissing(
}
}
const GALLERY_IMAGE_MAX_BYTES = 16 * 1024 * 1024;
const GALLERY_IMAGE_MAX_COUNT = 20;
/** Best-effort: downloads a resolved page's remaining gallery photos -- everything besides
* whichever one was already used as the plate thumbnail (skipUrl) -- and attaches each as a
* supporting file. Every image is independent: one failing doesn't stop the rest or the
* import as a whole. */
export async function attachGalleryImagesAsSupportingFiles(
printId: string | undefined,
images: { url: string; filename: string }[],
skipUrl: string | null | undefined,
): Promise<void> {
if (!printId || !images.length) return;
const toSave = images.filter((image) => image.url !== skipUrl).slice(0, GALLERY_IMAGE_MAX_COUNT);
for (const image of toSave) {
let tempPath: string | null = null;
try {
const res = await rawFetch(image.url, { "User-Agent": IMPORT_USER_AGENT, Accept: "image/*" });
if (!res.ok) {
await res.body?.cancel().catch(() => undefined);
continue;
}
const contentLength = res.headers.get("content-length");
if (contentLength && Number(contentLength) > GALLERY_IMAGE_MAX_BYTES) {
await res.body?.cancel().catch(() => undefined);
continue;
}
const buf = Buffer.from(await res.arrayBuffer());
if (buf.length > GALLERY_IMAGE_MAX_BYTES) continue;
tempPath = path.join(os.tmpdir(), `printstash-gallery-${crypto.randomBytes(8).toString("hex")}`);
await fs.writeFile(tempPath, buf);
await saveFileFromTemp(printId, tempPath, image.filename, res.headers.get("content-type"));
tempPath = null;
} catch {
// best-effort only
} finally {
if (tempPath) await fs.rm(tempPath, { force: true }).catch(() => undefined);
}
}
}
/** Downloads a URL and creates a single-plate Print from it (POST /import). */
export async function importPrintFromUrl(
url: string,
body: ImportRequestBody,
): Promise<{ print: Print; plates: Plate[] }> {
): Promise<{ print: Print; plates: Plate[]; author: Author | null }> {
const { tempPath, filename, mime, meta } = await downloadImportToTemp(url, body);
const author = await upsertAuthorFromImport(meta.author);
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: body.folder_id ?? null,
creator: meta.creator ?? null,
authorId: author?.id ?? null,
};
try {
const result = await createPrint(printMeta, path.parse(filename).name, [{ filename, mime, tempFilePath: tempPath }]);
await applyCoverThumbnailIfMissing(result.plates[0]?.id, meta.previewImageUrl);
return result;
await attachGalleryImagesAsSupportingFiles(result.print.id, meta.galleryImages, meta.previewImageUrl);
return { ...result, author };
} finally {
if (fsSync.existsSync(tempPath)) await fs.rm(tempPath, { force: true }).catch(() => undefined);
}
+100 -3
View File
@@ -1,5 +1,6 @@
import { IMPORT_BROWSER_USER_AGENT, IMPORT_TIMEOUT_SECONDS } from "../config";
import { decodeHtmlEntities, htmlToPlainText, type ImportedPageMetadata } from "./importResolvers";
import { fetchViaFlaresolverr, isFlaresolverrEnabled, looksLikeCloudflareBlock } from "./flaresolverr";
import { decodeHtmlEntities, htmlToPlainText, type ImportedAuthorInfo, type ImportedPageMetadata } from "./importResolvers";
// MakerWorld's own website (makerworld.com) sits behind Cloudflare bot management and a
// separate Geetest CAPTCHA on its download-resolution endpoints. api.bambulab.com is the
@@ -9,7 +10,12 @@ import { decodeHtmlEntities, htmlToPlainText, type ImportedPageMetadata } from "
// projects (kloshi-io/makerworld-api-reverse, maziggy/bambuddy, Pr0zak/YASTL).
const DESIGN_API_BASE = "https://api.bambulab.com/v1/design-service";
const PROFILE_DOWNLOAD_BASE = "https://api.bambulab.com/v1/iot-service/api/user/profile";
// This one lives on makerworld.com rather than api.bambulab.com (unlike the two above) -- but
// like the other makerworld.com /api/v1/* paths already used elsewhere (favorites/collections),
// it's a clean, unauthenticated, non-Cloudflare-gated JSON endpoint. Confirmed live.
const AUTHOR_PROFILE_BASE = "https://makerworld.com/api/v1/design-user-service/user/profile";
const CLOUD_API_TIMEOUT_MS = IMPORT_TIMEOUT_SECONDS * 1000;
const MAKERWORLD_PROVIDER = "makerworld";
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
@@ -141,6 +147,91 @@ function pickString(source: Record<string, unknown>, keys: string[]): string | n
return null;
}
/** Same defensive fallback used for the other makerworld.com /api/v1/* endpoints (collections):
* not seen behind Cloudflare's challenge in practice, but retry once through FlareSolverr
* rather than failing outright if that ever changes. No bearer token needed -- author profiles
* are public. */
async function fetchAuthorProfileJson(uid: string): Promise<unknown | null> {
const url = `${AUTHOR_PROFILE_BASE}/${uid}`;
const headers: Record<string, string> = {
"User-Agent": IMPORT_BROWSER_USER_AGENT,
Accept: "application/json",
Referer: "https://makerworld.com/",
};
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), CLOUD_API_TIMEOUT_MS);
try {
const res = await fetch(url, { headers, redirect: "follow", signal: controller.signal });
if (res.status === 403 && isFlaresolverrEnabled() && looksLikeCloudflareBlock(res.headers)) {
const solved = await fetchViaFlaresolverr(url, null);
if (!solved) return null;
try {
return JSON.parse(solved.body);
} catch {
return null;
}
}
if (!res.ok) return null;
const text = await res.text();
if (!text.trim()) return null;
try {
return JSON.parse(text);
} catch {
return null;
}
} catch {
return null;
} finally {
clearTimeout(timeout);
}
}
/** Fetches the richer author record (bio, links, background image) for a MakerWorld uid, for
* the Author table. Best-effort: a failure here shouldn't fail the import, since `creator`
* (plain string, from the design's own embedded designCreator summary) already covers the
* simple display case. */
async function fetchMakerworldAuthorInfo(uid: string): Promise<ImportedAuthorInfo | null> {
const data = await fetchAuthorProfileJson(uid);
if (!isRecord(data)) return null;
const personal = isRecord(data.personal) ? data.personal : {};
const links = Array.isArray(personal.links)
? personal.links.filter((link): link is string => typeof link === "string" && link.trim().length > 0)
: [];
return {
provider: MAKERWORLD_PROVIDER,
externalId: uid,
name: pickString(data, ["name"]),
handle: pickString(personal, ["handle"]) ?? pickString(data, ["handle"]),
bio: pickString(personal, ["bio"]),
bioTranslated: pickString(personal, ["bioTranslated"]),
links,
avatarUrl: pickString(data, ["avatar"]),
backgroundUrl: pickString(personal, ["backgroundUrl"]),
};
}
export type MakerworldGalleryImage = { url: string; filename: string };
/** The model page's photo gallery -- design.designExtension.design_pictures -- distinct from
* coverUrl/coverPortrait/coverLandscape, which are just crops of the same single cover image
* for different UI contexts. Confirmed live: a model can have several of these (renders and/or
* isRealLifePhoto: 1 real-world photos of the print). */
function extractGalleryImages(design: Record<string, unknown>): MakerworldGalleryImage[] {
const extension = design.designExtension;
if (!isRecord(extension)) return [];
const pictures = extension.design_pictures;
if (!Array.isArray(pictures)) return [];
const images: MakerworldGalleryImage[] = [];
for (const picture of pictures) {
if (!isRecord(picture)) continue;
const url = typeof picture.url === "string" ? picture.url.trim() : "";
if (!url) continue;
const filename = typeof picture.name === "string" && picture.name.trim() ? picture.name.trim() : null;
images.push({ url, filename: filename ?? url.split("/").pop() ?? "preview.jpg" });
}
return images;
}
/**
* Resolves a MakerWorld design to a real, directly-downloadable (signed S3) URL entirely
* through api.bambulab.com -- no Cloudflare, no cookie-gated web session, no HTML scraping.
@@ -204,9 +295,13 @@ export async function resolveMakerworldViaCloudApi(
? design.tags.filter((tag): tag is string => typeof tag === "string" && tag.trim().length > 0).map((tag) => tag.trim())
: [];
const summary = typeof design.summary === "string" ? design.summary : null;
const creator = isRecord(design.designCreator) ? pickString(design.designCreator, ["nickName", "name", "handle"]) : null;
const designCreator = isRecord(design.designCreator) ? design.designCreator : null;
const creator = designCreator ? pickString(designCreator, ["nickName", "name", "handle"]) : null;
const creatorUid = designCreator?.uid != null ? String(designCreator.uid) : null;
const previewImageUrl = pickString(design, ["coverUrl", "coverPortrait", "coverLandscape"]);
const title = pickString(design, ["title"]);
const galleryImages = extractGalleryImages(design);
const author = creatorUid ? await fetchMakerworldAuthorInfo(creatorUid) : null;
return {
downloadUrl: body.url,
@@ -214,9 +309,11 @@ export async function resolveMakerworldViaCloudApi(
title: title ? decodeHtmlEntities(title) : null,
tags,
description: summary ? htmlToPlainText(summary) : null,
creator,
creator: author?.name ?? creator,
previewImageUrl,
filename: pickString(body, ["filename"]),
galleryImages,
author,
},
};
}
+3
View File
@@ -37,6 +37,8 @@ export type PrintMetaInput = {
folderId?: string | null;
creator?: string | null;
collection?: string | null;
/** Id of an already-upserted Author row (see authorService.ts), e.g. "makerworld:12345". */
authorId?: string | null;
};
async function placeFile(input: NewPlateInput, destAbsPath: string): Promise<string | null> {
@@ -159,6 +161,7 @@ export async function createPrint(
collection: meta.collection?.trim() || null,
tags: (meta.tags || []).map((t) => t.trim()).filter(Boolean),
folderId: meta.folderId ?? null,
authorId: meta.authorId ?? null,
},
});
+9 -4
View File
@@ -1,12 +1,17 @@
import { prisma } from "../db";
import { HttpError } from "../utils/fileUtils";
import { toPrintOut, type PrintOut } from "../dto";
import type { Plate, Print, PrintFile } from "@prisma/client";
import type { Author, Plate, Print, PrintFile } from "@prisma/client";
export type FullPrint = { print: Print; plates: Plate[]; files: PrintFile[]; preparedFile: PrintFile | null };
export type FullPrint = {
print: Print & { author: Author | null };
plates: Plate[];
files: PrintFile[];
preparedFile: PrintFile | null;
};
export async function loadFullPrint(printId: string): Promise<FullPrint> {
const print = await prisma.print.findUnique({ where: { id: printId } });
const print = await prisma.print.findUnique({ where: { id: printId }, include: { author: true } });
if (!print) throw new HttpError(404, "Print not found");
const [plates, files] = await Promise.all([
prisma.plate.findMany({ where: { printId }, orderBy: { position: "asc" } }),
@@ -20,5 +25,5 @@ export async function loadFullPrint(printId: string): Promise<FullPrint> {
export async function printOutById(printId: string): Promise<PrintOut> {
const full = await loadFullPrint(printId);
return toPrintOut(full.print, full.plates, full.files, full.preparedFile);
return toPrintOut(full.print, full.plates, full.files, full.preparedFile, full.print.author);
}
+5 -1
View File
@@ -7,7 +7,7 @@ import { HttpError, sanitizeFilename, guessMimeFromPath } from "../utils/fileUti
import { IMPORT_MAX_BYTES } from "../config";
import { listZipEntries as listRawZipEntries, readZipEntry } from "../utils/zipReader";
import { validateParentFolder } from "./folderService";
import { applyCoverThumbnailIfMissing } from "./importService";
import { applyCoverThumbnailIfMissing, attachGalleryImagesAsSupportingFiles } from "./importService";
import { createPrint, type PrintMetaInput } from "./printCreation";
import type { Print, Plate } from "@prisma/client";
@@ -91,7 +91,9 @@ export type ZipExtractOptions = {
tags?: string[];
folderId?: string | null;
creator?: string | null;
authorId?: string | null;
previewImageUrl?: string | null;
galleryImages?: { url: string; filename: string }[];
};
/**
@@ -159,6 +161,7 @@ export async function extractZipEntriesToPrints(
tags: options.tags ?? [],
folderId: targetFolderId,
creator: options.creator ?? null,
authorId: options.authorId ?? null,
};
const mime = guessMimeFromPath(filename);
const { print, plates } = await createPrint(meta, path.parse(filename).name, [
@@ -166,6 +169,7 @@ export async function extractZipEntriesToPrints(
]);
tempPath = null;
await applyCoverThumbnailIfMissing(plates[0]?.id, options.previewImageUrl);
await attachGalleryImagesAsSupportingFiles(print.id, options.galleryImages ?? [], options.previewImageUrl);
prints.push({ ...print, plates });
} catch {
if (tempPath) await fs.rm(tempPath, { force: true }).catch(() => undefined);
+14
View File
@@ -30,12 +30,26 @@ export type PrintFile = {
url: string; // /print/{printId}/files/{fileId}
};
export type Author = {
id: string;
provider: string;
external_id: string;
name: string | null;
handle: string | null;
bio: string | null;
bio_translated: string | null;
links: string[];
avatar_url: string | null;
background_url: string | null;
};
export type Print = {
id: string;
name: string;
title?: string | null;
notes?: string | null;
creator?: string | null;
author?: Author | null;
collection?: string | null;
tags: string[];
folder_id?: string | null;