mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-21 06:09:56 -05:00
feat: enable NextPrint provider
This commit is contained in:
1 parent
84cbe2d178
commit
4037151e4a
68 files changed
+2081
-123
No files matched your search
@@ -278,7 +278,7 @@ To have Thingport import whatever you drop into a folder on the server, uncommen
|
||||
|
||||
## Features
|
||||
|
||||
- **[Import from MakerWorld, Printables, Thingiverse, MyMiniFactory, Snapmaker Space, MakerOnline and Creality Cloud](docs/features/importing.md)**
|
||||
- **[Import from MakerWorld, Printables, Thingiverse, MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud and NexPrint](docs/features/importing.md)**
|
||||
- **[Synced collections](docs/guides/synced-collections.md)**
|
||||
- **[Bring your existing library](docs/guides/existing-library.md)**
|
||||
- **[Consume folder](docs/guides/consume-folder.md)**
|
||||
@@ -306,11 +306,11 @@ To have Thingport import whatever you drop into a folder on the server, uncommen
|
||||
Thingport ships two small companion tools, each downloadable from the in-app Download page or GitHub Releases:
|
||||
|
||||
- **[Thingport Bridge](bridge/README.md)** — a lightweight desktop helper that makes "Open in {Slicer}" work for slicers (Bambu Studio, PrusaSlicer, Cura, Anycubic Slicer Next) whose own URL-protocol handlers won't accept a link from a self-hosted domain.
|
||||
- **[Thingport Grab](extension/README.md)** — a browser extension for Chrome, Edge and Firefox that imports MakerWorld, Thingiverse, Printables, MyMiniFactory, Snapmaker Space, MakerOnline and Creality Cloud models straight from their own pages, without leaving the site (see the screenshots above). Even without a Thingport instance, it adds a "Download normalized" button to MakerWorld model pages that converts Bambu Studio projects into 3MFs other slicers open with their colours and settings. Get it from the [Chrome Web Store](https://chromewebstore.google.com/detail/nmblahmglpbplmfcggghdgohohlaeiee), [Firefox Add-ons](https://addons.mozilla.org/firefox/addon/thingport-grab/) or [Microsoft Edge Add-ons](https://microsoftedge.microsoft.com/addons/detail/kahfidpmojfocohinlmglnfoaimocbol).
|
||||
- **[Thingport Grab](extension/README.md)** — a browser extension for Chrome, Edge and Firefox that imports MakerWorld, Thingiverse, Printables, MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud and NexPrint models straight from their own pages, without leaving the site (see the screenshots above). Even without a Thingport instance, it adds a "Download normalized" button to MakerWorld model pages that converts Bambu Studio projects into 3MFs other slicers open with their colours and settings. Get it from the [Chrome Web Store](https://chromewebstore.google.com/detail/nmblahmglpbplmfcggghdgohohlaeiee), [Firefox Add-ons](https://addons.mozilla.org/firefox/addon/thingport-grab/) or [Microsoft Edge Add-ons](https://microsoftedge.microsoft.com/addons/detail/kahfidpmojfocohinlmglnfoaimocbol).
|
||||
|
||||
## Provider Setup
|
||||
|
||||
Printables imports work with no setup. MakerWorld needs each user's session cookie; Thingiverse needs an App Token and MyMiniFactory an API key, each set once by an admin for the whole instance. Users connect their own MyMiniFactory session only to import paid or sign-in-only models from their library, and their own Snapmaker Space sign-in, which Thingport Grab keeps fresh, for any Snapmaker import. MakerOnline's files come through Thingport Grab, which also connects each user's MakerOnline sign-in for their likes and private collections. Creality Cloud needs each user's own sign-in for its files and likes, which Thingport Grab sends on. Connect them under **Configuration → Providers** (from the user menu): right-click a provider to connect or disconnect it. Admins can turn any provider off for the whole instance under **Administration → Providers**. See **[docs/PROVIDER_SETUP.md](docs/PROVIDER_SETUP.md)** for how to create a Thingiverse App Token or a MyMiniFactory API key, and how to grab a MakerWorld session cookie.
|
||||
Printables imports work with no setup. MakerWorld needs each user's session cookie; Thingiverse needs an App Token and MyMiniFactory an API key, each set once by an admin for the whole instance. Users connect their own MyMiniFactory session only to import paid or sign-in-only models from their library, and their own Snapmaker Space sign-in, which Thingport Grab keeps fresh, for any Snapmaker import. MakerOnline's files come through Thingport Grab, which also connects each user's MakerOnline sign-in for their likes and private collections. Creality Cloud needs each user's own sign-in for its files and likes, and NexPrint for its files; Thingport Grab sends both on. Connect them under **Configuration → Providers** (from the user menu): right-click a provider to connect or disconnect it. Admins can turn any provider off for the whole instance under **Administration → Providers**. See **[docs/PROVIDER_SETUP.md](docs/PROVIDER_SETUP.md)** for how to create a Thingiverse App Token or a MyMiniFactory API key, and how to grab a MakerWorld session cookie.
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
-- NexPrint as an eighth provider: its site category ids on categories, kept as text since they're 16 digits long;
|
||||
-- and the user's NexPrint sign-in (the auth_token cookie, sent as a Bearer token), sent by Thingport Grab or pasted.
|
||||
ALTER TABLE "Category" ADD COLUMN "nexprintCatIds" TEXT[] NOT NULL DEFAULT '{}';
|
||||
CREATE INDEX "Category_nexprintCatIds_idx" ON "Category" USING GIN ("nexprintCatIds");
|
||||
|
||||
ALTER TABLE "User" ADD COLUMN "nexprintToken" TEXT,
|
||||
ADD COLUMN "nexprintExpiresAt" TIMESTAMP(3),
|
||||
ADD COLUMN "nexprintRejectedAt" TIMESTAMP(3);
|
||||
|
||||
-- Adds NexPrint's category ids to the starter categories of users who signed up before they were in
|
||||
-- seedData/defaultCategories.ts, so their NexPrint imports are categorized too. A starter category is found by its
|
||||
-- name, and a subcategory by its top-level parent's name as well; one a user renamed, moved or deleted is skipped, and
|
||||
-- ids a user already set are kept.
|
||||
UPDATE "Category" AS c
|
||||
SET "nexprintCatIds" = m.ids
|
||||
FROM (VALUES
|
||||
(NULL::text, '3D Printer', ARRAY['1422473859022863']),
|
||||
('3D Printer', '3D Printer Accessories', ARRAY['1422473859022858']),
|
||||
('3D Printer', '3D Printer Parts', ARRAY['2']),
|
||||
('3D Printer', 'Test Models', ARRAY['1422473859022859']),
|
||||
(NULL::text, 'Art', ARRAY['1422473858908165']),
|
||||
('Art', '2D Art', ARRAY['1422473859022850']),
|
||||
('Art', 'Coin & Badges', ARRAY['1422473859022853']),
|
||||
('Art', 'Signs & Logos', ARRAY['1422473859022855']),
|
||||
('Art', 'Sculptures', ARRAY['1422473859022854']),
|
||||
('Art', 'Other Art Models', ARRAY['1422473859022851', '1422473859022852', '1422473859022857']),
|
||||
('Education', 'Other Education Models', ARRAY['1422473859014665']),
|
||||
(NULL::text, 'Fashion', ARRAY['1422473858908164']),
|
||||
('Fashion', 'Bags', ARRAY['1422473859014676']),
|
||||
('Fashion', 'Clothes', ARRAY['1422473859014674']),
|
||||
('Fashion', 'Footwear', ARRAY['1422473859022848']),
|
||||
('Fashion', 'Other Fashion Models', ARRAY['1422473859014675', '1422473859022849']),
|
||||
(NULL::text, 'Hobby & DIY', ARRAY['1422473858908163']),
|
||||
('Hobby & DIY', 'Electronics', ARRAY['1422473858908161', '1422473859006469', '1422473859014657', '1422473859014658', '1422473859014659', '1422473859014660']),
|
||||
('Hobby & DIY', 'Music', ARRAY['1422473859022856']),
|
||||
('Hobby & DIY', 'RC', ARRAY['1422473859014656']),
|
||||
('Hobby & DIY', 'Sport & Outdoors', ARRAY['1422473859014671']),
|
||||
('Hobby & DIY', 'Vehicles', ARRAY['1422473859014672']),
|
||||
('Hobby & DIY', 'Other Hobby & DIY', ARRAY['1422473859014673']),
|
||||
(NULL::text, 'Household', ARRAY['1422473858908162']),
|
||||
('Household', 'Decor', ARRAY['1422473859014662']),
|
||||
('Household', 'Garden', ARRAY['1422473859014668']),
|
||||
('Household', 'Office', ARRAY['1422473859014664']),
|
||||
('Household', 'Pets', ARRAY['1422473859014669']),
|
||||
('Household', 'Other Household Models', ARRAY['1422473859014663', '1422473859014666', '1422473859014667', '1422473859014670']),
|
||||
(NULL::text, 'Miniatures', ARRAY['1422473859006464']),
|
||||
(NULL::text, 'Props & Cosplays', ARRAY['1422473859006467']),
|
||||
(NULL::text, 'Tools', ARRAY['1422473858908166']),
|
||||
('Tools', 'Organizers', ARRAY['1422473859014661']),
|
||||
('Tools', 'Other Tools', ARRAY['1422473859022860', '1422473859022861', '1422473859022862']),
|
||||
(NULL::text, 'Toys & Games', ARRAY['1422473858908160']),
|
||||
('Toys & Games', 'Board Games', ARRAY['1422473859006466']),
|
||||
('Toys & Games', 'Construction Sets', ARRAY['1422473859006465']),
|
||||
('Toys & Games', 'Other Toys & Games', ARRAY['1422473859006468'])
|
||||
) AS m(parent_name, name, ids)
|
||||
WHERE c."kind" = 'category'
|
||||
AND c."name" = m.name
|
||||
AND cardinality(c."nexprintCatIds") = 0
|
||||
AND (
|
||||
(m.parent_name IS NULL AND c."parentId" IS NULL)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM "Category" p
|
||||
WHERE p."id" = c."parentId" AND p."parentId" IS NULL AND p."name" = m.parent_name
|
||||
)
|
||||
);
|
||||
@@ -94,6 +94,12 @@ model User {
|
||||
crealitycloudUserId String?
|
||||
// When Creality Cloud last turned the token down.
|
||||
crealitycloudRejectedAt DateTime?
|
||||
// The user's NexPrint token, from its auth_token cookie, sent as a Bearer token. The site keeps the cookie for a
|
||||
// week and renews it as it refreshes the token; Thingport Grab sends each new one here.
|
||||
nexprintToken String?
|
||||
nexprintExpiresAt DateTime?
|
||||
// When NexPrint last turned the token down.
|
||||
nexprintRejectedAt DateTime?
|
||||
slicer String?
|
||||
// "light" | "dark" | "system"; null means never set.
|
||||
themePreference String?
|
||||
@@ -169,6 +175,8 @@ model Category {
|
||||
snapmakerCatIds Int[] @default([])
|
||||
makeronlineCatIds Int[] @default([])
|
||||
crealitycloudCatIds Int[] @default([])
|
||||
// Text: NexPrint's ids are 16 digits, past Int, and a BigInt would break JSON serialization of the row.
|
||||
nexprintCatIds String[] @default([])
|
||||
prints Print[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -182,6 +190,7 @@ model Category {
|
||||
@@index([snapmakerCatIds], type: Gin)
|
||||
@@index([makeronlineCatIds], type: Gin)
|
||||
@@index([crealitycloudCatIds], type: Gin)
|
||||
@@index([nexprintCatIds], type: Gin)
|
||||
}
|
||||
|
||||
model Print {
|
||||
|
||||
+3
-1
@@ -182,6 +182,7 @@ export type CategoryOut = {
|
||||
snapmaker_cat_ids: string;
|
||||
makeronline_cat_ids: string;
|
||||
crealitycloud_cat_ids: string;
|
||||
nexprint_cat_ids: string;
|
||||
};
|
||||
|
||||
function plateThumbUrl(plateId: string): string | null {
|
||||
@@ -458,7 +459,7 @@ export function toSystemCollectionOut(
|
||||
};
|
||||
}
|
||||
|
||||
function formatCatIds(ids: number[]): string {
|
||||
function formatCatIds(ids: (number | string)[]): string {
|
||||
return ids.join(";");
|
||||
}
|
||||
|
||||
@@ -479,6 +480,7 @@ export function toCategoryOut(category: Category): CategoryOut {
|
||||
snapmaker_cat_ids: formatCatIds(category.snapmakerCatIds),
|
||||
makeronline_cat_ids: formatCatIds(category.makeronlineCatIds),
|
||||
crealitycloud_cat_ids: formatCatIds(category.crealitycloudCatIds),
|
||||
nexprint_cat_ids: formatCatIds(category.nexprintCatIds),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -165,7 +165,8 @@ function parseCatIdsInput(raw: string | null | undefined): number[] {
|
||||
const ids: number[] = [];
|
||||
const seen = new Set<number>();
|
||||
for (const token of tokens) {
|
||||
if (!/^\d+$/.test(token) || token.length > 15) {
|
||||
// NexPrint's ids are 16 digits; isSafeInteger below still catches anything too large.
|
||||
if (!/^\d+$/.test(token) || token.length > 16) {
|
||||
throw new HttpError(400, `Invalid category id "${token}" -- use numbers separated by ";", e.g. 800;71;1001`);
|
||||
}
|
||||
const id = Number(token);
|
||||
@@ -194,6 +195,7 @@ const categoryMetaSchema = z.object({
|
||||
snapmaker_cat_ids: catIdsField,
|
||||
makeronline_cat_ids: catIdsField,
|
||||
crealitycloud_cat_ids: catIdsField,
|
||||
nexprint_cat_ids: catIdsField,
|
||||
});
|
||||
|
||||
router.patch(
|
||||
@@ -214,6 +216,7 @@ router.patch(
|
||||
snapmakerCatIds: parseCatIdsInput(body.snapmaker_cat_ids),
|
||||
makeronlineCatIds: parseCatIdsInput(body.makeronline_cat_ids),
|
||||
crealitycloudCatIds: parseCatIdsInput(body.crealitycloud_cat_ids),
|
||||
nexprintCatIds: parseCatIdsInput(body.nexprint_cat_ids).map(String),
|
||||
},
|
||||
});
|
||||
res.json(toCategoryOut(updated));
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
assertCanImportUrl,
|
||||
SNAPMAKER_SESSION_CODE,
|
||||
CREALITYCLOUD_SESSION_CODE,
|
||||
NEXPRINT_SESSION_CODE,
|
||||
checkImportStatus,
|
||||
downloadImportToTemp,
|
||||
findImportedExternalIds,
|
||||
@@ -102,6 +103,13 @@ import {
|
||||
getUserCrealitycloudAuth,
|
||||
markCrealitycloudSessionRejected,
|
||||
} from "../services/crealitycloudSessionService";
|
||||
import {
|
||||
fetchNexprintCollection,
|
||||
NexprintAuthError,
|
||||
parseNexprintCollectionUrl,
|
||||
parseNexprintModelUrl,
|
||||
} from "../services/nexprintApi";
|
||||
import { markNexprintSessionRejected } from "../services/nexprintSessionService";
|
||||
import { addPrintsToCollection, findOrCreateCollectionByName } from "../services/collectionService";
|
||||
import { listZipEntries } from "../services/zipService";
|
||||
import { createJob, createJobItems, getActiveJob, getJob, listJobItems } from "../services/importJobService";
|
||||
@@ -111,6 +119,8 @@ import {
|
||||
runCollectionImportJob,
|
||||
runCrealitycloudListImportJob,
|
||||
runCrealitycloudProfilesImportJob,
|
||||
runNexprintCollectionImportJob,
|
||||
runNexprintProfilesImportJob,
|
||||
runLinksImportJob,
|
||||
runMakerworldProfilesImportJob,
|
||||
runMyminifactoryCollectionImportJob,
|
||||
@@ -287,6 +297,7 @@ const statusBatchSchema = z.object({
|
||||
"snapmaker",
|
||||
"makeronline",
|
||||
"crealitycloud",
|
||||
"nexprint",
|
||||
]),
|
||||
// Creality Cloud's ids are its models' aliases, made of their titles.
|
||||
external_ids: z.array(z.string().max(200)).max(10_000),
|
||||
@@ -628,6 +639,51 @@ router.post(
|
||||
}),
|
||||
);
|
||||
|
||||
/** A NexPrint collection from its link; even private ones read without a sign-in. */
|
||||
async function nexprintCollectionOf(userId: string, url: string) {
|
||||
const parsed = parseNexprintCollectionUrl(url);
|
||||
if (!parsed) throw new HttpError(400, "Not a link to a NexPrint collection");
|
||||
try {
|
||||
const collection = await fetchNexprintCollection(parsed.collectionId);
|
||||
if (!collection) throw new HttpError(404, "That collection can't be found. It may have been deleted.");
|
||||
return collection;
|
||||
} catch (err) {
|
||||
if (err instanceof NexprintAuthError) {
|
||||
await markNexprintSessionRejected(userId);
|
||||
// 400, not 401: the frontend treats any 401 as an expired Thingport session.
|
||||
throw new HttpError(400, err.message, NEXPRINT_SESSION_CODE);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
router.post(
|
||||
"/import/nexprint-collection/entries",
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = parseBody(importRequestSchema, req.body);
|
||||
const url = await enabledImportUrl(body.url);
|
||||
const collection = await nexprintCollectionOf(req.userId!, url);
|
||||
const title = collection.info.name;
|
||||
if (!collection.entries.length) throw new HttpError(400, `"${title}" is empty`);
|
||||
const alreadyImported = await findImportedExternalIds(
|
||||
req.userId!,
|
||||
"nexprint",
|
||||
collection.entries.map((e) => e.modelId),
|
||||
);
|
||||
res.json({
|
||||
title,
|
||||
total: collection.total,
|
||||
truncated: collection.truncated,
|
||||
entries: collection.entries.map((e) => ({
|
||||
design_id: e.modelId,
|
||||
title: e.title,
|
||||
cover: e.cover,
|
||||
already_imported: alreadyImported.has(e.modelId),
|
||||
})),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
// ---- MakerOnline: files only through Thingport Grab ----
|
||||
// MakerOnline signs its download links only for a page its own anti-bot script runs on. Grab asks what a model needs
|
||||
// (plan), gets it signed on makeronline.com, and imports with the signed links; imports started anywhere else wait
|
||||
@@ -870,6 +926,20 @@ router.post(
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/import/nexprint-profiles",
|
||||
requireCaptcha("import"),
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = parseBody(makerworldProfilesImportRequestSchema, req.body);
|
||||
await assertNoActiveJob(req.userId!);
|
||||
const url = await enabledImportUrl(body.url);
|
||||
if (!parseNexprintModelUrl(url)) throw new HttpError(400, "Not a NexPrint model link");
|
||||
const job = await createJob(req.userId!, "PROFILES", { sourceUrl: url, provider: "nexprint" });
|
||||
void runNexprintProfilesImportJob(job.id, req.userId!, { ...body, url });
|
||||
res.status(202).json({ job_id: job.id });
|
||||
}),
|
||||
);
|
||||
|
||||
const collectionImportRequestSchema = importRequestSchema.extend({ design_ids: z.array(z.string()).min(1) });
|
||||
|
||||
router.post(
|
||||
@@ -1027,6 +1097,41 @@ router.post(
|
||||
}),
|
||||
);
|
||||
|
||||
const nexprintCollectionImportRequestSchema = importRequestSchema.extend({
|
||||
model_ids: z
|
||||
.array(
|
||||
z
|
||||
.string()
|
||||
.regex(/^G\d+$/i)
|
||||
.max(32),
|
||||
)
|
||||
.min(1)
|
||||
.max(1000),
|
||||
});
|
||||
|
||||
router.post(
|
||||
"/import/nexprint-collection",
|
||||
requireCaptcha("import"),
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = parseBody(nexprintCollectionImportRequestSchema, req.body);
|
||||
await assertNoActiveJob(req.userId!);
|
||||
const url = await enabledImportUrl(body.url);
|
||||
const { info } = await nexprintCollectionOf(req.userId!, url);
|
||||
const job = await createJob(req.userId!, "COLLECTION", {
|
||||
sourceUrl: url,
|
||||
provider: "nexprint",
|
||||
total: body.model_ids.length,
|
||||
});
|
||||
void runNexprintCollectionImportJob(job.id, req.userId!, {
|
||||
...body,
|
||||
model_ids: body.model_ids.map((id) => id.toUpperCase()),
|
||||
url,
|
||||
title: info.name,
|
||||
});
|
||||
res.status(202).json({ job_id: job.id });
|
||||
}),
|
||||
);
|
||||
|
||||
const zipExtractRequestSchema = importRequestSchema.extend({ entries: z.array(z.string()) });
|
||||
|
||||
router.post(
|
||||
|
||||
@@ -74,6 +74,12 @@ import {
|
||||
normalizeCrealitycloudSession,
|
||||
setUserCrealitycloudSession,
|
||||
} from "../services/crealitycloudSessionService";
|
||||
import { checkNexprintAuth } from "../services/nexprintApi";
|
||||
import {
|
||||
getNexprintConnection,
|
||||
normalizeNexprintSession,
|
||||
setUserNexprintSession,
|
||||
} from "../services/nexprintSessionService";
|
||||
import { SLICER_IDS, getUserSlicer, setUserSlicer } from "../services/slicerPreferenceService";
|
||||
import { CATEGORIES_VIEWS, getCategoriesView, setCategoriesView } from "../services/categoriesViewService";
|
||||
import {
|
||||
@@ -762,6 +768,49 @@ router.patch(
|
||||
}),
|
||||
);
|
||||
|
||||
// Per-user: the NexPrint sign-in, which Thingport Grab sends whenever the browser's changes.
|
||||
router.get(
|
||||
"/settings/nexprint-session",
|
||||
asyncHandler(async (req, res) => {
|
||||
const connection = await getNexprintConnection(req.userId!);
|
||||
res.json({ configured: connection !== "disconnected", expired: connection === "expired" });
|
||||
}),
|
||||
);
|
||||
|
||||
// `expires_at`: the auth_token cookie's own expiry, which Grab can read; it's only shown, as the token may outlive it.
|
||||
const nexprintSessionSchema = z.object({
|
||||
token: z.string().max(10_000).nullable(),
|
||||
expires_at: z.string().datetime().nullable().optional(),
|
||||
verify: z.boolean().optional(),
|
||||
});
|
||||
router.patch(
|
||||
"/settings/nexprint-session",
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = parseBody(nexprintSessionSchema, req.body);
|
||||
const session = normalizeNexprintSession(body.token, body.expires_at ? new Date(body.expires_at) : null);
|
||||
if ((body.token ?? "").trim() && !session) {
|
||||
throw new HttpError(
|
||||
422,
|
||||
"That doesn't look like a NexPrint sign-in: paste the auth_token cookie, or the Authorization header of a signed-in nexprint.com request.",
|
||||
);
|
||||
}
|
||||
if (body.verify && session) {
|
||||
const check = await checkNexprintAuth(session.token);
|
||||
if (check === "rejected") {
|
||||
throw new HttpError(
|
||||
422,
|
||||
"NexPrint didn't accept this sign-in -- it may have expired. Copy it again from a signed-in nexprint.com tab and try again.",
|
||||
);
|
||||
}
|
||||
// 503, not 422: nothing is known to be wrong with the sign-in.
|
||||
if (check === "unreachable")
|
||||
throw new HttpError(503, "Couldn't reach NexPrint to check this sign-in. Try again in a moment.");
|
||||
}
|
||||
const configured = await setUserNexprintSession(req.userId!, session);
|
||||
res.json({ configured });
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/settings/slicer",
|
||||
asyncHandler(async (req, res) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** Starter category tree modeled on MakerWorld's, with matching Thingiverse, Printables, MyMiniFactory, Snapmaker,
|
||||
* MakerOnline and Creality Cloud ids so imports are auto-categorized from the start. */
|
||||
* MakerOnline, Creality Cloud and NexPrint ids so imports are auto-categorized from the start. NexPrint's are text:
|
||||
* they're 16 digits long. */
|
||||
export type DefaultCategoryNode = {
|
||||
name: string;
|
||||
tags?: string[];
|
||||
@@ -12,6 +13,7 @@ export type DefaultCategoryNode = {
|
||||
snapmakerCatIds?: number[];
|
||||
makeronlineCatIds?: number[];
|
||||
crealitycloudCatIds?: number[];
|
||||
nexprintCatIds?: string[];
|
||||
children?: DefaultCategoryNode[];
|
||||
};
|
||||
|
||||
@@ -28,6 +30,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
snapmakerCatIds: [103],
|
||||
makeronlineCatIds: [33],
|
||||
crealitycloudCatIds: [1731, 6006],
|
||||
nexprintCatIds: ["1422473859022863"],
|
||||
children: [
|
||||
{
|
||||
name: "3D Printer Accessories",
|
||||
@@ -41,6 +44,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
snapmakerCatIds: [116, 115, 117, 118],
|
||||
makeronlineCatIds: [34],
|
||||
crealitycloudCatIds: [1904],
|
||||
nexprintCatIds: ["1422473859022858"],
|
||||
},
|
||||
{
|
||||
name: "3D Printer Parts",
|
||||
@@ -56,6 +60,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
],
|
||||
makeronlineCatIds: [35],
|
||||
crealitycloudCatIds: [1316],
|
||||
nexprintCatIds: ["2"],
|
||||
},
|
||||
{
|
||||
name: "Test Models",
|
||||
@@ -69,6 +74,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
snapmakerCatIds: [114],
|
||||
makeronlineCatIds: [36],
|
||||
crealitycloudCatIds: [1645],
|
||||
nexprintCatIds: ["1422473859022859"],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -83,6 +89,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
snapmakerCatIds: [104],
|
||||
makeronlineCatIds: [70],
|
||||
crealitycloudCatIds: [1670],
|
||||
nexprintCatIds: ["1422473858908165"],
|
||||
children: [
|
||||
{
|
||||
name: "2D Art",
|
||||
@@ -95,6 +102,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
snapmakerCatIds: [119],
|
||||
makeronlineCatIds: [75],
|
||||
crealitycloudCatIds: [1662],
|
||||
nexprintCatIds: ["1422473859022850"],
|
||||
},
|
||||
{
|
||||
name: "Coin & Badges",
|
||||
@@ -105,6 +113,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
thingiverseCatIds: [143],
|
||||
makeronlineCatIds: [72],
|
||||
crealitycloudCatIds: [1341],
|
||||
nexprintCatIds: ["1422473859022853"],
|
||||
},
|
||||
{
|
||||
name: "Signs & Logos",
|
||||
@@ -115,6 +124,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
thingiverseCatIds: [76],
|
||||
myminifactoryCatIds: [105],
|
||||
makeronlineCatIds: [73],
|
||||
nexprintCatIds: ["1422473859022855"],
|
||||
},
|
||||
{
|
||||
name: "Sculptures",
|
||||
@@ -128,6 +138,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
snapmakerCatIds: [121],
|
||||
makeronlineCatIds: [74],
|
||||
crealitycloudCatIds: [1584],
|
||||
nexprintCatIds: ["1422473859022854"],
|
||||
},
|
||||
{
|
||||
name: "Other Art Models",
|
||||
@@ -140,6 +151,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
snapmakerCatIds: [120, 183],
|
||||
makeronlineCatIds: [71, 105],
|
||||
crealitycloudCatIds: [1997, 6005],
|
||||
nexprintCatIds: ["1422473859022851", "1422473859022852", "1422473859022857"],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -236,6 +248,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
snapmakerCatIds: [133],
|
||||
makeronlineCatIds: [83],
|
||||
crealitycloudCatIds: [1974, 1343, 6003],
|
||||
nexprintCatIds: ["1422473859014665"],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -251,6 +264,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
snapmakerCatIds: [107],
|
||||
makeronlineCatIds: [76],
|
||||
crealitycloudCatIds: [1175],
|
||||
nexprintCatIds: ["1422473858908164"],
|
||||
children: [
|
||||
{
|
||||
name: "Bags",
|
||||
@@ -261,6 +275,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
myminifactoryCatIds: [143],
|
||||
snapmakerCatIds: [135],
|
||||
makeronlineCatIds: [80],
|
||||
nexprintCatIds: ["1422473859014676"],
|
||||
},
|
||||
{
|
||||
name: "Clothes",
|
||||
@@ -273,6 +288,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
snapmakerCatIds: [182],
|
||||
makeronlineCatIds: [79],
|
||||
crealitycloudCatIds: [1598],
|
||||
nexprintCatIds: ["1422473859014674"],
|
||||
},
|
||||
{
|
||||
name: "Earrings",
|
||||
@@ -291,6 +307,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
makerworldCatIds: [204],
|
||||
myminifactoryCatIds: [140],
|
||||
snapmakerCatIds: [137],
|
||||
nexprintCatIds: ["1422473859022848"],
|
||||
},
|
||||
{
|
||||
name: "Glasses",
|
||||
@@ -334,6 +351,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
snapmakerCatIds: [136, 138],
|
||||
makeronlineCatIds: [77, 81],
|
||||
crealitycloudCatIds: [1647, 6002],
|
||||
nexprintCatIds: ["1422473859014675", "1422473859022849"],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -348,6 +366,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
snapmakerCatIds: [109],
|
||||
makeronlineCatIds: [46],
|
||||
crealitycloudCatIds: [1519],
|
||||
nexprintCatIds: ["1422473858908163"],
|
||||
children: [
|
||||
{
|
||||
name: "Electronics",
|
||||
@@ -363,6 +382,14 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
snapmakerCatIds: [148],
|
||||
makeronlineCatIds: [48],
|
||||
crealitycloudCatIds: [1741],
|
||||
nexprintCatIds: [
|
||||
"1422473858908161",
|
||||
"1422473859006469",
|
||||
"1422473859014657",
|
||||
"1422473859014658",
|
||||
"1422473859014659",
|
||||
"1422473859014660",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Music",
|
||||
@@ -376,6 +403,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
snapmakerCatIds: [152],
|
||||
makeronlineCatIds: [49],
|
||||
crealitycloudCatIds: [1420],
|
||||
nexprintCatIds: ["1422473859022856"],
|
||||
},
|
||||
{
|
||||
name: "RC",
|
||||
@@ -390,6 +418,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
],
|
||||
snapmakerCatIds: [150],
|
||||
makeronlineCatIds: [51],
|
||||
nexprintCatIds: ["1422473859014656"],
|
||||
},
|
||||
{
|
||||
name: "Robotics",
|
||||
@@ -416,6 +445,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
snapmakerCatIds: [154],
|
||||
makeronlineCatIds: [50],
|
||||
crealitycloudCatIds: [1246],
|
||||
nexprintCatIds: ["1422473859014671"],
|
||||
},
|
||||
{
|
||||
name: "Vehicles",
|
||||
@@ -428,6 +458,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
snapmakerCatIds: [153],
|
||||
makeronlineCatIds: [47],
|
||||
crealitycloudCatIds: [1194],
|
||||
nexprintCatIds: ["1422473859014672"],
|
||||
},
|
||||
{
|
||||
name: "Other Hobby & DIY",
|
||||
@@ -440,6 +471,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
snapmakerCatIds: [151, 155],
|
||||
makeronlineCatIds: [54, 53],
|
||||
crealitycloudCatIds: [6004],
|
||||
nexprintCatIds: ["1422473859014673"],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -455,6 +487,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
snapmakerCatIds: [108],
|
||||
makeronlineCatIds: [37],
|
||||
crealitycloudCatIds: [1010],
|
||||
nexprintCatIds: ["1422473858908162"],
|
||||
children: [
|
||||
{
|
||||
name: "Decor",
|
||||
@@ -468,6 +501,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
snapmakerCatIds: [139],
|
||||
makeronlineCatIds: [38, 43],
|
||||
crealitycloudCatIds: [1150, 1096],
|
||||
nexprintCatIds: ["1422473859014662"],
|
||||
},
|
||||
{
|
||||
name: "Festivities",
|
||||
@@ -491,6 +525,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
myminifactoryCatIds: [150],
|
||||
snapmakerCatIds: [146],
|
||||
makeronlineCatIds: [39],
|
||||
nexprintCatIds: ["1422473859014668"],
|
||||
},
|
||||
{
|
||||
name: "Office",
|
||||
@@ -503,6 +538,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
myminifactoryCatIds: [382, 377],
|
||||
snapmakerCatIds: [143],
|
||||
makeronlineCatIds: [41],
|
||||
nexprintCatIds: ["1422473859014664"],
|
||||
},
|
||||
{
|
||||
name: "Pets",
|
||||
@@ -515,6 +551,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
snapmakerCatIds: [147],
|
||||
makeronlineCatIds: [44],
|
||||
crealitycloudCatIds: [1151],
|
||||
nexprintCatIds: ["1422473859014669"],
|
||||
},
|
||||
{
|
||||
name: "Other Household Models",
|
||||
@@ -531,6 +568,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
snapmakerCatIds: [142, 144, 145, 184],
|
||||
makeronlineCatIds: [45, 40],
|
||||
crealitycloudCatIds: [1775, 6000],
|
||||
nexprintCatIds: ["1422473859014663", "1422473859014666", "1422473859014667", "1422473859014670"],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -546,6 +584,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
snapmakerCatIds: [110],
|
||||
makeronlineCatIds: [95],
|
||||
crealitycloudCatIds: [1952],
|
||||
nexprintCatIds: ["1422473859006464"],
|
||||
children: [
|
||||
{
|
||||
name: "Animals",
|
||||
@@ -620,6 +659,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
snapmakerCatIds: [105],
|
||||
makeronlineCatIds: [90],
|
||||
crealitycloudCatIds: [1966],
|
||||
nexprintCatIds: ["1422473859006467"],
|
||||
children: [
|
||||
{
|
||||
name: "Costumes",
|
||||
@@ -676,6 +716,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
snapmakerCatIds: [111],
|
||||
makeronlineCatIds: [55],
|
||||
crealitycloudCatIds: [1671],
|
||||
nexprintCatIds: ["1422473858908166"],
|
||||
children: [
|
||||
{
|
||||
name: "Gadgets",
|
||||
@@ -740,6 +781,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
myminifactoryCatIds: [149],
|
||||
snapmakerCatIds: [141, 163],
|
||||
makeronlineCatIds: [61],
|
||||
nexprintCatIds: ["1422473859014661"],
|
||||
},
|
||||
{
|
||||
name: "Other Tools",
|
||||
@@ -751,6 +793,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
printablesCatIds: [49],
|
||||
snapmakerCatIds: [164, 166],
|
||||
makeronlineCatIds: [57],
|
||||
nexprintCatIds: ["1422473859022860", "1422473859022861", "1422473859022862"],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -766,6 +809,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
snapmakerCatIds: [112],
|
||||
makeronlineCatIds: [62],
|
||||
crealitycloudCatIds: [1809],
|
||||
nexprintCatIds: ["1422473858908160"],
|
||||
children: [
|
||||
{
|
||||
name: "Board Games",
|
||||
@@ -779,6 +823,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
snapmakerCatIds: [172],
|
||||
makeronlineCatIds: [68],
|
||||
crealitycloudCatIds: [1575],
|
||||
nexprintCatIds: ["1422473859006466"],
|
||||
},
|
||||
{
|
||||
name: "Characters",
|
||||
@@ -826,6 +871,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
snapmakerCatIds: [170],
|
||||
makeronlineCatIds: [67],
|
||||
crealitycloudCatIds: [1141],
|
||||
nexprintCatIds: ["1422473859006465"],
|
||||
},
|
||||
{
|
||||
name: "Other Toys & Games",
|
||||
@@ -839,6 +885,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
snapmakerCatIds: [167, 168, 174, 175],
|
||||
makeronlineCatIds: [63, 64],
|
||||
crealitycloudCatIds: [1793, 6007],
|
||||
nexprintCatIds: ["1422473859006468"],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -21,6 +21,7 @@ import { resolveThingiverseThing, ThingiverseAuthError, ThingiverseRateLimitErro
|
||||
import { resolveSnapmakerModel } from "./snapmakerApi";
|
||||
import { resolveMakeronlineModel } from "./makeronlineApi";
|
||||
import { resolveCrealitycloudModel } from "./crealitycloudApi";
|
||||
import { resolveNexprintModel } from "./nexprintApi";
|
||||
import { getDisabledProviders } from "./providerSettingsService";
|
||||
|
||||
// Links models that only know their author by name: first by name matching, then by looking the
|
||||
@@ -34,6 +35,7 @@ const LOOKUP_PROVIDERS = [
|
||||
"snapmaker",
|
||||
"makeronline",
|
||||
"crealitycloud",
|
||||
"nexprint",
|
||||
] as const;
|
||||
type LookupProvider = (typeof LOOKUP_PROVIDERS)[number];
|
||||
|
||||
@@ -208,6 +210,10 @@ async function lookUpAuthor(
|
||||
await maybeSleep(IMPORT_COLLECTION_DELAY_MS);
|
||||
return (await resolveCrealitycloudModel(candidate.externalId))?.meta.author ?? null;
|
||||
}
|
||||
case "nexprint": {
|
||||
await maybeSleep(IMPORT_COLLECTION_DELAY_MS);
|
||||
return (await resolveNexprintModel(candidate.externalId))?.meta.author ?? null;
|
||||
}
|
||||
case "makerworld": {
|
||||
const cookie = await getUserMakerworldCookie(candidate.userId);
|
||||
const bearer = extractMakerworldBearerToken(cookie);
|
||||
|
||||
@@ -79,6 +79,7 @@ export async function seedDefaultCategories(tx: Prisma.TransactionClient, userId
|
||||
snapmakerCatIds: node.snapmakerCatIds ?? [],
|
||||
makeronlineCatIds: node.makeronlineCatIds ?? [],
|
||||
crealitycloudCatIds: node.crealitycloudCatIds ?? [],
|
||||
nexprintCatIds: node.nexprintCatIds ?? [],
|
||||
},
|
||||
});
|
||||
let i = 0;
|
||||
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
getUserCrealitycloudAuth,
|
||||
markCrealitycloudSessionRejected,
|
||||
} from "./crealitycloudSessionService";
|
||||
import { fetchNexprintCollection } from "./nexprintApi";
|
||||
import { notifyGrabImportsQueued, queueGrabImport } from "./grabImportService";
|
||||
import { getSnapmakerConnection, getUserSnapmakerToken, markSnapmakerTokenRejected } from "./snapmakerSessionService";
|
||||
import {
|
||||
@@ -172,6 +173,11 @@ export async function fetchListing(
|
||||
const listing = await fetchCrealitycloudCollection(id);
|
||||
return listing ? listing.entries.map((e) => e.modelId) : null;
|
||||
}
|
||||
if (sync.provider === "nexprint") {
|
||||
// Collections, the private ones too, list without a sign-in; only their files need one.
|
||||
const listing = await fetchNexprintCollection(id);
|
||||
return listing ? listing.entries.map((e) => e.modelId) : null;
|
||||
}
|
||||
throw new Error(`Unknown provider "${sync.provider}"`);
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
parseCrealitycloudLikesUrl,
|
||||
} from "./crealitycloudApi";
|
||||
import { getUserCrealitycloudId } from "./crealitycloudSessionService";
|
||||
import { nexprintCollectionUrl, nexprintModelUrl, parseNexprintCollectionUrl } from "./nexprintApi";
|
||||
import { createLog } from "./auditLog";
|
||||
import { pushedLikes } from "./providerListingsService";
|
||||
import { createNotification } from "./notificationService";
|
||||
@@ -55,7 +56,14 @@ import {
|
||||
} from "../dto";
|
||||
|
||||
export type SyncProvider =
|
||||
"makerworld" | "printables" | "thingiverse" | "myminifactory" | "snapmaker" | "makeronline" | "crealitycloud";
|
||||
| "makerworld"
|
||||
| "printables"
|
||||
| "thingiverse"
|
||||
| "myminifactory"
|
||||
| "snapmaker"
|
||||
| "makeronline"
|
||||
| "crealitycloud"
|
||||
| "nexprint";
|
||||
|
||||
/** `likesOf` is set for a user's likes (on Thingiverse, or the user's own on Printables), which a
|
||||
* sync follows like a collection. */
|
||||
@@ -69,12 +77,17 @@ const PROVIDER_NAMES: Record<SyncProvider, string> = {
|
||||
snapmaker: "Snapmaker Space",
|
||||
makeronline: "MakerOnline",
|
||||
crealitycloud: "Creality Cloud",
|
||||
nexprint: "NexPrint",
|
||||
};
|
||||
|
||||
/** Providers whose models come with print profiles a sync can pick between. */
|
||||
export function hasProfiles(provider: string): boolean {
|
||||
return (
|
||||
provider === "makerworld" || provider === "snapmaker" || provider === "makeronline" || provider === "crealitycloud"
|
||||
provider === "makerworld" ||
|
||||
provider === "snapmaker" ||
|
||||
provider === "makeronline" ||
|
||||
provider === "crealitycloud" ||
|
||||
provider === "nexprint"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -151,6 +164,11 @@ export function parseSyncSource(url: string): SyncSource | null {
|
||||
const { userId } = crealitycloudLikes;
|
||||
return { provider: "crealitycloud", externalId: crealitycloudLikesKey(userId), url: crealitycloudLikesUrl(userId) };
|
||||
}
|
||||
const nexprint = parseNexprintCollectionUrl(url);
|
||||
if (nexprint) {
|
||||
const { collectionId, ownerId } = nexprint;
|
||||
return { provider: "nexprint", externalId: collectionId, url: nexprintCollectionUrl(collectionId, ownerId) };
|
||||
}
|
||||
const printablesLikes = parsePrintablesLikesUrl(url);
|
||||
if (printablesLikes) {
|
||||
// Only Thingport Grab can read these, as their owner (see providerListingsService.ts).
|
||||
@@ -182,6 +200,7 @@ export function modelUrlFor(provider: string, externalId: string): string {
|
||||
if (provider === "snapmaker") return snapmakerModelUrl(externalId);
|
||||
if (provider === "makeronline") return makeronlineModelUrl(externalId);
|
||||
if (provider === "crealitycloud") return crealitycloudModelUrl(externalId);
|
||||
if (provider === "nexprint") return nexprintModelUrl(externalId);
|
||||
return `https://www.thingiverse.com/thing:${externalId}`;
|
||||
}
|
||||
|
||||
@@ -194,6 +213,7 @@ export function modelIdFromUrl(provider: string, url: string): string | null {
|
||||
snapmaker: /\/model\/(\d+)/,
|
||||
makeronline: /\/model\/(?:[^/#]+\/)?(\d+)/,
|
||||
crealitycloud: /\/model-detail\/([\w-]+)/,
|
||||
nexprint: /\/models\/(G\d+)/i,
|
||||
};
|
||||
const pattern = patterns[provider] ?? /thing:(\d+)/;
|
||||
return url.match(pattern)?.[1] ?? null;
|
||||
@@ -265,7 +285,7 @@ export async function enableSync(userId: string, input: EnableSyncInput): Promis
|
||||
if (!source)
|
||||
throw new HttpError(
|
||||
400,
|
||||
"Only MakerWorld, Printables, Thingiverse, MyMiniFactory, MakerOnline and Creality Cloud collections, Thingiverse or your own Printables, MakerOnline and Creality Cloud likes, and your own Snapmaker Space likes and bookmarks, can be synced",
|
||||
"Only MakerWorld, Printables, Thingiverse, MyMiniFactory, MakerOnline, Creality Cloud and NexPrint collections, Thingiverse or your own Printables, MakerOnline and Creality Cloud likes, and your own Snapmaker Space likes and bookmarks, can be synced",
|
||||
);
|
||||
if (source.provider === "printables" && source.likesOf) await assertOwnPrintablesLikes(userId, source.likesOf);
|
||||
if (source.provider === "snapmaker") await assertOwnSnapmakerList(userId, source.url);
|
||||
@@ -503,8 +523,8 @@ export async function disableSync(userId: string, collectionId: string): Promise
|
||||
}
|
||||
|
||||
/** Applies to checks and models found from now on; models already waiting in the import queue
|
||||
* keep the profile scope they were queued with. Only MakerWorld, Snapmaker, MakerOnline and Creality Cloud models
|
||||
* have print profiles. */
|
||||
* keep the profile scope they were queued with. Only MakerWorld, Snapmaker, MakerOnline, Creality Cloud and NexPrint
|
||||
* models have print profiles. */
|
||||
export async function updateSyncSettings(
|
||||
userId: string,
|
||||
collectionId: string,
|
||||
@@ -514,7 +534,7 @@ export async function updateSyncSettings(
|
||||
if (settings.profileScope && !hasProfiles(sync.provider)) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
"Only MakerWorld, Snapmaker Space, MakerOnline and Creality Cloud models have print profiles to pick",
|
||||
"Only MakerWorld, Snapmaker Space, MakerOnline, Creality Cloud and NexPrint models have print profiles to pick",
|
||||
);
|
||||
}
|
||||
const updated = await prisma.collectionSync.update({
|
||||
|
||||
@@ -23,6 +23,7 @@ const SOURCE_PROVIDERS = [
|
||||
"snapmaker",
|
||||
"makeronline",
|
||||
"crealitycloud",
|
||||
"nexprint",
|
||||
] as const;
|
||||
type SourceProvider = (typeof SOURCE_PROVIDERS)[number];
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import { MyminifactoryAuthError, MyminifactoryRateLimitError, resolveMyminifacto
|
||||
import { resolveSnapmakerModel } from "./snapmakerApi";
|
||||
import { resolveMakeronlineModel } from "./makeronlineApi";
|
||||
import { resolveCrealitycloudModel } from "./crealitycloudApi";
|
||||
import { resolveNexprintModel } from "./nexprintApi";
|
||||
import { getUserMakerworldCookie } from "./makerworldCookieService";
|
||||
import { upsertAuthorFromImport } from "./authorService";
|
||||
import { emptyGaps, openSourceGaps, type SourceGap } from "./sourceGaps";
|
||||
@@ -117,6 +118,12 @@ export async function fetchSourceMeta(userId: string, print: Print, sourceUrl: s
|
||||
return { ...emptyImportedPageMetadata(), ...resolved.meta, galleryImages: resolved.galleryImages };
|
||||
}
|
||||
|
||||
if (provider === "nexprint") {
|
||||
const resolved = await resolveNexprintModel(externalId);
|
||||
if (!resolved) throw new HttpError(404, "This NexPrint model could not be found, or isn't public.");
|
||||
return { ...emptyImportedPageMetadata(), ...resolved.meta, galleryImages: resolved.galleryImages };
|
||||
}
|
||||
|
||||
throw new HttpError(400, "This model has no source to fill its details from");
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
importPrintFromUrl,
|
||||
SNAPMAKER_SESSION_CODE,
|
||||
CREALITYCLOUD_SESSION_CODE,
|
||||
NEXPRINT_SESSION_CODE,
|
||||
type ImportRequestBody,
|
||||
} from "./importService";
|
||||
import {
|
||||
@@ -44,6 +45,8 @@ import {
|
||||
selectCrealitycloudProfiles,
|
||||
} from "./crealitycloudApi";
|
||||
import { CREALITYCLOUD_CONNECT, getUserCrealitycloudAuth } from "./crealitycloudSessionService";
|
||||
import { nexprintModelUrl, parseNexprintModelUrl, resolveNexprintModel, selectNexprintProfiles } from "./nexprintApi";
|
||||
import { getUserNexprintToken, NEXPRINT_CONNECT } from "./nexprintSessionService";
|
||||
import { queueGrabImport } from "./grabImportService";
|
||||
import { upsertAuthorFromImport } from "./authorService";
|
||||
import { extractZipEntriesToPrints } from "./zipService";
|
||||
@@ -83,6 +86,7 @@ type PrintablesCollectionImportJobBody = ImportRequestBody & {
|
||||
type MakerworldProfilesImportJobBody = ImportRequestBody & { scope: Exclude<MakerworldProfileScope, "url"> };
|
||||
type SnapmakerListImportJobBody = ImportRequestBody & { model_ids: string[]; list: SnapmakerList };
|
||||
type CrealitycloudListImportJobBody = ImportRequestBody & { model_ids: string[]; title: string };
|
||||
type NexprintCollectionImportJobBody = ImportRequestBody & { model_ids: string[]; title: string };
|
||||
|
||||
// Why a design failed, so a batch reads as one clear cause instead of "N failed". Once a CAPTCHA
|
||||
// ("rateLimited"), the daily download limit, an auth failure or a Cloudflare block ("blocked")
|
||||
@@ -102,7 +106,8 @@ function classifyImportFailure(err: unknown): ImportFailureReason {
|
||||
if (
|
||||
err.code === MAKERWORLD_AUTH_CODE ||
|
||||
err.code === SNAPMAKER_SESSION_CODE ||
|
||||
err.code === CREALITYCLOUD_SESSION_CODE
|
||||
err.code === CREALITYCLOUD_SESSION_CODE ||
|
||||
err.code === NEXPRINT_SESSION_CODE
|
||||
) {
|
||||
return "auth";
|
||||
}
|
||||
@@ -301,14 +306,33 @@ async function crealitycloudProfileUrls(
|
||||
};
|
||||
}
|
||||
|
||||
/** The print profiles a scope picks from a NexPrint model, as links that each name one. */
|
||||
async function nexprintProfileUrls(
|
||||
url: string,
|
||||
scope: Exclude<MakerworldProfileScope, "url">,
|
||||
): Promise<{ title: string | null; urls: string[] }> {
|
||||
const parsed = parseNexprintModelUrl(url);
|
||||
if (!parsed) throw new HttpError(400, "Not a NexPrint model link");
|
||||
const resolved = await resolveNexprintModel(parsed.modelId);
|
||||
if (!resolved) throw new HttpError(404, "This NexPrint model could not be found, or isn't public.");
|
||||
const profileIds = selectNexprintProfiles(resolved, scope, parsed.requestedProfileId);
|
||||
if (!profileIds.length) throw new HttpError(400, "This model has no print profiles to import");
|
||||
return {
|
||||
title: resolved.meta.title ?? null,
|
||||
urls: profileIds.map((profileId) => nexprintModelUrl(resolved.modelId, profileId)),
|
||||
};
|
||||
}
|
||||
|
||||
const SNAPMAKER_AUTH_TEXT =
|
||||
"your Snapmaker sign-in ran out — visit space.snapmaker.com with Thingport Grab, then retry";
|
||||
const CREALITYCLOUD_AUTH_TEXT =
|
||||
"Creality Cloud turned your sign-in down — visit crealitycloud.com signed in with Thingport Grab, then retry";
|
||||
const NEXPRINT_AUTH_TEXT =
|
||||
"NexPrint turned your sign-in down — visit nexprint.com signed in with Thingport Grab, then retry";
|
||||
|
||||
/** A provider whose print profiles import by link, each as a plate of one model. */
|
||||
type ProfilesProvider = {
|
||||
provider: "snapmaker" | "crealitycloud";
|
||||
provider: "snapmaker" | "crealitycloud" | "nexprint";
|
||||
name: string;
|
||||
profileUrls: typeof snapmakerProfileUrls;
|
||||
authText: string;
|
||||
@@ -328,6 +352,13 @@ const CREALITYCLOUD_PROFILES: ProfilesProvider = {
|
||||
authText: CREALITYCLOUD_AUTH_TEXT,
|
||||
};
|
||||
|
||||
const NEXPRINT_PROFILES: ProfilesProvider = {
|
||||
provider: "nexprint",
|
||||
name: "NexPrint",
|
||||
profileUrls: nexprintProfileUrls,
|
||||
authText: NEXPRINT_AUTH_TEXT,
|
||||
};
|
||||
|
||||
/** Imports several print profiles of one model as one model with a file per profile. */
|
||||
async function runProfilesImportJob(
|
||||
jobId: string,
|
||||
@@ -399,6 +430,14 @@ export function runCrealitycloudProfilesImportJob(
|
||||
return runProfilesImportJob(jobId, userId, CREALITYCLOUD_PROFILES, body);
|
||||
}
|
||||
|
||||
export function runNexprintProfilesImportJob(
|
||||
jobId: string,
|
||||
userId: string,
|
||||
body: MakerworldProfilesImportJobBody,
|
||||
): Promise<void> {
|
||||
return runProfilesImportJob(jobId, userId, NEXPRINT_PROFILES, body);
|
||||
}
|
||||
|
||||
export async function runCollectionImportJob(
|
||||
jobId: string,
|
||||
userId: string,
|
||||
@@ -546,7 +585,7 @@ export async function runCollectionImportJob(
|
||||
/** A provider whose models import one by one with a token: Thingiverse's and MyMiniFactory's instance-wide one, or
|
||||
* the user's own Snapmaker or Creality Cloud sign-in. */
|
||||
type TokenBatchProvider = {
|
||||
provider: "thingiverse" | "myminifactory" | "snapmaker" | "crealitycloud";
|
||||
provider: "thingiverse" | "myminifactory" | "snapmaker" | "crealitycloud" | "nexprint";
|
||||
getToken: () => Promise<string | null>;
|
||||
notConfigured: string;
|
||||
modelUrl: (id: string) => string;
|
||||
@@ -597,8 +636,19 @@ function crealitycloudBatch(userId: string): TokenBatchProvider {
|
||||
};
|
||||
}
|
||||
|
||||
function nexprintBatch(userId: string): TokenBatchProvider {
|
||||
return {
|
||||
provider: "nexprint",
|
||||
getToken: () => getUserNexprintToken(userId),
|
||||
notConfigured: NEXPRINT_CONNECT,
|
||||
modelUrl: (id) => nexprintModelUrl(id),
|
||||
rateLimitText: "blocked by NexPrint's rate limit — wait a while, then retry",
|
||||
authText: `failed because ${NEXPRINT_AUTH_TEXT}`,
|
||||
};
|
||||
}
|
||||
|
||||
/** Shared by the Thingiverse Likes and Collection imports, MyMiniFactory's collections, Snapmaker's likes and
|
||||
* bookmarks, and Creality Cloud's likes and collections.
|
||||
* bookmarks, Creality Cloud's likes and collections, and NexPrint's collections.
|
||||
* Successful imports are filed into the collection named by `resolveCollectionTitle`, which runs
|
||||
* afterwards so it costs no API call when nothing was imported. */
|
||||
async function runTokenBatchImportJob(
|
||||
@@ -802,6 +852,23 @@ export async function runCrealitycloudListImportJob(
|
||||
);
|
||||
}
|
||||
|
||||
/** A NexPrint collection, filed into a collection named after it. */
|
||||
export async function runNexprintCollectionImportJob(
|
||||
jobId: string,
|
||||
userId: string,
|
||||
body: NexprintCollectionImportJobBody,
|
||||
): Promise<void> {
|
||||
await runTokenBatchImportJob(
|
||||
jobId,
|
||||
userId,
|
||||
nexprintBatch(userId),
|
||||
body,
|
||||
body.model_ids,
|
||||
async () => body.title,
|
||||
(collectionTitle) => `"${collectionTitle}"`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function runPrintablesCollectionImportJob(
|
||||
jobId: string,
|
||||
userId: string,
|
||||
@@ -1011,6 +1078,7 @@ const LINKS_RETRY_DELAY_MS = 2000;
|
||||
function authStopText(url: string): string {
|
||||
if (parseSnapmakerModelUrl(url)) return SNAPMAKER_AUTH_TEXT;
|
||||
if (parseCrealitycloudModelUrl(url)) return CREALITYCLOUD_AUTH_TEXT;
|
||||
if (parseNexprintModelUrl(url)) return NEXPRINT_AUTH_TEXT;
|
||||
return "the MakerWorld session expired — update the cookie in Settings, then retry";
|
||||
}
|
||||
|
||||
@@ -1023,7 +1091,7 @@ function failureMessage(err: unknown): string {
|
||||
return err instanceof Error && err.message.trim() ? err.message.trim() : "Import failed";
|
||||
}
|
||||
|
||||
/** One queued link. A MakerWorld, Snapmaker or Creality Cloud model with a multi-profile scope imports each wanted profile onto
|
||||
/** One queued link. A MakerWorld, Snapmaker, Creality Cloud or NexPrint model with a multi-profile scope imports each wanted profile onto
|
||||
* one print, like the PROFILES job; anything else is a plain single import. `profiles` marks the
|
||||
* former, whose totals count print profiles rather than models. */
|
||||
async function importOneLink(
|
||||
@@ -1056,6 +1124,10 @@ async function importOneLink(
|
||||
const { urls } = await crealitycloudProfileUrls(url, body.scope);
|
||||
return { ...(await importProfileUrlsSequentially(userId, urls, { ...body, url })), profiles: true };
|
||||
}
|
||||
if (parseNexprintModelUrl(url) && body.scope && body.scope !== "url") {
|
||||
const { urls } = await nexprintProfileUrls(url, body.scope);
|
||||
return { ...(await importProfileUrlsSequentially(userId, urls, { ...body, url })), profiles: true };
|
||||
}
|
||||
const result = await importPrintFromUrl(userId, url, { ...body, url });
|
||||
return {
|
||||
processed: 1,
|
||||
|
||||
@@ -498,6 +498,7 @@ export type ImportedPageMetadata = {
|
||||
| "snapmaker"
|
||||
| "makeronline"
|
||||
| "crealitycloud"
|
||||
| "nexprint"
|
||||
| null;
|
||||
/** Lets a second profile of an imported design be added as its own plate. MakerWorld only. */
|
||||
makerworldProfile?: MakerworldProfileRef;
|
||||
|
||||
@@ -112,6 +112,18 @@ import {
|
||||
getUserCrealitycloudAuth,
|
||||
markCrealitycloudSessionRejected,
|
||||
} from "./crealitycloudSessionService";
|
||||
import {
|
||||
fetchNexprintModelFiles,
|
||||
fetchNexprintProfileFile,
|
||||
isNexprintFileHost,
|
||||
NexprintAuthError,
|
||||
type NexprintFile,
|
||||
type NexprintModelResolution,
|
||||
parseNexprintModelUrl,
|
||||
resolveNexprintModel,
|
||||
selectNexprintProfiles,
|
||||
} from "./nexprintApi";
|
||||
import { getUserNexprintToken, markNexprintSessionRejected, NEXPRINT_CONNECT } from "./nexprintSessionService";
|
||||
|
||||
/** An import refused because the user's MyMiniFactory session has expired. */
|
||||
export const MYMINIFACTORY_SESSION_CODE = "myminifactory_session";
|
||||
@@ -119,6 +131,8 @@ export const MYMINIFACTORY_SESSION_CODE = "myminifactory_session";
|
||||
export const SNAPMAKER_SESSION_CODE = "snapmaker_session";
|
||||
/** An import refused because the user hasn't connected Creality Cloud, or it turned their sign-in down. */
|
||||
export const CREALITYCLOUD_SESSION_CODE = "crealitycloud_session";
|
||||
/** An import refused because the user hasn't connected NexPrint, or it turned their sign-in down. */
|
||||
export const NEXPRINT_SESSION_CODE = "nexprint_session";
|
||||
/** A MakerOnline import that came without the signed file links only Thingport Grab can get. */
|
||||
export const MAKERONLINE_GRAB_CODE = "makeronline_grab";
|
||||
export const MAKERONLINE_NEEDS_GRAB =
|
||||
@@ -613,6 +627,7 @@ const CATEGORY_SITE_CAT_IDS_FIELD = {
|
||||
snapmaker: "snapmakerCatIds",
|
||||
makeronline: "makeronlineCatIds",
|
||||
crealitycloud: "crealitycloudCatIds",
|
||||
nexprint: "nexprintCatIds",
|
||||
} as const;
|
||||
|
||||
/** Auto-categorizes an import when a category's `*CatIds` for this site overlap the model's own
|
||||
@@ -626,15 +641,21 @@ export async function resolveCategoryIdByCategory(
|
||||
): Promise<string | null> {
|
||||
if (!categorySite || !siteCategoryIds.length) return null;
|
||||
const field = CATEGORY_SITE_CAT_IDS_FIELD[categorySite];
|
||||
// NexPrint's column holds text (its ids are past Int); compare everything as text so one path serves all.
|
||||
const wanted = siteCategoryIds.map(String);
|
||||
const candidates = await prisma.category.findMany({
|
||||
// Folders are the user's own tree; only categories take imports by site category id.
|
||||
where: { userId, kind: "category", [field]: { hasSome: siteCategoryIds } },
|
||||
where: {
|
||||
userId,
|
||||
kind: "category",
|
||||
[field]: { hasSome: field === "nexprintCatIds" ? wanted : siteCategoryIds },
|
||||
},
|
||||
orderBy: { position: "asc" },
|
||||
});
|
||||
const rank = (category: (typeof candidates)[number]) => {
|
||||
const ids = category[field];
|
||||
const index = siteCategoryIds.findIndex((id) => ids.includes(id));
|
||||
return index === -1 ? siteCategoryIds.length : index;
|
||||
const ids = new Set((category[field] as (number | string)[]).map(String));
|
||||
const index = wanted.findIndex((id) => ids.has(id));
|
||||
return index === -1 ? wanted.length : index;
|
||||
};
|
||||
// Stable, so equally specific matches keep their position order.
|
||||
return candidates.toSorted((a, b) => rank(a) - rank(b))[0]?.id ?? null;
|
||||
@@ -656,15 +677,18 @@ export function identifySourceModel(url: string): { provider: string; externalId
|
||||
if (makeronline) return { provider: "makeronline", externalId: makeronline.modelId };
|
||||
const crealitycloud = parseCrealitycloudModelUrl(url);
|
||||
if (crealitycloud) return { provider: "crealitycloud", externalId: crealitycloud.modelId };
|
||||
const nexprint = parseNexprintModelUrl(url);
|
||||
if (nexprint) return { provider: "nexprint", externalId: nexprint.modelId };
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The print profile a MakerWorld, Snapmaker, MakerOnline or Creality Cloud link names, if any. */
|
||||
/** The print profile a MakerWorld, Snapmaker, MakerOnline, Creality Cloud or NexPrint link names, if any. */
|
||||
function requestedProfileOf(provider: string, url: string): string | null {
|
||||
if (provider === "makerworld") return parseMakerworldModelUrl(url)?.requestedInstanceId ?? null;
|
||||
if (provider === "snapmaker") return parseSnapmakerModelUrl(url)?.requestedProfileId ?? null;
|
||||
if (provider === "makeronline") return parseMakeronlineModelUrl(url)?.requestedProfileId ?? null;
|
||||
if (provider === "crealitycloud") return parseCrealitycloudModelUrl(url)?.requestedProfileId ?? null;
|
||||
if (provider === "nexprint") return parseNexprintModelUrl(url)?.requestedProfileId ?? null;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -712,6 +736,7 @@ export function buildImportSourceUrl(provider: string | null, externalId: string
|
||||
if (provider === "snapmaker") return `https://space.snapmaker.com/en/model/${externalId}`;
|
||||
if (provider === "makeronline") return `https://www.makeronline.com/en/model/${externalId}`;
|
||||
if (provider === "crealitycloud") return `https://www.crealitycloud.com/model-detail/${externalId}`;
|
||||
if (provider === "nexprint") return `https://www.nexprint.com/en/models/${externalId}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1580,6 +1605,111 @@ async function addCrealitycloudProfileToPrint(
|
||||
return addProfilePlate(existing, profile, wanted);
|
||||
}
|
||||
|
||||
const NEXPRINT_BLOCKED =
|
||||
"NexPrint blocked the file download (rate limit). This usually clears after a while -- wait, then retry the same import.";
|
||||
const NEXPRINT_MISSING = "This NexPrint model could not be found, or isn't public.";
|
||||
|
||||
function downloadNexprintFile(file: NexprintFile): Promise<NewPlateInput[] | null> {
|
||||
return downloadSignedFile(file, isNexprintFileHost, NEXPRINT_BLOCKED);
|
||||
}
|
||||
|
||||
/** The user's token, or the HttpError that says to connect NexPrint. */
|
||||
async function requireNexprintToken(userId: string): Promise<string> {
|
||||
const token = await getUserNexprintToken(userId);
|
||||
if (!token) throw new HttpError(400, NEXPRINT_CONNECT, NEXPRINT_SESSION_CODE);
|
||||
return token;
|
||||
}
|
||||
|
||||
/** NexPrint's errors as the HttpErrors an import reports; a rejected sign-in is remembered, so the app can say so. */
|
||||
async function nexprintFailure(userId: string, err: unknown): Promise<unknown> {
|
||||
if (err instanceof NexprintAuthError) {
|
||||
await markNexprintSessionRejected(userId);
|
||||
// 400, not 401: see tryMakerworldCloudApi.
|
||||
return new HttpError(400, err.message, NEXPRINT_SESSION_CODE);
|
||||
}
|
||||
return err;
|
||||
}
|
||||
|
||||
/** Downloads one print profile's 3MF, tagged with the profile; null when the model has no such profile. */
|
||||
async function downloadNexprintProfile(
|
||||
resolved: NexprintModelResolution,
|
||||
profileId: string,
|
||||
token: string,
|
||||
): Promise<NewPlateInput | null> {
|
||||
const profile = resolved.profiles.find((p) => p.id === profileId);
|
||||
const file = profile ? await fetchNexprintProfileFile(profile, token) : null;
|
||||
if (!file) return null;
|
||||
const inputs = await downloadNexprintFile(file);
|
||||
if (!inputs?.length) throw new HttpError(400, "Couldn't download this print profile from NexPrint. Try again later.");
|
||||
// A profile is one 3MF; anything else it unpacked to is dropped.
|
||||
await Promise.all(inputs.slice(1).map((input) => fs.rm(input.tempFilePath!, { force: true }).catch(() => undefined)));
|
||||
return { ...inputs[0], sourceInstanceId: profileId };
|
||||
}
|
||||
|
||||
/** Like Creality Cloud: a model is one print, and each print profile its 3MF on a plate of its own, tagged with the
|
||||
* profile. An import brings the profile the link names, else the designer's first; a model without profiles brings
|
||||
* its model files instead. Files only download as the user, so everything needs their NexPrint sign-in. */
|
||||
async function importNexprintModel(
|
||||
userId: string,
|
||||
source: { provider: string; externalId: string },
|
||||
url: string,
|
||||
body: ImportRequestBody,
|
||||
): Promise<ImportResult> {
|
||||
const token = await requireNexprintToken(userId);
|
||||
let resolved: NexprintModelResolution | null;
|
||||
try {
|
||||
resolved = await resolveNexprintModel(source.externalId);
|
||||
} catch (err) {
|
||||
throw await nexprintFailure(userId, err);
|
||||
}
|
||||
if (!resolved) throw new HttpError(404, NEXPRINT_MISSING);
|
||||
const downloaded: NewPlateInput[] = [];
|
||||
try {
|
||||
const requested = body.resolved_instance_id ?? parseNexprintModelUrl(url)?.requestedProfileId ?? null;
|
||||
const [profileId] = selectNexprintProfiles(resolved, "url", requested);
|
||||
if (profileId) {
|
||||
const profile = await downloadNexprintProfile(resolved, profileId, token);
|
||||
if (profile) downloaded.push(profile);
|
||||
}
|
||||
if (!downloaded.length) {
|
||||
for (const file of await fetchNexprintModelFiles(resolved, token)) {
|
||||
downloaded.push(...((await downloadNexprintFile(file)) ?? []));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
await Promise.all(downloaded.map((input) => fs.rm(input.tempFilePath!, { force: true }).catch(() => undefined)));
|
||||
throw await nexprintFailure(userId, err);
|
||||
}
|
||||
if (!downloaded.length) throw new HttpError(400, "This NexPrint model has no files Thingport can import.");
|
||||
|
||||
return createSourcedPrint(userId, source, body, resolved, downloaded, `nexprint-${source.externalId}`);
|
||||
}
|
||||
|
||||
/** Re-import of a NexPrint model naming a profile it doesn't have yet: that profile's 3MF becomes another plate. A
|
||||
* link without a profile, or with one already there, needs no download. */
|
||||
async function addNexprintProfileToPrint(
|
||||
existing: ExistingImportedPrint,
|
||||
url: string,
|
||||
body: ImportRequestBody,
|
||||
): Promise<ImportResult> {
|
||||
const wanted = body.resolved_instance_id ?? parseNexprintModelUrl(url)?.requestedProfileId ?? null;
|
||||
if (!wanted || existing.plates.some((plate) => plate.sourceInstanceId === wanted)) {
|
||||
return { ...existing, alreadyImported: true };
|
||||
}
|
||||
const userId = existing.print.userId;
|
||||
const token = await requireNexprintToken(userId);
|
||||
let profile: NewPlateInput | null;
|
||||
try {
|
||||
const model = await resolveNexprintModel(existing.print.sourceExternalId!);
|
||||
if (!model) throw new HttpError(404, NEXPRINT_MISSING);
|
||||
profile = await downloadNexprintProfile(model, wanted, token);
|
||||
} catch (err) {
|
||||
throw await nexprintFailure(userId, err);
|
||||
}
|
||||
if (!profile) throw new HttpError(404, "This NexPrint model has no such print profile.");
|
||||
return addProfilePlate(existing, profile, wanted);
|
||||
}
|
||||
|
||||
const MAKERONLINE_BLOCKED =
|
||||
"MakerOnline blocked the file download (rate limit). This usually clears after a while -- wait, then try again.";
|
||||
|
||||
@@ -1689,10 +1819,10 @@ export async function importPrintFromUrl(
|
||||
}
|
||||
|
||||
/** Providers whose re-import of a model may add a print profile, so fetches. */
|
||||
const PROFILE_PROVIDERS = new Set(["makerworld", "snapmaker", "makeronline", "crealitycloud"]);
|
||||
const PROFILE_PROVIDERS = new Set(["makerworld", "snapmaker", "makeronline", "crealitycloud", "nexprint"]);
|
||||
|
||||
/** Refuses a link from a provider an admin turned off, unless it's a model the user already has (MakerWorld, Snapmaker,
|
||||
* MakerOnline and Creality Cloud add a profile, so fetch). */
|
||||
* MakerOnline, Creality Cloud and NexPrint add a profile, so fetch). */
|
||||
export async function assertCanImportUrl(userId: string, url: string): Promise<void> {
|
||||
const source = identifySourceModel(url);
|
||||
if (source && !PROFILE_PROVIDERS.has(source.provider) && (await findExistingImportedPrint(userId, source))) return;
|
||||
@@ -1709,6 +1839,7 @@ async function importNewOrExisting(userId: string, url: string, body: ImportRequ
|
||||
if (existing && source?.provider === "snapmaker") return addSnapmakerProfileToPrint(existing, url, body);
|
||||
if (existing && source?.provider === "makeronline") return addMakeronlineProfilesToPrint(existing, body);
|
||||
if (existing && source?.provider === "crealitycloud") return addCrealitycloudProfileToPrint(existing, url, body);
|
||||
if (existing && source?.provider === "nexprint") return addNexprintProfileToPrint(existing, url, body);
|
||||
if (existing) return addMakerworldProfileToPrint(existing, url, body);
|
||||
|
||||
if (source?.provider === "thingiverse") {
|
||||
@@ -1729,6 +1860,9 @@ async function importNewOrExisting(userId: string, url: string, body: ImportRequ
|
||||
if (source?.provider === "crealitycloud") {
|
||||
return importCrealitycloudModel(userId, source, url, body);
|
||||
}
|
||||
if (source?.provider === "nexprint") {
|
||||
return importNexprintModel(userId, source, url, body);
|
||||
}
|
||||
|
||||
const { tempPath, filename, mime, meta } = await downloadImportToTemp(url, body);
|
||||
const author = await upsertAuthorFromImport(meta.author);
|
||||
|
||||
@@ -0,0 +1,472 @@
|
||||
import { IMPORT_TIMEOUT_SECONDS } from "../config";
|
||||
import type { ImportedAuthorInfo, ImportedPageMetadata } from "./importResolvers";
|
||||
import { htmlToMarkdown } from "./descriptionMarkdown";
|
||||
import type { ProviderCollectionCard } from "./providerCollectionCard";
|
||||
|
||||
// NexPrint (Elegoo's model site) has no public API, so this is the one its own pages call, under /gateway: answers are
|
||||
// `{code, msg, data}`, code 0 when it went well and 401 for a missing or refused sign-in. Details, categories and
|
||||
// collections, private ones included, need no sign-in; file downloads and the user's own collection list need their
|
||||
// token, sent as a Bearer token.
|
||||
const SITE_ORIGIN = "https://www.nexprint.com";
|
||||
const API_BASE = `${SITE_ORIGIN}/gateway/api/v1`;
|
||||
const API_TIMEOUT_MS = IMPORT_TIMEOUT_SECONDS * 1000;
|
||||
const PROVIDER = "nexprint";
|
||||
const USER_AGENT =
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36";
|
||||
|
||||
const CODE_OK = 0;
|
||||
const CODE_UNAUTHORIZED = 401;
|
||||
|
||||
// The kinds of file presigned-download-url signs: a model's own file, and a print profile's 3MF.
|
||||
const FILE_TYPE_MODEL = 2;
|
||||
const FILE_TYPE_PROFILE = 5;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function str(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
/** NexPrint's internal ids are 19-digit numbers, past what a JS number holds, so they always come as strings. */
|
||||
function id(value: unknown): string | null {
|
||||
if (typeof value === "number" && Number.isSafeInteger(value)) return String(value);
|
||||
return typeof value === "string" && /^\d+$/.test(value) ? value : null;
|
||||
}
|
||||
|
||||
/** The ids its pages show: a "G" and digits for models, profiles and collections, a "U" and digits for users. */
|
||||
function outId(value: unknown, prefix: "G" | "U"): string | null {
|
||||
const text = str(value)?.toUpperCase();
|
||||
return text && new RegExp(`^${prefix}\\d+$`).test(text) ? text : null;
|
||||
}
|
||||
|
||||
function count(value: unknown): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
export class NexprintAuthError extends Error {
|
||||
constructor() {
|
||||
super(
|
||||
"NexPrint didn't accept your sign-in; it may have expired. Visit nexprint.com with Thingport Grab, " +
|
||||
"or connect it again in Configuration > Providers.",
|
||||
);
|
||||
this.name = "NexprintAuthError";
|
||||
}
|
||||
}
|
||||
|
||||
function parseUrl(url: string): URL | null {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return /^(www\.)?nexprint\.com$/i.test(parsed.hostname) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const LANG = String.raw`(?:[a-z]{2}(?:-[a-z]{2,4})?/)?`;
|
||||
const MODEL_PATH = new RegExp(String.raw`^/${LANG}models/(G\d+)/?$`, "i");
|
||||
const COLLECTION_PATH = new RegExp(String.raw`^/${LANG}collection/(G\d+)/?$`, "i");
|
||||
const USER_HOME_PATH = new RegExp(String.raw`^/${LANG}(U\d+)/home/?$`, "i");
|
||||
|
||||
/** `/en/models/<id>`; `?printConfigId=<id>` names a print profile, as the site's own links do. Thingport's
|
||||
* `#profileId-<id>` names one too, and wins. */
|
||||
export function parseNexprintModelUrl(url: string): { modelId: string; requestedProfileId: string | null } | null {
|
||||
const parsed = parseUrl(url);
|
||||
const m = parsed?.pathname.match(MODEL_PATH);
|
||||
if (!m) return null;
|
||||
const fromHash = parsed!.hash.match(/profileId-(G\d+)/i)?.[1];
|
||||
return {
|
||||
modelId: m[1].toUpperCase(),
|
||||
requestedProfileId: outId(fromHash ?? parsed!.searchParams.get("printConfigId"), "G"),
|
||||
};
|
||||
}
|
||||
|
||||
export function nexprintModelUrl(modelId: string, profileId?: string | null): string {
|
||||
return `${SITE_ORIGIN}/en/models/${modelId}${profileId ? `?printConfigId=${profileId}` : ""}`;
|
||||
}
|
||||
|
||||
/** A collection: `/en/collection/<id>`, or as its owner's page shows it, `/en/<user>/home?...&collectionId=<id>`. */
|
||||
export function parseNexprintCollectionUrl(url: string): { collectionId: string; ownerId: string | null } | null {
|
||||
const parsed = parseUrl(url);
|
||||
if (!parsed) return null;
|
||||
const direct = parsed.pathname.match(COLLECTION_PATH);
|
||||
if (direct) return { collectionId: direct[1].toUpperCase(), ownerId: null };
|
||||
const home = parsed.pathname.match(USER_HOME_PATH);
|
||||
const collectionId = home ? outId(parsed.searchParams.get("collectionId"), "G") : null;
|
||||
return collectionId ? { collectionId, ownerId: home![1].toUpperCase() } : null;
|
||||
}
|
||||
|
||||
/** On its owner's page when the owner is known, the way the site links to it; else its own address. */
|
||||
export function nexprintCollectionUrl(collectionId: string, ownerId?: string | null): string {
|
||||
return ownerId
|
||||
? `${SITE_ORIGIN}/en/${ownerId}/home?component=UserCollect&subComp=UserCollectDetail&collectionId=${collectionId}`
|
||||
: `${SITE_ORIGIN}/en/collection/${collectionId}`;
|
||||
}
|
||||
|
||||
function headers(token: string | null, json: boolean): Record<string, string> {
|
||||
return {
|
||||
Accept: "application/json",
|
||||
"User-Agent": USER_AGENT,
|
||||
"client-id": "Nexprint",
|
||||
"user-lang": "en",
|
||||
...(json ? { "Content-Type": "application/json" } : {}),
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchApi(
|
||||
path: string,
|
||||
{ query, body }: { query?: Record<string, string | number>; body?: unknown },
|
||||
token: string | null,
|
||||
): Promise<{ code: number | null; message: string | null; data: unknown }> {
|
||||
const url = new URL(`${API_BASE}${path}`);
|
||||
for (const [key, value] of Object.entries(query ?? {})) url.searchParams.set(key, String(value));
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), API_TIMEOUT_MS);
|
||||
let json: unknown;
|
||||
let status: number;
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: body === undefined ? "GET" : "POST",
|
||||
headers: headers(token, body !== undefined),
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
status = res.status;
|
||||
json = await res.json().catch(() => null);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
const code = isRecord(json) && typeof json.code === "number" ? json.code : null;
|
||||
if (token && (code === CODE_UNAUTHORIZED || status === 401)) throw new NexprintAuthError();
|
||||
return { code, message: isRecord(json) ? str(json.msg) : null, data: isRecord(json) ? json.data : null };
|
||||
}
|
||||
|
||||
function failure(what: string, code: number | null, message: string | null): Error {
|
||||
return new Error(`Couldn't read ${what} on NexPrint${message ? `: ${message}` : ` (code ${code})`}`);
|
||||
}
|
||||
|
||||
/** Who the token belongs to, or "rejected"/"unreachable". */
|
||||
export async function checkNexprintAuth(
|
||||
token: string,
|
||||
): Promise<{ userId: string; name: string | null } | "rejected" | "unreachable"> {
|
||||
try {
|
||||
const { code, data } = await fetchApi("/model-user-server/member/user_data", { body: {} }, token);
|
||||
const user = isRecord(data) && isRecord(data.userData) ? data.userData : null;
|
||||
if (code !== CODE_OK || !user || !outId(user.userId, "U")) return code === null ? "unreachable" : "rejected";
|
||||
return { userId: outId(user.userId, "U")!, name: str(user.nickname) };
|
||||
} catch (err) {
|
||||
return err instanceof NexprintAuthError ? "rejected" : "unreachable";
|
||||
}
|
||||
}
|
||||
|
||||
export type NexprintProfile = {
|
||||
/** Its "G" id, the one the site's links carry. */
|
||||
id: string;
|
||||
/** Its internal id, which signs its file. */
|
||||
settingId: string;
|
||||
fileId: string;
|
||||
title: string;
|
||||
designers: boolean;
|
||||
};
|
||||
|
||||
export type NexprintModelFile = { fileId: string; name: string };
|
||||
|
||||
export type NexprintModelResolution = {
|
||||
meta: Partial<ImportedPageMetadata>;
|
||||
galleryImages: { url: string; filename: string }[];
|
||||
/** Its "G" id, the one Thingport keeps for it. */
|
||||
modelId: string;
|
||||
/** Its internal id, which signs its files. */
|
||||
internalId: string;
|
||||
profiles: NexprintProfile[];
|
||||
/** The model's own files that aren't a print profile's 3MF. */
|
||||
files: NexprintModelFile[];
|
||||
};
|
||||
|
||||
function authorOf(author: unknown): ImportedAuthorInfo | null {
|
||||
if (!isRecord(author)) return null;
|
||||
const userId = outId(author.userId, "U");
|
||||
const name = str(author.nickname);
|
||||
if (!userId || !name) return null;
|
||||
return {
|
||||
provider: PROVIDER,
|
||||
externalId: userId,
|
||||
name,
|
||||
handle: null,
|
||||
bio: null,
|
||||
bioTranslated: null,
|
||||
links: [`${SITE_ORIGIN}/en/${userId}/home`],
|
||||
avatarUrl: str(author.avatarUrl),
|
||||
backgroundUrl: null,
|
||||
};
|
||||
}
|
||||
|
||||
function imageName(url: string, index: number): string {
|
||||
return url.split("/").pop()?.split("?")[0] || `image-${index}.jpg`;
|
||||
}
|
||||
|
||||
function records(value: unknown): Record<string, unknown>[] {
|
||||
return Array.isArray(value) ? value.filter(isRecord) : [];
|
||||
}
|
||||
|
||||
/** The metadata mapping, apart from the fetch so it can be tested on its own. */
|
||||
export function nexprintMetaFromDetail(data: Record<string, unknown>): NexprintModelResolution | null {
|
||||
const modelId = outId(data.modelId, "G");
|
||||
const internalId = id(data.id);
|
||||
if (!modelId || !internalId) return null;
|
||||
const meta: Partial<ImportedPageMetadata> = {};
|
||||
const title = str(data.modelName);
|
||||
if (title) meta.title = title;
|
||||
const description = str(data.modelDetail);
|
||||
if (description) {
|
||||
const markdown = htmlToMarkdown(description)?.trim();
|
||||
if (markdown) meta.description = markdown;
|
||||
}
|
||||
const tags = [...new Set((Array.isArray(data.modelTagList) ? data.modelTagList : []).flatMap((t) => str(t) ?? []))];
|
||||
if (tags.length) meta.tags = tags;
|
||||
const images = [
|
||||
...new Set([str(data.coverImgUrl), ...records(data.modelPicList).map((pic) => str(pic.fileUrl))].filter(Boolean)),
|
||||
] as string[];
|
||||
if (images[0]) meta.previewImageUrl = images[0];
|
||||
const author = authorOf(data.author);
|
||||
if (author) {
|
||||
meta.author = author;
|
||||
meta.creator = author.name;
|
||||
}
|
||||
// The model's own category, then its parents, so a subcategory beats its parent.
|
||||
const categories = records(data.classificationInfos).toSorted((a, b) => count(b.level) - count(a.level));
|
||||
const categoryIds = [
|
||||
...new Set(
|
||||
[data.classificationId, ...categories.map((c) => c.id)]
|
||||
.map((value) => Number(value))
|
||||
.filter((value) => Number.isSafeInteger(value) && value > 0),
|
||||
),
|
||||
];
|
||||
if (categoryIds.length) {
|
||||
meta.siteCategoryIds = categoryIds;
|
||||
meta.categorySite = PROVIDER;
|
||||
}
|
||||
const authorId = id(data.authorId);
|
||||
const profiles = records(data.settingInfoList).flatMap((setting): NexprintProfile[] => {
|
||||
const profileId = outId(setting.settingOutId, "G");
|
||||
const settingId = id(setting.id);
|
||||
const fileId = isRecord(setting.settingFile) ? id(setting.settingFile.fileId) : null;
|
||||
if (!profileId || !settingId || !fileId || setting.open === false || setting.isInvalid === true) return [];
|
||||
return [
|
||||
{
|
||||
id: profileId,
|
||||
settingId,
|
||||
fileId,
|
||||
title: str(setting.settingName) ?? `Profile ${profileId}`,
|
||||
designers: setting.isCreator === true || (!!authorId && id(setting.userId) === authorId),
|
||||
},
|
||||
];
|
||||
});
|
||||
// The model's file list repeats each profile's 3MF; those come with their profile instead.
|
||||
const profileFiles = new Set(profiles.map((profile) => profile.fileId));
|
||||
const files = new Map<string, NexprintModelFile>();
|
||||
for (const file of records(data.modelFileList)) {
|
||||
const fileId = id(file.fileId);
|
||||
if (fileId && !profileFiles.has(fileId) && !files.has(fileId)) {
|
||||
files.set(fileId, { fileId, name: str(file.fileName) ?? `nexprint-${modelId}-${files.size}` });
|
||||
}
|
||||
}
|
||||
return {
|
||||
meta,
|
||||
galleryImages: images.map((url, index) => ({ url, filename: imageName(url, index) })),
|
||||
modelId,
|
||||
internalId,
|
||||
profiles,
|
||||
files: [...files.values()],
|
||||
};
|
||||
}
|
||||
|
||||
/** By its "G" id. Null for a model that doesn't exist, was deleted or isn't public. */
|
||||
export async function resolveNexprintModel(modelId: string): Promise<NexprintModelResolution | null> {
|
||||
const { code, message, data } = await fetchApi(
|
||||
"/model-library-server/model-base-info/get",
|
||||
{ query: { id: modelId, loadGeneratorInfo: "true", loadSupportPrintProfile: "true" } },
|
||||
null,
|
||||
);
|
||||
if (code !== CODE_OK) throw failure("this model", code, message);
|
||||
// A model that isn't there comes back as one with every field null.
|
||||
return isRecord(data) ? nexprintMetaFromDetail(data) : null;
|
||||
}
|
||||
|
||||
export type NexprintProfileScope = "url" | "designer" | "all";
|
||||
|
||||
/** Which profiles an import brings, as for MakerWorld: "url" the one the link names, else the designer's first (else
|
||||
* the first); "designer" every one the designer made; "all" every one. */
|
||||
export function selectNexprintProfiles(
|
||||
resolution: Pick<NexprintModelResolution, "profiles">,
|
||||
scope: NexprintProfileScope,
|
||||
requestedProfileId: string | null,
|
||||
): string[] {
|
||||
const { profiles } = resolution;
|
||||
const designers = profiles.filter((profile) => profile.designers);
|
||||
if (scope === "all") return profiles.map((profile) => profile.id);
|
||||
if (scope === "designer") return (designers.length ? designers : profiles.slice(0, 1)).map((profile) => profile.id);
|
||||
const requested = requestedProfileId && profiles.find((profile) => profile.id === requestedProfileId);
|
||||
const pick = requested || designers[0] || profiles[0];
|
||||
return pick ? [pick.id] : [];
|
||||
}
|
||||
|
||||
export type NexprintFile = { name: string; url: string };
|
||||
|
||||
/** The extension a signed link's file has, when its name lacks one. */
|
||||
function withExtension(name: string, url: string): string {
|
||||
let ext = "";
|
||||
try {
|
||||
ext = decodeURIComponent(new URL(url).pathname).match(/\.[a-z0-9]{2,5}$/i)?.[0] ?? "";
|
||||
} catch {}
|
||||
return ext && !name.toLowerCase().endsWith(ext.toLowerCase()) ? `${name}${ext}` : name;
|
||||
}
|
||||
|
||||
/** Signs files for the signed-in user, all in one call. Throws NexprintAuthError for a token NexPrint doesn't take. */
|
||||
async function signFiles(
|
||||
files: { fileId: string; type: number; bizId: string; name: string }[],
|
||||
token: string,
|
||||
): Promise<NexprintFile[]> {
|
||||
if (!files.length) return [];
|
||||
const { code, message, data } = await fetchApi(
|
||||
"/model-library-server/common/presigned-download-url",
|
||||
{ body: { fileInfoList: files.map((f) => ({ fileId: f.fileId, type: String(f.type), bizId: f.bizId })) } },
|
||||
token,
|
||||
);
|
||||
if (code !== CODE_OK || !isRecord(data)) throw failure("this model's files", code, message);
|
||||
const urls = new Map(
|
||||
records(data.fileInfoList).flatMap((f) =>
|
||||
id(f.fileId) && str(f.resultUrl) ? [[id(f.fileId)!, str(f.resultUrl)!]] : [],
|
||||
),
|
||||
);
|
||||
return files.flatMap((file) => {
|
||||
const url = urls.get(file.fileId);
|
||||
return url ? [{ name: withExtension(file.name, url), url }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
/** Signed links to the model's own files (not its profiles'). */
|
||||
export function fetchNexprintModelFiles(
|
||||
resolution: Pick<NexprintModelResolution, "files" | "internalId">,
|
||||
token: string,
|
||||
): Promise<NexprintFile[]> {
|
||||
return signFiles(
|
||||
resolution.files.map((file) => ({ ...file, type: FILE_TYPE_MODEL, bizId: resolution.internalId })),
|
||||
token,
|
||||
);
|
||||
}
|
||||
|
||||
/** A signed link to one print profile's 3MF, named after the profile. Null when NexPrint doesn't sign it. */
|
||||
export async function fetchNexprintProfileFile(profile: NexprintProfile, token: string): Promise<NexprintFile | null> {
|
||||
const name = profile.title.replace(/[\\/:*?"<>|]+/g, " ").trim() || profile.id;
|
||||
const [file] = await signFiles(
|
||||
[{ fileId: profile.fileId, type: FILE_TYPE_PROFILE, bizId: profile.settingId, name }],
|
||||
token,
|
||||
);
|
||||
return file ?? null;
|
||||
}
|
||||
|
||||
/** NexPrint's signed file links point at its Aliyun OSS bucket. */
|
||||
export function isNexprintFileHost(hostname: string): boolean {
|
||||
return /^[a-z0-9-]+\.oss-[a-z0-9-]+\.aliyuncs\.com$/i.test(hostname);
|
||||
}
|
||||
|
||||
export type NexprintModelSummary = { modelId: string; title: string; cover: string | null };
|
||||
export type NexprintListing = { entries: NexprintModelSummary[]; total: number; truncated: boolean };
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
const LISTING_MAX_ENTRIES = 600;
|
||||
const CARD_THUMBNAILS = 4;
|
||||
|
||||
/** Every page of a collection's models, as the site orders them; models no longer shared are left out, since they
|
||||
* can't be imported. */
|
||||
async function fetchCollectionModels(collectionId: string, maxItems: number): Promise<NexprintListing> {
|
||||
const entries: NexprintModelSummary[] = [];
|
||||
const seen = new Set<string>();
|
||||
let total = 0;
|
||||
for (let page = 1; entries.length < maxItems; page++) {
|
||||
const pageSize = Math.min(PAGE_SIZE, maxItems);
|
||||
const { code, message, data } = await fetchApi(
|
||||
`/model-library-server/model-collection/${collectionId}/model/page`,
|
||||
{ query: { pageSize, pageNo: page } },
|
||||
null,
|
||||
);
|
||||
if (code !== CODE_OK || !isRecord(data)) throw failure("this collection", code, message);
|
||||
total = count(data.total) || total;
|
||||
const items = records(data.list);
|
||||
for (const item of items) {
|
||||
const modelId = outId(item.modelId, "G");
|
||||
if (!modelId || item.isInvalid === true || item.open === false || seen.has(modelId)) continue;
|
||||
seen.add(modelId);
|
||||
entries.push({ modelId, title: str(item.modelName) ?? `Model ${modelId}`, cover: str(item.coverImgUrl) });
|
||||
if (entries.length >= maxItems) break;
|
||||
}
|
||||
if (items.length < pageSize || page * pageSize >= total) break;
|
||||
}
|
||||
return { entries, total, truncated: entries.length >= maxItems && total > entries.length };
|
||||
}
|
||||
|
||||
export type NexprintCollectionInfo = {
|
||||
id: string;
|
||||
name: string;
|
||||
ownerId: string | null;
|
||||
private: boolean;
|
||||
modelCount: number;
|
||||
};
|
||||
|
||||
function collectionInfo(data: unknown): NexprintCollectionInfo | null {
|
||||
if (!isRecord(data)) return null;
|
||||
const collectionId = outId(data.collectOutId, "G");
|
||||
if (!collectionId) return null;
|
||||
return {
|
||||
id: collectionId,
|
||||
name: str(data.collectionName) ?? "NexPrint collection",
|
||||
ownerId: isRecord(data.author) ? outId(data.author.userId, "U") : null,
|
||||
private: data.collectionOpenStatus === false,
|
||||
modelCount: count(data.countNum),
|
||||
};
|
||||
}
|
||||
|
||||
/** A collection's details by its "G" id. Null for one that doesn't exist. */
|
||||
export async function fetchNexprintCollectionInfo(collectionId: string): Promise<NexprintCollectionInfo | null> {
|
||||
const { code, message, data } = await fetchApi(`/model-library-server/model-collection/${collectionId}`, {}, null);
|
||||
if (code !== CODE_OK) throw failure("this collection", code, message);
|
||||
return collectionInfo(data);
|
||||
}
|
||||
|
||||
/** A collection's models; private ones read without a sign-in too. Null for one that doesn't exist. */
|
||||
export async function fetchNexprintCollection(
|
||||
collectionId: string,
|
||||
maxItems: number = LISTING_MAX_ENTRIES,
|
||||
): Promise<(NexprintListing & { info: NexprintCollectionInfo }) | null> {
|
||||
const info = await fetchNexprintCollectionInfo(collectionId);
|
||||
if (!info) return null;
|
||||
return { ...(await fetchCollectionModels(collectionId, maxItems)), info };
|
||||
}
|
||||
|
||||
/** The user's own collections, as the sync dialog's Browse tab shows them. NexPrint lists no liked models anywhere,
|
||||
* so there's no likes card. */
|
||||
export async function listNexprintCollections(token: string): Promise<ProviderCollectionCard[]> {
|
||||
const { code, message, data } = await fetchApi("/model-library-server/model-collection/list", {}, token);
|
||||
if (code !== CODE_OK) throw failure("your collections", code, message);
|
||||
const cards: ProviderCollectionCard[] = [];
|
||||
for (const info of records(data).map(collectionInfo)) {
|
||||
if (!info) continue;
|
||||
// The list has the collection's own cover only; its first models make the card's.
|
||||
const thumbnails = info.modelCount
|
||||
? (await fetchCollectionModels(info.id, CARD_THUMBNAILS)).entries.flatMap((entry) => entry.cover ?? [])
|
||||
: [];
|
||||
cards.push({
|
||||
id: info.id,
|
||||
name: info.name,
|
||||
url: nexprintCollectionUrl(info.id, info.ownerId),
|
||||
modelCount: info.modelCount,
|
||||
thumbnails,
|
||||
private: info.private,
|
||||
owner: null,
|
||||
});
|
||||
}
|
||||
return cards;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { prisma } from "../db";
|
||||
|
||||
// Per-user: the NexPrint token, its auth_token cookie, sent as a Bearer token. The site keeps the cookie for a week and
|
||||
// sets it again whenever it refreshes the token, so Thingport Grab sends each new one on. The cookie's expiry is kept
|
||||
// only to show; the token is used until NexPrint turns it down, as it may outlive the cookie.
|
||||
|
||||
const TOKEN_COOKIE = "auth_token";
|
||||
|
||||
export type NexprintSession = { token: string; expiresAt: Date | null };
|
||||
|
||||
/** The token from a bare value, an `Authorization: Bearer` header or a whole Cookie header; null when there's none. */
|
||||
export function normalizeNexprintSession(
|
||||
raw: string | null | undefined,
|
||||
expiresAt: Date | null = null,
|
||||
): NexprintSession | null {
|
||||
let text = (raw ?? "")
|
||||
.replace(/^\s*(cookie|authorization):\s*/i, "")
|
||||
.replace(/^\s*bearer\s+/i, "")
|
||||
.trim();
|
||||
const pair = text
|
||||
.split(";")
|
||||
.map((part) => part.trim())
|
||||
.find((part) => part.startsWith(`${TOKEN_COOKIE}=`));
|
||||
if (pair) {
|
||||
try {
|
||||
text = decodeURIComponent(pair.slice(TOKEN_COOKIE.length + 1));
|
||||
} catch {
|
||||
return null; // Malformed percent-encoding: not a token.
|
||||
}
|
||||
}
|
||||
return /^[0-9a-f]{32,128}$/i.test(text) ? { token: text, expiresAt } : null;
|
||||
}
|
||||
|
||||
/** The user's token until NexPrint turns it down; null without one. */
|
||||
export async function getUserNexprintToken(userId: string): Promise<string | null> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { nexprintToken: true, nexprintRejectedAt: true },
|
||||
});
|
||||
if (!user?.nexprintToken || user.nexprintRejectedAt) return null;
|
||||
return user.nexprintToken;
|
||||
}
|
||||
|
||||
/** A new token clears an earlier rejection. */
|
||||
export async function setUserNexprintSession(userId: string, session: NexprintSession | null): Promise<boolean> {
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: session
|
||||
? { nexprintToken: session.token, nexprintExpiresAt: session.expiresAt, nexprintRejectedAt: null }
|
||||
: { nexprintToken: null, nexprintExpiresAt: null, nexprintRejectedAt: null },
|
||||
});
|
||||
return Boolean(session);
|
||||
}
|
||||
|
||||
export async function markNexprintSessionRejected(userId: string): Promise<void> {
|
||||
await prisma.user.update({ where: { id: userId }, data: { nexprintRejectedAt: new Date() } });
|
||||
}
|
||||
|
||||
export type NexprintConnection = "connected" | "disconnected" | "expired";
|
||||
|
||||
export async function getNexprintConnection(userId: string): Promise<NexprintConnection> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { nexprintToken: true, nexprintRejectedAt: true },
|
||||
});
|
||||
if (!user?.nexprintToken) return "disconnected";
|
||||
return user.nexprintRejectedAt ? "expired" : "connected";
|
||||
}
|
||||
|
||||
/** Shown wherever a NexPrint download needs a sign-in the user hasn't given, or that ran out. */
|
||||
export const NEXPRINT_CONNECT =
|
||||
"NexPrint only gives its files to a signed-in user: visit nexprint.com signed in with Thingport Grab, " +
|
||||
"or connect it in Configuration > Providers, then import it again.";
|
||||
@@ -32,6 +32,13 @@ import {
|
||||
listCrealitycloudCollections,
|
||||
} from "./crealitycloudApi";
|
||||
import { getCrealitycloudConnection, getUserCrealitycloudAuth } from "./crealitycloudSessionService";
|
||||
import {
|
||||
fetchNexprintCollection,
|
||||
listNexprintCollections,
|
||||
NexprintAuthError,
|
||||
nexprintCollectionUrl,
|
||||
} from "./nexprintApi";
|
||||
import { getNexprintConnection, getUserNexprintToken } from "./nexprintSessionService";
|
||||
import { MakerworldAuthError, MakerworldCaptchaError, extractMakerworldBearerToken } from "./makerworldCloudApi";
|
||||
import { fetchMakerworldCollectionCard, listMakerworldOwnCollections } from "./makerworldCollections";
|
||||
import { getUserMakerworldCookie } from "./makerworldCookieService";
|
||||
@@ -74,6 +81,7 @@ export const BROWSABLE_PROVIDERS = [
|
||||
"snapmaker",
|
||||
"makeronline",
|
||||
"crealitycloud",
|
||||
"nexprint",
|
||||
] as const;
|
||||
export type BrowsableProvider = (typeof BROWSABLE_PROVIDERS)[number];
|
||||
|
||||
@@ -97,22 +105,25 @@ export type ProviderCollectionOut = {
|
||||
};
|
||||
|
||||
export async function getProviderAccounts(userId: string): Promise<ProviderAccountsOut> {
|
||||
const [user, thingiverseToken, myminifactoryKey, snapmaker, makeronline, crealitycloud] = await Promise.all([
|
||||
prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
makerworldCookie: true,
|
||||
printablesHandle: true,
|
||||
thingiverseUsername: true,
|
||||
myminifactoryUsername: true,
|
||||
},
|
||||
}),
|
||||
getThingiverseAccessToken(),
|
||||
getMyminifactoryApiKey(),
|
||||
getSnapmakerConnection(userId),
|
||||
getMakeronlineConnection(userId),
|
||||
getCrealitycloudConnection(userId),
|
||||
]);
|
||||
const [user, thingiverseToken, myminifactoryKey, snapmaker, makeronline, crealitycloud, nexprint] = await Promise.all(
|
||||
[
|
||||
prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
makerworldCookie: true,
|
||||
printablesHandle: true,
|
||||
thingiverseUsername: true,
|
||||
myminifactoryUsername: true,
|
||||
},
|
||||
}),
|
||||
getThingiverseAccessToken(),
|
||||
getMyminifactoryApiKey(),
|
||||
getSnapmakerConnection(userId),
|
||||
getMakeronlineConnection(userId),
|
||||
getCrealitycloudConnection(userId),
|
||||
getNexprintConnection(userId),
|
||||
],
|
||||
);
|
||||
return {
|
||||
makerworld: { connected: Boolean(extractMakerworldBearerToken(user?.makerworldCookie)), account: null },
|
||||
printables: { connected: true, account: user?.printablesHandle ?? null },
|
||||
@@ -121,6 +132,7 @@ export async function getProviderAccounts(userId: string): Promise<ProviderAccou
|
||||
snapmaker: { connected: snapmaker === "connected", account: null },
|
||||
makeronline: { connected: makeronline === "connected", account: null },
|
||||
crealitycloud: { connected: crealitycloud === "connected", account: null },
|
||||
nexprint: { connected: nexprint === "connected", account: null },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -169,6 +181,8 @@ export async function setProviderAccount(
|
||||
throw new HttpError(400, "MakerOnline is linked through its sign-in in Configuration > Providers");
|
||||
} else if (provider === "crealitycloud") {
|
||||
throw new HttpError(400, "Creality Cloud is linked through its sign-in in Configuration > Providers");
|
||||
} else if (provider === "nexprint") {
|
||||
throw new HttpError(400, "NexPrint is linked through its sign-in in Configuration > Providers");
|
||||
} else {
|
||||
throw new HttpError(400, "MakerWorld is linked through its cookie in Configuration > Providers");
|
||||
}
|
||||
@@ -207,6 +221,7 @@ async function providerCall<T>(call: () => Promise<T>): Promise<T> {
|
||||
if (err instanceof SnapmakerAuthError) throw new HttpError(400, err.message);
|
||||
if (err instanceof MakeronlineAuthError) throw new HttpError(400, err.message);
|
||||
if (err instanceof CrealitycloudAuthError) throw new HttpError(400, err.message);
|
||||
if (err instanceof NexprintAuthError) throw new HttpError(400, err.message);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -274,6 +289,31 @@ async function crealitycloudCollectionCard(collectionId: string): Promise<Provid
|
||||
});
|
||||
}
|
||||
|
||||
/** The user's own collections, read with their sign-in. */
|
||||
async function nexprintCards(userId: string): Promise<ProviderCollectionCard[]> {
|
||||
const token = await getUserNexprintToken(userId);
|
||||
if (!token)
|
||||
throw new HttpError(409, "Connect NexPrint in Configuration > Providers, or visit it with Thingport Grab");
|
||||
return providerCall(() => listNexprintCollections(token));
|
||||
}
|
||||
|
||||
/** Any collection, by its "G" id: NexPrint lists even private ones without a sign-in. */
|
||||
async function nexprintCollectionCard(collectionId: string): Promise<ProviderCollectionCard | null> {
|
||||
return providerCall(async () => {
|
||||
const collection = await fetchNexprintCollection(collectionId, 4);
|
||||
if (!collection) return null;
|
||||
return {
|
||||
id: collectionId,
|
||||
name: collection.info.name,
|
||||
url: nexprintCollectionUrl(collectionId, collection.info.ownerId),
|
||||
modelCount: collection.info.modelCount,
|
||||
thumbnails: collection.entries.flatMap((entry) => (entry.cover ? [entry.cover] : [])),
|
||||
private: collection.info.private,
|
||||
owner: null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function withSyncs(
|
||||
userId: string,
|
||||
provider: SyncProvider,
|
||||
@@ -330,6 +370,8 @@ export async function listProviderCollections(
|
||||
cards = await makeronlineCards(userId);
|
||||
} else if (provider === "crealitycloud") {
|
||||
cards = await crealitycloudCards(userId);
|
||||
} else if (provider === "nexprint") {
|
||||
cards = await nexprintCards(userId);
|
||||
} else if (provider === "myminifactory") {
|
||||
const key = await requireMyminifactoryKey();
|
||||
if (!user?.myminifactoryUsername) throw new HttpError(409, "Tell Thingport your MyMiniFactory username first");
|
||||
@@ -357,7 +399,7 @@ export async function lookupProviderCollection(userId: string, url: string): Pro
|
||||
if (!source)
|
||||
throw new HttpError(
|
||||
400,
|
||||
"That isn't a MakerWorld, Printables, Thingiverse, MyMiniFactory, Snapmaker Space, MakerOnline or Creality Cloud collection link",
|
||||
"That isn't a MakerWorld, Printables, Thingiverse, MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud or NexPrint collection link",
|
||||
);
|
||||
await assertProviderEnabled(source.provider);
|
||||
let card: ProviderCollectionCard | null;
|
||||
@@ -391,6 +433,8 @@ export async function lookupProviderCollection(userId: string, url: string): Pro
|
||||
card = source.externalId.startsWith("likes-")
|
||||
? ((await crealitycloudCards(userId)).find((own) => own.id === source.externalId) ?? null)
|
||||
: await crealitycloudCollectionCard(source.externalId);
|
||||
} else if (source.provider === "nexprint") {
|
||||
card = await nexprintCollectionCard(source.externalId);
|
||||
} else if (source.provider === "snapmaker") {
|
||||
// Only the user's own likes and bookmarks are there to find.
|
||||
card = (await snapmakerCards(userId)).find((own) => own.id === source.externalId) ?? null;
|
||||
@@ -424,13 +468,13 @@ export async function enableSyncFromApp(
|
||||
if (!source)
|
||||
throw new HttpError(
|
||||
400,
|
||||
"That isn't a MakerWorld, Printables, Thingiverse, MyMiniFactory, Snapmaker Space, MakerOnline or Creality Cloud collection link",
|
||||
"That isn't a MakerWorld, Printables, Thingiverse, MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud or NexPrint collection link",
|
||||
);
|
||||
await assertProviderEnabled(source.provider);
|
||||
if (input.profileScope && !hasProfiles(source.provider)) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
"Only MakerWorld, Snapmaker Space, MakerOnline and Creality Cloud models have print profiles to pick",
|
||||
"Only MakerWorld, Snapmaker Space, MakerOnline, Creality Cloud and NexPrint models have print profiles to pick",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ export const PROVIDERS = [
|
||||
"snapmaker",
|
||||
"makeronline",
|
||||
"crealitycloud",
|
||||
"nexprint",
|
||||
] as const;
|
||||
export type Provider = (typeof PROVIDERS)[number];
|
||||
|
||||
@@ -25,6 +26,7 @@ const PROVIDER_NAMES: Record<Provider, string> = {
|
||||
snapmaker: "Snapmaker Space",
|
||||
makeronline: "MakerOnline",
|
||||
crealitycloud: "Creality Cloud",
|
||||
nexprint: "NexPrint",
|
||||
};
|
||||
|
||||
const SETTING_KEY = "disabled_providers";
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
checkNexprintAuth,
|
||||
fetchNexprintCollection,
|
||||
fetchNexprintModelFiles,
|
||||
fetchNexprintProfileFile,
|
||||
isNexprintFileHost,
|
||||
listNexprintCollections,
|
||||
nexprintMetaFromDetail,
|
||||
NexprintAuthError,
|
||||
parseNexprintCollectionUrl,
|
||||
parseNexprintModelUrl,
|
||||
resolveNexprintModel,
|
||||
selectNexprintProfiles,
|
||||
} from "../src/services/nexprintApi";
|
||||
import { modelIdFromUrl, modelUrlFor, parseSyncSource } from "../src/services/collectionSyncService";
|
||||
import { normalizeNexprintSession } from "../src/services/nexprintSessionService";
|
||||
import { buildImportSourceUrl, identifySourceModel } from "../src/services/importService";
|
||||
|
||||
// The NexPrint client against the shapes its own site gets back, with fetch mocked. No database.
|
||||
|
||||
const TOKEN = "513a1971690b4f2985177303998a1a9f";
|
||||
|
||||
function apiResponse(code: number, data: unknown = null, msg = ""): Response {
|
||||
return new Response(JSON.stringify({ code, data, msg, traceId: "t" }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
type Call = { path: string; query: URLSearchParams; method: string; body: unknown; headers: Record<string, string> };
|
||||
|
||||
/** Answers each call by its path; records what was sent. */
|
||||
function stubApi(answers: Record<string, (call: Call) => Response>) {
|
||||
const calls: Call[] = [];
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: URL | string, init: RequestInit) => {
|
||||
const parsed = new URL(url);
|
||||
const call: Call = {
|
||||
path: parsed.pathname.replace(/^\/gateway\/api\/v1/, ""),
|
||||
query: parsed.searchParams,
|
||||
method: init.method ?? "GET",
|
||||
body: init.body ? JSON.parse(String(init.body)) : undefined,
|
||||
headers: init.headers as Record<string, string>,
|
||||
};
|
||||
calls.push(call);
|
||||
const answer = answers[call.path];
|
||||
if (!answer) throw new Error(`unexpected call ${call.path}`);
|
||||
return answer(call);
|
||||
}),
|
||||
);
|
||||
return calls;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("NexPrint URLs", () => {
|
||||
it("recognizes model pages, with the print profile the site's own links name", () => {
|
||||
expect(parseNexprintModelUrl("https://www.nexprint.com/en/models/G1215164?printConfigId=G6354239")).toEqual({
|
||||
modelId: "G1215164",
|
||||
requestedProfileId: "G6354239",
|
||||
});
|
||||
expect(parseNexprintModelUrl("https://nexprint.com/models/g1215164")).toEqual({
|
||||
modelId: "G1215164",
|
||||
requestedProfileId: null,
|
||||
});
|
||||
// Thingport's own profile marker wins over the page's.
|
||||
expect(
|
||||
parseNexprintModelUrl("https://www.nexprint.com/de/models/G1215164?printConfigId=G1#profileId-G6354239"),
|
||||
).toMatchObject({ requestedProfileId: "G6354239" });
|
||||
expect(parseNexprintModelUrl("https://www.nexprint.com/en/models")).toBeNull();
|
||||
expect(parseNexprintModelUrl("https://www.nexprint.co/en/models/G1")).toBeNull();
|
||||
});
|
||||
|
||||
it("recognizes collections on their owner's page or on their own, and syncs them", () => {
|
||||
const home =
|
||||
"https://www.nexprint.com/en/U0092359913/home?component=UserCollect&subComp=UserCollectDetail&collectionId=G10199482";
|
||||
expect(parseNexprintCollectionUrl(home)).toEqual({ collectionId: "G10199482", ownerId: "U0092359913" });
|
||||
expect(parseSyncSource(home)).toEqual({ provider: "nexprint", externalId: "G10199482", url: home });
|
||||
expect(parseNexprintCollectionUrl("https://www.nexprint.com/en/collection/G10199482")).toEqual({
|
||||
collectionId: "G10199482",
|
||||
ownerId: null,
|
||||
});
|
||||
// The user's page without a collection open isn't one.
|
||||
expect(parseNexprintCollectionUrl("https://www.nexprint.com/en/U0092359913/home")).toBeNull();
|
||||
});
|
||||
|
||||
it("identifies imported models and links back to them", () => {
|
||||
const source = identifySourceModel("https://www.nexprint.com/en/models/G1215164?printConfigId=G6354239");
|
||||
expect(source).toEqual({ provider: "nexprint", externalId: "G1215164" });
|
||||
expect(buildImportSourceUrl("nexprint", "G1215164")).toBe("https://www.nexprint.com/en/models/G1215164");
|
||||
expect(modelIdFromUrl("nexprint", modelUrlFor("nexprint", "G1215164"))).toBe("G1215164");
|
||||
});
|
||||
|
||||
it("only takes signed files from NexPrint's OSS bucket", () => {
|
||||
expect(isNexprintFileHost("nexprint.oss-eu-central-1.aliyuncs.com")).toBe(true);
|
||||
expect(isNexprintFileHost("aliyuncs.com.evil.net")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("NexPrint sign-in", () => {
|
||||
it("takes the token from a Cookie header, an Authorization header or on its own", () => {
|
||||
expect(normalizeNexprintSession(`Cookie: i18n_redirected=en; auth_token=${TOKEN}; ORIGIN=Nexprint`)).toEqual({
|
||||
token: TOKEN,
|
||||
expiresAt: null,
|
||||
});
|
||||
expect(normalizeNexprintSession(`authorization: Bearer ${TOKEN}`)?.token).toBe(TOKEN);
|
||||
expect(normalizeNexprintSession(TOKEN)?.token).toBe(TOKEN);
|
||||
expect(normalizeNexprintSession("auth_token=undefined")).toBeNull();
|
||||
expect(normalizeNexprintSession("")).toBeNull();
|
||||
});
|
||||
|
||||
it("sends it as a Bearer token, and tells a rejected one apart", async () => {
|
||||
const calls = stubApi({
|
||||
"/model-user-server/member/user_data": () =>
|
||||
apiResponse(0, { userData: { userId: "U0092359913", nickname: "Tautvydas" } }),
|
||||
});
|
||||
expect(await checkNexprintAuth(TOKEN)).toEqual({ userId: "U0092359913", name: "Tautvydas" });
|
||||
expect(calls[0].method).toBe("POST");
|
||||
expect(calls[0].headers).toMatchObject({ Authorization: `Bearer ${TOKEN}`, "client-id": "Nexprint" });
|
||||
|
||||
stubApi({ "/model-user-server/member/user_data": () => apiResponse(401, null, "账号未登录") });
|
||||
expect(await checkNexprintAuth(TOKEN)).toBe("rejected");
|
||||
});
|
||||
});
|
||||
|
||||
const PROFILE_FILE = { fileId: "2109986422412034048", fileName: "Dog.3mf", fileExt: null };
|
||||
|
||||
const DETAIL = {
|
||||
id: "2109988470143172608",
|
||||
modelId: "G1215164",
|
||||
modelName: "Dog",
|
||||
modelDetail: "<p>A <strong>crochet</strong> dog.</p>",
|
||||
coverImgUrl: "https://np.nexprint.com/images/1/cover.png",
|
||||
modelPicList: [
|
||||
{ fileUrl: "https://np.nexprint.com/images/1/cover.png" },
|
||||
{ fileUrl: "https://np.nexprint.com/images/1/side.jpg" },
|
||||
],
|
||||
modelTagList: ["dog", "amigurumi", "dog"],
|
||||
classificationId: 1422473859006464,
|
||||
classificationInfos: [
|
||||
{ id: 1422473858908160, classificationName: "Toys & Games", level: 1, pid: null },
|
||||
{ id: 1422473859006464, classificationName: "Miniature Model", level: 2, pid: 1422473858908160 },
|
||||
],
|
||||
authorId: "2109966715818881024",
|
||||
author: { userId: "U0030208991", nickname: "Cute Animals", avatarUrl: "https://np.nexprint.com/images/a.png" },
|
||||
// The profile's 3MF appears twice more among the model's own files.
|
||||
modelFileList: [
|
||||
{ ...PROFILE_FILE, fileExt: "3mf" },
|
||||
PROFILE_FILE,
|
||||
{ fileId: "2109986422412034999", fileName: "Dog.stl", fileExt: "stl" },
|
||||
],
|
||||
settingInfoList: [
|
||||
{
|
||||
id: "2109988470411608064",
|
||||
settingOutId: "G6354239",
|
||||
settingName: "0.2mm layer, 2 walls, 15% infill",
|
||||
userId: "2109966715818881024",
|
||||
isCreator: true,
|
||||
open: true,
|
||||
settingFile: PROFILE_FILE,
|
||||
},
|
||||
{
|
||||
id: "2109988470411608999",
|
||||
settingOutId: "G6354240",
|
||||
settingName: "0.12mm fine",
|
||||
userId: "1",
|
||||
isCreator: false,
|
||||
open: true,
|
||||
settingFile: { fileId: "2109986422412035000", fileName: "Dog fine.3mf" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("NexPrint model details", () => {
|
||||
it("maps the details, the category most specific first and the designer", () => {
|
||||
const resolved = nexprintMetaFromDetail(DETAIL)!;
|
||||
expect(resolved.modelId).toBe("G1215164");
|
||||
expect(resolved.internalId).toBe("2109988470143172608");
|
||||
expect(resolved.meta.title).toBe("Dog");
|
||||
expect(resolved.meta.description).toBe("A **crochet** dog.");
|
||||
expect(resolved.meta.tags).toEqual(["dog", "amigurumi"]);
|
||||
expect(resolved.galleryImages.map((image) => image.filename)).toEqual(["cover.png", "side.jpg"]);
|
||||
expect(resolved.meta.siteCategoryIds).toEqual([1422473859006464, 1422473858908160]);
|
||||
expect(resolved.meta.categorySite).toBe("nexprint");
|
||||
expect(resolved.meta.author).toMatchObject({
|
||||
provider: "nexprint",
|
||||
externalId: "U0030208991",
|
||||
name: "Cute Animals",
|
||||
links: ["https://www.nexprint.com/en/U0030208991/home"],
|
||||
});
|
||||
expect(resolved.profiles.map((p) => [p.id, p.designers])).toEqual([
|
||||
["G6354239", true],
|
||||
["G6354240", false],
|
||||
]);
|
||||
// The profile's 3MF isn't counted again as a model file.
|
||||
expect(resolved.files).toEqual([{ fileId: "2109986422412034999", name: "Dog.stl" }]);
|
||||
});
|
||||
|
||||
it("picks profiles like MakerWorld", () => {
|
||||
const resolved = nexprintMetaFromDetail(DETAIL)!;
|
||||
expect(selectNexprintProfiles(resolved, "url", null)).toEqual(["G6354239"]);
|
||||
expect(selectNexprintProfiles(resolved, "url", "G6354240")).toEqual(["G6354240"]);
|
||||
expect(selectNexprintProfiles(resolved, "designer", null)).toEqual(["G6354239"]);
|
||||
expect(selectNexprintProfiles(resolved, "all", null)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("reads a model by its id, and one that isn't there as missing", async () => {
|
||||
const calls = stubApi({ "/model-library-server/model-base-info/get": () => apiResponse(0, DETAIL) });
|
||||
expect((await resolveNexprintModel("G1215164"))?.profiles).toHaveLength(2);
|
||||
expect(calls[0].query.get("id")).toBe("G1215164");
|
||||
expect(calls[0].query.get("loadSupportPrintProfile")).toBe("true");
|
||||
|
||||
// NexPrint answers a missing model with one whose every field is null.
|
||||
stubApi({ "/model-library-server/model-base-info/get": () => apiResponse(0, { id: null, modelId: null }) });
|
||||
expect(await resolveNexprintModel("G9999999")).toBeNull();
|
||||
});
|
||||
|
||||
it("signs a profile's 3MF and the model's own files as the user", async () => {
|
||||
const resolved = nexprintMetaFromDetail(DETAIL)!;
|
||||
const calls = stubApi({
|
||||
"/model-library-server/common/presigned-download-url": (call) => {
|
||||
const [file] = (call.body as { fileInfoList: { fileId: string }[] }).fileInfoList;
|
||||
return apiResponse(0, {
|
||||
fileInfoList: [
|
||||
{
|
||||
fileId: file.fileId,
|
||||
resultUrl: `https://nexprint.oss-eu-central-1.aliyuncs.com/models/x.3mf?X-Amz-Signature=s`,
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
});
|
||||
expect(await fetchNexprintProfileFile(resolved.profiles[0], TOKEN)).toEqual({
|
||||
name: "0.2mm layer, 2 walls, 15% infill.3mf",
|
||||
url: "https://nexprint.oss-eu-central-1.aliyuncs.com/models/x.3mf?X-Amz-Signature=s",
|
||||
});
|
||||
expect(calls[0].body).toEqual({
|
||||
fileInfoList: [{ fileId: "2109986422412034048", type: "5", bizId: "2109988470411608064" }],
|
||||
});
|
||||
expect(calls[0].headers).toMatchObject({ Authorization: `Bearer ${TOKEN}` });
|
||||
|
||||
await fetchNexprintModelFiles(resolved, TOKEN);
|
||||
expect(calls[1].body).toEqual({
|
||||
fileInfoList: [{ fileId: "2109986422412034999", type: "2", bizId: "2109988470143172608" }],
|
||||
});
|
||||
|
||||
stubApi({ "/model-library-server/common/presigned-download-url": () => apiResponse(401, null, "账号未登录") });
|
||||
await expect(fetchNexprintProfileFile(resolved.profiles[0], TOKEN)).rejects.toBeInstanceOf(NexprintAuthError);
|
||||
});
|
||||
});
|
||||
|
||||
const COLLECTION = {
|
||||
id: "2112058171795062784",
|
||||
collectOutId: "G10199482",
|
||||
collectionName: "Test collection",
|
||||
collectionOpenStatus: false,
|
||||
countNum: 2,
|
||||
author: { userId: "U0092359913", nickname: "Tautvydas" },
|
||||
};
|
||||
|
||||
describe("NexPrint collections", () => {
|
||||
it("reads a collection by its id, leaving out models no longer shared", async () => {
|
||||
const calls = stubApi({
|
||||
"/model-library-server/model-collection/G10199482": () => apiResponse(0, COLLECTION),
|
||||
"/model-library-server/model-collection/G10199482/model/page": () =>
|
||||
apiResponse(0, {
|
||||
total: 2,
|
||||
list: [
|
||||
{ modelId: "G6213245", modelName: "CC2 profiles", coverImgUrl: "https://np.nexprint.com/images/c.png" },
|
||||
{ modelId: "G1215164", modelName: "Gone", isInvalid: true },
|
||||
],
|
||||
}),
|
||||
});
|
||||
const collection = await fetchNexprintCollection("G10199482");
|
||||
expect(collection?.info).toEqual({
|
||||
id: "G10199482",
|
||||
name: "Test collection",
|
||||
ownerId: "U0092359913",
|
||||
private: true,
|
||||
modelCount: 2,
|
||||
});
|
||||
expect(collection?.entries).toEqual([
|
||||
{ modelId: "G6213245", title: "CC2 profiles", cover: "https://np.nexprint.com/images/c.png" },
|
||||
]);
|
||||
expect(calls[1].query.get("pageNo")).toBe("1");
|
||||
|
||||
stubApi({ "/model-library-server/model-collection/G1": () => apiResponse(0, null) });
|
||||
expect(await fetchNexprintCollection("G1")).toBeNull();
|
||||
});
|
||||
|
||||
it("lists the user's own collections, on their page, with their first models' covers", async () => {
|
||||
stubApi({
|
||||
"/model-library-server/model-collection/list": () =>
|
||||
apiResponse(0, [
|
||||
COLLECTION,
|
||||
{ ...COLLECTION, collectOutId: "G10199480", collectionName: "Empty", countNum: 0 },
|
||||
]),
|
||||
"/model-library-server/model-collection/G10199482/model/page": (call) => {
|
||||
expect(call.query.get("pageSize")).toBe("4");
|
||||
return apiResponse(0, { total: 1, list: [{ modelId: "G1", coverImgUrl: "https://np.nexprint.com/1.png" }] });
|
||||
},
|
||||
});
|
||||
const cards = await listNexprintCollections(TOKEN);
|
||||
expect(cards.map((card) => [card.id, card.thumbnails, card.private])).toEqual([
|
||||
["G10199482", ["https://np.nexprint.com/1.png"], true],
|
||||
["G10199480", [], true],
|
||||
]);
|
||||
expect(cards[0].url).toBe(
|
||||
"https://www.nexprint.com/en/U0092359913/home?component=UserCollect&subComp=UserCollectDetail&collectionId=G10199482",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,7 @@ afterAll(async () => {
|
||||
"snapmaker",
|
||||
"makeronline",
|
||||
"crealitycloud",
|
||||
"nexprint",
|
||||
] as const)
|
||||
await setProviderEnabled(provider, true);
|
||||
});
|
||||
@@ -54,6 +55,7 @@ describe("turning providers off", () => {
|
||||
snapmaker: true,
|
||||
makeronline: true,
|
||||
crealitycloud: true,
|
||||
nexprint: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
@@ -73,6 +75,7 @@ describe("turning providers off", () => {
|
||||
snapmaker: true,
|
||||
makeronline: true,
|
||||
crealitycloud: true,
|
||||
nexprint: true,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+29
-2
@@ -1,6 +1,6 @@
|
||||
# Provider setup
|
||||
|
||||
Importing from **Printables** works out of the box (its public API needs no credential). **MakerWorld** needs each user's own session cookie, and **Thingiverse** an App Token an admin sets once for the whole instance. **MyMiniFactory** needs an API key an admin sets once for free models; each user connects their own MyMiniFactory session only to import paid or sign-in-only models from their library. **Snapmaker Space** needs each user's own sign-in, which [Thingport Grab](../extension/README.md) keeps fresh. **MakerOnline** reads its models and public collections with nothing, but hands out files only through Thingport Grab; each user's own sign-in adds their likes and private collections. **Creality Cloud** reads its models and collections with nothing, but gives files and likes only to each user's own sign-in, which Thingport Grab sends on.
|
||||
Importing from **Printables** works out of the box (its public API needs no credential). **MakerWorld** needs each user's own session cookie, and **Thingiverse** an App Token an admin sets once for the whole instance. **MyMiniFactory** needs an API key an admin sets once for free models; each user connects their own MyMiniFactory session only to import paid or sign-in-only models from their library. **Snapmaker Space** needs each user's own sign-in, which [Thingport Grab](../extension/README.md) keeps fresh. **MakerOnline** reads its models and public collections with nothing, but hands out files only through Thingport Grab; each user's own sign-in adds their likes and private collections. **Creality Cloud** reads its models and collections with nothing, but gives files and likes only to each user's own sign-in, which Thingport Grab sends on. **NexPrint** reads its models and collections, private ones included, with nothing, and gives files only to each user's own sign-in, which Thingport Grab sends on.
|
||||
|
||||
Connect them under **Configuration → Providers**: open the user menu (your avatar, top right) and choose **Configuration**, or click one of the provider chips at the bottom of that menu. Right-click a provider to connect or disconnect it, or double-click one that isn't connected yet. A green tick means it's connected. A provider your admin has [turned off](#turning-a-provider-off) isn't listed.
|
||||
|
||||
@@ -131,6 +131,33 @@ Thingport says the sign-in ran out, and your next sign-in on crealitycloud.com c
|
||||
4. Right-click **Creality Cloud** in Thingport's **Configuration > Providers**, choose **Connect** and paste it.
|
||||
Thingport checks it with Creality Cloud before storing it.
|
||||
|
||||
## NexPrint
|
||||
|
||||
Per-user (Configuration > Providers > NexPrint). NexPrint's model details, print profiles, categories and collections,
|
||||
private ones included, need nothing; its files are given only to a signed-in user, so every NexPrint import runs as
|
||||
you. A model comes in like a MakerWorld one: its print profile's 3MF, as a file tagged with the profile, and importing
|
||||
it again with another profile (`?printConfigId=` in the link, or picking the designer's or all profiles) adds more; a
|
||||
model without print profiles brings its model files instead. NexPrint lists no liked models anywhere, so there are no
|
||||
likes to import or sync.
|
||||
|
||||
Its sign-in is one token, the `auth_token` cookie, which its API takes as a Bearer token. The site keeps the cookie for
|
||||
a week and sets it again as it refreshes the token while you use it.
|
||||
|
||||
### Easiest: Thingport Grab
|
||||
|
||||
With [Thingport Grab](../extension/README.md) allowed on NexPrint, opening nexprint.com while you're signed in connects
|
||||
your account, and sends the new token whenever the site renews it. If NexPrint turns it down, Thingport says the
|
||||
sign-in ran out, and your next visit to nexprint.com connects it again.
|
||||
|
||||
### Manual: copy it from your browser
|
||||
|
||||
1. Sign in at [nexprint.com](https://www.nexprint.com).
|
||||
2. Open DevTools (`F12`, or `Cmd+Option+I` on macOS) and reload the page.
|
||||
3. On the **Network** tab, pick any request to `nexprint.com/gateway` and copy its `Authorization` request header (or
|
||||
the `auth_token` cookie, or the whole `Cookie` header).
|
||||
4. Right-click **NexPrint** in Thingport's **Configuration > Providers**, choose **Connect** and paste it. Thingport
|
||||
checks it with NexPrint before storing it.
|
||||
|
||||
## MakerWorld cookie
|
||||
|
||||
Per-user (Configuration > Providers > MakerWorld) -- MakerWorld has no developer API, so importing from it runs as _your own_ logged-in MakerWorld session, captured as a cookie value.
|
||||
@@ -153,7 +180,7 @@ This is your own MakerWorld login session -- don't share it, and expect to redo
|
||||
|
||||
## Turning a provider off
|
||||
|
||||
Admins choose which providers this Thingport imports from, under **Administration → Providers**. All seven are on by
|
||||
Admins choose which providers this Thingport imports from, under **Administration → Providers**. All eight are on by
|
||||
default. Turn one off and nothing new comes from it for anyone: links to it, Thingport Grab and the import queue are
|
||||
refused, its synced collections pause, and background jobs skip it. It also disappears from everyone's
|
||||
**Configuration → Providers**. Models already imported from it stay as they are, and turning it back on picks up where
|
||||
|
||||
@@ -3,20 +3,20 @@
|
||||
Every instance-wide setting lives under **Administration**, in the sidebar, with a page for each part of Thingport. The
|
||||
first account on an instance is its admin, and there's no config file to edit after the first start.
|
||||
|
||||
| Page | What it's for |
|
||||
| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Overview | [System Health](../guides/system-health.md) for everything Thingport relies on, disk space used, and the update check, which says when newer Thingport images are out. |
|
||||
| Providers | Turn MakerWorld, Printables, Thingiverse, MyMiniFactory, Snapmaker Space, MakerOnline or Creality Cloud off for everyone, and set the Thingiverse App Token and the MyMiniFactory API key. See [turning a provider off](../PROVIDER_SETUP.md#turning-a-provider-off). |
|
||||
| Storage | The folder layout on disk, reorganizing existing files, browsing the storage folder, and the [consume folder](../guides/consume-folder.md). See [plain files on your disk](storage.md). |
|
||||
| Auth | The public URL, open or invite-only registration, [single sign-on](../guides/single-sign-on.md) providers, and how long a sign-in lasts. |
|
||||
| Rendering | How card thumbnails are made, and simplifying the previews of very detailed models. See [3D previews](3d-previews.md). |
|
||||
| AI | AI models and the [AI features](../guides/ai.md): categorization and improved descriptions. |
|
||||
| Users | Everyone's accounts, roles and sign-in methods. See [multi-user](multi-user.md). |
|
||||
| Import queue | Imports waiting to run, such as links sent from Thingport Grab: start, pause, retry or clear them. |
|
||||
| Logs | What each person did, by person and date: sign-ins, imports, edits, deletions, syncs, role changes, provider switches and AI runs. |
|
||||
| Triggers | One-off maintenance jobs: link missing authors, refetch imported descriptions, or delete all of a user's models. |
|
||||
| Connections | FlareSolverr, email (SMTP) and the database, each tested before it's saved. |
|
||||
| Captcha | Where to ask for a captcha: sign-in, registration, import, and password and email changes. |
|
||||
| Page | What it's for |
|
||||
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Overview | [System Health](../guides/system-health.md) for everything Thingport relies on, disk space used, and the update check, which says when newer Thingport images are out. |
|
||||
| Providers | Turn MakerWorld, Printables, Thingiverse, MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud or NexPrint off for everyone, and set the Thingiverse App Token and the MyMiniFactory API key. See [turning a provider off](../PROVIDER_SETUP.md#turning-a-provider-off). |
|
||||
| Storage | The folder layout on disk, reorganizing existing files, browsing the storage folder, and the [consume folder](../guides/consume-folder.md). See [plain files on your disk](storage.md). |
|
||||
| Auth | The public URL, open or invite-only registration, [single sign-on](../guides/single-sign-on.md) providers, and how long a sign-in lasts. |
|
||||
| Rendering | How card thumbnails are made, and simplifying the previews of very detailed models. See [3D previews](3d-previews.md). |
|
||||
| AI | AI models and the [AI features](../guides/ai.md): categorization and improved descriptions. |
|
||||
| Users | Everyone's accounts, roles and sign-in methods. See [multi-user](multi-user.md). |
|
||||
| Import queue | Imports waiting to run, such as links sent from Thingport Grab: start, pause, retry or clear them. |
|
||||
| Logs | What each person did, by person and date: sign-ins, imports, edits, deletions, syncs, role changes, provider switches and AI runs. |
|
||||
| Triggers | One-off maintenance jobs: link missing authors, refetch imported descriptions, or delete all of a user's models. |
|
||||
| Connections | FlareSolverr, email (SMTP) and the database, each tested before it's saved. |
|
||||
| Captcha | Where to ask for a captcha: sign-in, registration, import, and password and email changes. |
|
||||
|
||||
After installing, and whenever something stops working, start with
|
||||
[System Health](../guides/system-health.md): ideally everything there is green.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Import from MakerWorld, Printables, Thingiverse, MyMiniFactory, Snapmaker Space, MakerOnline and Creality Cloud
|
||||
# Import from MakerWorld, Printables, Thingiverse, MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud and NexPrint
|
||||
|
||||
Thingport keeps a real copy of every model you import: its files, photos, title, description, tags, author and a link
|
||||
back to where it came from. If the original page disappears, the model is still in your library.
|
||||
@@ -6,10 +6,10 @@ back to where it came from. If the original page disappears, the model is still
|
||||
## Ways to import
|
||||
|
||||
- **Paste a link.** Click **Add → Import** and paste a model link from MakerWorld, Printables, Thingiverse,
|
||||
MyMiniFactory, Snapmaker Space, MakerOnline or Creality Cloud, or any direct link to a model file. Paste several links, one per line, and they're imported one after
|
||||
MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud or NexPrint, or any direct link to a model file. Paste several links, one per line, and they're imported one after
|
||||
another as a background job. You can also paste a link straight into the search bar.
|
||||
- **Use Thingport Grab.** The [browser extension](../../extension/README.md) adds a Thingport button to model pages on
|
||||
all seven sites (MyMiniFactory, Snapmaker Space, MakerOnline and Creality Cloud once you allow them, with a single click in its popup), so you can
|
||||
all eight sites (MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud and NexPrint once you allow them, with a single click in its popup), so you can
|
||||
import without leaving the page, file the model into a collection, and import whole collections.
|
||||
- **Import a collection or your likes.** Paste a collection link, or your Thingiverse likes, and pick which models to
|
||||
bring in. Your Snapmaker Space likes and bookmarks import from your profile's Interactions tab with Thingport Grab,
|
||||
@@ -22,7 +22,7 @@ back to where it came from. If the original page disappears, the model is still
|
||||
|
||||
## What comes with a model
|
||||
|
||||
- Every file, and on MakerWorld, Snapmaker Space, MakerOnline and Creality Cloud, its print profiles: the one the link points to, all of the
|
||||
- Every file, and on MakerWorld, Snapmaker Space, MakerOnline, Creality Cloud and NexPrint, its print profiles: the one the link points to, all of the
|
||||
designer's, or the community's too. Each profile becomes its own file on the model.
|
||||
- The photo gallery, the formatted description, tags and the source site's category, which Thingport maps to your own
|
||||
[categories](organize-and-search.md).
|
||||
@@ -42,8 +42,8 @@ from Thingport Grab wait in **Administration → Import queue** until they're st
|
||||
|
||||
Printables works with no setup. MakerWorld needs your session cookie, and Thingiverse and MyMiniFactory a key set by an
|
||||
admin; MyMiniFactory models you bought come in once you connect your MyMiniFactory account. Snapmaker Space needs your
|
||||
own sign-in, which Thingport Grab keeps fresh, MakerOnline Thingport Grab for its files, and Creality Cloud your own
|
||||
sign-in, which Grab sends each time you sign in there. See
|
||||
own sign-in, which Thingport Grab keeps fresh, MakerOnline Thingport Grab for its files, and Creality Cloud and NexPrint your own
|
||||
sign-in, which Grab sends on when you visit them. See
|
||||
[provider setup](../PROVIDER_SETUP.md). Admins can also
|
||||
[turn a provider off](../PROVIDER_SETUP.md#turning-a-provider-off) for the whole instance, and then nothing is imported
|
||||
from it.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Synced collections
|
||||
|
||||
A synced collection follows a collection on MakerWorld, Printables, Thingiverse, MyMiniFactory, MakerOnline or Creality
|
||||
Cloud, your MakerOnline or Creality Cloud likes, or your likes or bookmarks on Snapmaker Space. When a model is added
|
||||
A synced collection follows a collection on MakerWorld, Printables, Thingiverse, MyMiniFactory, MakerOnline, Creality
|
||||
Cloud or NexPrint, your MakerOnline or Creality Cloud likes, or your likes or bookmarks on Snapmaker Space. When a model is added
|
||||
to it there, Thingport imports that model into the matching Thingport collection on its own, without you visiting the
|
||||
page again.
|
||||
|
||||
@@ -34,7 +34,8 @@ page again.
|
||||
your next visit there. MakerOnline reads public collections with nothing; your likes and private collections need
|
||||
your sign-in, and its new models always wait for Thingport Grab (see below). Creality Cloud reads collections with
|
||||
nothing, but its files and your likes need your sign-in, which lasts a week from when you signed in there; Grab
|
||||
sends the new one on your next visit.
|
||||
sends the new one on your next visit. NexPrint reads collections, private ones too, with nothing, but its files need
|
||||
your sign-in, which Grab sends on as you visit nexprint.com.
|
||||
|
||||
## Turn sync on in Thingport
|
||||
|
||||
@@ -42,13 +43,13 @@ page again.
|
||||
2. Choose **⋮ → Sync configuration**. A collection that isn't synced yet opens on **Browse**.
|
||||
3. Pick the provider. Your own collections there are listed: on MakerWorld through your cookie, private ones included;
|
||||
on Printables, Thingiverse and MyMiniFactory, the public ones of the username you give the first time; on Snapmaker
|
||||
Space, your likes and bookmarks; on MakerOnline and Creality Cloud, your likes and your collections. Or paste any
|
||||
Space, your likes and bookmarks; on MakerOnline and Creality Cloud, your likes and your collections; on NexPrint, your collections. Or paste any
|
||||
collection's link, yours or someone else's.
|
||||
4. Pick a collection and click **Sync with "…"**. **Settings** sets how often it's checked, and for MakerWorld,
|
||||
Snapmaker Space, MakerOnline and Creality Cloud which print profiles each new model brings, before you do.
|
||||
Snapmaker Space, MakerOnline, Creality Cloud and NexPrint which print profiles each new model brings, before you do.
|
||||
|
||||
What happens to the models already in the provider's collection depends on the provider. On Printables, Thingiverse,
|
||||
MyMiniFactory, Snapmaker Space and Creality Cloud they're imported now, one at a time. MakerWorld rate-limits bulk imports quickly, so there only models
|
||||
MyMiniFactory, Snapmaker Space, Creality Cloud and NexPrint they're imported now, one at a time. MakerWorld rate-limits bulk imports quickly, so there only models
|
||||
added from now on come in; ones already in your library are added to the collection either way. A provider collection
|
||||
syncs with one Thingport collection at most, so one that's taken shows which collection follows it.
|
||||
|
||||
@@ -71,7 +72,7 @@ read it, and its models come in on that next visit.
|
||||
1. Open the collection's page on MakerWorld, Printables, Thingiverse or MyMiniFactory (allowed once from Thingport
|
||||
Grab's popup first). A Thingiverse user's **Likes** page works too, and so does your own **Interactions** tab on
|
||||
Snapmaker Space, where the panel's switch picks your likes or bookmarks. On MakerOnline and Creality Cloud, a
|
||||
collection page or your own **Likes** page.
|
||||
collection page or your own **Likes** page; on NexPrint, a collection open on its owner's page.
|
||||
2. Click the Thingport Grab button in the bottom-right corner. The panel lists the models in the collection.
|
||||
3. Tick **Keep this collection in sync** (**Keep these likes in sync** on a Likes page). It's off by default.
|
||||
4. Pick the models to import now and click **Import selected** (**Start import** on MakerWorld).
|
||||
@@ -101,7 +102,7 @@ linked:
|
||||
check is due, and a link to the collection on the provider's site. **Sync now** checks it right away. **Stop
|
||||
syncing** unlinks it.
|
||||
- **Settings** sets how often it's checked: every hour, 6 hours or 24 hours (the default). For MakerWorld, Snapmaker
|
||||
Space, MakerOnline and Creality Cloud it also sets which print profiles each new model brings: the default one, the
|
||||
Space, MakerOnline, Creality Cloud and NexPrint it also sets which print profiles each new model brings: the default one, the
|
||||
designer's (the default), or all of them, community ones included.
|
||||
|
||||
## How it works
|
||||
|
||||
+20
-13
@@ -3,7 +3,7 @@
|
||||
_Effective 20 October 2026._
|
||||
|
||||
Thingport Grab is a browser extension that imports 3D-printing models from MakerWorld, Printables,
|
||||
Thingiverse and, once you allow them, MyMiniFactory, Snapmaker Space, MakerOnline and Creality Cloud into **your own self-hosted Thingport instance**, and converts MakerWorld files for
|
||||
Thingiverse and, once you allow them, MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud and NexPrint into **your own self-hosted Thingport instance**, and converts MakerWorld files for
|
||||
other slicers in your browser. It has no servers of its own: the
|
||||
extension's developer never receives any of your data, and nothing is shared with anyone else.
|
||||
|
||||
@@ -34,6 +34,8 @@ the list of recent imports, which is synced between your browsers as described b
|
||||
when it changes.
|
||||
- **Your Creality Cloud sign-in (its `model_token` and `model_user_id` cookies),** as last sent to your Thingport
|
||||
instance, so it's only sent again when it changes.
|
||||
- **Your NexPrint sign-in (its `auth_token` cookie),** as last sent to your Thingport instance, so it's only sent again
|
||||
when it changes.
|
||||
- **Collection import progress:** while a MakerWorld collection import is running, the list of
|
||||
model pages still to visit, so it can carry on across page loads. It's deleted when the import
|
||||
ends.
|
||||
@@ -49,7 +51,7 @@ Snapmaker sign-in at id.snapmaker.com every few hours while the browser is open,
|
||||
|
||||
- Your email and password, to sign in.
|
||||
- The address of each model page you open on MakerWorld, Printables, Thingiverse, MyMiniFactory, Snapmaker Space,
|
||||
MakerOnline or Creality Cloud, to check
|
||||
MakerOnline, Creality Cloud or NexPrint, to check
|
||||
whether you've already imported that model. This happens when the page opens, so the extension
|
||||
knows whether to show its button. On a MakerWorld collection page, the same check runs for each
|
||||
model in the collection when you open the extension's panel there.
|
||||
@@ -75,6 +77,9 @@ Snapmaker sign-in at id.snapmaker.com every few hours while the browser is open,
|
||||
sign-in (its `model_token` and `model_user_id` cookies, and when they expire), when you open a Creality Cloud page and
|
||||
it changed since last sent. Your Thingport instance uses it to download Creality Cloud models and to read your likes
|
||||
there.
|
||||
- If you're signed in to NexPrint in this browser, and have allowed the extension there: your NexPrint sign-in (its
|
||||
`auth_token` cookie, and when it expires), when you open a NexPrint page and it changed since last sent. Your
|
||||
Thingport instance uses it to download NexPrint models.
|
||||
- On MakerOnline: the download links MakerOnline gives the page for the files of a model you import, or of the models
|
||||
waiting on your Thingport instance for the extension (it asks your instance for that list on each MakerOnline page).
|
||||
Your instance downloads the files from those links.
|
||||
@@ -86,7 +91,7 @@ Snapmaker sign-in at id.snapmaker.com every few hours while the browser is open,
|
||||
included), the models you liked there, and the models in the private collections you sync. Only
|
||||
you can see these on Printables, so this is how Thingport can sync them.
|
||||
|
||||
**To MakerWorld, Printables, Thingiverse, MyMiniFactory, Snapmaker Space, MakerOnline and Creality Cloud:**
|
||||
**To MakerWorld, Printables, Thingiverse, MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud and NexPrint:**
|
||||
|
||||
- On MakerWorld, to find a model's download link, the extension asks MakerWorld's own website for
|
||||
it, the same way the page does when you click Download, using your existing MakerWorld session.
|
||||
@@ -113,6 +118,8 @@ Snapmaker sign-in at id.snapmaker.com every few hours while the browser is open,
|
||||
MakerOnline's public model API which print profiles a model has.
|
||||
- On Creality Cloud, once you've allowed it there: when you open the panel on a model page, the extension asks Creality
|
||||
Cloud's public model API which print profiles the model has, the same way the page does, without your sign-in.
|
||||
- On NexPrint, once you've allowed it there: when you open the panel on a model page, the extension asks NexPrint's
|
||||
public model API which print profiles the model has, the same way the page does, without your sign-in.
|
||||
|
||||
**The extension reads, on supported pages only:** the model's title, MakerWorld's page data for the
|
||||
model, the model links listed on collection pages, and, on Thingiverse and MyMiniFactory, your username from the
|
||||
@@ -128,16 +135,16 @@ page's header. It doesn't read any other websites.
|
||||
|
||||
## Permissions
|
||||
|
||||
| Permission | Why |
|
||||
| ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Storage | Keeps the settings and data listed under "What the extension stores". |
|
||||
| Cookies | Reads MakerWorld's, Printables', MyMiniFactory's, MakerOnline's and Creality Cloud's session cookies, and reads and renews Snapmaker Space's sign-in cookies, only on their sites (see above). |
|
||||
| Downloads | Reads the link from the MakerWorld download it starts, then cancels and removes that download. |
|
||||
| Scripting | Adds the import button to MyMiniFactory's, Snapmaker Space's, MakerOnline's and Creality Cloud's pages, and those of sites added later, once you allow them; on MakerOnline, also asks for file links from within the page (see above). |
|
||||
| Alarms | Renews the Snapmaker Space sign-in every few hours, once you've allowed Snapmaker Space. |
|
||||
| Access to MakerWorld, Printables and Thingiverse | Shows the import button, and MakerWorld's Download normalized button, on their pages. |
|
||||
| Access to newer model sites (optional) | Asked for one site at a time, only when you click **Enable** next to it under **Providers** in the popup (MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud, and sites added in later updates). You can take this access back in your browser's extension settings. |
|
||||
| Access to your Thingport instance | Requested for that one address when you set the extension up, so it can talk to your instance. |
|
||||
| Permission | Why |
|
||||
| ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Storage | Keeps the settings and data listed under "What the extension stores". |
|
||||
| Cookies | Reads MakerWorld's, Printables', MyMiniFactory's, MakerOnline's, Creality Cloud's and NexPrint's session cookies, and reads and renews Snapmaker Space's sign-in cookies, only on their sites (see above). |
|
||||
| Downloads | Reads the link from the MakerWorld download it starts, then cancels and removes that download. |
|
||||
| Scripting | Adds the import button to MyMiniFactory's, Snapmaker Space's, MakerOnline's, Creality Cloud's and NexPrint's pages, and those of sites added later, once you allow them; on MakerOnline, also asks for file links from within the page (see above). |
|
||||
| Alarms | Renews the Snapmaker Space sign-in every few hours, once you've allowed Snapmaker Space. |
|
||||
| Access to MakerWorld, Printables and Thingiverse | Shows the import button, and MakerWorld's Download normalized button, on their pages. |
|
||||
| Access to newer model sites (optional) | Asked for one site at a time, only when you click **Enable** next to it under **Providers** in the popup (MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud, NexPrint, and sites added in later updates). You can take this access back in your browser's extension settings. |
|
||||
| Access to your Thingport instance | Requested for that one address when you set the extension up, so it can talk to your instance. |
|
||||
|
||||
## Your control over your data
|
||||
|
||||
|
||||
+10
-4
@@ -1,7 +1,7 @@
|
||||
# Thingport Grab
|
||||
|
||||
A browser extension for Chrome, Edge and Firefox that imports MakerWorld, Thingiverse, Printables
|
||||
MyMiniFactory, Snapmaker Space, MakerOnline and Creality Cloud models into your self-hosted Thingport instance without leaving the provider's site.
|
||||
MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud and NexPrint models into your self-hosted Thingport instance without leaving the provider's site.
|
||||
Visiting a model, collection, or Thingiverse Likes page shows a floating Thingport icon; clicking it
|
||||
opens a small panel to pick what to import (and, for a single model, an optional destination
|
||||
collection), then imports it the same way Thingport's own "+ Add > Import" does.
|
||||
@@ -207,12 +207,18 @@ signed in there; the site never renews it. Opening crealitycloud.com signed in c
|
||||
sends the new sign-in each time you sign in there again. On your own **Likes** page, or any collection page, the panel
|
||||
imports the listed models and can keep them in sync.
|
||||
|
||||
### NexPrint: a sign-in the site keeps fresh
|
||||
|
||||
NexPrint gives its files only to a signed-in user. Opening nexprint.com signed in connects your account to Thingport,
|
||||
and sends the new token whenever the site renews it. On a collection, open on its owner's page, the panel imports the
|
||||
listed models and can keep them in sync; NexPrint has no page of liked models, so there are no likes to import.
|
||||
|
||||
## What counts as "importable"
|
||||
|
||||
- A single model page (MakerWorld, a Thingiverse Thing, a Printables Model, a MyMiniFactory object, a Snapmaker Space,
|
||||
MakerOnline or Creality Cloud model) -- hidden automatically once you've already imported that exact page.
|
||||
- A MakerWorld collection, a Thingiverse Collection or Likes page, a Printables, MyMiniFactory, MakerOnline or Creality
|
||||
Cloud collection, your MakerOnline or Creality Cloud likes, or your own Snapmaker Space likes and bookmarks -- lets you import the listed designs in one go.
|
||||
MakerOnline, Creality Cloud or NexPrint model) -- hidden automatically once you've already imported that exact page.
|
||||
- A MakerWorld collection, a Thingiverse Collection or Likes page, a Printables, MyMiniFactory, MakerOnline, Creality
|
||||
Cloud or NexPrint collection, your MakerOnline or Creality Cloud likes, or your own Snapmaker Space likes and bookmarks -- lets you import the listed designs in one go.
|
||||
|
||||
Picking a destination collection is only offered for a single-model import; a batch import instead
|
||||
lands in Thingport's own auto-named collection for that batch (e.g. "Thingiverse likes (@username)"), matching
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
Thingport Grab {{version}}. Changes in this version: {{changes}}
|
||||
|
||||
WHAT IT DOES
|
||||
Imports 3D-printing models from MakerWorld, Printables, Thingiverse and, once turned on, MyMiniFactory, Snapmaker Space, MakerOnline and Creality Cloud into the user's own self-hosted Thingport server (open source: https://github.com/TautvydasDerzinskas/Thingport). It has no servers of its own and sends nothing to the developer or any third party.
|
||||
Imports 3D-printing models from MakerWorld, Printables, Thingiverse and, once turned on, MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud and NexPrint into the user's own self-hosted Thingport server (open source: https://github.com/TautvydasDerzinskas/Thingport). It has no servers of its own and sends nothing to the developer or any third party.
|
||||
|
||||
HOW TO TEST
|
||||
1. A Thingport server is required. {{testServer}}
|
||||
@@ -11,10 +11,10 @@ HOW TO TEST
|
||||
|
||||
PERMISSIONS
|
||||
- storage: the server address, sign-in and recent imports.
|
||||
- cookies: reads the MakerWorld session cookie, on makerworld.com only, which MakerWorld requires for downloads, and the MyMiniFactory session cookie, on myminifactory.com only, which downloads the user's bought models from their own library, and reads and renews the Snapmaker Space sign-in cookies, on snapmaker.com only, which Snapmaker requires for any download, and the MakerOnline sign-in cookie, on makeronline.com only, which reads the user's likes and private collections, and the Creality Cloud sign-in cookies, on crealitycloud.com only, which Creality Cloud requires for downloads. The MakerWorld and MyMiniFactory cookies and the Snapmaker, MakerOnline and Creality Cloud sign-ins are sent only to the user's own server; the Snapmaker refresh token is sent only to Snapmaker's own sign-in service (id.snapmaker.com), to renew the sign-in.
|
||||
- cookies: reads the MakerWorld session cookie, on makerworld.com only, which MakerWorld requires for downloads, and the MyMiniFactory session cookie, on myminifactory.com only, which downloads the user's bought models from their own library, and reads and renews the Snapmaker Space sign-in cookies, on snapmaker.com only, which Snapmaker requires for any download, and the MakerOnline sign-in cookie, on makeronline.com only, which reads the user's likes and private collections, and the Creality Cloud sign-in cookies, on crealitycloud.com only, which Creality Cloud requires for downloads, and the NexPrint sign-in cookie, on nexprint.com only, which NexPrint requires for downloads. The MakerWorld and MyMiniFactory cookies and the Snapmaker, MakerOnline, Creality Cloud and NexPrint sign-ins are sent only to the user's own server; the Snapmaker refresh token is sent only to Snapmaker's own sign-in service (id.snapmaker.com), to renew the sign-in.
|
||||
- downloads: reads the link from a download MakerWorld's page starts, then cancels it.
|
||||
- alarms: renews the Snapmaker Space sign-in every few hours, as it lasts a day; only once the user has turned Snapmaker Space on.
|
||||
- scripting: registers the content script on MyMiniFactory, Snapmaker Space, MakerOnline and Creality Cloud, and on model sites added in later versions, once the user allows them. On MakerOnline it also runs a function in the page itself (world: MAIN) that calls the site's own download endpoint, as its Download button does: MakerOnline signs file links only for requests carrying a token from its own in-page security script.
|
||||
- host access: the three model sites; the user's own server address (requested at setup); and, optionally, newer model sites (such as MyMiniFactory, Snapmaker Space, MakerOnline and Creality Cloud), each requested only when the user clicks "Enable" next to it under Providers in the popup, so model sites added in later versions need no new required permission. The content script is only ever registered on supported model sites.
|
||||
- scripting: registers the content script on MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud and NexPrint, and on model sites added in later versions, once the user allows them. On MakerOnline it also runs a function in the page itself (world: MAIN) that calls the site's own download endpoint, as its Download button does: MakerOnline signs file links only for requests carrying a token from its own in-page security script.
|
||||
- host access: the three model sites; the user's own server address (requested at setup); and, optionally, newer model sites (such as MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud and NexPrint), each requested only when the user clicks "Enable" next to it under Providers in the popup, so model sites added in later versions need no new required permission. The content script is only ever registered on supported model sites.
|
||||
|
||||
The code isn't minified or obfuscated. Documentation: https://thingport.net/docs/grab/ Privacy policy: https://github.com/TautvydasDerzinskas/Thingport/blob/main/extension/PRIVACY.md
|
||||
@@ -21,6 +21,7 @@ import { syncMyminifactorySession } from "./myminifactorySession";
|
||||
import { scheduleSnapmakerRenewal, SNAPMAKER_ALARM, syncSnapmakerSession } from "./snapmakerSession";
|
||||
import { importMakeronlineModel, runWaitingImports, syncMakeronlineSession } from "./makeronline";
|
||||
import { syncCrealitycloudSession } from "./crealitycloudSession";
|
||||
import { syncNexprintSession } from "./nexprintSession";
|
||||
import { onPairingTabClosed, onPairingTabUrl, startPairing } from "./pairing";
|
||||
import { openSetup } from "./setup";
|
||||
import { setTabIconState } from "./tabIcon";
|
||||
@@ -44,6 +45,7 @@ listen<BackgroundMessages>({
|
||||
SYNC_SNAPMAKER_SESSION: () => syncSnapmakerSession(),
|
||||
SYNC_MAKERONLINE_SESSION: () => syncMakeronlineSession(),
|
||||
SYNC_CREALITYCLOUD_SESSION: () => syncCrealitycloudSession(),
|
||||
SYNC_NEXPRINT_SESSION: () => syncNexprintSession(),
|
||||
IMPORT_MAKERONLINE_MODEL: (payload, sender) => importMakeronlineModel(senderTabId(sender), payload),
|
||||
RUN_MAKERONLINE_WAITING: (_payload, sender) => runWaitingImports(senderTabId(sender)),
|
||||
PRINTABLES_PUSH_DUE: ({ now }) => printablesPushDue({ now }),
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// The user's NexPrint sign-in, which downloads its files: the auth_token cookie, sent as a Bearer token. The site keeps
|
||||
// it for a week and sets it again whenever it refreshes the token, so Grab sends each new one on, with the cookie's
|
||||
// expiry for the app to show.
|
||||
|
||||
import { STORAGE_KEYS } from "../shared/storage";
|
||||
import { apiCall, requireConfig } from "./api";
|
||||
|
||||
const SITE = "https://www.nexprint.com";
|
||||
|
||||
/** Sends the instance the browser's NexPrint sign-in when it changed. Best-effort: never throws. */
|
||||
export async function syncNexprintSession(): Promise<void> {
|
||||
const config = await requireConfig().catch(() => null);
|
||||
if (!config) return;
|
||||
let cookie: chrome.cookies.Cookie | null;
|
||||
try {
|
||||
cookie = await chrome.cookies.get({ url: SITE, name: "auth_token" });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
// Per Thingport account, so switching the extension to another one sends it there too.
|
||||
const account = `${config.instanceUrl} ${config.email}`;
|
||||
const last = config.lastSyncedNexprintSession;
|
||||
const token = cookie?.value ? decodeURIComponent(cookie.value) : "";
|
||||
// Signed out, the site clears the cookie or leaves a placeholder in it.
|
||||
if (!/^[0-9a-f]{32,128}$/i.test(token)) {
|
||||
// Clear the sign-in Grab sent, but not one pasted in Thingport.
|
||||
if (last?.account === account) {
|
||||
try {
|
||||
await apiCall("PATCH", "/settings/nexprint-session", { token: null });
|
||||
await chrome.storage.local.remove(STORAGE_KEYS.lastSyncedNexprintSession);
|
||||
} catch {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (last?.account === account && last.value === token) return;
|
||||
try {
|
||||
await apiCall("PATCH", "/settings/nexprint-session", {
|
||||
token,
|
||||
expires_at: cookie?.expirationDate ? new Date(cookie.expirationDate * 1000).toISOString() : null,
|
||||
});
|
||||
await chrome.storage.local.set({ [STORAGE_KEYS.lastSyncedNexprintSession]: { account, value: token } });
|
||||
} catch {}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// NexPrint's own model data, as its pages read it: which print profiles a model has, and whose they are.
|
||||
|
||||
import { parseNexprintModelUrl } from "../shared/urls";
|
||||
|
||||
export type NexprintProfileScope = "url" | "designer" | "all";
|
||||
|
||||
type Profile = { id: string; designers: boolean };
|
||||
|
||||
/** The model's print profiles, from the same public API the page calls. Null when they can't be read. */
|
||||
async function loadProfiles(modelId: string): Promise<Profile[] | null> {
|
||||
try {
|
||||
const query = new URLSearchParams({ id: modelId, loadGeneratorInfo: "true", loadSupportPrintProfile: "true" });
|
||||
const res = await fetch(`/gateway/api/v1/model-library-server/model-base-info/get?${query}`, {
|
||||
headers: { "client-id": "Nexprint", "user-lang": "en" },
|
||||
credentials: "omit",
|
||||
});
|
||||
const json = (await res.json()) as { code?: number; data?: Record<string, unknown> | null };
|
||||
const data = json.code === 0 ? json.data : null;
|
||||
if (!data?.modelId) return null;
|
||||
const authorId = data.authorId == null ? null : String(data.authorId);
|
||||
const settings = Array.isArray(data.settingInfoList) ? (data.settingInfoList as Record<string, unknown>[]) : [];
|
||||
return settings.flatMap((s) => {
|
||||
const id = typeof s?.settingOutId === "string" ? s.settingOutId.toUpperCase() : null;
|
||||
if (!id || s.open === false || s.isInvalid === true) return [];
|
||||
return [{ id, designers: s.isCreator === true || (!!authorId && String(s.userId) === authorId) }];
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Mirrors the backend's selectNexprintProfiles, the linked (or designer's first) profile first. */
|
||||
function profileIds(profiles: Profile[], scope: NexprintProfileScope, requested: string | null): string[] {
|
||||
const designers = profiles.filter((p) => p.designers);
|
||||
const primary = profiles.find((p) => p.id === requested)?.id ?? designers[0]?.id ?? profiles[0]?.id ?? null;
|
||||
if (!primary) return [];
|
||||
if (scope === "url") return [primary];
|
||||
const wanted = (scope === "all" ? profiles : designers).map((p) => p.id);
|
||||
return [primary, ...wanted.filter((id) => id !== primary)];
|
||||
}
|
||||
|
||||
/** Profile ids for each scope, for the panel's picker and its import. Null when the model can't be read. */
|
||||
export async function loadNexprintProfiles(url: string): Promise<((scope: NexprintProfileScope) => string[]) | null> {
|
||||
const parsed = parseNexprintModelUrl(url);
|
||||
const profiles = parsed ? await loadProfiles(parsed.modelId) : null;
|
||||
if (!parsed || !profiles) return null;
|
||||
return (scope) => profileIds(profiles, scope, parsed.requestedProfileId);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// Batch flow for Thingiverse Likes/Collections, Printables and MyMiniFactory Collections, Snapmaker Space likes and
|
||||
// bookmarks, MakerOnline collections and likes, and Creality Cloud collections and likes. MakerWorld collections use the guided flow in
|
||||
// bookmarks, MakerOnline collections and likes, Creality Cloud collections and likes, and NexPrint collections.
|
||||
// MakerWorld collections use the guided flow in
|
||||
// makerworldCollection.ts.
|
||||
|
||||
import type { BatchEntriesResult, ImportJob, SyncLookup } from "../../shared/api";
|
||||
@@ -24,7 +25,8 @@ type BatchKey =
|
||||
| "makeronline:collection"
|
||||
| "makeronline:likes"
|
||||
| "crealitycloud:collection"
|
||||
| "crealitycloud:likes";
|
||||
| "crealitycloud:likes"
|
||||
| "nexprint:collection";
|
||||
|
||||
const SNAPMAKER_LIST = {
|
||||
entries: "/import/snapmaker-list/entries",
|
||||
@@ -72,6 +74,11 @@ const BATCH_ENDPOINTS: Record<BatchKey, { entries: string; start: string; idFiel
|
||||
"makeronline:likes": MAKERONLINE_LIST,
|
||||
"crealitycloud:collection": CREALITYCLOUD_LIST,
|
||||
"crealitycloud:likes": CREALITYCLOUD_LIST,
|
||||
"nexprint:collection": {
|
||||
entries: "/import/nexprint-collection/entries",
|
||||
start: "/import/nexprint-collection",
|
||||
idField: "model_ids",
|
||||
},
|
||||
};
|
||||
|
||||
const SNAPMAKER_LISTS: { list: SnapmakerList; label: string }[] = [
|
||||
|
||||
@@ -15,6 +15,7 @@ export const SITE_NAMES: Record<Provider, string> = {
|
||||
snapmaker: "Snapmaker Space",
|
||||
makeronline: "MakerOnline",
|
||||
crealitycloud: "Creality Cloud",
|
||||
nexprint: "NexPrint",
|
||||
};
|
||||
|
||||
/** The instance inspects the link: finds the file, reads its title, checks whether it's a zip. */
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Thingiverse, Printables, MyMiniFactory, Snapmaker Space, MakerOnline and Creality Cloud import directly; other links go through
|
||||
// Thingiverse, Printables, MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud and NexPrint import directly; other links go through
|
||||
// /import/inspect first, like the web app's useUploadImport.tsx. MakerOnline's files are signed on its own page first
|
||||
// (see background/makeronline.ts).
|
||||
|
||||
@@ -20,6 +20,7 @@ import { api, escapeHtml } from "../runtime";
|
||||
import { loadSnapmakerProfiles } from "../snapmaker";
|
||||
import { loadMakeronlineProfiles } from "../makeronline";
|
||||
import { loadCrealitycloudProfiles } from "../crealitycloud";
|
||||
import { loadNexprintProfiles } from "../nexprint";
|
||||
import { onPanelAction, panelQuery, panelQueryAll, renderPanel } from "../shell";
|
||||
import { collectionIdFor, collectionPickerHtml, readCollectionChoice, wireCollectionPicker } from "./collectionPicker";
|
||||
import { errorHtml, statusHtml, successHtml } from "./results";
|
||||
@@ -52,7 +53,7 @@ function isPaidMyminifactoryObject(): boolean {
|
||||
const PROFILE_GAP_MS = 2000;
|
||||
|
||||
/** The print profile ids a scope brings, the page's own first: MakerWorld's from its page data, Snapmaker's,
|
||||
* MakerOnline's and Creality Cloud's from their APIs. Null on other sites, or when they can't be read. */
|
||||
* MakerOnline's, Creality Cloud's and NexPrint's from their APIs. Null on other sites, or when they can't be read. */
|
||||
async function profileIdsFor(): Promise<((scope: MakerworldProfileScope) => string[]) | null> {
|
||||
const { url, classification } = ctx();
|
||||
if (classification.provider === "snapmaker" && classification.type === "model") return loadSnapmakerProfiles(url);
|
||||
@@ -60,6 +61,7 @@ async function profileIdsFor(): Promise<((scope: MakerworldProfileScope) => stri
|
||||
if (classification.provider === "crealitycloud" && classification.type === "model") {
|
||||
return loadCrealitycloudProfiles(url);
|
||||
}
|
||||
if (classification.provider === "nexprint" && classification.type === "model") return loadNexprintProfiles(url);
|
||||
if (classification.provider !== "makerworld" || classification.type !== "model") return null;
|
||||
const page = await loadMakerworldDesignForPage(url);
|
||||
return page ? (scope) => makerworldProfileIds(page.design, scope, page.requestedInstanceId) : null;
|
||||
@@ -87,6 +89,13 @@ function selectedProfileScope(): MakerworldProfileScope {
|
||||
}
|
||||
|
||||
function profileUrl(url: string, instanceId: string): string {
|
||||
// NexPrint's own links name a profile in their query.
|
||||
if (ctx().classification.provider === "nexprint") {
|
||||
const named = new URL(url);
|
||||
named.hash = "";
|
||||
named.searchParams.set("printConfigId", instanceId);
|
||||
return named.toString();
|
||||
}
|
||||
return `${url.split("#")[0]}#profileId-${instanceId}`;
|
||||
}
|
||||
|
||||
@@ -144,7 +153,8 @@ export async function loadSingleItem(): Promise<void> {
|
||||
(provider === "myminifactory" && type === "object") ||
|
||||
(provider === "snapmaker" && type === "model") ||
|
||||
(provider === "makeronline" && type === "model") ||
|
||||
(provider === "crealitycloud" && type === "model");
|
||||
(provider === "crealitycloud" && type === "model") ||
|
||||
(provider === "nexprint" && type === "model");
|
||||
|
||||
let zipFilename: string | null = null;
|
||||
if (skipInspect) {
|
||||
|
||||
@@ -15,6 +15,7 @@ const PROVIDER_NAMES: Record<string, string> = {
|
||||
snapmaker: "Snapmaker Space",
|
||||
makeronline: "MakerOnline",
|
||||
crealitycloud: "Creality Cloud",
|
||||
nexprint: "NexPrint",
|
||||
};
|
||||
|
||||
/** A Thingiverse user's Likes page, which a sync follows like a collection. */
|
||||
|
||||
@@ -93,6 +93,12 @@ export function captureProviderAccount(hostname: string): void {
|
||||
void send("SYNC_CREALITYCLOUD_SESSION");
|
||||
return;
|
||||
}
|
||||
if (/(^|\.)nexprint\.com$/i.test(hostname)) {
|
||||
captured = true;
|
||||
// Its sign-in downloads the files; the site renews it as it refreshes the token, and each new one is sent on.
|
||||
void send("SYNC_NEXPRINT_SESSION");
|
||||
return;
|
||||
}
|
||||
if (hostname === "space.snapmaker.com") {
|
||||
captured = true;
|
||||
// Its sign-in downloads the files, and lasts a day: kept fresh by the background, and sent on.
|
||||
|
||||
@@ -20,7 +20,6 @@ type Logo = {
|
||||
const COMING_SOON = [
|
||||
{ id: "thangs", name: "Thangs" },
|
||||
{ id: "cults3d", name: "Cults3D" },
|
||||
{ id: "nexprint", name: "NextPrint" },
|
||||
] as const;
|
||||
|
||||
// Same glyphs as the web app's providerLogos.tsx.
|
||||
|
||||
@@ -122,6 +122,8 @@ export type BackgroundMessages = {
|
||||
SYNC_MAKERONLINE_SESSION: { payload: void; result: { userId: string | null } };
|
||||
/** On a Creality Cloud page: send the sign-in on, if it changed. Says who's signed in there. */
|
||||
SYNC_CREALITYCLOUD_SESSION: { payload: void; result: { userId: string | null } };
|
||||
/** On a NexPrint page: send the sign-in on, if it changed. */
|
||||
SYNC_NEXPRINT_SESSION: { payload: void; result: void };
|
||||
/** On a MakerOnline model page: get its files signed there, then import it. */
|
||||
IMPORT_MAKERONLINE_MODEL: {
|
||||
payload: { url: string; scope: "url" | "designer" | "all"; collectionId: string | null; title: string | null };
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// the background registers the content script there. It only ever runs on the sites listed here.
|
||||
|
||||
export type OptionalSite = {
|
||||
id: "myminifactory" | "snapmaker" | "makeronline" | "crealitycloud";
|
||||
id: "myminifactory" | "snapmaker" | "makeronline" | "crealitycloud" | "nexprint";
|
||||
name: string;
|
||||
origins: string[];
|
||||
};
|
||||
@@ -15,6 +15,7 @@ export const OPTIONAL_SITES: readonly OptionalSite[] = [
|
||||
{ id: "snapmaker", name: "Snapmaker Space", origins: ["*://space.snapmaker.com/*", "*://id.snapmaker.com/*"] },
|
||||
{ id: "makeronline", name: "MakerOnline", origins: ["*://*.makeronline.com/*"] },
|
||||
{ id: "crealitycloud", name: "Creality Cloud", origins: ["*://*.crealitycloud.com/*"] },
|
||||
{ id: "nexprint", name: "NexPrint", origins: ["*://*.nexprint.com/*"] },
|
||||
];
|
||||
|
||||
const scriptId = (site: OptionalSite) => `site-${site.id}`;
|
||||
|
||||
@@ -26,6 +26,8 @@ export const STORAGE_KEYS = {
|
||||
lastSyncedMakeronlineToken: "lastSyncedMakeronlineToken",
|
||||
// The Creality Cloud sign-in last sent, and to which Thingport account.
|
||||
lastSyncedCrealitycloudSession: "lastSyncedCrealitycloudSession",
|
||||
// The NexPrint token last sent, and to which Thingport account.
|
||||
lastSyncedNexprintSession: "lastSyncedNexprintSession",
|
||||
// The Printables and Thingiverse usernames last sent to the instance, so unchanged ones aren't.
|
||||
lastSentProviderAccounts: "lastSentProviderAccounts",
|
||||
// When Printables' lists were last read and sent, so a burst of page loads reads them once.
|
||||
@@ -52,6 +54,7 @@ export type StoredConfig = {
|
||||
lastSyncedSnapmakerToken?: { account: string; value: string };
|
||||
lastSyncedMakeronlineToken?: { account: string; value: string };
|
||||
lastSyncedCrealitycloudSession?: { account: string; value: string };
|
||||
lastSyncedNexprintSession?: { account: string; value: string };
|
||||
/** `account` is the Thingport instance and email they were sent to. */
|
||||
lastSentProviderAccounts?: { account: string; values: Record<string, string> };
|
||||
lastPrintablesPushAt?: number;
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
// Provider URL recognition, duplicated from the frontend and backend parsers. Keep in sync by hand.
|
||||
|
||||
export type Provider =
|
||||
"makerworld" | "thingiverse" | "printables" | "myminifactory" | "snapmaker" | "makeronline" | "crealitycloud";
|
||||
| "makerworld"
|
||||
| "thingiverse"
|
||||
| "printables"
|
||||
| "myminifactory"
|
||||
| "snapmaker"
|
||||
| "makeronline"
|
||||
| "crealitycloud"
|
||||
| "nexprint";
|
||||
|
||||
export type Classification =
|
||||
| { kind: "single"; provider: "makerworld"; type: "model" }
|
||||
@@ -11,13 +18,15 @@ export type Classification =
|
||||
| { kind: "single"; provider: "snapmaker"; type: "model" }
|
||||
| { kind: "single"; provider: "makeronline"; type: "model" }
|
||||
| { kind: "single"; provider: "crealitycloud"; type: "model" }
|
||||
| { kind: "single"; provider: "nexprint"; type: "model" }
|
||||
| { kind: "batch"; provider: "makerworld"; type: "collection" }
|
||||
| { kind: "batch"; provider: "thingiverse"; type: "likes" | "collection" }
|
||||
| { kind: "batch"; provider: "printables"; type: "collection" }
|
||||
| { kind: "batch"; provider: "myminifactory"; type: "collection" }
|
||||
| { kind: "batch"; provider: "snapmaker"; type: SnapmakerList }
|
||||
| { kind: "batch"; provider: "makeronline"; type: "collection" | "likes" }
|
||||
| { kind: "batch"; provider: "crealitycloud"; type: "collection" | "likes" };
|
||||
| { kind: "batch"; provider: "crealitycloud"; type: "collection" | "likes" }
|
||||
| { kind: "batch"; provider: "nexprint"; type: "collection" };
|
||||
|
||||
export type SnapmakerList = "likes" | "bookmarks";
|
||||
|
||||
@@ -209,6 +218,36 @@ export function parseCrealitycloudLikesUrl(url: string): { userId: string } | nu
|
||||
return m ? { userId: m[1] } : null;
|
||||
}
|
||||
|
||||
function isNexprint(parsed: URL): boolean {
|
||||
return /^(www\.)?nexprint\.com$/i.test(parsed.hostname);
|
||||
}
|
||||
|
||||
const NEXPRINT_LANG = String.raw`(?:[a-z]{2}(?:-[a-z]{2,4})?/)?`;
|
||||
|
||||
/** `/en/models/<id>`; `?printConfigId=<id>` names a print profile, as do Thingport's own `#profileId-<id>` links. */
|
||||
export function parseNexprintModelUrl(url: string): { modelId: string; requestedProfileId: string | null } | null {
|
||||
const parsed = parse(url);
|
||||
if (!parsed || !isNexprint(parsed)) return null;
|
||||
const m = parsed.pathname.match(new RegExp(String.raw`^/${NEXPRINT_LANG}models/(G\d+)/?$`, "i"));
|
||||
if (!m) return null;
|
||||
const profile = parsed.hash.match(/profileId-(G\d+)/i)?.[1] ?? parsed.searchParams.get("printConfigId");
|
||||
return {
|
||||
modelId: m[1].toUpperCase(),
|
||||
requestedProfileId: profile && /^G\d+$/i.test(profile) ? profile.toUpperCase() : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** A collection: `/en/collection/<id>`, or open on its owner's page (`/en/<user>/home?...&collectionId=<id>`). */
|
||||
export function isNexprintCollectionUrl(url: string): boolean {
|
||||
const parsed = parse(url);
|
||||
if (!parsed || !isNexprint(parsed)) return false;
|
||||
if (new RegExp(String.raw`^/${NEXPRINT_LANG}collection/G\d+/?$`, "i").test(parsed.pathname)) return true;
|
||||
return (
|
||||
new RegExp(String.raw`^/${NEXPRINT_LANG}U\d+/home/?$`, "i").test(parsed.pathname) &&
|
||||
/^G\d+$/i.test(parsed.searchParams.get("collectionId") ?? "")
|
||||
);
|
||||
}
|
||||
|
||||
/** Null keeps the icon hidden. "single" pages get a dedup check; "batch" pages always show it. */
|
||||
export function classifyUrl(url: string): Classification | null {
|
||||
if (isMakerworldCollectionUrl(url)) return { kind: "batch", provider: "makerworld", type: "collection" };
|
||||
@@ -222,6 +261,7 @@ export function classifyUrl(url: string): Classification | null {
|
||||
if (isMakeronlineLikesUrl(url)) return { kind: "batch", provider: "makeronline", type: "likes" };
|
||||
if (isCrealitycloudCollectionUrl(url)) return { kind: "batch", provider: "crealitycloud", type: "collection" };
|
||||
if (parseCrealitycloudLikesUrl(url)) return { kind: "batch", provider: "crealitycloud", type: "likes" };
|
||||
if (isNexprintCollectionUrl(url)) return { kind: "batch", provider: "nexprint", type: "collection" };
|
||||
if (parseMakerworldModelUrl(url)) return { kind: "single", provider: "makerworld", type: "model" };
|
||||
if (parseThingiverseThingUrl(url)) return { kind: "single", provider: "thingiverse", type: "thing" };
|
||||
if (parsePrintablesModelUrl(url)) return { kind: "single", provider: "printables", type: "model" };
|
||||
@@ -229,5 +269,6 @@ export function classifyUrl(url: string): Classification | null {
|
||||
if (parseSnapmakerModelUrl(url)) return { kind: "single", provider: "snapmaker", type: "model" };
|
||||
if (parseMakeronlineModelUrl(url)) return { kind: "single", provider: "makeronline", type: "model" };
|
||||
if (parseCrealitycloudModelUrl(url)) return { kind: "single", provider: "crealitycloud", type: "model" };
|
||||
if (parseNexprintModelUrl(url)) return { kind: "single", provider: "nexprint", type: "model" };
|
||||
return null;
|
||||
}
|
||||
@@ -22,6 +22,7 @@ export type Category = {
|
||||
snapmaker_cat_ids: string;
|
||||
makeronline_cat_ids: string;
|
||||
crealitycloud_cat_ids: string;
|
||||
nexprint_cat_ids: string;
|
||||
};
|
||||
|
||||
export type CategoryMetaInput = {
|
||||
@@ -34,6 +35,7 @@ export type CategoryMetaInput = {
|
||||
snapmakerCatIds: string;
|
||||
makeronlineCatIds: string;
|
||||
crealitycloudCatIds: string;
|
||||
nexprintCatIds: string;
|
||||
};
|
||||
|
||||
export const categoriesApi = {
|
||||
@@ -84,6 +86,7 @@ export const categoriesApi = {
|
||||
snapmaker_cat_ids: meta.snapmakerCatIds,
|
||||
makeronline_cat_ids: meta.makeronlineCatIds,
|
||||
crealitycloud_cat_ids: meta.crealitycloudCatIds,
|
||||
nexprint_cat_ids: meta.nexprintCatIds,
|
||||
}),
|
||||
});
|
||||
if (res.status === 401) throw new UnauthorizedError();
|
||||
|
||||
@@ -209,6 +209,20 @@ export const importsApi = {
|
||||
return res.json();
|
||||
},
|
||||
|
||||
/** fromMakerworldProfiles for a NexPrint model. */
|
||||
fromNexprintProfiles: async (
|
||||
payload: ImportLinkPayload & { scope: Exclude<MakerworldProfileScope, "url"> },
|
||||
): Promise<{ job_id: string }> => {
|
||||
const res = await fetch(`${apiBase()}/import/nexprint-profiles`, {
|
||||
method: "POST",
|
||||
headers: authHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (res.status === 401) throw new UnauthorizedError();
|
||||
if (!res.ok) throw new Error(await readErrorMessage(res, "Import failed"));
|
||||
return res.json();
|
||||
},
|
||||
|
||||
/** Returns immediately; ImportJobContext polls the job. A pasted list of links, imported one at
|
||||
* a time; what fails can be rerun with retryImportJob. */
|
||||
fromLinks: async (
|
||||
@@ -438,6 +452,30 @@ export const importsApi = {
|
||||
return res.json();
|
||||
},
|
||||
|
||||
/** A NexPrint collection, private ones too. */
|
||||
listNexprintCollectionEntries: async (payload: ImportLinkPayload): Promise<ImportCollectionEntriesResult> => {
|
||||
const res = await fetch(`${apiBase()}/import/nexprint-collection/entries`, {
|
||||
method: "POST",
|
||||
headers: authHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (res.status === 401) throw new UnauthorizedError();
|
||||
if (!res.ok) throw new Error(await readErrorMessage(res, "Could not load this collection"));
|
||||
return res.json();
|
||||
},
|
||||
|
||||
/** Returns immediately; ImportJobContext polls the job. */
|
||||
fromNexprintCollection: async (payload: ImportLinkPayload & { model_ids: string[] }): Promise<{ job_id: string }> => {
|
||||
const res = await fetch(`${apiBase()}/import/nexprint-collection`, {
|
||||
method: "POST",
|
||||
headers: authHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (res.status === 401) throw new UnauthorizedError();
|
||||
if (!res.ok) throw new Error(await readErrorMessage(res, "NexPrint import failed"));
|
||||
return res.json();
|
||||
},
|
||||
|
||||
/** MakerOnline gives its files only to its own pages: the link waits for Thingport Grab's next visit there. */
|
||||
queueMakeronline: async (
|
||||
payload: ImportLinkPayload & { scope: MakerworldProfileScope },
|
||||
|
||||
@@ -10,6 +10,7 @@ export const BROWSABLE_PROVIDERS = [
|
||||
"snapmaker",
|
||||
"makeronline",
|
||||
"crealitycloud",
|
||||
"nexprint",
|
||||
] as const;
|
||||
export type BrowsableProvider = (typeof BROWSABLE_PROVIDERS)[number];
|
||||
|
||||
|
||||
@@ -147,7 +147,14 @@ export type DatabaseCredentialsInput = {
|
||||
};
|
||||
|
||||
export type ImportProvider =
|
||||
"makerworld" | "printables" | "thingiverse" | "myminifactory" | "snapmaker" | "makeronline" | "crealitycloud";
|
||||
| "makerworld"
|
||||
| "printables"
|
||||
| "thingiverse"
|
||||
| "myminifactory"
|
||||
| "snapmaker"
|
||||
| "makeronline"
|
||||
| "crealitycloud"
|
||||
| "nexprint";
|
||||
/** An admin can turn a provider off for the whole instance; menus hide it then. */
|
||||
export type ProviderSetting = { id: ImportProvider; enabled: boolean };
|
||||
|
||||
@@ -431,6 +438,25 @@ export const settingsApi = {
|
||||
return res.json();
|
||||
},
|
||||
|
||||
// The user's own NexPrint sign-in (its auth_token cookie), which Thingport Grab sends on. Write-only.
|
||||
getNexprintSession: async (): Promise<{ configured: boolean; expired: boolean }> => {
|
||||
const res = await fetch(`${apiBase()}/settings/nexprint-session`, { headers: authHeaders() });
|
||||
assertOk(res, "Failed to load NexPrint settings");
|
||||
return res.json();
|
||||
},
|
||||
|
||||
/** Checked with NexPrint before it's saved. */
|
||||
updateNexprintSession: async (token: string | null): Promise<{ configured: boolean }> => {
|
||||
const res = await fetch(`${apiBase()}/settings/nexprint-session`, {
|
||||
method: "PATCH",
|
||||
headers: authHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify({ token, verify: true }),
|
||||
});
|
||||
if (res.status === 401) throw new UnauthorizedError();
|
||||
if (!res.ok) throw new Error(await readErrorMessage(res, "Failed to update NexPrint settings"));
|
||||
return res.json();
|
||||
},
|
||||
|
||||
getAuth: async (): Promise<AuthSettings> => {
|
||||
const res = await fetch(`${apiBase()}/settings/auth`, { headers: authHeaders() });
|
||||
assertOk(res, "Failed to load session settings");
|
||||
|
||||
@@ -18,6 +18,7 @@ const LOGO_PROVIDERS = new Set<string>([
|
||||
"snapmaker",
|
||||
"makeronline",
|
||||
"crealitycloud",
|
||||
"nexprint",
|
||||
]);
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -20,6 +20,7 @@ const LOGO_PROVIDERS = new Set<string>([
|
||||
"snapmaker",
|
||||
"makeronline",
|
||||
"crealitycloud",
|
||||
"nexprint",
|
||||
]);
|
||||
|
||||
/** The provider's logo with a small green tick, slim enough for a toolbar row. Opens the
|
||||
|
||||
@@ -42,6 +42,8 @@ import {
|
||||
isMakeronlineModelUrl,
|
||||
isCrealitycloudListUrl,
|
||||
isCrealitycloudModelUrl,
|
||||
isNexprintCollectionUrl,
|
||||
isNexprintModelUrl,
|
||||
isThingiverseCollectionUrl,
|
||||
isThingiverseLikesUrl,
|
||||
type ImportProviderKey,
|
||||
@@ -57,6 +59,7 @@ const IMPORT_PROVIDERS: ImportProviderKey[] = [
|
||||
"snapmaker",
|
||||
"makeronline",
|
||||
"crealitycloud",
|
||||
"nexprint",
|
||||
];
|
||||
|
||||
/** What the search palette's !import command calls. */
|
||||
@@ -124,7 +127,8 @@ const AddMenu = React.forwardRef<AddMenuHandle, Props>(function AddMenu(
|
||||
isMyminifactoryCollectionUrl(link) ||
|
||||
isSnapmakerListUrl(link) ||
|
||||
isMakeronlineListUrl(link) ||
|
||||
isCrealitycloudListUrl(link),
|
||||
isCrealitycloudListUrl(link) ||
|
||||
isNexprintCollectionUrl(link),
|
||||
)
|
||||
: isMakerworldCollectionUrl(linkValue);
|
||||
// Models with print profiles to pick between.
|
||||
@@ -133,7 +137,8 @@ const AddMenu = React.forwardRef<AddMenuHandle, Props>(function AddMenu(
|
||||
isMakerworldModelUrl(link) ||
|
||||
isSnapmakerModelUrl(link) ||
|
||||
isMakeronlineModelUrl(link) ||
|
||||
isCrealitycloudModelUrl(link),
|
||||
isCrealitycloudModelUrl(link) ||
|
||||
isNexprintModelUrl(link),
|
||||
);
|
||||
|
||||
const closeMenu = () => setAnchorEl(null);
|
||||
|
||||
@@ -32,6 +32,7 @@ const PROVIDERS: { id: ProviderId; name: string }[] = [
|
||||
{ id: "snapmaker", name: "Snapmaker Space" },
|
||||
{ id: "makeronline", name: "MakerOnline" },
|
||||
{ id: "crealitycloud", name: "Creality Cloud" },
|
||||
{ id: "nexprint", name: "NexPrint" },
|
||||
...COMING_SOON_PROVIDERS,
|
||||
];
|
||||
const isComingSoon = (id: ProviderId) => COMING_SOON_PROVIDERS.some((provider) => provider.id === id);
|
||||
@@ -40,14 +41,16 @@ const SHARED = new Set<ProviderId>(["thingiverse"]);
|
||||
// Connected with the user's own sign-in, which the site can turn down once it expires. MyMiniFactory's also has
|
||||
// the instance's API key, set in Administration; the user's sign-in adds paid and login-only models. Snapmaker's
|
||||
// lasts a day; Thingport Grab keeps renewing it. MakerOnline's reads the user's likes and private collections; its
|
||||
// files only ever come through Thingport Grab. Creality Cloud's lasts a week from signing in there.
|
||||
type SessionProvider = "makerworld" | "myminifactory" | "snapmaker" | "makeronline" | "crealitycloud";
|
||||
// files only ever come through Thingport Grab. Creality Cloud's lasts a week from signing in there; NexPrint's is
|
||||
// renewed by the site as the user visits it.
|
||||
type SessionProvider = "makerworld" | "myminifactory" | "snapmaker" | "makeronline" | "crealitycloud" | "nexprint";
|
||||
const SESSION_PROVIDERS = new Set<ProviderId>([
|
||||
"makerworld",
|
||||
"myminifactory",
|
||||
"snapmaker",
|
||||
"makeronline",
|
||||
"crealitycloud",
|
||||
"nexprint",
|
||||
]);
|
||||
const isSessionProvider = (id: ProviderId): id is SessionProvider => SESSION_PROVIDERS.has(id);
|
||||
|
||||
@@ -75,7 +78,7 @@ export default function ProvidersPanel({ isAdmin, cookie, onUpdateMakerWorld, on
|
||||
cults3d: false,
|
||||
makeronline: null,
|
||||
crealitycloud: null,
|
||||
nexprint: false,
|
||||
nexprint: null,
|
||||
snapmaker: null,
|
||||
});
|
||||
const [menu, setMenu] = React.useState<{ id: ProviderId; top: number; left: number } | null>(null);
|
||||
@@ -85,6 +88,7 @@ export default function ProvidersPanel({ isAdmin, cookie, onUpdateMakerWorld, on
|
||||
snapmaker: false,
|
||||
makeronline: false,
|
||||
crealitycloud: false,
|
||||
nexprint: false,
|
||||
});
|
||||
const isExpired = (id: ProviderId) => isSessionProvider(id) && Boolean(connected[id]) && expired[id];
|
||||
const [connecting, setConnecting] = React.useState<ProviderId | null>(null);
|
||||
@@ -137,6 +141,14 @@ export default function ProvidersPanel({ isAdmin, cookie, onUpdateMakerWorld, on
|
||||
setExpired((current) => ({ ...current, crealitycloud: res.expired }));
|
||||
})
|
||||
.catch(() => undefined);
|
||||
settingsApi
|
||||
.getNexprintSession()
|
||||
.then((res) => {
|
||||
if (!active) return;
|
||||
setConnected((current) => ({ ...current, nexprint: res.configured }));
|
||||
setExpired((current) => ({ ...current, nexprint: res.expired }));
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
@@ -177,6 +189,10 @@ export default function ProvidersPanel({ isAdmin, cookie, onUpdateMakerWorld, on
|
||||
const result = await settingsApi.updateCrealitycloudSession(value);
|
||||
setConnected((current) => ({ ...current, crealitycloud: result.configured }));
|
||||
setExpired((current) => ({ ...current, crealitycloud: false }));
|
||||
} else if (id === "nexprint") {
|
||||
const result = await settingsApi.updateNexprintSession(value);
|
||||
setConnected((current) => ({ ...current, nexprint: result.configured }));
|
||||
setExpired((current) => ({ ...current, nexprint: false }));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ export type ProviderId =
|
||||
export const COMING_SOON_PROVIDERS: { id: ProviderId; name: string }[] = [
|
||||
{ id: "thangs", name: "Thangs" },
|
||||
{ id: "cults3d", name: "Cults3D" },
|
||||
{ id: "nexprint", name: "NextPrint" },
|
||||
];
|
||||
|
||||
type Logo = {
|
||||
|
||||
@@ -87,6 +87,7 @@ export const SEARCH_PAGES: SearchPage[] = [
|
||||
"snapmaker",
|
||||
"makeronline",
|
||||
"creality cloud",
|
||||
"nexprint",
|
||||
"app token",
|
||||
"api key",
|
||||
"access token",
|
||||
|
||||
@@ -15,6 +15,8 @@ type StartSnapmakerProfilesImportPayload = Parameters<typeof importsApi.fromSnap
|
||||
type StartSnapmakerListImportPayload = Parameters<typeof importsApi.fromSnapmakerList>[0];
|
||||
type StartCrealitycloudProfilesImportPayload = Parameters<typeof importsApi.fromCrealitycloudProfiles>[0];
|
||||
type StartCrealitycloudListImportPayload = Parameters<typeof importsApi.fromCrealitycloudList>[0];
|
||||
type StartNexprintProfilesImportPayload = Parameters<typeof importsApi.fromNexprintProfiles>[0];
|
||||
type StartNexprintCollectionImportPayload = Parameters<typeof importsApi.fromNexprintCollection>[0];
|
||||
type StartLinksImportPayload = Parameters<typeof importsApi.fromLinks>[0];
|
||||
|
||||
type ImportJobContextValue = {
|
||||
@@ -32,6 +34,8 @@ type ImportJobContextValue = {
|
||||
startSnapmakerListImport: (payload: StartSnapmakerListImportPayload) => Promise<void>;
|
||||
startCrealitycloudProfilesImport: (payload: StartCrealitycloudProfilesImportPayload) => Promise<void>;
|
||||
startCrealitycloudListImport: (payload: StartCrealitycloudListImportPayload) => Promise<void>;
|
||||
startNexprintProfilesImport: (payload: StartNexprintProfilesImportPayload) => Promise<void>;
|
||||
startNexprintCollectionImport: (payload: StartNexprintCollectionImportPayload) => Promise<void>;
|
||||
startLinksImport: (payload: StartLinksImportPayload) => Promise<void>;
|
||||
/** Reruns the links that failed on the finished job shown in the progress bar. */
|
||||
retryJob: () => Promise<void>;
|
||||
@@ -239,6 +243,26 @@ export function ImportJobProvider({
|
||||
[startPolling],
|
||||
);
|
||||
|
||||
const startNexprintProfilesImport = useCallback(
|
||||
async (payload: StartNexprintProfilesImportPayload) => {
|
||||
const { job_id } = await importsApi.fromNexprintProfiles(payload);
|
||||
const job = await importsApi.getImportJob(job_id);
|
||||
setActiveJob(job);
|
||||
startPolling(job_id);
|
||||
},
|
||||
[startPolling],
|
||||
);
|
||||
|
||||
const startNexprintCollectionImport = useCallback(
|
||||
async (payload: StartNexprintCollectionImportPayload) => {
|
||||
const { job_id } = await importsApi.fromNexprintCollection(payload);
|
||||
const job = await importsApi.getImportJob(job_id);
|
||||
setActiveJob(job);
|
||||
startPolling(job_id);
|
||||
},
|
||||
[startPolling],
|
||||
);
|
||||
|
||||
const startLinksImport = useCallback(
|
||||
async (payload: StartLinksImportPayload) => {
|
||||
const { job_id } = await importsApi.fromLinks(payload);
|
||||
@@ -277,6 +301,8 @@ export function ImportJobProvider({
|
||||
startSnapmakerListImport,
|
||||
startCrealitycloudProfilesImport,
|
||||
startCrealitycloudListImport,
|
||||
startNexprintProfilesImport,
|
||||
startNexprintCollectionImport,
|
||||
startLinksImport,
|
||||
retryJob,
|
||||
dismissJob,
|
||||
@@ -294,6 +320,8 @@ export function ImportJobProvider({
|
||||
startSnapmakerListImport,
|
||||
startCrealitycloudProfilesImport,
|
||||
startCrealitycloudListImport,
|
||||
startNexprintProfilesImport,
|
||||
startNexprintCollectionImport,
|
||||
startLinksImport,
|
||||
retryJob,
|
||||
dismissJob,
|
||||
|
||||
@@ -25,7 +25,15 @@ import { settingsApi } from "../../api/settings";
|
||||
import { useDisabledProviders } from "../../hooks/useDisabledProviders";
|
||||
|
||||
type ServiceChipDef = {
|
||||
key: "makerworld" | "thingiverse" | "printables" | "myminifactory" | "snapmaker" | "makeronline" | "crealitycloud";
|
||||
key:
|
||||
| "makerworld"
|
||||
| "thingiverse"
|
||||
| "printables"
|
||||
| "myminifactory"
|
||||
| "snapmaker"
|
||||
| "makeronline"
|
||||
| "crealitycloud"
|
||||
| "nexprint";
|
||||
label: string;
|
||||
connected: boolean;
|
||||
/** Explains what's missing when not connected. */
|
||||
@@ -56,6 +64,8 @@ export function UserMenu({ user, onOpenConfiguration, onOpenProfile, onLogout }:
|
||||
const [makeronlineExpired, setMakeronlineExpired] = React.useState(false);
|
||||
const [crealitycloudConfigured, setCrealitycloudConfigured] = React.useState(false);
|
||||
const [crealitycloudExpired, setCrealitycloudExpired] = React.useState(false);
|
||||
const [nexprintConfigured, setNexprintConfigured] = React.useState(false);
|
||||
const [nexprintExpired, setNexprintExpired] = React.useState(false);
|
||||
|
||||
const menuOpen = Boolean(anchorEl);
|
||||
// Checked on every open: the cookie can change in Configuration while the menu stays mounted.
|
||||
@@ -106,6 +116,14 @@ export function UserMenu({ user, onOpenConfiguration, onOpenProfile, onLogout }:
|
||||
setCrealitycloudExpired(res.expired);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
settingsApi
|
||||
.getNexprintSession()
|
||||
.then((res) => {
|
||||
if (!active) return;
|
||||
setNexprintConfigured(res.configured);
|
||||
setNexprintExpired(res.expired);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
@@ -155,6 +173,12 @@ export function UserMenu({ user, onOpenConfiguration, onOpenProfile, onLogout }:
|
||||
? t("providers.crealitycloud.expired")
|
||||
: t("userMenu.crealitycloudDisabledReason"),
|
||||
},
|
||||
{
|
||||
key: "nexprint",
|
||||
...IMPORT_PROVIDER_INFO.nexprint,
|
||||
connected: nexprintConfigured && !nexprintExpired,
|
||||
disabledReason: nexprintExpired ? t("providers.nexprint.expired") : t("userMenu.nexprintDisabledReason"),
|
||||
},
|
||||
];
|
||||
// Turned off by an admin: no icon. Coming-soon sites follow the live ones.
|
||||
const services: { id: ProviderId; label: string; connected: boolean; tooltip: string }[] = [
|
||||
|
||||
@@ -33,6 +33,8 @@ import {
|
||||
isMakeronlineModelUrl,
|
||||
isCrealitycloudListUrl,
|
||||
isCrealitycloudModelUrl,
|
||||
isNexprintCollectionUrl,
|
||||
isNexprintModelUrl,
|
||||
isThingiverseCollectionUrl,
|
||||
isThingiverseLikesUrl,
|
||||
isThingiverseThingUrl,
|
||||
@@ -74,6 +76,8 @@ export function useUploadImport({ onUploaded, categoryId, makerworldCookie, onUn
|
||||
startSnapmakerListImport,
|
||||
startCrealitycloudProfilesImport,
|
||||
startCrealitycloudListImport,
|
||||
startNexprintProfilesImport,
|
||||
startNexprintCollectionImport,
|
||||
startLinksImport,
|
||||
} = useImportJob();
|
||||
const isBusy = uploading || importing || zipPrompt.isOpen || collectionPrompt.isOpen || importModePrompt.isOpen;
|
||||
@@ -261,8 +265,8 @@ export function useUploadImport({ onUploaded, categoryId, makerworldCookie, onUn
|
||||
showToast({ message: t(key, { name }) });
|
||||
};
|
||||
|
||||
/** `profileScope`: which MakerWorld, Snapmaker, MakerOnline or Creality Cloud print profiles to import; more than
|
||||
* the link's runs as a job, and MakerOnline's wait for Thingport Grab. */
|
||||
/** `profileScope`: which MakerWorld, Snapmaker, MakerOnline, Creality Cloud or NexPrint print profiles to import;
|
||||
* more than the link's runs as a job, and MakerOnline's wait for Thingport Grab. */
|
||||
const submitImport = async (
|
||||
rawUrl: string,
|
||||
captcha?: CaptchaAnswer | null,
|
||||
@@ -301,6 +305,11 @@ export function useUploadImport({ onUploaded, categoryId, makerworldCookie, onUn
|
||||
return;
|
||||
}
|
||||
|
||||
if (profileScope !== "url" && isNexprintModelUrl(url)) {
|
||||
await startNexprintProfilesImport({ ...payload, scope: profileScope });
|
||||
return;
|
||||
}
|
||||
|
||||
if (isMakeronlineModelUrl(url)) {
|
||||
// Its files come only through Thingport Grab, on makeronline.com.
|
||||
await importsApi.queueMakeronline({ ...payload, scope: profileScope });
|
||||
@@ -471,6 +480,33 @@ export function useUploadImport({ onUploaded, categoryId, makerworldCookie, onUn
|
||||
return;
|
||||
}
|
||||
|
||||
if (isNexprintCollectionUrl(url)) {
|
||||
setImporting(false);
|
||||
await collectionPrompt.prompt({
|
||||
label: url,
|
||||
loadEntries: async () => {
|
||||
try {
|
||||
return await importsApi.listNexprintCollectionEntries(payload);
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedError) onUnauthorized?.();
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
onImportSelected: async (modelIds: string[]) => {
|
||||
try {
|
||||
await startNexprintCollectionImport({ ...payload, model_ids: modelIds });
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedError) {
|
||||
onUnauthorized?.();
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSnapmakerListUrl(url)) {
|
||||
setImporting(false);
|
||||
await collectionPrompt.prompt({
|
||||
@@ -503,7 +539,8 @@ export function useUploadImport({ onUploaded, categoryId, makerworldCookie, onUn
|
||||
isPrintablesModelUrl(url) ||
|
||||
isMyminifactoryObjectUrl(url) ||
|
||||
isSnapmakerModelUrl(url) ||
|
||||
isCrealitycloudModelUrl(url)
|
||||
isCrealitycloudModelUrl(url) ||
|
||||
isNexprintModelUrl(url)
|
||||
) {
|
||||
// The backend splits these into plates itself.
|
||||
const imported = await importsApi.fromLink(payload);
|
||||
|
||||
@@ -23,6 +23,7 @@ export const IMPORT_PROVIDER_INFO: Record<string, ImportProviderInfo> = {
|
||||
textColor: (theme) => (theme.palette.mode === "dark" ? "#FFFFFF" : theme.palette.background.paper),
|
||||
},
|
||||
crealitycloud: { label: "Creality Cloud", color: "#1AC686" },
|
||||
nexprint: { label: "NexPrint", color: "#7A55E3" },
|
||||
// Direct uploads (source_provider is null).
|
||||
thingport: { label: "Thingport", color: "common.white", textColor: "common.black", outlined: true },
|
||||
};
|
||||
|
||||
@@ -28,6 +28,7 @@ const ALL: readonly ImportProvider[] = [
|
||||
"snapmaker",
|
||||
"makeronline",
|
||||
"crealitycloud",
|
||||
"nexprint",
|
||||
];
|
||||
|
||||
/** With every provider off there's nothing to import from or connect to, so those parts of the app hide. */
|
||||
|
||||
@@ -275,6 +275,7 @@
|
||||
"snapmakerDisabledReason": "Snapmaker Space: connect your account in Configuration → Providers",
|
||||
"makeronlineDisabledReason": "MakerOnline: connect your account in Configuration → Providers",
|
||||
"crealitycloudDisabledReason": "Creality Cloud: connect your account in Configuration → Providers",
|
||||
"nexprintDisabledReason": "NexPrint: connect your account in Configuration → Providers",
|
||||
"myModels": "My models"
|
||||
},
|
||||
"notifications": {
|
||||
@@ -540,7 +541,8 @@
|
||||
"myminifactory": "Models and public collections, through this Thingport's API key; bought models through each user's own MyMiniFactory account.",
|
||||
"snapmaker": "Models with their print profiles, and each user's likes and bookmarks, through each user's own Snapmaker account.",
|
||||
"makeronline": "Models with their print profiles, collections and each user's likes. Files come through Thingport Grab: MakerOnline gives them only to its own pages.",
|
||||
"crealitycloud": "Models with their print profiles, collections and each user's likes, through each user's own Creality Cloud account."
|
||||
"crealitycloud": "Models with their print profiles, collections and each user's likes, through each user's own Creality Cloud account.",
|
||||
"nexprint": "Models with their print profiles, and collections, through each user's own NexPrint account."
|
||||
},
|
||||
"keyStatus": {
|
||||
"thingiverse": {
|
||||
@@ -1648,6 +1650,17 @@
|
||||
"expired": "Sign-in expired. Sign in on crealitycloud.com with Thingport Grab, or connect Creality Cloud again in Configuration → Providers.",
|
||||
"expiredStatus": "Sign-in expired"
|
||||
},
|
||||
"nexprint": {
|
||||
"placeholder": "The auth_token cookie, or the Authorization header",
|
||||
"steps": [
|
||||
"Easiest: with Thingport Grab, just open nexprint.com while signed in. It connects your account, and keeps it connected as you visit.",
|
||||
"Or by hand: sign in at nexprint.com, open your browser's developer tools (F12) and reload the page.",
|
||||
"Under Network, pick any request to nexprint.com/gateway, copy its Authorization request header (or the auth_token cookie) and paste it below."
|
||||
],
|
||||
"notConnected": "NexPrint gives its files to signed-in users only. Connect your account to import its models.",
|
||||
"expired": "Sign-in expired. Sign in on nexprint.com with Thingport Grab, or connect NexPrint again in Configuration → Providers.",
|
||||
"expiredStatus": "Sign-in expired"
|
||||
},
|
||||
"printables": {
|
||||
"alwaysConnected": "Printables is connected by default: it needs no account or key."
|
||||
},
|
||||
@@ -2598,7 +2611,7 @@
|
||||
"takenBy": "Synced with \"{{name}}\"",
|
||||
"loadFailed": "Couldn't load the collections.",
|
||||
"lookupFailed": "Couldn't find that collection.",
|
||||
"notALink": "Paste a MakerWorld, Printables, Thingiverse, MyMiniFactory, Snapmaker Space, MakerOnline or Creality Cloud collection link.",
|
||||
"notALink": "Paste a MakerWorld, Printables, Thingiverse, MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud or NexPrint collection link.",
|
||||
"usernameFailed": "Couldn't save the username.",
|
||||
"openProviders": "Open Providers",
|
||||
"notConnected": {
|
||||
@@ -2608,7 +2621,8 @@
|
||||
"myminifactory": "MyMiniFactory isn't connected on this Thingport yet. An admin can connect it in Configuration > Providers.",
|
||||
"snapmaker": "Connect Snapmaker Space in Configuration > Providers, or visit it with Thingport Grab, to see your likes and bookmarks here.",
|
||||
"makeronline": "Connect MakerOnline in Configuration > Providers, or visit it with Thingport Grab, to see your likes and collections here.",
|
||||
"crealitycloud": "Connect Creality Cloud in Configuration > Providers, or visit it with Thingport Grab, to see your likes and collections here."
|
||||
"crealitycloud": "Connect Creality Cloud in Configuration > Providers, or visit it with Thingport Grab, to see your likes and collections here.",
|
||||
"nexprint": "Connect NexPrint in Configuration > Providers, or visit it with Thingport Grab, to see your collections here."
|
||||
},
|
||||
"usernameLabel": "Your {{provider}} username",
|
||||
"usernameHint": {
|
||||
@@ -2727,6 +2741,7 @@
|
||||
"snapmakerCatIdLabel": "Snapmaker Space category IDs",
|
||||
"makeronlineCatIdLabel": "MakerOnline category IDs",
|
||||
"crealitycloudCatIdLabel": "Creality Cloud category IDs",
|
||||
"nexprintCatIdLabel": "NexPrint category IDs",
|
||||
"catIdsPlaceholder": "e.g. 800;71;1001",
|
||||
"catIdsHelp": "Separate multiple IDs with \";\" -- matches if an import's category is any of these",
|
||||
"dragToMove": "Drag to move"
|
||||
|
||||
@@ -276,6 +276,7 @@
|
||||
"snapmakerDisabledReason": "Snapmaker Space: prijunkite paskyrą skiltyje Konfigūracija → Šaltiniai",
|
||||
"makeronlineDisabledReason": "MakerOnline: prijunkite paskyrą skiltyje Konfigūracija → Šaltiniai",
|
||||
"crealitycloudDisabledReason": "Creality Cloud: prijunkite paskyrą skiltyje Konfigūracija → Šaltiniai",
|
||||
"nexprintDisabledReason": "NexPrint: prijunkite paskyrą skiltyje Konfigūracija → Šaltiniai",
|
||||
"myModels": "Mano modeliai"
|
||||
},
|
||||
"notifications": {
|
||||
@@ -541,7 +542,8 @@
|
||||
"myminifactory": "Modeliai ir viešos kolekcijos, naudojant šio Thingport API raktą; nusipirkti modeliai – per kiekvieno naudotojo MyMiniFactory paskyrą.",
|
||||
"snapmaker": "Modeliai su spausdinimo profiliais bei kiekvieno naudotojo pamėgti ir pažymėti modeliai, per kiekvieno naudotojo Snapmaker paskyrą.",
|
||||
"makeronline": "Modeliai su spausdinimo profiliais, kolekcijos ir kiekvieno naudotojo pamėgti modeliai. Failai atkeliauja per Thingport Grab: MakerOnline juos duoda tik savo puslapiams.",
|
||||
"crealitycloud": "Modeliai su spausdinimo profiliais, kolekcijos ir kiekvieno naudotojo pamėgti modeliai, per kiekvieno naudotojo Creality Cloud paskyrą."
|
||||
"crealitycloud": "Modeliai su spausdinimo profiliais, kolekcijos ir kiekvieno naudotojo pamėgti modeliai, per kiekvieno naudotojo Creality Cloud paskyrą.",
|
||||
"nexprint": "Modeliai su spausdinimo profiliais ir kolekcijos, per kiekvieno naudotojo NexPrint paskyrą."
|
||||
},
|
||||
"keyStatus": {
|
||||
"thingiverse": {
|
||||
@@ -1704,6 +1706,17 @@
|
||||
"expired": "Prisijungimo galiojimas baigėsi. Prisijunkite crealitycloud.com su Thingport Grab arba vėl prijunkite Creality Cloud skiltyje Konfigūracija → Šaltiniai.",
|
||||
"expiredStatus": "Prisijungimas nebegalioja"
|
||||
},
|
||||
"nexprint": {
|
||||
"placeholder": "Slapukas auth_token arba Authorization antraštė",
|
||||
"steps": [
|
||||
"Paprasčiausia: su Thingport Grab tiesiog atverkite nexprint.com prisijungę. Jis prijungs jūsų paskyrą ir palaikys ją prijungtą, kol lankotės ten.",
|
||||
"Arba rankiniu būdu: prisijunkite prie nexprint.com, atverkite naršyklės kūrėjo įrankius (F12) ir perkraukite puslapį.",
|
||||
"Skiltyje Network pasirinkite bet kurią užklausą į nexprint.com/gateway, nukopijuokite jos Authorization antraštę (arba slapuką auth_token) ir įklijuokite ją žemiau."
|
||||
],
|
||||
"notConnected": "NexPrint failus duoda tik prisijungusiems naudotojams. Prijunkite paskyrą, kad galėtumėte importuoti jo modelius.",
|
||||
"expired": "Prisijungimo galiojimas baigėsi. Prisijunkite nexprint.com su Thingport Grab arba vėl prijunkite NexPrint skiltyje Konfigūracija → Šaltiniai.",
|
||||
"expiredStatus": "Prisijungimas nebegalioja"
|
||||
},
|
||||
"printables": {
|
||||
"alwaysConnected": "Printables prijungta iš karto: nereikia paskyros ar rakto."
|
||||
},
|
||||
@@ -2669,7 +2682,7 @@
|
||||
"takenBy": "Sinchronizuojama su „{{name}}“",
|
||||
"loadFailed": "Nepavyko įkelti kolekcijų.",
|
||||
"lookupFailed": "Nepavyko rasti šios kolekcijos.",
|
||||
"notALink": "Įklijuokite MakerWorld, Printables, Thingiverse, MyMiniFactory, Snapmaker Space, MakerOnline arba Creality Cloud kolekcijos nuorodą.",
|
||||
"notALink": "Įklijuokite MakerWorld, Printables, Thingiverse, MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud arba NexPrint kolekcijos nuorodą.",
|
||||
"usernameFailed": "Nepavyko išsaugoti vartotojo vardo.",
|
||||
"openProviders": "Atidaryti tiekėjus",
|
||||
"notConnected": {
|
||||
@@ -2679,7 +2692,8 @@
|
||||
"myminifactory": "MyMiniFactory šiame Thingport dar neprijungtas. Administratorius gali jį prijungti per Konfigūracija > Tiekėjai.",
|
||||
"snapmaker": "Prijunkite Snapmaker Space per Konfigūracija > Šaltiniai arba atverkite jį su Thingport Grab, kad čia matytumėte savo pamėgtus ir pažymėtus modelius.",
|
||||
"makeronline": "Prijunkite MakerOnline per Konfigūracija > Šaltiniai arba atverkite jį su Thingport Grab, kad čia matytumėte savo pamėgtus modelius ir kolekcijas.",
|
||||
"crealitycloud": "Prijunkite Creality Cloud per Konfigūracija > Šaltiniai arba atverkite jį su Thingport Grab, kad čia matytumėte savo pamėgtus modelius ir kolekcijas."
|
||||
"crealitycloud": "Prijunkite Creality Cloud per Konfigūracija > Šaltiniai arba atverkite jį su Thingport Grab, kad čia matytumėte savo pamėgtus modelius ir kolekcijas.",
|
||||
"nexprint": "Prijunkite NexPrint per Konfigūracija > Šaltiniai arba atverkite jį su Thingport Grab, kad čia matytumėte savo kolekcijas."
|
||||
},
|
||||
"usernameLabel": "Jūsų {{provider}} vartotojo vardas",
|
||||
"usernameHint": {
|
||||
@@ -2802,6 +2816,7 @@
|
||||
"snapmakerCatIdLabel": "Snapmaker Space kategorijų ID",
|
||||
"makeronlineCatIdLabel": "MakerOnline kategorijų ID",
|
||||
"crealitycloudCatIdLabel": "Creality Cloud kategorijų ID",
|
||||
"nexprintCatIdLabel": "NexPrint kategorijų ID",
|
||||
"catIdsPlaceholder": "pvz. 800;71;1001",
|
||||
"catIdsHelp": "Kelis ID atskirkite „;“ -- atitiks, jei importo kategorija sutampa su bet kuriuo iš jų",
|
||||
"dragToMove": "Vilkite, kad perkeltumėte"
|
||||
|
||||
@@ -35,6 +35,7 @@ const NAMES: Record<ImportProvider, string> = {
|
||||
snapmaker: "Snapmaker Space",
|
||||
makeronline: "MakerOnline",
|
||||
crealitycloud: "Creality Cloud",
|
||||
nexprint: "NexPrint",
|
||||
};
|
||||
|
||||
const KEYED = new Set<ImportProvider>(["thingiverse", "myminifactory"]);
|
||||
@@ -126,6 +127,7 @@ export default function ProvidersPage({ onUnauthorized }: Props) {
|
||||
"snapmaker",
|
||||
"makeronline",
|
||||
"crealitycloud",
|
||||
"nexprint",
|
||||
] as const
|
||||
).map((id) => ({
|
||||
id,
|
||||
|
||||
@@ -19,6 +19,7 @@ export const PROVIDER_ACCENTS: Record<BrowsableProvider, string> = {
|
||||
// Anycubic's blue, for the same reason: MakerOnline's own mark is black and white.
|
||||
makeronline: "#1F7AE0",
|
||||
crealitycloud: "#1AC686",
|
||||
nexprint: "#7A55E3",
|
||||
};
|
||||
|
||||
type Props = {
|
||||
@@ -339,6 +340,47 @@ function CrealitycloudTile({ collection }: { collection: ProviderCollection }) {
|
||||
);
|
||||
}
|
||||
|
||||
// The gradient its title sits on: dark in both themes, so the white text reads over any cover.
|
||||
const NEXPRINT_SCRIM = "linear-gradient(to top, rgba(0, 0, 0, 0.72), rgba(0, 0, 0, 0) 70%)";
|
||||
|
||||
/** NexPrint: one cover, its first model's, with the name over it, edged in its purple. */
|
||||
function NexprintTile({ collection }: { collection: ProviderCollection }) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: "relative",
|
||||
borderRadius: "10px",
|
||||
overflow: "hidden",
|
||||
borderLeft: `3px solid ${PROVIDER_ACCENTS.nexprint}`,
|
||||
bgcolor: "background.paper",
|
||||
}}
|
||||
>
|
||||
<Thumb src={collection.thumbnails[0]} sx={{ aspectRatio: "4 / 3" }} />
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "flex-end",
|
||||
px: 1.25,
|
||||
py: 1,
|
||||
backgroundImage: NEXPRINT_SCRIM,
|
||||
color: "common.white",
|
||||
}}
|
||||
>
|
||||
<Typography noWrap sx={{ fontWeight: 700, fontSize: "0.875rem" }}>
|
||||
{collection.name}
|
||||
</Typography>
|
||||
<Box sx={{ display: "flex", alignItems: "center", fontSize: "0.75rem", opacity: 0.85 }}>
|
||||
<ModelCount count={collection.model_count} provider="nexprint" />
|
||||
{collection.private && <LockOutlinedIcon sx={{ fontSize: 14, ml: "auto" }} />}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const TILES: Record<BrowsableProvider, (props: { collection: ProviderCollection }) => JSX.Element> = {
|
||||
makerworld: MakerWorldTile,
|
||||
printables: PrintablesTile,
|
||||
@@ -347,6 +389,7 @@ const TILES: Record<BrowsableProvider, (props: { collection: ProviderCollection
|
||||
snapmaker: SnapmakerTile,
|
||||
makeronline: MakeronlineTile,
|
||||
crealitycloud: CrealitycloudTile,
|
||||
nexprint: NexprintTile,
|
||||
};
|
||||
|
||||
/** The name to show: a user's likes, and Snapmaker's likes and bookmarks, come named in English by the instance, as
|
||||
@@ -471,6 +514,13 @@ export function ProviderCollectionTileSkeleton({ provider }: { provider: Browsab
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (provider === "nexprint") {
|
||||
return (
|
||||
<Box aria-hidden sx={{ borderRadius: "10px", overflow: "hidden" }}>
|
||||
<Skeleton variant="rectangular" sx={{ width: "100%", height: "auto", aspectRatio: "4 / 3" }} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (provider === "crealitycloud") {
|
||||
return (
|
||||
<Box aria-hidden sx={{ borderRadius: "12px", border: 1, borderColor: "divider", overflow: "hidden" }}>
|
||||
|
||||
@@ -48,10 +48,11 @@ const PASTE_EXAMPLES: Record<BrowsableProvider, string> = {
|
||||
snapmaker: "https://space.snapmaker.com/en/creator/…?tab=interactions&list=likes",
|
||||
makeronline: "https://www.makeronline.com/en/user/collectionDetail/…/….html",
|
||||
crealitycloud: "https://www.crealitycloud.com/collection/…",
|
||||
nexprint: "https://www.nexprint.com/en/…/home?component=UserCollect&subComp=UserCollectDetail&collectionId=G…",
|
||||
};
|
||||
|
||||
// Listed with the user's own sign-in, which already says who they are, so no username is asked for.
|
||||
const SIGNED_IN = new Set<BrowsableProvider>(["makerworld", "snapmaker", "makeronline", "crealitycloud"]);
|
||||
const SIGNED_IN = new Set<BrowsableProvider>(["makerworld", "snapmaker", "makeronline", "crealitycloud", "nexprint"]);
|
||||
|
||||
/** The one the user's slicer goes with, unless it isn't connected; then the first that is. Only from `available`,
|
||||
* the ones an admin hasn't turned off. */
|
||||
|
||||
@@ -25,6 +25,7 @@ const LOGOS = new Set<string>([
|
||||
"snapmaker",
|
||||
"makeronline",
|
||||
"crealitycloud",
|
||||
"nexprint",
|
||||
]);
|
||||
|
||||
// The pulse is 40% of its segment wide, so 250% starts it past the right end and -100% ends it
|
||||
|
||||
@@ -165,6 +165,7 @@ export default function LogsPage({ onUnauthorized }: Props) {
|
||||
snapmaker: "Snapmaker Space",
|
||||
makeronline: "MakerOnline",
|
||||
crealitycloud: "Creality Cloud",
|
||||
nexprint: "NexPrint",
|
||||
};
|
||||
const provider = typeof d.provider === "string" ? (names[d.provider] ?? d.provider) : "";
|
||||
return t(d.enabled ? "adminSettings.logs.providerOn" : "adminSettings.logs.providerOff", { provider });
|
||||
|
||||
@@ -74,7 +74,8 @@ function hasMeta(category: Category): boolean {
|
||||
category.myminifactory_cat_ids ||
|
||||
category.snapmaker_cat_ids ||
|
||||
category.makeronline_cat_ids ||
|
||||
category.crealitycloud_cat_ids,
|
||||
category.crealitycloud_cat_ids ||
|
||||
category.nexprint_cat_ids,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ export default function CategoryMetaDialog({ category, showSiteCatIds, onClose,
|
||||
const [snapmakerCatIds, setSnapmakerCatIds] = useState(category.snapmaker_cat_ids);
|
||||
const [makeronlineCatIds, setMakeronlineCatIds] = useState(category.makeronline_cat_ids);
|
||||
const [crealitycloudCatIds, setCrealitycloudCatIds] = useState(category.crealitycloud_cat_ids);
|
||||
const [nexprintCatIds, setNexprintCatIds] = useState(category.nexprint_cat_ids);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -51,6 +52,7 @@ export default function CategoryMetaDialog({ category, showSiteCatIds, onClose,
|
||||
snapmakerCatIds,
|
||||
makeronlineCatIds,
|
||||
crealitycloudCatIds,
|
||||
nexprintCatIds,
|
||||
});
|
||||
onClose();
|
||||
} catch (err) {
|
||||
@@ -151,6 +153,15 @@ export default function CategoryMetaDialog({ category, showSiteCatIds, onClose,
|
||||
onChange={(e) => setCrealitycloudCatIds(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
<TextField
|
||||
label={t("models:categories.manager.nexprintCatIdLabel")}
|
||||
placeholder={t("models:categories.manager.catIdsPlaceholder") ?? undefined}
|
||||
helperText={t("models:categories.manager.catIdsHelp")}
|
||||
fullWidth
|
||||
value={nexprintCatIds}
|
||||
onChange={(e) => setNexprintCatIds(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{error && <Alert severity="error">{error}</Alert>}
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
/** URL-shape detection shared by useUploadImport and AddMenu so they can't drift apart. */
|
||||
|
||||
export type ImportProviderKey =
|
||||
"makerworld" | "thingiverse" | "printables" | "myminifactory" | "snapmaker" | "makeronline" | "crealitycloud";
|
||||
| "makerworld"
|
||||
| "thingiverse"
|
||||
| "printables"
|
||||
| "myminifactory"
|
||||
| "snapmaker"
|
||||
| "makeronline"
|
||||
| "crealitycloud"
|
||||
| "nexprint";
|
||||
|
||||
function parseUrl(url: string): URL | null {
|
||||
try {
|
||||
@@ -22,6 +29,7 @@ export function detectImportProvider(url: string): ImportProviderKey | null {
|
||||
if (host === "space.snapmaker.com") return "snapmaker";
|
||||
if (host === "makeronline.com" || host === "www.makeronline.com") return "makeronline";
|
||||
if (host === "crealitycloud.com" || host === "www.crealitycloud.com") return "crealitycloud";
|
||||
if (host === "nexprint.com" || host === "www.nexprint.com") return "nexprint";
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -160,6 +168,30 @@ export function isCrealitycloudListUrl(url: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function isNexprintHost(parsed: URL): boolean {
|
||||
return /^(www\.)?nexprint\.com$/i.test(parsed.hostname);
|
||||
}
|
||||
|
||||
const NEXPRINT_LANG = String.raw`(?:[a-z]{2}(?:-[a-z]{2,4})?/)?`;
|
||||
|
||||
/** See isThingiverseThingUrl. `/en/models/<id>`; `?printConfigId=<id>` may pick a print profile. */
|
||||
export function isNexprintModelUrl(url: string): boolean {
|
||||
const parsed = parseUrl(url);
|
||||
if (!parsed || !isNexprintHost(parsed)) return false;
|
||||
return new RegExp(String.raw`^/${NEXPRINT_LANG}models/G\d+/?$`, "i").test(parsed.pathname);
|
||||
}
|
||||
|
||||
/** A NexPrint collection: `/en/collection/<id>`, or open on its owner's page (`/en/<user>/home?...&collectionId=`). */
|
||||
export function isNexprintCollectionUrl(url: string): boolean {
|
||||
const parsed = parseUrl(url);
|
||||
if (!parsed || !isNexprintHost(parsed)) return false;
|
||||
if (new RegExp(String.raw`^/${NEXPRINT_LANG}collection/G\d+/?$`, "i").test(parsed.pathname)) return true;
|
||||
return (
|
||||
new RegExp(String.raw`^/${NEXPRINT_LANG}U\d+/home/?$`, "i").test(parsed.pathname) &&
|
||||
/^G\d+$/i.test(parsed.searchParams.get("collectionId") ?? "")
|
||||
);
|
||||
}
|
||||
|
||||
export const IMPORT_LINK_EXAMPLES: Record<ImportProviderKey, { model: string; collection: string }> = {
|
||||
makerworld: {
|
||||
model: "https://makerworld.com/en/models/123456-example-model",
|
||||
@@ -189,4 +221,8 @@ export const IMPORT_LINK_EXAMPLES: Record<ImportProviderKey, { model: string; co
|
||||
model: "https://www.crealitycloud.com/model-detail/example-model",
|
||||
collection: "https://www.crealitycloud.com/collection/3d-model-collection-AbCdEf",
|
||||
},
|
||||
nexprint: {
|
||||
model: "https://www.nexprint.com/en/models/G1234567",
|
||||
collection: "https://www.nexprint.com/en/collection/G12345678",
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user