mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-22 14:42:29 -05:00
feat: enable Creality Cloud provider
This commit is contained in:
1 parent
2f3b5ffcc7
commit
772b1acfef
73 files changed
+2395
-184
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 and MakerOnline](docs/features/importing.md)**
|
||||
- **[Import from MakerWorld, Printables, Thingiverse, MyMiniFactory, Snapmaker Space, MakerOnline and Creality Cloud](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 and MakerOnline 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 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).
|
||||
|
||||
## 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. 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, 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.
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
-- Creality Cloud as a seventh provider: its site category ids on categories; and the user's Creality Cloud sign-in
|
||||
-- (the model_token cookie, which the site sets for 7 days and never renews), sent by Thingport Grab or pasted.
|
||||
ALTER TABLE "Category" ADD COLUMN "crealitycloudCatIds" INTEGER[] NOT NULL DEFAULT '{}';
|
||||
CREATE INDEX "Category_crealitycloudCatIds_idx" ON "Category" USING GIN ("crealitycloudCatIds");
|
||||
|
||||
ALTER TABLE "User" ADD COLUMN "crealitycloudToken" TEXT,
|
||||
ADD COLUMN "crealitycloudExpiresAt" TIMESTAMP(3),
|
||||
ADD COLUMN "crealitycloudUserId" TEXT,
|
||||
ADD COLUMN "crealitycloudRejectedAt" TIMESTAMP(3);
|
||||
|
||||
-- Adds Creality Cloud's category ids to the starter categories of users who signed up before they were in
|
||||
-- seedData/defaultCategories.ts, so their Creality Cloud 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 "crealitycloudCatIds" = m.ids
|
||||
FROM (VALUES
|
||||
(NULL::text, '3D Printer', ARRAY[1731, 6006]),
|
||||
('3D Printer', '3D Printer Accessories', ARRAY[1904]),
|
||||
('3D Printer', '3D Printer Parts', ARRAY[1316]),
|
||||
('3D Printer', 'Test Models', ARRAY[1645]),
|
||||
(NULL::text, 'Art', ARRAY[1670]),
|
||||
('Art', '2D Art', ARRAY[1662]),
|
||||
('Art', 'Coin & Badges', ARRAY[1341]),
|
||||
('Art', 'Sculptures', ARRAY[1584]),
|
||||
('Art', 'Other Art Models', ARRAY[1997, 6005]),
|
||||
(NULL::text, 'Education', ARRAY[1501]),
|
||||
('Education', 'Other Education Models', ARRAY[1974, 1343, 6003]),
|
||||
(NULL::text, 'Fashion', ARRAY[1175]),
|
||||
('Fashion', 'Clothes', ARRAY[1598]),
|
||||
('Fashion', 'Jewelry', ARRAY[1693]),
|
||||
('Fashion', 'Other Fashion Models', ARRAY[1647, 6002]),
|
||||
(NULL::text, 'Hobby & DIY', ARRAY[1519]),
|
||||
('Hobby & DIY', 'Electronics', ARRAY[1741]),
|
||||
('Hobby & DIY', 'Music', ARRAY[1420]),
|
||||
('Hobby & DIY', 'Robotics', ARRAY[1648]),
|
||||
('Hobby & DIY', 'Sport & Outdoors', ARRAY[1246]),
|
||||
('Hobby & DIY', 'Vehicles', ARRAY[1194]),
|
||||
('Hobby & DIY', 'Other Hobby & DIY', ARRAY[6004]),
|
||||
(NULL::text, 'Household', ARRAY[1010]),
|
||||
('Household', 'Decor', ARRAY[1150, 1096]),
|
||||
('Household', 'Pets', ARRAY[1151]),
|
||||
('Household', 'Other Household Models', ARRAY[1775, 6000]),
|
||||
(NULL::text, 'Miniatures', ARRAY[1952]),
|
||||
('Miniatures', 'Architecture', ARRAY[1846]),
|
||||
('Miniatures', 'Creatures', ARRAY[1025]),
|
||||
('Miniatures', 'Other Miniatures', ARRAY[1888, 1982, 6008]),
|
||||
(NULL::text, 'Props & Cosplays', ARRAY[1966]),
|
||||
(NULL::text, 'Tools', ARRAY[1671]),
|
||||
('Tools', 'Medical Tools', ARRAY[1160, 1192, 1765, 6001]),
|
||||
(NULL::text, 'Toys & Games', ARRAY[1809]),
|
||||
('Toys & Games', 'Board Games', ARRAY[1575]),
|
||||
('Toys & Games', 'Construction Sets', ARRAY[1141]),
|
||||
('Toys & Games', 'Other Toys & Games', ARRAY[1793, 6007]),
|
||||
(NULL::text, 'Generative 3D Model', ARRAY[6012, 6010, 6013]),
|
||||
('Generative 3D Model', 'Make My Sign', ARRAY[6011]),
|
||||
('Generative 3D Model', 'Relief Sculpture Maker', ARRAY[6014])
|
||||
) AS m(parent_name, name, ids)
|
||||
WHERE c."kind" = 'category'
|
||||
AND c."name" = m.name
|
||||
AND cardinality(c."crealitycloudCatIds") = 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
|
||||
)
|
||||
);
|
||||
@@ -86,6 +86,14 @@ model User {
|
||||
makeronlineUserId String?
|
||||
// When MakerOnline last turned the token down.
|
||||
makeronlineRejectedAt DateTime?
|
||||
// The user's Creality Cloud token, from its model_token cookie. The site sets it once at sign-in for 7 days and
|
||||
// never renews it; Thingport Grab sends each new one here.
|
||||
crealitycloudToken String?
|
||||
crealitycloudExpiresAt DateTime?
|
||||
// Their Creality Cloud user id (model_user_id): their likes are synced under it.
|
||||
crealitycloudUserId String?
|
||||
// When Creality Cloud last turned the token down.
|
||||
crealitycloudRejectedAt DateTime?
|
||||
slicer String?
|
||||
// "light" | "dark" | "system"; null means never set.
|
||||
themePreference String?
|
||||
@@ -160,6 +168,7 @@ model Category {
|
||||
myminifactoryCatIds Int[] @default([])
|
||||
snapmakerCatIds Int[] @default([])
|
||||
makeronlineCatIds Int[] @default([])
|
||||
crealitycloudCatIds Int[] @default([])
|
||||
prints Print[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -172,6 +181,7 @@ model Category {
|
||||
@@index([myminifactoryCatIds], type: Gin)
|
||||
@@index([snapmakerCatIds], type: Gin)
|
||||
@@index([makeronlineCatIds], type: Gin)
|
||||
@@index([crealitycloudCatIds], type: Gin)
|
||||
}
|
||||
|
||||
model Print {
|
||||
|
||||
@@ -181,6 +181,7 @@ export type CategoryOut = {
|
||||
myminifactory_cat_ids: string;
|
||||
snapmaker_cat_ids: string;
|
||||
makeronline_cat_ids: string;
|
||||
crealitycloud_cat_ids: string;
|
||||
};
|
||||
|
||||
function plateThumbUrl(plateId: string): string | null {
|
||||
@@ -477,6 +478,7 @@ export function toCategoryOut(category: Category): CategoryOut {
|
||||
myminifactory_cat_ids: formatCatIds(category.myminifactoryCatIds),
|
||||
snapmaker_cat_ids: formatCatIds(category.snapmakerCatIds),
|
||||
makeronline_cat_ids: formatCatIds(category.makeronlineCatIds),
|
||||
crealitycloud_cat_ids: formatCatIds(category.crealitycloudCatIds),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -193,6 +193,7 @@ const categoryMetaSchema = z.object({
|
||||
myminifactory_cat_ids: catIdsField,
|
||||
snapmaker_cat_ids: catIdsField,
|
||||
makeronline_cat_ids: catIdsField,
|
||||
crealitycloud_cat_ids: catIdsField,
|
||||
});
|
||||
|
||||
router.patch(
|
||||
@@ -212,6 +213,7 @@ router.patch(
|
||||
myminifactoryCatIds: parseCatIdsInput(body.myminifactory_cat_ids),
|
||||
snapmakerCatIds: parseCatIdsInput(body.snapmaker_cat_ids),
|
||||
makeronlineCatIds: parseCatIdsInput(body.makeronline_cat_ids),
|
||||
crealitycloudCatIds: parseCatIdsInput(body.crealitycloud_cat_ids),
|
||||
},
|
||||
});
|
||||
res.json(toCategoryOut(updated));
|
||||
|
||||
@@ -13,6 +13,7 @@ import { requireCaptcha } from "../services/captchaService";
|
||||
import {
|
||||
assertCanImportUrl,
|
||||
SNAPMAKER_SESSION_CODE,
|
||||
CREALITYCLOUD_SESSION_CODE,
|
||||
checkImportStatus,
|
||||
downloadImportToTemp,
|
||||
findImportedExternalIds,
|
||||
@@ -86,6 +87,21 @@ import {
|
||||
queueGrabImport,
|
||||
recordGrabImportFailure,
|
||||
} from "../services/grabImportService";
|
||||
import {
|
||||
CREALITYCLOUD_LIKES_NAME,
|
||||
CrealitycloudAuthError,
|
||||
type CrealitycloudListing,
|
||||
fetchCrealitycloudCollection,
|
||||
fetchCrealitycloudLikes,
|
||||
parseCrealitycloudCollectionUrl,
|
||||
parseCrealitycloudLikesUrl,
|
||||
parseCrealitycloudModelUrl,
|
||||
} from "../services/crealitycloudApi";
|
||||
import {
|
||||
CREALITYCLOUD_CONNECT,
|
||||
getUserCrealitycloudAuth,
|
||||
markCrealitycloudSessionRejected,
|
||||
} from "../services/crealitycloudSessionService";
|
||||
import { addPrintsToCollection, findOrCreateCollectionByName } from "../services/collectionService";
|
||||
import { listZipEntries } from "../services/zipService";
|
||||
import { createJob, createJobItems, getActiveJob, getJob, listJobItems } from "../services/importJobService";
|
||||
@@ -93,6 +109,8 @@ import { queueLink, startLinksJob } from "../services/importQueueService";
|
||||
import { getImportTask, startImportTask } from "../services/importTaskService";
|
||||
import {
|
||||
runCollectionImportJob,
|
||||
runCrealitycloudListImportJob,
|
||||
runCrealitycloudProfilesImportJob,
|
||||
runLinksImportJob,
|
||||
runMakerworldProfilesImportJob,
|
||||
runMyminifactoryCollectionImportJob,
|
||||
@@ -261,8 +279,17 @@ router.get(
|
||||
);
|
||||
|
||||
const statusBatchSchema = z.object({
|
||||
provider: z.enum(["makerworld", "thingiverse", "printables", "myminifactory", "snapmaker", "makeronline"]),
|
||||
external_ids: z.array(z.string().max(64)).max(10_000),
|
||||
provider: z.enum([
|
||||
"makerworld",
|
||||
"thingiverse",
|
||||
"printables",
|
||||
"myminifactory",
|
||||
"snapmaker",
|
||||
"makeronline",
|
||||
"crealitycloud",
|
||||
]),
|
||||
// Creality Cloud's ids are its models' aliases, made of their titles.
|
||||
external_ids: z.array(z.string().max(200)).max(10_000),
|
||||
});
|
||||
|
||||
// One query for a whole collection page; per-model /import/status calls bog down on big ones.
|
||||
@@ -544,6 +571,63 @@ router.post(
|
||||
}),
|
||||
);
|
||||
|
||||
/** The user's likes, or a collection, from a Creality Cloud link. Likes are their owner's only; collections are read
|
||||
* without a sign-in. */
|
||||
async function crealitycloudListOf(
|
||||
userId: string,
|
||||
url: string,
|
||||
): Promise<{ title: string; listing: CrealitycloudListing }> {
|
||||
try {
|
||||
const likes = parseCrealitycloudLikesUrl(url);
|
||||
if (likes) {
|
||||
const auth = await getUserCrealitycloudAuth(userId);
|
||||
if (!auth) throw new HttpError(400, CREALITYCLOUD_CONNECT, CREALITYCLOUD_SESSION_CODE);
|
||||
if (auth.userId !== likes.userId) {
|
||||
throw new HttpError(400, "Creality Cloud shows likes to their owner only, so only your own can be imported");
|
||||
}
|
||||
return { title: CREALITYCLOUD_LIKES_NAME, listing: await fetchCrealitycloudLikes(auth) };
|
||||
}
|
||||
const parsed = parseCrealitycloudCollectionUrl(url);
|
||||
if (!parsed) throw new HttpError(400, "Not a link to a Creality Cloud collection or your likes");
|
||||
const collection = await fetchCrealitycloudCollection(parsed.collectionId);
|
||||
if (!collection) throw new HttpError(404, "That collection can't be found. It may have been deleted.");
|
||||
return { title: collection.info.name, listing: collection };
|
||||
} catch (err) {
|
||||
if (err instanceof CrealitycloudAuthError) {
|
||||
await markCrealitycloudSessionRejected(userId);
|
||||
// 400, not 401: the frontend treats any 401 as an expired Thingport session.
|
||||
throw new HttpError(400, err.message, CREALITYCLOUD_SESSION_CODE);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
router.post(
|
||||
"/import/crealitycloud-list/entries",
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = parseBody(importRequestSchema, req.body);
|
||||
const url = await enabledImportUrl(body.url);
|
||||
const { title, listing } = await crealitycloudListOf(req.userId!, url);
|
||||
if (!listing.entries.length) throw new HttpError(400, `"${title}" is empty`);
|
||||
const alreadyImported = await findImportedExternalIds(
|
||||
req.userId!,
|
||||
"crealitycloud",
|
||||
listing.entries.map((e) => e.modelId),
|
||||
);
|
||||
res.json({
|
||||
title,
|
||||
total: listing.total,
|
||||
truncated: listing.truncated,
|
||||
entries: listing.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
|
||||
@@ -772,6 +856,20 @@ router.post(
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/import/crealitycloud-profiles",
|
||||
requireCaptcha("import"),
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = parseBody(makerworldProfilesImportRequestSchema, req.body);
|
||||
await assertNoActiveJob(req.userId!);
|
||||
const url = await enabledImportUrl(body.url);
|
||||
if (!parseCrealitycloudModelUrl(url)) throw new HttpError(400, "Not a Creality Cloud model link");
|
||||
const job = await createJob(req.userId!, "PROFILES", { sourceUrl: url, provider: "crealitycloud" });
|
||||
void runCrealitycloudProfilesImportJob(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(
|
||||
@@ -899,6 +997,36 @@ router.post(
|
||||
}),
|
||||
);
|
||||
|
||||
const crealitycloudListImportRequestSchema = importRequestSchema.extend({
|
||||
model_ids: z
|
||||
.array(
|
||||
z
|
||||
.string()
|
||||
.regex(/^[\w-]+$/)
|
||||
.max(200),
|
||||
)
|
||||
.min(1)
|
||||
.max(1000),
|
||||
});
|
||||
|
||||
router.post(
|
||||
"/import/crealitycloud-list",
|
||||
requireCaptcha("import"),
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = parseBody(crealitycloudListImportRequestSchema, req.body);
|
||||
await assertNoActiveJob(req.userId!);
|
||||
const url = await enabledImportUrl(body.url);
|
||||
const { title } = await crealitycloudListOf(req.userId!, url);
|
||||
const job = await createJob(req.userId!, "COLLECTION", {
|
||||
sourceUrl: url,
|
||||
provider: "crealitycloud",
|
||||
total: body.model_ids.length,
|
||||
});
|
||||
void runCrealitycloudListImportJob(job.id, req.userId!, { ...body, url, title });
|
||||
res.status(202).json({ job_id: job.id });
|
||||
}),
|
||||
);
|
||||
|
||||
const zipExtractRequestSchema = importRequestSchema.extend({ entries: z.array(z.string()) });
|
||||
|
||||
router.post(
|
||||
|
||||
@@ -68,6 +68,12 @@ import {
|
||||
normalizeMakeronlineToken,
|
||||
setUserMakeronlineToken,
|
||||
} from "../services/makeronlineSessionService";
|
||||
import { checkCrealitycloudAuth } from "../services/crealitycloudApi";
|
||||
import {
|
||||
getCrealitycloudConnection,
|
||||
normalizeCrealitycloudSession,
|
||||
setUserCrealitycloudSession,
|
||||
} from "../services/crealitycloudSessionService";
|
||||
import { SLICER_IDS, getUserSlicer, setUserSlicer } from "../services/slicerPreferenceService";
|
||||
import { CATEGORIES_VIEWS, getCategoriesView, setCategoriesView } from "../services/categoriesViewService";
|
||||
import {
|
||||
@@ -713,6 +719,49 @@ router.patch(
|
||||
}),
|
||||
);
|
||||
|
||||
// Per-user: the Creality Cloud sign-in, which Thingport Grab sends whenever the browser's changes.
|
||||
router.get(
|
||||
"/settings/crealitycloud-session",
|
||||
asyncHandler(async (req, res) => {
|
||||
const connection = await getCrealitycloudConnection(req.userId!);
|
||||
res.json({ configured: connection !== "disconnected", expired: connection === "expired" });
|
||||
}),
|
||||
);
|
||||
|
||||
// `expires_at`: the model_token cookie's own expiry, which Grab can read; it's only shown, as the token may outlive it.
|
||||
const crealitycloudSessionSchema = z.object({
|
||||
token: z.string().max(10_000).nullable(),
|
||||
expires_at: z.string().datetime().nullable().optional(),
|
||||
verify: z.boolean().optional(),
|
||||
});
|
||||
router.patch(
|
||||
"/settings/crealitycloud-session",
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = parseBody(crealitycloudSessionSchema, req.body);
|
||||
const session = normalizeCrealitycloudSession(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 Creality Cloud sign-in: paste the Cookie header of a signed-in crealitycloud.com request (it has model_token and model_user_id).",
|
||||
);
|
||||
}
|
||||
if (body.verify && session) {
|
||||
const check = await checkCrealitycloudAuth(session);
|
||||
if (check === "rejected") {
|
||||
throw new HttpError(
|
||||
422,
|
||||
"Creality Cloud didn't accept this sign-in -- it may have expired. Copy the cookies from a signed-in crealitycloud.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 Creality Cloud to check this sign-in. Try again in a moment.");
|
||||
}
|
||||
const configured = await setUserCrealitycloudSession(req.userId!, session);
|
||||
res.json({ configured });
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/settings/slicer",
|
||||
asyncHandler(async (req, res) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** Starter category tree modeled on MakerWorld's, with matching Thingiverse, Printables, MyMiniFactory, Snapmaker and
|
||||
* MakerOnline ids so imports are auto-categorized from the start. */
|
||||
/** 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. */
|
||||
export type DefaultCategoryNode = {
|
||||
name: string;
|
||||
tags?: string[];
|
||||
@@ -11,6 +11,7 @@ export type DefaultCategoryNode = {
|
||||
myminifactoryCatIds?: number[];
|
||||
snapmakerCatIds?: number[];
|
||||
makeronlineCatIds?: number[];
|
||||
crealitycloudCatIds?: number[];
|
||||
children?: DefaultCategoryNode[];
|
||||
};
|
||||
|
||||
@@ -26,6 +27,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
myminifactoryCatIds: [366],
|
||||
snapmakerCatIds: [103],
|
||||
makeronlineCatIds: [33],
|
||||
crealitycloudCatIds: [1731, 6006],
|
||||
children: [
|
||||
{
|
||||
name: "3D Printer Accessories",
|
||||
@@ -38,6 +40,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
myminifactoryCatIds: [1001, 1000],
|
||||
snapmakerCatIds: [116, 115, 117, 118],
|
||||
makeronlineCatIds: [34],
|
||||
crealitycloudCatIds: [1904],
|
||||
},
|
||||
{
|
||||
name: "3D Printer Parts",
|
||||
@@ -52,6 +55,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
608, 672, 949, 1271,
|
||||
],
|
||||
makeronlineCatIds: [35],
|
||||
crealitycloudCatIds: [1316],
|
||||
},
|
||||
{
|
||||
name: "Test Models",
|
||||
@@ -64,6 +68,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
myminifactoryCatIds: [960],
|
||||
snapmakerCatIds: [114],
|
||||
makeronlineCatIds: [36],
|
||||
crealitycloudCatIds: [1645],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -77,6 +82,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
printablesCatIds: [13],
|
||||
snapmakerCatIds: [104],
|
||||
makeronlineCatIds: [70],
|
||||
crealitycloudCatIds: [1670],
|
||||
children: [
|
||||
{
|
||||
name: "2D Art",
|
||||
@@ -88,6 +94,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
printablesCatIds: [16],
|
||||
snapmakerCatIds: [119],
|
||||
makeronlineCatIds: [75],
|
||||
crealitycloudCatIds: [1662],
|
||||
},
|
||||
{
|
||||
name: "Coin & Badges",
|
||||
@@ -97,6 +104,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
makerworldCatIds: [103],
|
||||
thingiverseCatIds: [143],
|
||||
makeronlineCatIds: [72],
|
||||
crealitycloudCatIds: [1341],
|
||||
},
|
||||
{
|
||||
name: "Signs & Logos",
|
||||
@@ -119,6 +127,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
myminifactoryCatIds: [112, 355],
|
||||
snapmakerCatIds: [121],
|
||||
makeronlineCatIds: [74],
|
||||
crealitycloudCatIds: [1584],
|
||||
},
|
||||
{
|
||||
name: "Other Art Models",
|
||||
@@ -130,6 +139,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
printablesCatIds: [15, 41],
|
||||
snapmakerCatIds: [120, 183],
|
||||
makeronlineCatIds: [71, 105],
|
||||
crealitycloudCatIds: [1997, 6005],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -144,6 +154,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
myminifactoryCatIds: [86],
|
||||
snapmakerCatIds: [106],
|
||||
makeronlineCatIds: [82],
|
||||
crealitycloudCatIds: [1501],
|
||||
children: [
|
||||
{
|
||||
name: "Biology",
|
||||
@@ -224,6 +235,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
myminifactoryCatIds: [344, 166, 109, 1034, 969, 970, 971, 972, 973],
|
||||
snapmakerCatIds: [133],
|
||||
makeronlineCatIds: [83],
|
||||
crealitycloudCatIds: [1974, 1343, 6003],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -238,6 +250,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
myminifactoryCatIds: [58],
|
||||
snapmakerCatIds: [107],
|
||||
makeronlineCatIds: [76],
|
||||
crealitycloudCatIds: [1175],
|
||||
children: [
|
||||
{
|
||||
name: "Bags",
|
||||
@@ -259,6 +272,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
myminifactoryCatIds: [169],
|
||||
snapmakerCatIds: [182],
|
||||
makeronlineCatIds: [79],
|
||||
crealitycloudCatIds: [1598],
|
||||
},
|
||||
{
|
||||
name: "Earrings",
|
||||
@@ -297,6 +311,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
myminifactoryCatIds: [291, 148, 426, 359, 430, 145, 439],
|
||||
snapmakerCatIds: [134],
|
||||
makeronlineCatIds: [78],
|
||||
crealitycloudCatIds: [1693],
|
||||
},
|
||||
{
|
||||
name: "Rings",
|
||||
@@ -318,6 +333,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
myminifactoryCatIds: [357],
|
||||
snapmakerCatIds: [136, 138],
|
||||
makeronlineCatIds: [77, 81],
|
||||
crealitycloudCatIds: [1647, 6002],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -331,6 +347,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
printablesCatIds: [48],
|
||||
snapmakerCatIds: [109],
|
||||
makeronlineCatIds: [46],
|
||||
crealitycloudCatIds: [1519],
|
||||
children: [
|
||||
{
|
||||
name: "Electronics",
|
||||
@@ -345,6 +362,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
],
|
||||
snapmakerCatIds: [148],
|
||||
makeronlineCatIds: [48],
|
||||
crealitycloudCatIds: [1741],
|
||||
},
|
||||
{
|
||||
name: "Music",
|
||||
@@ -357,6 +375,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
myminifactoryCatIds: [68],
|
||||
snapmakerCatIds: [152],
|
||||
makeronlineCatIds: [49],
|
||||
crealitycloudCatIds: [1420],
|
||||
},
|
||||
{
|
||||
name: "RC",
|
||||
@@ -383,6 +402,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
myminifactoryCatIds: [393, 429, 999],
|
||||
snapmakerCatIds: [149],
|
||||
makeronlineCatIds: [52],
|
||||
crealitycloudCatIds: [1648],
|
||||
},
|
||||
{
|
||||
name: "Sport & Outdoors",
|
||||
@@ -395,6 +415,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
myminifactoryCatIds: [56, 69, 71, 966, 967, 968, 114, 342, 550, 551, 962],
|
||||
snapmakerCatIds: [154],
|
||||
makeronlineCatIds: [50],
|
||||
crealitycloudCatIds: [1246],
|
||||
},
|
||||
{
|
||||
name: "Vehicles",
|
||||
@@ -406,6 +427,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
printablesCatIds: [89],
|
||||
snapmakerCatIds: [153],
|
||||
makeronlineCatIds: [47],
|
||||
crealitycloudCatIds: [1194],
|
||||
},
|
||||
{
|
||||
name: "Other Hobby & DIY",
|
||||
@@ -417,6 +439,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
printablesCatIds: [51, 82],
|
||||
snapmakerCatIds: [151, 155],
|
||||
makeronlineCatIds: [54, 53],
|
||||
crealitycloudCatIds: [6004],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -431,6 +454,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
myminifactoryCatIds: [57],
|
||||
snapmakerCatIds: [108],
|
||||
makeronlineCatIds: [37],
|
||||
crealitycloudCatIds: [1010],
|
||||
children: [
|
||||
{
|
||||
name: "Decor",
|
||||
@@ -443,6 +467,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
myminifactoryCatIds: [335, 389, 399, 372, 398, 362, 336],
|
||||
snapmakerCatIds: [139],
|
||||
makeronlineCatIds: [38, 43],
|
||||
crealitycloudCatIds: [1150, 1096],
|
||||
},
|
||||
{
|
||||
name: "Festivities",
|
||||
@@ -489,6 +514,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
printablesCatIds: [57],
|
||||
snapmakerCatIds: [147],
|
||||
makeronlineCatIds: [44],
|
||||
crealitycloudCatIds: [1151],
|
||||
},
|
||||
{
|
||||
name: "Other Household Models",
|
||||
@@ -504,6 +530,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
],
|
||||
snapmakerCatIds: [142, 144, 145, 184],
|
||||
makeronlineCatIds: [45, 40],
|
||||
crealitycloudCatIds: [1775, 6000],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -518,6 +545,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
myminifactoryCatIds: [1015],
|
||||
snapmakerCatIds: [110],
|
||||
makeronlineCatIds: [95],
|
||||
crealitycloudCatIds: [1952],
|
||||
children: [
|
||||
{
|
||||
name: "Animals",
|
||||
@@ -540,6 +568,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
myminifactoryCatIds: [787, 1153, 1158, 913],
|
||||
snapmakerCatIds: [158],
|
||||
makeronlineCatIds: [100],
|
||||
crealitycloudCatIds: [1846],
|
||||
},
|
||||
{
|
||||
name: "Creatures",
|
||||
@@ -551,6 +580,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
printablesCatIds: [97],
|
||||
snapmakerCatIds: [159],
|
||||
makeronlineCatIds: [99],
|
||||
crealitycloudCatIds: [1025],
|
||||
},
|
||||
{
|
||||
name: "People",
|
||||
@@ -575,6 +605,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
myminifactoryCatIds: [617, 786, 1306, 1310, 1311, 1312, 1313, 1304, 1319, 1325, 1324],
|
||||
snapmakerCatIds: [160],
|
||||
makeronlineCatIds: [96, 97, 98],
|
||||
crealitycloudCatIds: [1888, 1982, 6008],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -588,6 +619,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
myminifactoryCatIds: [119],
|
||||
snapmakerCatIds: [105],
|
||||
makeronlineCatIds: [90],
|
||||
crealitycloudCatIds: [1966],
|
||||
children: [
|
||||
{
|
||||
name: "Costumes",
|
||||
@@ -643,6 +675,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
myminifactoryCatIds: [252],
|
||||
snapmakerCatIds: [111],
|
||||
makeronlineCatIds: [55],
|
||||
crealitycloudCatIds: [1671],
|
||||
children: [
|
||||
{
|
||||
name: "Gadgets",
|
||||
@@ -694,6 +727,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
288, 110, 1259, 1004, 1022, 1023, 1024, 1006, 1005, 1007, 1008, 1025, 1028, 1029, 1031, 1032, 1033, 1091,
|
||||
],
|
||||
makeronlineCatIds: [58],
|
||||
crealitycloudCatIds: [1160, 1192, 1765, 6001],
|
||||
},
|
||||
{
|
||||
name: "Organizers",
|
||||
@@ -731,6 +765,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
myminifactoryCatIds: [60],
|
||||
snapmakerCatIds: [112],
|
||||
makeronlineCatIds: [62],
|
||||
crealitycloudCatIds: [1809],
|
||||
children: [
|
||||
{
|
||||
name: "Board Games",
|
||||
@@ -743,6 +778,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
myminifactoryCatIds: [1014, 523, 1016],
|
||||
snapmakerCatIds: [172],
|
||||
makeronlineCatIds: [68],
|
||||
crealitycloudCatIds: [1575],
|
||||
},
|
||||
{
|
||||
name: "Characters",
|
||||
@@ -789,6 +825,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
printablesCatIds: [37],
|
||||
snapmakerCatIds: [170],
|
||||
makeronlineCatIds: [67],
|
||||
crealitycloudCatIds: [1141],
|
||||
},
|
||||
{
|
||||
name: "Other Toys & Games",
|
||||
@@ -801,6 +838,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
myminifactoryCatIds: [462, 1308, 1307, 529],
|
||||
snapmakerCatIds: [167, 168, 174, 175],
|
||||
makeronlineCatIds: [63, 64],
|
||||
crealitycloudCatIds: [1793, 6007],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -811,6 +849,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
"Here you can find creative models built using 3D model generators. They are beautifully crafted and may provide inspiration on how to utilize these generators.",
|
||||
makerworldCatIds: [2000],
|
||||
makeronlineCatIds: [113, 114, 115, 117],
|
||||
crealitycloudCatIds: [6012, 6010, 6013],
|
||||
children: [
|
||||
{
|
||||
name: "Hueforge & Lithophane",
|
||||
@@ -825,6 +864,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
metaDescription:
|
||||
"Create personalized and eye-catching signs using our user-friendly Make My Sign tool, perfect for home décor, businesses, and special occasions.",
|
||||
makerworldCatIds: [2002],
|
||||
crealitycloudCatIds: [6011],
|
||||
},
|
||||
{
|
||||
name: "Make My Vase",
|
||||
@@ -846,6 +886,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
metaDescription:
|
||||
"Elevate your artwork with our 3D to Relief Sculpture tool, seamlessly transforming 3D models or images into stunning bas-relief sculptures.",
|
||||
makerworldCatIds: [2005],
|
||||
crealitycloudCatIds: [6014],
|
||||
},
|
||||
{
|
||||
name: "AI Scanner",
|
||||
|
||||
@@ -20,6 +20,7 @@ import { MyminifactoryAuthError, MyminifactoryRateLimitError, resolveMyminifacto
|
||||
import { resolveThingiverseThing, ThingiverseAuthError, ThingiverseRateLimitError } from "./thingiverseApi";
|
||||
import { resolveSnapmakerModel } from "./snapmakerApi";
|
||||
import { resolveMakeronlineModel } from "./makeronlineApi";
|
||||
import { resolveCrealitycloudModel } from "./crealitycloudApi";
|
||||
import { getDisabledProviders } from "./providerSettingsService";
|
||||
|
||||
// Links models that only know their author by name: first by name matching, then by looking the
|
||||
@@ -32,6 +33,7 @@ const LOOKUP_PROVIDERS = [
|
||||
"myminifactory",
|
||||
"snapmaker",
|
||||
"makeronline",
|
||||
"crealitycloud",
|
||||
] as const;
|
||||
type LookupProvider = (typeof LOOKUP_PROVIDERS)[number];
|
||||
|
||||
@@ -202,6 +204,10 @@ async function lookUpAuthor(
|
||||
await maybeSleep(IMPORT_COLLECTION_DELAY_MS);
|
||||
return (await resolveMakeronlineModel(candidate.externalId))?.meta.author ?? null;
|
||||
}
|
||||
case "crealitycloud": {
|
||||
await maybeSleep(IMPORT_COLLECTION_DELAY_MS);
|
||||
return (await resolveCrealitycloudModel(candidate.externalId))?.meta.author ?? null;
|
||||
}
|
||||
case "makerworld": {
|
||||
const cookie = await getUserMakerworldCookie(candidate.userId);
|
||||
const bearer = extractMakerworldBearerToken(cookie);
|
||||
|
||||
@@ -78,6 +78,7 @@ export async function seedDefaultCategories(tx: Prisma.TransactionClient, userId
|
||||
myminifactoryCatIds: node.myminifactoryCatIds ?? [],
|
||||
snapmakerCatIds: node.snapmakerCatIds ?? [],
|
||||
makeronlineCatIds: node.makeronlineCatIds ?? [],
|
||||
crealitycloudCatIds: node.crealitycloudCatIds ?? [],
|
||||
},
|
||||
});
|
||||
let i = 0;
|
||||
|
||||
@@ -47,6 +47,12 @@ import {
|
||||
getUserMakeronlineToken,
|
||||
markMakeronlineTokenRejected,
|
||||
} from "./makeronlineSessionService";
|
||||
import { CrealitycloudAuthError, fetchCrealitycloudCollection, fetchCrealitycloudLikes } from "./crealitycloudApi";
|
||||
import {
|
||||
getCrealitycloudConnection,
|
||||
getUserCrealitycloudAuth,
|
||||
markCrealitycloudSessionRejected,
|
||||
} from "./crealitycloudSessionService";
|
||||
import { notifyGrabImportsQueued, queueGrabImport } from "./grabImportService";
|
||||
import { getSnapmakerConnection, getUserSnapmakerToken, markSnapmakerTokenRejected } from "./snapmakerSessionService";
|
||||
import {
|
||||
@@ -67,11 +73,18 @@ const MISSING_CHECKS_TO_UNSYNC = 2;
|
||||
|
||||
type SyncWithCollection = CollectionSync & { collection: Collection };
|
||||
|
||||
/** The user's MakerOnline likes can't be read without their sign-in; unlike a rejected one, it holds the likes only. */
|
||||
class MakeronlineLikesUnreadableError extends Error {}
|
||||
/** The user's MakerOnline or Creality Cloud likes can't be read without their sign-in; unlike a rejected one, it holds
|
||||
* the likes only. */
|
||||
class LikesUnreadableError extends Error {
|
||||
constructor(readonly problem: ConnectionProblem) {
|
||||
super(problem.error);
|
||||
}
|
||||
}
|
||||
|
||||
const isMakeronlineLikes = (sync: Pick<CollectionSync, "provider" | "externalId">) =>
|
||||
sync.provider === "makeronline" && sync.externalId === MAKERONLINE_LIKES_KEY;
|
||||
/** Likes on a provider whose collections read without a sign-in: only they wait for one. */
|
||||
const isSignInLikes = (sync: Pick<CollectionSync, "provider" | "externalId">) =>
|
||||
(sync.provider === "makeronline" && sync.externalId === MAKERONLINE_LIKES_KEY) ||
|
||||
(sync.provider === "crealitycloud" && sync.externalId.startsWith("likes-"));
|
||||
|
||||
const capitalize = (text: string) => text.charAt(0).toUpperCase() + text.slice(1);
|
||||
|
||||
@@ -134,12 +147,22 @@ export async function fetchListing(
|
||||
// Public collections list without a sign-in; the user's likes and private collections need theirs.
|
||||
const token = await getUserMakeronlineToken(sync.userId);
|
||||
if (id === MAKERONLINE_LIKES_KEY) {
|
||||
if (!token) throw new MakeronlineLikesUnreadableError();
|
||||
if (!token) throw new LikesUnreadableError(CONNECTION_PROBLEMS.makeronlineLikesDisconnected);
|
||||
return (await fetchMakeronlineLikes(token)).entries.map((e) => e.modelId);
|
||||
}
|
||||
const listing = await fetchMakeronlineCollection(id, token);
|
||||
return listing ? listing.entries.map((e) => e.modelId) : null;
|
||||
}
|
||||
if (sync.provider === "crealitycloud") {
|
||||
// Collections, the private ones too, list without a sign-in; the user's likes ("likes-<id>") need theirs.
|
||||
if (isSignInLikes(sync)) {
|
||||
const auth = await getUserCrealitycloudAuth(sync.userId);
|
||||
if (!auth) throw new LikesUnreadableError(CONNECTION_PROBLEMS.crealitycloudLikesDisconnected);
|
||||
return (await fetchCrealitycloudLikes(auth)).entries.map((e) => e.modelId);
|
||||
}
|
||||
const listing = await fetchCrealitycloudCollection(id);
|
||||
return listing ? listing.entries.map((e) => e.modelId) : null;
|
||||
}
|
||||
throw new Error(`Unknown provider "${sync.provider}"`);
|
||||
}
|
||||
|
||||
@@ -200,6 +223,16 @@ const CONNECTION_PROBLEMS = {
|
||||
reason: "Your MakerOnline sign-in ran out.",
|
||||
fix: "you open makeronline.com with Thingport Grab, or connect it again",
|
||||
},
|
||||
crealitycloudLikesDisconnected: {
|
||||
error: "Creality Cloud isn't connected",
|
||||
reason: "Creality Cloud shows likes to their owner only, and it isn't connected.",
|
||||
fix: "you open crealitycloud.com with Thingport Grab, or connect it",
|
||||
},
|
||||
crealitycloudExpired: {
|
||||
error: "Creality Cloud sign-in ran out",
|
||||
reason: "Creality Cloud turned your sign-in down; it lasts a week from when you signed in there.",
|
||||
fix: "you sign in on crealitycloud.com with Thingport Grab, or connect it again",
|
||||
},
|
||||
makerworldExpired: {
|
||||
error: MAKERWORLD_REJECTED_ERROR,
|
||||
reason: "MakerWorld turned your sign-in down; it may have expired.",
|
||||
@@ -276,12 +309,17 @@ async function connectionProblem(
|
||||
if (connection === "disconnected") return CONNECTION_PROBLEMS.snapmakerDisconnected;
|
||||
if (connection === "expired") return CONNECTION_PROBLEMS.snapmakerExpired;
|
||||
}
|
||||
if (isMakeronlineLikes(sync)) {
|
||||
if (isSignInLikes(sync) && sync.provider === "makeronline") {
|
||||
// Public collections read without a sign-in, so only the likes wait for one.
|
||||
const connection = await getMakeronlineConnection(sync.userId);
|
||||
if (connection === "disconnected") return CONNECTION_PROBLEMS.makeronlineLikesDisconnected;
|
||||
if (connection === "expired") return CONNECTION_PROBLEMS.makeronlineExpired;
|
||||
}
|
||||
if (isSignInLikes(sync) && sync.provider === "crealitycloud") {
|
||||
const connection = await getCrealitycloudConnection(sync.userId);
|
||||
if (connection === "disconnected") return CONNECTION_PROBLEMS.crealitycloudLikesDisconnected;
|
||||
if (connection === "expired") return CONNECTION_PROBLEMS.crealitycloudExpired;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -314,7 +352,7 @@ async function runSync(syncId: string): Promise<SyncOutcome> {
|
||||
const { userId } = sync;
|
||||
const problem = await connectionProblem(sync);
|
||||
if (problem) {
|
||||
await holdProvider(userId, sync.provider, problem, isMakeronlineLikes(sync) ? sync.externalId : undefined);
|
||||
await holdProvider(userId, sync.provider, problem, isSignInLikes(sync) ? sync.externalId : undefined);
|
||||
return "disconnected";
|
||||
}
|
||||
|
||||
@@ -352,8 +390,8 @@ async function runSync(syncId: string): Promise<SyncOutcome> {
|
||||
await holdProvider(userId, sync.provider, CONNECTION_PROBLEMS.snapmakerExpired);
|
||||
return "disconnected";
|
||||
}
|
||||
if (err instanceof MakeronlineLikesUnreadableError) {
|
||||
await holdProvider(userId, sync.provider, CONNECTION_PROBLEMS.makeronlineLikesDisconnected, sync.externalId);
|
||||
if (err instanceof LikesUnreadableError) {
|
||||
await holdProvider(userId, sync.provider, err.problem, sync.externalId);
|
||||
return "disconnected";
|
||||
}
|
||||
if (err instanceof MakeronlineAuthError) {
|
||||
@@ -361,6 +399,12 @@ async function runSync(syncId: string): Promise<SyncOutcome> {
|
||||
await holdProvider(userId, sync.provider, CONNECTION_PROBLEMS.makeronlineExpired);
|
||||
return "disconnected";
|
||||
}
|
||||
if (err instanceof CrealitycloudAuthError) {
|
||||
// Only the likes are read with the sign-in, so only they wait for a new one.
|
||||
await markCrealitycloudSessionRejected(userId);
|
||||
await holdProvider(userId, sync.provider, CONNECTION_PROBLEMS.crealitycloudExpired, sync.externalId);
|
||||
return "disconnected";
|
||||
}
|
||||
const message = err instanceof Error && err.message.trim() ? err.message.trim() : "Sync failed";
|
||||
console.warn(`[sync] "${sync.collection.name}" (${sync.provider} ${sync.externalId}): ${message}`);
|
||||
await prisma.collectionSync
|
||||
@@ -614,7 +658,7 @@ export function runSyncRound(): Promise<void> {
|
||||
// Each user's connection to each provider, looked up once a round.
|
||||
const problems = new Map<string, ConnectionProblem | null>();
|
||||
for (const sync of syncs.filter((s) => isSyncDue(s) && !disabled.has(s.provider as Provider))) {
|
||||
const key = `${sync.userId} ${sync.provider}${isMakeronlineLikes(sync) ? " likes" : ""}`;
|
||||
const key = `${sync.userId} ${sync.provider}${isSignInLikes(sync) ? " likes" : ""}`;
|
||||
if (problems.has(key)) {
|
||||
if (!problems.get(key)) due.push(sync.id);
|
||||
continue;
|
||||
@@ -626,7 +670,7 @@ export function runSyncRound(): Promise<void> {
|
||||
continue;
|
||||
}
|
||||
// Held here rather than in the lane: there's no provider call to pace.
|
||||
const only = isMakeronlineLikes(sync) ? sync.externalId : undefined;
|
||||
const only = isSignInLikes(sync) ? sync.externalId : undefined;
|
||||
await holdProvider(sync.userId, sync.provider, problem, only).catch((err) =>
|
||||
console.error(`[sync] holding ${key} failed:`, err),
|
||||
);
|
||||
|
||||
@@ -32,6 +32,16 @@ import {
|
||||
parseMakeronlineCollectionUrl,
|
||||
} from "./makeronlineApi";
|
||||
import { getUserMakeronlineToken } from "./makeronlineSessionService";
|
||||
import {
|
||||
CREALITYCLOUD_LIKES_NAME,
|
||||
crealitycloudCollectionUrl,
|
||||
crealitycloudLikesKey,
|
||||
crealitycloudLikesUrl,
|
||||
crealitycloudModelUrl,
|
||||
parseCrealitycloudCollectionUrl,
|
||||
parseCrealitycloudLikesUrl,
|
||||
} from "./crealitycloudApi";
|
||||
import { getUserCrealitycloudId } from "./crealitycloudSessionService";
|
||||
import { createLog } from "./auditLog";
|
||||
import { pushedLikes } from "./providerListingsService";
|
||||
import { createNotification } from "./notificationService";
|
||||
@@ -44,7 +54,8 @@ import {
|
||||
type SyncProfileScope,
|
||||
} from "../dto";
|
||||
|
||||
export type SyncProvider = "makerworld" | "printables" | "thingiverse" | "myminifactory" | "snapmaker" | "makeronline";
|
||||
export type SyncProvider =
|
||||
"makerworld" | "printables" | "thingiverse" | "myminifactory" | "snapmaker" | "makeronline" | "crealitycloud";
|
||||
|
||||
/** `likesOf` is set for a user's likes (on Thingiverse, or the user's own on Printables), which a
|
||||
* sync follows like a collection. */
|
||||
@@ -57,11 +68,14 @@ const PROVIDER_NAMES: Record<SyncProvider, string> = {
|
||||
myminifactory: "MyMiniFactory",
|
||||
snapmaker: "Snapmaker Space",
|
||||
makeronline: "MakerOnline",
|
||||
crealitycloud: "Creality Cloud",
|
||||
};
|
||||
|
||||
/** 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";
|
||||
return (
|
||||
provider === "makerworld" || provider === "snapmaker" || provider === "makeronline" || provider === "crealitycloud"
|
||||
);
|
||||
}
|
||||
|
||||
export function providerName(provider: string): string {
|
||||
@@ -126,6 +140,17 @@ export function parseSyncSource(url: string): SyncSource | null {
|
||||
// The page names no user: it's always the signed-in one's, read with their token.
|
||||
return { provider: "makeronline", externalId: MAKERONLINE_LIKES_KEY, url: MAKERONLINE_LIKES_URL };
|
||||
}
|
||||
const crealitycloud = parseCrealitycloudCollectionUrl(url);
|
||||
if (crealitycloud) {
|
||||
const id = crealitycloud.collectionId;
|
||||
return { provider: "crealitycloud", externalId: id, url: crealitycloudCollectionUrl(id) };
|
||||
}
|
||||
const crealitycloudLikes = parseCrealitycloudLikesUrl(url);
|
||||
if (crealitycloudLikes) {
|
||||
// The user's own likes; Creality Cloud shows them to their owner only.
|
||||
const { userId } = crealitycloudLikes;
|
||||
return { provider: "crealitycloud", externalId: crealitycloudLikesKey(userId), url: crealitycloudLikesUrl(userId) };
|
||||
}
|
||||
const printablesLikes = parsePrintablesLikesUrl(url);
|
||||
if (printablesLikes) {
|
||||
// Only Thingport Grab can read these, as their owner (see providerListingsService.ts).
|
||||
@@ -156,6 +181,7 @@ export function modelUrlFor(provider: string, externalId: string): string {
|
||||
if (provider === "myminifactory") return `https://www.myminifactory.com/object/3d-print-${externalId}`;
|
||||
if (provider === "snapmaker") return snapmakerModelUrl(externalId);
|
||||
if (provider === "makeronline") return makeronlineModelUrl(externalId);
|
||||
if (provider === "crealitycloud") return crealitycloudModelUrl(externalId);
|
||||
return `https://www.thingiverse.com/thing:${externalId}`;
|
||||
}
|
||||
|
||||
@@ -167,6 +193,7 @@ export function modelIdFromUrl(provider: string, url: string): string | null {
|
||||
myminifactory: /\/object\/(?:[^/]*-)?(\d+)/,
|
||||
snapmaker: /\/model\/(\d+)/,
|
||||
makeronline: /\/model\/(?:[^/#]+\/)?(\d+)/,
|
||||
crealitycloud: /\/model-detail\/([\w-]+)/,
|
||||
};
|
||||
const pattern = patterns[provider] ?? /thing:(\d+)/;
|
||||
return url.match(pattern)?.[1] ?? null;
|
||||
@@ -238,13 +265,15 @@ export async function enableSync(userId: string, input: EnableSyncInput): Promis
|
||||
if (!source)
|
||||
throw new HttpError(
|
||||
400,
|
||||
"Only MakerWorld, Printables, Thingiverse, MyMiniFactory and MakerOnline collections, Thingiverse or your own Printables and MakerOnline likes, and your own Snapmaker Space likes and bookmarks, can be synced",
|
||||
"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",
|
||||
);
|
||||
if (source.provider === "printables" && source.likesOf) await assertOwnPrintablesLikes(userId, source.likesOf);
|
||||
if (source.provider === "snapmaker") await assertOwnSnapmakerList(userId, source.url);
|
||||
if (source.provider === "makeronline" && source.externalId === MAKERONLINE_LIKES_KEY) {
|
||||
await assertMakeronlineConnected(userId);
|
||||
}
|
||||
const crealitycloudLikes = source.provider === "crealitycloud" ? parseCrealitycloudLikesUrl(source.url) : null;
|
||||
if (crealitycloudLikes) await assertOwnCrealitycloudLikes(userId, crealitycloudLikes.userId);
|
||||
const knownIds = [...new Set(input.knownIds.map((id) => id.trim()).filter(Boolean))];
|
||||
|
||||
const existing = await findSyncForSource(userId, source);
|
||||
@@ -285,7 +314,9 @@ export async function enableSync(userId: string, input: EnableSyncInput): Promis
|
||||
? snapmakerListName(snapmakerList)
|
||||
: source.provider === "makeronline" && source.externalId === MAKERONLINE_LIKES_KEY
|
||||
? MAKERONLINE_LIKES_NAME
|
||||
: input.title?.trim() || `${providerName(source.provider)} Collection ${source.externalId}`;
|
||||
: crealitycloudLikes
|
||||
? CREALITYCLOUD_LIKES_NAME
|
||||
: input.title?.trim() || `${providerName(source.provider)} Collection ${source.externalId}`;
|
||||
let found: (CollectionSync & { collection: Collection }) | null = null;
|
||||
for (const name of collectionNamesFor(title, source.provider)) {
|
||||
const collection = await findOrCreateCollectionByName(userId, name);
|
||||
@@ -373,6 +404,17 @@ async function assertMakeronlineConnected(userId: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Creality Cloud shows likes to their owner only, and a sync reads them with the user's own sign-in, as for
|
||||
* Snapmaker. */
|
||||
async function assertOwnCrealitycloudLikes(userId: string, likesOf: string): Promise<void> {
|
||||
if ((await getUserCrealitycloudId(userId)) !== likesOf) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
"Creality Cloud shows likes to their owner only, so only your own can be synced, once Creality Cloud is connected",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// How many "Things (MakerWorld 2)"-style names to try before giving up.
|
||||
const MAX_NAME_SUFFIX = 20;
|
||||
|
||||
@@ -461,8 +503,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 and MakerOnline models have print
|
||||
* profiles. */
|
||||
* keep the profile scope they were queued with. Only MakerWorld, Snapmaker, MakerOnline and Creality Cloud models
|
||||
* have print profiles. */
|
||||
export async function updateSyncSettings(
|
||||
userId: string,
|
||||
collectionId: string,
|
||||
@@ -470,7 +512,10 @@ export async function updateSyncSettings(
|
||||
): Promise<CollectionSyncOut> {
|
||||
const sync = await ownedSync(userId, collectionId);
|
||||
if (settings.profileScope && !hasProfiles(sync.provider)) {
|
||||
throw new HttpError(400, "Only MakerWorld, Snapmaker Space and MakerOnline models have print profiles to pick");
|
||||
throw new HttpError(
|
||||
400,
|
||||
"Only MakerWorld, Snapmaker Space, MakerOnline and Creality Cloud models have print profiles to pick",
|
||||
);
|
||||
}
|
||||
const updated = await prisma.collectionSync.update({
|
||||
where: { id: sync.id },
|
||||
|
||||
@@ -0,0 +1,530 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { IMPORT_TIMEOUT_SECONDS } from "../config";
|
||||
import type { ImportedAuthorInfo, ImportedPageMetadata } from "./importResolvers";
|
||||
import { htmlToMarkdown } from "./descriptionMarkdown";
|
||||
import type { ProviderCollectionCard } from "./providerCollectionCard";
|
||||
|
||||
// Creality Cloud has no public API, so this is the one its own pages call: every call a POST with a JSON body, and
|
||||
// every answer `{code, msg, result}` with a code of its own. It refuses ("Invalid Parameter") any call without the
|
||||
// whole set of `__cxy_*` headers its site sends. Details, file lists, print profiles and collections need no sign-in;
|
||||
// downloads and likes need the user's token and user id.
|
||||
const SITE_ORIGIN = "https://www.crealitycloud.com";
|
||||
const API_BASE = `${SITE_ORIGIN}/api/cxy`;
|
||||
const API_TIMEOUT_MS = IMPORT_TIMEOUT_SECONDS * 1000;
|
||||
const PROVIDER = "crealitycloud";
|
||||
// Cloudflare turns away a request without a browser's User-Agent.
|
||||
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 DEVICE_ID = randomUUID();
|
||||
|
||||
const CODE_OK = 0;
|
||||
const CODE_INVALID_TOKEN = 4;
|
||||
// Deleted (512), private (513), doesn't exist (517), and taken down, under review or unlicensed (1000069-1000074).
|
||||
const MISSING_CODES = new Set([512, 513, 517, 1000069, 1000070, 1000071, 1000072, 1000074]);
|
||||
|
||||
/** The user's sign-in: Creality Cloud wants the token and the user id it belongs to together. */
|
||||
export type CrealitycloudAuth = { token: string; userId: string };
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function id(value: unknown): string | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
||||
return typeof value === "string" && /^[\w-]+$/.test(value) ? value : null;
|
||||
}
|
||||
|
||||
function count(value: unknown): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
export class CrealitycloudAuthError extends Error {
|
||||
constructor() {
|
||||
super(
|
||||
"Creality Cloud didn't accept your sign-in; it may have expired. Visit crealitycloud.com with Thingport Grab, " +
|
||||
"or connect it again in Configuration > Providers.",
|
||||
);
|
||||
this.name = "CrealitycloudAuthError";
|
||||
}
|
||||
}
|
||||
|
||||
function parseUrl(url: string): URL | null {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return /^(www\.)?crealitycloud\.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}model-detail/([\w-]+)/?$`, "i");
|
||||
const COLLECTION_PATH = new RegExp(String.raw`^/${LANG}collection/([\w-]+)/?$`, "i");
|
||||
const LIKES_PATH = new RegExp(String.raw`^/${LANG}user/(\d+)/likes/?$`, "i");
|
||||
|
||||
/** `/model-detail/<alias>`, the alias being the model's name in its address; a link with its id instead redirects to
|
||||
* the alias. Creality Cloud keeps no print profile in its links; Thingport's own `#profileId-<id>` names one. */
|
||||
export function parseCrealitycloudModelUrl(url: string): { modelId: string; requestedProfileId: string | null } | null {
|
||||
const parsed = parseUrl(url);
|
||||
const m = parsed?.pathname.match(MODEL_PATH);
|
||||
if (!m) return null;
|
||||
return {
|
||||
modelId: m[1].toLowerCase(),
|
||||
requestedProfileId: parsed!.hash.match(/profileId-([0-9a-f]{24})/i)?.[1]?.toLowerCase() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function crealitycloudModelUrl(modelId: string, profileId?: string | null): string {
|
||||
return `${SITE_ORIGIN}/model-detail/${modelId}${profileId ? `#profileId-${profileId}` : ""}`;
|
||||
}
|
||||
|
||||
/** A model's id: 24 hex digits, where its alias is words. */
|
||||
export function isCrealitycloudModelId(value: string): boolean {
|
||||
return /^[0-9a-f]{24}$/i.test(value);
|
||||
}
|
||||
|
||||
/** `/collection/<alias>`; the query its own links carry (`isUserUpload`, `from`) is left out. */
|
||||
export function parseCrealitycloudCollectionUrl(url: string): { collectionId: string } | null {
|
||||
const m = parseUrl(url)?.pathname.match(COLLECTION_PATH);
|
||||
return m ? { collectionId: m[1] } : null;
|
||||
}
|
||||
|
||||
export function crealitycloudCollectionUrl(collectionId: string): string {
|
||||
return `${SITE_ORIGIN}/collection/${collectionId}`;
|
||||
}
|
||||
|
||||
/** The user's likes, `/user/<id>/likes`. Creality Cloud shows them to their owner only. */
|
||||
export function parseCrealitycloudLikesUrl(url: string): { userId: string } | null {
|
||||
const m = parseUrl(url)?.pathname.match(LIKES_PATH);
|
||||
return m ? { userId: m[1] } : null;
|
||||
}
|
||||
|
||||
export function crealitycloudLikesUrl(userId: string): string {
|
||||
return `${SITE_ORIGIN}/user/${userId}/likes`;
|
||||
}
|
||||
|
||||
/** The id a sync keeps for the user's likes. */
|
||||
export function crealitycloudLikesKey(userId: string): string {
|
||||
return `likes-${userId}`;
|
||||
}
|
||||
|
||||
export const CREALITYCLOUD_LIKES_NAME = "Creality Cloud likes";
|
||||
|
||||
function headers(auth: CrealitycloudAuth | null): Record<string, string> {
|
||||
return {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
Origin: SITE_ORIGIN,
|
||||
"User-Agent": USER_AGENT,
|
||||
__cxy_app_id_: "cxy-gen2",
|
||||
__cxy_app_ver_: "7.3.28",
|
||||
__cxy_app_ch_: "Chrome 152.0.0.0",
|
||||
__cxy_brand_: "creality",
|
||||
__cxy_platform_: "2",
|
||||
__cxy_os_lang_: "0",
|
||||
__cxy_os_ver_: "macOS 10.15.7",
|
||||
__cxy_timezone_: "0",
|
||||
__cxy_duid_: DEVICE_ID,
|
||||
__cxy_requestid_: randomUUID(),
|
||||
...(auth ? { __cxy_token_: auth.token, __cxy_uid_: auth.userId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchApi(
|
||||
path: string,
|
||||
body: Record<string, unknown>,
|
||||
auth: CrealitycloudAuth | null,
|
||||
): Promise<{ code: number | null; message: string | null; result: unknown }> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), API_TIMEOUT_MS);
|
||||
let json: unknown;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
method: "POST",
|
||||
headers: headers(auth),
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
json = await res.json().catch(() => null);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
const code = isRecord(json) && typeof json.code === "number" ? json.code : null;
|
||||
if (code === CODE_INVALID_TOKEN && auth) throw new CrealitycloudAuthError();
|
||||
return { code, message: isRecord(json) ? str(json.msg) : null, result: isRecord(json) ? json.result : null };
|
||||
}
|
||||
|
||||
function failure(what: string, code: number | null, message: string | null): Error {
|
||||
return new Error(`Couldn't read ${what} on Creality Cloud${message ? `: ${message}` : ` (code ${code})`}`);
|
||||
}
|
||||
|
||||
/** Who the sign-in belongs to, or "rejected"/"unreachable". */
|
||||
export async function checkCrealitycloudAuth(
|
||||
auth: CrealitycloudAuth,
|
||||
): Promise<{ userId: string; name: string | null } | "rejected" | "unreachable"> {
|
||||
try {
|
||||
const { code, result } = await fetchApi("/v3/user/getInfo", {}, auth);
|
||||
const base = isRecord(result) && isRecord(result.profileUserInfo) ? result.profileUserInfo.base : null;
|
||||
if (code !== CODE_OK || !isRecord(base) || !id(base.userId)) return code === null ? "unreachable" : "rejected";
|
||||
return { userId: id(base.userId)!, name: str(base.nickName) };
|
||||
} catch (err) {
|
||||
return err instanceof CrealitycloudAuthError ? "rejected" : "unreachable";
|
||||
}
|
||||
}
|
||||
|
||||
export type CrealitycloudProfile = { id: string; title: string; creatorId: string | null; designers: boolean };
|
||||
|
||||
export type CrealitycloudModelResolution = {
|
||||
meta: Partial<ImportedPageMetadata>;
|
||||
galleryImages: { url: string; filename: string }[];
|
||||
/** The model's 24-hex id, which its file and profile calls take. */
|
||||
groupId: string;
|
||||
/** Its alias, the id Thingport keeps for it; the hex id where it has none. */
|
||||
alias: string;
|
||||
creatorId: string | null;
|
||||
profiles: CrealitycloudProfile[];
|
||||
};
|
||||
|
||||
function creatorAuthor(userInfo: unknown): ImportedAuthorInfo | null {
|
||||
if (!isRecord(userInfo)) return null;
|
||||
const creatorId = id(userInfo.userId);
|
||||
const name = str(userInfo.nickName);
|
||||
if (!creatorId || !name) return null;
|
||||
return {
|
||||
provider: PROVIDER,
|
||||
externalId: creatorId,
|
||||
name,
|
||||
handle: null,
|
||||
bio: str(userInfo.introduction),
|
||||
bioTranslated: null,
|
||||
links: [`${SITE_ORIGIN}/user/${creatorId}`],
|
||||
avatarUrl: str(userInfo.avatar),
|
||||
backgroundUrl: null,
|
||||
};
|
||||
}
|
||||
|
||||
function imageName(url: string, index: number): string {
|
||||
return url.split("/").pop()?.split("?")[0] || `image-${index}.jpg`;
|
||||
}
|
||||
|
||||
function coverUrls(value: unknown): string[] {
|
||||
return (Array.isArray(value) ? value.filter(isRecord) : []).flatMap((cover) =>
|
||||
str(cover.url) ? [str(cover.url)!] : [],
|
||||
);
|
||||
}
|
||||
|
||||
/** A print profile's name: what it says about its settings, and the printer it's for. */
|
||||
function profileTitle(profile: Record<string, unknown>, profileId: string): string {
|
||||
const settings = str(profile.secondName) ?? str(profile.name);
|
||||
const printer = str(profile.printerName);
|
||||
if (settings && printer) return `${settings} (${printer})`;
|
||||
return settings ?? printer ?? `Profile ${profileId}`;
|
||||
}
|
||||
|
||||
/** The metadata mapping, apart from the fetch so it can be tested on its own. `profiles` is the model's 3MF list. */
|
||||
export function crealitycloudMetaFromDetail(
|
||||
groupItem: Record<string, unknown>,
|
||||
profiles: Record<string, unknown>[] = [],
|
||||
): CrealitycloudModelResolution | null {
|
||||
const groupId = id(groupItem.id);
|
||||
if (!groupId) return null;
|
||||
const meta: Partial<ImportedPageMetadata> = {};
|
||||
const title = str(groupItem.groupName);
|
||||
if (title) meta.title = title;
|
||||
const description = str(groupItem.groupDesc);
|
||||
if (description) {
|
||||
const markdown = htmlToMarkdown(description)?.trim();
|
||||
if (markdown) meta.description = markdown;
|
||||
}
|
||||
if (Array.isArray(groupItem.tags)) {
|
||||
const tags = [
|
||||
...new Set(groupItem.tags.flatMap((tag) => (isRecord(tag) && str(tag.name) ? [str(tag.name)!] : []))),
|
||||
];
|
||||
if (tags.length) meta.tags = tags;
|
||||
}
|
||||
const images = coverUrls(groupItem.covers);
|
||||
if (images[0]) meta.previewImageUrl = images[0];
|
||||
const author = creatorAuthor(groupItem.userInfo);
|
||||
if (author) {
|
||||
meta.author = author;
|
||||
meta.creator = author.name;
|
||||
}
|
||||
// The model's own category, then its parent, as the site lists them, so a subcategory beats its parent.
|
||||
const categoryIds = [
|
||||
...new Set(
|
||||
[
|
||||
groupItem.categoryId,
|
||||
...(Array.isArray(groupItem.categories) ? groupItem.categories : []).map((c) => (isRecord(c) ? c.id : null)),
|
||||
]
|
||||
.map((value) => Number(value))
|
||||
.filter((value) => Number.isInteger(value) && value > 0),
|
||||
),
|
||||
];
|
||||
if (categoryIds.length) {
|
||||
meta.siteCategoryIds = categoryIds;
|
||||
meta.categorySite = PROVIDER;
|
||||
}
|
||||
const creatorId = isRecord(groupItem.userInfo) ? id(groupItem.userInfo.userId) : null;
|
||||
return {
|
||||
meta,
|
||||
galleryImages: images.map((url, index) => ({ url, filename: imageName(url, index) })),
|
||||
groupId,
|
||||
alias: str(groupItem.urlAlias)?.toLowerCase() ?? groupId,
|
||||
creatorId,
|
||||
profiles: profiles.flatMap((profile) => {
|
||||
const profileId = id(profile.id);
|
||||
if (!profileId) return [];
|
||||
const owner = isRecord(profile.userInfo) ? id(profile.userInfo.userId) : null;
|
||||
return [
|
||||
{
|
||||
id: profileId,
|
||||
title: profileTitle(profile, profileId),
|
||||
creatorId: owner,
|
||||
designers: profile.isAuth === true || (!!owner && owner === creatorId),
|
||||
},
|
||||
];
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const PROFILE_PAGE_SIZE = 199;
|
||||
|
||||
async function fetchProfiles(groupId: string): Promise<Record<string, unknown>[]> {
|
||||
const { code, message, result } = await fetchApi(
|
||||
"/v3/model/3mfList",
|
||||
{ page: 1, pageSize: PROFILE_PAGE_SIZE, modelGroupId: groupId, filterType: 11 },
|
||||
null,
|
||||
);
|
||||
if (code !== CODE_OK) throw failure("this model's print profiles", code, message);
|
||||
return isRecord(result) && Array.isArray(result.list) ? result.list.filter(isRecord) : [];
|
||||
}
|
||||
|
||||
/** By alias or id. Null for a model that doesn't exist, was deleted or isn't public. */
|
||||
export async function resolveCrealitycloudModel(modelId: string): Promise<CrealitycloudModelResolution | null> {
|
||||
const { code, message, result } = await fetchApi("/v3/model/modelGroupDetail", { id: modelId }, null);
|
||||
if (code !== null && MISSING_CODES.has(code)) return null;
|
||||
if (code !== CODE_OK || !isRecord(result) || !isRecord(result.groupItem)) throw failure("this model", code, message);
|
||||
const groupId = id(result.groupItem.id);
|
||||
if (!groupId) return null;
|
||||
return crealitycloudMetaFromDetail(result.groupItem, await fetchProfiles(groupId));
|
||||
}
|
||||
|
||||
export type CrealitycloudProfileScope = "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 selectCrealitycloudProfiles(
|
||||
resolution: Pick<CrealitycloudModelResolution, "profiles">,
|
||||
scope: CrealitycloudProfileScope,
|
||||
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 CrealitycloudFile = { name: string; url: string };
|
||||
|
||||
/** The extension a signed link's file has, since Creality Cloud names files without 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;
|
||||
}
|
||||
|
||||
/** Signed links to every one of the model's own files, for the signed-in user. Throws CrealitycloudAuthError for a
|
||||
* sign-in Creality Cloud doesn't take. */
|
||||
export async function fetchCrealitycloudModelFiles(
|
||||
groupId: string,
|
||||
auth: CrealitycloudAuth,
|
||||
): Promise<CrealitycloudFile[] | null> {
|
||||
// No file ids: every file.
|
||||
const { code, message, result } = await fetchApi(
|
||||
"/v3/model/memberDownload",
|
||||
{ modelId: groupId, trailType: 0, fileIds: [] },
|
||||
auth,
|
||||
);
|
||||
if (code !== null && MISSING_CODES.has(code)) return null;
|
||||
if (code !== CODE_OK || !isRecord(result)) throw failure("this model's files", code, message);
|
||||
return (Array.isArray(result.downloadUrls) ? result.downloadUrls.filter(isRecord) : []).flatMap((file, index) => {
|
||||
const url = str(file.url);
|
||||
return url ? [{ name: withExtension(str(file.fileName) ?? `crealitycloud-${groupId}-${index}`, url), url }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
/** A signed link to one print profile's 3MF. Null for a profile that's gone. */
|
||||
export async function fetchCrealitycloudProfileFile(
|
||||
profile: Pick<CrealitycloudProfile, "id" | "title">,
|
||||
auth: CrealitycloudAuth,
|
||||
): Promise<CrealitycloudFile | null> {
|
||||
const { code, message, result } = await fetchApi("/v3/model/3mfDownload", { id: profile.id }, auth);
|
||||
if (code !== null && MISSING_CODES.has(code)) return null;
|
||||
if (code !== CODE_OK || !isRecord(result) || !str(result.downloadUrl)) {
|
||||
throw failure("this print profile's file", code, message);
|
||||
}
|
||||
const url = str(result.downloadUrl)!;
|
||||
return { name: withExtension(profile.title.replace(/[\\/:*?"<>|]+/g, " ").trim() || profile.id, url), url };
|
||||
}
|
||||
|
||||
/** Creality Cloud's signed file links point at its Aliyun OSS buckets. */
|
||||
export function isCrealitycloudFileHost(hostname: string): boolean {
|
||||
return /^[a-z0-9-]+\.oss-[a-z0-9-]+\.aliyuncs\.com$/i.test(hostname);
|
||||
}
|
||||
|
||||
export type CrealitycloudModelSummary = { modelId: string; title: string; cover: string | null };
|
||||
export type CrealitycloudListing = { entries: CrealitycloudModelSummary[]; total: number; truncated: boolean };
|
||||
|
||||
const PAGE_SIZE = 24;
|
||||
const LISTING_MAX_ENTRIES = 600;
|
||||
const CARD_THUMBNAILS = 4;
|
||||
|
||||
/** Every page of a list of models, as the site orders it; models no longer shared are left out, since they can't be
|
||||
* imported. */
|
||||
async function fetchPagedModels(
|
||||
path: string,
|
||||
body: Record<string, unknown>,
|
||||
auth: CrealitycloudAuth | null,
|
||||
what: string,
|
||||
maxItems: number,
|
||||
): Promise<CrealitycloudListing> {
|
||||
const entries: CrealitycloudModelSummary[] = [];
|
||||
const seen = new Set<string>();
|
||||
let total = 0;
|
||||
for (let page = 1; entries.length < maxItems; page++) {
|
||||
const { code, message, result } = await fetchApi(path, { ...body, page, pageSize: PAGE_SIZE }, auth);
|
||||
if (code !== CODE_OK || !isRecord(result)) throw failure(what, code, message);
|
||||
total = count(result.count) || total;
|
||||
const items = Array.isArray(result.list) ? result.list.filter(isRecord) : [];
|
||||
for (const item of items) {
|
||||
const modelId = str(item.urlAlias)?.toLowerCase() ?? id(item.id);
|
||||
if (!modelId || item.isShared === false || seen.has(modelId)) continue;
|
||||
seen.add(modelId);
|
||||
entries.push({
|
||||
modelId,
|
||||
title: str(item.groupName) ?? `Model ${modelId}`,
|
||||
cover: coverUrls(item.covers)[0] ?? null,
|
||||
});
|
||||
if (entries.length >= maxItems) break;
|
||||
}
|
||||
if (items.length < PAGE_SIZE || page * PAGE_SIZE >= total) break;
|
||||
}
|
||||
return { entries, total, truncated: entries.length >= maxItems && total > entries.length };
|
||||
}
|
||||
|
||||
/** The signed-in user's liked models. */
|
||||
export async function fetchCrealitycloudLikes(
|
||||
auth: CrealitycloudAuth,
|
||||
maxItems: number = LISTING_MAX_ENTRIES,
|
||||
): Promise<CrealitycloudListing> {
|
||||
return fetchPagedModels("/v3/model/likeListPage", {}, auth, "your likes", maxItems);
|
||||
}
|
||||
|
||||
export type CrealitycloudCollectionInfo = {
|
||||
folderId: string;
|
||||
alias: string;
|
||||
name: string;
|
||||
ownerId: string | null;
|
||||
private: boolean;
|
||||
modelCount: number;
|
||||
};
|
||||
|
||||
function collectionInfo(result: unknown): CrealitycloudCollectionInfo | null {
|
||||
if (!isRecord(result) || !id(result.id) || !id(result.customizationUrl)) return null;
|
||||
return {
|
||||
folderId: id(result.id)!,
|
||||
alias: id(result.customizationUrl)!,
|
||||
name: str(result.thematicName) ?? "Creality Cloud collection",
|
||||
ownerId: id(result.creator),
|
||||
private: result.isPublic === false,
|
||||
modelCount: count(result.modelQuantity),
|
||||
};
|
||||
}
|
||||
|
||||
/** A collection by its alias, or by its folder id (`byFolderId`). Null for one that doesn't exist. */
|
||||
export async function fetchCrealitycloudCollectionInfo(
|
||||
key: string,
|
||||
{ byFolderId = false } = {},
|
||||
): Promise<CrealitycloudCollectionInfo | null> {
|
||||
const { code, message, result } = await fetchApi(
|
||||
"/v2/thematic/detail",
|
||||
byFolderId ? { id: key } : { customizationUrl: key },
|
||||
null,
|
||||
);
|
||||
if (code !== null && MISSING_CODES.has(code)) return null;
|
||||
if (code !== CODE_OK) throw failure("this collection", code, message);
|
||||
return collectionInfo(result);
|
||||
}
|
||||
|
||||
/** A collection's models, by its alias. Null for one that doesn't exist. */
|
||||
export async function fetchCrealitycloudCollection(
|
||||
collectionId: string,
|
||||
maxItems: number = LISTING_MAX_ENTRIES,
|
||||
): Promise<(CrealitycloudListing & { info: CrealitycloudCollectionInfo }) | null> {
|
||||
const info = await fetchCrealitycloudCollectionInfo(collectionId);
|
||||
if (!info) return null;
|
||||
const listing = await fetchPagedModels(
|
||||
"/v3/collection/listCollFolderModel",
|
||||
{ folderId: info.folderId },
|
||||
null,
|
||||
"this collection",
|
||||
maxItems,
|
||||
);
|
||||
return { ...listing, info };
|
||||
}
|
||||
|
||||
// The folder kinds listCollFolder answers with: the user's own collections, beside lists the site keeps for them
|
||||
// (likes, downloads, browsing history) that have no address of their own.
|
||||
const FOLDER_KIND_COLLECTION = 3;
|
||||
|
||||
/** The user's likes, then their own collections, as the sync dialog's Browse tab shows them. */
|
||||
export async function listCrealitycloudCollections(auth: CrealitycloudAuth): Promise<ProviderCollectionCard[]> {
|
||||
const likes = await fetchCrealitycloudLikes(auth, CARD_THUMBNAILS);
|
||||
const cards: ProviderCollectionCard[] = [
|
||||
{
|
||||
id: crealitycloudLikesKey(auth.userId),
|
||||
name: CREALITYCLOUD_LIKES_NAME,
|
||||
url: crealitycloudLikesUrl(auth.userId),
|
||||
modelCount: likes.total,
|
||||
thumbnails: likes.entries.flatMap((entry) => (entry.cover ? [entry.cover] : [])),
|
||||
private: true,
|
||||
owner: null,
|
||||
},
|
||||
];
|
||||
const { code, message, result } = await fetchApi(
|
||||
"/v3/collection/listCollFolder",
|
||||
{ userId: Number(auth.userId), page: 1, pageSize: 100 },
|
||||
auth,
|
||||
);
|
||||
if (code !== CODE_OK || !isRecord(result)) throw failure("your collections", code, message);
|
||||
const folders = (Array.isArray(result.list) ? result.list.filter(isRecord) : []).filter(
|
||||
(folder) => folder.type === FOLDER_KIND_COLLECTION && id(folder.id),
|
||||
);
|
||||
for (const folder of folders) {
|
||||
// The folder list leaves out the alias a collection's address is made of.
|
||||
const info = await fetchCrealitycloudCollectionInfo(id(folder.id)!, { byFolderId: true });
|
||||
if (!info) continue;
|
||||
const thumbs = (Array.isArray(folder.coverList) ? folder.coverList.filter(isRecord) : []).flatMap((cover) =>
|
||||
isRecord(cover.covers) && str(cover.covers.url) ? [str(cover.covers.url)!] : [],
|
||||
);
|
||||
cards.push({
|
||||
id: info.alias,
|
||||
name: str(folder.name) ?? info.name,
|
||||
url: crealitycloudCollectionUrl(info.alias),
|
||||
modelCount: count(folder.modelQuantity),
|
||||
thumbnails: thumbs.slice(0, CARD_THUMBNAILS),
|
||||
private: folder.isPublic === false,
|
||||
owner: null,
|
||||
});
|
||||
}
|
||||
return cards;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { prisma } from "../db";
|
||||
import type { CrealitycloudAuth } from "./crealitycloudApi";
|
||||
|
||||
// Per-user: the Creality Cloud sign-in, its model_token and model_user_id cookies, which the API takes together. The
|
||||
// site sets them once at sign-in for 7 days and never renews them, and Thingport doesn't either: Thingport Grab sends
|
||||
// each new pair. The cookie's expiry is kept only to show; the token is used until Creality Cloud turns it down, as it
|
||||
// may outlive the cookie.
|
||||
|
||||
const TOKEN_COOKIE = "model_token";
|
||||
const USER_COOKIE = "model_user_id";
|
||||
|
||||
export type CrealitycloudSession = CrealitycloudAuth & { expiresAt: Date | null };
|
||||
|
||||
function valueOf(text: string, names: string[]): string | null {
|
||||
for (const name of names) {
|
||||
// `name=value` from a Cookie header, `name: value` or `name` then the value on the next line from copied headers.
|
||||
const m = text.match(new RegExp(String.raw`(?:^|[;\s])${name}(?:\s*[=:]\s*|\s+)([^;\s]+)`, "i"));
|
||||
if (m) {
|
||||
try {
|
||||
return decodeURIComponent(m[1]);
|
||||
} catch {
|
||||
return null; // Malformed percent-encoding: not a value.
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The token and user id from a Cookie header, or from the `__cxy_token_` and `__cxy_uid_` request headers copied
|
||||
* from the browser; null without both. */
|
||||
export function normalizeCrealitycloudSession(
|
||||
raw: string | null | undefined,
|
||||
expiresAt: Date | null = null,
|
||||
): CrealitycloudSession | null {
|
||||
const text = (raw ?? "").replace(/^\s*cookie:\s*/i, "").trim();
|
||||
if (!text) return null;
|
||||
const token = valueOf(text, [TOKEN_COOKIE, "__cxy_token_"]);
|
||||
const userId = valueOf(text, [USER_COOKIE, "__cxy_uid_"]);
|
||||
if (!token || !/^[0-9a-f]{32,128}$/i.test(token) || !userId || !/^\d+$/.test(userId)) return null;
|
||||
return { token, userId, expiresAt };
|
||||
}
|
||||
|
||||
/** The user's sign-in until Creality Cloud turns it down; null without one. */
|
||||
export async function getUserCrealitycloudAuth(userId: string): Promise<CrealitycloudAuth | null> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { crealitycloudToken: true, crealitycloudUserId: true, crealitycloudRejectedAt: true },
|
||||
});
|
||||
if (!user?.crealitycloudToken || !user.crealitycloudUserId || user.crealitycloudRejectedAt) return null;
|
||||
return { token: user.crealitycloudToken, userId: user.crealitycloudUserId };
|
||||
}
|
||||
|
||||
export async function getUserCrealitycloudId(userId: string): Promise<string | null> {
|
||||
const user = await prisma.user.findUnique({ where: { id: userId }, select: { crealitycloudUserId: true } });
|
||||
return user?.crealitycloudUserId ?? null;
|
||||
}
|
||||
|
||||
/** A new sign-in clears an earlier rejection. The Creality Cloud user id is kept on disconnect, as for Snapmaker. */
|
||||
export async function setUserCrealitycloudSession(
|
||||
userId: string,
|
||||
session: CrealitycloudSession | null,
|
||||
): Promise<boolean> {
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: session
|
||||
? {
|
||||
crealitycloudToken: session.token,
|
||||
crealitycloudExpiresAt: session.expiresAt,
|
||||
crealitycloudUserId: session.userId,
|
||||
crealitycloudRejectedAt: null,
|
||||
}
|
||||
: { crealitycloudToken: null, crealitycloudExpiresAt: null, crealitycloudRejectedAt: null },
|
||||
});
|
||||
return Boolean(session);
|
||||
}
|
||||
|
||||
export async function markCrealitycloudSessionRejected(userId: string): Promise<void> {
|
||||
await prisma.user.update({ where: { id: userId }, data: { crealitycloudRejectedAt: new Date() } });
|
||||
}
|
||||
|
||||
export type CrealitycloudConnection = "connected" | "disconnected" | "expired";
|
||||
|
||||
export async function getCrealitycloudConnection(userId: string): Promise<CrealitycloudConnection> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { crealitycloudToken: true, crealitycloudRejectedAt: true },
|
||||
});
|
||||
if (!user?.crealitycloudToken) return "disconnected";
|
||||
return user.crealitycloudRejectedAt ? "expired" : "connected";
|
||||
}
|
||||
|
||||
/** Shown wherever a Creality Cloud download or the user's likes need a sign-in they haven't given, or that ran out. */
|
||||
export const CREALITYCLOUD_CONNECT =
|
||||
"Creality Cloud only gives its files to a signed-in user: visit crealitycloud.com signed in with Thingport Grab, " +
|
||||
"or connect it in Configuration > Providers, then import it again.";
|
||||
@@ -22,6 +22,7 @@ const SOURCE_PROVIDERS = [
|
||||
"myminifactory",
|
||||
"snapmaker",
|
||||
"makeronline",
|
||||
"crealitycloud",
|
||||
] as const;
|
||||
type SourceProvider = (typeof SOURCE_PROVIDERS)[number];
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import { getMyminifactoryApiKey, getThingiverseAccessToken } from "./settingsSer
|
||||
import { MyminifactoryAuthError, MyminifactoryRateLimitError, resolveMyminifactoryObject } from "./myminifactoryApi";
|
||||
import { resolveSnapmakerModel } from "./snapmakerApi";
|
||||
import { resolveMakeronlineModel } from "./makeronlineApi";
|
||||
import { resolveCrealitycloudModel } from "./crealitycloudApi";
|
||||
import { getUserMakerworldCookie } from "./makerworldCookieService";
|
||||
import { upsertAuthorFromImport } from "./authorService";
|
||||
import { emptyGaps, openSourceGaps, type SourceGap } from "./sourceGaps";
|
||||
@@ -110,6 +111,12 @@ export async function fetchSourceMeta(userId: string, print: Print, sourceUrl: s
|
||||
return { ...emptyImportedPageMetadata(), ...resolved.meta, galleryImages: resolved.galleryImages };
|
||||
}
|
||||
|
||||
if (provider === "crealitycloud") {
|
||||
const resolved = await resolveCrealitycloudModel(externalId);
|
||||
if (!resolved) throw new HttpError(404, "This Creality Cloud 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");
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
fetchMakerworldDesignForImport,
|
||||
importPrintFromUrl,
|
||||
SNAPMAKER_SESSION_CODE,
|
||||
CREALITYCLOUD_SESSION_CODE,
|
||||
type ImportRequestBody,
|
||||
} from "./importService";
|
||||
import {
|
||||
@@ -36,6 +37,13 @@ import {
|
||||
} from "./snapmakerApi";
|
||||
import { getUserSnapmakerToken, SNAPMAKER_CONNECT } from "./snapmakerSessionService";
|
||||
import { parseMakeronlineModelUrl } from "./makeronlineApi";
|
||||
import {
|
||||
crealitycloudModelUrl,
|
||||
parseCrealitycloudModelUrl,
|
||||
resolveCrealitycloudModel,
|
||||
selectCrealitycloudProfiles,
|
||||
} from "./crealitycloudApi";
|
||||
import { CREALITYCLOUD_CONNECT, getUserCrealitycloudAuth } from "./crealitycloudSessionService";
|
||||
import { queueGrabImport } from "./grabImportService";
|
||||
import { upsertAuthorFromImport } from "./authorService";
|
||||
import { extractZipEntriesToPrints } from "./zipService";
|
||||
@@ -74,6 +82,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 };
|
||||
|
||||
// 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")
|
||||
@@ -90,7 +99,13 @@ const PROVIDER_OFF_TEXT = (name: string) =>
|
||||
function classifyImportFailure(err: unknown): ImportFailureReason {
|
||||
if (err instanceof MakerworldAuthError) return "auth";
|
||||
if (err instanceof HttpError) {
|
||||
if (err.code === MAKERWORLD_AUTH_CODE || err.code === SNAPMAKER_SESSION_CODE) return "auth";
|
||||
if (
|
||||
err.code === MAKERWORLD_AUTH_CODE ||
|
||||
err.code === SNAPMAKER_SESSION_CODE ||
|
||||
err.code === CREALITYCLOUD_SESSION_CODE
|
||||
) {
|
||||
return "auth";
|
||||
}
|
||||
if (err.code === CLOUDFLARE_BLOCKED_CODE) return "blocked";
|
||||
if (err.code === PROVIDER_DISABLED_CODE) return "disabled";
|
||||
if (err.status === 403 || err.status === 404) return "unavailable";
|
||||
@@ -269,16 +284,61 @@ async function snapmakerProfileUrls(
|
||||
};
|
||||
}
|
||||
|
||||
/** Imports several print profiles of one Snapmaker model as one model with a file per profile. */
|
||||
export async function runSnapmakerProfilesImportJob(
|
||||
/** The print profiles a scope picks from a Creality Cloud model, as links that each name one. */
|
||||
async function crealitycloudProfileUrls(
|
||||
url: string,
|
||||
scope: Exclude<MakerworldProfileScope, "url">,
|
||||
): Promise<{ title: string | null; urls: string[] }> {
|
||||
const parsed = parseCrealitycloudModelUrl(url);
|
||||
if (!parsed) throw new HttpError(400, "Not a Creality Cloud model link");
|
||||
const resolved = await resolveCrealitycloudModel(parsed.modelId);
|
||||
if (!resolved) throw new HttpError(404, "This Creality Cloud model could not be found, or isn't public.");
|
||||
const profileIds = selectCrealitycloudProfiles(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) => crealitycloudModelUrl(resolved.alias, 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";
|
||||
|
||||
/** A provider whose print profiles import by link, each as a plate of one model. */
|
||||
type ProfilesProvider = {
|
||||
provider: "snapmaker" | "crealitycloud";
|
||||
name: string;
|
||||
profileUrls: typeof snapmakerProfileUrls;
|
||||
authText: string;
|
||||
};
|
||||
|
||||
const SNAPMAKER_PROFILES: ProfilesProvider = {
|
||||
provider: "snapmaker",
|
||||
name: "Snapmaker Space",
|
||||
profileUrls: snapmakerProfileUrls,
|
||||
authText: SNAPMAKER_AUTH_TEXT,
|
||||
};
|
||||
|
||||
const CREALITYCLOUD_PROFILES: ProfilesProvider = {
|
||||
provider: "crealitycloud",
|
||||
name: "Creality Cloud",
|
||||
profileUrls: crealitycloudProfileUrls,
|
||||
authText: CREALITYCLOUD_AUTH_TEXT,
|
||||
};
|
||||
|
||||
/** Imports several print profiles of one model as one model with a file per profile. */
|
||||
async function runProfilesImportJob(
|
||||
jobId: string,
|
||||
userId: string,
|
||||
source: ProfilesProvider,
|
||||
body: MakerworldProfilesImportJobBody,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { title, urls } = await snapmakerProfileUrls(body.url, body.scope);
|
||||
const { title, urls } = await source.profileUrls(body.url, body.scope);
|
||||
await updateJob(jobId, { total: urls.length, sourceLabel: title });
|
||||
log.info("snapmaker profiles job started", { job: jobId, url: body.url, profiles: urls.length });
|
||||
log.info(`${source.provider} profiles job started`, { job: jobId, url: body.url, profiles: urls.length });
|
||||
const totals = await importProfileUrlsSequentially(
|
||||
userId,
|
||||
urls,
|
||||
@@ -304,16 +364,16 @@ export async function runSnapmakerProfilesImportJob(
|
||||
userId,
|
||||
action: "import_completed",
|
||||
targetId: printId,
|
||||
details: { provider: "snapmaker", sourceLabel: title, imported, alreadyInLibrary, failed },
|
||||
details: { provider: source.provider, sourceLabel: title, imported, alreadyInLibrary, failed },
|
||||
});
|
||||
const bodyParts: string[] = [];
|
||||
if (alreadyInLibrary) bodyParts.push(`${alreadyInLibrary} already on the model`);
|
||||
if (stopReason === "auth") bodyParts.push(`the rest failed because ${SNAPMAKER_AUTH_TEXT}`);
|
||||
if (stopReason === "auth") bodyParts.push(`the rest failed because ${source.authText}`);
|
||||
else if (failed) bodyParts.push(`${failed} failed`);
|
||||
const label = title ? `"${title}"` : "a Snapmaker model";
|
||||
const label = title ? `"${title}"` : `a ${source.name} model`;
|
||||
await createNotification(userId, {
|
||||
type: "import",
|
||||
title: `Imported ${imported} of ${urls.length} print profiles from Snapmaker Space`,
|
||||
title: `Imported ${imported} of ${urls.length} print profiles from ${source.name}`,
|
||||
body: bodyParts.length ? `Of ${label} — ${bodyParts.join(", ")}.` : `Of ${label}.`,
|
||||
externalUrl: body.url,
|
||||
internalPath: printId ? `/models/${printId}` : null,
|
||||
@@ -323,8 +383,21 @@ export async function runSnapmakerProfilesImportJob(
|
||||
}
|
||||
}
|
||||
|
||||
const SNAPMAKER_AUTH_TEXT =
|
||||
"your Snapmaker sign-in ran out — visit space.snapmaker.com with Thingport Grab, then retry";
|
||||
export function runSnapmakerProfilesImportJob(
|
||||
jobId: string,
|
||||
userId: string,
|
||||
body: MakerworldProfilesImportJobBody,
|
||||
): Promise<void> {
|
||||
return runProfilesImportJob(jobId, userId, SNAPMAKER_PROFILES, body);
|
||||
}
|
||||
|
||||
export function runCrealitycloudProfilesImportJob(
|
||||
jobId: string,
|
||||
userId: string,
|
||||
body: MakerworldProfilesImportJobBody,
|
||||
): Promise<void> {
|
||||
return runProfilesImportJob(jobId, userId, CREALITYCLOUD_PROFILES, body);
|
||||
}
|
||||
|
||||
export async function runCollectionImportJob(
|
||||
jobId: string,
|
||||
@@ -471,9 +544,9 @@ 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 sign-in. */
|
||||
* the user's own Snapmaker or Creality Cloud sign-in. */
|
||||
type TokenBatchProvider = {
|
||||
provider: "thingiverse" | "myminifactory" | "snapmaker";
|
||||
provider: "thingiverse" | "myminifactory" | "snapmaker" | "crealitycloud";
|
||||
getToken: () => Promise<string | null>;
|
||||
notConfigured: string;
|
||||
modelUrl: (id: string) => string;
|
||||
@@ -513,8 +586,19 @@ function snapmakerBatch(userId: string): TokenBatchProvider {
|
||||
};
|
||||
}
|
||||
|
||||
/** Shared by the Thingiverse Likes and Collection imports, MyMiniFactory's collections and Snapmaker's likes and
|
||||
* bookmarks.
|
||||
function crealitycloudBatch(userId: string): TokenBatchProvider {
|
||||
return {
|
||||
provider: "crealitycloud",
|
||||
getToken: async () => (await getUserCrealitycloudAuth(userId))?.token ?? null,
|
||||
notConfigured: CREALITYCLOUD_CONNECT,
|
||||
modelUrl: (id) => crealitycloudModelUrl(id),
|
||||
rateLimitText: "blocked by Creality Cloud's rate limit — wait a while, then retry",
|
||||
authText: `failed because ${CREALITYCLOUD_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.
|
||||
* 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(
|
||||
@@ -701,6 +785,23 @@ export async function runSnapmakerListImportJob(
|
||||
);
|
||||
}
|
||||
|
||||
/** The user's Creality Cloud likes, or a collection, filed into a collection named after it. */
|
||||
export async function runCrealitycloudListImportJob(
|
||||
jobId: string,
|
||||
userId: string,
|
||||
body: CrealitycloudListImportJobBody,
|
||||
): Promise<void> {
|
||||
await runTokenBatchImportJob(
|
||||
jobId,
|
||||
userId,
|
||||
crealitycloudBatch(userId),
|
||||
body,
|
||||
body.model_ids,
|
||||
async () => body.title,
|
||||
(collectionTitle) => `"${collectionTitle}"`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function runPrintablesCollectionImportJob(
|
||||
jobId: string,
|
||||
userId: string,
|
||||
@@ -908,9 +1009,9 @@ const LINKS_RETRY_DELAY_MS = 2000;
|
||||
|
||||
/** Why a link's sign-in failed, by its provider. */
|
||||
function authStopText(url: string): string {
|
||||
return parseSnapmakerModelUrl(url)
|
||||
? SNAPMAKER_AUTH_TEXT
|
||||
: "the MakerWorld session expired — update the cookie in Settings, then retry";
|
||||
if (parseSnapmakerModelUrl(url)) return SNAPMAKER_AUTH_TEXT;
|
||||
if (parseCrealitycloudModelUrl(url)) return CREALITYCLOUD_AUTH_TEXT;
|
||||
return "the MakerWorld session expired — update the cookie in Settings, then retry";
|
||||
}
|
||||
|
||||
function isTransientImportFailure(err: unknown): boolean {
|
||||
@@ -922,7 +1023,7 @@ function failureMessage(err: unknown): string {
|
||||
return err instanceof Error && err.message.trim() ? err.message.trim() : "Import failed";
|
||||
}
|
||||
|
||||
/** One queued link. A MakerWorld or Snapmaker model with a multi-profile scope imports each wanted profile onto
|
||||
/** One queued link. A MakerWorld, Snapmaker or Creality Cloud 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(
|
||||
@@ -951,6 +1052,10 @@ async function importOneLink(
|
||||
const { urls } = await snapmakerProfileUrls(url, body.scope);
|
||||
return { ...(await importProfileUrlsSequentially(userId, urls, { ...body, url })), profiles: true };
|
||||
}
|
||||
if (parseCrealitycloudModelUrl(url) && body.scope && body.scope !== "url") {
|
||||
const { urls } = await crealitycloudProfileUrls(url, body.scope);
|
||||
return { ...(await importProfileUrlsSequentially(userId, urls, { ...body, url })), profiles: true };
|
||||
}
|
||||
const result = await importPrintFromUrl(userId, url, { ...body, url });
|
||||
return {
|
||||
processed: 1,
|
||||
|
||||
@@ -490,7 +490,15 @@ export type ImportedPageMetadata = {
|
||||
author: ImportedAuthorInfo | null;
|
||||
/** The site's own category ids; each site has its own id namespace. */
|
||||
siteCategoryIds: number[];
|
||||
categorySite: "makerworld" | "thingiverse" | "printables" | "myminifactory" | "snapmaker" | "makeronline" | null;
|
||||
categorySite:
|
||||
| "makerworld"
|
||||
| "thingiverse"
|
||||
| "printables"
|
||||
| "myminifactory"
|
||||
| "snapmaker"
|
||||
| "makeronline"
|
||||
| "crealitycloud"
|
||||
| null;
|
||||
/** Lets a second profile of an imported design be added as its own plate. MakerWorld only. */
|
||||
makerworldProfile?: MakerworldProfileRef;
|
||||
};
|
||||
|
||||
@@ -95,12 +95,31 @@ import {
|
||||
import { getUserSnapmakerToken, markSnapmakerTokenRejected, SNAPMAKER_CONNECT } from "./snapmakerSessionService";
|
||||
import { isMakeronlineFileHost, parseMakeronlineModelUrl, resolveMakeronlineModel } from "./makeronlineApi";
|
||||
import { clearGrabImports } from "./grabImportService";
|
||||
import {
|
||||
CrealitycloudAuthError,
|
||||
type CrealitycloudFile,
|
||||
type CrealitycloudModelResolution,
|
||||
fetchCrealitycloudModelFiles,
|
||||
fetchCrealitycloudProfileFile,
|
||||
isCrealitycloudFileHost,
|
||||
parseCrealitycloudModelUrl,
|
||||
resolveCrealitycloudModel,
|
||||
selectCrealitycloudProfiles,
|
||||
type CrealitycloudAuth,
|
||||
} from "./crealitycloudApi";
|
||||
import {
|
||||
CREALITYCLOUD_CONNECT,
|
||||
getUserCrealitycloudAuth,
|
||||
markCrealitycloudSessionRejected,
|
||||
} from "./crealitycloudSessionService";
|
||||
|
||||
/** An import refused because the user's MyMiniFactory session has expired. */
|
||||
export const MYMINIFACTORY_SESSION_CODE = "myminifactory_session";
|
||||
/** An import refused because the user hasn't connected Snapmaker, or their sign-in has run out. */
|
||||
export const SNAPMAKER_SESSION_CODE = "snapmaker_session";
|
||||
/** A MakerOnline import that came without the signed file links only Thingport Grab can get. */
|
||||
/** 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";
|
||||
export const MAKERONLINE_GRAB_CODE = "makeronline_grab";
|
||||
export const MAKERONLINE_NEEDS_GRAB =
|
||||
"MakerOnline only gives its files to its own pages: import it with Thingport Grab on makeronline.com.";
|
||||
@@ -593,6 +612,7 @@ const CATEGORY_SITE_CAT_IDS_FIELD = {
|
||||
myminifactory: "myminifactoryCatIds",
|
||||
snapmaker: "snapmakerCatIds",
|
||||
makeronline: "makeronlineCatIds",
|
||||
crealitycloud: "crealitycloudCatIds",
|
||||
} as const;
|
||||
|
||||
/** Auto-categorizes an import when a category's `*CatIds` for this site overlap the model's own
|
||||
@@ -634,14 +654,17 @@ export function identifySourceModel(url: string): { provider: string; externalId
|
||||
if (snapmaker) return { provider: "snapmaker", externalId: snapmaker.modelId };
|
||||
const makeronline = parseMakeronlineModelUrl(url);
|
||||
if (makeronline) return { provider: "makeronline", externalId: makeronline.modelId };
|
||||
const crealitycloud = parseCrealitycloudModelUrl(url);
|
||||
if (crealitycloud) return { provider: "crealitycloud", externalId: crealitycloud.modelId };
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The print profile a MakerWorld, Snapmaker or MakerOnline link names, if any. */
|
||||
/** The print profile a MakerWorld, Snapmaker, MakerOnline or Creality Cloud 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;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -688,6 +711,7 @@ export function buildImportSourceUrl(provider: string | null, externalId: string
|
||||
if (provider === "myminifactory") return `https://www.myminifactory.com/object/3d-print-${externalId}`;
|
||||
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}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1441,6 +1465,121 @@ async function addSnapmakerProfileToPrint(
|
||||
return addProfilePlate(existing, profile, wanted);
|
||||
}
|
||||
|
||||
const CREALITYCLOUD_BLOCKED =
|
||||
"Creality Cloud blocked the file download (rate limit). This usually clears after a while -- wait, then retry the same import.";
|
||||
const CREALITYCLOUD_MISSING = "This Creality Cloud model could not be found, or isn't public.";
|
||||
|
||||
function downloadCrealitycloudFile(file: CrealitycloudFile): Promise<NewPlateInput[] | null> {
|
||||
return downloadSignedFile(file, isCrealitycloudFileHost, CREALITYCLOUD_BLOCKED);
|
||||
}
|
||||
|
||||
/** The user's sign-in, or the HttpError that says to connect Creality Cloud. */
|
||||
async function requireCrealitycloudAuth(userId: string): Promise<CrealitycloudAuth> {
|
||||
const auth = await getUserCrealitycloudAuth(userId);
|
||||
if (!auth) throw new HttpError(400, CREALITYCLOUD_CONNECT, CREALITYCLOUD_SESSION_CODE);
|
||||
return auth;
|
||||
}
|
||||
|
||||
/** Creality Cloud's errors as the HttpErrors an import reports; a rejected sign-in is remembered, so the app can say
|
||||
* so. */
|
||||
async function crealitycloudFailure(userId: string, err: unknown): Promise<unknown> {
|
||||
if (err instanceof CrealitycloudAuthError) {
|
||||
await markCrealitycloudSessionRejected(userId);
|
||||
// 400, not 401: see tryMakerworldCloudApi.
|
||||
return new HttpError(400, err.message, CREALITYCLOUD_SESSION_CODE);
|
||||
}
|
||||
return err;
|
||||
}
|
||||
|
||||
/** Downloads one print profile's 3MF, tagged with the profile; null when the model has no such profile. */
|
||||
async function downloadCrealitycloudProfile(
|
||||
resolved: CrealitycloudModelResolution,
|
||||
profileId: string,
|
||||
auth: CrealitycloudAuth,
|
||||
): Promise<NewPlateInput | null> {
|
||||
const profile = resolved.profiles.find((p) => p.id === profileId);
|
||||
const file = profile ? await fetchCrealitycloudProfileFile(profile, auth) : null;
|
||||
if (!file) return null;
|
||||
const inputs = await downloadCrealitycloudFile(file);
|
||||
if (!inputs?.length) {
|
||||
throw new HttpError(400, "Couldn't download this print profile from Creality Cloud. 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 Snapmaker: 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 Creality Cloud sign-in. A link
|
||||
* by id is kept under the model's alias, the id the site's own links have. */
|
||||
async function importCrealitycloudModel(
|
||||
userId: string,
|
||||
linked: { provider: string; externalId: string },
|
||||
url: string,
|
||||
body: ImportRequestBody,
|
||||
): Promise<ImportResult> {
|
||||
const auth = await requireCrealitycloudAuth(userId);
|
||||
let resolved: CrealitycloudModelResolution | null;
|
||||
try {
|
||||
resolved = await resolveCrealitycloudModel(linked.externalId);
|
||||
} catch (err) {
|
||||
throw await crealitycloudFailure(userId, err);
|
||||
}
|
||||
if (!resolved) throw new HttpError(404, CREALITYCLOUD_MISSING);
|
||||
const source = { provider: linked.provider, externalId: resolved.alias };
|
||||
if (source.externalId !== linked.externalId) {
|
||||
const existing = await findExistingImportedPrint(userId, source);
|
||||
if (existing) return addCrealitycloudProfileToPrint(existing, url, body, resolved);
|
||||
}
|
||||
const downloaded: NewPlateInput[] = [];
|
||||
try {
|
||||
const requested = body.resolved_instance_id ?? parseCrealitycloudModelUrl(url)?.requestedProfileId ?? null;
|
||||
const [profileId] = selectCrealitycloudProfiles(resolved, "url", requested);
|
||||
if (profileId) {
|
||||
const profile = await downloadCrealitycloudProfile(resolved, profileId, auth);
|
||||
if (profile) downloaded.push(profile);
|
||||
}
|
||||
if (!downloaded.length) {
|
||||
const files = await fetchCrealitycloudModelFiles(resolved.groupId, auth);
|
||||
if (!files) throw new HttpError(404, CREALITYCLOUD_MISSING);
|
||||
for (const file of files) downloaded.push(...((await downloadCrealitycloudFile(file)) ?? []));
|
||||
}
|
||||
} catch (err) {
|
||||
await Promise.all(downloaded.map((input) => fs.rm(input.tempFilePath!, { force: true }).catch(() => undefined)));
|
||||
throw await crealitycloudFailure(userId, err);
|
||||
}
|
||||
if (!downloaded.length) throw new HttpError(400, "This Creality Cloud model has no files Thingport can import.");
|
||||
|
||||
return createSourcedPrint(userId, source, body, resolved, downloaded, `crealitycloud-${source.externalId}`);
|
||||
}
|
||||
|
||||
/** Re-import of a Creality Cloud 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 addCrealitycloudProfileToPrint(
|
||||
existing: ExistingImportedPrint,
|
||||
url: string,
|
||||
body: ImportRequestBody,
|
||||
resolved: CrealitycloudModelResolution | null = null,
|
||||
): Promise<ImportResult> {
|
||||
const wanted = body.resolved_instance_id ?? parseCrealitycloudModelUrl(url)?.requestedProfileId ?? null;
|
||||
if (!wanted || existing.plates.some((plate) => plate.sourceInstanceId === wanted)) {
|
||||
return { ...existing, alreadyImported: true };
|
||||
}
|
||||
const userId = existing.print.userId;
|
||||
const auth = await requireCrealitycloudAuth(userId);
|
||||
let profile: NewPlateInput | null;
|
||||
try {
|
||||
const model = resolved ?? (await resolveCrealitycloudModel(existing.print.sourceExternalId!));
|
||||
if (!model) throw new HttpError(404, CREALITYCLOUD_MISSING);
|
||||
profile = await downloadCrealitycloudProfile(model, wanted, auth);
|
||||
} catch (err) {
|
||||
throw await crealitycloudFailure(userId, err);
|
||||
}
|
||||
if (!profile) throw new HttpError(404, "This Creality Cloud 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.";
|
||||
|
||||
@@ -1550,10 +1689,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"]);
|
||||
const PROFILE_PROVIDERS = new Set(["makerworld", "snapmaker", "makeronline", "crealitycloud"]);
|
||||
|
||||
/** Refuses a link from a provider an admin turned off, unless it's a model the user already has (MakerWorld, Snapmaker
|
||||
* and MakerOnline add a profile, so fetch). */
|
||||
/** 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). */
|
||||
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;
|
||||
@@ -1569,6 +1708,7 @@ async function importNewOrExisting(userId: string, url: string, body: ImportRequ
|
||||
await assertUrlProviderEnabled(url);
|
||||
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) return addMakerworldProfileToPrint(existing, url, body);
|
||||
|
||||
if (source?.provider === "thingiverse") {
|
||||
@@ -1586,6 +1726,9 @@ async function importNewOrExisting(userId: string, url: string, body: ImportRequ
|
||||
if (source?.provider === "makeronline") {
|
||||
return importMakeronlineModel(userId, source, body);
|
||||
}
|
||||
if (source?.provider === "crealitycloud") {
|
||||
return importCrealitycloudModel(userId, source, url, body);
|
||||
}
|
||||
|
||||
const { tempPath, filename, mime, meta } = await downloadImportToTemp(url, body);
|
||||
const author = await upsertAuthorFromImport(meta.author);
|
||||
|
||||
@@ -25,6 +25,13 @@ import {
|
||||
MAKERONLINE_LIKES_KEY,
|
||||
} from "./makeronlineApi";
|
||||
import { getMakeronlineConnection, getUserMakeronlineId, getUserMakeronlineToken } from "./makeronlineSessionService";
|
||||
import {
|
||||
CrealitycloudAuthError,
|
||||
crealitycloudCollectionUrl,
|
||||
fetchCrealitycloudCollection,
|
||||
listCrealitycloudCollections,
|
||||
} from "./crealitycloudApi";
|
||||
import { getCrealitycloudConnection, getUserCrealitycloudAuth } from "./crealitycloudSessionService";
|
||||
import { MakerworldAuthError, MakerworldCaptchaError, extractMakerworldBearerToken } from "./makerworldCloudApi";
|
||||
import { fetchMakerworldCollectionCard, listMakerworldOwnCollections } from "./makerworldCollections";
|
||||
import { getUserMakerworldCookie } from "./makerworldCookieService";
|
||||
@@ -66,6 +73,7 @@ export const BROWSABLE_PROVIDERS = [
|
||||
"myminifactory",
|
||||
"snapmaker",
|
||||
"makeronline",
|
||||
"crealitycloud",
|
||||
] as const;
|
||||
export type BrowsableProvider = (typeof BROWSABLE_PROVIDERS)[number];
|
||||
|
||||
@@ -89,7 +97,7 @@ export type ProviderCollectionOut = {
|
||||
};
|
||||
|
||||
export async function getProviderAccounts(userId: string): Promise<ProviderAccountsOut> {
|
||||
const [user, thingiverseToken, myminifactoryKey, snapmaker, makeronline] = await Promise.all([
|
||||
const [user, thingiverseToken, myminifactoryKey, snapmaker, makeronline, crealitycloud] = await Promise.all([
|
||||
prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
@@ -103,6 +111,7 @@ export async function getProviderAccounts(userId: string): Promise<ProviderAccou
|
||||
getMyminifactoryApiKey(),
|
||||
getSnapmakerConnection(userId),
|
||||
getMakeronlineConnection(userId),
|
||||
getCrealitycloudConnection(userId),
|
||||
]);
|
||||
return {
|
||||
makerworld: { connected: Boolean(extractMakerworldBearerToken(user?.makerworldCookie)), account: null },
|
||||
@@ -111,6 +120,7 @@ export async function getProviderAccounts(userId: string): Promise<ProviderAccou
|
||||
myminifactory: { connected: Boolean(myminifactoryKey), account: user?.myminifactoryUsername ?? null },
|
||||
snapmaker: { connected: snapmaker === "connected", account: null },
|
||||
makeronline: { connected: makeronline === "connected", account: null },
|
||||
crealitycloud: { connected: crealitycloud === "connected", account: null },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -157,6 +167,8 @@ export async function setProviderAccount(
|
||||
throw new HttpError(400, "Snapmaker Space is linked through its sign-in in Configuration > Providers");
|
||||
} else if (provider === "makeronline") {
|
||||
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 {
|
||||
throw new HttpError(400, "MakerWorld is linked through its cookie in Configuration > Providers");
|
||||
}
|
||||
@@ -194,6 +206,7 @@ async function providerCall<T>(call: () => Promise<T>): Promise<T> {
|
||||
if (err instanceof MyminifactoryAuthError) throw new HttpError(400, err.message);
|
||||
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);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -235,6 +248,32 @@ async function makeronlineCollectionCard(userId: string, collectionId: string):
|
||||
});
|
||||
}
|
||||
|
||||
/** The user's likes and their own collections, read with their sign-in. */
|
||||
async function crealitycloudCards(userId: string): Promise<ProviderCollectionCard[]> {
|
||||
const auth = await getUserCrealitycloudAuth(userId);
|
||||
if (!auth) {
|
||||
throw new HttpError(409, "Connect Creality Cloud in Configuration > Providers, or visit it with Thingport Grab");
|
||||
}
|
||||
return providerCall(() => listCrealitycloudCollections(auth));
|
||||
}
|
||||
|
||||
/** Any collection, by its alias: Creality Cloud lists even private ones without a sign-in. */
|
||||
async function crealitycloudCollectionCard(collectionId: string): Promise<ProviderCollectionCard | null> {
|
||||
return providerCall(async () => {
|
||||
const collection = await fetchCrealitycloudCollection(collectionId, 4);
|
||||
if (!collection) return null;
|
||||
return {
|
||||
id: collectionId,
|
||||
name: collection.info.name,
|
||||
url: crealitycloudCollectionUrl(collectionId),
|
||||
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,
|
||||
@@ -289,6 +328,8 @@ export async function listProviderCollections(
|
||||
cards = await snapmakerCards(userId);
|
||||
} else if (provider === "makeronline") {
|
||||
cards = await makeronlineCards(userId);
|
||||
} else if (provider === "crealitycloud") {
|
||||
cards = await crealitycloudCards(userId);
|
||||
} else if (provider === "myminifactory") {
|
||||
const key = await requireMyminifactoryKey();
|
||||
if (!user?.myminifactoryUsername) throw new HttpError(409, "Tell Thingport your MyMiniFactory username first");
|
||||
@@ -316,7 +357,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 or MakerOnline collection link",
|
||||
"That isn't a MakerWorld, Printables, Thingiverse, MyMiniFactory, Snapmaker Space, MakerOnline or Creality Cloud collection link",
|
||||
);
|
||||
await assertProviderEnabled(source.provider);
|
||||
let card: ProviderCollectionCard | null;
|
||||
@@ -346,6 +387,10 @@ export async function lookupProviderCollection(userId: string, url: string): Pro
|
||||
source.externalId === MAKERONLINE_LIKES_KEY
|
||||
? ((await makeronlineCards(userId)).find((own) => own.id === MAKERONLINE_LIKES_KEY) ?? null)
|
||||
: await makeronlineCollectionCard(userId, source.externalId);
|
||||
} else if (source.provider === "crealitycloud") {
|
||||
card = source.externalId.startsWith("likes-")
|
||||
? ((await crealitycloudCards(userId)).find((own) => own.id === source.externalId) ?? null)
|
||||
: await crealitycloudCollectionCard(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;
|
||||
@@ -379,11 +424,14 @@ export async function enableSyncFromApp(
|
||||
if (!source)
|
||||
throw new HttpError(
|
||||
400,
|
||||
"That isn't a MakerWorld, Printables, Thingiverse, MyMiniFactory, Snapmaker Space or MakerOnline collection link",
|
||||
"That isn't a MakerWorld, Printables, Thingiverse, MyMiniFactory, Snapmaker Space, MakerOnline or Creality Cloud collection link",
|
||||
);
|
||||
await assertProviderEnabled(source.provider);
|
||||
if (input.profileScope && !hasProfiles(source.provider)) {
|
||||
throw new HttpError(400, "Only MakerWorld, Snapmaker Space and MakerOnline models have print profiles to pick");
|
||||
throw new HttpError(
|
||||
400,
|
||||
"Only MakerWorld, Snapmaker Space, MakerOnline and Creality Cloud models have print profiles to pick",
|
||||
);
|
||||
}
|
||||
|
||||
let knownIds: string[] = [];
|
||||
|
||||
@@ -13,6 +13,7 @@ export const PROVIDERS = [
|
||||
"myminifactory",
|
||||
"snapmaker",
|
||||
"makeronline",
|
||||
"crealitycloud",
|
||||
] as const;
|
||||
export type Provider = (typeof PROVIDERS)[number];
|
||||
|
||||
@@ -23,6 +24,7 @@ const PROVIDER_NAMES: Record<Provider, string> = {
|
||||
myminifactory: "MyMiniFactory",
|
||||
snapmaker: "Snapmaker Space",
|
||||
makeronline: "MakerOnline",
|
||||
crealitycloud: "Creality Cloud",
|
||||
};
|
||||
|
||||
const SETTING_KEY = "disabled_providers";
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
checkCrealitycloudAuth,
|
||||
crealitycloudMetaFromDetail,
|
||||
CrealitycloudAuthError,
|
||||
fetchCrealitycloudCollection,
|
||||
fetchCrealitycloudLikes,
|
||||
fetchCrealitycloudModelFiles,
|
||||
isCrealitycloudFileHost,
|
||||
parseCrealitycloudCollectionUrl,
|
||||
parseCrealitycloudLikesUrl,
|
||||
parseCrealitycloudModelUrl,
|
||||
resolveCrealitycloudModel,
|
||||
selectCrealitycloudProfiles,
|
||||
} from "../src/services/crealitycloudApi";
|
||||
import { modelIdFromUrl, modelUrlFor, parseSyncSource } from "../src/services/collectionSyncService";
|
||||
import { normalizeCrealitycloudSession } from "../src/services/crealitycloudSessionService";
|
||||
import { buildImportSourceUrl, identifySourceModel } from "../src/services/importService";
|
||||
|
||||
// The Creality Cloud client against the shapes its own site gets back, with fetch mocked. No database.
|
||||
|
||||
const TOKEN = "b6609720113002cac49d2cc97b463cb0bc975695772ba5917b00e0ed4444cec5";
|
||||
const AUTH = { token: TOKEN, userId: "2859484974" };
|
||||
|
||||
function apiResponse(code: number, result: unknown = {}, msg = "ok"): Response {
|
||||
return new Response(JSON.stringify({ code, msg, reqId: "r", result }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
/** Answers each call by its path; records what was sent. */
|
||||
function stubApi(answers: Record<string, (body: Record<string, unknown>) => Response>) {
|
||||
const calls: { path: string; body: Record<string, unknown>; headers: Record<string, string> }[] = [];
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: string, init: RequestInit) => {
|
||||
const path = new URL(url).pathname.replace(/^\/api\/cxy/, "");
|
||||
const body = JSON.parse(String(init.body)) as Record<string, unknown>;
|
||||
calls.push({ path, body, headers: init.headers as Record<string, string> });
|
||||
const answer = answers[path];
|
||||
if (!answer) throw new Error(`unexpected call ${path}`);
|
||||
return answer(body);
|
||||
}),
|
||||
);
|
||||
return calls;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("Creality Cloud URLs", () => {
|
||||
it("recognizes model pages by alias or id, with a language prefix and a picked print profile", () => {
|
||||
expect(
|
||||
parseCrealitycloudModelUrl("https://www.crealitycloud.com/model-detail/snap-clip-fidget-small-size"),
|
||||
).toEqual({ modelId: "snap-clip-fidget-small-size", requestedProfileId: null });
|
||||
expect(
|
||||
parseCrealitycloudModelUrl(
|
||||
"https://crealitycloud.com/de/model-detail/Ghost-Ship-Sliced#profileId-6AC6F004729F5BA4B35740A6",
|
||||
),
|
||||
).toEqual({ modelId: "ghost-ship-sliced", requestedProfileId: "6ac6f004729f5ba4b35740a6" });
|
||||
expect(parseCrealitycloudModelUrl("https://www.crealitycloud.com/model-detail/6ac6f004729f5ba4b3574093")).toEqual({
|
||||
modelId: "6ac6f004729f5ba4b3574093",
|
||||
requestedProfileId: null,
|
||||
});
|
||||
expect(parseCrealitycloudModelUrl("https://www.crealitycloud.com/models")).toBeNull();
|
||||
expect(parseCrealitycloudModelUrl("https://www.crealitycloud.co/model-detail/a")).toBeNull();
|
||||
});
|
||||
|
||||
it("recognizes collections and the user's likes, and syncs both", () => {
|
||||
const collection =
|
||||
"https://www.crealitycloud.com/collection/3d-model-collection-gQVJSX?isUserUpload=true&from=favorites-folder";
|
||||
expect(parseCrealitycloudCollectionUrl(collection)).toEqual({ collectionId: "3d-model-collection-gQVJSX" });
|
||||
expect(parseSyncSource(collection)).toEqual({
|
||||
provider: "crealitycloud",
|
||||
externalId: "3d-model-collection-gQVJSX",
|
||||
url: "https://www.crealitycloud.com/collection/3d-model-collection-gQVJSX",
|
||||
});
|
||||
// The store's own collections aren't models.
|
||||
expect(parseCrealitycloudCollectionUrl("https://www.crealitycloud.com/collections/materials")).toBeNull();
|
||||
const likes = "https://www.crealitycloud.com/user/2859484974/likes";
|
||||
expect(parseCrealitycloudLikesUrl(likes)).toEqual({ userId: "2859484974" });
|
||||
expect(parseSyncSource(likes)).toEqual({ provider: "crealitycloud", externalId: "likes-2859484974", url: likes });
|
||||
});
|
||||
|
||||
it("identifies imported models and links back to them", () => {
|
||||
const source = identifySourceModel("https://www.crealitycloud.com/model-detail/ghost-ship-sliced");
|
||||
expect(source).toEqual({ provider: "crealitycloud", externalId: "ghost-ship-sliced" });
|
||||
expect(buildImportSourceUrl("crealitycloud", "ghost-ship-sliced")).toBe(
|
||||
"https://www.crealitycloud.com/model-detail/ghost-ship-sliced",
|
||||
);
|
||||
expect(modelIdFromUrl("crealitycloud", modelUrlFor("crealitycloud", "ghost-ship-sliced"))).toBe(
|
||||
"ghost-ship-sliced",
|
||||
);
|
||||
});
|
||||
|
||||
it("only takes signed files from Creality Cloud's OSS buckets", () => {
|
||||
expect(isCrealitycloudFileHost("internal-creality-usa.oss-us-east-1.aliyuncs.com")).toBe(true);
|
||||
expect(isCrealitycloudFileHost("aliyuncs.com.evil.net")).toBe(false);
|
||||
expect(isCrealitycloudFileHost("evil.example.com")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Creality Cloud sign-in", () => {
|
||||
it("takes the token and user id from a Cookie header, or from copied request headers", () => {
|
||||
expect(
|
||||
normalizeCrealitycloudSession(`Cookie: model_lang=0; model_token=${TOKEN}; model_user_id=2859484974; x=1`),
|
||||
).toEqual({ ...AUTH, expiresAt: null });
|
||||
expect(normalizeCrealitycloudSession(`__cxy_token_\n${TOKEN}\n__cxy_uid_\n2859484974`)).toEqual({
|
||||
...AUTH,
|
||||
expiresAt: null,
|
||||
});
|
||||
expect(normalizeCrealitycloudSession(`__cxy_token_: ${TOKEN}\n__cxy_uid_: 2859484974`)).toMatchObject(AUTH);
|
||||
});
|
||||
|
||||
it("refuses a token without its user id, or anything else", () => {
|
||||
expect(normalizeCrealitycloudSession(TOKEN)).toBeNull();
|
||||
expect(normalizeCrealitycloudSession(`model_token=${TOKEN}`)).toBeNull();
|
||||
expect(normalizeCrealitycloudSession("model_token=not-a-token; model_user_id=1")).toBeNull();
|
||||
expect(normalizeCrealitycloudSession("")).toBeNull();
|
||||
});
|
||||
|
||||
it("sends the site's whole header set, the sign-in included, and tells a rejected sign-in apart", async () => {
|
||||
const calls = stubApi({
|
||||
"/v3/user/getInfo": () =>
|
||||
apiResponse(0, { profileUserInfo: { base: { userId: 2859484974, nickName: "Tautvydas" } } }),
|
||||
});
|
||||
expect(await checkCrealitycloudAuth(AUTH)).toEqual({ userId: "2859484974", name: "Tautvydas" });
|
||||
expect(calls[0].headers).toMatchObject({
|
||||
__cxy_token_: TOKEN,
|
||||
__cxy_uid_: "2859484974",
|
||||
__cxy_app_id_: "cxy-gen2",
|
||||
__cxy_os_ver_: expect.any(String),
|
||||
__cxy_app_ch_: expect.any(String),
|
||||
});
|
||||
|
||||
stubApi({ "/v3/user/getInfo": () => apiResponse(4, null, "Invalid Token") });
|
||||
expect(await checkCrealitycloudAuth(AUTH)).toBe("rejected");
|
||||
});
|
||||
});
|
||||
|
||||
const GROUP_ITEM = {
|
||||
id: "6ac6f004729f5ba4b3574093",
|
||||
urlAlias: "snap-clip-fidget",
|
||||
groupName: "Snap-Clip Fidget",
|
||||
groupDesc: "<p>A <strong>fidget</strong> toy.</p>",
|
||||
categoryId: "6007",
|
||||
categories: [
|
||||
{ id: "6007", name: "Other" },
|
||||
{ id: "1809", name: "Toys & Games" },
|
||||
],
|
||||
tags: [{ name: "clip" }, { name: "fidget" }, { name: "clip" }],
|
||||
covers: [
|
||||
{ url: "https://pic2-cdn.creality.com/crealityCloud/common/cover.webp" },
|
||||
{ url: "https://pic2-cdn.creality.com/pic/second.png" },
|
||||
],
|
||||
userInfo: { userId: 4247093750, nickName: "Pablo Zebari", avatar: "https://pic2-cdn.creality.com/avatar/a" },
|
||||
};
|
||||
|
||||
const PROFILES = [
|
||||
{
|
||||
id: "6ac6f004729f5ba4b35740a6",
|
||||
secondName: "0.2mm layer, 2 walls",
|
||||
printerName: "Ender-3 V3 Plus",
|
||||
isAuth: true,
|
||||
userInfo: { userId: 4247093750 },
|
||||
},
|
||||
{
|
||||
id: "6ad5fedd23030f2e2c08899b",
|
||||
secondName: "0.16mm layer",
|
||||
printerName: "SPARKX i7",
|
||||
isAuth: false,
|
||||
userInfo: { userId: 4055423748 },
|
||||
},
|
||||
];
|
||||
|
||||
describe("Creality Cloud model details", () => {
|
||||
it("maps the details, the category most specific first and the designer", () => {
|
||||
const resolved = crealitycloudMetaFromDetail(GROUP_ITEM, PROFILES)!;
|
||||
expect(resolved.groupId).toBe("6ac6f004729f5ba4b3574093");
|
||||
expect(resolved.alias).toBe("snap-clip-fidget");
|
||||
expect(resolved.meta.title).toBe("Snap-Clip Fidget");
|
||||
expect(resolved.meta.description).toBe("A **fidget** toy.");
|
||||
expect(resolved.meta.tags).toEqual(["clip", "fidget"]);
|
||||
expect(resolved.meta.previewImageUrl).toBe("https://pic2-cdn.creality.com/crealityCloud/common/cover.webp");
|
||||
expect(resolved.galleryImages.map((image) => image.filename)).toEqual(["cover.webp", "second.png"]);
|
||||
expect(resolved.meta.siteCategoryIds).toEqual([6007, 1809]);
|
||||
expect(resolved.meta.categorySite).toBe("crealitycloud");
|
||||
expect(resolved.meta.author).toMatchObject({
|
||||
provider: "crealitycloud",
|
||||
externalId: "4247093750",
|
||||
name: "Pablo Zebari",
|
||||
links: ["https://www.crealitycloud.com/user/4247093750"],
|
||||
});
|
||||
expect(resolved.profiles.map((p) => [p.title, p.designers])).toEqual([
|
||||
["0.2mm layer, 2 walls (Ender-3 V3 Plus)", true],
|
||||
["0.16mm layer (SPARKX i7)", false],
|
||||
]);
|
||||
});
|
||||
|
||||
it("picks profiles like MakerWorld", () => {
|
||||
const resolved = crealitycloudMetaFromDetail(GROUP_ITEM, PROFILES)!;
|
||||
expect(selectCrealitycloudProfiles(resolved, "url", null)).toEqual(["6ac6f004729f5ba4b35740a6"]);
|
||||
expect(selectCrealitycloudProfiles(resolved, "url", "6ad5fedd23030f2e2c08899b")).toEqual([
|
||||
"6ad5fedd23030f2e2c08899b",
|
||||
]);
|
||||
expect(selectCrealitycloudProfiles(resolved, "designer", null)).toEqual(["6ac6f004729f5ba4b35740a6"]);
|
||||
expect(selectCrealitycloudProfiles(resolved, "all", null)).toHaveLength(2);
|
||||
expect(selectCrealitycloudProfiles(crealitycloudMetaFromDetail(GROUP_ITEM)!, "url", null)).toEqual([]);
|
||||
});
|
||||
|
||||
it("reads a model and its profiles, and a deleted or private one as missing", async () => {
|
||||
const calls = stubApi({
|
||||
"/v3/model/modelGroupDetail": () => apiResponse(0, { groupItem: GROUP_ITEM, modelList: [] }),
|
||||
"/v3/model/3mfList": () => apiResponse(0, { list: PROFILES, count: 2 }),
|
||||
});
|
||||
const resolved = await resolveCrealitycloudModel("snap-clip-fidget");
|
||||
expect(resolved?.profiles).toHaveLength(2);
|
||||
expect(calls[1].body).toMatchObject({ modelGroupId: "6ac6f004729f5ba4b3574093", filterType: 11 });
|
||||
|
||||
for (const code of [512, 513, 517]) {
|
||||
stubApi({ "/v3/model/modelGroupDetail": () => apiResponse(code, null, "The model has been deleted") });
|
||||
expect(await resolveCrealitycloudModel("gone")).toBeNull();
|
||||
}
|
||||
stubApi({ "/v3/model/modelGroupDetail": () => apiResponse(2, null, "System Error") });
|
||||
await expect(resolveCrealitycloudModel("broken")).rejects.toThrow("System Error");
|
||||
});
|
||||
|
||||
it("names the model's files with the extension their signed link has", async () => {
|
||||
stubApi({
|
||||
"/v3/model/memberDownload": () =>
|
||||
apiResponse(0, {
|
||||
downloadUrls: [
|
||||
{
|
||||
fileName: "Snap-Clip Fidget - 6 Arms",
|
||||
url: "https://internal-creality-usa.oss-us-east-1.aliyuncs.com/model%2Fb528.stl?Expires=1&Signature=x",
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
expect(await fetchCrealitycloudModelFiles("6ac6f004729f5ba4b3574093", AUTH)).toEqual([
|
||||
{
|
||||
name: "Snap-Clip Fidget - 6 Arms.stl",
|
||||
url: "https://internal-creality-usa.oss-us-east-1.aliyuncs.com/model%2Fb528.stl?Expires=1&Signature=x",
|
||||
},
|
||||
]);
|
||||
|
||||
stubApi({ "/v3/model/memberDownload": () => apiResponse(4, null, "Invalid Token") });
|
||||
await expect(fetchCrealitycloudModelFiles("6ac6f004729f5ba4b3574093", AUTH)).rejects.toBeInstanceOf(
|
||||
CrealitycloudAuthError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Creality Cloud lists", () => {
|
||||
it("pages through the user's likes, keeping models by alias", async () => {
|
||||
const item = (n: number) => ({
|
||||
id: `6ace88accc5a1c53dfe1e${String(n).padStart(3, "0")}`,
|
||||
urlAlias: `model-${n}`,
|
||||
groupName: `Model ${n}`,
|
||||
covers: [{ url: `https://pic2-cdn.creality.com/${n}.webp` }],
|
||||
});
|
||||
const calls = stubApi({
|
||||
"/v3/model/likeListPage": (body) =>
|
||||
apiResponse(0, {
|
||||
count: 30,
|
||||
list: Array.from({ length: body.page === 1 ? 24 : 6 }, (_, i) => item((Number(body.page) - 1) * 24 + i)),
|
||||
}),
|
||||
});
|
||||
const likes = await fetchCrealitycloudLikes(AUTH);
|
||||
expect(likes.total).toBe(30);
|
||||
expect(likes.entries).toHaveLength(30);
|
||||
expect(likes.entries[0]).toEqual({
|
||||
modelId: "model-0",
|
||||
title: "Model 0",
|
||||
cover: "https://pic2-cdn.creality.com/0.webp",
|
||||
});
|
||||
expect(calls.map((call) => call.body.page)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("reads a collection by its alias, through its folder id", async () => {
|
||||
const calls = stubApi({
|
||||
"/v2/thematic/detail": () =>
|
||||
apiResponse(0, {
|
||||
id: "6ad5fb7d6418247fd75f3f3c",
|
||||
customizationUrl: "3d-model-collection-gQVJSX",
|
||||
thematicName: "Test",
|
||||
creator: 2859484974,
|
||||
isPublic: true,
|
||||
modelQuantity: 1,
|
||||
}),
|
||||
"/v3/collection/listCollFolderModel": () =>
|
||||
apiResponse(0, { count: 1, list: [{ id: "6a60842144eb4483e2202bed", urlAlias: "original-benchy" }] }),
|
||||
});
|
||||
const collection = await fetchCrealitycloudCollection("3d-model-collection-gQVJSX");
|
||||
expect(collection?.info).toMatchObject({ name: "Test", folderId: "6ad5fb7d6418247fd75f3f3c", private: false });
|
||||
expect(collection?.entries.map((entry) => entry.modelId)).toEqual(["original-benchy"]);
|
||||
expect(calls[1].body).toMatchObject({ folderId: "6ad5fb7d6418247fd75f3f3c" });
|
||||
|
||||
stubApi({ "/v2/thematic/detail": () => apiResponse(517, null, "The item does not exist") });
|
||||
expect(await fetchCrealitycloudCollection("gone")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -37,6 +37,7 @@ afterAll(async () => {
|
||||
"myminifactory",
|
||||
"snapmaker",
|
||||
"makeronline",
|
||||
"crealitycloud",
|
||||
] as const)
|
||||
await setProviderEnabled(provider, true);
|
||||
});
|
||||
@@ -52,6 +53,7 @@ describe("turning providers off", () => {
|
||||
myminifactory: true,
|
||||
snapmaker: true,
|
||||
makeronline: true,
|
||||
crealitycloud: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
@@ -70,6 +72,7 @@ describe("turning providers off", () => {
|
||||
myminifactory: true,
|
||||
snapmaker: true,
|
||||
makeronline: true,
|
||||
crealitycloud: true,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+27
-1
@@ -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.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -105,6 +105,32 @@ or sync them. Opening makeronline.com signed in, with Grab allowed there, connec
|
||||
|
||||
It lasts about three months; Grab sends the new one whenever you sign in again.
|
||||
|
||||
## Creality Cloud
|
||||
|
||||
Per-user (Configuration > Providers > Creality Cloud). Creality Cloud's model details, print profiles and collections,
|
||||
private ones included, need nothing; its files and your likes are given only to a signed-in user, so every Creality
|
||||
Cloud import, and reading your likes, 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 (or picking the designer's or all profiles)
|
||||
adds more; a model without print profiles brings its model files instead.
|
||||
|
||||
Its sign-in is two cookies, `model_token` and `model_user_id`, which its API takes together. The site sets them once
|
||||
when you sign in, for a week, and never renews them; Thingport doesn't either.
|
||||
|
||||
### Easiest: Thingport Grab
|
||||
|
||||
With [Thingport Grab](../extension/README.md) allowed on Creality Cloud, opening crealitycloud.com while you're signed
|
||||
in connects your account, and sends the new sign-in each time you sign in there again. Once a week has passed,
|
||||
Thingport says the sign-in ran out, and your next sign-in on crealitycloud.com connects it again.
|
||||
|
||||
### Manual: copy it from your browser
|
||||
|
||||
1. Sign in at [crealitycloud.com](https://www.crealitycloud.com).
|
||||
2. Open DevTools (`F12`, or `Cmd+Option+I` on macOS) and reload the page.
|
||||
3. On the **Network** tab, pick any request to `crealitycloud.com/api` and copy its `Cookie` request header. (Its
|
||||
`__cxy_token_` and `__cxy_uid_` request headers, copied together, work too.)
|
||||
4. Right-click **Creality Cloud** in Thingport's **Configuration > Providers**, choose **Connect** and paste it.
|
||||
Thingport checks it with Creality Cloud 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.
|
||||
|
||||
@@ -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 or MakerOnline 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 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. |
|
||||
|
||||
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 and MakerOnline
|
||||
# Import from MakerWorld, Printables, Thingiverse, MyMiniFactory, Snapmaker Space, MakerOnline and Creality Cloud
|
||||
|
||||
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,13 +6,14 @@ 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 or MakerOnline, or any direct link to a model file. Paste several links, one per line, and they're imported one after
|
||||
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
|
||||
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 six sites (MyMiniFactory, Snapmaker Space and MakerOnline once you allow them, with a single click in its popup), so you can
|
||||
all seven sites (MyMiniFactory, Snapmaker Space, MakerOnline and Creality Cloud 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. MakerWorld collections are imported with Thingport Grab.
|
||||
bring in. Your Snapmaker Space likes and bookmarks import from your profile's Interactions tab with Thingport Grab,
|
||||
and your Creality Cloud likes from their page, in Thingport or with Grab. MakerWorld collections are imported with Thingport Grab.
|
||||
- **MakerOnline goes through Thingport Grab.** MakerOnline hands out its files only to its own pages, so a MakerOnline
|
||||
link pasted in Thingport, a MakerOnline collection or your likes imported here, and a synced MakerOnline collection
|
||||
all wait for Grab: the next time you open makeronline.com in a browser with Grab, it imports them there.
|
||||
@@ -21,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 and MakerOnline, its print profiles: the one the link points to, all of the
|
||||
- Every file, and on MakerWorld, Snapmaker Space, MakerOnline and Creality Cloud, 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).
|
||||
@@ -41,7 +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, and MakerOnline Thingport Grab for its files. See
|
||||
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
|
||||
[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 or MakerOnline, your
|
||||
MakerOnline 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 or Creality
|
||||
Cloud, 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.
|
||||
|
||||
@@ -32,7 +32,9 @@ page again.
|
||||
MyMiniFactory collection's paid models are left out of its sync. Snapmaker Space needs your own sign-in, which
|
||||
Thingport Grab keeps fresh; if Grab's browser stays closed for a day it runs out, and Snapmaker syncs wait until
|
||||
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).
|
||||
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.
|
||||
|
||||
## Turn sync on in Thingport
|
||||
|
||||
@@ -40,13 +42,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, your likes and your collections. Or paste any
|
||||
Space, your likes and bookmarks; on MakerOnline and Creality Cloud, your likes and 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 and MakerOnline which print profiles each new model brings, before you do.
|
||||
Snapmaker Space, MakerOnline and Creality Cloud 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 and Snapmaker Space they're imported now, one at a time. MakerWorld rate-limits bulk imports quickly, so there only models
|
||||
MyMiniFactory, Snapmaker Space and Creality Cloud 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.
|
||||
|
||||
@@ -68,8 +70,8 @@ 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, a collection page or your
|
||||
own **Likes** page.
|
||||
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.
|
||||
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).
|
||||
|
||||
+22
-14
@@ -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 and MakerOnline into **your own self-hosted Thingport instance**, and converts MakerWorld files for
|
||||
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
|
||||
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.
|
||||
|
||||
@@ -32,6 +32,8 @@ the list of recent imports, which is synced between your browsers as described b
|
||||
again when it changes.
|
||||
- **Your MakerOnline sign-in (its access token),** as last sent to your Thingport instance, so it's only sent again
|
||||
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.
|
||||
- **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.
|
||||
@@ -46,8 +48,8 @@ Snapmaker sign-in at id.snapmaker.com every few hours while the browser is open,
|
||||
**To your Thingport instance:**
|
||||
|
||||
- Your email and password, to sign in.
|
||||
- The address of each model page you open on MakerWorld, Printables, Thingiverse, MyMiniFactory, Snapmaker Space or
|
||||
MakerOnline, to check
|
||||
- The address of each model page you open on MakerWorld, Printables, Thingiverse, MyMiniFactory, Snapmaker Space,
|
||||
MakerOnline or Creality Cloud, 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.
|
||||
@@ -69,6 +71,10 @@ Snapmaker sign-in at id.snapmaker.com every few hours while the browser is open,
|
||||
- If you're signed in to MakerOnline in this browser, and have allowed the extension there: your MakerOnline access
|
||||
token, when you open a MakerOnline page and it changed since last sent. Your Thingport instance uses it to read your
|
||||
likes and private collections there.
|
||||
- If you're signed in to Creality Cloud in this browser, and have allowed the extension there: your Creality Cloud
|
||||
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.
|
||||
- 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.
|
||||
@@ -80,7 +86,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 and MakerOnline:**
|
||||
**To MakerWorld, Printables, Thingiverse, MyMiniFactory, Snapmaker Space, MakerOnline and Creality Cloud:**
|
||||
|
||||
- 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.
|
||||
@@ -105,6 +111,8 @@ Snapmaker sign-in at id.snapmaker.com every few hours while the browser is open,
|
||||
the extension asks MakerOnline's download service for each file's link from the page itself, the same way the page's
|
||||
own Download button does, with your existing MakerOnline sign-in and the page's own security check. It also asks
|
||||
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.
|
||||
|
||||
**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
|
||||
@@ -120,16 +128,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 and MakerOnline'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 and MakerOnline'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, 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 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. |
|
||||
|
||||
## Your control over your data
|
||||
|
||||
|
||||
+12
-5
@@ -1,7 +1,7 @@
|
||||
# Thingport Grab
|
||||
|
||||
A browser extension for Chrome, Edge and Firefox that imports MakerWorld, Thingiverse, Printables
|
||||
MyMiniFactory, Snapmaker Space and MakerOnline models into your self-hosted Thingport instance without leaving the provider's site.
|
||||
MyMiniFactory, Snapmaker Space, MakerOnline and Creality Cloud 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.
|
||||
@@ -200,12 +200,19 @@ synced collection's new models -- waits on your instance, and Grab imports it on
|
||||
Opening MakerOnline signed in also connects your account, for your likes and private collections. Firefox needs
|
||||
version 128 or newer for this.
|
||||
|
||||
### Creality Cloud: a week-long sign-in
|
||||
|
||||
Creality Cloud gives its files and your likes only to a signed-in user, and its sign-in lasts a week from when you
|
||||
signed in there; the site never renews it. Opening crealitycloud.com signed in connects your account to Thingport, and
|
||||
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.
|
||||
|
||||
## What counts as "importable"
|
||||
|
||||
- A single model page (MakerWorld, a Thingiverse Thing, a Printables Model, a MyMiniFactory object, a Snapmaker Space
|
||||
or MakerOnline model) -- hidden automatically once you've already imported that exact page.
|
||||
- A MakerWorld collection, a Thingiverse Collection or Likes page, a Printables, MyMiniFactory or MakerOnline
|
||||
collection, your MakerOnline likes, or your own Snapmaker Space likes and bookmarks -- lets you import the listed designs in one go.
|
||||
- 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.
|
||||
|
||||
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 and MakerOnline 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 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.
|
||||
|
||||
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. The MakerWorld and MyMiniFactory cookies and the Snapmaker and MakerOnline access tokens 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. 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.
|
||||
- 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 and MakerOnline, 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 and MakerOnline), 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 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.
|
||||
|
||||
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
|
||||
@@ -0,0 +1,40 @@
|
||||
// The user's Creality Cloud sign-in, which downloads its files and reads the user's likes: the model_token and
|
||||
// model_user_id cookies, which its API takes together. The site sets them once at sign-in for a week and never renews
|
||||
// them, so Grab only sends each new pair 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.crealitycloud.com";
|
||||
|
||||
/** Sends the instance the browser's Creality Cloud sign-in when it changed. Best-effort: never throws. Says who's
|
||||
* signed in there. */
|
||||
export async function syncCrealitycloudSession(): Promise<{ userId: string | null }> {
|
||||
const config = await requireConfig().catch(() => null);
|
||||
if (!config) return { userId: null };
|
||||
let token: chrome.cookies.Cookie | null;
|
||||
let user: chrome.cookies.Cookie | null;
|
||||
try {
|
||||
[token, user] = await Promise.all([
|
||||
chrome.cookies.get({ url: SITE, name: "model_token" }),
|
||||
chrome.cookies.get({ url: SITE, name: "model_user_id" }),
|
||||
]);
|
||||
} catch {
|
||||
return { userId: null };
|
||||
}
|
||||
if (!token?.value || !user?.value) return { userId: null };
|
||||
const value = `model_token=${token.value}; model_user_id=${user.value}`;
|
||||
// Per Thingport account, so switching the extension to another one sends it there too.
|
||||
const account = `${config.instanceUrl} ${config.email}`;
|
||||
const last = config.lastSyncedCrealitycloudSession;
|
||||
if (last?.account !== account || last.value !== value) {
|
||||
try {
|
||||
await apiCall("PATCH", "/settings/crealitycloud-session", {
|
||||
token: value,
|
||||
expires_at: token.expirationDate ? new Date(token.expirationDate * 1000).toISOString() : null,
|
||||
});
|
||||
await chrome.storage.local.set({ [STORAGE_KEYS.lastSyncedCrealitycloudSession]: { account, value } });
|
||||
} catch {}
|
||||
}
|
||||
return { userId: decodeURIComponent(user.value) };
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import { getRecentImports } from "./recentImports";
|
||||
import { syncMyminifactorySession } from "./myminifactorySession";
|
||||
import { scheduleSnapmakerRenewal, SNAPMAKER_ALARM, syncSnapmakerSession } from "./snapmakerSession";
|
||||
import { importMakeronlineModel, runWaitingImports, syncMakeronlineSession } from "./makeronline";
|
||||
import { syncCrealitycloudSession } from "./crealitycloudSession";
|
||||
import { onPairingTabClosed, onPairingTabUrl, startPairing } from "./pairing";
|
||||
import { openSetup } from "./setup";
|
||||
import { setTabIconState } from "./tabIcon";
|
||||
@@ -42,6 +43,7 @@ listen<BackgroundMessages>({
|
||||
SYNC_MYMINIFACTORY_SESSION: () => syncMyminifactorySession(),
|
||||
SYNC_SNAPMAKER_SESSION: () => syncSnapmakerSession(),
|
||||
SYNC_MAKERONLINE_SESSION: () => syncMakeronlineSession(),
|
||||
SYNC_CREALITYCLOUD_SESSION: () => syncCrealitycloudSession(),
|
||||
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,82 @@
|
||||
// Creality Cloud's own model data, as its pages read it: which print profiles a model has, and whose they are.
|
||||
|
||||
import { request } from "../shared/messages";
|
||||
import { parseCrealitycloudModelUrl } from "../shared/urls";
|
||||
|
||||
export type CrealitycloudProfileScope = "url" | "designer" | "all";
|
||||
|
||||
type Profile = { id: string; designers: boolean };
|
||||
|
||||
const asId = (value: unknown) => (value == null ? null : String(value));
|
||||
|
||||
// Its API answers "Invalid Parameter" to a call without the headers its own pages send.
|
||||
const HEADERS = {
|
||||
"Content-Type": "application/json",
|
||||
__cxy_app_id_: "cxy-gen2",
|
||||
__cxy_app_ver_: "7.3.28",
|
||||
__cxy_app_ch_: "Chrome",
|
||||
__cxy_brand_: "creality",
|
||||
__cxy_platform_: "2",
|
||||
__cxy_os_lang_: "0",
|
||||
__cxy_os_ver_: "Web",
|
||||
__cxy_timezone_: "0",
|
||||
__cxy_duid_: crypto.randomUUID(),
|
||||
};
|
||||
|
||||
async function call(path: string, body: Record<string, unknown>): Promise<Record<string, unknown> | null> {
|
||||
const res = await fetch(`/api/cxy${path}`, {
|
||||
method: "POST",
|
||||
headers: { ...HEADERS, __cxy_requestid_: crypto.randomUUID() },
|
||||
body: JSON.stringify(body),
|
||||
credentials: "omit",
|
||||
});
|
||||
const json = (await res.json()) as { code?: number; result?: Record<string, unknown> };
|
||||
return json.code === 0 && json.result ? json.result : null;
|
||||
}
|
||||
|
||||
/** 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 detail = await call("/v3/model/modelGroupDetail", { id: modelId });
|
||||
const group = detail?.groupItem as Record<string, unknown> | undefined;
|
||||
const groupId = asId(group?.id);
|
||||
if (!groupId) return null;
|
||||
const creatorId = asId((group?.userInfo as Record<string, unknown> | undefined)?.userId);
|
||||
const list = await call("/v3/model/3mfList", { page: 1, pageSize: 199, modelGroupId: groupId, filterType: 11 });
|
||||
if (!list) return null;
|
||||
return (Array.isArray(list.list) ? (list.list as Record<string, unknown>[]) : []).flatMap((p) => {
|
||||
const id = asId(p?.id);
|
||||
if (!id) return [];
|
||||
const owner = asId((p.userInfo as Record<string, unknown> | undefined)?.userId);
|
||||
return [{ id, designers: p.isAuth === true || (!!owner && owner === creatorId) }];
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Mirrors the backend's selectCrealitycloudProfiles, the linked (or designer's first) profile first. */
|
||||
function profileIds(profiles: Profile[], scope: CrealitycloudProfileScope, 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 loadCrealitycloudProfiles(
|
||||
url: string,
|
||||
): Promise<((scope: CrealitycloudProfileScope) => string[]) | null> {
|
||||
const parsed = parseCrealitycloudModelUrl(url);
|
||||
const profiles = parsed ? await loadProfiles(parsed.modelId) : null;
|
||||
if (!parsed || !profiles) return null;
|
||||
return (scope) => profileIds(profiles, scope, parsed.requestedProfileId);
|
||||
}
|
||||
|
||||
/** Who's signed in on Creality Cloud, so likes are only offered on their own page. */
|
||||
export async function crealitycloudUserId(): Promise<string | null> {
|
||||
const res = await request("SYNC_CREALITYCLOUD_SESSION").catch(() => null);
|
||||
return res?.userId ?? null;
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
// Content script entry point (MakerWorld, Thingiverse, Printables, and MyMiniFactory and Snapmaker Space once turned
|
||||
// on). Network calls go through the background (see runtime.ts).
|
||||
// Content script entry point (MakerWorld, Thingiverse, Printables, and the optional sites in optionalSites.ts once
|
||||
// turned on). Network calls go through the background (see runtime.ts).
|
||||
|
||||
import type { ImportStatus } from "../shared/api";
|
||||
import { listen, send, type ContentMessages } from "../shared/messages";
|
||||
import { CONFIG_CHANGE_KEYS } from "../shared/storage";
|
||||
import { classifyUrl, parseSnapmakerListUrl, snapmakerListUrl } from "../shared/urls";
|
||||
import { classifyUrl, parseCrealitycloudLikesUrl, parseSnapmakerListUrl, snapmakerListUrl } from "../shared/urls";
|
||||
import { setContext, type LibraryState } from "./context";
|
||||
import { isMakerworldBlockingError, resolveMakerworldDownloadUrl } from "./makerworld/downloadResolver";
|
||||
import { offerNormalizedDownload, unmountNormalizedDownload } from "./makerworld/normalizedDownload";
|
||||
@@ -12,6 +12,7 @@ import { mountJobOverlay, mountPausedJobOverlay } from "./overlays";
|
||||
import { loadPanel } from "./panels";
|
||||
import { captureProviderAccount } from "./providerAccount";
|
||||
import { snapmakerUserId } from "./snapmaker";
|
||||
import { crealitycloudUserId } from "./crealitycloud";
|
||||
import { providerEnabled } from "./providerSettings";
|
||||
import { errorHtml } from "./panels/results";
|
||||
import { api } from "./runtime";
|
||||
@@ -132,6 +133,15 @@ async function init(): Promise<void> {
|
||||
}
|
||||
pageUrl = snapmakerListUrl(own, classification.type);
|
||||
}
|
||||
// Creality Cloud's likes too: anyone can open the address, but only the owner sees them.
|
||||
if (classification.provider === "crealitycloud" && classification.type === "likes") {
|
||||
const own = await crealitycloudUserId();
|
||||
if (isStale()) return;
|
||||
if (!own || own !== parseCrealitycloudLikesUrl(url)?.userId) {
|
||||
reportTabIconState(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let library: LibraryState | null = null;
|
||||
if (classification.kind === "single") {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Batch flow for Thingiverse Likes/Collections, Printables and MyMiniFactory Collections, Snapmaker Space likes and
|
||||
// bookmarks, and MakerOnline collections and likes. MakerWorld collections use the guided flow in
|
||||
// bookmarks, MakerOnline collections and likes, and Creality Cloud collections and likes. MakerWorld collections use the guided flow in
|
||||
// makerworldCollection.ts.
|
||||
|
||||
import type { BatchEntriesResult, ImportJob, SyncLookup } from "../../shared/api";
|
||||
@@ -22,7 +22,9 @@ type BatchKey =
|
||||
| "snapmaker:likes"
|
||||
| "snapmaker:bookmarks"
|
||||
| "makeronline:collection"
|
||||
| "makeronline:likes";
|
||||
| "makeronline:likes"
|
||||
| "crealitycloud:collection"
|
||||
| "crealitycloud:likes";
|
||||
|
||||
const SNAPMAKER_LIST = {
|
||||
entries: "/import/snapmaker-list/entries",
|
||||
@@ -37,6 +39,12 @@ const MAKERONLINE_LIST = {
|
||||
idField: "model_ids",
|
||||
};
|
||||
|
||||
const CREALITYCLOUD_LIST = {
|
||||
entries: "/import/crealitycloud-list/entries",
|
||||
start: "/import/crealitycloud-list",
|
||||
idField: "model_ids",
|
||||
};
|
||||
|
||||
const BATCH_ENDPOINTS: Record<BatchKey, { entries: string; start: string; idField: string }> = {
|
||||
"thingiverse:likes": {
|
||||
entries: "/import/thingiverse-likes/entries",
|
||||
@@ -62,6 +70,8 @@ const BATCH_ENDPOINTS: Record<BatchKey, { entries: string; start: string; idFiel
|
||||
"snapmaker:bookmarks": SNAPMAKER_LIST,
|
||||
"makeronline:collection": MAKERONLINE_LIST,
|
||||
"makeronline:likes": MAKERONLINE_LIST,
|
||||
"crealitycloud:collection": CREALITYCLOUD_LIST,
|
||||
"crealitycloud:likes": CREALITYCLOUD_LIST,
|
||||
};
|
||||
|
||||
const SNAPMAKER_LISTS: { list: SnapmakerList; label: string }[] = [
|
||||
|
||||
@@ -14,6 +14,7 @@ export const SITE_NAMES: Record<Provider, string> = {
|
||||
myminifactory: "MyMiniFactory",
|
||||
snapmaker: "Snapmaker Space",
|
||||
makeronline: "MakerOnline",
|
||||
crealitycloud: "Creality Cloud",
|
||||
};
|
||||
|
||||
/** 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 and MakerOnline import directly; other links go through
|
||||
// Thingiverse, Printables, MyMiniFactory, Snapmaker Space, MakerOnline and Creality Cloud 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).
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { api, escapeHtml } from "../runtime";
|
||||
import { loadSnapmakerProfiles } from "../snapmaker";
|
||||
import { loadMakeronlineProfiles } from "../makeronline";
|
||||
import { loadCrealitycloudProfiles } from "../crealitycloud";
|
||||
import { onPanelAction, panelQuery, panelQueryAll, renderPanel } from "../shell";
|
||||
import { collectionIdFor, collectionPickerHtml, readCollectionChoice, wireCollectionPicker } from "./collectionPicker";
|
||||
import { errorHtml, statusHtml, successHtml } from "./results";
|
||||
@@ -50,12 +51,15 @@ function isPaidMyminifactoryObject(): boolean {
|
||||
// Several profile resolutions in a quick burst still look automated to MakerWorld.
|
||||
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 and
|
||||
* MakerOnline's from their APIs. Null on other sites, or when they can't be read. */
|
||||
/** 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. */
|
||||
async function profileIdsFor(): Promise<((scope: MakerworldProfileScope) => string[]) | null> {
|
||||
const { url, classification } = ctx();
|
||||
if (classification.provider === "snapmaker" && classification.type === "model") return loadSnapmakerProfiles(url);
|
||||
if (classification.provider === "makeronline" && classification.type === "model") return loadMakeronlineProfiles(url);
|
||||
if (classification.provider === "crealitycloud" && classification.type === "model") {
|
||||
return loadCrealitycloudProfiles(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;
|
||||
@@ -139,7 +143,8 @@ export async function loadSingleItem(): Promise<void> {
|
||||
(provider === "printables" && type === "model") ||
|
||||
(provider === "myminifactory" && type === "object") ||
|
||||
(provider === "snapmaker" && type === "model") ||
|
||||
(provider === "makeronline" && type === "model");
|
||||
(provider === "makeronline" && type === "model") ||
|
||||
(provider === "crealitycloud" && type === "model");
|
||||
|
||||
let zipFilename: string | null = null;
|
||||
if (skipInspect) {
|
||||
@@ -398,7 +403,8 @@ async function runProfilesImport(scope: MakerworldProfileScope, collectionId: st
|
||||
for (const [index, instanceId] of ids.entries()) {
|
||||
renderPanel(statusHtml(`Importing print profile ${index + 1} of ${ids.length}…`));
|
||||
if (index > 0) await new Promise((resolve) => setTimeout(resolve, PROFILE_GAP_MS));
|
||||
// The page's Download button gives exactly the link's profile. Snapmaker's the instance fetches itself.
|
||||
// The page's Download button gives exactly the link's profile. Snapmaker's and Creality Cloud's the instance
|
||||
// fetches itself.
|
||||
try {
|
||||
const resolved = !makerworld
|
||||
? null
|
||||
@@ -406,7 +412,7 @@ async function runProfilesImport(scope: MakerworldProfileScope, collectionId: st
|
||||
? await resolveMakerworldDownloadUrl(url).catch(nullUnlessBlocking)
|
||||
: await resolveMakerworldProfileDownload(url, instanceId).catch(nullUnlessBlocking);
|
||||
const print = await request("IMPORT_SINGLE", {
|
||||
// MakerWorld's page link carries its profile; Snapmaker's is named, so a model already in the library gets it.
|
||||
// MakerWorld's page link carries its profile; the others' is named, so a model already in the library gets it.
|
||||
url: index === 0 && makerworld ? url : profileUrl(url, instanceId),
|
||||
collectionId,
|
||||
resolved,
|
||||
|
||||
@@ -14,6 +14,7 @@ const PROVIDER_NAMES: Record<string, string> = {
|
||||
myminifactory: "MyMiniFactory",
|
||||
snapmaker: "Snapmaker Space",
|
||||
makeronline: "MakerOnline",
|
||||
crealitycloud: "Creality Cloud",
|
||||
};
|
||||
|
||||
/** A Thingiverse user's Likes page, which a sync follows like a collection. */
|
||||
|
||||
@@ -87,6 +87,12 @@ export function captureProviderAccount(hostname: string): void {
|
||||
void syncMakeronline();
|
||||
return;
|
||||
}
|
||||
if (/(^|\.)crealitycloud\.com$/i.test(hostname)) {
|
||||
captured = true;
|
||||
// Its sign-in downloads the files and reads the likes; a new one comes with each sign-in there.
|
||||
void send("SYNC_CREALITYCLOUD_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: "crealitycloud", name: "Creality Cloud" },
|
||||
{ id: "nexprint", name: "NextPrint" },
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -120,6 +120,8 @@ export type BackgroundMessages = {
|
||||
SYNC_SNAPMAKER_SESSION: { payload: void; result: { userId: string | null } };
|
||||
/** On a MakerOnline page: send the sign-in on, if it changed. Says who's signed in there. */
|
||||
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 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 };
|
||||
|
||||
@@ -3,13 +3,18 @@
|
||||
// asked for at runtime, when the user enables it in the popup (within the manifest's optional_host_permissions), and
|
||||
// the background registers the content script there. It only ever runs on the sites listed here.
|
||||
|
||||
export type OptionalSite = { id: "myminifactory" | "snapmaker" | "makeronline"; name: string; origins: string[] };
|
||||
export type OptionalSite = {
|
||||
id: "myminifactory" | "snapmaker" | "makeronline" | "crealitycloud";
|
||||
name: string;
|
||||
origins: string[];
|
||||
};
|
||||
|
||||
export const OPTIONAL_SITES: readonly OptionalSite[] = [
|
||||
{ id: "myminifactory", name: "MyMiniFactory", origins: ["*://*.myminifactory.com/*"] },
|
||||
// id.snapmaker.com renews the sign-in (see snapmakerSession.ts); Grab's panel only ever shows on Snapmaker Space.
|
||||
{ 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/*"] },
|
||||
];
|
||||
|
||||
const scriptId = (site: OptionalSite) => `site-${site.id}`;
|
||||
|
||||
@@ -24,6 +24,8 @@ export const STORAGE_KEYS = {
|
||||
lastSyncedSnapmakerToken: "lastSyncedSnapmakerToken",
|
||||
// The MakerOnline access token last sent, and to which Thingport account.
|
||||
lastSyncedMakeronlineToken: "lastSyncedMakeronlineToken",
|
||||
// The Creality Cloud sign-in last sent, and to which Thingport account.
|
||||
lastSyncedCrealitycloudSession: "lastSyncedCrealitycloudSession",
|
||||
// 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.
|
||||
@@ -49,6 +51,7 @@ export type StoredConfig = {
|
||||
lastSyncedMyminifactorySession?: { account: string; value: string };
|
||||
lastSyncedSnapmakerToken?: { account: string; value: string };
|
||||
lastSyncedMakeronlineToken?: { account: string; value: string };
|
||||
lastSyncedCrealitycloudSession?: { 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,6 +1,7 @@
|
||||
// Provider URL recognition, duplicated from the frontend and backend parsers. Keep in sync by hand.
|
||||
|
||||
export type Provider = "makerworld" | "thingiverse" | "printables" | "myminifactory" | "snapmaker" | "makeronline";
|
||||
export type Provider =
|
||||
"makerworld" | "thingiverse" | "printables" | "myminifactory" | "snapmaker" | "makeronline" | "crealitycloud";
|
||||
|
||||
export type Classification =
|
||||
| { kind: "single"; provider: "makerworld"; type: "model" }
|
||||
@@ -9,12 +10,14 @@ export type Classification =
|
||||
| { kind: "single"; provider: "myminifactory"; type: "object" }
|
||||
| { kind: "single"; provider: "snapmaker"; type: "model" }
|
||||
| { kind: "single"; provider: "makeronline"; type: "model" }
|
||||
| { kind: "single"; provider: "crealitycloud"; 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: "makeronline"; type: "collection" | "likes" }
|
||||
| { kind: "batch"; provider: "crealitycloud"; type: "collection" | "likes" };
|
||||
|
||||
export type SnapmakerList = "likes" | "bookmarks";
|
||||
|
||||
@@ -169,6 +172,43 @@ export function isMakeronlineLikesUrl(url: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function isCrealitycloud(parsed: URL): boolean {
|
||||
return /^(www\.)?crealitycloud\.com$/i.test(parsed.hostname);
|
||||
}
|
||||
|
||||
const CREALITYCLOUD_LANG = String.raw`(?:[a-z]{2}(?:-[a-z]{2,4})?/)?`;
|
||||
|
||||
/** `/model-detail/<alias>`, the alias made of the model's title; Thingport's own `#profileId-<id>` picks a print
|
||||
* profile. */
|
||||
export function parseCrealitycloudModelUrl(url: string): { modelId: string; requestedProfileId: string | null } | null {
|
||||
const parsed = parse(url);
|
||||
if (!parsed || !isCrealitycloud(parsed)) return null;
|
||||
const m = parsed.pathname.match(new RegExp(String.raw`^/${CREALITYCLOUD_LANG}model-detail/([\w-]+)/?$`, "i"));
|
||||
if (!m) return null;
|
||||
return {
|
||||
modelId: m[1].toLowerCase(),
|
||||
requestedProfileId: parsed.hash.match(/profileId-([0-9a-f]{24})/i)?.[1]?.toLowerCase() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** `/collection/<alias>`. */
|
||||
export function isCrealitycloudCollectionUrl(url: string): boolean {
|
||||
const parsed = parse(url);
|
||||
return Boolean(
|
||||
parsed &&
|
||||
isCrealitycloud(parsed) &&
|
||||
new RegExp(String.raw`^/${CREALITYCLOUD_LANG}collection/[\w-]+/?$`, "i").test(parsed.pathname),
|
||||
);
|
||||
}
|
||||
|
||||
/** The user's likes, `/user/<id>/likes`; Creality Cloud shows them to their owner only. */
|
||||
export function parseCrealitycloudLikesUrl(url: string): { userId: string } | null {
|
||||
const parsed = parse(url);
|
||||
if (!parsed || !isCrealitycloud(parsed)) return null;
|
||||
const m = parsed.pathname.match(new RegExp(String.raw`^/${CREALITYCLOUD_LANG}user/(\d+)/likes/?$`, "i"));
|
||||
return m ? { userId: m[1] } : null;
|
||||
}
|
||||
|
||||
/** 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" };
|
||||
@@ -180,11 +220,14 @@ export function classifyUrl(url: string): Classification | null {
|
||||
if (snapmakerList) return { kind: "batch", provider: "snapmaker", type: snapmakerList.list };
|
||||
if (isMakeronlineCollectionUrl(url)) return { kind: "batch", provider: "makeronline", type: "collection" };
|
||||
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 (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" };
|
||||
if (parseMyminifactoryObjectUrl(url)) return { kind: "single", provider: "myminifactory", type: "object" };
|
||||
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" };
|
||||
return null;
|
||||
}
|
||||
@@ -21,6 +21,7 @@ export type Category = {
|
||||
myminifactory_cat_ids: string;
|
||||
snapmaker_cat_ids: string;
|
||||
makeronline_cat_ids: string;
|
||||
crealitycloud_cat_ids: string;
|
||||
};
|
||||
|
||||
export type CategoryMetaInput = {
|
||||
@@ -32,6 +33,7 @@ export type CategoryMetaInput = {
|
||||
myminifactoryCatIds: string;
|
||||
snapmakerCatIds: string;
|
||||
makeronlineCatIds: string;
|
||||
crealitycloudCatIds: string;
|
||||
};
|
||||
|
||||
export const categoriesApi = {
|
||||
@@ -81,6 +83,7 @@ export const categoriesApi = {
|
||||
myminifactory_cat_ids: meta.myminifactoryCatIds,
|
||||
snapmaker_cat_ids: meta.snapmakerCatIds,
|
||||
makeronline_cat_ids: meta.makeronlineCatIds,
|
||||
crealitycloud_cat_ids: meta.crealitycloudCatIds,
|
||||
}),
|
||||
});
|
||||
if (res.status === 401) throw new UnauthorizedError();
|
||||
|
||||
@@ -195,6 +195,20 @@ export const importsApi = {
|
||||
return res.json();
|
||||
},
|
||||
|
||||
/** fromMakerworldProfiles for a Creality Cloud model. */
|
||||
fromCrealitycloudProfiles: async (
|
||||
payload: ImportLinkPayload & { scope: Exclude<MakerworldProfileScope, "url"> },
|
||||
): Promise<{ job_id: string }> => {
|
||||
const res = await fetch(`${apiBase()}/import/crealitycloud-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 (
|
||||
@@ -400,6 +414,30 @@ export const importsApi = {
|
||||
return res.json();
|
||||
},
|
||||
|
||||
/** A Creality Cloud collection, or the user's own likes. */
|
||||
listCrealitycloudListEntries: async (payload: ImportLinkPayload): Promise<ImportCollectionEntriesResult> => {
|
||||
const res = await fetch(`${apiBase()}/import/crealitycloud-list/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. */
|
||||
fromCrealitycloudList: async (payload: ImportLinkPayload & { model_ids: string[] }): Promise<{ job_id: string }> => {
|
||||
const res = await fetch(`${apiBase()}/import/crealitycloud-list`, {
|
||||
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, "Creality Cloud 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 },
|
||||
|
||||
@@ -9,6 +9,7 @@ export const BROWSABLE_PROVIDERS = [
|
||||
"myminifactory",
|
||||
"snapmaker",
|
||||
"makeronline",
|
||||
"crealitycloud",
|
||||
] as const;
|
||||
export type BrowsableProvider = (typeof BROWSABLE_PROVIDERS)[number];
|
||||
|
||||
|
||||
@@ -147,7 +147,7 @@ export type DatabaseCredentialsInput = {
|
||||
};
|
||||
|
||||
export type ImportProvider =
|
||||
"makerworld" | "printables" | "thingiverse" | "myminifactory" | "snapmaker" | "makeronline";
|
||||
"makerworld" | "printables" | "thingiverse" | "myminifactory" | "snapmaker" | "makeronline" | "crealitycloud";
|
||||
/** An admin can turn a provider off for the whole instance; menus hide it then. */
|
||||
export type ProviderSetting = { id: ImportProvider; enabled: boolean };
|
||||
|
||||
@@ -411,6 +411,26 @@ export const settingsApi = {
|
||||
return res.json();
|
||||
},
|
||||
|
||||
// The user's own Creality Cloud sign-in (its model_token and model_user_id cookies), which Thingport Grab sends on.
|
||||
// Write-only.
|
||||
getCrealitycloudSession: async (): Promise<{ configured: boolean; expired: boolean }> => {
|
||||
const res = await fetch(`${apiBase()}/settings/crealitycloud-session`, { headers: authHeaders() });
|
||||
assertOk(res, "Failed to load Creality Cloud settings");
|
||||
return res.json();
|
||||
},
|
||||
|
||||
/** Checked with Creality Cloud before it's saved. */
|
||||
updateCrealitycloudSession: async (token: string | null): Promise<{ configured: boolean }> => {
|
||||
const res = await fetch(`${apiBase()}/settings/crealitycloud-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 Creality Cloud settings"));
|
||||
return res.json();
|
||||
},
|
||||
|
||||
getAuth: async (): Promise<AuthSettings> => {
|
||||
const res = await fetch(`${apiBase()}/settings/auth`, { headers: authHeaders() });
|
||||
assertOk(res, "Failed to load session settings");
|
||||
|
||||
@@ -101,7 +101,7 @@ function AuthorPreviewCard({ authorId, viewer }: CardProps) {
|
||||
sx={{
|
||||
position: "relative",
|
||||
height: COVER_HEIGHT,
|
||||
bgcolor: providerInfo?.color ?? "primary.main",
|
||||
bgcolor: providerInfo && !providerInfo.outlined ? providerInfo.color : "primary.main",
|
||||
backgroundImage: coverUrl
|
||||
? `url("${coverUrl}")`
|
||||
: "linear-gradient(135deg, rgba(255,255,255,0.18), rgba(0,0,0,0.25))",
|
||||
|
||||
@@ -17,6 +17,7 @@ const LOGO_PROVIDERS = new Set<string>([
|
||||
"myminifactory",
|
||||
"snapmaker",
|
||||
"makeronline",
|
||||
"crealitycloud",
|
||||
]);
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -19,6 +19,7 @@ const LOGO_PROVIDERS = new Set<string>([
|
||||
"myminifactory",
|
||||
"snapmaker",
|
||||
"makeronline",
|
||||
"crealitycloud",
|
||||
]);
|
||||
|
||||
/** The provider's logo with a small green tick, slim enough for a toolbar row. Opens the
|
||||
|
||||
@@ -40,6 +40,8 @@ import {
|
||||
isSnapmakerModelUrl,
|
||||
isMakeronlineListUrl,
|
||||
isMakeronlineModelUrl,
|
||||
isCrealitycloudListUrl,
|
||||
isCrealitycloudModelUrl,
|
||||
isThingiverseCollectionUrl,
|
||||
isThingiverseLikesUrl,
|
||||
type ImportProviderKey,
|
||||
@@ -54,6 +56,7 @@ const IMPORT_PROVIDERS: ImportProviderKey[] = [
|
||||
"myminifactory",
|
||||
"snapmaker",
|
||||
"makeronline",
|
||||
"crealitycloud",
|
||||
];
|
||||
|
||||
/** What the search palette's !import command calls. */
|
||||
@@ -120,12 +123,17 @@ const AddMenu = React.forwardRef<AddMenuHandle, Props>(function AddMenu(
|
||||
isPrintablesCollectionUrl(link) ||
|
||||
isMyminifactoryCollectionUrl(link) ||
|
||||
isSnapmakerListUrl(link) ||
|
||||
isMakeronlineListUrl(link),
|
||||
isMakeronlineListUrl(link) ||
|
||||
isCrealitycloudListUrl(link),
|
||||
)
|
||||
: isMakerworldCollectionUrl(linkValue);
|
||||
// Models with print profiles to pick between.
|
||||
const hasProfiles = links.some(
|
||||
(link) => isMakerworldModelUrl(link) || isSnapmakerModelUrl(link) || isMakeronlineModelUrl(link),
|
||||
(link) =>
|
||||
isMakerworldModelUrl(link) ||
|
||||
isSnapmakerModelUrl(link) ||
|
||||
isMakeronlineModelUrl(link) ||
|
||||
isCrealitycloudModelUrl(link),
|
||||
);
|
||||
|
||||
const closeMenu = () => setAnchorEl(null);
|
||||
|
||||
@@ -31,6 +31,7 @@ const PROVIDERS: { id: ProviderId; name: string }[] = [
|
||||
{ id: "myminifactory", name: "MyMiniFactory" },
|
||||
{ id: "snapmaker", name: "Snapmaker Space" },
|
||||
{ id: "makeronline", name: "MakerOnline" },
|
||||
{ id: "crealitycloud", name: "Creality Cloud" },
|
||||
...COMING_SOON_PROVIDERS,
|
||||
];
|
||||
const isComingSoon = (id: ProviderId) => COMING_SOON_PROVIDERS.some((provider) => provider.id === id);
|
||||
@@ -39,10 +40,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.
|
||||
type SessionProvider = "makerworld" | "myminifactory" | "snapmaker" | "makeronline";
|
||||
const isSessionProvider = (id: ProviderId): id is SessionProvider =>
|
||||
id === "makerworld" || id === "myminifactory" || id === "snapmaker" || id === "makeronline";
|
||||
// files only ever come through Thingport Grab. Creality Cloud's lasts a week from signing in there.
|
||||
type SessionProvider = "makerworld" | "myminifactory" | "snapmaker" | "makeronline" | "crealitycloud";
|
||||
const SESSION_PROVIDERS = new Set<ProviderId>([
|
||||
"makerworld",
|
||||
"myminifactory",
|
||||
"snapmaker",
|
||||
"makeronline",
|
||||
"crealitycloud",
|
||||
]);
|
||||
const isSessionProvider = (id: ProviderId): id is SessionProvider => SESSION_PROVIDERS.has(id);
|
||||
|
||||
type Props = {
|
||||
isAdmin: boolean;
|
||||
@@ -67,7 +74,7 @@ export default function ProvidersPanel({ isAdmin, cookie, onUpdateMakerWorld, on
|
||||
thangs: false,
|
||||
cults3d: false,
|
||||
makeronline: null,
|
||||
crealitycloud: false,
|
||||
crealitycloud: null,
|
||||
nexprint: false,
|
||||
snapmaker: null,
|
||||
});
|
||||
@@ -77,6 +84,7 @@ export default function ProvidersPanel({ isAdmin, cookie, onUpdateMakerWorld, on
|
||||
myminifactory: false,
|
||||
snapmaker: false,
|
||||
makeronline: false,
|
||||
crealitycloud: false,
|
||||
});
|
||||
const isExpired = (id: ProviderId) => isSessionProvider(id) && Boolean(connected[id]) && expired[id];
|
||||
const [connecting, setConnecting] = React.useState<ProviderId | null>(null);
|
||||
@@ -121,6 +129,14 @@ export default function ProvidersPanel({ isAdmin, cookie, onUpdateMakerWorld, on
|
||||
setExpired((current) => ({ ...current, makeronline: res.expired }));
|
||||
})
|
||||
.catch(() => undefined);
|
||||
settingsApi
|
||||
.getCrealitycloudSession()
|
||||
.then((res) => {
|
||||
if (!active) return;
|
||||
setConnected((current) => ({ ...current, crealitycloud: res.configured }));
|
||||
setExpired((current) => ({ ...current, crealitycloud: res.expired }));
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
@@ -157,6 +173,10 @@ export default function ProvidersPanel({ isAdmin, cookie, onUpdateMakerWorld, on
|
||||
const result = await settingsApi.updateMakeronlineSession(value);
|
||||
setConnected((current) => ({ ...current, makeronline: result.configured }));
|
||||
setExpired((current) => ({ ...current, makeronline: false }));
|
||||
} else if (id === "crealitycloud") {
|
||||
const result = await settingsApi.updateCrealitycloudSession(value);
|
||||
setConnected((current) => ({ ...current, crealitycloud: result.configured }));
|
||||
setExpired((current) => ({ ...current, crealitycloud: false }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -227,7 +247,7 @@ export default function ProvidersPanel({ isAdmin, cookie, onUpdateMakerWorld, on
|
||||
? locked
|
||||
: isConnected
|
||||
? t("providers.connected")
|
||||
: id === "myminifactory" || id === "snapmaker" || id === "makeronline"
|
||||
: isSessionProvider(id) && id !== "makerworld"
|
||||
? t(`providers.${id}.notConnected`)
|
||||
: t("providers.notConnected");
|
||||
return (
|
||||
@@ -353,7 +373,8 @@ function ConnectBox({ id, name, onCancel, onConnect, onUnauthorized }: ConnectBo
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const steps = t(`providers.${id}.steps`, { returnObjects: true }) as string[];
|
||||
const multiline = id === "makerworld";
|
||||
// MakerWorld's cookie is long; Creality Cloud's sign-in may be pasted as two copied request headers.
|
||||
const multiline = id === "makerworld" || id === "crealitycloud";
|
||||
const rootRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// It opens below the icons, often past the bottom of the panel.
|
||||
|
||||
@@ -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: "crealitycloud", name: "Creality Cloud" },
|
||||
{ id: "nexprint", name: "NextPrint" },
|
||||
];
|
||||
|
||||
|
||||
@@ -86,6 +86,7 @@ export const SEARCH_PAGES: SearchPage[] = [
|
||||
"myminifactory",
|
||||
"snapmaker",
|
||||
"makeronline",
|
||||
"creality cloud",
|
||||
"app token",
|
||||
"api key",
|
||||
"access token",
|
||||
|
||||
@@ -13,6 +13,8 @@ type StartMyminifactoryCollectionImportPayload = Parameters<typeof importsApi.fr
|
||||
type StartMakerworldProfilesImportPayload = Parameters<typeof importsApi.fromMakerworldProfiles>[0];
|
||||
type StartSnapmakerProfilesImportPayload = Parameters<typeof importsApi.fromSnapmakerProfiles>[0];
|
||||
type StartSnapmakerListImportPayload = Parameters<typeof importsApi.fromSnapmakerList>[0];
|
||||
type StartCrealitycloudProfilesImportPayload = Parameters<typeof importsApi.fromCrealitycloudProfiles>[0];
|
||||
type StartCrealitycloudListImportPayload = Parameters<typeof importsApi.fromCrealitycloudList>[0];
|
||||
type StartLinksImportPayload = Parameters<typeof importsApi.fromLinks>[0];
|
||||
|
||||
type ImportJobContextValue = {
|
||||
@@ -28,6 +30,8 @@ type ImportJobContextValue = {
|
||||
startMakerworldProfilesImport: (payload: StartMakerworldProfilesImportPayload) => Promise<void>;
|
||||
startSnapmakerProfilesImport: (payload: StartSnapmakerProfilesImportPayload) => Promise<void>;
|
||||
startSnapmakerListImport: (payload: StartSnapmakerListImportPayload) => Promise<void>;
|
||||
startCrealitycloudProfilesImport: (payload: StartCrealitycloudProfilesImportPayload) => Promise<void>;
|
||||
startCrealitycloudListImport: (payload: StartCrealitycloudListImportPayload) => Promise<void>;
|
||||
startLinksImport: (payload: StartLinksImportPayload) => Promise<void>;
|
||||
/** Reruns the links that failed on the finished job shown in the progress bar. */
|
||||
retryJob: () => Promise<void>;
|
||||
@@ -215,6 +219,26 @@ export function ImportJobProvider({
|
||||
[startPolling],
|
||||
);
|
||||
|
||||
const startCrealitycloudProfilesImport = useCallback(
|
||||
async (payload: StartCrealitycloudProfilesImportPayload) => {
|
||||
const { job_id } = await importsApi.fromCrealitycloudProfiles(payload);
|
||||
const job = await importsApi.getImportJob(job_id);
|
||||
setActiveJob(job);
|
||||
startPolling(job_id);
|
||||
},
|
||||
[startPolling],
|
||||
);
|
||||
|
||||
const startCrealitycloudListImport = useCallback(
|
||||
async (payload: StartCrealitycloudListImportPayload) => {
|
||||
const { job_id } = await importsApi.fromCrealitycloudList(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);
|
||||
@@ -251,6 +275,8 @@ export function ImportJobProvider({
|
||||
startMakerworldProfilesImport,
|
||||
startSnapmakerProfilesImport,
|
||||
startSnapmakerListImport,
|
||||
startCrealitycloudProfilesImport,
|
||||
startCrealitycloudListImport,
|
||||
startLinksImport,
|
||||
retryJob,
|
||||
dismissJob,
|
||||
@@ -266,6 +292,8 @@ export function ImportJobProvider({
|
||||
startMakerworldProfilesImport,
|
||||
startSnapmakerProfilesImport,
|
||||
startSnapmakerListImport,
|
||||
startCrealitycloudProfilesImport,
|
||||
startCrealitycloudListImport,
|
||||
startLinksImport,
|
||||
retryJob,
|
||||
dismissJob,
|
||||
|
||||
@@ -25,7 +25,7 @@ import { settingsApi } from "../../api/settings";
|
||||
import { useDisabledProviders } from "../../hooks/useDisabledProviders";
|
||||
|
||||
type ServiceChipDef = {
|
||||
key: "makerworld" | "thingiverse" | "printables" | "myminifactory" | "snapmaker" | "makeronline";
|
||||
key: "makerworld" | "thingiverse" | "printables" | "myminifactory" | "snapmaker" | "makeronline" | "crealitycloud";
|
||||
label: string;
|
||||
connected: boolean;
|
||||
/** Explains what's missing when not connected. */
|
||||
@@ -54,6 +54,8 @@ export function UserMenu({ user, onOpenConfiguration, onOpenProfile, onLogout }:
|
||||
const [snapmakerExpired, setSnapmakerExpired] = React.useState(false);
|
||||
const [makeronlineConfigured, setMakeronlineConfigured] = React.useState(false);
|
||||
const [makeronlineExpired, setMakeronlineExpired] = React.useState(false);
|
||||
const [crealitycloudConfigured, setCrealitycloudConfigured] = React.useState(false);
|
||||
const [crealitycloudExpired, setCrealitycloudExpired] = React.useState(false);
|
||||
|
||||
const menuOpen = Boolean(anchorEl);
|
||||
// Checked on every open: the cookie can change in Configuration while the menu stays mounted.
|
||||
@@ -96,6 +98,14 @@ export function UserMenu({ user, onOpenConfiguration, onOpenProfile, onLogout }:
|
||||
setMakeronlineExpired(res.expired);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
settingsApi
|
||||
.getCrealitycloudSession()
|
||||
.then((res) => {
|
||||
if (!active) return;
|
||||
setCrealitycloudConfigured(res.configured);
|
||||
setCrealitycloudExpired(res.expired);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
@@ -137,6 +147,14 @@ export function UserMenu({ user, onOpenConfiguration, onOpenProfile, onLogout }:
|
||||
connected: makeronlineConfigured && !makeronlineExpired,
|
||||
disabledReason: makeronlineExpired ? t("providers.makeronline.expired") : t("userMenu.makeronlineDisabledReason"),
|
||||
},
|
||||
{
|
||||
key: "crealitycloud",
|
||||
...IMPORT_PROVIDER_INFO.crealitycloud,
|
||||
connected: crealitycloudConfigured && !crealitycloudExpired,
|
||||
disabledReason: crealitycloudExpired
|
||||
? t("providers.crealitycloud.expired")
|
||||
: t("userMenu.crealitycloudDisabledReason"),
|
||||
},
|
||||
];
|
||||
// Turned off by an admin: no icon. Coming-soon sites follow the live ones.
|
||||
const services: { id: ProviderId; label: string; connected: boolean; tooltip: string }[] = [
|
||||
|
||||
@@ -31,6 +31,8 @@ import {
|
||||
isSnapmakerModelUrl,
|
||||
isMakeronlineListUrl,
|
||||
isMakeronlineModelUrl,
|
||||
isCrealitycloudListUrl,
|
||||
isCrealitycloudModelUrl,
|
||||
isThingiverseCollectionUrl,
|
||||
isThingiverseLikesUrl,
|
||||
isThingiverseThingUrl,
|
||||
@@ -70,6 +72,8 @@ export function useUploadImport({ onUploaded, categoryId, makerworldCookie, onUn
|
||||
startMakerworldProfilesImport,
|
||||
startSnapmakerProfilesImport,
|
||||
startSnapmakerListImport,
|
||||
startCrealitycloudProfilesImport,
|
||||
startCrealitycloudListImport,
|
||||
startLinksImport,
|
||||
} = useImportJob();
|
||||
const isBusy = uploading || importing || zipPrompt.isOpen || collectionPrompt.isOpen || importModePrompt.isOpen;
|
||||
@@ -257,8 +261,8 @@ export function useUploadImport({ onUploaded, categoryId, makerworldCookie, onUn
|
||||
showToast({ message: t(key, { name }) });
|
||||
};
|
||||
|
||||
/** `profileScope`: which MakerWorld, Snapmaker or MakerOnline 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 or Creality Cloud 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,
|
||||
@@ -292,6 +296,11 @@ export function useUploadImport({ onUploaded, categoryId, makerworldCookie, onUn
|
||||
return;
|
||||
}
|
||||
|
||||
if (profileScope !== "url" && isCrealitycloudModelUrl(url)) {
|
||||
await startCrealitycloudProfilesImport({ ...payload, scope: profileScope });
|
||||
return;
|
||||
}
|
||||
|
||||
if (isMakeronlineModelUrl(url)) {
|
||||
// Its files come only through Thingport Grab, on makeronline.com.
|
||||
await importsApi.queueMakeronline({ ...payload, scope: profileScope });
|
||||
@@ -435,6 +444,33 @@ export function useUploadImport({ onUploaded, categoryId, makerworldCookie, onUn
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCrealitycloudListUrl(url)) {
|
||||
setImporting(false);
|
||||
await collectionPrompt.prompt({
|
||||
label: url,
|
||||
loadEntries: async () => {
|
||||
try {
|
||||
return await importsApi.listCrealitycloudListEntries(payload);
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedError) onUnauthorized?.();
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
onImportSelected: async (modelIds: string[]) => {
|
||||
try {
|
||||
await startCrealitycloudListImport({ ...payload, model_ids: modelIds });
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedError) {
|
||||
onUnauthorized?.();
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSnapmakerListUrl(url)) {
|
||||
setImporting(false);
|
||||
await collectionPrompt.prompt({
|
||||
@@ -466,7 +502,8 @@ export function useUploadImport({ onUploaded, categoryId, makerworldCookie, onUn
|
||||
isThingiverseThingUrl(url) ||
|
||||
isPrintablesModelUrl(url) ||
|
||||
isMyminifactoryObjectUrl(url) ||
|
||||
isSnapmakerModelUrl(url)
|
||||
isSnapmakerModelUrl(url) ||
|
||||
isCrealitycloudModelUrl(url)
|
||||
) {
|
||||
// The backend splits these into plates itself.
|
||||
const imported = await importsApi.fromLink(payload);
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import type { Theme } from "@mui/material/styles";
|
||||
|
||||
// `color`/`textColor` are sx palette tokens or hex brand colors; `textColor` may also be a theme
|
||||
// callback.
|
||||
export type ImportProviderInfo = { label: string; color: string; textColor?: string | ((theme: Theme) => string) };
|
||||
// callback. `outlined` adds a primary-colour ring.
|
||||
export type ImportProviderInfo = {
|
||||
label: string;
|
||||
color: string;
|
||||
textColor?: string | ((theme: Theme) => string);
|
||||
outlined?: boolean;
|
||||
};
|
||||
|
||||
export const IMPORT_PROVIDER_INFO: Record<string, ImportProviderInfo> = {
|
||||
makerworld: { label: "MakerWorld", color: "#00B800" },
|
||||
@@ -10,25 +15,16 @@ export const IMPORT_PROVIDER_INFO: Record<string, ImportProviderInfo> = {
|
||||
printables: { label: "Printables", color: "#FA6831" },
|
||||
// A darker shade of MyMiniFactory's #00C4A6, so the white label stays readable.
|
||||
myminifactory: { label: "MyMiniFactory", color: "#00866F" },
|
||||
// Snapmaker's mark is black and white, so it follows the theme like Thingport's own.
|
||||
snapmaker: {
|
||||
label: "Snapmaker Space",
|
||||
color: "text.primary",
|
||||
textColor: (theme) => (theme.palette.mode === "dark" ? "#FFFFFF" : theme.palette.background.paper),
|
||||
},
|
||||
// MakerOnline's mark is black and white too.
|
||||
snapmaker: { label: "Snapmaker Space", color: "#0C63E2" },
|
||||
// MakerOnline's mark is black and white, so it follows the theme.
|
||||
makeronline: {
|
||||
label: "MakerOnline",
|
||||
color: "text.primary",
|
||||
textColor: (theme) => (theme.palette.mode === "dark" ? "#FFFFFF" : theme.palette.background.paper),
|
||||
},
|
||||
// Direct uploads (source_provider is null). Monochrome so it doesn't compete with provider badges;
|
||||
// dark mode uses flat white for contrast against the chip.
|
||||
thingport: {
|
||||
label: "Thingport",
|
||||
color: "text.primary",
|
||||
textColor: (theme) => (theme.palette.mode === "dark" ? "#FFFFFF" : theme.palette.background.paper),
|
||||
},
|
||||
crealitycloud: { label: "Creality Cloud", color: "#1AC686" },
|
||||
// Direct uploads (source_provider is null).
|
||||
thingport: { label: "Thingport", color: "common.white", textColor: "common.black", outlined: true },
|
||||
};
|
||||
|
||||
/** Known external providers only; null for uploads. */
|
||||
@@ -41,3 +37,13 @@ export function importProviderInfo(provider: string | null | undefined): ImportP
|
||||
export function printProviderInfo(provider: string | null | undefined): ImportProviderInfo {
|
||||
return importProviderInfo(provider) ?? IMPORT_PROVIDER_INFO.thingport;
|
||||
}
|
||||
|
||||
/** The fill, text and ring of a provider chip. */
|
||||
export function providerChipSx(info: ImportProviderInfo) {
|
||||
return {
|
||||
bgcolor: info.color,
|
||||
color: info.textColor ?? "#fff",
|
||||
// An inset ring, so the outlined chip stays the same size as the others.
|
||||
...(info.outlined && { boxShadow: (theme: Theme) => `inset 0 0 0 1.5px ${theme.palette.primary.main}` }),
|
||||
};
|
||||
}
|
||||
@@ -27,6 +27,7 @@ const ALL: readonly ImportProvider[] = [
|
||||
"myminifactory",
|
||||
"snapmaker",
|
||||
"makeronline",
|
||||
"crealitycloud",
|
||||
];
|
||||
|
||||
/** With every provider off there's nothing to import from or connect to, so those parts of the app hide. */
|
||||
|
||||
@@ -274,6 +274,7 @@
|
||||
"myminifactoryDisabledReason": "MyMiniFactory: the admin hasn't configured an API key yet",
|
||||
"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",
|
||||
"myModels": "My models"
|
||||
},
|
||||
"notifications": {
|
||||
@@ -538,7 +539,8 @@
|
||||
"thingiverse": "Imports, collections and liked models, through this Thingport's App Token.",
|
||||
"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."
|
||||
"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."
|
||||
},
|
||||
"keyStatus": {
|
||||
"thingiverse": {
|
||||
@@ -1635,6 +1637,17 @@
|
||||
"expired": "Sign-in expired. Visit makeronline.com with Thingport Grab, or connect MakerOnline again in Configuration → Providers.",
|
||||
"expiredStatus": "Sign-in expired"
|
||||
},
|
||||
"crealitycloud": {
|
||||
"placeholder": "The Cookie header, with model_token and model_user_id",
|
||||
"steps": [
|
||||
"Easiest: with Thingport Grab, just open crealitycloud.com while signed in. It connects your account, and again each time you sign in there.",
|
||||
"Or by hand: sign in at crealitycloud.com, open your browser's developer tools (F12) and reload the page.",
|
||||
"Under Network, pick any request to crealitycloud.com/api, copy its Cookie request header and paste it below. It lasts a week from when you signed in."
|
||||
],
|
||||
"notConnected": "Creality Cloud gives its files to signed-in users only. Connect your account to import its models, and your likes.",
|
||||
"expired": "Sign-in expired. Sign in on crealitycloud.com with Thingport Grab, or connect Creality Cloud again in Configuration → Providers.",
|
||||
"expiredStatus": "Sign-in expired"
|
||||
},
|
||||
"printables": {
|
||||
"alwaysConnected": "Printables is connected by default: it needs no account or key."
|
||||
},
|
||||
@@ -2585,7 +2598,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 or MakerOnline collection link.",
|
||||
"notALink": "Paste a MakerWorld, Printables, Thingiverse, MyMiniFactory, Snapmaker Space, MakerOnline or Creality Cloud collection link.",
|
||||
"usernameFailed": "Couldn't save the username.",
|
||||
"openProviders": "Open Providers",
|
||||
"notConnected": {
|
||||
@@ -2594,7 +2607,8 @@
|
||||
"thingiverse": "Thingiverse isn't connected on this Thingport yet. An admin can connect it in Configuration > Providers.",
|
||||
"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."
|
||||
"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."
|
||||
},
|
||||
"usernameLabel": "Your {{provider}} username",
|
||||
"usernameHint": {
|
||||
@@ -2712,6 +2726,7 @@
|
||||
"myminifactoryCatIdLabel": "MyMiniFactory category IDs",
|
||||
"snapmakerCatIdLabel": "Snapmaker Space category IDs",
|
||||
"makeronlineCatIdLabel": "MakerOnline category IDs",
|
||||
"crealitycloudCatIdLabel": "Creality Cloud 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"
|
||||
|
||||
@@ -275,6 +275,7 @@
|
||||
"myminifactoryDisabledReason": "MyMiniFactory: administratorius dar nesukonfigūravo API rakto",
|
||||
"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",
|
||||
"myModels": "Mano modeliai"
|
||||
},
|
||||
"notifications": {
|
||||
@@ -539,7 +540,8 @@
|
||||
"thingiverse": "Importai, kolekcijos ir patikę modeliai, naudojant šio Thingport programėlės raktą.",
|
||||
"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."
|
||||
"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ą."
|
||||
},
|
||||
"keyStatus": {
|
||||
"thingiverse": {
|
||||
@@ -1691,6 +1693,17 @@
|
||||
"expired": "Prisijungimo galiojimas baigėsi. Atverkite makeronline.com su Thingport Grab arba vėl prijunkite MakerOnline skiltyje Konfigūracija → Šaltiniai.",
|
||||
"expiredStatus": "Prisijungimas nebegalioja"
|
||||
},
|
||||
"crealitycloud": {
|
||||
"placeholder": "Cookie antraštė su model_token ir model_user_id",
|
||||
"steps": [
|
||||
"Paprasčiausia: su Thingport Grab tiesiog atverkite crealitycloud.com prisijungę. Jis prijungs jūsų paskyrą, ir vėl – kaskart ten prisijungus.",
|
||||
"Arba rankiniu būdu: prisijunkite prie crealitycloud.com, atverkite naršyklės kūrėjo įrankius (F12) ir perkraukite puslapį.",
|
||||
"Skiltyje Network pasirinkite bet kurią užklausą į crealitycloud.com/api, nukopijuokite jos Cookie antraštę ir įklijuokite ją žemiau. Ji galioja savaitę nuo prisijungimo."
|
||||
],
|
||||
"notConnected": "Creality Cloud failus duoda tik prisijungusiems naudotojams. Prijunkite paskyrą, kad galėtumėte importuoti jo modelius ir savo pamėgtus modelius.",
|
||||
"expired": "Prisijungimo galiojimas baigėsi. Prisijunkite crealitycloud.com su Thingport Grab arba vėl prijunkite Creality Cloud skiltyje Konfigūracija → Šaltiniai.",
|
||||
"expiredStatus": "Prisijungimas nebegalioja"
|
||||
},
|
||||
"printables": {
|
||||
"alwaysConnected": "Printables prijungta iš karto: nereikia paskyros ar rakto."
|
||||
},
|
||||
@@ -2656,7 +2669,7 @@
|
||||
"takenBy": "Sinchronizuojama su „{{name}}“",
|
||||
"loadFailed": "Nepavyko įkelti kolekcijų.",
|
||||
"lookupFailed": "Nepavyko rasti šios kolekcijos.",
|
||||
"notALink": "Įklijuokite MakerWorld, Printables, Thingiverse, MyMiniFactory, Snapmaker Space arba MakerOnline kolekcijos nuorodą.",
|
||||
"notALink": "Įklijuokite MakerWorld, Printables, Thingiverse, MyMiniFactory, Snapmaker Space, MakerOnline arba Creality Cloud kolekcijos nuorodą.",
|
||||
"usernameFailed": "Nepavyko išsaugoti vartotojo vardo.",
|
||||
"openProviders": "Atidaryti tiekėjus",
|
||||
"notConnected": {
|
||||
@@ -2665,7 +2678,8 @@
|
||||
"thingiverse": "Thingiverse šiame Thingport dar neprijungtas. Administratorius gali jį prijungti per Konfigūracija > Tiekėjai.",
|
||||
"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."
|
||||
"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."
|
||||
},
|
||||
"usernameLabel": "Jūsų {{provider}} vartotojo vardas",
|
||||
"usernameHint": {
|
||||
@@ -2787,6 +2801,7 @@
|
||||
"myminifactoryCatIdLabel": "MyMiniFactory kategorijų ID",
|
||||
"snapmakerCatIdLabel": "Snapmaker Space kategorijų ID",
|
||||
"makeronlineCatIdLabel": "MakerOnline kategorijų ID",
|
||||
"crealitycloudCatIdLabel": "Creality Cloud 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"
|
||||
|
||||
@@ -34,6 +34,7 @@ const NAMES: Record<ImportProvider, string> = {
|
||||
myminifactory: "MyMiniFactory",
|
||||
snapmaker: "Snapmaker Space",
|
||||
makeronline: "MakerOnline",
|
||||
crealitycloud: "Creality Cloud",
|
||||
};
|
||||
|
||||
const KEYED = new Set<ImportProvider>(["thingiverse", "myminifactory"]);
|
||||
@@ -116,12 +117,20 @@ export default function ProvidersPage({ onUnauthorized }: Props) {
|
||||
<Paper variant="outlined" sx={{ borderRadius: "12px", overflow: "hidden" }}>
|
||||
{(
|
||||
providers ??
|
||||
(["makerworld", "printables", "thingiverse", "myminifactory", "snapmaker", "makeronline"] as const).map(
|
||||
(id) => ({
|
||||
id,
|
||||
enabled: true,
|
||||
}),
|
||||
)
|
||||
(
|
||||
[
|
||||
"makerworld",
|
||||
"printables",
|
||||
"thingiverse",
|
||||
"myminifactory",
|
||||
"snapmaker",
|
||||
"makeronline",
|
||||
"crealitycloud",
|
||||
] as const
|
||||
).map((id) => ({
|
||||
id,
|
||||
enabled: true,
|
||||
}))
|
||||
).map(({ id, enabled }) => {
|
||||
const keySetHere = isKeyed(id) ? keySet[id] : undefined;
|
||||
return (
|
||||
|
||||
@@ -20,7 +20,7 @@ import { useInfiniteScroll } from "../../hooks/useInfiniteScroll";
|
||||
import { useGravatarUrl } from "../../hooks/useGravatarUrl";
|
||||
import { useConfirm } from "../../components/ConfirmProvider";
|
||||
import { useToast } from "../../components/ToastProvider";
|
||||
import { importProviderInfo, printProviderInfo } from "../../constants/importProviders";
|
||||
import { importProviderInfo, printProviderInfo, providerChipSx } from "../../constants/importProviders";
|
||||
import { authorProfileUrl } from "../../utils/authorProfileUrl";
|
||||
import { SELF_AUTHOR_ID } from "../../constants/selfAuthor";
|
||||
import ModelCard from "../ModelsPage/ModelCard";
|
||||
@@ -268,7 +268,7 @@ export default function AuthorPage({ theme, previewMode, onUnauthorized, viewer,
|
||||
size="small"
|
||||
clickable
|
||||
onClick={() => navigate(`/authors/${linked.id}`)}
|
||||
sx={{ bgcolor: info.color, color: info.textColor ?? "#fff", fontWeight: 600 }}
|
||||
sx={{ ...providerChipSx(info), fontWeight: 600 }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -291,9 +291,8 @@ export default function AuthorPage({ theme, previewMode, onUnauthorized, viewer,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.6,
|
||||
color: "#fff",
|
||||
textDecoration: "none",
|
||||
bgcolor: providerInfo.color,
|
||||
...providerChipSx(providerInfo),
|
||||
}}
|
||||
>
|
||||
{providerInfo.label}
|
||||
|
||||
@@ -15,10 +15,10 @@ export const PROVIDER_ACCENTS: Record<BrowsableProvider, string> = {
|
||||
printables: "#FA6831",
|
||||
thingiverse: "#5575FE",
|
||||
myminifactory: "#00C4A6",
|
||||
// Snapmaker's own blue: its mark is black, which a dark theme would lose.
|
||||
snapmaker: "#2563EB",
|
||||
snapmaker: "#0C63E2",
|
||||
// Anycubic's blue, for the same reason: MakerOnline's own mark is black and white.
|
||||
makeronline: "#1F7AE0",
|
||||
crealitycloud: "#1AC686",
|
||||
};
|
||||
|
||||
type Props = {
|
||||
@@ -308,6 +308,37 @@ function MakeronlineTile({ collection }: { collection: ProviderCollection }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Creality Cloud's collection card: one large cover beside two stacked ones, under its green.
|
||||
function CrealitycloudTile({ collection }: { collection: ProviderCollection }) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: "12px",
|
||||
border: 1,
|
||||
borderColor: "divider",
|
||||
borderTop: `3px solid ${PROVIDER_ACCENTS.crealitycloud}`,
|
||||
bgcolor: "background.paper",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "2fr 1fr", gridTemplateRows: "1fr 1fr", gap: "2px" }}>
|
||||
<Thumb src={collection.thumbnails[0]} sx={{ gridRow: "span 2", height: "100%", aspectRatio: "1 / 1" }} />
|
||||
<Thumb src={collection.thumbnails[1]} sx={{ height: "100%" }} />
|
||||
<Thumb src={collection.thumbnails[2]} sx={{ height: "100%" }} />
|
||||
</Box>
|
||||
<Box sx={{ px: 1.25, py: 1 }}>
|
||||
<Typography noWrap sx={{ fontWeight: 600, fontSize: "0.875rem" }}>
|
||||
{collection.name}
|
||||
</Typography>
|
||||
<Box sx={{ display: "flex", alignItems: "center", fontSize: "0.8rem", color: "text.secondary" }}>
|
||||
<ModelCount count={collection.model_count} provider="crealitycloud" />
|
||||
{collection.private && <LockOutlinedIcon sx={{ fontSize: 14, ml: "auto" }} />}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const TILES: Record<BrowsableProvider, (props: { collection: ProviderCollection }) => JSX.Element> = {
|
||||
makerworld: MakerWorldTile,
|
||||
printables: PrintablesTile,
|
||||
@@ -315,14 +346,17 @@ const TILES: Record<BrowsableProvider, (props: { collection: ProviderCollection
|
||||
myminifactory: MyMiniFactoryTile,
|
||||
snapmaker: SnapmakerTile,
|
||||
makeronline: MakeronlineTile,
|
||||
crealitycloud: CrealitycloudTile,
|
||||
};
|
||||
|
||||
/** The name to show: a user's likes, and Snapmaker's likes and bookmarks, come named in English by the instance. */
|
||||
/** The name to show: a user's likes, and Snapmaker's likes and bookmarks, come named in English by the instance, as
|
||||
* do MakerOnline's and Creality Cloud's likes. */
|
||||
function displayName(provider: BrowsableProvider, collection: ProviderCollection, t: (key: string) => string): string {
|
||||
if (collection.likes) return t("collections.sync.browse.likes");
|
||||
if (provider === "snapmaker" && collection.id.startsWith("likes-")) return t("collections.sync.browse.likes");
|
||||
if (provider === "snapmaker" && collection.id.startsWith("bookmarks-")) return t("collections.sync.browse.bookmarks");
|
||||
if (provider === "makeronline" && collection.id === "likes") return t("collections.sync.browse.likes");
|
||||
if (provider === "crealitycloud" && collection.id.startsWith("likes-")) return t("collections.sync.browse.likes");
|
||||
return collection.name;
|
||||
}
|
||||
|
||||
@@ -437,6 +471,21 @@ export function ProviderCollectionTileSkeleton({ provider }: { provider: Browsab
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (provider === "crealitycloud") {
|
||||
return (
|
||||
<Box aria-hidden sx={{ borderRadius: "12px", border: 1, borderColor: "divider", overflow: "hidden" }}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "2fr 1fr", gridTemplateRows: "1fr 1fr", gap: "2px" }}>
|
||||
<Skeleton
|
||||
variant="rectangular"
|
||||
sx={{ gridRow: "span 2", width: "100%", height: "100%", aspectRatio: "1 / 1" }}
|
||||
/>
|
||||
<Skeleton variant="rectangular" sx={{ width: "100%", height: "100%" }} />
|
||||
<Skeleton variant="rectangular" sx={{ width: "100%", height: "100%" }} />
|
||||
</Box>
|
||||
<Box sx={{ px: 1.25, py: 1 }}>{lines}</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (provider === "snapmaker") {
|
||||
return (
|
||||
<Box aria-hidden sx={{ borderRadius: "12px", border: 1, borderColor: "divider", overflow: "hidden" }}>
|
||||
|
||||
@@ -47,10 +47,11 @@ const PASTE_EXAMPLES: Record<BrowsableProvider, string> = {
|
||||
myminifactory: "https://www.myminifactory.com/users/…/collection/…",
|
||||
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/…",
|
||||
};
|
||||
|
||||
// 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"]);
|
||||
const SIGNED_IN = new Set<BrowsableProvider>(["makerworld", "snapmaker", "makeronline", "crealitycloud"]);
|
||||
|
||||
/** 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. */
|
||||
|
||||
@@ -17,7 +17,15 @@ import { collectionDisplayName } from "../../utils/collectionDisplay";
|
||||
// One trip of the pulse: provider to Thingport in the first half, Thingport to the collection in
|
||||
// the second, each node lighting up as it arrives.
|
||||
const CYCLE = "2.8s";
|
||||
const LOGOS = new Set<string>(["makerworld", "thingiverse", "printables", "myminifactory", "snapmaker", "makeronline"]);
|
||||
const LOGOS = new Set<string>([
|
||||
"makerworld",
|
||||
"thingiverse",
|
||||
"printables",
|
||||
"myminifactory",
|
||||
"snapmaker",
|
||||
"makeronline",
|
||||
"crealitycloud",
|
||||
]);
|
||||
|
||||
// The pulse is 40% of its segment wide, so 250% starts it past the right end and -100% ends it
|
||||
// past the left one.
|
||||
|
||||
@@ -7,7 +7,7 @@ import List from "@mui/material/List";
|
||||
import ListItem from "@mui/material/ListItem";
|
||||
import Chip from "@mui/material/Chip";
|
||||
import PublicIcon from "@mui/icons-material/Public";
|
||||
import { printProviderInfo } from "../../constants/importProviders";
|
||||
import { printProviderInfo, providerChipSx } from "../../constants/importProviders";
|
||||
import type { DashboardProvider } from "../../api/dashboard";
|
||||
|
||||
type Props = {
|
||||
@@ -46,11 +46,7 @@ export default function ProviderListCard({ providers }: Props) {
|
||||
return (
|
||||
<ListItem key={p.provider} sx={{ px: 1 }}>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ width: "100%" }}>
|
||||
<Chip
|
||||
label={info.label}
|
||||
size="small"
|
||||
sx={{ bgcolor: info.color, color: info.textColor ?? "#fff", fontWeight: 600 }}
|
||||
/>
|
||||
<Chip label={info.label} size="small" sx={{ ...providerChipSx(info), fontWeight: 600 }} />
|
||||
<Typography variant="body2" color="text.secondary" sx={{ flexShrink: 0, pl: 1 }}>
|
||||
{t("dashboard.topProviders.modelCount", { count: p.model_count })}
|
||||
</Typography>
|
||||
|
||||
@@ -164,6 +164,7 @@ export default function LogsPage({ onUnauthorized }: Props) {
|
||||
myminifactory: "MyMiniFactory",
|
||||
snapmaker: "Snapmaker Space",
|
||||
makeronline: "MakerOnline",
|
||||
crealitycloud: "Creality Cloud",
|
||||
};
|
||||
const provider = typeof d.provider === "string" ? (names[d.provider] ?? d.provider) : "";
|
||||
return t(d.enabled ? "adminSettings.logs.providerOn" : "adminSettings.logs.providerOff", { provider });
|
||||
|
||||
@@ -73,7 +73,8 @@ function hasMeta(category: Category): boolean {
|
||||
category.printables_cat_ids ||
|
||||
category.myminifactory_cat_ids ||
|
||||
category.snapmaker_cat_ids ||
|
||||
category.makeronline_cat_ids,
|
||||
category.makeronline_cat_ids ||
|
||||
category.crealitycloud_cat_ids,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ export default function CategoryMetaDialog({ category, showSiteCatIds, onClose,
|
||||
const [myminifactoryCatIds, setMyminifactoryCatIds] = useState(category.myminifactory_cat_ids);
|
||||
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 [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -49,6 +50,7 @@ export default function CategoryMetaDialog({ category, showSiteCatIds, onClose,
|
||||
myminifactoryCatIds,
|
||||
snapmakerCatIds,
|
||||
makeronlineCatIds,
|
||||
crealitycloudCatIds,
|
||||
});
|
||||
onClose();
|
||||
} catch (err) {
|
||||
@@ -140,6 +142,15 @@ export default function CategoryMetaDialog({ category, showSiteCatIds, onClose,
|
||||
onChange={(e) => setMakeronlineCatIds(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
<TextField
|
||||
label={t("models:categories.manager.crealitycloudCatIdLabel")}
|
||||
placeholder={t("models:categories.manager.catIdsPlaceholder") ?? undefined}
|
||||
helperText={t("models:categories.manager.catIdsHelp")}
|
||||
fullWidth
|
||||
value={crealitycloudCatIds}
|
||||
onChange={(e) => setCrealitycloudCatIds(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{error && <Alert severity="error">{error}</Alert>}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { type Print, printsApi } from "../../api/prints";
|
||||
import { type PreviewMode } from "../../api/settings";
|
||||
import { type ResolvedTheme } from "../../constants/settingsOptions";
|
||||
import { renderPreviewContent } from "../../components/media/renderPreviewContent";
|
||||
import { printProviderInfo } from "../../constants/importProviders";
|
||||
import { printProviderInfo, providerChipSx } from "../../constants/importProviders";
|
||||
import { SELF_AUTHOR_ID } from "../../constants/selfAuthor";
|
||||
import { useGravatarUrl } from "../../hooks/useGravatarUrl";
|
||||
import StarToggle from "../../components/StarToggle";
|
||||
@@ -143,8 +143,7 @@ export default function ModelCard({
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.4,
|
||||
color: providerInfo.textColor ?? "#fff",
|
||||
bgcolor: providerInfo.color,
|
||||
...providerChipSx(providerInfo),
|
||||
}}
|
||||
>
|
||||
{providerInfo.label}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** URL-shape detection shared by useUploadImport and AddMenu so they can't drift apart. */
|
||||
|
||||
export type ImportProviderKey =
|
||||
"makerworld" | "thingiverse" | "printables" | "myminifactory" | "snapmaker" | "makeronline";
|
||||
"makerworld" | "thingiverse" | "printables" | "myminifactory" | "snapmaker" | "makeronline" | "crealitycloud";
|
||||
|
||||
function parseUrl(url: string): URL | null {
|
||||
try {
|
||||
@@ -21,6 +21,7 @@ export function detectImportProvider(url: string): ImportProviderKey | null {
|
||||
if (host === "myminifactory.com" || host === "www.myminifactory.com") return "myminifactory";
|
||||
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";
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -137,6 +138,28 @@ export function isMakeronlineListUrl(url: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function isCrealitycloudHost(parsed: URL): boolean {
|
||||
return /^(www\.)?crealitycloud\.com$/i.test(parsed.hostname);
|
||||
}
|
||||
|
||||
const CREALITYCLOUD_LANG = String.raw`(?:[a-z]{2}(?:-[a-z]{2,4})?/)?`;
|
||||
|
||||
/** See isThingiverseThingUrl. `/model-detail/<alias>`; `#profileId-<id>` may pick a print profile. */
|
||||
export function isCrealitycloudModelUrl(url: string): boolean {
|
||||
const parsed = parseUrl(url);
|
||||
if (!parsed || !isCrealitycloudHost(parsed)) return false;
|
||||
return new RegExp(String.raw`^/${CREALITYCLOUD_LANG}model-detail/[\w-]+/?$`, "i").test(parsed.pathname);
|
||||
}
|
||||
|
||||
/** A Creality Cloud collection (`/collection/<alias>`), or the user's likes (`/user/<id>/likes`). */
|
||||
export function isCrealitycloudListUrl(url: string): boolean {
|
||||
const parsed = parseUrl(url);
|
||||
if (!parsed || !isCrealitycloudHost(parsed)) return false;
|
||||
return new RegExp(String.raw`^/${CREALITYCLOUD_LANG}(?:collection/[\w-]+|user/\d+/likes)/?$`, "i").test(
|
||||
parsed.pathname,
|
||||
);
|
||||
}
|
||||
|
||||
export const IMPORT_LINK_EXAMPLES: Record<ImportProviderKey, { model: string; collection: string }> = {
|
||||
makerworld: {
|
||||
model: "https://makerworld.com/en/models/123456-example-model",
|
||||
@@ -162,4 +185,8 @@ export const IMPORT_LINK_EXAMPLES: Record<ImportProviderKey, { model: string; co
|
||||
model: "https://www.makeronline.com/en/model/Example-Model/123456.html",
|
||||
collection: "https://www.makeronline.com/en/user/collectionDetail/…/123456.html",
|
||||
},
|
||||
crealitycloud: {
|
||||
model: "https://www.crealitycloud.com/model-detail/example-model",
|
||||
collection: "https://www.crealitycloud.com/collection/3d-model-collection-AbCdEf",
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user