mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-21 06:09:56 -05:00
feat: add Cults3D provider
Imports Cults3D models, anyone's likes and the user's own printlists, and syncs them. Metadata comes from Cults3D's GraphQL API with each user's own nickname and API key. The API never hands out files, and the site's pages are behind a Cloudflare check only a browser passes, so Thingport Grab sends the browser's _session_id and cf_clearance cookies with its User-Agent; with them the server orders a free model for nothing, as its Download button does, and follows the download page to the signed file link (CloudFront, or Cults3D's Scaleway bucket for older models). Paid models download once bought. A Cloudflare block defers a sync until the next visit with Grab.
This commit is contained in:
1 parent
fc1223944b
commit
930b9b48db
73 files changed
+3305
-129
No files matched your search
@@ -278,7 +278,7 @@ To have Thingport import whatever you drop into a folder on the server, uncommen
|
||||
|
||||
## Features
|
||||
|
||||
- **[Import from MakerWorld, Printables, Thingiverse, MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud, NexPrint and Thangs](docs/features/importing.md)**
|
||||
- **[Import from MakerWorld, Printables, Thingiverse, MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud, NexPrint, Thangs and Cults3D](docs/features/importing.md)**
|
||||
- **[Import bookmarklet](docs/guides/bookmarklet.md)**
|
||||
- **[Synced collections](docs/guides/synced-collections.md)**
|
||||
- **[Bring your existing library](docs/guides/existing-library.md)**
|
||||
@@ -307,11 +307,11 @@ To have Thingport import whatever you drop into a folder on the server, uncommen
|
||||
Thingport ships two small companion tools, each downloadable from the in-app Download page or GitHub Releases:
|
||||
|
||||
- **[Thingport Bridge](bridge/README.md)** — a lightweight desktop helper that makes "Open in {Slicer}" work for slicers (Bambu Studio, PrusaSlicer, Cura, Anycubic Slicer Next) whose own URL-protocol handlers won't accept a link from a self-hosted domain.
|
||||
- **[Thingport Grab](extension/README.md)** — a browser extension for Chrome, Edge and Firefox that imports MakerWorld, Thingiverse, Printables, MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud, NexPrint and Thangs models straight from their own pages, without leaving the site (see the screenshots above). Even without a Thingport instance, it adds a "Download normalized" button to MakerWorld model pages that converts Bambu Studio projects into 3MFs other slicers open with their colours and settings. Get it from the [Chrome Web Store](https://chromewebstore.google.com/detail/nmblahmglpbplmfcggghdgohohlaeiee), [Firefox Add-ons](https://addons.mozilla.org/firefox/addon/thingport-grab/) or [Microsoft Edge Add-ons](https://microsoftedge.microsoft.com/addons/detail/kahfidpmojfocohinlmglnfoaimocbol).
|
||||
- **[Thingport Grab](extension/README.md)** — a browser extension for Chrome, Edge and Firefox that imports MakerWorld, Thingiverse, Printables, MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud, NexPrint, Thangs and Cults3D models straight from their own pages, without leaving the site (see the screenshots above). Even without a Thingport instance, it adds a "Download normalized" button to MakerWorld model pages that converts Bambu Studio projects into 3MFs other slicers open with their colours and settings. Get it from the [Chrome Web Store](https://chromewebstore.google.com/detail/nmblahmglpbplmfcggghdgohohlaeiee), [Firefox Add-ons](https://addons.mozilla.org/firefox/addon/thingport-grab/) or [Microsoft Edge Add-ons](https://microsoftedge.microsoft.com/addons/detail/kahfidpmojfocohinlmglnfoaimocbol).
|
||||
|
||||
## Provider Setup
|
||||
|
||||
Printables imports work with no setup. MakerWorld needs each user's session cookie; Thingiverse needs an App Token and MyMiniFactory an API key, each set once by an admin for the whole instance. Users connect their own MyMiniFactory session only to import paid or sign-in-only models from their library, and their own Snapmaker Space sign-in, which Thingport Grab keeps fresh, for any Snapmaker import. MakerOnline's files come through Thingport Grab, which also connects each user's MakerOnline sign-in for their likes and private collections. Creality Cloud needs each user's own sign-in for its files and likes, NexPrint for its files, and Thangs for its files, which Thingport then keeps renewed itself; Thingport Grab sends each of them on. Connect them under **Configuration → Providers** (from the user menu): right-click a provider to connect or disconnect it. Admins can turn any provider off for the whole instance under **Administration → Providers**. See **[docs/PROVIDER_SETUP.md](docs/PROVIDER_SETUP.md)** for how to create a Thingiverse App Token or a MyMiniFactory API key, and how to grab a MakerWorld session cookie.
|
||||
Printables imports work with no setup. MakerWorld needs each user's session cookie; Thingiverse needs an App Token and MyMiniFactory an API key, each set once by an admin for the whole instance. Users connect their own MyMiniFactory session only to import paid or sign-in-only models from their library, and their own Snapmaker Space sign-in, which Thingport Grab keeps fresh, for any Snapmaker import. MakerOnline's files come through Thingport Grab, which also connects each user's MakerOnline sign-in for their likes and private collections. Creality Cloud needs each user's own sign-in for its files and likes, NexPrint for its files, and Thangs for its files, which Thingport then keeps renewed itself; Thingport Grab sends each of them on. Cults3D is read with each user's own API key, and its files come with the browser sign-in Thingport Grab sends. 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,81 @@
|
||||
-- Cults3D as a tenth provider: its category ids on categories, and the user's Cults3D access: API credentials (their
|
||||
-- nickname and an API key) for the metadata, and the browser's sign-in and Cloudflare clearance, sent by Thingport Grab,
|
||||
-- for the order and download pages.
|
||||
ALTER TABLE "Category" ADD COLUMN "cults3dCatIds" INTEGER[] NOT NULL DEFAULT '{}';
|
||||
CREATE INDEX "Category_cults3dCatIds_idx" ON "Category" USING GIN ("cults3dCatIds");
|
||||
|
||||
ALTER TABLE "User" ADD COLUMN "cults3dNick" TEXT,
|
||||
ADD COLUMN "cults3dApiKey" TEXT,
|
||||
ADD COLUMN "cults3dApiRejectedAt" TIMESTAMP(3),
|
||||
ADD COLUMN "cults3dSession" TEXT,
|
||||
ADD COLUMN "cults3dClearance" TEXT,
|
||||
ADD COLUMN "cults3dUserAgent" TEXT,
|
||||
ADD COLUMN "cults3dSessionRejectedAt" TIMESTAMP(3);
|
||||
|
||||
-- Adds Cults3D's category ids to the starter categories of users who signed up before they were in
|
||||
-- seedData/defaultCategories.ts, so their Cults3D 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 "cults3dCatIds" = m.ids
|
||||
FROM (VALUES
|
||||
('3D Printer', '3D Printer Accessories', ARRAY[101]),
|
||||
(NULL::text, 'Art', ARRAY[23]),
|
||||
('Art', 'Coin & Badges', ARRAY[36, 51]),
|
||||
('Art', 'Signs & Logos', ARRAY[44]),
|
||||
('Art', 'Sculptures', ARRAY[43, 42]),
|
||||
('Art', 'Other Art Models', ARRAY[37, 38, 39, 35]),
|
||||
(NULL::text, 'Education', ARRAY[116]),
|
||||
(NULL::text, 'Fashion', ARRAY[24]),
|
||||
('Fashion', 'Bags', ARRAY[49]),
|
||||
('Fashion', 'Earrings', ARRAY[53]),
|
||||
('Fashion', 'Footwear', ARRAY[122]),
|
||||
('Fashion', 'Glasses', ARRAY[47]),
|
||||
('Fashion', 'Jewelry', ARRAY[26, 50, 55, 52]),
|
||||
('Fashion', 'Rings', ARRAY[56]),
|
||||
(NULL::text, 'Hobby & DIY', ARRAY[102]),
|
||||
('Hobby & DIY', 'Electronics', ARRAY[84]),
|
||||
('Hobby & DIY', 'Music', ARRAY[78]),
|
||||
('Hobby & DIY', 'RC', ARRAY[97, 83]),
|
||||
('Hobby & DIY', 'Robotics', ARRAY[41]),
|
||||
('Hobby & DIY', 'Sport & Outdoors', ARRAY[121, 89]),
|
||||
('Hobby & DIY', 'Vehicles', ARRAY[86, 8]),
|
||||
('Hobby & DIY', 'Other Hobby & DIY', ARRAY[79, 118, 119]),
|
||||
(NULL::text, 'Household', ARRAY[30]),
|
||||
('Household', 'Decor', ARRAY[61, 64, 71, 70, 72]),
|
||||
('Household', 'Festivities', ARRAY[120]),
|
||||
('Household', 'Garden', ARRAY[67]),
|
||||
('Household', 'Office', ARRAY[66]),
|
||||
('Household', 'Pets', ARRAY[68]),
|
||||
('Household', 'Other Household Models', ARRAY[63, 57, 60, 58, 59, 65, 69, 62, 113]),
|
||||
(NULL::text, 'Miniatures', ARRAY[98]),
|
||||
('Miniatures', 'Animals', ARRAY[34]),
|
||||
('Miniatures', 'Architecture', ARRAY[32, 75, 73, 74, 76, 77, 115]),
|
||||
('Miniatures', 'People', ARRAY[40]),
|
||||
('Props & Cosplays', 'Masks & Helmets', ARRAY[48]),
|
||||
('Props & Cosplays', 'Cosplay Weapons', ARRAY[46]),
|
||||
(NULL::text, 'Tools', ARRAY[27]),
|
||||
('Tools', 'Gadgets', ARRAY[25, 85, 82, 81, 80]),
|
||||
('Tools', 'Hand Tools', ARRAY[103]),
|
||||
('Tools', 'Machine Tools', ARRAY[104]),
|
||||
('Tools', 'Medical Tools', ARRAY[117, 114]),
|
||||
('Tools', 'Organizers', ARRAY[106]),
|
||||
('Tools', 'Other Tools', ARRAY[105]),
|
||||
(NULL::text, 'Toys & Games', ARRAY[31, 99]),
|
||||
('Toys & Games', 'Board Games', ARRAY[91]),
|
||||
('Toys & Games', 'Characters', ARRAY[87]),
|
||||
('Toys & Games', 'Puzzles', ARRAY[93]),
|
||||
('Toys & Games', 'Construction Sets', ARRAY[94]),
|
||||
('Toys & Games', 'Other Toys & Games', ARRAY[90, 100, 88, 96, 92, 95, 54]),
|
||||
('Generative 3D Model', 'Hueforge & Lithophane', ARRAY[33])
|
||||
) AS m(parent_name, name, ids)
|
||||
WHERE c."kind" = 'category'
|
||||
AND c."name" = m.name
|
||||
AND cardinality(c."cults3dCatIds") = 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
|
||||
)
|
||||
);
|
||||
@@ -110,6 +110,20 @@ model User {
|
||||
thangsUserId String?
|
||||
// When Thangs last turned the sign-in down, refresh token included.
|
||||
thangsRejectedAt DateTime?
|
||||
// The user's Cults3D API credentials (their nickname and an API key they made on the site), which read models,
|
||||
// likes, printlists and orders without Cloudflare in the way.
|
||||
cults3dNick String?
|
||||
cults3dApiKey String?
|
||||
// When Cults3D last turned the API key down.
|
||||
cults3dApiRejectedAt DateTime?
|
||||
// The browser's Cults3D sign-in, sent by Thingport Grab: the _session_id cookie (20 days, renewed on use), and the
|
||||
// cf_clearance cookie with the User-Agent it was issued to, which the site's Cloudflare wants for the order and
|
||||
// download pages. The clearance only holds from the browser's own IP address.
|
||||
cults3dSession String?
|
||||
cults3dClearance String?
|
||||
cults3dUserAgent String?
|
||||
// When Cults3D last answered the session with its sign-in page.
|
||||
cults3dSessionRejectedAt DateTime?
|
||||
slicer String?
|
||||
// "light" | "dark" | "system"; null means never set.
|
||||
themePreference String?
|
||||
@@ -189,6 +203,7 @@ model Category {
|
||||
nexprintCatIds String[] @default([])
|
||||
// Thangs has no category ids, only paths: "Tools & Organizers", or "Tools & Organizers/Multiboard".
|
||||
thangsCatIds String[] @default([])
|
||||
cults3dCatIds Int[] @default([])
|
||||
prints Print[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -204,6 +219,7 @@ model Category {
|
||||
@@index([crealitycloudCatIds], type: Gin)
|
||||
@@index([nexprintCatIds], type: Gin)
|
||||
@@index([thangsCatIds], type: Gin)
|
||||
@@index([cults3dCatIds], type: Gin)
|
||||
}
|
||||
|
||||
model Print {
|
||||
|
||||
@@ -4,13 +4,15 @@
|
||||
// npx tsx scripts/syncProviderCategories.ts
|
||||
//
|
||||
// Thingiverse and MyMiniFactory list their categories only with an API key, so it reads this instance's (from the
|
||||
// database in backend/.env, as the app does). The rest are public.
|
||||
// database in backend/.env, as the app does). Cults3D's API needs a user's own key: CULTS3D_NICK and CULTS3D_API_KEY,
|
||||
// or else the first user's who set one. The rest are public.
|
||||
|
||||
import { writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { browserFetch } from "../src/utils/browserFetch";
|
||||
import { getMyminifactoryApiKey, getThingiverseAccessToken } from "../src/services/settingsService";
|
||||
import { cults3dNumericId } from "../src/services/cults3dApi";
|
||||
import { prisma } from "../src/db";
|
||||
|
||||
type Entry = { id: string; name: string; parent: string | null };
|
||||
@@ -172,6 +174,31 @@ const FETCHERS: Record<string, () => Promise<Entry[]>> = {
|
||||
}
|
||||
return out;
|
||||
},
|
||||
async cults3d() {
|
||||
const user = await prisma.user.findFirst({
|
||||
where: { cults3dNick: { not: null }, cults3dApiKey: { not: null } },
|
||||
select: { cults3dNick: true, cults3dApiKey: true },
|
||||
});
|
||||
const nick = process.env.CULTS3D_NICK ?? user?.cults3dNick;
|
||||
const key = process.env.CULTS3D_API_KEY ?? user?.cults3dApiKey;
|
||||
if (!nick || !key) throw new Error("needs CULTS3D_NICK and CULTS3D_API_KEY, or a user with a Cults3D API key");
|
||||
const res = await fetch("https://cults3d.com/graphql", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Basic ${Buffer.from(`${nick}:${key}`).toString("base64")}`,
|
||||
},
|
||||
body: JSON.stringify({ query: "{ categories(safe: false) { id name children { id name } } }" }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Cults3D: HTTP ${res.status}`);
|
||||
const data = ((await res.json()) as Node).data as Node;
|
||||
// Its ids are base64 of "Category/<number>"; the number is what imports carry.
|
||||
return flatten(nodes(data?.categories), (node) => ({
|
||||
id: cults3dNumericId(node.id),
|
||||
name: node.name,
|
||||
children: node.children,
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
(async () => {
|
||||
|
||||
+6
-3
@@ -184,6 +184,7 @@ export type CategoryOut = {
|
||||
crealitycloud_cat_ids: string;
|
||||
nexprint_cat_ids: string;
|
||||
thangs_cat_ids: string;
|
||||
cults3d_cat_ids: string;
|
||||
};
|
||||
|
||||
function plateThumbUrl(plateId: string): string | null {
|
||||
@@ -392,12 +393,13 @@ export type CollectionSyncOut = {
|
||||
// A Likes sync's externalId; collection ids are numeric, so the two never collide.
|
||||
export const LIKES_ID_PREFIX = "likes:";
|
||||
|
||||
/** The user whose likes a sync follows (on Thingiverse or Printables), as written in its page
|
||||
/** The user whose likes a sync follows (on Thingiverse, Printables or Cults3D), as written in its page
|
||||
* address, or null for a collection sync. */
|
||||
export function syncLikesUsername(sync: { externalId: string; sourceUrl: string }): string | null {
|
||||
if (!sync.externalId.startsWith(LIKES_ID_PREFIX)) return null;
|
||||
// Printables writes its profile paths as "/@handle".
|
||||
const fromUrl = new URL(sync.sourceUrl).pathname.split("/").find(Boolean)?.replace(/^@/, "");
|
||||
const parts = new URL(sync.sourceUrl).pathname.split("/").filter(Boolean);
|
||||
// Printables writes its profile paths as "/@handle"; Cults3D's are "/en/users/<nick>/likes".
|
||||
const fromUrl = (/^[a-z]{2}$/.test(parts[0] ?? "") && parts.length === 4 ? parts[2] : parts[0])?.replace(/^@/, "");
|
||||
return fromUrl ? decodeURIComponent(fromUrl) : sync.externalId.slice(LIKES_ID_PREFIX.length);
|
||||
}
|
||||
|
||||
@@ -483,6 +485,7 @@ export function toCategoryOut(category: Category): CategoryOut {
|
||||
crealitycloud_cat_ids: formatCatIds(category.crealitycloudCatIds),
|
||||
nexprint_cat_ids: formatCatIds(category.nexprintCatIds),
|
||||
thangs_cat_ids: formatCatIds(category.thangsCatIds),
|
||||
cults3d_cat_ids: formatCatIds(category.cults3dCatIds),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -233,6 +233,7 @@ const categoryMetaSchema = z.object({
|
||||
crealitycloud_cat_ids: catIdsField,
|
||||
nexprint_cat_ids: catIdsField,
|
||||
thangs_cat_ids: catPathsField,
|
||||
cults3d_cat_ids: catIdsField,
|
||||
});
|
||||
|
||||
router.patch(
|
||||
@@ -255,6 +256,7 @@ router.patch(
|
||||
crealitycloudCatIds: catIdsUpdate(body.crealitycloud_cat_ids),
|
||||
nexprintCatIds: catIdsUpdate(body.nexprint_cat_ids)?.map(String),
|
||||
thangsCatIds: body.thangs_cat_ids === undefined ? undefined : parseCatPathsInput(body.thangs_cat_ids),
|
||||
cults3dCatIds: catIdsUpdate(body.cults3d_cat_ids),
|
||||
},
|
||||
});
|
||||
res.json(toCategoryOut(updated));
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
CREALITYCLOUD_SESSION_CODE,
|
||||
NEXPRINT_SESSION_CODE,
|
||||
THANGS_SESSION_CODE,
|
||||
CULTS3D_SESSION_CODE,
|
||||
checkImportStatus,
|
||||
downloadImportToTemp,
|
||||
findImportedExternalIds,
|
||||
@@ -130,6 +131,18 @@ import {
|
||||
THANGS_CONNECT,
|
||||
withThangsToken,
|
||||
} from "../services/thangsSessionService";
|
||||
import {
|
||||
Cults3dAuthError,
|
||||
cults3dLikesName,
|
||||
type Cults3dListing,
|
||||
Cults3dRateLimitError,
|
||||
fetchCults3dLikes,
|
||||
fetchCults3dOrders,
|
||||
fetchCults3dPrintlist,
|
||||
parseCults3dLikesUrl,
|
||||
parseCults3dPrintlistUrl,
|
||||
} from "../services/cults3dApi";
|
||||
import { CULTS3D_CONNECT, getUserCults3dCredentials, markCults3dApiRejected } from "../services/cults3dSessionService";
|
||||
import { addPrintsToCollection, findOrCreateCollectionByName } from "../services/collectionService";
|
||||
import { listZipEntries } from "../services/zipService";
|
||||
import { createJob, createJobItems, getActiveJob, getJob, listJobItems } from "../services/importJobService";
|
||||
@@ -142,6 +155,7 @@ import {
|
||||
runNexprintCollectionImportJob,
|
||||
runNexprintProfilesImportJob,
|
||||
runThangsListImportJob,
|
||||
runCults3dListImportJob,
|
||||
runLinksImportJob,
|
||||
runMakerworldProfilesImportJob,
|
||||
runMyminifactoryCollectionImportJob,
|
||||
@@ -320,6 +334,7 @@ const statusBatchSchema = z.object({
|
||||
"crealitycloud",
|
||||
"nexprint",
|
||||
"thangs",
|
||||
"cults3d",
|
||||
]),
|
||||
// Creality Cloud's ids are its models' aliases, made of their titles.
|
||||
external_ids: z.array(z.string().max(200)).max(10_000),
|
||||
@@ -760,6 +775,64 @@ router.post(
|
||||
}),
|
||||
);
|
||||
|
||||
/** A user's likes, or one of the user's own printlists, from a Cults3D link, read with their API key. Their orders say
|
||||
* which paid models they own, which the listing keeps. */
|
||||
async function cults3dListOf(userId: string, url: string): Promise<{ title: string; listing: Cults3dListing }> {
|
||||
const likes = parseCults3dLikesUrl(url);
|
||||
const printlist = likes ? null : parseCults3dPrintlistUrl(url);
|
||||
if (!likes && !printlist) throw new HttpError(400, "Not a link to Cults3D likes or one of your printlists");
|
||||
const credentials = await getUserCults3dCredentials(userId);
|
||||
if (!credentials) throw new HttpError(400, CULTS3D_CONNECT, CULTS3D_SESSION_CODE);
|
||||
try {
|
||||
const owned = new Set((await fetchCults3dOrders(credentials)).keys());
|
||||
if (likes) {
|
||||
const listing = await fetchCults3dLikes(credentials, likes.nick, owned);
|
||||
if (!listing) throw new HttpError(404, `There's no Cults3D user called "${likes.nick}".`);
|
||||
return { title: cults3dLikesName(likes.nick), listing };
|
||||
}
|
||||
if (printlist!.nick.toLowerCase() !== credentials.nick.toLowerCase()) {
|
||||
throw new HttpError(403, "Only your own Cults3D printlists can be imported: Cults3D's API shows no one else's.");
|
||||
}
|
||||
const found = await fetchCults3dPrintlist(credentials, printlist!.key, owned);
|
||||
if (!found) throw new HttpError(404, "That printlist can't be found. It may have been deleted.");
|
||||
return { title: found.info.name, listing: found };
|
||||
} catch (err) {
|
||||
if (err instanceof Cults3dAuthError) {
|
||||
await markCults3dApiRejected(userId);
|
||||
// 400, not 401: the frontend treats any 401 as an expired Thingport session.
|
||||
throw new HttpError(400, err.message, CULTS3D_SESSION_CODE);
|
||||
}
|
||||
if (err instanceof Cults3dRateLimitError) throw new HttpError(429, err.message);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
router.post(
|
||||
"/import/cults3d-list/entries",
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = parseBody(importRequestSchema, req.body);
|
||||
const url = await enabledImportUrl(body.url);
|
||||
const { title, listing } = await cults3dListOf(req.userId!, url);
|
||||
if (!listing.entries.length) throw new HttpError(400, `"${title}" has no models Thingport can import`);
|
||||
const alreadyImported = await findImportedExternalIds(
|
||||
req.userId!,
|
||||
"cults3d",
|
||||
listing.entries.map((e) => e.slug),
|
||||
);
|
||||
res.json({
|
||||
title,
|
||||
total: listing.total,
|
||||
truncated: listing.truncated,
|
||||
entries: listing.entries.map((e) => ({
|
||||
design_id: e.slug,
|
||||
title: e.title,
|
||||
cover: e.cover,
|
||||
already_imported: alreadyImported.has(e.slug),
|
||||
})),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
// ---- 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
|
||||
@@ -1230,6 +1303,36 @@ router.post(
|
||||
}),
|
||||
);
|
||||
|
||||
const cults3dListImportRequestSchema = importRequestSchema.extend({
|
||||
model_ids: z
|
||||
.array(
|
||||
z
|
||||
.string()
|
||||
.regex(/^[a-z0-9][a-z0-9_-]*$/i)
|
||||
.max(200),
|
||||
)
|
||||
.min(1)
|
||||
.max(1000),
|
||||
});
|
||||
|
||||
router.post(
|
||||
"/import/cults3d-list",
|
||||
requireCaptcha("import"),
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = parseBody(cults3dListImportRequestSchema, req.body);
|
||||
await assertNoActiveJob(req.userId!);
|
||||
const url = await enabledImportUrl(body.url);
|
||||
const { title } = await cults3dListOf(req.userId!, url);
|
||||
const job = await createJob(req.userId!, "COLLECTION", {
|
||||
sourceUrl: url,
|
||||
provider: "cults3d",
|
||||
total: body.model_ids.length,
|
||||
});
|
||||
void runCults3dListImportJob(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(
|
||||
|
||||
@@ -82,6 +82,13 @@ import {
|
||||
} from "../services/nexprintSessionService";
|
||||
import { checkThangsAuth, refreshThangsToken, ThangsAuthError } from "../services/thangsApi";
|
||||
import { getThangsConnection, normalizeThangsSession, setUserThangsSession } from "../services/thangsSessionService";
|
||||
import { checkCults3dCredentials } from "../services/cults3dApi";
|
||||
import {
|
||||
getCults3dConnection,
|
||||
normalizeCults3dBrowser,
|
||||
setUserCults3dBrowser,
|
||||
setUserCults3dCredentials,
|
||||
} from "../services/cults3dSessionService";
|
||||
import { SLICER_IDS, getUserSlicer, setUserSlicer } from "../services/slicerPreferenceService";
|
||||
import { CATEGORIES_VIEWS, getCategoriesView, setCategoriesView } from "../services/categoriesViewService";
|
||||
import {
|
||||
@@ -867,6 +874,79 @@ router.patch(
|
||||
}),
|
||||
);
|
||||
|
||||
// Per-user: Cults3D's API credentials, the user's nickname and an API key made on the site, and the browser's sign-in,
|
||||
// which Thingport Grab sends whenever it changes.
|
||||
router.get(
|
||||
"/settings/cults3d",
|
||||
asyncHandler(async (req, res) => {
|
||||
const connection = await getCults3dConnection(req.userId!);
|
||||
res.json({
|
||||
configured: connection.api !== "disconnected",
|
||||
expired: connection.api === "expired",
|
||||
nick: connection.nick,
|
||||
session_configured: connection.site !== "disconnected",
|
||||
session_expired: connection.site === "expired",
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const cults3dCredentialsSchema = z.object({
|
||||
nick: z.string().trim().max(100).nullable(),
|
||||
api_key: z.string().trim().max(200).nullable(),
|
||||
verify: z.boolean().optional(),
|
||||
});
|
||||
router.patch(
|
||||
"/settings/cults3d",
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = parseBody(cults3dCredentialsSchema, req.body);
|
||||
if (!body.nick || !body.api_key) {
|
||||
if (body.nick || body.api_key) throw new HttpError(422, "Enter both your Cults3D nickname and the API key.");
|
||||
await setUserCults3dCredentials(req.userId!, null);
|
||||
res.json({ configured: false, nick: null });
|
||||
return;
|
||||
}
|
||||
let nick = body.nick.replace(/^@/, "");
|
||||
if (body.verify) {
|
||||
const check = await checkCults3dCredentials({ nick, apiKey: body.api_key });
|
||||
if (check === "rejected") {
|
||||
throw new HttpError(
|
||||
422,
|
||||
"Cults3D didn't accept this nickname and API key. Check both -- the nickname's capitals count -- or make a " +
|
||||
"new key at cults3d.com/en/api/keys.",
|
||||
);
|
||||
}
|
||||
// 503, not 422: nothing is known to be wrong with them.
|
||||
if (check === "unreachable")
|
||||
throw new HttpError(503, "Couldn't reach Cults3D to check them. Try again in a moment.");
|
||||
nick = check.nick;
|
||||
}
|
||||
await setUserCults3dCredentials(req.userId!, { nick, apiKey: body.api_key });
|
||||
res.json({ configured: true, nick });
|
||||
}),
|
||||
);
|
||||
|
||||
// `session`: a Cookie header with _session_id (and cf_clearance), or a signed-in cults3d.com request's copied headers;
|
||||
// `user_agent` the browser's, when `session` doesn't carry it. Null forgets it.
|
||||
const cults3dSessionSchema = z.object({
|
||||
session: z.string().max(20_000).nullable(),
|
||||
user_agent: z.string().max(1000).nullable().optional(),
|
||||
});
|
||||
router.patch(
|
||||
"/settings/cults3d-session",
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = parseBody(cults3dSessionSchema, req.body);
|
||||
const browser = normalizeCults3dBrowser(body.session, body.user_agent);
|
||||
if (body.session?.trim() && !browser) {
|
||||
throw new HttpError(
|
||||
422,
|
||||
"That doesn't look like a Cults3D sign-in: paste the request headers of a signed-in cults3d.com page, with its _session_id cookie.",
|
||||
);
|
||||
}
|
||||
await setUserCults3dBrowser(req.userId!, browser);
|
||||
res.json({ configured: !!browser });
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/settings/slicer",
|
||||
asyncHandler(async (req, res) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Starter category tree modeled on MakerWorld's, with matching Thingiverse, Printables, MyMiniFactory, Snapmaker,
|
||||
* MakerOnline, Creality Cloud, NexPrint and Thangs ids so imports are auto-categorized from the start. NexPrint's are
|
||||
* text: they're 16 digits long. Thangs has no ids, so its are category paths ("Parent/Child"). */
|
||||
* MakerOnline, Creality Cloud, NexPrint, Thangs and Cults3D ids so imports are auto-categorized from the start. NexPrint's
|
||||
* are text: they're 16 digits long. Thangs has no ids, so its are category paths ("Parent/Child"). */
|
||||
export type DefaultCategoryNode = {
|
||||
name: string;
|
||||
tags?: string[];
|
||||
@@ -15,6 +15,7 @@ export type DefaultCategoryNode = {
|
||||
crealitycloudCatIds?: number[];
|
||||
nexprintCatIds?: string[];
|
||||
thangsCatIds?: string[];
|
||||
cults3dCatIds?: number[];
|
||||
children?: DefaultCategoryNode[];
|
||||
};
|
||||
|
||||
@@ -53,6 +54,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
"3D Printer Parts & Accessories/Maintenance Tools",
|
||||
"3D Printer Parts & Accessories/Print Bed Accessories",
|
||||
],
|
||||
cults3dCatIds: [101],
|
||||
},
|
||||
{
|
||||
name: "3D Printer Parts",
|
||||
@@ -101,6 +103,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
crealitycloudCatIds: [1670],
|
||||
nexprintCatIds: ["1422473858908165"],
|
||||
thangsCatIds: ["Art & Decor"],
|
||||
cults3dCatIds: [23],
|
||||
children: [
|
||||
{
|
||||
name: "2D Art",
|
||||
@@ -127,6 +130,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
crealitycloudCatIds: [1341],
|
||||
nexprintCatIds: ["1422473859022853"],
|
||||
thangsCatIds: ["Art & Decor/Trophies & Awards"],
|
||||
cults3dCatIds: [36, 51],
|
||||
},
|
||||
{
|
||||
name: "Signs & Logos",
|
||||
@@ -138,6 +142,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
myminifactoryCatIds: [105],
|
||||
makeronlineCatIds: [73],
|
||||
nexprintCatIds: ["1422473859022855"],
|
||||
cults3dCatIds: [44],
|
||||
},
|
||||
{
|
||||
name: "Sculptures",
|
||||
@@ -153,6 +158,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
crealitycloudCatIds: [1584],
|
||||
nexprintCatIds: ["1422473859022854"],
|
||||
thangsCatIds: ["Art & Decor/Sculptures & Statues", "Art & Decor/Busts"],
|
||||
cults3dCatIds: [43, 42],
|
||||
},
|
||||
{
|
||||
name: "Other Art Models",
|
||||
@@ -167,6 +173,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
crealitycloudCatIds: [1997, 6005],
|
||||
nexprintCatIds: ["1422473859022851", "1422473859022852", "1422473859022857"],
|
||||
thangsCatIds: ["Art & Decor/Textured & Patterned"],
|
||||
cults3dCatIds: [37, 38, 39, 35],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -183,6 +190,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
makeronlineCatIds: [82],
|
||||
crealitycloudCatIds: [1501],
|
||||
thangsCatIds: ["Educational & Scientific"],
|
||||
cults3dCatIds: [116],
|
||||
children: [
|
||||
{
|
||||
name: "Biology",
|
||||
@@ -286,6 +294,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
crealitycloudCatIds: [1175],
|
||||
nexprintCatIds: ["1422473858908164"],
|
||||
thangsCatIds: ["Fashion & Jewelry"],
|
||||
cults3dCatIds: [24],
|
||||
children: [
|
||||
{
|
||||
name: "Bags",
|
||||
@@ -297,6 +306,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
snapmakerCatIds: [135],
|
||||
makeronlineCatIds: [80],
|
||||
nexprintCatIds: ["1422473859014676"],
|
||||
cults3dCatIds: [49],
|
||||
},
|
||||
{
|
||||
name: "Clothes",
|
||||
@@ -321,6 +331,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
thingiverseCatIds: [139],
|
||||
myminifactoryCatIds: [405],
|
||||
thangsCatIds: ["Fashion & Jewelry/Earrings"],
|
||||
cults3dCatIds: [53],
|
||||
},
|
||||
{
|
||||
name: "Footwear",
|
||||
@@ -331,6 +342,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
myminifactoryCatIds: [140],
|
||||
snapmakerCatIds: [137],
|
||||
nexprintCatIds: ["1422473859022848"],
|
||||
cults3dCatIds: [122],
|
||||
},
|
||||
{
|
||||
name: "Glasses",
|
||||
@@ -340,6 +352,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
makerworldCatIds: [203],
|
||||
thingiverseCatIds: [83],
|
||||
myminifactoryCatIds: [147],
|
||||
cults3dCatIds: [47],
|
||||
},
|
||||
{
|
||||
name: "Jewelry",
|
||||
@@ -357,6 +370,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
"Fashion & Jewelry/Bracelets",
|
||||
"Fashion & Jewelry/Jewelry Storage",
|
||||
],
|
||||
cults3dCatIds: [26, 50, 55, 52],
|
||||
},
|
||||
{
|
||||
name: "Rings",
|
||||
@@ -367,6 +381,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
thingiverseCatIds: [85],
|
||||
myminifactoryCatIds: [406],
|
||||
thangsCatIds: ["Fashion & Jewelry/Rings"],
|
||||
cults3dCatIds: [56],
|
||||
},
|
||||
{
|
||||
name: "Other Fashion Models",
|
||||
@@ -398,6 +413,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
crealitycloudCatIds: [1519],
|
||||
nexprintCatIds: ["1422473858908163"],
|
||||
thangsCatIds: ["Hobby & DIY"],
|
||||
cults3dCatIds: [102],
|
||||
children: [
|
||||
{
|
||||
name: "Electronics",
|
||||
@@ -422,6 +438,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
"1422473859014660",
|
||||
],
|
||||
thangsCatIds: ["Hobby & DIY/Electronics"],
|
||||
cults3dCatIds: [84],
|
||||
},
|
||||
{
|
||||
name: "Music",
|
||||
@@ -437,6 +454,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
crealitycloudCatIds: [1420],
|
||||
nexprintCatIds: ["1422473859022856"],
|
||||
thangsCatIds: ["Hobby & DIY/Music & Audio"],
|
||||
cults3dCatIds: [78],
|
||||
},
|
||||
{
|
||||
name: "RC",
|
||||
@@ -453,6 +471,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
makeronlineCatIds: [51],
|
||||
nexprintCatIds: ["1422473859014656"],
|
||||
thangsCatIds: ["Hobby & DIY/RC"],
|
||||
cults3dCatIds: [97, 83],
|
||||
},
|
||||
{
|
||||
name: "Robotics",
|
||||
@@ -467,6 +486,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
makeronlineCatIds: [52],
|
||||
crealitycloudCatIds: [1648],
|
||||
thangsCatIds: ["Hobby & DIY/Robotics"],
|
||||
cults3dCatIds: [41],
|
||||
},
|
||||
{
|
||||
name: "Sport & Outdoors",
|
||||
@@ -482,6 +502,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
crealitycloudCatIds: [1246],
|
||||
nexprintCatIds: ["1422473859014671"],
|
||||
thangsCatIds: ["Hobby & DIY/Outdoor", "Health & Fitness/Sports"],
|
||||
cults3dCatIds: [121, 89],
|
||||
},
|
||||
{
|
||||
name: "Vehicles",
|
||||
@@ -496,6 +517,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
crealitycloudCatIds: [1194],
|
||||
nexprintCatIds: ["1422473859014672"],
|
||||
thangsCatIds: ["Hobby & DIY/Automotive"],
|
||||
cults3dCatIds: [86, 8],
|
||||
},
|
||||
{
|
||||
name: "Other Hobby & DIY",
|
||||
@@ -510,6 +532,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
crealitycloudCatIds: [6004],
|
||||
nexprintCatIds: ["1422473859014673"],
|
||||
thangsCatIds: ["Hobby & DIY/Baking and Cooking", "Hobby & DIY/Photography & Video"],
|
||||
cults3dCatIds: [79, 118, 119],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -527,6 +550,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
crealitycloudCatIds: [1010],
|
||||
nexprintCatIds: ["1422473858908162"],
|
||||
thangsCatIds: ["Home & Garden"],
|
||||
cults3dCatIds: [30],
|
||||
children: [
|
||||
{
|
||||
name: "Decor",
|
||||
@@ -548,6 +572,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
"Art & Decor/Lamps & Lighting",
|
||||
"Art & Decor/Vases & Planters",
|
||||
],
|
||||
cults3dCatIds: [61, 64, 71, 70, 72],
|
||||
},
|
||||
{
|
||||
name: "Festivities",
|
||||
@@ -566,6 +591,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
"Seasonal/Halloween & Fall",
|
||||
"Seasonal/Holidays & Winter",
|
||||
],
|
||||
cults3dCatIds: [120],
|
||||
},
|
||||
{
|
||||
name: "Garden",
|
||||
@@ -580,6 +606,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
makeronlineCatIds: [39],
|
||||
nexprintCatIds: ["1422473859014668"],
|
||||
thangsCatIds: ["Home & Garden/Outdoor & Garden"],
|
||||
cults3dCatIds: [67],
|
||||
},
|
||||
{
|
||||
name: "Office",
|
||||
@@ -594,6 +621,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
makeronlineCatIds: [41],
|
||||
nexprintCatIds: ["1422473859014664"],
|
||||
thangsCatIds: ["Tools & Organizers/Office & Desk"],
|
||||
cults3dCatIds: [66],
|
||||
},
|
||||
{
|
||||
name: "Pets",
|
||||
@@ -608,6 +636,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
crealitycloudCatIds: [1151],
|
||||
nexprintCatIds: ["1422473859014669"],
|
||||
thangsCatIds: ["Home & Garden/Pet Accessories"],
|
||||
cults3dCatIds: [68],
|
||||
},
|
||||
{
|
||||
name: "Other Household Models",
|
||||
@@ -632,6 +661,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
"Health & Fitness/Wellness",
|
||||
"Health & Fitness/Accessibility",
|
||||
],
|
||||
cults3dCatIds: [63, 57, 60, 58, 59, 65, 69, 62, 113],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -649,6 +679,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
crealitycloudCatIds: [1952],
|
||||
nexprintCatIds: ["1422473859006464"],
|
||||
thangsCatIds: ["Miniatures & Tabletop"],
|
||||
cults3dCatIds: [98],
|
||||
children: [
|
||||
{
|
||||
name: "Animals",
|
||||
@@ -659,6 +690,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
thingiverseCatIds: [107],
|
||||
printablesCatIds: [61],
|
||||
snapmakerCatIds: [157],
|
||||
cults3dCatIds: [34],
|
||||
},
|
||||
{
|
||||
name: "Architecture",
|
||||
@@ -673,6 +705,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
makeronlineCatIds: [100],
|
||||
crealitycloudCatIds: [1846],
|
||||
thangsCatIds: ["Miniatures & Tabletop/Dioramas & Scenery"],
|
||||
cults3dCatIds: [32, 75, 73, 74, 76, 77, 115],
|
||||
},
|
||||
{
|
||||
name: "Creatures",
|
||||
@@ -698,6 +731,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
myminifactoryCatIds: [785, 780, 782, 783, 784, 1145],
|
||||
snapmakerCatIds: [156],
|
||||
makeronlineCatIds: [101],
|
||||
cults3dCatIds: [40],
|
||||
},
|
||||
{
|
||||
name: "Other Miniatures",
|
||||
@@ -760,6 +794,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
snapmakerCatIds: [122],
|
||||
makeronlineCatIds: [93],
|
||||
thangsCatIds: ["Costumes & Cosplay/Masks & Helmets"],
|
||||
cults3dCatIds: [48],
|
||||
},
|
||||
{
|
||||
name: "Cosplay Weapons",
|
||||
@@ -770,6 +805,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
myminifactoryCatIds: [542, 156, 304],
|
||||
snapmakerCatIds: [124],
|
||||
thangsCatIds: ["Costumes & Cosplay/Armor & Props"],
|
||||
cults3dCatIds: [46],
|
||||
},
|
||||
{
|
||||
name: "Other Props & Cosplays",
|
||||
@@ -798,6 +834,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
crealitycloudCatIds: [1671],
|
||||
nexprintCatIds: ["1422473858908166"],
|
||||
thangsCatIds: ["Tools & Organizers", "Functional Prints"],
|
||||
cults3dCatIds: [27],
|
||||
children: [
|
||||
{
|
||||
name: "Gadgets",
|
||||
@@ -809,6 +846,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
printablesCatIds: [21, 25, 27, 26, 28, 100, 140, 43],
|
||||
myminifactoryCatIds: [94, 330, 368, 388],
|
||||
makeronlineCatIds: [56],
|
||||
cults3dCatIds: [25, 85, 82, 81, 80],
|
||||
},
|
||||
{
|
||||
name: "Hand Tools",
|
||||
@@ -820,6 +858,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
snapmakerCatIds: [161],
|
||||
makeronlineCatIds: [60],
|
||||
thangsCatIds: ["Tools & Organizers/Utility & Tools", "Functional Prints/Tools"],
|
||||
cults3dCatIds: [103],
|
||||
},
|
||||
{
|
||||
name: "Machine Tools",
|
||||
@@ -831,6 +870,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
snapmakerCatIds: [165],
|
||||
makeronlineCatIds: [59],
|
||||
thangsCatIds: ["Functional Prints/Jigs & Fixtures"],
|
||||
cults3dCatIds: [104],
|
||||
},
|
||||
{
|
||||
name: "Measure Tools",
|
||||
@@ -852,6 +892,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
],
|
||||
makeronlineCatIds: [58],
|
||||
crealitycloudCatIds: [1160, 1192, 1765, 6001],
|
||||
cults3dCatIds: [117, 114],
|
||||
},
|
||||
{
|
||||
name: "Organizers",
|
||||
@@ -871,6 +912,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
"Tools & Organizers/Storage Solutions",
|
||||
"Tools & Organizers/Shop & Garage",
|
||||
],
|
||||
cults3dCatIds: [106],
|
||||
},
|
||||
{
|
||||
name: "Other Tools",
|
||||
@@ -888,6 +930,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
"Functional Prints/Replacement Parts",
|
||||
"Functional Prints/Customizable",
|
||||
],
|
||||
cults3dCatIds: [105],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -905,6 +948,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
crealitycloudCatIds: [1809],
|
||||
nexprintCatIds: ["1422473858908160"],
|
||||
thangsCatIds: ["Toys & Games"],
|
||||
cults3dCatIds: [31, 99],
|
||||
children: [
|
||||
{
|
||||
name: "Board Games",
|
||||
@@ -920,6 +964,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
crealitycloudCatIds: [1575],
|
||||
nexprintCatIds: ["1422473859006466"],
|
||||
thangsCatIds: ["Toys & Games/Board Game Accessories"],
|
||||
cults3dCatIds: [91],
|
||||
},
|
||||
{
|
||||
name: "Characters",
|
||||
@@ -934,6 +979,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
snapmakerCatIds: [171],
|
||||
makeronlineCatIds: [104, 69, 110],
|
||||
thangsCatIds: ["Toys & Games/Action Figures & Collectibles", "Toys & Games/Articulated"],
|
||||
cults3dCatIds: [87],
|
||||
},
|
||||
{
|
||||
name: "Outdoor Toys",
|
||||
@@ -958,6 +1004,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
snapmakerCatIds: [169],
|
||||
makeronlineCatIds: [65],
|
||||
thangsCatIds: ["Toys & Games/Puzzles"],
|
||||
cults3dCatIds: [93],
|
||||
},
|
||||
{
|
||||
name: "Construction Sets",
|
||||
@@ -971,6 +1018,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
makeronlineCatIds: [67],
|
||||
crealitycloudCatIds: [1141],
|
||||
nexprintCatIds: ["1422473859006465"],
|
||||
cults3dCatIds: [94],
|
||||
},
|
||||
{
|
||||
name: "Other Toys & Games",
|
||||
@@ -991,6 +1039,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
"Toys & Games/Keychains",
|
||||
"Toys & Games/Video Games",
|
||||
],
|
||||
cults3dCatIds: [90, 100, 88, 96, 92, 95, 54],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -1010,6 +1059,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
"Transform your favorite images into stunning 3D art pieces with Hueforge and create mesmerizing lithophanes that glow with light.",
|
||||
makerworldCatIds: [2001],
|
||||
thangsCatIds: ["Art & Decor/Hueforge", "Art & Decor/Lithophanes"],
|
||||
cults3dCatIds: [33],
|
||||
},
|
||||
{
|
||||
name: "Make My Sign",
|
||||
|
||||
@@ -23,6 +23,8 @@ import { resolveMakeronlineModel } from "./makeronlineApi";
|
||||
import { resolveCrealitycloudModel } from "./crealitycloudApi";
|
||||
import { resolveNexprintModel } from "./nexprintApi";
|
||||
import { resolveThangsModel } from "./thangsApi";
|
||||
import { resolveCults3dModel } from "./cults3dApi";
|
||||
import { getUserCults3dCredentials } from "./cults3dSessionService";
|
||||
import { getDisabledProviders } from "./providerSettingsService";
|
||||
|
||||
// Links models that only know their author by name: first by name matching, then by looking the
|
||||
@@ -38,6 +40,7 @@ const LOOKUP_PROVIDERS = [
|
||||
"crealitycloud",
|
||||
"nexprint",
|
||||
"thangs",
|
||||
"cults3d",
|
||||
] as const;
|
||||
type LookupProvider = (typeof LOOKUP_PROVIDERS)[number];
|
||||
|
||||
@@ -220,6 +223,13 @@ async function lookUpAuthor(
|
||||
await maybeSleep(IMPORT_COLLECTION_DELAY_MS);
|
||||
return (await resolveThangsModel(candidate.externalId))?.meta.author ?? null;
|
||||
}
|
||||
case "cults3d": {
|
||||
// Its API reads only with a user's own key: the model's importer's.
|
||||
const credentials = await getUserCults3dCredentials(candidate.userId);
|
||||
if (!credentials) return null;
|
||||
await maybeSleep(IMPORT_COLLECTION_DELAY_MS);
|
||||
return (await resolveCults3dModel(candidate.externalId, credentials))?.meta.author ?? null;
|
||||
}
|
||||
case "makerworld": {
|
||||
const cookie = await getUserMakerworldCookie(candidate.userId);
|
||||
const bearer = extractMakerworldBearerToken(cookie);
|
||||
|
||||
@@ -81,6 +81,7 @@ export async function seedDefaultCategories(tx: Prisma.TransactionClient, userId
|
||||
crealitycloudCatIds: node.crealitycloudCatIds ?? [],
|
||||
nexprintCatIds: node.nexprintCatIds ?? [],
|
||||
thangsCatIds: node.thangsCatIds ?? [],
|
||||
cults3dCatIds: node.cults3dCatIds ?? [],
|
||||
},
|
||||
});
|
||||
let i = 0;
|
||||
|
||||
@@ -73,6 +73,8 @@ import {
|
||||
markThangsSessionRejected,
|
||||
withThangsToken,
|
||||
} from "./thangsSessionService";
|
||||
import { Cults3dAuthError, fetchCults3dLikes, fetchCults3dOrders, fetchCults3dPrintlist } from "./cults3dApi";
|
||||
import { getCults3dConnection, getUserCults3dCredentials, markCults3dApiRejected } from "./cults3dSessionService";
|
||||
import { notifyGrabImportsQueued, queueGrabImport } from "./grabImportService";
|
||||
import { getSnapmakerConnection, getUserSnapmakerToken, markSnapmakerTokenRejected } from "./snapmakerSessionService";
|
||||
import {
|
||||
@@ -208,6 +210,18 @@ export async function fetchListing(
|
||||
);
|
||||
return listing ? listing.entries.map((e) => e.modelId) : null;
|
||||
}
|
||||
if (sync.provider === "cults3d") {
|
||||
// Read with the user's API key (see connectionProblem). Paid models they own are kept in, the rest of the paid
|
||||
// ones left out.
|
||||
const credentials = await getUserCults3dCredentials(sync.userId);
|
||||
if (!credentials) throw new Cults3dAuthError();
|
||||
const owned = new Set((await fetchCults3dOrders(credentials)).keys());
|
||||
const likesOf = syncLikesUsername(sync);
|
||||
const listing = likesOf
|
||||
? await fetchCults3dLikes(credentials, likesOf, owned, Number.POSITIVE_INFINITY)
|
||||
: await fetchCults3dPrintlist(credentials, id, owned, Number.POSITIVE_INFINITY);
|
||||
return listing ? listing.entries.map((e) => e.slug) : null;
|
||||
}
|
||||
throw new Error(`Unknown provider "${sync.provider}"`);
|
||||
}
|
||||
|
||||
@@ -318,6 +332,26 @@ const CONNECTION_PROBLEMS = {
|
||||
reason: "Thangs turned your sign-in down; signing out there, or a year since signing in, ends it.",
|
||||
fix: "you sign in on thangs.com with Thingport Grab, or connect it again",
|
||||
},
|
||||
cults3dApiDisconnected: {
|
||||
error: "Cults3D isn't connected",
|
||||
reason: "Thingport reads Cults3D with your own API key, and none is set.",
|
||||
fix: "you enter your Cults3D nickname and API key",
|
||||
},
|
||||
cults3dApiExpired: {
|
||||
error: "Cults3D turned the API key down",
|
||||
reason: "Cults3D turned your API key down; deleting it on the site ends it.",
|
||||
fix: "you enter a working Cults3D API key",
|
||||
},
|
||||
cults3dSiteDisconnected: {
|
||||
error: "Cults3D sign-in missing",
|
||||
reason: "Cults3D gives its files to a signed-in user only, and Thingport Grab hasn't sent the browser's sign-in.",
|
||||
fix: "you open cults3d.com signed in with Thingport Grab, or paste its sign-in",
|
||||
},
|
||||
cults3dSiteExpired: {
|
||||
error: "Cults3D sign-in ran out",
|
||||
reason: "Cults3D turned your sign-in down; it ends 20 days after it was last used, or on signing out.",
|
||||
fix: "you sign in on cults3d.com with Thingport Grab, or paste its sign-in again",
|
||||
},
|
||||
} satisfies Record<string, ConnectionProblem>;
|
||||
|
||||
/** `"A"`, `"A" and "B"`, or the first few and how many more. */
|
||||
@@ -358,6 +392,13 @@ async function connectionProblem(
|
||||
if (connection === "disconnected") return CONNECTION_PROBLEMS.makerworldDisconnected;
|
||||
if (connection === "expired") return CONNECTION_PROBLEMS.makerworldExpired;
|
||||
}
|
||||
if (sync.provider === "cults3d") {
|
||||
const connection = await getCults3dConnection(sync.userId);
|
||||
if (connection.api === "disconnected") return CONNECTION_PROBLEMS.cults3dApiDisconnected;
|
||||
if (connection.api === "expired") return CONNECTION_PROBLEMS.cults3dApiExpired;
|
||||
if (connection.site === "disconnected") return CONNECTION_PROBLEMS.cults3dSiteDisconnected;
|
||||
if (connection.site === "expired") return CONNECTION_PROBLEMS.cults3dSiteExpired;
|
||||
}
|
||||
if (sync.provider === "thingiverse" && !(await getThingiverseAccessToken())) {
|
||||
return CONNECTION_PROBLEMS.thingiverseDisconnected;
|
||||
}
|
||||
@@ -475,6 +516,11 @@ async function runSync(syncId: string): Promise<SyncOutcome> {
|
||||
await holdProvider(userId, sync.provider, CONNECTION_PROBLEMS.thangsExpired);
|
||||
return "disconnected";
|
||||
}
|
||||
if (err instanceof Cults3dAuthError) {
|
||||
await markCults3dApiRejected(userId);
|
||||
await holdProvider(userId, sync.provider, CONNECTION_PROBLEMS.cults3dApiExpired);
|
||||
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
|
||||
|
||||
@@ -52,6 +52,15 @@ import {
|
||||
thangsModelUrl,
|
||||
} from "./thangsApi";
|
||||
import { getThangsConnection } from "./thangsSessionService";
|
||||
import {
|
||||
cults3dLikesKey,
|
||||
cults3dLikesUrl,
|
||||
cults3dModelUrl,
|
||||
parseCults3dLikesUrl,
|
||||
parseCults3dModelUrl,
|
||||
parseCults3dPrintlistUrl,
|
||||
} from "./cults3dApi";
|
||||
import { getCults3dConnection } from "./cults3dSessionService";
|
||||
import { createLog } from "./auditLog";
|
||||
import { pushedLikes } from "./providerListingsService";
|
||||
import { createNotification } from "./notificationService";
|
||||
@@ -73,9 +82,10 @@ export type SyncProvider =
|
||||
| "makeronline"
|
||||
| "crealitycloud"
|
||||
| "nexprint"
|
||||
| "thangs";
|
||||
| "thangs"
|
||||
| "cults3d";
|
||||
|
||||
/** `likesOf` is set for a user's likes (on Thingiverse, or the user's own on Printables), which a
|
||||
/** `likesOf` is set for a user's likes (on Thingiverse or Cults3D, or the user's own on Printables), which a
|
||||
* sync follows like a collection. */
|
||||
export type SyncSource = { provider: SyncProvider; externalId: string; url: string; likesOf?: string };
|
||||
|
||||
@@ -89,6 +99,7 @@ const PROVIDER_NAMES: Record<SyncProvider, string> = {
|
||||
crealitycloud: "Creality Cloud",
|
||||
nexprint: "NexPrint",
|
||||
thangs: "Thangs",
|
||||
cults3d: "Cults3D",
|
||||
};
|
||||
|
||||
/** Providers whose models come with print profiles a sync can pick between. */
|
||||
@@ -186,6 +197,20 @@ export function parseSyncSource(url: string): SyncSource | null {
|
||||
// The page names no user: it's always the signed-in one's, read as the account Thingport has.
|
||||
return { provider: "thangs", externalId: THANGS_LIKES_KEY, url: THANGS_LIKES_URL };
|
||||
}
|
||||
const cults3dPrintlist = parseCults3dPrintlistUrl(url);
|
||||
if (cults3dPrintlist) {
|
||||
return { provider: "cults3d", externalId: cults3dPrintlist.key, url: cults3dPrintlist.url };
|
||||
}
|
||||
const cults3dLikes = parseCults3dLikesUrl(url);
|
||||
if (cults3dLikes) {
|
||||
// Anyone's, as on Thingiverse: Cults3D's API shows them to any key.
|
||||
return {
|
||||
provider: "cults3d",
|
||||
externalId: cults3dLikesKey(cults3dLikes.nick),
|
||||
url: cults3dLikesUrl(cults3dLikes.nick),
|
||||
likesOf: cults3dLikes.nick,
|
||||
};
|
||||
}
|
||||
const printablesLikes = parsePrintablesLikesUrl(url);
|
||||
if (printablesLikes) {
|
||||
// Only Thingport Grab can read these, as their owner (see providerListingsService.ts).
|
||||
@@ -219,6 +244,7 @@ export function modelUrlFor(provider: string, externalId: string): string {
|
||||
if (provider === "crealitycloud") return crealitycloudModelUrl(externalId);
|
||||
if (provider === "nexprint") return nexprintModelUrl(externalId);
|
||||
if (provider === "thangs") return thangsModelUrl(externalId);
|
||||
if (provider === "cults3d") return cults3dModelUrl(externalId);
|
||||
return `https://www.thingiverse.com/thing:${externalId}`;
|
||||
}
|
||||
|
||||
@@ -234,6 +260,8 @@ export function modelIdFromUrl(provider: string, url: string): string | null {
|
||||
nexprint: /\/models\/(G\d+)/i,
|
||||
thangs: /\/m\/(\d+)/,
|
||||
};
|
||||
// Cults3D's links name a model by its slug, in any of its languages.
|
||||
if (provider === "cults3d") return parseCults3dModelUrl(url)?.slug ?? null;
|
||||
const pattern = patterns[provider] ?? /thing:(\d+)/;
|
||||
return url.match(pattern)?.[1] ?? null;
|
||||
}
|
||||
@@ -304,7 +332,7 @@ export async function enableSync(userId: string, input: EnableSyncInput): Promis
|
||||
if (!source)
|
||||
throw new HttpError(
|
||||
400,
|
||||
"Only MakerWorld, Printables, Thingiverse, MyMiniFactory, MakerOnline, Creality Cloud, NexPrint and Thangs collections, Thingiverse or your own Printables, MakerOnline, Creality Cloud and Thangs likes, and your own Snapmaker Space likes and bookmarks, can be synced",
|
||||
"Only MakerWorld, Printables, Thingiverse, MyMiniFactory, MakerOnline, Creality Cloud, NexPrint and Thangs collections, your own Cults3D printlists, Thingiverse or Cults3D likes, your own Printables, MakerOnline, Creality Cloud and Thangs 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);
|
||||
@@ -312,6 +340,7 @@ export async function enableSync(userId: string, input: EnableSyncInput): Promis
|
||||
await assertMakeronlineConnected(userId);
|
||||
}
|
||||
if (source.provider === "thangs") await assertThangsConnected(userId);
|
||||
if (source.provider === "cults3d") await assertCults3dSource(userId, source);
|
||||
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))];
|
||||
@@ -456,6 +485,17 @@ async function assertThangsConnected(userId: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Cults3D's listings are read with the user's API key, which shows no one's printlists but their own. */
|
||||
async function assertCults3dSource(userId: string, source: SyncSource): Promise<void> {
|
||||
const connection = await getCults3dConnection(userId);
|
||||
if (connection.api !== "connected") {
|
||||
throw new HttpError(400, "Cults3D is read with your own API key: enter it in Configuration > Providers first");
|
||||
}
|
||||
if (!source.likesOf && !source.externalId.startsWith(`${connection.nick?.toLowerCase()}/`)) {
|
||||
throw new HttpError(400, "Only your own Cults3D printlists can be synced: Cults3D's API shows no one else's");
|
||||
}
|
||||
}
|
||||
|
||||
/** 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> {
|
||||
|
||||
@@ -0,0 +1,779 @@
|
||||
import { IMPORT_TIMEOUT_SECONDS } from "../config";
|
||||
import { sleep } from "../utils/concurrency";
|
||||
import type { ImportedAuthorInfo, ImportedPageMetadata } from "./importResolvers";
|
||||
import type { ProviderCollectionCard } from "./providerCollectionCard";
|
||||
import { isCloudflareChallenge } from "./flaresolverr";
|
||||
|
||||
// Cults3D has two doors. Its GraphQL API, with the user's nickname and an API key they make on the site, reads models,
|
||||
// likes, their own printlists and their orders, and isn't behind Cloudflare. It never hands out files, though: those
|
||||
// are on the site's own pages, behind a Cloudflare challenge that no client Thingport has gets past. What does is the
|
||||
// browser's own cf_clearance cookie, sent with the User-Agent it was issued to, from the browser's IP address; Thingport
|
||||
// Grab sends it along with the _session_id sign-in. With them, a free model is "ordered" for nothing, as its Download
|
||||
// button does, and an order line's download page redirects to a signed CloudFront link that needs nothing at all.
|
||||
const SITE_ORIGIN = "https://cults3d.com";
|
||||
const GRAPHQL_URL = `${SITE_ORIGIN}/graphql`;
|
||||
const API_TIMEOUT_MS = IMPORT_TIMEOUT_SECONDS * 1000;
|
||||
const PROVIDER = "cults3d";
|
||||
// The API allows 1 to 100 per page.
|
||||
const PAGE_SIZE = 100;
|
||||
// The API's rate limit is per minute; a wait past that means something else is wrong.
|
||||
const MAX_RATE_LIMIT_WAIT_MS = 65_000;
|
||||
const RATE_LIMIT_RETRIES = 2;
|
||||
|
||||
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 records(value: unknown): Record<string, unknown>[] {
|
||||
return Array.isArray(value) ? value.filter(isRecord) : [];
|
||||
}
|
||||
|
||||
function count(value: unknown): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
/** The user's API access: their Cults3D nickname and an API key made on the site. */
|
||||
export type Cults3dCredentials = { nick: string; apiKey: string };
|
||||
|
||||
/** The browser's sign-in and Cloudflare clearance, as Thingport Grab sends them. */
|
||||
export type Cults3dBrowser = { session: string; clearance: string | null; userAgent: string | null };
|
||||
|
||||
export class Cults3dAuthError extends Error {
|
||||
constructor() {
|
||||
super(
|
||||
"Cults3D didn't accept your nickname and API key. Check them, or make a new key on cults3d.com, in " +
|
||||
"Configuration > Providers.",
|
||||
);
|
||||
this.name = "Cults3dAuthError";
|
||||
}
|
||||
}
|
||||
|
||||
/** The API's rate limit, still in place after waiting it out. */
|
||||
export class Cults3dRateLimitError extends Error {
|
||||
constructor() {
|
||||
super("Cults3D's API rate limit was reached. It clears within a minute or so -- wait, then try again.");
|
||||
this.name = "Cults3dRateLimitError";
|
||||
}
|
||||
}
|
||||
|
||||
/** The site's sign-in page instead of the user's: the session ended, or Grab never sent one. */
|
||||
export class Cults3dSessionError extends Error {
|
||||
constructor() {
|
||||
super(
|
||||
"Cults3D's site didn't accept your sign-in; it may have expired. Visit cults3d.com signed in with Thingport " +
|
||||
"Grab, then try again.",
|
||||
);
|
||||
this.name = "Cults3dSessionError";
|
||||
}
|
||||
}
|
||||
|
||||
/** The site's Cloudflare challenged Thingport: the clearance ran out, or Thingport isn't online from the browser's IP
|
||||
* address, which the clearance is tied to. */
|
||||
export class Cults3dBlockedError extends Error {
|
||||
constructor() {
|
||||
super(
|
||||
"Cults3D's Cloudflare turned Thingport away from its download pages. Visit cults3d.com with Thingport Grab to " +
|
||||
"renew its clearance, then try again. It only works when Thingport reaches the internet from the same " +
|
||||
"address as that browser.",
|
||||
);
|
||||
this.name = "Cults3dBlockedError";
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Links ----
|
||||
|
||||
function parseUrl(url: string): URL | null {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return /^(www\.)?cults3d\.com$/i.test(parsed.hostname) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The path's segments, decoded: the site writes them in each language, accents and all ("modèle-3d"). */
|
||||
function segments(parsed: URL): string[] {
|
||||
return parsed.pathname
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.map((part) => {
|
||||
try {
|
||||
return decodeURIComponent(part).toLowerCase();
|
||||
} catch {
|
||||
return part.toLowerCase();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// What each of the site's languages calls a model page, a user and their likes, and printlists (which the site calls
|
||||
// "design collections").
|
||||
const MODEL_WORDS = new Set([
|
||||
"3d-model",
|
||||
"modell-3d",
|
||||
"modelo-3d",
|
||||
"modèle-3d",
|
||||
"modello-3d",
|
||||
"3d-moderu",
|
||||
"3d-móxíng",
|
||||
]);
|
||||
const LIKES_WORDS = new Set(["likes", "mi-piace", "iine-ichiran", "curtidas", "laiki", "beğeniler", "xǐhuān"]);
|
||||
const PRINTLIST_WORDS = new Set([
|
||||
"design-collections",
|
||||
"drucklisten",
|
||||
"colecciones-de-diseños",
|
||||
"collections-de-designs",
|
||||
"raccolta-progetti",
|
||||
"dezain-korekushon",
|
||||
"designs-colecoes",
|
||||
"dizain-kollektsii",
|
||||
"tasarım-koleksiyonlar",
|
||||
"shèjì-xìliè",
|
||||
]);
|
||||
// `/en/creations/<slug>` redirects to the model, but these are lists of them.
|
||||
const CREATIONS_PAGES = new Set(["latest", "popular", "selected", "trending", "best-sellers", "random", "mine", "new"]);
|
||||
const SLUG = /^[a-z0-9][a-z0-9_-]*$/i;
|
||||
const LOCALE = /^[a-z]{2}$/;
|
||||
|
||||
/** A model page: `/<locale>/3d-model/<category>/<slug>`, in any of the site's languages, or `/<locale>/creations/<slug>`.
|
||||
* A model is known by its slug, the only id its links carry. */
|
||||
export function parseCults3dModelUrl(url: string): { slug: string } | null {
|
||||
const parsed = parseUrl(url);
|
||||
if (!parsed) return null;
|
||||
const parts = segments(parsed);
|
||||
if (!LOCALE.test(parts[0] ?? "")) return null;
|
||||
if (MODEL_WORDS.has(parts[1]) && parts.length >= 4 && SLUG.test(parts[3])) return { slug: parts[3] };
|
||||
if (parts[1] === "creations" && parts.length === 3 && SLUG.test(parts[2]) && !CREATIONS_PAGES.has(parts[2])) {
|
||||
return { slug: parts[2] };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The site redirects this to the model's page, whatever its category. */
|
||||
export function cults3dModelUrl(slug: string): string {
|
||||
return `${SITE_ORIGIN}/en/creations/${encodeURIComponent(slug)}`;
|
||||
}
|
||||
|
||||
const NICK = /^[\w.-]+$/;
|
||||
|
||||
/** A user's likes: `/<locale>/users/<nick>/likes`, in any of the site's languages. */
|
||||
export function parseCults3dLikesUrl(url: string): { nick: string } | null {
|
||||
const parsed = parseUrl(url);
|
||||
if (!parsed) return null;
|
||||
// Nicknames keep their case, so they're read from the path as it is.
|
||||
const raw = parsed.pathname.split("/").filter(Boolean);
|
||||
const parts = segments(parsed);
|
||||
if (parts.length !== 4 || !LOCALE.test(parts[0]) || !LIKES_WORDS.has(parts[3])) return null;
|
||||
const nick = decodeURIComponent(raw[2]);
|
||||
return NICK.test(nick) ? { nick } : null;
|
||||
}
|
||||
|
||||
export function cults3dLikesUrl(nick: string): string {
|
||||
return `${SITE_ORIGIN}/en/users/${encodeURIComponent(nick)}/likes`;
|
||||
}
|
||||
|
||||
/** A sync of a user's likes is known by this, as Thingiverse's are; nicknames aren't case-sensitive. */
|
||||
export function cults3dLikesKey(nick: string): string {
|
||||
return `likes:${nick.toLowerCase()}`;
|
||||
}
|
||||
|
||||
/** As likesCollectionName names Thingiverse's. */
|
||||
export function cults3dLikesName(nick: string): string {
|
||||
return `Cults3D likes (@${nick})`;
|
||||
}
|
||||
|
||||
/** A printlist, as the site calls collections in its API ("design collections" on its pages):
|
||||
* `/<locale>/design-collections/<nick>/<slug>`. `key`, what a sync knows it by, is "<nick>/<slug>" in lower case. */
|
||||
export function parseCults3dPrintlistUrl(url: string): { nick: string; slug: string; key: string; url: string } | null {
|
||||
const parsed = parseUrl(url);
|
||||
if (!parsed) return null;
|
||||
const raw = parsed.pathname.split("/").filter(Boolean);
|
||||
const parts = segments(parsed);
|
||||
if (parts.length !== 4 || !LOCALE.test(parts[0]) || !PRINTLIST_WORDS.has(parts[1])) return null;
|
||||
const nick = decodeURIComponent(raw[2]);
|
||||
const slug = decodeURIComponent(raw[3]);
|
||||
if (!NICK.test(nick) || !slug) return null;
|
||||
return { nick, slug, key: `${nick}/${slug}`.toLowerCase(), url: cults3dPrintlistUrl(nick, slug) };
|
||||
}
|
||||
|
||||
export function cults3dPrintlistUrl(nick: string, slug: string): string {
|
||||
return `${SITE_ORIGIN}/en/design-collections/${encodeURIComponent(nick)}/${encodeURIComponent(slug)}`;
|
||||
}
|
||||
|
||||
// Its own bucket on Scaleway's object storage, where older models' files are: the host is Scaleway's, shared with
|
||||
// everyone else's buckets, so a link there counts only in this one.
|
||||
const SCALEWAY_HOST = "s3.fr-par.scw.cloud";
|
||||
const SCALEWAY_BUCKET = "/cults-stl-prod/";
|
||||
|
||||
/** The hosts of the signed links the site's download pages redirect to: its CloudFront one, and Scaleway's. */
|
||||
export function isCults3dFileHost(hostname: string): boolean {
|
||||
const host = hostname.toLowerCase();
|
||||
return host === "download.cults3d.com" || host === SCALEWAY_HOST;
|
||||
}
|
||||
|
||||
/** A signed link to the model's files: on CloudFront, or in Cults3D's own bucket on Scaleway. */
|
||||
export function isCults3dFileUrl(url: URL): boolean {
|
||||
const host = url.hostname.toLowerCase();
|
||||
if (host === "download.cults3d.com") return true;
|
||||
return host === SCALEWAY_HOST && url.pathname.startsWith(SCALEWAY_BUCKET);
|
||||
}
|
||||
|
||||
// ---- The API ----
|
||||
|
||||
async function postGraphql(
|
||||
credentials: Cults3dCredentials,
|
||||
query: string,
|
||||
variables: Record<string, unknown>,
|
||||
): Promise<Response> {
|
||||
const auth = Buffer.from(`${credentials.nick}:${credentials.apiKey}`).toString("base64");
|
||||
return fetch(GRAPHQL_URL, {
|
||||
method: "POST",
|
||||
headers: { Accept: "application/json", "Content-Type": "application/json", Authorization: `Basic ${auth}` },
|
||||
body: JSON.stringify({ query, variables }),
|
||||
signal: AbortSignal.timeout(API_TIMEOUT_MS),
|
||||
});
|
||||
}
|
||||
|
||||
/** How long until the rate limit resets, from its header; null when it doesn't say. */
|
||||
function rateLimitWait(headers: Headers): number | null {
|
||||
const reset = Date.parse(headers.get("x-ratelimit-reset") ?? "");
|
||||
if (!Number.isFinite(reset)) return null;
|
||||
return Math.max(0, reset - Date.now()) + 1000;
|
||||
}
|
||||
|
||||
/** One query's `data`. Throws Cults3dAuthError for credentials it turns down, Cults3dRateLimitError when its rate limit
|
||||
* outlasts a wait, and a plain Error for anything else, its message the API's own. */
|
||||
async function graphql(
|
||||
credentials: Cults3dCredentials,
|
||||
query: string,
|
||||
variables: Record<string, unknown> = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
let res = await postGraphql(credentials, query, variables);
|
||||
for (let retry = 1; retry <= RATE_LIMIT_RETRIES && res.status === 429; retry++) {
|
||||
await res.body?.cancel().catch(() => undefined);
|
||||
const wait = rateLimitWait(res.headers) ?? 15_000 * retry;
|
||||
if (wait > MAX_RATE_LIMIT_WAIT_MS) break;
|
||||
await sleep(wait);
|
||||
res = await postGraphql(credentials, query, variables);
|
||||
}
|
||||
if (res.status === 401) {
|
||||
await res.body?.cancel().catch(() => undefined);
|
||||
throw new Cults3dAuthError();
|
||||
}
|
||||
if (res.status === 429) {
|
||||
await res.body?.cancel().catch(() => undefined);
|
||||
throw new Cults3dRateLimitError();
|
||||
}
|
||||
const body: unknown = await res.json().catch(() => null);
|
||||
const errors = isRecord(body) ? records(body.errors) : [];
|
||||
if (!res.ok || !isRecord(body) || !isRecord(body.data)) {
|
||||
const message = errors.map((e) => str(e.message)).find(Boolean);
|
||||
throw new Error(`Cults3D's API failed${message ? `: ${message}` : ` (HTTP ${res.status})`}`);
|
||||
}
|
||||
return body.data;
|
||||
}
|
||||
|
||||
/** Whose the credentials are, or "rejected"/"unreachable". */
|
||||
export async function checkCults3dCredentials(
|
||||
credentials: Cults3dCredentials,
|
||||
): Promise<{ nick: string } | "rejected" | "unreachable"> {
|
||||
try {
|
||||
const data = await graphql(credentials, "{ myself { user { nick } } }");
|
||||
const nick = isRecord(data.myself) && isRecord(data.myself.user) ? str(data.myself.user.nick) : null;
|
||||
return nick ? { nick } : "unreachable";
|
||||
} catch (err) {
|
||||
return err instanceof Cults3dAuthError ? "rejected" : "unreachable";
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Models ----
|
||||
|
||||
const CREATION_FIELDS = `
|
||||
slug
|
||||
name
|
||||
description
|
||||
details
|
||||
tags
|
||||
url
|
||||
price(currency: EUR) { cents formatted }
|
||||
category { id }
|
||||
subCategories { id }
|
||||
illustrations { imageUrl }
|
||||
blueprints { fileName }
|
||||
creator { nick imageUrl bio url }
|
||||
`;
|
||||
|
||||
/** The site's GraphQL ids are base64 of "Type/<number>": "Q2F0ZWdvcnkvMjM" is Category 23. */
|
||||
export function cults3dNumericId(identifier: unknown): number | null {
|
||||
const value = str(identifier);
|
||||
if (!value) return null;
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = Buffer.from(value, "base64").toString("utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const m = decoded.match(/^[A-Za-z]+\/(\d+)$/);
|
||||
const n = m ? Number(m[1]) : NaN;
|
||||
return Number.isSafeInteger(n) && n > 0 ? n : null;
|
||||
}
|
||||
|
||||
/** The original upload, which needs nothing to download, rather than the API's 516-pixel copy of it. */
|
||||
export function cults3dOriginalImage(url: string): string {
|
||||
const original = url.match(/\/(https:\/\/fbi\.cults3d\.com\/[^?#]+)$/i)?.[1];
|
||||
return original ?? url;
|
||||
}
|
||||
|
||||
/** Designers put up videos among their pictures; an image can't be made of those. */
|
||||
function isPicture(url: string): boolean {
|
||||
return !/\.(mp4|webm|mov|m4v)$/i.test(url.split(/[?#]/)[0]);
|
||||
}
|
||||
|
||||
function imageName(url: string, index: number): string {
|
||||
let name = "";
|
||||
try {
|
||||
name = decodeURIComponent(new URL(url).pathname.split("/").pop() ?? "");
|
||||
} catch {}
|
||||
return name || `image-${index}.jpg`;
|
||||
}
|
||||
|
||||
function authorOf(creator: unknown): ImportedAuthorInfo | null {
|
||||
if (!isRecord(creator)) return null;
|
||||
const nick = str(creator.nick);
|
||||
if (!nick) return null;
|
||||
return {
|
||||
provider: PROVIDER,
|
||||
// Nicknames are what the site knows its users by, in links and its API alike.
|
||||
externalId: nick,
|
||||
name: nick,
|
||||
handle: nick,
|
||||
bio: str(creator.bio)?.replace(/\r\n?/g, "\n") ?? null,
|
||||
bioTranslated: null,
|
||||
links: [str(creator.url) ?? `${SITE_ORIGIN}/en/users/${encodeURIComponent(nick)}`],
|
||||
avatarUrl: str(creator.imageUrl),
|
||||
backgroundUrl: null,
|
||||
};
|
||||
}
|
||||
|
||||
export type Cults3dModelResolution = {
|
||||
meta: Partial<ImportedPageMetadata>;
|
||||
galleryImages: { url: string; filename: string }[];
|
||||
slug: string;
|
||||
/** In cents, 0 when free. */
|
||||
priceCents: number;
|
||||
/** As the site shows it, e.g. "€2.99"; null when free. */
|
||||
priceText: string | null;
|
||||
};
|
||||
|
||||
/** The metadata mapping, apart from the fetch so it can be tested on its own. */
|
||||
export function cults3dMetaFromCreation(creation: Record<string, unknown>): Cults3dModelResolution | null {
|
||||
const slug = str(creation.slug);
|
||||
if (!slug) return null;
|
||||
const meta: Partial<ImportedPageMetadata> = {};
|
||||
const title = str(creation.name);
|
||||
if (title) meta.title = title;
|
||||
// Plain text, as the site shows it; its print settings come after the description, under their own heading.
|
||||
const description = str(creation.description)?.replace(/\r\n?/g, "\n");
|
||||
const details = str(creation.details)?.replace(/\r\n?/g, "\n");
|
||||
const text = [description, details ? `### 3D printing settings\n\n${details}` : null].filter(Boolean).join("\n\n");
|
||||
if (text) meta.description = text;
|
||||
const tags = [...new Set((Array.isArray(creation.tags) ? creation.tags : []).flatMap((t) => str(t) ?? []))];
|
||||
if (tags.length) meta.tags = tags;
|
||||
const images = [
|
||||
...new Set(
|
||||
records(creation.illustrations)
|
||||
.flatMap((i) => (str(i.imageUrl) ? [cults3dOriginalImage(str(i.imageUrl)!)] : []))
|
||||
.filter(isPicture),
|
||||
),
|
||||
];
|
||||
if (images[0]) meta.previewImageUrl = images[0];
|
||||
const author = authorOf(creation.creator);
|
||||
if (author) {
|
||||
meta.author = author;
|
||||
meta.creator = author.name;
|
||||
}
|
||||
// Most specific first: a subcategory beats the category it's in.
|
||||
const categories = [
|
||||
...new Set(
|
||||
[...records(creation.subCategories), isRecord(creation.category) ? creation.category : null].flatMap((c) =>
|
||||
c ? (cults3dNumericId(c.id) ?? []) : [],
|
||||
),
|
||||
),
|
||||
];
|
||||
if (categories.length) {
|
||||
meta.siteCategoryIds = categories;
|
||||
meta.categorySite = PROVIDER;
|
||||
}
|
||||
const price = isRecord(creation.price) ? creation.price : null;
|
||||
const priceCents = count(price?.cents);
|
||||
return {
|
||||
meta,
|
||||
galleryImages: images.map((url, index) => ({ url, filename: imageName(url, index) })),
|
||||
slug,
|
||||
priceCents,
|
||||
priceText: priceCents > 0 ? (str(price?.formatted) ?? `€${(priceCents / 100).toFixed(2)}`) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** By its slug. Null for one that doesn't exist, or isn't public. */
|
||||
export async function resolveCults3dModel(
|
||||
slug: string,
|
||||
credentials: Cults3dCredentials,
|
||||
): Promise<Cults3dModelResolution | null> {
|
||||
const data = await graphql(credentials, `query ($slug: String!) { creation(slug: $slug) { ${CREATION_FIELDS} } }`, {
|
||||
slug,
|
||||
});
|
||||
return isRecord(data.creation) ? cults3dMetaFromCreation(data.creation) : null;
|
||||
}
|
||||
|
||||
// ---- Orders: what the user can download ----
|
||||
|
||||
/** Every model the user ordered, free ones included, by slug: the page each one's files are on. Cults3D's orders are
|
||||
* its library: a bought or downloaded model is in one for good. */
|
||||
export async function fetchCults3dOrders(credentials: Cults3dCredentials): Promise<Map<string, string>> {
|
||||
const lines = new Map<string, string>();
|
||||
for (let offset = 0; ; offset += PAGE_SIZE) {
|
||||
const data = await graphql(
|
||||
credentials,
|
||||
`
|
||||
query ($limit: Int!, $offset: Int!) {
|
||||
myself {
|
||||
ordersBatch(limit: $limit, offset: $offset) {
|
||||
total
|
||||
results {
|
||||
lines {
|
||||
downloadUrl
|
||||
creation {
|
||||
slug
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ limit: PAGE_SIZE, offset },
|
||||
);
|
||||
const batch = isRecord(data.myself) && isRecord(data.myself.ordersBatch) ? data.myself.ordersBatch : null;
|
||||
const orders = records(batch?.results);
|
||||
for (const order of orders) {
|
||||
for (const line of records(order.lines)) {
|
||||
const slug = isRecord(line.creation) ? str(line.creation.slug) : null;
|
||||
const url = str(line.downloadUrl);
|
||||
if (slug && url && !lines.has(slug)) lines.set(slug, url);
|
||||
}
|
||||
}
|
||||
if (orders.length < PAGE_SIZE || offset + PAGE_SIZE >= count(batch?.total)) break;
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
// ---- The site: free orders and downloads, with the browser's sign-in ----
|
||||
|
||||
const SITE_ACCEPT = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8";
|
||||
|
||||
/** One request to the site as the browser: its cookies and the User-Agent its clearance was issued to. Plain fetch on
|
||||
* purpose: the clearance is what gets past Cloudflare, and it only holds for that User-Agent. Redirects are the
|
||||
* caller's. */
|
||||
async function fetchSite(
|
||||
url: string,
|
||||
browser: Cults3dBrowser,
|
||||
{ method = "GET", body, referer }: { method?: "GET" | "POST"; body?: string; referer?: string } = {},
|
||||
): Promise<Response> {
|
||||
const cookie = [`_session_id=${browser.session}`, browser.clearance ? `cf_clearance=${browser.clearance}` : null]
|
||||
.filter(Boolean)
|
||||
.join("; ");
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
body,
|
||||
redirect: "manual",
|
||||
headers: {
|
||||
Accept: SITE_ACCEPT,
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
Cookie: cookie,
|
||||
...(browser.userAgent ? { "User-Agent": browser.userAgent } : {}),
|
||||
...(body ? { "Content-Type": "application/x-www-form-urlencoded", Origin: SITE_ORIGIN } : {}),
|
||||
...(referer ? { Referer: referer } : {}),
|
||||
},
|
||||
signal: AbortSignal.timeout(API_TIMEOUT_MS),
|
||||
});
|
||||
if (isCloudflareChallenge(res.headers)) {
|
||||
await res.body?.cancel().catch(() => undefined);
|
||||
throw new Cults3dBlockedError();
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
function locationOf(res: Response, base: string): string | null {
|
||||
const location = res.headers.get("location");
|
||||
if (!location) return null;
|
||||
try {
|
||||
return new URL(location, base).toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The site's sign-in pages, where it sends a request without a session it accepts. */
|
||||
function isSignInUrl(url: string): boolean {
|
||||
try {
|
||||
return /\/(log-in|users\/sign[_-]in)/i.test(new URL(url).pathname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** A page of the site, its redirects followed while they stay on it; throws Cults3dSessionError when they lead to its
|
||||
* sign-in. */
|
||||
async function fetchSitePage(url: string, browser: Cults3dBrowser): Promise<{ url: string; html: string }> {
|
||||
let current = url;
|
||||
for (let hop = 0; hop < 5; hop++) {
|
||||
const res = await fetchSite(current, browser);
|
||||
const next = locationOf(res, current);
|
||||
if (res.status >= 300 && res.status < 400 && next) {
|
||||
await res.body?.cancel().catch(() => undefined);
|
||||
if (isSignInUrl(next)) throw new Cults3dSessionError();
|
||||
if (!parseUrl(next)) throw new Error(`Cults3D sent its page elsewhere (${new URL(next).hostname})`);
|
||||
current = next;
|
||||
continue;
|
||||
}
|
||||
if (!res.ok) {
|
||||
await res.body?.cancel().catch(() => undefined);
|
||||
throw new Error(`Couldn't open the model's page on Cults3D (HTTP ${res.status})`);
|
||||
}
|
||||
return { url: current, html: await res.text() };
|
||||
}
|
||||
throw new Error("Cults3D redirected the model's page too many times");
|
||||
}
|
||||
|
||||
function decodeEntities(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
/** The free model's Download form: where it posts and its CSRF token. Null on a page without one -- a paid model, or
|
||||
* one the user signed out of. */
|
||||
export function cults3dFreeOrderForm(html: string): { action: string; token: string } | null {
|
||||
const form = html.match(/<form\b[^>]*\baction="(\/[a-z]{2}\/free_orders\?[^"]+)"[^>]*>([\s\S]*?)<\/form>/i);
|
||||
if (!form) return null;
|
||||
const token = form[2].match(/name="authenticity_token"\s+value="([^"]+)"/i)?.[1];
|
||||
return token ? { action: decodeEntities(form[1]), token: decodeEntities(token) } : null;
|
||||
}
|
||||
|
||||
/** Orders a free model for the user, as its Download button does, so its files are theirs to download. Throws
|
||||
* Cults3dSessionError when the page shows the user signed out. */
|
||||
export async function placeCults3dFreeOrder(slug: string, browser: Cults3dBrowser): Promise<void> {
|
||||
const page = await fetchSitePage(cults3dModelUrl(slug), browser);
|
||||
const form = cults3dFreeOrderForm(page.html);
|
||||
if (!form) throw new Error("Cults3D's page for this model has no free download");
|
||||
const res = await fetchSite(`${SITE_ORIGIN}${form.action}`, browser, {
|
||||
method: "POST",
|
||||
body: new URLSearchParams({ authenticity_token: form.token }).toString(),
|
||||
referer: page.url,
|
||||
});
|
||||
await res.body?.cancel().catch(() => undefined);
|
||||
const next = locationOf(res, SITE_ORIGIN);
|
||||
if (next && isSignInUrl(next)) throw new Cults3dSessionError();
|
||||
if (res.status === 422) throw new Cults3dSessionError();
|
||||
if (res.status >= 400) throw new Error(`Cults3D didn't take the free order (HTTP ${res.status})`);
|
||||
}
|
||||
|
||||
export type Cults3dFile = { name: string; url: string };
|
||||
|
||||
/** An order line's download page: the signed link to the model's files, a zip, it redirects to. */
|
||||
export async function resolveCults3dDownload(downloadUrl: string, browser: Cults3dBrowser): Promise<Cults3dFile> {
|
||||
if (!parseUrl(downloadUrl)) throw new Error("Not a Cults3D download link");
|
||||
const res = await fetchSite(downloadUrl, browser);
|
||||
await res.body?.cancel().catch(() => undefined);
|
||||
const next = locationOf(res, downloadUrl);
|
||||
if (!next || res.status < 300 || res.status >= 400) {
|
||||
throw new Error(`Cults3D didn't hand over the model's files (HTTP ${res.status})`);
|
||||
}
|
||||
if (isSignInUrl(next)) throw new Cults3dSessionError();
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(next);
|
||||
} catch {
|
||||
throw new Error("Cults3D sent the download somewhere unexpected");
|
||||
}
|
||||
if (!isCults3dFileUrl(parsed)) {
|
||||
throw new Error(`Cults3D sent the download somewhere unexpected (${parsed.hostname})`);
|
||||
}
|
||||
const name = decodeURIComponent(parsed.pathname.split("/").pop() ?? "") || "cults3d.zip";
|
||||
return { name, url: next };
|
||||
}
|
||||
|
||||
// ---- Listings ----
|
||||
|
||||
export type Cults3dModelSummary = { slug: string; title: string; cover: string | null };
|
||||
export type Cults3dListing = { entries: Cults3dModelSummary[]; total: number; truncated: boolean };
|
||||
|
||||
const LISTING_MAX_ENTRIES = 600;
|
||||
const CARD_THUMBNAILS = 4;
|
||||
const SUMMARY_FIELDS = "slug name illustrationImageUrl price(currency: EUR) { cents }";
|
||||
|
||||
/** What a listing shows of a model. Paid ones download only once bought (`owned`), so the rest are left out: a sync
|
||||
* would otherwise fail them on every round. */
|
||||
function summaryOf(creation: Record<string, unknown>, owned: Set<string>): Cults3dModelSummary | null {
|
||||
const slug = str(creation.slug);
|
||||
if (!slug) return null;
|
||||
const price = isRecord(creation.price) ? count(creation.price.cents) : 0;
|
||||
if (price > 0 && !owned.has(slug)) return null;
|
||||
const cover = str(creation.illustrationImageUrl);
|
||||
return { slug, title: str(creation.name) ?? slug, cover: cover && isPicture(cover) ? cover : null };
|
||||
}
|
||||
|
||||
function listingOf(creations: Record<string, unknown>[], owned: Set<string>, maxItems: number): Cults3dListing {
|
||||
const seen = new Set<string>();
|
||||
const entries: Cults3dModelSummary[] = [];
|
||||
for (const creation of creations) {
|
||||
const entry = summaryOf(creation, owned);
|
||||
if (!entry || seen.has(entry.slug)) continue;
|
||||
seen.add(entry.slug);
|
||||
entries.push(entry);
|
||||
}
|
||||
return { entries: entries.slice(0, maxItems), total: entries.length, truncated: entries.length > maxItems };
|
||||
}
|
||||
|
||||
/** A user's liked models, which Cults3D shows anyone. Null for a user that doesn't exist. */
|
||||
export async function fetchCults3dLikes(
|
||||
credentials: Cults3dCredentials,
|
||||
nick: string,
|
||||
owned: Set<string>,
|
||||
maxItems: number = LISTING_MAX_ENTRIES,
|
||||
): Promise<Cults3dListing | null> {
|
||||
const creations: Record<string, unknown>[] = [];
|
||||
// The likes come without a total: a short page is the last.
|
||||
for (let offset = 0; creations.length < maxItems; offset += PAGE_SIZE) {
|
||||
const data = await graphql(
|
||||
credentials,
|
||||
`query ($nick: String!, $limit: Int!, $offset: Int!) {
|
||||
user(nick: $nick) { likedCreations(limit: $limit, offset: $offset) { ${SUMMARY_FIELDS} } }
|
||||
}`,
|
||||
{ nick, limit: PAGE_SIZE, offset },
|
||||
);
|
||||
if (!isRecord(data.user)) return null;
|
||||
const page = records(data.user.likedCreations);
|
||||
creations.push(...page);
|
||||
if (page.length < PAGE_SIZE) break;
|
||||
}
|
||||
return listingOf(creations, owned, maxItems);
|
||||
}
|
||||
|
||||
export type Cults3dPrintlistInfo = {
|
||||
id: string;
|
||||
name: string;
|
||||
nick: string;
|
||||
slug: string;
|
||||
url: string;
|
||||
public: boolean;
|
||||
modelCount: number;
|
||||
/** Its first few models' pictures. */
|
||||
thumbnails: string[];
|
||||
};
|
||||
|
||||
/** The user's own printlists. Cults3D's API shows no one else's. */
|
||||
export async function fetchOwnCults3dPrintlists(credentials: Cults3dCredentials): Promise<Cults3dPrintlistInfo[]> {
|
||||
const out: Cults3dPrintlistInfo[] = [];
|
||||
for (let offset = 0; ; offset += PAGE_SIZE) {
|
||||
const data = await graphql(
|
||||
credentials,
|
||||
`query ($limit: Int!, $offset: Int!) {
|
||||
myself { printlistsBatch(limit: $limit, offset: $offset) { total results {
|
||||
id name url public creationsCount creationsBatch(limit: ${CARD_THUMBNAILS}) { results { illustrationImageUrl } }
|
||||
} } }
|
||||
}`,
|
||||
{ limit: PAGE_SIZE, offset },
|
||||
);
|
||||
const batch = isRecord(data.myself) && isRecord(data.myself.printlistsBatch) ? data.myself.printlistsBatch : null;
|
||||
const results = records(batch?.results);
|
||||
for (const printlist of results) {
|
||||
const id = str(printlist.id);
|
||||
const parsed = parseCults3dPrintlistUrl(str(printlist.url) ?? "");
|
||||
if (!id || !parsed) continue;
|
||||
out.push({
|
||||
id,
|
||||
name: str(printlist.name) ?? parsed.slug,
|
||||
nick: parsed.nick,
|
||||
slug: parsed.slug,
|
||||
url: parsed.url,
|
||||
public: printlist.public === true,
|
||||
modelCount: count(printlist.creationsCount),
|
||||
thumbnails: records(isRecord(printlist.creationsBatch) ? printlist.creationsBatch.results : null)
|
||||
.flatMap((creation) => str(creation.illustrationImageUrl) ?? [])
|
||||
.filter(isPicture),
|
||||
});
|
||||
}
|
||||
if (results.length < PAGE_SIZE || offset + PAGE_SIZE >= count(batch?.total)) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** One of the user's own printlists, by its "<nick>/<slug>" key, and its models. Null for one they don't have. */
|
||||
export async function fetchCults3dPrintlist(
|
||||
credentials: Cults3dCredentials,
|
||||
key: string,
|
||||
owned: Set<string>,
|
||||
maxItems: number = LISTING_MAX_ENTRIES,
|
||||
): Promise<(Cults3dListing & { info: Cults3dPrintlistInfo }) | null> {
|
||||
const info = (await fetchOwnCults3dPrintlists(credentials)).find(
|
||||
(printlist) => `${printlist.nick}/${printlist.slug}`.toLowerCase() === key.toLowerCase(),
|
||||
);
|
||||
if (!info) return null;
|
||||
const creations: Record<string, unknown>[] = [];
|
||||
for (let offset = 0; creations.length < maxItems; offset += PAGE_SIZE) {
|
||||
const data = await graphql(
|
||||
credentials,
|
||||
`query ($limit: Int!, $offset: Int!) {
|
||||
myself { printlistsBatch(limit: 100) { results { id creationsBatch(limit: $limit, offset: $offset) { total results { ${SUMMARY_FIELDS} } } } } }
|
||||
}`,
|
||||
{ limit: PAGE_SIZE, offset },
|
||||
);
|
||||
const printlist = records(
|
||||
isRecord(data.myself) && isRecord(data.myself.printlistsBatch) ? data.myself.printlistsBatch.results : null,
|
||||
).find((p) => str(p.id) === info.id);
|
||||
const batch = printlist && isRecord(printlist.creationsBatch) ? printlist.creationsBatch : null;
|
||||
const page = records(batch?.results);
|
||||
creations.push(...page);
|
||||
if (page.length < PAGE_SIZE || offset + PAGE_SIZE >= count(batch?.total)) break;
|
||||
}
|
||||
return { ...listingOf(creations, owned, maxItems), info };
|
||||
}
|
||||
|
||||
/** The user's likes, then their own printlists, as the sync dialog's Browse tab shows them. */
|
||||
export async function listCults3dCollections(
|
||||
credentials: Cults3dCredentials,
|
||||
owned: Set<string>,
|
||||
): Promise<ProviderCollectionCard[]> {
|
||||
const likes = await fetchCults3dLikes(credentials, credentials.nick, owned);
|
||||
const cards: ProviderCollectionCard[] = [
|
||||
{
|
||||
id: cults3dLikesKey(credentials.nick),
|
||||
name: cults3dLikesName(credentials.nick),
|
||||
url: cults3dLikesUrl(credentials.nick),
|
||||
modelCount: likes?.total ?? 0,
|
||||
thumbnails: (likes?.entries ?? []).slice(0, CARD_THUMBNAILS).flatMap((entry) => entry.cover ?? []),
|
||||
private: false,
|
||||
owner: credentials.nick,
|
||||
},
|
||||
];
|
||||
for (const printlist of await fetchOwnCults3dPrintlists(credentials)) {
|
||||
cards.push({
|
||||
id: `${printlist.nick}/${printlist.slug}`.toLowerCase(),
|
||||
name: printlist.name,
|
||||
url: printlist.url,
|
||||
modelCount: printlist.modelCount,
|
||||
thumbnails: printlist.thumbnails,
|
||||
private: !printlist.public,
|
||||
owner: printlist.nick,
|
||||
});
|
||||
}
|
||||
return cards;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { prisma } from "../db";
|
||||
import type { Cults3dBrowser, Cults3dCredentials } from "./cults3dApi";
|
||||
|
||||
// Per-user, two things. The API credentials -- the user's Cults3D nickname and an API key they make on the site --
|
||||
// read models, likes, printlists and orders. The browser's sign-in -- its _session_id cookie, and the cf_clearance
|
||||
// cookie with the User-Agent it was issued to -- opens the site's order and download pages; Thingport Grab sends it
|
||||
// whenever the browser's changes, and pasting a signed-in request's headers works too. The session lasts 20 days from
|
||||
// its last use, and Thingport's own use renews it.
|
||||
|
||||
const SESSION = /(?:^|[;\s])_session_id=([0-9a-f]{16,128})(?=;|\s|$)/i;
|
||||
const CLEARANCE = /(?:^|[;\s])cf_clearance=([\w.~-]{20,4000})(?=;|\s|$)/i;
|
||||
// "User-Agent: ..." from copied headers, or Chrome's DevTools, which puts the name and the value on lines of their own.
|
||||
const USER_AGENT = /(?:^|\n)\s*user-agent\s*(?::\s*|\n\s*)(Mozilla\/[^\n]+)/i;
|
||||
|
||||
/** The sign-in in a Cookie header, or a signed-in request's copied headers. `userAgent` is the one to send with the
|
||||
* clearance when the text doesn't carry its own. Null without a session cookie. */
|
||||
export function normalizeCults3dBrowser(
|
||||
raw: string | null | undefined,
|
||||
userAgent?: string | null,
|
||||
): Cults3dBrowser | null {
|
||||
const text = raw ?? "";
|
||||
const session = text.match(SESSION)?.[1];
|
||||
if (!session) return null;
|
||||
const clearance = text.match(CLEARANCE)?.[1] ?? null;
|
||||
const agent = text.match(USER_AGENT)?.[1]?.trim() || userAgent?.trim() || null;
|
||||
return { session, clearance, userAgent: agent };
|
||||
}
|
||||
|
||||
const SELECT = {
|
||||
cults3dNick: true,
|
||||
cults3dApiKey: true,
|
||||
cults3dApiRejectedAt: true,
|
||||
cults3dSession: true,
|
||||
cults3dClearance: true,
|
||||
cults3dUserAgent: true,
|
||||
cults3dSessionRejectedAt: true,
|
||||
} as const;
|
||||
|
||||
/** New credentials clear an earlier rejection; null forgets them. */
|
||||
export async function setUserCults3dCredentials(userId: string, credentials: Cults3dCredentials | null): Promise<void> {
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
cults3dNick: credentials?.nick ?? null,
|
||||
cults3dApiKey: credentials?.apiKey ?? null,
|
||||
cults3dApiRejectedAt: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** A new sign-in clears an earlier rejection; null forgets it. */
|
||||
export async function setUserCults3dBrowser(userId: string, browser: Cults3dBrowser | null): Promise<void> {
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
cults3dSession: browser?.session ?? null,
|
||||
cults3dClearance: browser?.clearance ?? null,
|
||||
cults3dUserAgent: browser?.userAgent ?? null,
|
||||
cults3dSessionRejectedAt: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function markCults3dApiRejected(userId: string): Promise<void> {
|
||||
await prisma.user.update({ where: { id: userId }, data: { cults3dApiRejectedAt: new Date() } });
|
||||
}
|
||||
|
||||
export async function markCults3dSessionRejected(userId: string): Promise<void> {
|
||||
await prisma.user.update({ where: { id: userId }, data: { cults3dSessionRejectedAt: new Date() } });
|
||||
}
|
||||
|
||||
export type Cults3dConnectionState = "connected" | "disconnected" | "expired";
|
||||
export type Cults3dConnection = { api: Cults3dConnectionState; site: Cults3dConnectionState; nick: string | null };
|
||||
|
||||
export async function getCults3dConnection(userId: string): Promise<Cults3dConnection> {
|
||||
const user = await prisma.user.findUnique({ where: { id: userId }, select: SELECT });
|
||||
const api: Cults3dConnectionState =
|
||||
!user?.cults3dNick || !user.cults3dApiKey ? "disconnected" : user.cults3dApiRejectedAt ? "expired" : "connected";
|
||||
const site: Cults3dConnectionState = !user?.cults3dSession
|
||||
? "disconnected"
|
||||
: user.cults3dSessionRejectedAt
|
||||
? "expired"
|
||||
: "connected";
|
||||
return { api, site, nick: user?.cults3dNick ?? null };
|
||||
}
|
||||
|
||||
/** The user's API credentials; null when they haven't given them, or Cults3D turned them down. */
|
||||
export async function getUserCults3dCredentials(userId: string): Promise<Cults3dCredentials | null> {
|
||||
const user = await prisma.user.findUnique({ where: { id: userId }, select: SELECT });
|
||||
if (!user?.cults3dNick || !user.cults3dApiKey || user.cults3dApiRejectedAt) return null;
|
||||
return { nick: user.cults3dNick, apiKey: user.cults3dApiKey };
|
||||
}
|
||||
|
||||
/** The browser's sign-in; null when Grab hasn't sent one, or the site turned it down since. */
|
||||
export async function getUserCults3dBrowser(userId: string): Promise<Cults3dBrowser | null> {
|
||||
const user = await prisma.user.findUnique({ where: { id: userId }, select: SELECT });
|
||||
if (!user?.cults3dSession || user.cults3dSessionRejectedAt) return null;
|
||||
return { session: user.cults3dSession, clearance: user.cults3dClearance, userAgent: user.cults3dUserAgent };
|
||||
}
|
||||
|
||||
/** Shown wherever Cults3D's API is needed and the user hasn't given their credentials, or they were turned down. */
|
||||
export const CULTS3D_CONNECT =
|
||||
"Thingport reads Cults3D with your own API key: make one at cults3d.com/en/api/keys, then enter it with your " +
|
||||
"nickname in Configuration > Providers.";
|
||||
|
||||
/** Shown wherever a download needs the browser's sign-in, and there's none. */
|
||||
export const CULTS3D_SIGN_IN =
|
||||
"Cults3D only gives its files to a signed-in user: visit cults3d.com signed in with Thingport Grab, then import " +
|
||||
"it again.";
|
||||
@@ -25,6 +25,7 @@ const SOURCE_PROVIDERS = [
|
||||
"crealitycloud",
|
||||
"nexprint",
|
||||
"thangs",
|
||||
"cults3d",
|
||||
] as const;
|
||||
type SourceProvider = (typeof SOURCE_PROVIDERS)[number];
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ import { resolveMakeronlineModel } from "./makeronlineApi";
|
||||
import { resolveCrealitycloudModel } from "./crealitycloudApi";
|
||||
import { resolveNexprintModel } from "./nexprintApi";
|
||||
import { resolveThangsModel, ThangsBlockedError } from "./thangsApi";
|
||||
import { Cults3dAuthError, Cults3dRateLimitError, resolveCults3dModel } from "./cults3dApi";
|
||||
import { CULTS3D_CONNECT, getUserCults3dCredentials, markCults3dApiRejected } from "./cults3dSessionService";
|
||||
import { getUserMakerworldCookie } from "./makerworldCookieService";
|
||||
import { upsertAuthorFromImport } from "./authorService";
|
||||
import { emptyGaps, openSourceGaps, type SourceGap } from "./sourceGaps";
|
||||
@@ -131,6 +133,20 @@ export async function fetchSourceMeta(userId: string, print: Print, sourceUrl: s
|
||||
return { ...emptyImportedPageMetadata(), ...resolved.meta, galleryImages: resolved.galleryImages };
|
||||
}
|
||||
|
||||
if (provider === "cults3d") {
|
||||
const credentials = await getUserCults3dCredentials(userId);
|
||||
if (!credentials) throw new HttpError(400, CULTS3D_CONNECT);
|
||||
let resolved;
|
||||
try {
|
||||
resolved = await resolveCults3dModel(externalId, credentials);
|
||||
} catch (err) {
|
||||
if (err instanceof Cults3dAuthError) await markCults3dApiRejected(userId);
|
||||
throw err;
|
||||
}
|
||||
if (!resolved) throw new HttpError(404, "This Cults3D 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");
|
||||
}
|
||||
|
||||
@@ -186,6 +202,8 @@ export async function fillSourceGaps(userId: string, printId: string): Promise<F
|
||||
if (err instanceof MyminifactoryRateLimitError) throw new HttpError(429, err.message);
|
||||
if (err instanceof MyminifactoryAuthError) throw new HttpError(400, err.message);
|
||||
if (err instanceof ThangsBlockedError) throw new HttpError(429, err.message);
|
||||
if (err instanceof Cults3dRateLimitError) throw new HttpError(429, err.message);
|
||||
if (err instanceof Cults3dAuthError) throw new HttpError(400, err.message);
|
||||
throw err;
|
||||
}
|
||||
const wanted = new Set(gaps);
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
NEXPRINT_SESSION_CODE,
|
||||
THANGS_SESSION_CODE,
|
||||
THANGS_DOWNLOAD_LIMIT_CODE,
|
||||
CULTS3D_SESSION_CODE,
|
||||
type ImportRequestBody,
|
||||
} from "./importService";
|
||||
import {
|
||||
@@ -51,6 +52,8 @@ import { nexprintModelUrl, parseNexprintModelUrl, resolveNexprintModel, selectNe
|
||||
import { getUserNexprintToken, NEXPRINT_CONNECT } from "./nexprintSessionService";
|
||||
import { parseThangsModelUrl, thangsModelUrl } from "./thangsApi";
|
||||
import { getUserThangsToken, THANGS_CONNECT } from "./thangsSessionService";
|
||||
import { cults3dModelUrl, parseCults3dModelUrl } from "./cults3dApi";
|
||||
import { CULTS3D_CONNECT, getUserCults3dCredentials } from "./cults3dSessionService";
|
||||
import { queueGrabImport } from "./grabImportService";
|
||||
import { upsertAuthorFromImport } from "./authorService";
|
||||
import { extractZipEntriesToPrints } from "./zipService";
|
||||
@@ -92,6 +95,7 @@ type SnapmakerListImportJobBody = ImportRequestBody & { model_ids: string[]; lis
|
||||
type CrealitycloudListImportJobBody = ImportRequestBody & { model_ids: string[]; title: string };
|
||||
type NexprintCollectionImportJobBody = ImportRequestBody & { model_ids: string[]; title: string };
|
||||
type ThangsListImportJobBody = ImportRequestBody & { model_ids: string[]; title: string };
|
||||
type Cults3dListImportJobBody = 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")
|
||||
@@ -101,6 +105,21 @@ type StopReason = "rateLimited" | "downloadLimit" | "auth" | "blocked";
|
||||
|
||||
const BLOCKED_RETRY_TEXT =
|
||||
"Cloudflare blocked the request and FlareSolverr didn't get past it — check FlareSolverr in Administration → Connections, then retry";
|
||||
// Cults3D's pages take the browser's own Cloudflare clearance, which Grab sends; FlareSolverr's is no use there.
|
||||
const CULTS3D_BLOCKED_TEXT =
|
||||
"Cults3D's Cloudflare turned Thingport away — visit cults3d.com with Thingport Grab to renew its clearance";
|
||||
|
||||
/** A Cloudflare block, as a link's failure, by its provider. */
|
||||
function blockedRetryText(url: string | null): string {
|
||||
return url && parseCults3dModelUrl(url) ? `${CULTS3D_BLOCKED_TEXT}, then retry` : BLOCKED_RETRY_TEXT;
|
||||
}
|
||||
|
||||
/** A Cloudflare block, as a sync's. */
|
||||
function blockedText(provider: string): string {
|
||||
return provider === "cults3d"
|
||||
? CULTS3D_BLOCKED_TEXT
|
||||
: `Cloudflare blocked ${providerName(provider)} and FlareSolverr didn't get past it`;
|
||||
}
|
||||
|
||||
const PROVIDER_OFF_TEXT = (name: string) =>
|
||||
`${name} was turned off on this Thingport; an admin can turn it back on in Administration → Providers`;
|
||||
@@ -113,7 +132,8 @@ function classifyImportFailure(err: unknown): ImportFailureReason {
|
||||
err.code === SNAPMAKER_SESSION_CODE ||
|
||||
err.code === CREALITYCLOUD_SESSION_CODE ||
|
||||
err.code === NEXPRINT_SESSION_CODE ||
|
||||
err.code === THANGS_SESSION_CODE
|
||||
err.code === THANGS_SESSION_CODE ||
|
||||
err.code === CULTS3D_SESSION_CODE
|
||||
) {
|
||||
return "auth";
|
||||
}
|
||||
@@ -340,6 +360,9 @@ const CREALITYCLOUD_AUTH_TEXT =
|
||||
const NEXPRINT_AUTH_TEXT =
|
||||
"NexPrint turned your sign-in down — visit nexprint.com signed in with Thingport Grab, then retry";
|
||||
const THANGS_AUTH_TEXT = "Thangs turned your sign-in down — visit thangs.com signed in with Thingport Grab, then retry";
|
||||
const CULTS3D_AUTH_TEXT =
|
||||
"Cults3D turned your API key or sign-in down — check the key in Configuration → Providers, or visit cults3d.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 = {
|
||||
@@ -596,7 +619,7 @@ export async function runCollectionImportJob(
|
||||
/** A provider whose models import one by one with a token: Thingiverse's and MyMiniFactory's instance-wide one, or
|
||||
* the user's own Snapmaker, Creality Cloud, NexPrint or Thangs sign-in. */
|
||||
type TokenBatchProvider = {
|
||||
provider: "thingiverse" | "myminifactory" | "snapmaker" | "crealitycloud" | "nexprint" | "thangs";
|
||||
provider: "thingiverse" | "myminifactory" | "snapmaker" | "crealitycloud" | "nexprint" | "thangs" | "cults3d";
|
||||
getToken: () => Promise<string | null>;
|
||||
notConfigured: string;
|
||||
modelUrl: (id: string) => string;
|
||||
@@ -669,8 +692,21 @@ function thangsBatch(userId: string): TokenBatchProvider {
|
||||
};
|
||||
}
|
||||
|
||||
function cults3dBatch(userId: string): TokenBatchProvider {
|
||||
return {
|
||||
provider: "cults3d",
|
||||
// The batch only asks whether there's one; each model's import reads the credentials itself.
|
||||
getToken: async () => (await getUserCults3dCredentials(userId))?.nick ?? null,
|
||||
notConfigured: CULTS3D_CONNECT,
|
||||
modelUrl: (slug) => cults3dModelUrl(slug),
|
||||
rateLimitText: "blocked by Cults3D's rate limit — wait a while, then retry",
|
||||
authText: `failed because ${CULTS3D_AUTH_TEXT}`,
|
||||
};
|
||||
}
|
||||
|
||||
/** Shared by the Thingiverse Likes and Collection imports, MyMiniFactory's collections, Snapmaker's likes and
|
||||
* bookmarks, Creality Cloud's likes and collections, NexPrint's collections, and Thangs' likes and collections.
|
||||
* bookmarks, Creality Cloud's likes and collections, NexPrint's collections, Thangs' likes and collections, and
|
||||
* Cults3D's likes and printlists.
|
||||
* Once the provider's daily download limit is reached, the rest aren't asked for.
|
||||
* Successful imports are filed into the collection named by `resolveCollectionTitle`, which runs
|
||||
* afterwards so it costs no API call when nothing was imported. */
|
||||
@@ -924,6 +960,23 @@ export async function runThangsListImportJob(
|
||||
);
|
||||
}
|
||||
|
||||
/** A Cults3D user's likes, or one of the user's printlists, filed into a collection named after it. */
|
||||
export async function runCults3dListImportJob(
|
||||
jobId: string,
|
||||
userId: string,
|
||||
body: Cults3dListImportJobBody,
|
||||
): Promise<void> {
|
||||
await runTokenBatchImportJob(
|
||||
jobId,
|
||||
userId,
|
||||
cults3dBatch(userId),
|
||||
body,
|
||||
body.model_ids,
|
||||
async () => body.title,
|
||||
(collectionTitle) => `"${collectionTitle}"`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function runPrintablesCollectionImportJob(
|
||||
jobId: string,
|
||||
userId: string,
|
||||
@@ -1135,6 +1188,7 @@ function authStopText(url: string): string {
|
||||
if (parseCrealitycloudModelUrl(url)) return CREALITYCLOUD_AUTH_TEXT;
|
||||
if (parseNexprintModelUrl(url)) return NEXPRINT_AUTH_TEXT;
|
||||
if (parseThangsModelUrl(url)) return THANGS_AUTH_TEXT;
|
||||
if (parseCults3dModelUrl(url)) return CULTS3D_AUTH_TEXT;
|
||||
return "the MakerWorld session expired — update the cookie in Settings, then retry";
|
||||
}
|
||||
|
||||
@@ -1280,7 +1334,7 @@ async function runLinks(jobId: string, userId: string, body: LinksImportJobBody)
|
||||
rateLimited: "blocked by a MakerWorld CAPTCHA challenge — retry once it clears (usually 1-4 hours)",
|
||||
downloadLimit: `${downloadLimitSite(stopUrl)}'s daily download limit was reached — retry tomorrow`,
|
||||
auth: authStopText(stopUrl ?? item.url),
|
||||
blocked: BLOCKED_RETRY_TEXT,
|
||||
blocked: blockedRetryText(stopUrl ?? item.url),
|
||||
}[stopReason];
|
||||
settled = true;
|
||||
break;
|
||||
@@ -1310,7 +1364,7 @@ async function runLinks(jobId: string, userId: string, body: LinksImportJobBody)
|
||||
rateLimited: " (MakerWorld CAPTCHA challenge — retry once it clears)",
|
||||
downloadLimit: ` (${downloadLimitSite(item.url)}'s daily download limit — retry tomorrow)`,
|
||||
auth: ` (${authStopText(item.url)})`,
|
||||
blocked: ` (${BLOCKED_RETRY_TEXT})`,
|
||||
blocked: ` (${blockedRetryText(item.url)})`,
|
||||
}[result.stopReason];
|
||||
}
|
||||
settled = true;
|
||||
@@ -1431,7 +1485,7 @@ async function runLinks(jobId: string, userId: string, body: LinksImportJobBody)
|
||||
} else if (stopReason === "auth") {
|
||||
bodyParts.push(`the rest failed because ${authStopText(stopUrl ?? job.sourceUrl)}`);
|
||||
} else if (stopReason === "blocked") {
|
||||
bodyParts.push(`the rest failed: ${BLOCKED_RETRY_TEXT}`);
|
||||
bodyParts.push(`the rest failed: ${blockedRetryText(stopUrl ?? job.sourceUrl)}`);
|
||||
} else if (failedCount) {
|
||||
bodyParts.push(`${failedCount} failed — retry them from the import's progress bar`);
|
||||
}
|
||||
@@ -1477,7 +1531,7 @@ async function modelNames(printIds: string[]): Promise<string> {
|
||||
|
||||
function syncStopText(reason: StopReason, provider: string): string {
|
||||
if (reason === "downloadLimit") return `${providerName(provider)}'s daily download limit was reached`;
|
||||
if (reason === "blocked") return `Cloudflare blocked ${providerName(provider)} and FlareSolverr didn't get past it`;
|
||||
if (reason === "blocked") return blockedText(provider);
|
||||
if (reason === "auth") {
|
||||
return provider === "makerworld"
|
||||
? "your MakerWorld session expired, so connect MakerWorld again in Configuration > Providers"
|
||||
@@ -1560,7 +1614,7 @@ async function deferSyncRest(job: ImportJob, currentItemId: string | null, reaso
|
||||
reason === "disabled"
|
||||
? `${providerName(sync.provider)} is turned off on this Thingport`
|
||||
: reason === "blocked"
|
||||
? `Cloudflare blocked ${providerName(sync.provider)} and FlareSolverr didn't get past it`
|
||||
? blockedText(sync.provider)
|
||||
: reason === "downloadLimit"
|
||||
? `${providerName(sync.provider)}'s daily download limit was reached`
|
||||
: sync.provider === "makerworld"
|
||||
@@ -1606,7 +1660,9 @@ async function notifySyncJobDone(
|
||||
? `${more} will come in on a later sync: ${providerName(sync.provider)}'s daily download limit for your account was reached.`
|
||||
: deferred.reason === "auth"
|
||||
? `${more} will come in on a later sync: ${providerName(sync.provider)} turned Thingport's sign-in down.`
|
||||
: `${more} will come in on a later sync: Cloudflare blocked ${providerName(sync.provider)} and FlareSolverr didn't get past it. An admin can check FlareSolverr in Administration → Connections.`,
|
||||
: sync.provider === "cults3d"
|
||||
? `${more} will come in on a later sync: ${CULTS3D_BLOCKED_TEXT}.`
|
||||
: `${more} will come in on a later sync: Cloudflare blocked ${providerName(sync.provider)} and FlareSolverr didn't get past it. An admin can check FlareSolverr in Administration → Connections.`,
|
||||
);
|
||||
}
|
||||
if (failedCount) {
|
||||
|
||||
@@ -500,6 +500,7 @@ export type ImportedPageMetadata = {
|
||||
| "crealitycloud"
|
||||
| "nexprint"
|
||||
| "thangs"
|
||||
| "cults3d"
|
||||
| null;
|
||||
/** Lets a second profile of an imported design be added as its own plate. MakerWorld only. */
|
||||
makerworldProfile?: MakerworldProfileRef;
|
||||
|
||||
@@ -139,6 +139,29 @@ import {
|
||||
type ThangsModelResolution,
|
||||
} from "./thangsApi";
|
||||
import { getUserThangsToken, markThangsSessionRejected, THANGS_CONNECT, withThangsToken } from "./thangsSessionService";
|
||||
import {
|
||||
Cults3dAuthError,
|
||||
Cults3dBlockedError,
|
||||
cults3dModelUrl,
|
||||
Cults3dRateLimitError,
|
||||
Cults3dSessionError,
|
||||
fetchCults3dOrders,
|
||||
isCults3dFileHost,
|
||||
parseCults3dModelUrl,
|
||||
placeCults3dFreeOrder,
|
||||
resolveCults3dDownload,
|
||||
resolveCults3dModel,
|
||||
type Cults3dCredentials,
|
||||
type Cults3dModelResolution,
|
||||
} from "./cults3dApi";
|
||||
import {
|
||||
CULTS3D_CONNECT,
|
||||
CULTS3D_SIGN_IN,
|
||||
getUserCults3dBrowser,
|
||||
getUserCults3dCredentials,
|
||||
markCults3dApiRejected,
|
||||
markCults3dSessionRejected,
|
||||
} from "./cults3dSessionService";
|
||||
|
||||
/** An import refused because the user's MyMiniFactory session has expired. */
|
||||
export const MYMINIFACTORY_SESSION_CODE = "myminifactory_session";
|
||||
@@ -152,6 +175,9 @@ export const NEXPRINT_SESSION_CODE = "nexprint_session";
|
||||
export const THANGS_SESSION_CODE = "thangs_session";
|
||||
/** A Thangs import refused because the account's daily downloads are used up. */
|
||||
export const THANGS_DOWNLOAD_LIMIT_CODE = "THANGS_DOWNLOAD_LIMIT";
|
||||
/** A Cults3D import refused for want of the user's API key, or of the browser's sign-in, or because one was turned
|
||||
* down. */
|
||||
export const CULTS3D_SESSION_CODE = "cults3d_session";
|
||||
/** A MakerOnline import that came without the signed file links only Thingport Grab can get. */
|
||||
export const MAKERONLINE_GRAB_CODE = "makeronline_grab";
|
||||
export const MAKERONLINE_NEEDS_GRAB =
|
||||
@@ -813,6 +839,7 @@ const CATEGORY_SITE_CAT_IDS_FIELD = {
|
||||
crealitycloud: "crealitycloudCatIds",
|
||||
nexprint: "nexprintCatIds",
|
||||
thangs: "thangsCatIds",
|
||||
cults3d: "cults3dCatIds",
|
||||
} as const;
|
||||
|
||||
// Columns of text: NexPrint's ids are past Int, and Thangs' are category paths.
|
||||
@@ -871,6 +898,8 @@ export function identifySourceModel(url: string): { provider: string; externalId
|
||||
if (nexprint) return { provider: "nexprint", externalId: nexprint.modelId };
|
||||
const thangs = parseThangsModelUrl(url);
|
||||
if (thangs) return { provider: "thangs", externalId: thangs.modelId };
|
||||
const cults3d = parseCults3dModelUrl(url);
|
||||
if (cults3d) return { provider: "cults3d", externalId: cults3d.slug };
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -930,6 +959,7 @@ export function buildImportSourceUrl(provider: string | null, externalId: string
|
||||
if (provider === "crealitycloud") return `https://www.crealitycloud.com/model-detail/${externalId}`;
|
||||
if (provider === "nexprint") return `https://www.nexprint.com/en/models/${externalId}`;
|
||||
if (provider === "thangs") return thangsModelUrl(externalId);
|
||||
if (provider === "cults3d") return cults3dModelUrl(externalId);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -2031,6 +2061,81 @@ async function importThangsModel(
|
||||
return createSourcedPrint(userId, source, body, resolved, downloaded, `thangs-${source.externalId}`);
|
||||
}
|
||||
|
||||
const CULTS3D_MISSING = "This Cults3D model could not be found, or isn't public.";
|
||||
|
||||
/** The user's API credentials, or the HttpError that says to give them. */
|
||||
async function requireCults3dCredentials(userId: string): Promise<Cults3dCredentials> {
|
||||
const credentials = await getUserCults3dCredentials(userId);
|
||||
if (!credentials) throw new HttpError(400, CULTS3D_CONNECT, CULTS3D_SESSION_CODE);
|
||||
return credentials;
|
||||
}
|
||||
|
||||
/** Cults3D's errors as the HttpErrors an import reports; a rejected key or sign-in is remembered, so the app can say
|
||||
* so. */
|
||||
async function cults3dFailure(userId: string, err: unknown): Promise<unknown> {
|
||||
if (err instanceof Cults3dAuthError) {
|
||||
await markCults3dApiRejected(userId);
|
||||
// 400, not 401: see tryMakerworldCloudApi.
|
||||
return new HttpError(400, err.message, CULTS3D_SESSION_CODE);
|
||||
}
|
||||
if (err instanceof Cults3dSessionError) {
|
||||
await markCults3dSessionRejected(userId);
|
||||
return new HttpError(400, err.message, CULTS3D_SESSION_CODE);
|
||||
}
|
||||
// Its own code, so a sync waits for the next visit with Grab rather than failing the model.
|
||||
if (err instanceof Cults3dBlockedError) return new HttpError(403, err.message, CLOUDFLARE_BLOCKED_CODE);
|
||||
if (err instanceof Cults3dRateLimitError) return new HttpError(429, err.message);
|
||||
return err;
|
||||
}
|
||||
|
||||
function cults3dPaid(resolved: Cults3dModelResolution): HttpError {
|
||||
return new HttpError(
|
||||
403,
|
||||
`This is a paid Cults3D model (${resolved.priceText}), so it downloads only once bought. Buy it on Cults3D with ` +
|
||||
"the account connected to Thingport, then import it again.",
|
||||
);
|
||||
}
|
||||
|
||||
/** Metadata from the API; the files from the user's Cults3D library, where every model they ordered, bought or free,
|
||||
* stays. A free model not in it yet is ordered first, as its Download button does; a paid one is refused. The order
|
||||
* and download pages need the browser's sign-in and clearance, which Thingport Grab sends. */
|
||||
async function importCults3dModel(
|
||||
userId: string,
|
||||
source: { provider: string; externalId: string },
|
||||
body: ImportRequestBody,
|
||||
): Promise<ImportResult> {
|
||||
const credentials = await requireCults3dCredentials(userId);
|
||||
const downloaded: NewPlateInput[] = [];
|
||||
let resolved: Cults3dModelResolution | null;
|
||||
try {
|
||||
resolved = await resolveCults3dModel(source.externalId, credentials);
|
||||
if (!resolved) throw new HttpError(404, CULTS3D_MISSING);
|
||||
let downloadUrl = (await fetchCults3dOrders(credentials)).get(resolved.slug) ?? null;
|
||||
if (!downloadUrl && resolved.priceCents > 0) throw cults3dPaid(resolved);
|
||||
const browser = await getUserCults3dBrowser(userId);
|
||||
if (!browser) throw new HttpError(400, CULTS3D_SIGN_IN, CULTS3D_SESSION_CODE);
|
||||
if (!downloadUrl) {
|
||||
await placeCults3dFreeOrder(resolved.slug, browser);
|
||||
downloadUrl = (await fetchCults3dOrders(credentials)).get(resolved.slug) ?? null;
|
||||
if (!downloadUrl) throw new HttpError(502, "Cults3D took the free order, but it isn't in your downloads yet.");
|
||||
}
|
||||
const file = await resolveCults3dDownload(downloadUrl, browser);
|
||||
downloaded.push(
|
||||
...((await downloadSignedFile(
|
||||
file,
|
||||
isCults3dFileHost,
|
||||
"Cults3D blocked the file download (rate limit). This usually clears after a while -- wait, then retry.",
|
||||
)) ?? []),
|
||||
);
|
||||
} catch (err) {
|
||||
await Promise.all(downloaded.map((input) => fs.rm(input.tempFilePath!, { force: true }).catch(() => undefined)));
|
||||
throw await cults3dFailure(userId, err);
|
||||
}
|
||||
if (!downloaded.length) throw new HttpError(400, "This Cults3D model has no files Thingport can import.");
|
||||
|
||||
return createSourcedPrint(userId, source, body, resolved, downloaded, `cults3d-${source.externalId}`);
|
||||
}
|
||||
|
||||
const MAKERONLINE_BLOCKED =
|
||||
"MakerOnline blocked the file download (rate limit). This usually clears after a while -- wait, then try again.";
|
||||
|
||||
@@ -2198,6 +2303,9 @@ async function importNewOrExisting(userId: string, url: string, body: ImportRequ
|
||||
if (source?.provider === "thangs") {
|
||||
return importThangsModel(userId, source, body);
|
||||
}
|
||||
if (source?.provider === "cults3d") {
|
||||
return importCults3dModel(userId, source, body);
|
||||
}
|
||||
|
||||
const { tempPath, filename, mime, meta } = await downloadImportToTemp(url, body);
|
||||
const author = await upsertAuthorFromImport(meta.author);
|
||||
|
||||
@@ -56,6 +56,19 @@ import {
|
||||
markThangsSessionRejected,
|
||||
withThangsToken,
|
||||
} from "./thangsSessionService";
|
||||
import {
|
||||
Cults3dAuthError,
|
||||
cults3dLikesKey,
|
||||
cults3dLikesName,
|
||||
cults3dLikesUrl,
|
||||
Cults3dRateLimitError,
|
||||
fetchCults3dLikes,
|
||||
fetchCults3dOrders,
|
||||
listCults3dCollections,
|
||||
type Cults3dCredentials,
|
||||
} from "./cults3dApi";
|
||||
import { getCults3dConnection, getUserCults3dCredentials, markCults3dApiRejected } from "./cults3dSessionService";
|
||||
import { syncLikesUsername } from "../dto";
|
||||
import { MakerworldAuthError, MakerworldCaptchaError, extractMakerworldBearerToken } from "./makerworldCloudApi";
|
||||
import { fetchMakerworldCollectionCard, listMakerworldOwnCollections } from "./makerworldCollections";
|
||||
import { getUserMakerworldCookie } from "./makerworldCookieService";
|
||||
@@ -100,6 +113,7 @@ export const BROWSABLE_PROVIDERS = [
|
||||
"crealitycloud",
|
||||
"nexprint",
|
||||
"thangs",
|
||||
"cults3d",
|
||||
] as const;
|
||||
export type BrowsableProvider = (typeof BROWSABLE_PROVIDERS)[number];
|
||||
|
||||
@@ -123,7 +137,7 @@ export type ProviderCollectionOut = {
|
||||
};
|
||||
|
||||
export async function getProviderAccounts(userId: string): Promise<ProviderAccountsOut> {
|
||||
const [user, thingiverseToken, myminifactoryKey, snapmaker, makeronline, crealitycloud, nexprint, thangs] =
|
||||
const [user, thingiverseToken, myminifactoryKey, snapmaker, makeronline, crealitycloud, nexprint, thangs, cults3d] =
|
||||
await Promise.all([
|
||||
prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
@@ -141,6 +155,7 @@ export async function getProviderAccounts(userId: string): Promise<ProviderAccou
|
||||
getCrealitycloudConnection(userId),
|
||||
getNexprintConnection(userId),
|
||||
getThangsConnection(userId),
|
||||
getCults3dConnection(userId),
|
||||
]);
|
||||
return {
|
||||
makerworld: { connected: Boolean(extractMakerworldBearerToken(user?.makerworldCookie)), account: null },
|
||||
@@ -152,6 +167,7 @@ export async function getProviderAccounts(userId: string): Promise<ProviderAccou
|
||||
crealitycloud: { connected: crealitycloud === "connected", account: null },
|
||||
nexprint: { connected: nexprint === "connected", account: null },
|
||||
thangs: { connected: thangs === "connected", account: null },
|
||||
cults3d: { connected: cults3d.api === "connected", account: cults3d.nick },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -204,6 +220,8 @@ export async function setProviderAccount(
|
||||
throw new HttpError(400, "NexPrint is linked through its sign-in in Configuration > Providers");
|
||||
} else if (provider === "thangs") {
|
||||
throw new HttpError(400, "Thangs is linked through its sign-in in Configuration > Providers");
|
||||
} else if (provider === "cults3d") {
|
||||
throw new HttpError(400, "Cults3D is linked through its API key in Configuration > Providers");
|
||||
} else {
|
||||
throw new HttpError(400, "MakerWorld is linked through its cookie in Configuration > Providers");
|
||||
}
|
||||
@@ -246,6 +264,8 @@ async function providerCall<T>(call: () => Promise<T>): Promise<T> {
|
||||
if (err instanceof ThangsAuthError) throw new HttpError(400, err.message);
|
||||
if (err instanceof ThangsBlockedError) throw new HttpError(429, err.message);
|
||||
if (err instanceof ThangsPrivateCollectionError) throw new HttpError(403, err.message);
|
||||
if (err instanceof Cults3dAuthError) throw new HttpError(400, err.message);
|
||||
if (err instanceof Cults3dRateLimitError) throw new HttpError(429, err.message);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -380,6 +400,46 @@ function thangsCollectionCard(userId: string, collectionId: string): Promise<Pro
|
||||
});
|
||||
}
|
||||
|
||||
/** Runs `call` with the user's Cults3D API key and the models they own; remembers a rejected key. */
|
||||
async function withCults3d<T>(
|
||||
userId: string,
|
||||
call: (session: { credentials: Cults3dCredentials; owned: Set<string> }) => Promise<T>,
|
||||
): Promise<T> {
|
||||
return providerCall(async () => {
|
||||
const credentials = await getUserCults3dCredentials(userId);
|
||||
if (!credentials) throw new HttpError(409, "Enter your Cults3D nickname and API key in Configuration > Providers");
|
||||
try {
|
||||
const owned = new Set((await fetchCults3dOrders(credentials)).keys());
|
||||
return await call({ credentials, owned });
|
||||
} catch (err) {
|
||||
if (err instanceof Cults3dAuthError) await markCults3dApiRejected(userId);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** The user's likes and their own printlists. */
|
||||
function cults3dCards(userId: string): Promise<ProviderCollectionCard[]> {
|
||||
return withCults3d(userId, ({ credentials, owned }) => listCults3dCollections(credentials, owned));
|
||||
}
|
||||
|
||||
/** Anyone's likes, which Cults3D's API shows to any key. */
|
||||
function cults3dLikesCard(userId: string, nick: string): Promise<ProviderCollectionCard | null> {
|
||||
return withCults3d(userId, async ({ credentials, owned }) => {
|
||||
const likes = await fetchCults3dLikes(credentials, nick, owned);
|
||||
if (!likes) return null;
|
||||
return {
|
||||
id: cults3dLikesKey(nick),
|
||||
name: cults3dLikesName(nick),
|
||||
url: cults3dLikesUrl(nick),
|
||||
modelCount: likes.total,
|
||||
thumbnails: likes.entries.slice(0, 4).flatMap((entry) => entry.cover ?? []),
|
||||
private: false,
|
||||
owner: nick,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function withSyncs(
|
||||
userId: string,
|
||||
provider: SyncProvider,
|
||||
@@ -440,6 +500,8 @@ export async function listProviderCollections(
|
||||
cards = await nexprintCards(userId);
|
||||
} else if (provider === "thangs") {
|
||||
cards = await thangsCards(userId);
|
||||
} else if (provider === "cults3d") {
|
||||
cards = await cults3dCards(userId);
|
||||
} else if (provider === "myminifactory") {
|
||||
const key = await requireMyminifactoryKey();
|
||||
if (!user?.myminifactoryUsername) throw new HttpError(409, "Tell Thingport your MyMiniFactory username first");
|
||||
@@ -467,14 +529,14 @@ export async function lookupProviderCollection(userId: string, url: string): Pro
|
||||
if (!source)
|
||||
throw new HttpError(
|
||||
400,
|
||||
"That isn't a MakerWorld, Printables, Thingiverse, MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud, NexPrint or Thangs collection link",
|
||||
"That isn't a MakerWorld, Printables, Thingiverse, MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud, NexPrint, Thangs or Cults3D collection link",
|
||||
);
|
||||
await assertProviderEnabled(source.provider);
|
||||
let card: ProviderCollectionCard | null;
|
||||
if (source.likesOf && source.provider === "thingiverse") {
|
||||
const token = await requireThingiverseToken();
|
||||
card = await providerCall(() => thingiverseLikesCard(source.likesOf!, token));
|
||||
} else if (source.likesOf) {
|
||||
} else if (source.likesOf && source.provider === "printables") {
|
||||
// Printables shows likes to their owner only, so only the user's own, as Grab read them.
|
||||
const likes = await pushedLikes(userId);
|
||||
if (likes?.card.id !== source.externalId) {
|
||||
@@ -508,6 +570,17 @@ export async function lookupProviderCollection(userId: string, url: string): Pro
|
||||
source.externalId === THANGS_LIKES_KEY
|
||||
? ((await thangsCards(userId)).find((own) => own.id === THANGS_LIKES_KEY) ?? null)
|
||||
: await thangsCollectionCard(userId, source.externalId);
|
||||
} else if (source.provider === "cults3d") {
|
||||
const likesOf = source.likesOf ?? syncLikesUsername({ externalId: source.externalId, sourceUrl: source.url });
|
||||
if (likesOf) {
|
||||
card = await cults3dLikesCard(userId, likesOf);
|
||||
} else {
|
||||
// Cults3D's API shows the user their own printlists only.
|
||||
card = (await cults3dCards(userId)).find((own) => own.id === source.externalId) ?? null;
|
||||
if (!card) {
|
||||
throw new HttpError(404, "Only your own Cults3D printlists can be found: Cults3D's API shows no one else's.");
|
||||
}
|
||||
}
|
||||
} 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;
|
||||
@@ -541,7 +614,7 @@ export async function enableSyncFromApp(
|
||||
if (!source)
|
||||
throw new HttpError(
|
||||
400,
|
||||
"That isn't a MakerWorld, Printables, Thingiverse, MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud, NexPrint or Thangs collection link",
|
||||
"That isn't a MakerWorld, Printables, Thingiverse, MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud, NexPrint, Thangs or Cults3D collection link",
|
||||
);
|
||||
await assertProviderEnabled(source.provider);
|
||||
if (input.profileScope && !hasProfiles(source.provider)) {
|
||||
|
||||
@@ -16,6 +16,7 @@ export const PROVIDERS = [
|
||||
"crealitycloud",
|
||||
"nexprint",
|
||||
"thangs",
|
||||
"cults3d",
|
||||
] as const;
|
||||
export type Provider = (typeof PROVIDERS)[number];
|
||||
|
||||
@@ -29,6 +30,7 @@ const PROVIDER_NAMES: Record<Provider, string> = {
|
||||
crealitycloud: "Creality Cloud",
|
||||
nexprint: "NexPrint",
|
||||
thangs: "Thangs",
|
||||
cults3d: "Cults3D",
|
||||
};
|
||||
|
||||
const SETTING_KEY = "disabled_providers";
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
Cults3dAuthError,
|
||||
Cults3dBlockedError,
|
||||
cults3dFreeOrderForm,
|
||||
cults3dMetaFromCreation,
|
||||
cults3dModelUrl,
|
||||
cults3dNumericId,
|
||||
cults3dOriginalImage,
|
||||
Cults3dSessionError,
|
||||
fetchCults3dLikes,
|
||||
fetchCults3dOrders,
|
||||
isCults3dFileHost,
|
||||
parseCults3dLikesUrl,
|
||||
parseCults3dModelUrl,
|
||||
parseCults3dPrintlistUrl,
|
||||
placeCults3dFreeOrder,
|
||||
resolveCults3dDownload,
|
||||
resolveCults3dModel,
|
||||
} from "../src/services/cults3dApi";
|
||||
import { modelIdFromUrl, modelUrlFor, parseSyncSource } from "../src/services/collectionSyncService";
|
||||
import { normalizeCults3dBrowser } from "../src/services/cults3dSessionService";
|
||||
import { buildImportSourceUrl, identifySourceModel } from "../src/services/importService";
|
||||
import { syncLikesUsername } from "../src/dto";
|
||||
|
||||
// The Cults3D client against the shapes its API and site give back, with fetch mocked. No database.
|
||||
|
||||
const CREDENTIALS = { nick: "Maker", apiKey: "secret" };
|
||||
const BROWSER = {
|
||||
session: "a823b7a79d7156fe6eef032de8827999",
|
||||
clearance: "wmNqDF.jZwItqtSCGKAz749QYyvhuCD0stC6MC-1792449123-1.2.1.1-_Sef40o9",
|
||||
userAgent: "Mozilla/5.0 X",
|
||||
};
|
||||
|
||||
function json(data: unknown, status = 200, headers: Record<string, string> = {}): Response {
|
||||
return new Response(JSON.stringify(data), { status, headers: { "content-type": "application/json", ...headers } });
|
||||
}
|
||||
|
||||
function redirect(location: string, status = 302): Response {
|
||||
return new Response(null, { status, headers: { location } });
|
||||
}
|
||||
|
||||
type Call = { url: URL; method: string; headers: Record<string, string>; body: string | null };
|
||||
|
||||
/** Answers each call with the next of `answers`; records what was sent. */
|
||||
function stubFetch(...answers: ((call: Call) => Response)[]) {
|
||||
const calls: Call[] = [];
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: URL | string, init: RequestInit = {}) => {
|
||||
const call: Call = {
|
||||
url: new URL(url),
|
||||
method: init.method ?? "GET",
|
||||
headers: (init.headers ?? {}) as Record<string, string>,
|
||||
body: typeof init.body === "string" ? init.body : null,
|
||||
};
|
||||
calls.push(call);
|
||||
const answer = answers[calls.length - 1];
|
||||
if (!answer) throw new Error(`unexpected call ${call.method} ${call.url}`);
|
||||
return answer(call);
|
||||
}),
|
||||
);
|
||||
return calls;
|
||||
}
|
||||
|
||||
/** What a GraphQL call asked: its query and variables. */
|
||||
function graphqlOf(call: Call): { query: string; variables: Record<string, unknown> } {
|
||||
return JSON.parse(call.body ?? "{}");
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("Cults3D URLs", () => {
|
||||
it("recognizes model pages in every language, and their short form", () => {
|
||||
expect(parseCults3dModelUrl("https://cults3d.com/en/3d-model/art/ghostship-benchy")).toEqual({
|
||||
slug: "ghostship-benchy",
|
||||
});
|
||||
expect(parseCults3dModelUrl("https://cults3d.com/fr/mod%C3%A8le-3d/art/ghostship-benchy?ref=x")).toEqual({
|
||||
slug: "ghostship-benchy",
|
||||
});
|
||||
expect(parseCults3dModelUrl("https://cults3d.com/zh/3d-m%C3%B3x%C3%ADng/y%C3%ACsh%C3%B9/ghostship-benchy")).toEqual(
|
||||
{
|
||||
slug: "ghostship-benchy",
|
||||
},
|
||||
);
|
||||
expect(parseCults3dModelUrl("https://cults3d.com/en/creations/ghostship-benchy")).toEqual({
|
||||
slug: "ghostship-benchy",
|
||||
});
|
||||
expect(parseCults3dModelUrl("https://cults3d.com/en/creations/latest")).toBeNull();
|
||||
expect(parseCults3dModelUrl("https://cults3d.com/en/users/Maker/likes")).toBeNull();
|
||||
expect(parseCults3dModelUrl("https://example.com/en/3d-model/art/ghostship-benchy")).toBeNull();
|
||||
});
|
||||
|
||||
it("identifies imported models by slug, and links back to them", () => {
|
||||
const url = "https://cults3d.com/de/modell-3d/kunst/ghostship-benchy";
|
||||
expect(identifySourceModel(url)).toEqual({ provider: "cults3d", externalId: "ghostship-benchy" });
|
||||
expect(buildImportSourceUrl("cults3d", "ghostship-benchy")).toBe(cults3dModelUrl("ghostship-benchy"));
|
||||
expect(modelIdFromUrl("cults3d", modelUrlFor("cults3d", "ghostship-benchy"))).toBe("ghostship-benchy");
|
||||
});
|
||||
|
||||
it("recognizes likes and printlists, and syncs them", () => {
|
||||
expect(parseCults3dLikesUrl("https://cults3d.com/it/utenti/Maker/mi-piace")).toEqual({ nick: "Maker" });
|
||||
expect(parseCults3dPrintlistUrl("https://cults3d.com/de/drucklisten/Maker/My-Saves")).toEqual({
|
||||
nick: "Maker",
|
||||
slug: "My-Saves",
|
||||
key: "maker/my-saves",
|
||||
url: "https://cults3d.com/en/design-collections/Maker/My-Saves",
|
||||
});
|
||||
|
||||
const likes = parseSyncSource("https://cults3d.com/en/users/Maker/likes");
|
||||
expect(likes).toEqual({
|
||||
provider: "cults3d",
|
||||
externalId: "likes:maker",
|
||||
url: "https://cults3d.com/en/users/Maker/likes",
|
||||
likesOf: "Maker",
|
||||
});
|
||||
expect(syncLikesUsername({ externalId: likes!.externalId, sourceUrl: likes!.url })).toBe("Maker");
|
||||
expect(parseSyncSource("https://cults3d.com/en/design-collections/Maker/freebies")).toMatchObject({
|
||||
provider: "cults3d",
|
||||
externalId: "maker/freebies",
|
||||
});
|
||||
});
|
||||
|
||||
it("only takes files from its CloudFront host and its own Scaleway bucket", () => {
|
||||
expect(isCults3dFileHost("download.cults3d.com")).toBe(true);
|
||||
expect(isCults3dFileHost("s3.fr-par.scw.cloud")).toBe(true);
|
||||
expect(isCults3dFileHost("cults3d.com")).toBe(false);
|
||||
expect(isCults3dFileHost("download.cults3d.com.evil.test")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Cults3D sign-in", () => {
|
||||
it("reads the session, clearance and User-Agent from a Cookie header or copied request headers", () => {
|
||||
expect(normalizeCults3dBrowser(`cf_clearance=${BROWSER.clearance}; _session_id=${BROWSER.session}`, "UA")).toEqual({
|
||||
session: BROWSER.session,
|
||||
clearance: BROWSER.clearance,
|
||||
userAgent: "UA",
|
||||
});
|
||||
// As Chrome's DevTools copies them: each name and value on lines of their own.
|
||||
const copied = `cookie\nusprivacy=1---; cf_clearance=${BROWSER.clearance}; _session_id=${BROWSER.session}\nuser-agent\nMozilla/5.0 (Macintosh) Chrome/152\n`;
|
||||
expect(normalizeCults3dBrowser(copied)).toEqual({
|
||||
session: BROWSER.session,
|
||||
clearance: BROWSER.clearance,
|
||||
userAgent: "Mozilla/5.0 (Macintosh) Chrome/152",
|
||||
});
|
||||
expect(normalizeCults3dBrowser(`Cookie: _session_id=${BROWSER.session}\nUser-Agent: Mozilla/5.0 Y`)).toEqual({
|
||||
session: BROWSER.session,
|
||||
clearance: null,
|
||||
userAgent: "Mozilla/5.0 Y",
|
||||
});
|
||||
expect(normalizeCults3dBrowser(`cf_clearance=${BROWSER.clearance}`)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Cults3D model details", () => {
|
||||
const creation = {
|
||||
slug: "ghostship-benchy",
|
||||
name: "Ghostship Benchy",
|
||||
description: "A benchy.\r\nBoooo",
|
||||
details: "Supports: none",
|
||||
tags: ["benchy", "ghost", "benchy"],
|
||||
price: { cents: 0, formatted: "€ 0.00" },
|
||||
category: { id: "Q2F0ZWdvcnkvMjM" },
|
||||
subCategories: [{ id: "Q2F0ZWdvcnkvNDM" }],
|
||||
illustrations: [
|
||||
{ imageUrl: "https://videos.cults3d.com/x=/516x516/filters:no_upscale()/https://fbi.cults3d.com/u/1/turn.mp4" },
|
||||
{
|
||||
imageUrl:
|
||||
"https://images.cults3d.com/y=/516x516/filters:no_upscale()/https://fbi.cults3d.com/uploaders/1/illustration-file/a/Ghost_Benchy.jpg",
|
||||
},
|
||||
],
|
||||
creator: { nick: "3rdesignworks", imageUrl: "https://images.cults3d.com/avatar.jpg", bio: "Hi", url: null },
|
||||
};
|
||||
|
||||
it("reads ids out of the API's base64 ones, and full-size pictures out of its thumbnails", () => {
|
||||
expect(cults3dNumericId("Q2F0ZWdvcnkvMjM")).toBe(23);
|
||||
expect(cults3dNumericId("nonsense")).toBeNull();
|
||||
expect(cults3dOriginalImage(creation.illustrations[1].imageUrl)).toBe(
|
||||
"https://fbi.cults3d.com/uploaders/1/illustration-file/a/Ghost_Benchy.jpg",
|
||||
);
|
||||
});
|
||||
|
||||
it("maps the details, its categories most specific first and the designer, leaving videos out", () => {
|
||||
const resolved = cults3dMetaFromCreation(creation)!;
|
||||
expect(resolved.slug).toBe("ghostship-benchy");
|
||||
expect(resolved.priceCents).toBe(0);
|
||||
expect(resolved.priceText).toBeNull();
|
||||
expect(resolved.meta).toMatchObject({
|
||||
title: "Ghostship Benchy",
|
||||
description: "A benchy.\nBoooo\n\n### 3D printing settings\n\nSupports: none",
|
||||
tags: ["benchy", "ghost"],
|
||||
previewImageUrl: "https://fbi.cults3d.com/uploaders/1/illustration-file/a/Ghost_Benchy.jpg",
|
||||
siteCategoryIds: [43, 23],
|
||||
categorySite: "cults3d",
|
||||
creator: "3rdesignworks",
|
||||
});
|
||||
expect(resolved.meta.author).toMatchObject({ provider: "cults3d", externalId: "3rdesignworks" });
|
||||
expect(resolved.galleryImages).toEqual([
|
||||
{ url: "https://fbi.cults3d.com/uploaders/1/illustration-file/a/Ghost_Benchy.jpg", filename: "Ghost_Benchy.jpg" },
|
||||
]);
|
||||
expect(cults3dMetaFromCreation({ ...creation, price: { cents: 299, formatted: "€ 2.99" } })?.priceText).toBe(
|
||||
"€ 2.99",
|
||||
);
|
||||
});
|
||||
|
||||
it("asks the API with the user's nickname and key, and tells a missing model and a rejected key apart", async () => {
|
||||
const calls = stubFetch(
|
||||
() => json({ data: { creation } }),
|
||||
() => json({ data: { creation: null } }),
|
||||
() => new Response("HTTP Basic: Access denied.", { status: 401 }),
|
||||
);
|
||||
expect((await resolveCults3dModel("ghostship-benchy", CREDENTIALS))?.meta.title).toBe("Ghostship Benchy");
|
||||
expect(calls[0].url.toString()).toBe("https://cults3d.com/graphql");
|
||||
expect(calls[0].headers.Authorization).toBe(`Basic ${Buffer.from("Maker:secret").toString("base64")}`);
|
||||
expect(graphqlOf(calls[0]).variables).toEqual({ slug: "ghostship-benchy" });
|
||||
expect(await resolveCults3dModel("gone", CREDENTIALS)).toBeNull();
|
||||
await expect(resolveCults3dModel("x", CREDENTIALS)).rejects.toBeInstanceOf(Cults3dAuthError);
|
||||
});
|
||||
|
||||
it("waits out the rate limit once it says when it resets", async () => {
|
||||
const reset = new Date(Date.now() + 50).toISOString();
|
||||
stubFetch(
|
||||
() => json({}, 429, { "x-ratelimit-reset": reset }),
|
||||
() => json({ data: { creation } }),
|
||||
);
|
||||
expect((await resolveCults3dModel("ghostship-benchy", CREDENTIALS))?.slug).toBe("ghostship-benchy");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Cults3D orders and listings", () => {
|
||||
it("pages through the orders, keeping each model's download page", async () => {
|
||||
const line = (slug: string) => ({
|
||||
downloadUrl: `https://cults3d.com/en/downloads/1?creation=${slug}`,
|
||||
creation: { slug },
|
||||
});
|
||||
const full = Array.from({ length: 100 }, (_, i) => ({ lines: [line(`model-${i}`)] }));
|
||||
const calls = stubFetch(
|
||||
() => json({ data: { myself: { ordersBatch: { total: 101, results: full } } } }),
|
||||
() => json({ data: { myself: { ordersBatch: { total: 101, results: [{ lines: [line("last")] }] } } } }),
|
||||
);
|
||||
const orders = await fetchCults3dOrders(CREDENTIALS);
|
||||
expect(orders.size).toBe(101);
|
||||
expect(orders.get("last")).toBe("https://cults3d.com/en/downloads/1?creation=last");
|
||||
expect(graphqlOf(calls[1]).variables).toEqual({ limit: 100, offset: 100 });
|
||||
});
|
||||
|
||||
it("lists a user's likes, leaving out paid models they don't own", async () => {
|
||||
stubFetch(() =>
|
||||
json({
|
||||
data: {
|
||||
user: {
|
||||
likedCreations: [
|
||||
{
|
||||
slug: "free",
|
||||
name: "Free",
|
||||
illustrationImageUrl: "https://images.cults3d.com/a.jpg",
|
||||
price: { cents: 0 },
|
||||
},
|
||||
{ slug: "paid", name: "Paid", illustrationImageUrl: null, price: { cents: 299 } },
|
||||
{
|
||||
slug: "owned",
|
||||
name: "Owned",
|
||||
illustrationImageUrl: "https://videos.cults3d.com/b.mp4",
|
||||
price: { cents: 199 },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const listing = await fetchCults3dLikes(CREDENTIALS, "someone", new Set(["owned"]));
|
||||
expect(listing).toEqual({
|
||||
entries: [
|
||||
{ slug: "free", title: "Free", cover: "https://images.cults3d.com/a.jpg" },
|
||||
{ slug: "owned", title: "Owned", cover: null },
|
||||
],
|
||||
total: 2,
|
||||
truncated: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("says when the user doesn't exist", async () => {
|
||||
stubFetch(() => json({ data: { user: null } }));
|
||||
expect(await fetchCults3dLikes(CREDENTIALS, "nobody", new Set())).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Cults3D downloads", () => {
|
||||
const page = `<form data-turbo="false" class="button_to" method="post" action="/en/free_orders?creation_slug=ghostship-benchy&x=1"><button type="submit">Download</button><input type="hidden" name="authenticity_token" value="tok+en/=="></form>`;
|
||||
|
||||
it("finds the free Download form, and none on a paid model", () => {
|
||||
expect(cults3dFreeOrderForm(page)).toEqual({
|
||||
action: "/en/free_orders?creation_slug=ghostship-benchy&x=1",
|
||||
token: "tok+en/==",
|
||||
});
|
||||
expect(
|
||||
cults3dFreeOrderForm('<form class="button_to" method="post" action="/en/lines?creation_id=1"></form>'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("orders a free model as the browser, with its cookies and User-Agent", async () => {
|
||||
const calls = stubFetch(
|
||||
() => redirect("https://cults3d.com/en/3d-model/art/ghostship-benchy", 301),
|
||||
() => new Response(page, { status: 200 }),
|
||||
() => redirect("https://cults3d.com/en/orders/169364417"),
|
||||
);
|
||||
await placeCults3dFreeOrder("ghostship-benchy", BROWSER);
|
||||
expect(calls[0].url.toString()).toBe("https://cults3d.com/en/creations/ghostship-benchy");
|
||||
expect(calls[0].headers.Cookie).toBe(`_session_id=${BROWSER.session}; cf_clearance=${BROWSER.clearance}`);
|
||||
expect(calls[0].headers["User-Agent"]).toBe("Mozilla/5.0 X");
|
||||
expect(calls[2].method).toBe("POST");
|
||||
expect(calls[2].url.toString()).toBe("https://cults3d.com/en/free_orders?creation_slug=ghostship-benchy&x=1");
|
||||
expect(new URLSearchParams(calls[2].body!).get("authenticity_token")).toBe("tok+en/==");
|
||||
expect(calls[2].headers.Referer).toBe("https://cults3d.com/en/3d-model/art/ghostship-benchy");
|
||||
});
|
||||
|
||||
it("tells an ended sign-in from a Cloudflare challenge", async () => {
|
||||
stubFetch(
|
||||
() => new Response(page, { status: 200 }),
|
||||
() => redirect("https://cults3d.com/en/log-in-choice"),
|
||||
);
|
||||
await expect(placeCults3dFreeOrder("ghostship-benchy", BROWSER)).rejects.toBeInstanceOf(Cults3dSessionError);
|
||||
stubFetch(() => new Response("Just a moment...", { status: 403, headers: { "cf-mitigated": "challenge" } }));
|
||||
await expect(placeCults3dFreeOrder("ghostship-benchy", BROWSER)).rejects.toBeInstanceOf(Cults3dBlockedError);
|
||||
});
|
||||
|
||||
it("follows the download page to its signed CloudFront link, and nowhere else", async () => {
|
||||
const signed =
|
||||
"https://download.cults3d.com/uploads/creation_zip/file/263230/ghostship-benchy.zip?Expires=1&Signature=s&Key-Pair-Id=k";
|
||||
stubFetch(() => redirect(signed));
|
||||
expect(
|
||||
await resolveCults3dDownload("https://cults3d.com/en/downloads/1?creation=ghostship-benchy", BROWSER),
|
||||
).toEqual({
|
||||
name: "ghostship-benchy.zip",
|
||||
url: signed,
|
||||
});
|
||||
const scaleway =
|
||||
"https://s3.fr-par.scw.cloud/cults-stl-prod/uploaders/2169353/blueprint-file/d/Tsoi.zip?X-Amz-Expires=900&X-Amz-Signature=s";
|
||||
stubFetch(() => redirect(scaleway));
|
||||
expect(await resolveCults3dDownload("https://cults3d.com/en/downloads/2?creation=viktor-tsoi", BROWSER)).toEqual({
|
||||
name: "Tsoi.zip",
|
||||
url: scaleway,
|
||||
});
|
||||
// Scaleway's host is everyone's: another bucket there isn't Cults3D's.
|
||||
stubFetch(() => redirect("https://s3.fr-par.scw.cloud/someone-else/file.zip"));
|
||||
await expect(resolveCults3dDownload("https://cults3d.com/en/downloads/2", BROWSER)).rejects.toThrow(/unexpected/);
|
||||
stubFetch(() => redirect("/en/users/sign-in"));
|
||||
await expect(resolveCults3dDownload("https://cults3d.com/en/downloads/1", BROWSER)).rejects.toBeInstanceOf(
|
||||
Cults3dSessionError,
|
||||
);
|
||||
stubFetch(() => redirect("https://elsewhere.test/file.zip"));
|
||||
await expect(resolveCults3dDownload("https://cults3d.com/en/downloads/1", BROWSER)).rejects.toThrow(/unexpected/);
|
||||
await expect(resolveCults3dDownload("https://elsewhere.test/en/downloads/1", BROWSER)).rejects.toThrow(
|
||||
/Not a Cults3D/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -40,6 +40,7 @@ afterAll(async () => {
|
||||
"crealitycloud",
|
||||
"nexprint",
|
||||
"thangs",
|
||||
"cults3d",
|
||||
] as const)
|
||||
await setProviderEnabled(provider, true);
|
||||
});
|
||||
@@ -58,13 +59,14 @@ describe("turning providers off", () => {
|
||||
crealitycloud: true,
|
||||
nexprint: true,
|
||||
thangs: true,
|
||||
cults3d: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
(await request(app).patch("/api/settings/providers/thingiverse").set(as(member)).send({ enabled: false })).status,
|
||||
).toBe(403);
|
||||
expect(
|
||||
(await request(app).patch("/api/settings/providers/cults3d").set(as(admin)).send({ enabled: false })).status,
|
||||
(await request(app).patch("/api/settings/providers/no-such-site").set(as(admin)).send({ enabled: false })).status,
|
||||
).toBe(404);
|
||||
|
||||
const off = await request(app).patch("/api/settings/providers/thingiverse").set(as(admin)).send({ enabled: false });
|
||||
@@ -79,6 +81,7 @@ describe("turning providers off", () => {
|
||||
crealitycloud: true,
|
||||
nexprint: true,
|
||||
thangs: true,
|
||||
cults3d: true,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+38
-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. **Creality Cloud** reads its models and collections with nothing, but gives files and likes only to each user's own sign-in, which Thingport Grab sends on. **NexPrint** reads its models and collections, private ones included, with nothing, and gives files only to each user's own sign-in, which Thingport Grab sends on. **Thangs** reads its models, likes and public collections with nothing, and gives files only to each user's own sign-in, which Thingport Grab sends on and Thingport keeps renewed for about a year.
|
||||
Importing from **Printables** works out of the box (its public API needs no credential). **MakerWorld** needs each user's own session cookie, and **Thingiverse** an App Token an admin sets once for the whole instance. **MyMiniFactory** needs an API key an admin sets once for free models; each user connects their own MyMiniFactory session only to import paid or sign-in-only models from their library. **Snapmaker Space** needs each user's own sign-in, which [Thingport Grab](../extension/README.md) keeps fresh. **MakerOnline** reads its models and public collections with nothing, but hands out files only through Thingport Grab; each user's own sign-in adds their likes and private collections. **Creality Cloud** reads its models and collections with nothing, but gives files and likes only to each user's own sign-in, which Thingport Grab sends on. **NexPrint** reads its models and collections, private ones included, with nothing, and gives files only to each user's own sign-in, which Thingport Grab sends on. **Thangs** reads its models, likes and public collections with nothing, and gives files only to each user's own sign-in, which Thingport Grab sends on and Thingport keeps renewed for about a year. **Cults3D** is read with each user's own API key, and gives files only to the user's browser 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.
|
||||
|
||||
@@ -193,6 +193,43 @@ again.
|
||||
4. Right-click **Thangs** in Thingport's **Configuration > Providers**, choose **Connect** and paste it. Thingport checks
|
||||
it with Thangs before storing it.
|
||||
|
||||
## Cults3D
|
||||
|
||||
Per-user (Configuration > Providers > Cults3D), in two parts.
|
||||
|
||||
**Your API key** reads everything but the files: model details, categories, anyone's liked models, your own printlists
|
||||
(Cults3D's collections; its API shows no one else's) and your downloads. Make one at
|
||||
[cults3d.com/en/api/keys](https://cults3d.com/en/api/keys), then right-click **Cults3D** in **Configuration >
|
||||
Providers**, choose **Connect**, and enter your nickname exactly as your profile writes it (its capitals count) and the
|
||||
key's secret. Thingport checks them with Cults3D before storing them.
|
||||
|
||||
**Your browser's sign-in** downloads the files. They're on Cults3D's own pages, behind a Cloudflare check only a browser
|
||||
passes, so Thingport uses your browser's: the `_session_id` sign-in cookie, and the `cf_clearance` cookie Cloudflare
|
||||
gave your browser, sent with that browser's User-Agent. Cloudflare ties that clearance to your IP address too, so it
|
||||
only works while Thingport reaches the internet from the same address as your browser, as a server at home does.
|
||||
FlareSolverr doesn't help here: its browser can't hand over a download link. The sign-in lasts 20 days from its last
|
||||
use, and Thingport's own use renews it.
|
||||
|
||||
- **Free models** you haven't downloaded yet are added to your Cults3D downloads first, as the site's own Download
|
||||
button does. A model comes in as the zip Cults3D builds of its files, each one its own file in Thingport.
|
||||
- **Paid models** download only once you've bought them. Paid models you don't own are left out of likes and printlist
|
||||
imports and syncs.
|
||||
|
||||
### Easiest: Thingport Grab
|
||||
|
||||
With [Thingport Grab](../extension/README.md) allowed on Cults3D, opening cults3d.com while you're signed in sends the
|
||||
sign-in and clearance, and again whenever they change. If Cults3D turns them down, Thingport says so, and your next
|
||||
visit sends new ones.
|
||||
|
||||
### Manual: copy it from your browser
|
||||
|
||||
1. Sign in at [cults3d.com](https://cults3d.com).
|
||||
2. Open DevTools (`F12`, or `Cmd+Option+I` on macOS) and reload the page.
|
||||
3. On the **Network** tab, pick the page's own request and copy its request headers, with the `Cookie` and
|
||||
`User-Agent` ones.
|
||||
4. Right-click **Cults3D** in Thingport's **Configuration > Providers**, choose **Paste browser sign-in** and paste
|
||||
them.
|
||||
|
||||
## 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, MakerOnline, Creality Cloud, NexPrint or Thangs off for everyone, and set the Thingiverse App Token and the MyMiniFactory API key. See [turning a provider off](../PROVIDER_SETUP.md#turning-a-provider-off). |
|
||||
| Storage | The folder layout on disk, reorganizing existing files, browsing the storage folder, and the [consume folder](../guides/consume-folder.md). See [plain files on your disk](storage.md). |
|
||||
| Auth | The public URL, open or invite-only registration, [single sign-on](../guides/single-sign-on.md) providers, and how long a sign-in lasts. |
|
||||
| Rendering | How card thumbnails are made, and simplifying the previews of very detailed models. See [3D previews](3d-previews.md). |
|
||||
| AI | AI models and the [AI features](../guides/ai.md): categorization and improved descriptions. |
|
||||
| Users | Everyone's accounts, roles and sign-in methods. See [multi-user](multi-user.md). |
|
||||
| Import queue | Imports waiting to run, such as links sent from Thingport Grab: start, pause, retry or clear them. |
|
||||
| Logs | What each person did, by person and date: sign-ins, imports, edits, deletions, syncs, role changes, provider switches and AI runs. |
|
||||
| Triggers | One-off maintenance jobs: link missing authors, refetch imported descriptions, or delete all of a user's models. |
|
||||
| Connections | FlareSolverr, email (SMTP) and the database, each tested before it's saved. |
|
||||
| Captcha | Where to ask for a captcha: sign-in, registration, import, and password and email changes. |
|
||||
| Page | What it's for |
|
||||
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Overview | [System Health](../guides/system-health.md) for everything Thingport relies on, disk space used, and the update check, which says when newer Thingport images are out. |
|
||||
| Providers | Turn MakerWorld, Printables, Thingiverse, MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud, NexPrint, Thangs or Cults3D off for everyone, and set the Thingiverse App Token and the MyMiniFactory API key. See [turning a provider off](../PROVIDER_SETUP.md#turning-a-provider-off). |
|
||||
| Storage | The folder layout on disk, reorganizing existing files, browsing the storage folder, and the [consume folder](../guides/consume-folder.md). See [plain files on your disk](storage.md). |
|
||||
| Auth | The public URL, open or invite-only registration, [single sign-on](../guides/single-sign-on.md) providers, and how long a sign-in lasts. |
|
||||
| Rendering | How card thumbnails are made, and simplifying the previews of very detailed models. See [3D previews](3d-previews.md). |
|
||||
| AI | AI models and the [AI features](../guides/ai.md): categorization and improved descriptions. |
|
||||
| Users | Everyone's accounts, roles and sign-in methods. See [multi-user](multi-user.md). |
|
||||
| Import queue | Imports waiting to run, such as links sent from Thingport Grab: start, pause, retry or clear them. |
|
||||
| Logs | What each person did, by person and date: sign-ins, imports, edits, deletions, syncs, role changes, provider switches and AI runs. |
|
||||
| Triggers | One-off maintenance jobs: link missing authors, refetch imported descriptions, or delete all of a user's models. |
|
||||
| Connections | FlareSolverr, email (SMTP) and the database, each tested before it's saved. |
|
||||
| Captcha | Where to ask for a captcha: sign-in, registration, import, and password and email changes. |
|
||||
|
||||
After installing, and whenever something stops working, start with
|
||||
[System Health](../guides/system-health.md): ideally everything there is green.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Import from MakerWorld, Printables, Thingiverse, MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud, NexPrint and Thangs
|
||||
# Import from MakerWorld, Printables, Thingiverse, MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud, NexPrint, Thangs and Cults3D
|
||||
|
||||
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,17 +6,17 @@ back to where it came from. If the original page disappears, the model is still
|
||||
## Ways to import
|
||||
|
||||
- **Paste a link.** Click **Add → Import** and paste a model link from MakerWorld, Printables, Thingiverse,
|
||||
MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud, NexPrint or Thangs, or any direct link to a model file. Paste several links, one per line, and they're imported one after
|
||||
MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud, NexPrint, Thangs or Cults3D, 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 nine sites (MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud, NexPrint and Thangs once you allow them, with a single click in its popup), so you can
|
||||
all ten sites (MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud, NexPrint, Thangs and Cults3D 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.
|
||||
This is the recommended way to import.
|
||||
- **Or use the bookmarklet.** Without the extension, drag the [bookmarklet](../guides/bookmarklet.md) to your
|
||||
bookmarks bar: clicking it on a model's page opens Thingport with the link already in the import dialog.
|
||||
- **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,
|
||||
and your Creality Cloud and Thangs likes from their page, in Thingport or with Grab. MakerWorld collections are imported with Thingport Grab.
|
||||
and your Creality Cloud and Thangs likes, and anyone's Cults3D 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.
|
||||
@@ -48,7 +48,7 @@ admin; MyMiniFactory models you bought come in once you connect your MyMiniFacto
|
||||
own sign-in, which Thingport Grab keeps fresh; MakerOnline's files come only through Thingport Grab; and Creality Cloud
|
||||
and NexPrint need your own sign-in, which Grab sends to Thingport when you visit them. Thangs needs your own sign-in
|
||||
too, which Thingport keeps renewed once Grab has sent it; free Thangs accounts download a limited number of models a
|
||||
day. See
|
||||
day. Cults3D needs your own API key for everything, and your browser's sign-in, which Grab sends, for its files. 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.
|
||||
|
||||
@@ -32,7 +32,7 @@ The copy button needs Thingport to be opened over HTTPS or on `localhost`. On a
|
||||
## Use it
|
||||
|
||||
1. Open a model's page on any [supported site](../features/importing.md): MakerWorld, Printables, Thingiverse,
|
||||
MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud, NexPrint or Thangs.
|
||||
MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud, NexPrint, Thangs or Cults3D.
|
||||
2. Click **Send to Thingport** in the bookmarks bar.
|
||||
3. Thingport opens with the link in the import dialog. Pick the print profiles to bring in if the site has them, then
|
||||
click **Import**.
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# Synced collections
|
||||
|
||||
A synced collection follows a collection on MakerWorld, Printables, Thingiverse, MyMiniFactory, MakerOnline, Creality
|
||||
Cloud, NexPrint or Thangs, your MakerOnline, Creality Cloud or Thangs likes, or your likes or bookmarks on Snapmaker Space. When a model is added
|
||||
Cloud, NexPrint, Thangs or Cults3D, your MakerOnline, Creality Cloud or Thangs likes, anyone's Cults3D 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.
|
||||
|
||||
@@ -37,7 +38,9 @@ page again.
|
||||
sends the new one on your next visit. NexPrint reads collections, private ones too, with nothing, but its files need
|
||||
your sign-in, which Grab sends on as you visit nexprint.com. Every Thangs sync needs your sign-in, which Thingport keeps
|
||||
renewed; its paid models you don't own and its print-only ones are left out, and once your account's daily
|
||||
downloads are used up, the rest come in on a later check.
|
||||
downloads are used up, the rest come in on a later check. Cults3D syncs read your likes, anyone's likes and your own
|
||||
printlists with your API key, and download with the browser sign-in Grab sends; its paid models you don't own are
|
||||
left out, and if its Cloudflare turns Thingport away, the rest come in after your next visit to cults3d.com with Grab.
|
||||
|
||||
## Turn sync on in Thingport
|
||||
|
||||
@@ -45,13 +48,14 @@ 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, Creality Cloud and Thangs, your likes and your collections; on NexPrint, your collections. Or paste any
|
||||
Space, your likes and bookmarks; on MakerOnline, Creality Cloud and Thangs, your likes and your collections; on
|
||||
NexPrint, your collections; on Cults3D, your likes and your printlists. Or paste any
|
||||
collection's link, yours or someone else's.
|
||||
4. Pick a collection and click **Sync with "…"**. **Settings** sets how often it's checked, and for MakerWorld,
|
||||
Snapmaker Space, MakerOnline, Creality Cloud and NexPrint which print profiles each new model brings, before you do.
|
||||
|
||||
What happens to the models already in the provider's collection depends on the provider. On Printables, Thingiverse,
|
||||
MyMiniFactory, Snapmaker Space, Creality Cloud, NexPrint and Thangs they're imported now, one at a time. MakerWorld rate-limits bulk imports quickly, so there only models
|
||||
MyMiniFactory, Snapmaker Space, Creality Cloud, NexPrint, Thangs and Cults3D 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.
|
||||
|
||||
@@ -74,7 +78,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 and Creality Cloud, a
|
||||
collection page or your own **Likes** page; on NexPrint, a collection open on its owner's page; on Thangs, a collection page or your own **Liked** page.
|
||||
collection page or your own **Likes** page; on NexPrint, a collection open on its owner's page; on Thangs, a collection page or your own **Liked** page; on Cults3D, anyone's likes page or one of your own
|
||||
printlists.
|
||||
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).
|
||||
|
||||
+20
-13
@@ -3,7 +3,7 @@
|
||||
_Effective 20 October 2026._
|
||||
|
||||
Thingport Grab is a browser extension that imports 3D-printing models from MakerWorld, Printables,
|
||||
Thingiverse and, once you allow them, MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud, NexPrint and Thangs into **your own self-hosted Thingport instance**, and converts MakerWorld files for
|
||||
Thingiverse and, once you allow them, MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud, NexPrint, Thangs and Cults3D 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.
|
||||
|
||||
@@ -38,6 +38,8 @@ the list of recent imports, which is synced between your browsers as described b
|
||||
when it changes.
|
||||
- **Your Thangs sign-in (its `accessToken` and `refreshToken` cookies),** as last sent to your Thingport instance, so
|
||||
it's only sent again when it changes.
|
||||
- **Your Cults3D sign-in (its `_session_id` and `cf_clearance` cookies, and your browser's User-Agent),** 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.
|
||||
@@ -53,7 +55,7 @@ Snapmaker sign-in at id.snapmaker.com every few hours while the browser is open,
|
||||
|
||||
- Your email and password, to sign in.
|
||||
- The address of each model page you open on MakerWorld, Printables, Thingiverse, MyMiniFactory, Snapmaker Space,
|
||||
MakerOnline, Creality Cloud, NexPrint or Thangs, to check
|
||||
MakerOnline, Creality Cloud, NexPrint, Thangs or Cults3D, 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.
|
||||
@@ -86,6 +88,11 @@ Snapmaker sign-in at id.snapmaker.com every few hours while the browser is open,
|
||||
`accessToken` and `refreshToken` cookies), when you open a Thangs page and it changed since last sent. Your Thingport
|
||||
instance uses it to download Thangs models and read your collections there, and renews it with Thangs' own sign-in
|
||||
service as it runs out.
|
||||
- If you're signed in to Cults3D in this browser, and have allowed the extension there: your Cults3D sign-in (its
|
||||
`_session_id` cookie), the `cf_clearance` cookie Cloudflare gave this browser for the site, and your browser's
|
||||
User-Agent, which that cookie is tied to, when you open a signed-in Cults3D page and they changed since last sent.
|
||||
Your Thingport instance uses them to add free models to your Cults3D downloads and to download your models from
|
||||
there, as this browser would.
|
||||
- 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.
|
||||
@@ -97,7 +104,7 @@ Snapmaker sign-in at id.snapmaker.com every few hours while the browser is open,
|
||||
included), the models you liked there, and the models in the private collections you sync. Only
|
||||
you can see these on Printables, so this is how Thingport can sync them.
|
||||
|
||||
**To MakerWorld, Printables, Thingiverse, MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud, NexPrint and Thangs:**
|
||||
**To MakerWorld, Printables, Thingiverse, MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud, NexPrint, Thangs and Cults3D:**
|
||||
|
||||
- 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.
|
||||
@@ -144,16 +151,16 @@ page's header. It doesn't read any other websites.
|
||||
|
||||
## Permissions
|
||||
|
||||
| Permission | Why |
|
||||
| ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Storage | Keeps the settings and data listed under "What the extension stores". |
|
||||
| Cookies | Reads MakerWorld's, Printables', MyMiniFactory's, MakerOnline's, Creality Cloud's, NexPrint's and Thangs' session cookies, and reads and renews Snapmaker Space's sign-in cookies, only on their sites (see above). |
|
||||
| Downloads | Reads the link from the MakerWorld download it starts, then cancels and removes that download. |
|
||||
| Scripting | Adds the import button to MyMiniFactory's, Snapmaker Space's, MakerOnline's, Creality Cloud's, NexPrint's and Thangs' pages, and those of sites added later, once you allow them; on MakerOnline, also asks for file links from within the page (see above). |
|
||||
| Alarms | Renews the Snapmaker Space sign-in every few hours, once you've allowed Snapmaker Space. |
|
||||
| Access to MakerWorld, Printables and Thingiverse | Shows the import button, and MakerWorld's Download normalized button, on their pages. |
|
||||
| Access to newer model sites (optional) | Asked for one site at a time, only when you click **Enable** next to it under **Providers** in the popup (MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud, NexPrint, Thangs, and sites added in later updates). You can take this access back in your browser's extension settings. |
|
||||
| Access to your Thingport instance | Requested for that one address when you set the extension up, so it can talk to your instance. |
|
||||
| Permission | Why |
|
||||
| ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Storage | Keeps the settings and data listed under "What the extension stores". |
|
||||
| Cookies | Reads MakerWorld's, Printables', MyMiniFactory's, MakerOnline's, Creality Cloud's, NexPrint's, Thangs' and Cults3D's session cookies, and reads and renews Snapmaker Space's sign-in cookies, only on their sites (see above). |
|
||||
| Downloads | Reads the link from the MakerWorld download it starts, then cancels and removes that download. |
|
||||
| Scripting | Adds the import button to MyMiniFactory's, Snapmaker Space's, MakerOnline's, Creality Cloud's, NexPrint's, Thangs' and Cults3D's pages, and those of sites added later, once you allow them; on MakerOnline, also asks for file links from within the page (see above). |
|
||||
| Alarms | Renews the Snapmaker Space sign-in every few hours, once you've allowed Snapmaker Space. |
|
||||
| Access to MakerWorld, Printables and Thingiverse | Shows the import button, and MakerWorld's Download normalized button, on their pages. |
|
||||
| Access to newer model sites (optional) | Asked for one site at a time, only when you click **Enable** next to it under **Providers** in the popup (MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud, NexPrint, Thangs, Cults3D, 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
|
||||
|
||||
|
||||
+14
-3
@@ -1,7 +1,7 @@
|
||||
# Thingport Grab
|
||||
|
||||
A browser extension for Chrome, Edge and Firefox that imports MakerWorld, Thingiverse, Printables
|
||||
MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud, NexPrint and Thangs models into your self-hosted Thingport instance without leaving the provider's site.
|
||||
MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud, NexPrint, Thangs and Cults3D 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.
|
||||
@@ -221,12 +221,23 @@ download it, which costs none of your daily downloads, and says so up front for
|
||||
one you don't own. On your own **Liked** page, or any collection page, the panel imports the listed models and can keep
|
||||
them in sync.
|
||||
|
||||
### Cults3D: your API key, and the browser's sign-in
|
||||
|
||||
Thingport reads Cults3D with your own API key, which you enter once in **Configuration > Providers**. Its files sit
|
||||
behind a Cloudflare check only your browser passes, so opening cults3d.com signed in sends Thingport the sign-in and
|
||||
the clearance Cloudflare gave this browser; your instance then downloads as this browser would. That only works while
|
||||
your instance reaches the internet from the same address as the browser, as it does at home. A free model you haven't
|
||||
downloaded yet is added to your Cults3D downloads first, as its own Download button does; on a paid one you don't own,
|
||||
the panel says so up front. On anyone's likes page, or one of your own printlists, 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,
|
||||
MakerOnline, Creality Cloud, NexPrint or Thangs model) -- hidden automatically once you've already imported that exact page.
|
||||
MakerOnline, Creality Cloud, NexPrint, Thangs or Cults3D model) -- hidden automatically once you've already imported that exact page.
|
||||
- A MakerWorld collection, a Thingiverse Collection or Likes page, a Printables, MyMiniFactory, MakerOnline, Creality
|
||||
Cloud, NexPrint or Thangs collection, your MakerOnline, Creality Cloud or Thangs likes, or your own Snapmaker Space likes and bookmarks -- lets you import the listed designs in one go.
|
||||
Cloud, NexPrint, Thangs or Cults3D collection, your MakerOnline, Creality Cloud or Thangs likes, anyone's Cults3D likes,
|
||||
or your own Snapmaker Space likes and bookmarks -- lets you import the listed designs in one go.
|
||||
|
||||
Picking a destination collection is only offered for a single-model import; a batch import instead
|
||||
lands in Thingport's own auto-named collection for that batch (e.g. "Thingiverse likes (@username)"), matching
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
Thingport Grab {{version}}. Changes in this version: {{changes}}
|
||||
|
||||
WHAT IT DOES
|
||||
Imports 3D-printing models from MakerWorld, Printables, Thingiverse and, once turned on, MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud, NexPrint and Thangs into the user's own self-hosted Thingport server (open source: https://github.com/TautvydasDerzinskas/Thingport). It has no servers of its own and sends nothing to the developer or any third party.
|
||||
Imports 3D-printing models from MakerWorld, Printables, Thingiverse and, once turned on, MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud, NexPrint, Thangs and Cults3D into the user's own self-hosted Thingport server (open source: https://github.com/TautvydasDerzinskas/Thingport). It has no servers of its own and sends nothing to the developer or any third party.
|
||||
|
||||
HOW TO TEST
|
||||
1. A Thingport server is required. {{testServer}}
|
||||
@@ -11,10 +11,10 @@ HOW TO TEST
|
||||
|
||||
PERMISSIONS
|
||||
- storage: the server address, sign-in and recent imports.
|
||||
- cookies: reads the MakerWorld session cookie, on makerworld.com only, which MakerWorld requires for downloads, and the MyMiniFactory session cookie, on myminifactory.com only, which downloads the user's bought models from their own library, and reads and renews the Snapmaker Space sign-in cookies, on snapmaker.com only, which Snapmaker requires for any download, and the MakerOnline sign-in cookie, on makeronline.com only, which reads the user's likes and private collections, and the Creality Cloud sign-in cookies, on crealitycloud.com only, which Creality Cloud requires for downloads, and the NexPrint sign-in cookie, on nexprint.com only, which NexPrint requires for downloads, and the Thangs sign-in cookies, on thangs.com only, which Thangs requires for downloads. The MakerWorld and MyMiniFactory cookies and the Snapmaker, MakerOnline, Creality Cloud, NexPrint and Thangs sign-ins are sent only to the user's own server; the Snapmaker refresh token is sent only to Snapmaker's own sign-in service (id.snapmaker.com), to renew the sign-in.
|
||||
- cookies: reads the MakerWorld session cookie, on makerworld.com only, which MakerWorld requires for downloads, and the MyMiniFactory session cookie, on myminifactory.com only, which downloads the user's bought models from their own library, and reads and renews the Snapmaker Space sign-in cookies, on snapmaker.com only, which Snapmaker requires for any download, and the MakerOnline sign-in cookie, on makeronline.com only, which reads the user's likes and private collections, and the Creality Cloud sign-in cookies, on crealitycloud.com only, which Creality Cloud requires for downloads, and the NexPrint sign-in cookie, on nexprint.com only, which NexPrint requires for downloads, and the Thangs sign-in cookies, on thangs.com only, which Thangs requires for downloads, and the Cults3D sign-in and Cloudflare clearance cookies, on cults3d.com only, which Cults3D's download pages require. The MakerWorld and MyMiniFactory cookies and the Snapmaker, MakerOnline, Creality Cloud, NexPrint, Thangs and Cults3D sign-ins are sent only to the user's own server; the Snapmaker refresh token is sent only to Snapmaker's own sign-in service (id.snapmaker.com), to renew the sign-in.
|
||||
- downloads: reads the link from a download MakerWorld's page starts, then cancels it.
|
||||
- alarms: renews the Snapmaker Space sign-in every few hours, as it lasts a day; only once the user has turned Snapmaker Space on.
|
||||
- scripting: registers the content script on MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud, NexPrint and Thangs, and on model sites added in later versions, once the user allows them. On MakerOnline it also runs a function in the page itself (world: MAIN) that calls the site's own download endpoint, as its Download button does: MakerOnline signs file links only for requests carrying a token from its own in-page security script.
|
||||
- host access: the three model sites; the user's own server address (requested at setup); and, optionally, newer model sites (such as MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud, NexPrint and Thangs), each requested only when the user clicks "Enable" next to it under Providers in the popup, so model sites added in later versions need no new required permission. The content script is only ever registered on supported model sites.
|
||||
- scripting: registers the content script on MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud, NexPrint, Thangs and Cults3D, and on model sites added in later versions, once the user allows them. On MakerOnline it also runs a function in the page itself (world: MAIN) that calls the site's own download endpoint, as its Download button does: MakerOnline signs file links only for requests carrying a token from its own in-page security script.
|
||||
- host access: the three model sites; the user's own server address (requested at setup); and, optionally, newer model sites (such as MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud, NexPrint, Thangs and Cults3D), 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,44 @@
|
||||
// The browser's Cults3D sign-in, which opens the site's order and download pages: the _session_id cookie, and the
|
||||
// cf_clearance cookie Cloudflare gave this browser, with the User-Agent it gave it to. Thingport's server sends them
|
||||
// as this browser would; the clearance only holds from this browser's IP address. Sent on when they change.
|
||||
|
||||
import { STORAGE_KEYS } from "../shared/storage";
|
||||
import { apiCall, requireConfig } from "./api";
|
||||
|
||||
const SITE = "https://cults3d.com";
|
||||
|
||||
async function cookieValue(name: string): Promise<string> {
|
||||
try {
|
||||
return (await chrome.cookies.get({ url: SITE, name }))?.value ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** Sends the instance the browser's Cults3D sign-in when it changed. Best-effort: never throws. */
|
||||
export async function syncCults3dSession(): Promise<void> {
|
||||
const config = await requireConfig().catch(() => null);
|
||||
if (!config) return;
|
||||
const [session, clearance] = await Promise.all([cookieValue("_session_id"), cookieValue("cf_clearance")]);
|
||||
// Per Thingport account, so switching the extension to another one sends it there too.
|
||||
const account = `${config.instanceUrl} ${config.email}`;
|
||||
const last = config.lastSyncedCults3dSession;
|
||||
if (!session) {
|
||||
// Signed out there: clear the sign-in Grab sent, but not one pasted in Thingport.
|
||||
if (last?.account === account) {
|
||||
try {
|
||||
await apiCall("PATCH", "/settings/cults3d-session", { session: null });
|
||||
await chrome.storage.local.remove(STORAGE_KEYS.lastSyncedCults3dSession);
|
||||
} catch {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Only asked for from a signed-in page (see content/cults3d.ts): visitors get a session cookie too.
|
||||
const cookie = `_session_id=${session}${clearance ? `; cf_clearance=${clearance}` : ""}`;
|
||||
const value = `${cookie} ${navigator.userAgent}`;
|
||||
if (last?.account === account && last.value === value) return;
|
||||
try {
|
||||
await apiCall("PATCH", "/settings/cults3d-session", { session: cookie, user_agent: navigator.userAgent });
|
||||
await chrome.storage.local.set({ [STORAGE_KEYS.lastSyncedCults3dSession]: { account, value } });
|
||||
} catch {}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import { importMakeronlineModel, runWaitingImports, syncMakeronlineSession } fro
|
||||
import { syncCrealitycloudSession } from "./crealitycloudSession";
|
||||
import { syncNexprintSession } from "./nexprintSession";
|
||||
import { syncThangsSession } from "./thangsSession";
|
||||
import { syncCults3dSession } from "./cults3dSession";
|
||||
import { onPairingTabClosed, onPairingTabUrl, startPairing } from "./pairing";
|
||||
import { openSetup } from "./setup";
|
||||
import { setTabIconState } from "./tabIcon";
|
||||
@@ -48,6 +49,7 @@ listen<BackgroundMessages>({
|
||||
SYNC_CREALITYCLOUD_SESSION: () => syncCrealitycloudSession(),
|
||||
SYNC_NEXPRINT_SESSION: () => syncNexprintSession(),
|
||||
SYNC_THANGS_SESSION: () => syncThangsSession(),
|
||||
SYNC_CULTS3D_SESSION: () => syncCults3dSession(),
|
||||
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,19 @@
|
||||
// What a Cults3D page shows of the signed-in user and of the model, read from the page itself.
|
||||
|
||||
/** Links only a signed-in user's pages have: their own designs, messages and profile settings. A visitor gets a
|
||||
* session cookie too, and sending that on would replace a working sign-in with one that isn't. */
|
||||
export function cults3dSignedIn(): boolean {
|
||||
return Boolean(
|
||||
document.querySelector('a[href$="/creations/mine"], a[href$="/my-messages"], a[href$="/profile/edit"]'),
|
||||
);
|
||||
}
|
||||
|
||||
/** A paid model the user hasn't bought: its button puts it in the cart, and the page has no download of it. A bought
|
||||
* one comes from the user's downloads, so it imports like a free one. */
|
||||
export function isUnboughtCults3dModel(): boolean {
|
||||
const addsToCart = Boolean(document.querySelector('form[action*="/lines?creation_id="]'));
|
||||
const downloads = Boolean(
|
||||
document.querySelector('form[action*="/free_orders?"], a[href*="/downloads/"], a[href*="/orders/"]'),
|
||||
);
|
||||
return addsToCart && !downloads;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// Batch flow for Thingiverse Likes/Collections, Printables and MyMiniFactory Collections, Snapmaker Space likes and
|
||||
// bookmarks, MakerOnline collections and likes, Creality Cloud collections and likes, NexPrint collections, and Thangs
|
||||
// collections and likes.
|
||||
// bookmarks, MakerOnline collections and likes, Creality Cloud collections and likes, NexPrint collections, Thangs
|
||||
// collections and likes, and Cults3D printlists and likes.
|
||||
// MakerWorld collections use the guided flow in
|
||||
// makerworldCollection.ts.
|
||||
|
||||
@@ -29,9 +29,12 @@ type BatchKey =
|
||||
| "crealitycloud:likes"
|
||||
| "nexprint:collection"
|
||||
| "thangs:collection"
|
||||
| "thangs:likes";
|
||||
| "thangs:likes"
|
||||
| "cults3d:collection"
|
||||
| "cults3d:likes";
|
||||
|
||||
const THANGS_LIST = { entries: "/import/thangs-list/entries", start: "/import/thangs-list", idField: "model_ids" };
|
||||
const CULTS3D_LIST = { entries: "/import/cults3d-list/entries", start: "/import/cults3d-list", idField: "model_ids" };
|
||||
|
||||
const SNAPMAKER_LIST = {
|
||||
entries: "/import/snapmaker-list/entries",
|
||||
@@ -86,6 +89,8 @@ const BATCH_ENDPOINTS: Record<BatchKey, { entries: string; start: string; idFiel
|
||||
},
|
||||
"thangs:collection": THANGS_LIST,
|
||||
"thangs:likes": THANGS_LIST,
|
||||
"cults3d:collection": CULTS3D_LIST,
|
||||
"cults3d:likes": CULTS3D_LIST,
|
||||
};
|
||||
|
||||
const SNAPMAKER_LISTS: { list: SnapmakerList; label: string }[] = [
|
||||
|
||||
@@ -17,6 +17,7 @@ export const SITE_NAMES: Record<Provider, string> = {
|
||||
crealitycloud: "Creality Cloud",
|
||||
nexprint: "NexPrint",
|
||||
thangs: "Thangs",
|
||||
cults3d: "Cults3D",
|
||||
};
|
||||
|
||||
/** The instance inspects the link: finds the file, reads its title, checks whether it's a zip. */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Thingiverse, Printables, MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud, NexPrint and Thangs 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).
|
||||
// Thingiverse, Printables, MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud, NexPrint, Thangs and Cults3D
|
||||
// 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).
|
||||
|
||||
import type { FillGapsResult, InspectResult, SourceGap, ZipEntriesResult } from "../../shared/api";
|
||||
import { request } from "../../shared/messages";
|
||||
@@ -22,6 +22,7 @@ import { loadMakeronlineProfiles } from "../makeronline";
|
||||
import { loadCrealitycloudProfiles } from "../crealitycloud";
|
||||
import { loadNexprintProfiles } from "../nexprint";
|
||||
import { thangsDownloadBlock } from "../thangs";
|
||||
import { isUnboughtCults3dModel } from "../cults3d";
|
||||
import { onPanelAction, panelQuery, panelQueryAll, renderPanel } from "../shell";
|
||||
import { collectionIdFor, collectionPickerHtml, readCollectionChoice, wireCollectionPicker } from "./collectionPicker";
|
||||
import { errorHtml, statusHtml, successHtml } from "./results";
|
||||
@@ -147,6 +148,13 @@ export async function loadSingleItem(): Promise<void> {
|
||||
`);
|
||||
return;
|
||||
}
|
||||
if (provider === "cults3d" && isUnboughtCults3dModel()) {
|
||||
renderPanel(`
|
||||
<div class="tg-title">Paid model</div>
|
||||
<div class="tg-hint">Buy it on Cults3D first. Once it's in your downloads, Thingport imports it from there.</div>
|
||||
`);
|
||||
return;
|
||||
}
|
||||
renderFunStatus(checkingLinkPhrases(provider));
|
||||
// Thangs counts every download against the account's daily ones, so one it would refuse isn't tried.
|
||||
const thangsBlock = provider === "thangs" ? await thangsDownloadBlock(ctx().url) : null;
|
||||
@@ -165,7 +173,8 @@ export async function loadSingleItem(): Promise<void> {
|
||||
(provider === "makeronline" && type === "model") ||
|
||||
(provider === "crealitycloud" && type === "model") ||
|
||||
(provider === "nexprint" && type === "model") ||
|
||||
(provider === "thangs" && type === "model");
|
||||
(provider === "thangs" && type === "model") ||
|
||||
(provider === "cults3d" && type === "model");
|
||||
|
||||
let zipFilename: string | null = null;
|
||||
if (skipInspect) {
|
||||
|
||||
@@ -17,6 +17,7 @@ const PROVIDER_NAMES: Record<string, string> = {
|
||||
crealitycloud: "Creality Cloud",
|
||||
nexprint: "NexPrint",
|
||||
thangs: "Thangs",
|
||||
cults3d: "Cults3D",
|
||||
};
|
||||
|
||||
/** A Thingiverse user's Likes page, which a sync follows like a collection. */
|
||||
|
||||
@@ -6,6 +6,7 @@ import { request, send } from "../shared/messages";
|
||||
import { printablesToken, readMe, readPrintables } from "./printables";
|
||||
import { providerEnabled } from "./providerSettings";
|
||||
import { sleep } from "./runtime";
|
||||
import { cults3dSignedIn } from "./cults3d";
|
||||
|
||||
// The header is drawn after load; give up quietly if it never shows (e.g. signed out).
|
||||
const HEADER_WAIT_MS = 15_000;
|
||||
@@ -99,6 +100,13 @@ export function captureProviderAccount(hostname: string): void {
|
||||
void send("SYNC_NEXPRINT_SESSION");
|
||||
return;
|
||||
}
|
||||
if (/(^|\.)cults3d\.com$/i.test(hostname)) {
|
||||
captured = true;
|
||||
// Its sign-in and Cloudflare clearance open the order and download pages; sent once they change, and only from a
|
||||
// signed-in page.
|
||||
if (cults3dSignedIn()) void send("SYNC_CULTS3D_SESSION");
|
||||
return;
|
||||
}
|
||||
if (/(^|\.)thangs\.com$/i.test(hostname)) {
|
||||
captured = true;
|
||||
// Its sign-in downloads the files; Thingport renews it from the refresh token, so it's sent once it changes.
|
||||
|
||||
@@ -17,7 +17,7 @@ type Logo = {
|
||||
};
|
||||
|
||||
/** Listed so people know they're on the way; Grab does nothing on them yet. */
|
||||
const COMING_SOON = [{ id: "cults3d", name: "Cults3D" }] as const;
|
||||
const COMING_SOON: readonly { id: OptionalSite["id"]; name: string }[] = [];
|
||||
|
||||
// Same glyphs as the web app's providerLogos.tsx.
|
||||
const LOGOS: Record<
|
||||
|
||||
@@ -126,6 +126,8 @@ export type BackgroundMessages = {
|
||||
SYNC_NEXPRINT_SESSION: { payload: void; result: void };
|
||||
/** On a Thangs page: send the sign-in on, if it changed. */
|
||||
SYNC_THANGS_SESSION: { payload: void; result: void };
|
||||
/** On a Cults3D page: send the sign-in and Cloudflare clearance on, if they changed. */
|
||||
SYNC_CULTS3D_SESSION: { payload: void; result: void };
|
||||
/** On a MakerOnline model page: get its files signed there, then import it. */
|
||||
IMPORT_MAKERONLINE_MODEL: {
|
||||
payload: { url: string; scope: "url" | "designer" | "all"; collectionId: string | null; title: string | null };
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// the background registers the content script there. It only ever runs on the sites listed here.
|
||||
|
||||
export type OptionalSite = {
|
||||
id: "myminifactory" | "snapmaker" | "makeronline" | "crealitycloud" | "nexprint" | "thangs";
|
||||
id: "myminifactory" | "snapmaker" | "makeronline" | "crealitycloud" | "nexprint" | "thangs" | "cults3d";
|
||||
name: string;
|
||||
origins: string[];
|
||||
};
|
||||
@@ -17,6 +17,7 @@ export const OPTIONAL_SITES: readonly OptionalSite[] = [
|
||||
{ id: "crealitycloud", name: "Creality Cloud", origins: ["*://*.crealitycloud.com/*"] },
|
||||
{ id: "nexprint", name: "NexPrint", origins: ["*://*.nexprint.com/*"] },
|
||||
{ id: "thangs", name: "Thangs", origins: ["*://thangs.com/*", "*://*.thangs.com/*"] },
|
||||
{ id: "cults3d", name: "Cults3D", origins: ["*://cults3d.com/*", "*://*.cults3d.com/*"] },
|
||||
];
|
||||
|
||||
const scriptId = (site: OptionalSite) => `site-${site.id}`;
|
||||
|
||||
@@ -30,6 +30,8 @@ export const STORAGE_KEYS = {
|
||||
lastSyncedNexprintSession: "lastSyncedNexprintSession",
|
||||
// The Thangs tokens last sent, and to which Thingport account.
|
||||
lastSyncedThangsSession: "lastSyncedThangsSession",
|
||||
// The Cults3D sign-in last sent, and to which Thingport account.
|
||||
lastSyncedCults3dSession: "lastSyncedCults3dSession",
|
||||
// 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.
|
||||
@@ -58,6 +60,7 @@ export type StoredConfig = {
|
||||
lastSyncedCrealitycloudSession?: { account: string; value: string };
|
||||
lastSyncedNexprintSession?: { account: string; value: string };
|
||||
lastSyncedThangsSession?: { account: string; value: string };
|
||||
lastSyncedCults3dSession?: { account: string; value: string };
|
||||
/** `account` is the Thingport instance and email they were sent to. */
|
||||
lastSentProviderAccounts?: { account: string; values: Record<string, string> };
|
||||
lastPrintablesPushAt?: number;
|
||||
|
||||
@@ -9,7 +9,8 @@ export type Provider =
|
||||
| "makeronline"
|
||||
| "crealitycloud"
|
||||
| "nexprint"
|
||||
| "thangs";
|
||||
| "thangs"
|
||||
| "cults3d";
|
||||
|
||||
export type Classification =
|
||||
| { kind: "single"; provider: "makerworld"; type: "model" }
|
||||
@@ -21,6 +22,7 @@ export type Classification =
|
||||
| { kind: "single"; provider: "crealitycloud"; type: "model" }
|
||||
| { kind: "single"; provider: "nexprint"; type: "model" }
|
||||
| { kind: "single"; provider: "thangs"; type: "model" }
|
||||
| { kind: "single"; provider: "cults3d"; type: "model" }
|
||||
| { kind: "batch"; provider: "makerworld"; type: "collection" }
|
||||
| { kind: "batch"; provider: "thingiverse"; type: "likes" | "collection" }
|
||||
| { kind: "batch"; provider: "printables"; type: "collection" }
|
||||
@@ -29,7 +31,8 @@ export type Classification =
|
||||
| { kind: "batch"; provider: "makeronline"; type: "collection" | "likes" }
|
||||
| { kind: "batch"; provider: "crealitycloud"; type: "collection" | "likes" }
|
||||
| { kind: "batch"; provider: "nexprint"; type: "collection" }
|
||||
| { kind: "batch"; provider: "thangs"; type: "collection" | "likes" };
|
||||
| { kind: "batch"; provider: "thangs"; type: "collection" | "likes" }
|
||||
| { kind: "batch"; provider: "cults3d"; type: "collection" | "likes" };
|
||||
|
||||
export type SnapmakerList = "likes" | "bookmarks";
|
||||
|
||||
@@ -281,6 +284,75 @@ export function isThangsLikesUrl(url: string): boolean {
|
||||
return !!parsed && isThangs(parsed) && /^\/mythangs\/liked-models\/?$/i.test(parsed.pathname);
|
||||
}
|
||||
|
||||
/** Cults3D's path segments, decoded and lower-cased: it writes them in each of its languages, accents and all. Null
|
||||
* off the site. */
|
||||
function cults3dSegments(url: string): string[] | null {
|
||||
const parsed = parse(url);
|
||||
if (!parsed || !/^(www\.)?cults3d\.com$/i.test(parsed.hostname)) return null;
|
||||
const parts = parsed.pathname.split("/").filter(Boolean);
|
||||
try {
|
||||
return parts.map((part) => decodeURIComponent(part).toLowerCase());
|
||||
} catch {
|
||||
return parts.map((part) => part.toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
// What each of Cults3D's languages calls a model page, likes and printlists ("design collections"). Kept in step
|
||||
// with the backend's cults3dApi.ts.
|
||||
const CULTS3D_MODEL_WORDS = new Set([
|
||||
"3d-model",
|
||||
"modell-3d",
|
||||
"modelo-3d",
|
||||
"modèle-3d",
|
||||
"modello-3d",
|
||||
"3d-moderu",
|
||||
"3d-móxíng",
|
||||
]);
|
||||
const CULTS3D_LIKES_WORDS = new Set(["likes", "mi-piace", "iine-ichiran", "curtidas", "laiki", "beğeniler", "xǐhuān"]);
|
||||
const CULTS3D_PRINTLIST_WORDS = new Set([
|
||||
"design-collections",
|
||||
"drucklisten",
|
||||
"colecciones-de-diseños",
|
||||
"collections-de-designs",
|
||||
"raccolta-progetti",
|
||||
"dezain-korekushon",
|
||||
"designs-colecoes",
|
||||
"dizain-kollektsii",
|
||||
"tasarım-koleksiyonlar",
|
||||
"shèjì-xìliè",
|
||||
]);
|
||||
const CULTS3D_LIST_PAGES = new Set([
|
||||
"latest",
|
||||
"popular",
|
||||
"selected",
|
||||
"trending",
|
||||
"best-sellers",
|
||||
"random",
|
||||
"mine",
|
||||
"new",
|
||||
]);
|
||||
|
||||
/** `/<locale>/3d-model/<category>/<slug>` in any of its languages, or `/<locale>/creations/<slug>`. */
|
||||
export function parseCults3dModelUrl(url: string): { slug: string } | null {
|
||||
const parts = cults3dSegments(url);
|
||||
if (!parts || !/^[a-z]{2}$/.test(parts[0] ?? "")) return null;
|
||||
if (CULTS3D_MODEL_WORDS.has(parts[1]) && parts.length >= 4) return { slug: parts[3] };
|
||||
if (parts[1] === "creations" && parts.length === 3 && !CULTS3D_LIST_PAGES.has(parts[2])) return { slug: parts[2] };
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A printlist: `/<locale>/design-collections/<nick>/<slug>`. */
|
||||
export function isCults3dPrintlistUrl(url: string): boolean {
|
||||
const parts = cults3dSegments(url);
|
||||
return !!parts && parts.length === 4 && /^[a-z]{2}$/.test(parts[0]) && CULTS3D_PRINTLIST_WORDS.has(parts[1]);
|
||||
}
|
||||
|
||||
/** A user's likes: `/<locale>/users/<nick>/likes`. */
|
||||
export function isCults3dLikesUrl(url: string): boolean {
|
||||
const parts = cults3dSegments(url);
|
||||
return !!parts && parts.length === 4 && /^[a-z]{2}$/.test(parts[0]) && CULTS3D_LIKES_WORDS.has(parts[3]);
|
||||
}
|
||||
|
||||
/** 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" };
|
||||
@@ -297,6 +369,8 @@ export function classifyUrl(url: string): Classification | null {
|
||||
if (isNexprintCollectionUrl(url)) return { kind: "batch", provider: "nexprint", type: "collection" };
|
||||
if (isThangsCollectionUrl(url)) return { kind: "batch", provider: "thangs", type: "collection" };
|
||||
if (isThangsLikesUrl(url)) return { kind: "batch", provider: "thangs", type: "likes" };
|
||||
if (isCults3dPrintlistUrl(url)) return { kind: "batch", provider: "cults3d", type: "collection" };
|
||||
if (isCults3dLikesUrl(url)) return { kind: "batch", provider: "cults3d", 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" };
|
||||
@@ -306,5 +380,6 @@ export function classifyUrl(url: string): Classification | null {
|
||||
if (parseCrealitycloudModelUrl(url)) return { kind: "single", provider: "crealitycloud", type: "model" };
|
||||
if (parseNexprintModelUrl(url)) return { kind: "single", provider: "nexprint", type: "model" };
|
||||
if (parseThangsModelUrl(url)) return { kind: "single", provider: "thangs", type: "model" };
|
||||
if (parseCults3dModelUrl(url)) return { kind: "single", provider: "cults3d", type: "model" };
|
||||
return null;
|
||||
}
|
||||
@@ -24,6 +24,7 @@ export type Category = {
|
||||
crealitycloud_cat_ids: string;
|
||||
nexprint_cat_ids: string;
|
||||
thangs_cat_ids: string;
|
||||
cults3d_cat_ids: string;
|
||||
};
|
||||
|
||||
export type CategoryMetaInput = {
|
||||
@@ -38,6 +39,7 @@ export type CategoryMetaInput = {
|
||||
crealitycloudCatIds: string;
|
||||
nexprintCatIds: string;
|
||||
thangsCatIds: string;
|
||||
cults3dCatIds: string;
|
||||
};
|
||||
|
||||
export const categoriesApi = {
|
||||
@@ -90,6 +92,7 @@ export const categoriesApi = {
|
||||
crealitycloud_cat_ids: meta.crealitycloudCatIds,
|
||||
nexprint_cat_ids: meta.nexprintCatIds,
|
||||
thangs_cat_ids: meta.thangsCatIds,
|
||||
cults3d_cat_ids: meta.cults3dCatIds,
|
||||
}),
|
||||
});
|
||||
if (res.status === 401) throw new UnauthorizedError();
|
||||
|
||||
@@ -500,6 +500,30 @@ export const importsApi = {
|
||||
return res.json();
|
||||
},
|
||||
|
||||
/** A Cults3D user's likes, or one of the user's printlists, read with their API key. */
|
||||
listCults3dListEntries: async (payload: ImportLinkPayload): Promise<ImportCollectionEntriesResult> => {
|
||||
const res = await fetch(`${apiBase()}/import/cults3d-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 list"));
|
||||
return res.json();
|
||||
},
|
||||
|
||||
/** Returns immediately; ImportJobContext polls the job. */
|
||||
fromCults3dList: async (payload: ImportLinkPayload & { model_ids: string[] }): Promise<{ job_id: string }> => {
|
||||
const res = await fetch(`${apiBase()}/import/cults3d-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, "Cults3D 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 },
|
||||
|
||||
@@ -12,6 +12,7 @@ export const BROWSABLE_PROVIDERS = [
|
||||
"crealitycloud",
|
||||
"nexprint",
|
||||
"thangs",
|
||||
"cults3d",
|
||||
] as const;
|
||||
export type BrowsableProvider = (typeof BROWSABLE_PROVIDERS)[number];
|
||||
|
||||
|
||||
@@ -155,7 +155,16 @@ export type ImportProvider =
|
||||
| "makeronline"
|
||||
| "crealitycloud"
|
||||
| "nexprint"
|
||||
| "thangs";
|
||||
| "thangs"
|
||||
| "cults3d";
|
||||
export type Cults3dSettings = {
|
||||
configured: boolean;
|
||||
expired: boolean;
|
||||
nick: string | null;
|
||||
session_configured: boolean;
|
||||
session_expired: boolean;
|
||||
};
|
||||
|
||||
/** An admin can turn a provider off for the whole instance; menus hide it then. */
|
||||
export type ProviderSetting = { id: ImportProvider; enabled: boolean };
|
||||
|
||||
@@ -477,6 +486,40 @@ export const settingsApi = {
|
||||
return res.json();
|
||||
},
|
||||
|
||||
// The user's own Cults3D API credentials, their nickname and an API key made on the site, and whether Thingport
|
||||
// Grab has sent the browser's sign-in, which the downloads need. The key is write-only.
|
||||
getCults3d: async (): Promise<Cults3dSettings> => {
|
||||
const res = await fetch(`${apiBase()}/settings/cults3d`, { headers: authHeaders() });
|
||||
assertOk(res, "Failed to load Cults3D settings");
|
||||
return res.json();
|
||||
},
|
||||
|
||||
/** Checked with Cults3D before it's saved; null forgets them. */
|
||||
updateCults3d: async (
|
||||
credentials: { nick: string; apiKey: string } | null,
|
||||
): Promise<{ configured: boolean; nick: string | null }> => {
|
||||
const res = await fetch(`${apiBase()}/settings/cults3d`, {
|
||||
method: "PATCH",
|
||||
headers: authHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify({ nick: credentials?.nick ?? null, api_key: credentials?.apiKey ?? null, verify: true }),
|
||||
});
|
||||
if (res.status === 401) throw new UnauthorizedError();
|
||||
if (!res.ok) throw new Error(await readErrorMessage(res, "Failed to update Cults3D settings"));
|
||||
return res.json();
|
||||
},
|
||||
|
||||
/** The browser's sign-in by hand: a signed-in cults3d.com request's copied headers. Null forgets it. */
|
||||
updateCults3dSession: async (session: string | null): Promise<{ configured: boolean }> => {
|
||||
const res = await fetch(`${apiBase()}/settings/cults3d-session`, {
|
||||
method: "PATCH",
|
||||
headers: authHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify({ session }),
|
||||
});
|
||||
if (res.status === 401) throw new UnauthorizedError();
|
||||
if (!res.ok) throw new Error(await readErrorMessage(res, "Failed to update the Cults3D sign-in"));
|
||||
return res.json();
|
||||
},
|
||||
|
||||
getAuth: async (): Promise<AuthSettings> => {
|
||||
const res = await fetch(`${apiBase()}/settings/auth`, { headers: authHeaders() });
|
||||
assertOk(res, "Failed to load session settings");
|
||||
|
||||
@@ -20,6 +20,7 @@ const LOGO_PROVIDERS = new Set<string>([
|
||||
"crealitycloud",
|
||||
"nexprint",
|
||||
"thangs",
|
||||
"cults3d",
|
||||
]);
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -22,6 +22,7 @@ const LOGO_PROVIDERS = new Set<string>([
|
||||
"crealitycloud",
|
||||
"nexprint",
|
||||
"thangs",
|
||||
"cults3d",
|
||||
]);
|
||||
|
||||
/** The provider's logo with a small green tick, slim enough for a toolbar row. Opens the
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
isNexprintCollectionUrl,
|
||||
isNexprintModelUrl,
|
||||
isThangsListUrl,
|
||||
isCults3dListUrl,
|
||||
isThingiverseCollectionUrl,
|
||||
isThingiverseLikesUrl,
|
||||
type ImportProviderKey,
|
||||
@@ -66,6 +67,7 @@ const IMPORT_PROVIDERS: ImportProviderKey[] = [
|
||||
"crealitycloud",
|
||||
"nexprint",
|
||||
"thangs",
|
||||
"cults3d",
|
||||
];
|
||||
|
||||
/** What the search palette's !import command calls. */
|
||||
@@ -136,7 +138,8 @@ const AddMenu = React.forwardRef<AddMenuHandle, Props>(function AddMenu(
|
||||
isMakeronlineListUrl(link) ||
|
||||
isCrealitycloudListUrl(link) ||
|
||||
isNexprintCollectionUrl(link) ||
|
||||
isThangsListUrl(link),
|
||||
isThangsListUrl(link) ||
|
||||
isCults3dListUrl(link),
|
||||
)
|
||||
: isMakerworldCollectionUrl(linkValue);
|
||||
// Models with print profiles to pick between.
|
||||
|
||||
@@ -14,8 +14,10 @@ import Typography from "@mui/material/Typography";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import LinkIcon from "@mui/icons-material/Link";
|
||||
import LinkOffIcon from "@mui/icons-material/LinkOff";
|
||||
import ContentPasteIcon from "@mui/icons-material/ContentPaste";
|
||||
import { UnauthorizedError } from "../../../api/client";
|
||||
import { settingsApi } from "../../../api/settings";
|
||||
import { settingsApi, type Cults3dSettings } from "../../../api/settings";
|
||||
import { cults3dReady, cults3dStatus } from "./cults3dStatus";
|
||||
import type { MakerWorldSettings } from "../../../utils/settings";
|
||||
import { useConfirm } from "../../ConfirmProvider";
|
||||
import { AppGrid, AppTile, menuPosition, reopenContextMenuBelow, TileMenuHeader } from "./AppTiles";
|
||||
@@ -34,6 +36,7 @@ const PROVIDERS: { id: ProviderId; name: string }[] = [
|
||||
{ id: "crealitycloud", name: "Creality Cloud" },
|
||||
{ id: "nexprint", name: "NexPrint" },
|
||||
{ id: "thangs", name: "Thangs" },
|
||||
{ id: "cults3d", name: "Cults3D" },
|
||||
...COMING_SOON_PROVIDERS,
|
||||
];
|
||||
const isComingSoon = (id: ProviderId) => COMING_SOON_PROVIDERS.some((provider) => provider.id === id);
|
||||
@@ -43,9 +46,10 @@ const SHARED = new Set<ProviderId>(["thingiverse"]);
|
||||
// the instance's API key, set in Administration; the user's sign-in adds paid and login-only models. Snapmaker's
|
||||
// lasts a day; Thingport Grab keeps renewing it. MakerOnline's reads the user's likes and private collections; its
|
||||
// files only ever come through Thingport Grab. Creality Cloud's lasts a week from signing in there; NexPrint's is
|
||||
// renewed by the site as the user visits it. Thangs' renews itself on the server, for about a year.
|
||||
// renewed by the site as the user visits it. Thangs' renews itself on the server, for about a year. Cults3D's is the
|
||||
// user's API key; its files also need the browser's sign-in, which Grab sends (see cults3dStatus.ts).
|
||||
type SessionProvider =
|
||||
"makerworld" | "myminifactory" | "snapmaker" | "makeronline" | "crealitycloud" | "nexprint" | "thangs";
|
||||
"makerworld" | "myminifactory" | "snapmaker" | "makeronline" | "crealitycloud" | "nexprint" | "thangs" | "cults3d";
|
||||
const SESSION_PROVIDERS = new Set<ProviderId>([
|
||||
"makerworld",
|
||||
"myminifactory",
|
||||
@@ -54,6 +58,7 @@ const SESSION_PROVIDERS = new Set<ProviderId>([
|
||||
"crealitycloud",
|
||||
"nexprint",
|
||||
"thangs",
|
||||
"cults3d",
|
||||
]);
|
||||
const isSessionProvider = (id: ProviderId): id is SessionProvider => SESSION_PROVIDERS.has(id);
|
||||
|
||||
@@ -78,7 +83,7 @@ export default function ProvidersPanel({ isAdmin, cookie, onUpdateMakerWorld, on
|
||||
printables: true,
|
||||
myminifactory: null,
|
||||
thangs: null,
|
||||
cults3d: false,
|
||||
cults3d: null,
|
||||
makeronline: null,
|
||||
crealitycloud: null,
|
||||
nexprint: null,
|
||||
@@ -93,9 +98,13 @@ export default function ProvidersPanel({ isAdmin, cookie, onUpdateMakerWorld, on
|
||||
crealitycloud: false,
|
||||
nexprint: false,
|
||||
thangs: false,
|
||||
cults3d: false,
|
||||
});
|
||||
// Its browser sign-in, besides the API key the tile's connection is.
|
||||
const [cults3d, setCults3d] = React.useState<Cults3dSettings | null>(null);
|
||||
const isExpired = (id: ProviderId) => isSessionProvider(id) && Boolean(connected[id]) && expired[id];
|
||||
const [connecting, setConnecting] = React.useState<ProviderId | null>(null);
|
||||
const [pastingCults3dSignIn, setPastingCults3dSignIn] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [notice, setNotice] = React.useState<string | null>(null);
|
||||
|
||||
@@ -161,6 +170,15 @@ export default function ProvidersPanel({ isAdmin, cookie, onUpdateMakerWorld, on
|
||||
setExpired((current) => ({ ...current, thangs: res.expired }));
|
||||
})
|
||||
.catch(() => undefined);
|
||||
settingsApi
|
||||
.getCults3d()
|
||||
.then((res) => {
|
||||
if (!active) return;
|
||||
setCults3d(res);
|
||||
setConnected((current) => ({ ...current, cults3d: res.configured }));
|
||||
setExpired((current) => ({ ...current, cults3d: res.expired }));
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
@@ -175,8 +193,8 @@ export default function ProvidersPanel({ isAdmin, cookie, onUpdateMakerWorld, on
|
||||
return null;
|
||||
};
|
||||
|
||||
/** Saves a credential, or removes it with null. */
|
||||
const save = async (id: ProviderId, value: string | null) => {
|
||||
/** Saves a credential, or removes it with null. Cults3D's takes its API key as `secondary`, its nickname as `value`. */
|
||||
const save = async (id: ProviderId, value: string | null, secondary?: string) => {
|
||||
if (id === "makerworld") {
|
||||
const result = await settingsApi.updateMakerworld(value);
|
||||
onUpdateMakerWorld({ cookie: value ?? "" });
|
||||
@@ -209,12 +227,30 @@ export default function ProvidersPanel({ isAdmin, cookie, onUpdateMakerWorld, on
|
||||
const result = await settingsApi.updateThangsSession(value);
|
||||
setConnected((current) => ({ ...current, thangs: result.configured }));
|
||||
setExpired((current) => ({ ...current, thangs: false }));
|
||||
} else if (id === "cults3d") {
|
||||
if (value === null) {
|
||||
// Forgets the browser's sign-in too: disconnecting means Thingport has nothing of the account left.
|
||||
await settingsApi.updateCults3dSession(null);
|
||||
await settingsApi.updateCults3d(null);
|
||||
} else {
|
||||
await settingsApi.updateCults3d({ nick: value, apiKey: secondary ?? "" });
|
||||
}
|
||||
const res = await settingsApi.getCults3d();
|
||||
setCults3d(res);
|
||||
setConnected((current) => ({ ...current, cults3d: res.configured }));
|
||||
setExpired((current) => ({ ...current, cults3d: res.expired }));
|
||||
}
|
||||
};
|
||||
|
||||
const saveCults3dSignIn = async (value: string) => {
|
||||
await settingsApi.updateCults3dSession(value);
|
||||
setCults3d(await settingsApi.getCults3d());
|
||||
};
|
||||
|
||||
const startConnect = (id: ProviderId) => {
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
setPastingCults3dSignIn(false);
|
||||
setConnecting(id);
|
||||
};
|
||||
|
||||
@@ -270,18 +306,23 @@ export default function ProvidersPanel({ isAdmin, cookie, onUpdateMakerWorld, on
|
||||
const isConnected = Boolean(connected[id]);
|
||||
const locked = lockedReason(id);
|
||||
const tileExpired = isExpired(id);
|
||||
const status = connected[id] === null ? null : isConnected && !tileExpired;
|
||||
// Cults3D's files also need the browser's sign-in: connected without it is half connected.
|
||||
const cults3dMissing =
|
||||
id === "cults3d" && isConnected && !tileExpired ? cults3dStatus(cults3d, t) : undefined;
|
||||
const status = connected[id] === null ? null : isConnected && !tileExpired && !cults3dMissing;
|
||||
const tooltip = isComingSoon(id)
|
||||
? t("providers.comingSoon")
|
||||
: tileExpired
|
||||
? t(`providers.${id}.expired`)
|
||||
: SHARED.has(id) && locked && !isConnected
|
||||
? locked
|
||||
: isConnected
|
||||
? t("providers.connected")
|
||||
: isSessionProvider(id) && id !== "makerworld"
|
||||
? t(`providers.${id}.notConnected`)
|
||||
: t("providers.notConnected");
|
||||
: cults3dMissing
|
||||
? cults3dMissing
|
||||
: tileExpired
|
||||
? t(`providers.${id}.expired`)
|
||||
: SHARED.has(id) && locked && !isConnected
|
||||
? locked
|
||||
: isConnected
|
||||
? t("providers.connected")
|
||||
: isSessionProvider(id) && id !== "makerworld"
|
||||
? t(`providers.${id}.notConnected`)
|
||||
: t("providers.notConnected");
|
||||
return (
|
||||
<AppTile
|
||||
key={id}
|
||||
@@ -316,9 +357,11 @@ export default function ProvidersPanel({ isAdmin, cookie, onUpdateMakerWorld, on
|
||||
? t("providers.comingSoon")
|
||||
: menuExpired
|
||||
? t(`providers.${menuId}.expiredStatus`)
|
||||
: menuConnected
|
||||
? t("providers.connected")
|
||||
: t("providers.notConnected")
|
||||
: menuId === "cults3d" && menuConnected && !cults3dReady(cults3d)
|
||||
? t("providers.cults3d.siteMissingStatus")
|
||||
: menuConnected
|
||||
? t("providers.connected")
|
||||
: t("providers.notConnected")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
@@ -335,6 +378,22 @@ export default function ProvidersPanel({ isAdmin, cookie, onUpdateMakerWorld, on
|
||||
<ListItemText>{t("providers.connect")}</ListItemText>
|
||||
</MenuItem>
|
||||
)}
|
||||
{menuId === "cults3d" && menuConnected && !menuExpired && (
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setMenu(null);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
setConnecting(null);
|
||||
setPastingCults3dSignIn(true);
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<ContentPasteIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t("providers.cults3d.session.paste")}</ListItemText>
|
||||
</MenuItem>
|
||||
)}
|
||||
{menuId && (
|
||||
<MenuItem
|
||||
disabled={Boolean(menuLocked)}
|
||||
@@ -380,34 +439,57 @@ export default function ProvidersPanel({ isAdmin, cookie, onUpdateMakerWorld, on
|
||||
id={connecting}
|
||||
name={nameOf(connecting)}
|
||||
onCancel={() => setConnecting(null)}
|
||||
onConnect={async (value) => {
|
||||
await save(connecting, value);
|
||||
onConnect={async (value, secondary) => {
|
||||
await save(connecting, value, secondary);
|
||||
setConnecting(null);
|
||||
}}
|
||||
onUnauthorized={onUnauthorized}
|
||||
/>
|
||||
)}
|
||||
{pastingCults3dSignIn && !connecting && (
|
||||
<ConnectBox
|
||||
key="cults3d-session"
|
||||
id="cults3d"
|
||||
mode="session"
|
||||
name={nameOf("cults3d")}
|
||||
onCancel={() => setPastingCults3dSignIn(false)}
|
||||
onConnect={async (value) => {
|
||||
await saveCults3dSignIn(value);
|
||||
setPastingCults3dSignIn(false);
|
||||
}}
|
||||
onUnauthorized={onUnauthorized}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type ConnectBoxProps = {
|
||||
id: ProviderId;
|
||||
/** "session": Cults3D's browser sign-in, pasted, rather than its API key. */
|
||||
mode?: "account" | "session";
|
||||
name: string;
|
||||
onCancel: () => void;
|
||||
onConnect: (value: string) => Promise<void>;
|
||||
/** `secondary` is the second field's, where there is one: Cults3D's API key. */
|
||||
onConnect: (value: string, secondary?: string) => Promise<void>;
|
||||
onUnauthorized?: () => void;
|
||||
};
|
||||
|
||||
function ConnectBox({ id, name, onCancel, onConnect, onUnauthorized }: ConnectBoxProps) {
|
||||
function ConnectBox({ id, mode = "account", name, onCancel, onConnect, onUnauthorized }: ConnectBoxProps) {
|
||||
const { t } = useTranslation(["app", "common"]);
|
||||
const [draft, setDraft] = React.useState("");
|
||||
const [secondary, setSecondary] = React.useState("");
|
||||
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 session = mode === "session";
|
||||
// Cults3D's API wants two things: the user's nickname and an API key.
|
||||
const twoFields = id === "cults3d" && !session;
|
||||
const keyPrefix = session ? `providers.${id}.session` : `providers.${id}`;
|
||||
const steps = t(`${keyPrefix}.steps`, { returnObjects: true }) as string[];
|
||||
// MakerWorld's cookie is long; Creality Cloud's sign-in may be pasted as two copied request headers, Thangs' as a
|
||||
// whole Cookie header.
|
||||
const multiline = id === "makerworld" || id === "crealitycloud" || id === "thangs";
|
||||
// whole Cookie header, Cults3D's browser sign-in as a request's copied headers.
|
||||
const multiline = id === "makerworld" || id === "crealitycloud" || id === "thangs" || session;
|
||||
const ready = Boolean(draft.trim()) && (!twoFields || Boolean(secondary.trim()));
|
||||
const rootRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// It opens below the icons, often past the bottom of the panel.
|
||||
@@ -417,11 +499,11 @@ function ConnectBox({ id, name, onCancel, onConnect, onUnauthorized }: ConnectBo
|
||||
|
||||
const submit = async () => {
|
||||
const value = draft.trim();
|
||||
if (!value) return;
|
||||
if (!ready) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onConnect(value);
|
||||
await onConnect(value, twoFields ? secondary.trim() : undefined);
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedError) onUnauthorized?.();
|
||||
else setError(err instanceof Error ? err.message : t("providers.failed"));
|
||||
@@ -438,7 +520,7 @@ function ConnectBox({ id, name, onCancel, onConnect, onUnauthorized }: ConnectBo
|
||||
fontWeight={600}
|
||||
sx={{ flex: 1, color: (muiTheme) => muiTheme.thingport.headingText }}
|
||||
>
|
||||
{t("providers.connectTitle", { name })}
|
||||
{session ? t("providers.cults3d.session.title") : t("providers.connectTitle", { name })}
|
||||
</Typography>
|
||||
<IconButton size="small" onClick={onCancel} aria-label={t("common:close") ?? undefined}>
|
||||
<CloseIcon fontSize="small" />
|
||||
@@ -462,25 +544,42 @@ function ConnectBox({ id, name, onCancel, onConnect, onUnauthorized }: ConnectBo
|
||||
multiline={multiline}
|
||||
minRows={multiline ? 3 : undefined}
|
||||
size="small"
|
||||
label={twoFields ? t("providers.cults3d.nickLabel") : undefined}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (!multiline && e.key === "Enter") void submit();
|
||||
}}
|
||||
placeholder={t(`providers.${id}.placeholder`)}
|
||||
placeholder={t(`${keyPrefix}.placeholder`)}
|
||||
disabled={saving}
|
||||
autoComplete="off"
|
||||
// oxlint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus
|
||||
/>
|
||||
{twoFields && (
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
type="password"
|
||||
label={t("providers.cults3d.keyLabel")}
|
||||
value={secondary}
|
||||
onChange={(e) => setSecondary(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void submit();
|
||||
}}
|
||||
disabled={saving}
|
||||
autoComplete="off"
|
||||
sx={{ mt: 1.5 }}
|
||||
/>
|
||||
)}
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mt: 1.5 }} onClose={() => setError(null)}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 1.5 }}>
|
||||
<Button size="small" variant="contained" onClick={() => void submit()} disabled={saving || !draft.trim()}>
|
||||
{saving ? t("providers.connecting") : t("providers.connect")}
|
||||
<Button size="small" variant="contained" onClick={() => void submit()} disabled={saving || !ready}>
|
||||
{saving ? t("providers.connecting") : session ? t("providers.cults3d.session.save") : t("providers.connect")}
|
||||
</Button>
|
||||
<Button size="small" onClick={onCancel} disabled={saving}>
|
||||
{t("common:cancel")}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Cults3dSettings } from "../../../api/settings";
|
||||
|
||||
// Cults3D is connected in two parts: the API key reads models and lists, and the browser's sign-in, which Thingport
|
||||
// Grab sends, downloads the files. Both are needed for an import.
|
||||
|
||||
/** Whether both parts are there and neither was turned down. */
|
||||
export function cults3dReady(settings: Cults3dSettings | null): boolean {
|
||||
return Boolean(settings?.configured && !settings.expired && settings.session_configured && !settings.session_expired);
|
||||
}
|
||||
|
||||
/** What's missing, most basic first; undefined when nothing is. */
|
||||
export function cults3dStatus(settings: Cults3dSettings | null, t: (key: string) => string): string | undefined {
|
||||
if (!settings?.configured) return t("userMenu.cults3dDisabledReason");
|
||||
if (settings.expired) return t("providers.cults3d.expired");
|
||||
if (!settings.session_configured) return t("providers.cults3d.siteMissing");
|
||||
if (settings.session_expired) return t("providers.cults3d.siteExpired");
|
||||
return undefined;
|
||||
}
|
||||
@@ -16,7 +16,7 @@ export type ProviderId =
|
||||
| "snapmaker";
|
||||
|
||||
/** Sites shown as "Coming soon": listed, but nothing can be done with them yet. */
|
||||
export const COMING_SOON_PROVIDERS: { id: ProviderId; name: string }[] = [{ id: "cults3d", name: "Cults3D" }];
|
||||
export const COMING_SOON_PROVIDERS: { id: ProviderId; name: string }[] = [];
|
||||
|
||||
type Logo = {
|
||||
/** Defaults to 0 0 24 24, Simple Icons' box. */
|
||||
|
||||
@@ -89,6 +89,7 @@ export const SEARCH_PAGES: SearchPage[] = [
|
||||
"creality cloud",
|
||||
"nexprint",
|
||||
"thangs",
|
||||
"cults3d",
|
||||
"app token",
|
||||
"api key",
|
||||
"access token",
|
||||
|
||||
@@ -18,6 +18,7 @@ type StartCrealitycloudListImportPayload = Parameters<typeof importsApi.fromCrea
|
||||
type StartNexprintProfilesImportPayload = Parameters<typeof importsApi.fromNexprintProfiles>[0];
|
||||
type StartNexprintCollectionImportPayload = Parameters<typeof importsApi.fromNexprintCollection>[0];
|
||||
type StartThangsListImportPayload = Parameters<typeof importsApi.fromThangsList>[0];
|
||||
type StartCults3dListImportPayload = Parameters<typeof importsApi.fromCults3dList>[0];
|
||||
type StartLinksImportPayload = Parameters<typeof importsApi.fromLinks>[0];
|
||||
|
||||
type ImportJobContextValue = {
|
||||
@@ -38,6 +39,7 @@ type ImportJobContextValue = {
|
||||
startNexprintProfilesImport: (payload: StartNexprintProfilesImportPayload) => Promise<void>;
|
||||
startNexprintCollectionImport: (payload: StartNexprintCollectionImportPayload) => Promise<void>;
|
||||
startThangsListImport: (payload: StartThangsListImportPayload) => Promise<void>;
|
||||
startCults3dListImport: (payload: StartCults3dListImportPayload) => Promise<void>;
|
||||
startLinksImport: (payload: StartLinksImportPayload) => Promise<void>;
|
||||
/** Reruns the links that failed on the finished job shown in the progress bar. */
|
||||
retryJob: () => Promise<void>;
|
||||
@@ -275,6 +277,16 @@ export function ImportJobProvider({
|
||||
[startPolling],
|
||||
);
|
||||
|
||||
const startCults3dListImport = useCallback(
|
||||
async (payload: StartCults3dListImportPayload) => {
|
||||
const { job_id } = await importsApi.fromCults3dList(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);
|
||||
@@ -316,6 +328,7 @@ export function ImportJobProvider({
|
||||
startNexprintProfilesImport,
|
||||
startNexprintCollectionImport,
|
||||
startThangsListImport,
|
||||
startCults3dListImport,
|
||||
startLinksImport,
|
||||
retryJob,
|
||||
dismissJob,
|
||||
@@ -336,6 +349,7 @@ export function ImportJobProvider({
|
||||
startNexprintProfilesImport,
|
||||
startNexprintCollectionImport,
|
||||
startThangsListImport,
|
||||
startCults3dListImport,
|
||||
startLinksImport,
|
||||
retryJob,
|
||||
dismissJob,
|
||||
|
||||
@@ -21,7 +21,8 @@ import { IMPORT_PROVIDER_INFO } from "../../constants/importProviders";
|
||||
import { COMING_SOON_PROVIDERS, ProviderLogo, type ProviderId } from "./ConfigurationDialog/providerLogos";
|
||||
import { SELF_AUTHOR_ID } from "../../constants/selfAuthor";
|
||||
import { useGravatarUrl } from "../../hooks/useGravatarUrl";
|
||||
import { settingsApi } from "../../api/settings";
|
||||
import { settingsApi, type Cults3dSettings } from "../../api/settings";
|
||||
import { cults3dReady, cults3dStatus } from "./ConfigurationDialog/cults3dStatus";
|
||||
import { useDisabledProviders } from "../../hooks/useDisabledProviders";
|
||||
|
||||
type ServiceChipDef = {
|
||||
@@ -34,7 +35,8 @@ type ServiceChipDef = {
|
||||
| "makeronline"
|
||||
| "crealitycloud"
|
||||
| "nexprint"
|
||||
| "thangs";
|
||||
| "thangs"
|
||||
| "cults3d";
|
||||
label: string;
|
||||
connected: boolean;
|
||||
/** Explains what's missing when not connected. */
|
||||
@@ -69,6 +71,7 @@ export function UserMenu({ user, onOpenConfiguration, onOpenProfile, onLogout }:
|
||||
const [nexprintExpired, setNexprintExpired] = React.useState(false);
|
||||
const [thangsConfigured, setThangsConfigured] = React.useState(false);
|
||||
const [thangsExpired, setThangsExpired] = React.useState(false);
|
||||
const [cults3d, setCults3d] = React.useState<Cults3dSettings | null>(null);
|
||||
|
||||
const menuOpen = Boolean(anchorEl);
|
||||
// Checked on every open: the cookie can change in Configuration while the menu stays mounted.
|
||||
@@ -135,6 +138,10 @@ export function UserMenu({ user, onOpenConfiguration, onOpenProfile, onLogout }:
|
||||
setThangsExpired(res.expired);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
settingsApi
|
||||
.getCults3d()
|
||||
.then((res) => active && setCults3d(res))
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
@@ -196,6 +203,13 @@ export function UserMenu({ user, onOpenConfiguration, onOpenProfile, onLogout }:
|
||||
connected: thangsConfigured && !thangsExpired,
|
||||
disabledReason: thangsExpired ? t("providers.thangs.expired") : t("userMenu.thangsDisabledReason"),
|
||||
},
|
||||
{
|
||||
key: "cults3d",
|
||||
...IMPORT_PROVIDER_INFO.cults3d,
|
||||
// Downloads need both: the API key, and the browser's sign-in Grab sends.
|
||||
connected: cults3dReady(cults3d),
|
||||
disabledReason: cults3dStatus(cults3d, t),
|
||||
},
|
||||
];
|
||||
// Turned off by an admin: no icon. Coming-soon sites follow the live ones.
|
||||
const services: { id: ProviderId; label: string; connected: boolean; tooltip: string }[] = [
|
||||
|
||||
@@ -24,6 +24,7 @@ export const MAPPED_PROVIDERS = [
|
||||
"crealitycloud",
|
||||
"nexprint",
|
||||
"thangs",
|
||||
"cults3d",
|
||||
] as const satisfies readonly ImportProvider[];
|
||||
export type MappedProvider = (typeof MAPPED_PROVIDERS)[number];
|
||||
|
||||
|
||||
@@ -7996,5 +7996,512 @@
|
||||
"name": "Video Games",
|
||||
"parent": "Toys & Games"
|
||||
}
|
||||
],
|
||||
"cults3d": [
|
||||
{
|
||||
"id": "23",
|
||||
"name": "Art",
|
||||
"parent": null
|
||||
},
|
||||
{
|
||||
"id": "37",
|
||||
"name": "Fan Art",
|
||||
"parent": "23"
|
||||
},
|
||||
{
|
||||
"id": "43",
|
||||
"name": "Sculptures & Busts",
|
||||
"parent": "23"
|
||||
},
|
||||
{
|
||||
"id": "34",
|
||||
"name": "Animals & Creatures",
|
||||
"parent": "23"
|
||||
},
|
||||
{
|
||||
"id": "40",
|
||||
"name": "People",
|
||||
"parent": "23"
|
||||
},
|
||||
{
|
||||
"id": "33",
|
||||
"name": "2D Lithophanes",
|
||||
"parent": "23"
|
||||
},
|
||||
{
|
||||
"id": "38",
|
||||
"name": "Low Poly",
|
||||
"parent": "23"
|
||||
},
|
||||
{
|
||||
"id": "44",
|
||||
"name": "Signs & Logos",
|
||||
"parent": "23"
|
||||
},
|
||||
{
|
||||
"id": "42",
|
||||
"name": "Scans & Replicas",
|
||||
"parent": "23"
|
||||
},
|
||||
{
|
||||
"id": "39",
|
||||
"name": "Math Art",
|
||||
"parent": "23"
|
||||
},
|
||||
{
|
||||
"id": "35",
|
||||
"name": "Art Tools",
|
||||
"parent": "23"
|
||||
},
|
||||
{
|
||||
"id": "36",
|
||||
"name": "Coins",
|
||||
"parent": "23"
|
||||
},
|
||||
{
|
||||
"id": "24",
|
||||
"name": "Fashion",
|
||||
"parent": null
|
||||
},
|
||||
{
|
||||
"id": "46",
|
||||
"name": "Cosplay Props",
|
||||
"parent": "24"
|
||||
},
|
||||
{
|
||||
"id": "48",
|
||||
"name": "Masks",
|
||||
"parent": "24"
|
||||
},
|
||||
{
|
||||
"id": "122",
|
||||
"name": "Shoes",
|
||||
"parent": "24"
|
||||
},
|
||||
{
|
||||
"id": "47",
|
||||
"name": "Glasses",
|
||||
"parent": "24"
|
||||
},
|
||||
{
|
||||
"id": "49",
|
||||
"name": "Wallets",
|
||||
"parent": "24"
|
||||
},
|
||||
{
|
||||
"id": "26",
|
||||
"name": "Jewelry",
|
||||
"parent": null
|
||||
},
|
||||
{
|
||||
"id": "54",
|
||||
"name": "Keychains",
|
||||
"parent": "26"
|
||||
},
|
||||
{
|
||||
"id": "53",
|
||||
"name": "Earrings",
|
||||
"parent": "26"
|
||||
},
|
||||
{
|
||||
"id": "50",
|
||||
"name": "Bracelets",
|
||||
"parent": "26"
|
||||
},
|
||||
{
|
||||
"id": "55",
|
||||
"name": "Necklaces",
|
||||
"parent": "26"
|
||||
},
|
||||
{
|
||||
"id": "56",
|
||||
"name": "Rings",
|
||||
"parent": "26"
|
||||
},
|
||||
{
|
||||
"id": "51",
|
||||
"name": "Brooches & Badges",
|
||||
"parent": "26"
|
||||
},
|
||||
{
|
||||
"id": "52",
|
||||
"name": "Cufflinks",
|
||||
"parent": "26"
|
||||
},
|
||||
{
|
||||
"id": "30",
|
||||
"name": "Home",
|
||||
"parent": null
|
||||
},
|
||||
{
|
||||
"id": "66",
|
||||
"name": "Office",
|
||||
"parent": "30"
|
||||
},
|
||||
{
|
||||
"id": "63",
|
||||
"name": "Kitchen",
|
||||
"parent": "30"
|
||||
},
|
||||
{
|
||||
"id": "57",
|
||||
"name": "Bathroom",
|
||||
"parent": "30"
|
||||
},
|
||||
{
|
||||
"id": "67",
|
||||
"name": "Outdoor & Garden",
|
||||
"parent": "30"
|
||||
},
|
||||
{
|
||||
"id": "60",
|
||||
"name": "Furniture",
|
||||
"parent": "30"
|
||||
},
|
||||
{
|
||||
"id": "61",
|
||||
"name": "Home Decor",
|
||||
"parent": "30"
|
||||
},
|
||||
{
|
||||
"id": "64",
|
||||
"name": "Lamps",
|
||||
"parent": "30"
|
||||
},
|
||||
{
|
||||
"id": "71",
|
||||
"name": "Vases",
|
||||
"parent": "30"
|
||||
},
|
||||
{
|
||||
"id": "68",
|
||||
"name": "Pets",
|
||||
"parent": "30"
|
||||
},
|
||||
{
|
||||
"id": "70",
|
||||
"name": "Planters",
|
||||
"parent": "30"
|
||||
},
|
||||
{
|
||||
"id": "58",
|
||||
"name": "Cookie Cutters",
|
||||
"parent": "30"
|
||||
},
|
||||
{
|
||||
"id": "59",
|
||||
"name": "Food & Drink",
|
||||
"parent": "30"
|
||||
},
|
||||
{
|
||||
"id": "65",
|
||||
"name": "Molds",
|
||||
"parent": "30"
|
||||
},
|
||||
{
|
||||
"id": "72",
|
||||
"name": "Wall-mounted",
|
||||
"parent": "30"
|
||||
},
|
||||
{
|
||||
"id": "69",
|
||||
"name": "Piggy Banks",
|
||||
"parent": "30"
|
||||
},
|
||||
{
|
||||
"id": "62",
|
||||
"name": "Household Supplies",
|
||||
"parent": "30"
|
||||
},
|
||||
{
|
||||
"id": "32",
|
||||
"name": "Architecture",
|
||||
"parent": null
|
||||
},
|
||||
{
|
||||
"id": "75",
|
||||
"name": "Houses & Buildings",
|
||||
"parent": "32"
|
||||
},
|
||||
{
|
||||
"id": "73",
|
||||
"name": "Castles",
|
||||
"parent": "32"
|
||||
},
|
||||
{
|
||||
"id": "74",
|
||||
"name": "Famous Monuments",
|
||||
"parent": "32"
|
||||
},
|
||||
{
|
||||
"id": "76",
|
||||
"name": "Landscapes",
|
||||
"parent": "32"
|
||||
},
|
||||
{
|
||||
"id": "77",
|
||||
"name": "Maps",
|
||||
"parent": "32"
|
||||
},
|
||||
{
|
||||
"id": "25",
|
||||
"name": "Gadget",
|
||||
"parent": null
|
||||
},
|
||||
{
|
||||
"id": "85",
|
||||
"name": "Phones & Tablets",
|
||||
"parent": "25"
|
||||
},
|
||||
{
|
||||
"id": "82",
|
||||
"name": "Consoles & Video Games",
|
||||
"parent": "25"
|
||||
},
|
||||
{
|
||||
"id": "81",
|
||||
"name": "Computers",
|
||||
"parent": "25"
|
||||
},
|
||||
{
|
||||
"id": "84",
|
||||
"name": "Electronics",
|
||||
"parent": "25"
|
||||
},
|
||||
{
|
||||
"id": "41",
|
||||
"name": "Robots",
|
||||
"parent": "25"
|
||||
},
|
||||
{
|
||||
"id": "83",
|
||||
"name": "Drones",
|
||||
"parent": "25"
|
||||
},
|
||||
{
|
||||
"id": "86",
|
||||
"name": "Vehicle Accessories",
|
||||
"parent": "25"
|
||||
},
|
||||
{
|
||||
"id": "80",
|
||||
"name": "Clocks & Watches",
|
||||
"parent": "25"
|
||||
},
|
||||
{
|
||||
"id": "78",
|
||||
"name": "Audio & Music",
|
||||
"parent": "25"
|
||||
},
|
||||
{
|
||||
"id": "79",
|
||||
"name": "Cameras & Videos",
|
||||
"parent": "25"
|
||||
},
|
||||
{
|
||||
"id": "31",
|
||||
"name": "Game",
|
||||
"parent": null
|
||||
},
|
||||
{
|
||||
"id": "98",
|
||||
"name": "RPG & Tabletop",
|
||||
"parent": "31"
|
||||
},
|
||||
{
|
||||
"id": "87",
|
||||
"name": "Action Figures",
|
||||
"parent": "31"
|
||||
},
|
||||
{
|
||||
"id": "90",
|
||||
"name": "Auto & Moto",
|
||||
"parent": "31"
|
||||
},
|
||||
{
|
||||
"id": "99",
|
||||
"name": "Toys",
|
||||
"parent": "31"
|
||||
},
|
||||
{
|
||||
"id": "97",
|
||||
"name": "RC Vehicles",
|
||||
"parent": "31"
|
||||
},
|
||||
{
|
||||
"id": "89",
|
||||
"name": "Airsoft",
|
||||
"parent": "31"
|
||||
},
|
||||
{
|
||||
"id": "100",
|
||||
"name": "Trains",
|
||||
"parent": "31"
|
||||
},
|
||||
{
|
||||
"id": "91",
|
||||
"name": "Board Games",
|
||||
"parent": "31"
|
||||
},
|
||||
{
|
||||
"id": "93",
|
||||
"name": "Brainteasers & Puzzles",
|
||||
"parent": "31"
|
||||
},
|
||||
{
|
||||
"id": "88",
|
||||
"name": "Aircraft & Space",
|
||||
"parent": "31"
|
||||
},
|
||||
{
|
||||
"id": "96",
|
||||
"name": "Mechanical Toys",
|
||||
"parent": "31"
|
||||
},
|
||||
{
|
||||
"id": "94",
|
||||
"name": "Construction Toys",
|
||||
"parent": "31"
|
||||
},
|
||||
{
|
||||
"id": "92",
|
||||
"name": "Boats & Submarines",
|
||||
"parent": "31"
|
||||
},
|
||||
{
|
||||
"id": "95",
|
||||
"name": "Magic",
|
||||
"parent": "31"
|
||||
},
|
||||
{
|
||||
"id": "27",
|
||||
"name": "Tool",
|
||||
"parent": null
|
||||
},
|
||||
{
|
||||
"id": "101",
|
||||
"name": "3D Printing & Accessories",
|
||||
"parent": "27"
|
||||
},
|
||||
{
|
||||
"id": "105",
|
||||
"name": "Spare Parts",
|
||||
"parent": "27"
|
||||
},
|
||||
{
|
||||
"id": "8",
|
||||
"name": "Vehicle Spare Parts",
|
||||
"parent": "27"
|
||||
},
|
||||
{
|
||||
"id": "103",
|
||||
"name": "Hand Tools",
|
||||
"parent": "27"
|
||||
},
|
||||
{
|
||||
"id": "102",
|
||||
"name": "DIY",
|
||||
"parent": "27"
|
||||
},
|
||||
{
|
||||
"id": "104",
|
||||
"name": "Machine Tools",
|
||||
"parent": "27"
|
||||
},
|
||||
{
|
||||
"id": "106",
|
||||
"name": "Tool Holders & Boxes",
|
||||
"parent": "27"
|
||||
},
|
||||
{
|
||||
"id": "28",
|
||||
"name": "Naughties",
|
||||
"parent": null
|
||||
},
|
||||
{
|
||||
"id": "112",
|
||||
"name": "People NSFW",
|
||||
"parent": "28"
|
||||
},
|
||||
{
|
||||
"id": "111",
|
||||
"name": "Sextoys",
|
||||
"parent": "28"
|
||||
},
|
||||
{
|
||||
"id": "108",
|
||||
"name": "Dildos & Vibrators",
|
||||
"parent": "28"
|
||||
},
|
||||
{
|
||||
"id": "109",
|
||||
"name": "Hentai",
|
||||
"parent": "28"
|
||||
},
|
||||
{
|
||||
"id": "107",
|
||||
"name": "Creatures",
|
||||
"parent": "28"
|
||||
},
|
||||
{
|
||||
"id": "110",
|
||||
"name": "Lithophanes",
|
||||
"parent": "28"
|
||||
},
|
||||
{
|
||||
"id": "29",
|
||||
"name": "Various",
|
||||
"parent": null
|
||||
},
|
||||
{
|
||||
"id": "120",
|
||||
"name": "Seasonal & Celebrations",
|
||||
"parent": "29"
|
||||
},
|
||||
{
|
||||
"id": "121",
|
||||
"name": "Sports & Outdoor",
|
||||
"parent": "29"
|
||||
},
|
||||
{
|
||||
"id": "118",
|
||||
"name": "Prototyping",
|
||||
"parent": "29"
|
||||
},
|
||||
{
|
||||
"id": "116",
|
||||
"name": "Education",
|
||||
"parent": "29"
|
||||
},
|
||||
{
|
||||
"id": "117",
|
||||
"name": "Medical",
|
||||
"parent": "29"
|
||||
},
|
||||
{
|
||||
"id": "114",
|
||||
"name": "Dental",
|
||||
"parent": "29"
|
||||
},
|
||||
{
|
||||
"id": "115",
|
||||
"name": "Dioramas",
|
||||
"parent": "29"
|
||||
},
|
||||
{
|
||||
"id": "113",
|
||||
"name": "Books & Reading",
|
||||
"parent": "29"
|
||||
},
|
||||
{
|
||||
"id": "119",
|
||||
"name": "Recycling & Upcycling",
|
||||
"parent": "29"
|
||||
},
|
||||
{
|
||||
"id": "7",
|
||||
"name": "Software",
|
||||
"parent": "29"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -37,6 +37,8 @@ import {
|
||||
isNexprintModelUrl,
|
||||
isThangsListUrl,
|
||||
isThangsModelUrl,
|
||||
isCults3dListUrl,
|
||||
isCults3dModelUrl,
|
||||
isThingiverseCollectionUrl,
|
||||
isThingiverseLikesUrl,
|
||||
isThingiverseThingUrl,
|
||||
@@ -81,6 +83,7 @@ export function useUploadImport({ onUploaded, categoryId, makerworldCookie, onUn
|
||||
startNexprintProfilesImport,
|
||||
startNexprintCollectionImport,
|
||||
startThangsListImport,
|
||||
startCults3dListImport,
|
||||
startLinksImport,
|
||||
} = useImportJob();
|
||||
const isBusy = uploading || importing || zipPrompt.isOpen || collectionPrompt.isOpen || importModePrompt.isOpen;
|
||||
@@ -483,6 +486,33 @@ export function useUploadImport({ onUploaded, categoryId, makerworldCookie, onUn
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCults3dListUrl(url)) {
|
||||
setImporting(false);
|
||||
await collectionPrompt.prompt({
|
||||
label: url,
|
||||
loadEntries: async () => {
|
||||
try {
|
||||
return await importsApi.listCults3dListEntries(payload);
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedError) onUnauthorized?.();
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
onImportSelected: async (modelIds: string[]) => {
|
||||
try {
|
||||
await startCults3dListImport({ ...payload, model_ids: modelIds });
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedError) {
|
||||
onUnauthorized?.();
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCrealitycloudListUrl(url)) {
|
||||
setImporting(false);
|
||||
await collectionPrompt.prompt({
|
||||
@@ -571,7 +601,8 @@ export function useUploadImport({ onUploaded, categoryId, makerworldCookie, onUn
|
||||
isSnapmakerModelUrl(url) ||
|
||||
isCrealitycloudModelUrl(url) ||
|
||||
isNexprintModelUrl(url) ||
|
||||
isThangsModelUrl(url)
|
||||
isThangsModelUrl(url) ||
|
||||
isCults3dModelUrl(url)
|
||||
) {
|
||||
// The backend splits these into plates itself.
|
||||
const imported = await importsApi.fromLink(payload);
|
||||
|
||||
@@ -26,6 +26,7 @@ export const IMPORT_PROVIDER_INFO: Record<string, ImportProviderInfo> = {
|
||||
nexprint: { label: "NexPrint", color: "#7A55E3" },
|
||||
// Thangs' own yellow is too light for white text, so its badge text is dark in both themes.
|
||||
thangs: { label: "Thangs", color: "#FFBC00", textColor: "common.black" },
|
||||
cults3d: { label: "Cults3D", color: "#822EF5" },
|
||||
// Direct uploads (source_provider is null).
|
||||
thingport: { label: "Thingport", color: "common.white", textColor: "common.black", outlined: true },
|
||||
};
|
||||
|
||||
@@ -30,6 +30,7 @@ const ALL: readonly ImportProvider[] = [
|
||||
"crealitycloud",
|
||||
"nexprint",
|
||||
"thangs",
|
||||
"cults3d",
|
||||
];
|
||||
|
||||
/** With every provider off there's nothing to import from or connect to, so those parts of the app hide. */
|
||||
|
||||
@@ -277,6 +277,7 @@
|
||||
"crealitycloudDisabledReason": "Creality Cloud: connect your account in Configuration → Providers",
|
||||
"nexprintDisabledReason": "NexPrint: connect your account in Configuration → Providers",
|
||||
"thangsDisabledReason": "Thangs: connect your account in Configuration → Providers",
|
||||
"cults3dDisabledReason": "Cults3D: enter your nickname and API key in Configuration → Providers",
|
||||
"myModels": "My models"
|
||||
},
|
||||
"notifications": {
|
||||
@@ -544,7 +545,8 @@
|
||||
"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.",
|
||||
"nexprint": "Models with their print profiles, and collections, through each user's own NexPrint account.",
|
||||
"thangs": "Free and bought models, likes and collections, through each user's own Thangs account."
|
||||
"thangs": "Free and bought models, likes and collections, through each user's own Thangs account.",
|
||||
"cults3d": "Free and bought models, likes and printlists, through each user's own Cults3D API key. Files come with the browser's sign-in, which Thingport Grab sends."
|
||||
},
|
||||
"keyStatus": {
|
||||
"thingiverse": {
|
||||
@@ -1674,6 +1676,33 @@
|
||||
"expired": "Sign-in expired. Sign in on thangs.com with Thingport Grab, or connect Thangs again in Configuration → Providers.",
|
||||
"expiredStatus": "Sign-in expired"
|
||||
},
|
||||
"cults3d": {
|
||||
"nickLabel": "Cults3D nickname",
|
||||
"keyLabel": "API key",
|
||||
"placeholder": "Your nickname, as written on your profile",
|
||||
"steps": [
|
||||
"Sign in at cults3d.com and open cults3d.com/en/api/keys. Make a key there and copy its secret.",
|
||||
"Enter your nickname exactly as your profile writes it (capitals count) and the key below. Thingport reads models, likes, printlists and your downloads with it.",
|
||||
"Then open cults3d.com once with Thingport Grab, signed in: Cults3D gives its files only to its own signed-in pages, and Grab passes that sign-in on."
|
||||
],
|
||||
"notConnected": "Thingport reads Cults3D with your own API key. Connect it with your nickname and a key made on cults3d.com.",
|
||||
"expired": "Cults3D turned the API key down. Make a new one on cults3d.com and connect again in Configuration → Providers.",
|
||||
"expiredStatus": "API key turned down",
|
||||
"siteMissing": "Connected, but downloads need the browser's sign-in: open cults3d.com signed in with Thingport Grab, or paste it from the tile's menu.",
|
||||
"siteExpired": "The browser's sign-in ran out: open cults3d.com signed in with Thingport Grab, or paste it again from the tile's menu.",
|
||||
"siteMissingStatus": "No browser sign-in yet",
|
||||
"session": {
|
||||
"title": "Paste the Cults3D sign-in",
|
||||
"paste": "Paste browser sign-in",
|
||||
"save": "Save",
|
||||
"placeholder": "The request headers of a signed-in cults3d.com page",
|
||||
"steps": [
|
||||
"Easiest: with Thingport Grab, just open cults3d.com while signed in. It sends the sign-in itself.",
|
||||
"Or by hand: open your browser's developer tools (F12) on a signed-in cults3d.com page and reload it.",
|
||||
"Under Network, pick the page's own request, copy its request headers (with the Cookie and User-Agent) and paste them below. It only works when Thingport reaches the internet from the same address as that browser."
|
||||
]
|
||||
}
|
||||
},
|
||||
"printables": {
|
||||
"alwaysConnected": "Printables is connected by default: it needs no account or key."
|
||||
},
|
||||
@@ -2644,7 +2673,7 @@
|
||||
"takenBy": "Synced with \"{{name}}\"",
|
||||
"loadFailed": "Couldn't load the collections.",
|
||||
"lookupFailed": "Couldn't find that collection.",
|
||||
"notALink": "Paste a MakerWorld, Printables, Thingiverse, MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud, NexPrint or Thangs collection link.",
|
||||
"notALink": "Paste a MakerWorld, Printables, Thingiverse, MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud, NexPrint, Thangs or Cults3D collection link.",
|
||||
"usernameFailed": "Couldn't save the username.",
|
||||
"openProviders": "Open Providers",
|
||||
"notConnected": {
|
||||
@@ -2656,7 +2685,8 @@
|
||||
"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.",
|
||||
"nexprint": "Connect NexPrint in Configuration > Providers, or visit it with Thingport Grab, to see your collections here.",
|
||||
"thangs": "Connect Thangs in Configuration > Providers, or visit it with Thingport Grab, to see your likes and collections here."
|
||||
"thangs": "Connect Thangs in Configuration > Providers, or visit it with Thingport Grab, to see your likes and collections here.",
|
||||
"cults3d": "Enter your Cults3D nickname and API key in Configuration > Providers to see your likes and printlists here."
|
||||
},
|
||||
"usernameLabel": "Your {{provider}} username",
|
||||
"usernameHint": {
|
||||
|
||||
@@ -278,6 +278,7 @@
|
||||
"crealitycloudDisabledReason": "Creality Cloud: prijunkite paskyrą skiltyje Konfigūracija → Šaltiniai",
|
||||
"nexprintDisabledReason": "NexPrint: prijunkite paskyrą skiltyje Konfigūracija → Šaltiniai",
|
||||
"thangsDisabledReason": "Thangs: prijunkite paskyrą skiltyje Konfigūracija → Šaltiniai",
|
||||
"cults3dDisabledReason": "Cults3D: įveskite slapyvardį ir API raktą skiltyje Konfigūracija → Šaltiniai",
|
||||
"myModels": "Mano modeliai"
|
||||
},
|
||||
"notifications": {
|
||||
@@ -545,7 +546,8 @@
|
||||
"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ą.",
|
||||
"nexprint": "Modeliai su spausdinimo profiliais ir kolekcijos, per kiekvieno naudotojo NexPrint paskyrą.",
|
||||
"thangs": "Nemokami ir įsigyti modeliai, patikę modeliai ir kolekcijos, per kiekvieno naudotojo Thangs paskyrą."
|
||||
"thangs": "Nemokami ir įsigyti modeliai, patikę modeliai ir kolekcijos, per kiekvieno naudotojo Thangs paskyrą.",
|
||||
"cults3d": "Nemokami ir įsigyti modeliai, patikę modeliai ir spausdinimo sąrašai, per kiekvieno naudotojo Cults3D API raktą. Failai gaunami su naršyklės prisijungimu, kurį perduoda Thingport Grab."
|
||||
},
|
||||
"keyStatus": {
|
||||
"thingiverse": {
|
||||
@@ -1730,6 +1732,33 @@
|
||||
"expired": "Prisijungimo galiojimas baigėsi. Prisijunkite thangs.com su Thingport Grab arba vėl prijunkite Thangs skiltyje Konfigūracija → Šaltiniai.",
|
||||
"expiredStatus": "Prisijungimas nebegalioja"
|
||||
},
|
||||
"cults3d": {
|
||||
"nickLabel": "Cults3D slapyvardis",
|
||||
"keyLabel": "API raktas",
|
||||
"placeholder": "Jūsų slapyvardis, kaip parašyta profilyje",
|
||||
"steps": [
|
||||
"Prisijunkite prie cults3d.com ir atverkite cults3d.com/en/api/keys. Sukurkite raktą ir nukopijuokite jo slaptą reikšmę.",
|
||||
"Žemiau įveskite slapyvardį tiksliai taip, kaip jis parašytas profilyje (didžiosios raidės svarbios), ir raktą. Juo Thingport skaito modelius, patikusius modelius, spausdinimo sąrašus ir jūsų atsisiuntimus.",
|
||||
"Tada vieną kartą atverkite cults3d.com su Thingport Grab, prisijungę: Cults3D failus duoda tik savo puslapiams su prisijungusiu naudotoju, o Grab tą prisijungimą perduoda."
|
||||
],
|
||||
"notConnected": "Thingport skaito Cults3D jūsų paties API raktu. Prijunkite jį su slapyvardžiu ir cults3d.com sukurtu raktu.",
|
||||
"expired": "Cults3D atmetė API raktą. Sukurkite naują cults3d.com ir vėl prijunkite skiltyje Konfigūracija → Šaltiniai.",
|
||||
"expiredStatus": "API raktas atmestas",
|
||||
"siteMissing": "Prijungta, bet atsisiuntimams reikia naršyklės prisijungimo: atverkite cults3d.com prisijungę su Thingport Grab arba įklijuokite jį iš plytelės meniu.",
|
||||
"siteExpired": "Naršyklės prisijungimas nebegalioja: atverkite cults3d.com prisijungę su Thingport Grab arba vėl įklijuokite jį iš plytelės meniu.",
|
||||
"siteMissingStatus": "Dar nėra naršyklės prisijungimo",
|
||||
"session": {
|
||||
"title": "Įklijuokite Cults3D prisijungimą",
|
||||
"paste": "Įklijuoti naršyklės prisijungimą",
|
||||
"save": "Išsaugoti",
|
||||
"placeholder": "Prisijungusio cults3d.com puslapio užklausos antraštės",
|
||||
"steps": [
|
||||
"Paprasčiausia: su Thingport Grab tiesiog atverkite cults3d.com prisijungę. Jis pats perduoda prisijungimą.",
|
||||
"Arba rankiniu būdu: prisijungusiame cults3d.com puslapyje atverkite naršyklės kūrėjo įrankius (F12) ir perkraukite jį.",
|
||||
"Skiltyje Network pasirinkite paties puslapio užklausą, nukopijuokite jos antraštes (su Cookie ir User-Agent) ir įklijuokite žemiau. Tai veikia tik tada, kai Thingport jungiasi prie interneto tuo pačiu adresu kaip ta naršyklė."
|
||||
]
|
||||
}
|
||||
},
|
||||
"printables": {
|
||||
"alwaysConnected": "Printables prijungta iš karto: nereikia paskyros ar rakto."
|
||||
},
|
||||
@@ -2715,7 +2744,7 @@
|
||||
"takenBy": "Sinchronizuojama su „{{name}}“",
|
||||
"loadFailed": "Nepavyko įkelti kolekcijų.",
|
||||
"lookupFailed": "Nepavyko rasti šios kolekcijos.",
|
||||
"notALink": "Įklijuokite MakerWorld, Printables, Thingiverse, MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud, NexPrint arba Thangs kolekcijos nuorodą.",
|
||||
"notALink": "Įklijuokite MakerWorld, Printables, Thingiverse, MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud, NexPrint, Thangs arba Cults3D kolekcijos nuorodą.",
|
||||
"usernameFailed": "Nepavyko išsaugoti vartotojo vardo.",
|
||||
"openProviders": "Atidaryti tiekėjus",
|
||||
"notConnected": {
|
||||
@@ -2727,7 +2756,8 @@
|
||||
"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.",
|
||||
"nexprint": "Prijunkite NexPrint per Konfigūracija > Šaltiniai arba atverkite jį su Thingport Grab, kad čia matytumėte savo kolekcijas.",
|
||||
"thangs": "Prijunkite Thangs per Konfigūracija > Šaltiniai arba atverkite jį su Thingport Grab, kad čia matytumėte savo patikusius modelius ir kolekcijas."
|
||||
"thangs": "Prijunkite Thangs per Konfigūracija > Šaltiniai arba atverkite jį su Thingport Grab, kad čia matytumėte savo patikusius modelius ir kolekcijas.",
|
||||
"cults3d": "Įveskite Cults3D slapyvardį ir API raktą per Konfigūracija > Šaltiniai, kad čia matytumėte patikusius modelius ir spausdinimo sąrašus."
|
||||
},
|
||||
"usernameLabel": "Jūsų {{provider}} vartotojo vardas",
|
||||
"usernameHint": {
|
||||
|
||||
@@ -37,6 +37,7 @@ const NAMES: Record<ImportProvider, string> = {
|
||||
crealitycloud: "Creality Cloud",
|
||||
nexprint: "NexPrint",
|
||||
thangs: "Thangs",
|
||||
cults3d: "Cults3D",
|
||||
};
|
||||
|
||||
const KEYED = new Set<ImportProvider>(["thingiverse", "myminifactory"]);
|
||||
@@ -130,6 +131,7 @@ export default function ProvidersPage({ onUnauthorized }: Props) {
|
||||
"crealitycloud",
|
||||
"nexprint",
|
||||
"thangs",
|
||||
"cults3d",
|
||||
] as const
|
||||
).map((id) => ({
|
||||
id,
|
||||
|
||||
@@ -21,6 +21,7 @@ export const PROVIDER_ACCENTS: Record<BrowsableProvider, string> = {
|
||||
crealitycloud: "#1AC686",
|
||||
nexprint: "#7A55E3",
|
||||
thangs: "#FFBC00",
|
||||
cults3d: "#822EF5",
|
||||
};
|
||||
|
||||
type Props = {
|
||||
@@ -424,6 +425,60 @@ function ThangsTile({ collection }: { collection: ProviderCollection }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Cults3D: a big cover beside two small ones, the name and a count in its purple below. */
|
||||
function Cults3dTile({ collection }: { collection: ProviderCollection }) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: "10px",
|
||||
border: 1,
|
||||
borderColor: "divider",
|
||||
bgcolor: "background.paper",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "2fr 1fr",
|
||||
gridTemplateRows: "1fr 1fr",
|
||||
gap: "2px",
|
||||
aspectRatio: "4 / 3",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ gridRow: "1 / 3", minHeight: 0, overflow: "hidden" }}>
|
||||
<Thumb src={collection.thumbnails[0]} sx={{ height: "100%" }} />
|
||||
</Box>
|
||||
{[1, 2].map((i) => (
|
||||
<Box key={i} sx={{ minHeight: 0, overflow: "hidden" }}>
|
||||
<Thumb src={collection.thumbnails[i]} sx={{ height: "100%" }} />
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
<Box sx={{ px: 1.25, py: 1 }}>
|
||||
<Typography noWrap sx={{ fontWeight: 700, fontSize: "0.875rem" }}>
|
||||
{collection.name}
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 0.5,
|
||||
mt: 0.25,
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 600,
|
||||
// Its purple is too dark to read on the dark theme's paper; a lighter shade of it there.
|
||||
color: (theme) => (theme.palette.mode === "dark" ? "#B48CFA" : PROVIDER_ACCENTS.cults3d),
|
||||
}}
|
||||
>
|
||||
<ModelCount count={collection.model_count} provider="cults3d" />
|
||||
{collection.private && <LockOutlinedIcon sx={{ fontSize: 14, ml: "auto", color: "text.secondary" }} />}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const TILES: Record<BrowsableProvider, (props: { collection: ProviderCollection }) => JSX.Element> = {
|
||||
makerworld: MakerWorldTile,
|
||||
printables: PrintablesTile,
|
||||
@@ -434,6 +489,7 @@ const TILES: Record<BrowsableProvider, (props: { collection: ProviderCollection
|
||||
crealitycloud: CrealitycloudTile,
|
||||
nexprint: NexprintTile,
|
||||
thangs: ThangsTile,
|
||||
cults3d: Cults3dTile,
|
||||
};
|
||||
|
||||
/** The name to show: a user's likes, and Snapmaker's likes and bookmarks, come named in English by the instance, as
|
||||
@@ -514,6 +570,26 @@ export function ProviderCollectionTileSkeleton({ provider }: { provider: Browsab
|
||||
<Skeleton variant="text" sx={{ fontSize: "0.75rem", width: "40%" }} />
|
||||
</>
|
||||
);
|
||||
if (provider === "cults3d") {
|
||||
return (
|
||||
<Box aria-hidden sx={{ borderRadius: "10px", border: 1, borderColor: "divider", overflow: "hidden" }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "2fr 1fr",
|
||||
gridTemplateRows: "1fr 1fr",
|
||||
gap: "2px",
|
||||
aspectRatio: "4 / 3",
|
||||
}}
|
||||
>
|
||||
<Skeleton variant="rectangular" sx={{ gridRow: "1 / 3", width: "100%", height: "100%" }} />
|
||||
<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 === "makerworld") {
|
||||
return (
|
||||
<Box aria-hidden sx={{ borderRadius: "12px", border: 1, borderColor: "divider", overflow: "hidden", mb: "11px" }}>
|
||||
|
||||
@@ -50,6 +50,8 @@ const PASTE_EXAMPLES: Record<BrowsableProvider, string> = {
|
||||
crealitycloud: "https://www.crealitycloud.com/collection/…",
|
||||
nexprint: "https://www.nexprint.com/en/…/home?component=UserCollect&subComp=UserCollectDetail&collectionId=G…",
|
||||
thangs: "https://thangs.com/designer/…/collection/…",
|
||||
// Cults3D's API shows no one's printlists but the user's own, so someone else's means their likes.
|
||||
cults3d: "https://cults3d.com/en/users/…/likes",
|
||||
};
|
||||
|
||||
// Listed with the user's own sign-in, which already says who they are, so no username is asked for.
|
||||
@@ -60,6 +62,7 @@ const SIGNED_IN = new Set<BrowsableProvider>([
|
||||
"crealitycloud",
|
||||
"nexprint",
|
||||
"thangs",
|
||||
"cults3d",
|
||||
]);
|
||||
|
||||
/** The one the user's slicer goes with, unless it isn't connected; then the first that is. Only from `available`,
|
||||
|
||||
@@ -27,6 +27,7 @@ const LOGOS = new Set<string>([
|
||||
"crealitycloud",
|
||||
"nexprint",
|
||||
"thangs",
|
||||
"cults3d",
|
||||
]);
|
||||
|
||||
// The pulse is 40% of its segment wide, so 250% starts it past the right end and -100% ends it
|
||||
|
||||
@@ -167,6 +167,7 @@ export default function LogsPage({ onUnauthorized }: Props) {
|
||||
crealitycloud: "Creality Cloud",
|
||||
nexprint: "NexPrint",
|
||||
thangs: "Thangs",
|
||||
cults3d: "Cults3D",
|
||||
};
|
||||
const provider = typeof d.provider === "string" ? (names[d.provider] ?? d.provider) : "";
|
||||
return t(d.enabled ? "adminSettings.logs.providerOn" : "adminSettings.logs.providerOff", { provider });
|
||||
|
||||
@@ -76,7 +76,8 @@ function hasMeta(category: Category): boolean {
|
||||
category.makeronline_cat_ids ||
|
||||
category.crealitycloud_cat_ids ||
|
||||
category.nexprint_cat_ids ||
|
||||
category.thangs_cat_ids,
|
||||
category.thangs_cat_ids ||
|
||||
category.cults3d_cat_ids,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ export default function CategoryMetaDialog({ category, showSiteCatIds, onClose,
|
||||
crealitycloud: category.crealitycloud_cat_ids,
|
||||
nexprint: category.nexprint_cat_ids,
|
||||
thangs: category.thangs_cat_ids,
|
||||
cults3d: category.cults3d_cat_ids,
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -62,6 +63,7 @@ export default function CategoryMetaDialog({ category, showSiteCatIds, onClose,
|
||||
crealitycloudCatIds: catIds.crealitycloud,
|
||||
nexprintCatIds: catIds.nexprint,
|
||||
thangsCatIds: catIds.thangs,
|
||||
cults3dCatIds: catIds.cults3d,
|
||||
});
|
||||
onClose();
|
||||
} catch (err) {
|
||||
|
||||
@@ -9,7 +9,8 @@ export type ImportProviderKey =
|
||||
| "makeronline"
|
||||
| "crealitycloud"
|
||||
| "nexprint"
|
||||
| "thangs";
|
||||
| "thangs"
|
||||
| "cults3d";
|
||||
|
||||
function parseUrl(url: string): URL | null {
|
||||
try {
|
||||
@@ -32,6 +33,7 @@ export function detectImportProvider(url: string): ImportProviderKey | null {
|
||||
if (host === "crealitycloud.com" || host === "www.crealitycloud.com") return "crealitycloud";
|
||||
if (host === "nexprint.com" || host === "www.nexprint.com") return "nexprint";
|
||||
if (host === "thangs.com" || host === "www.thangs.com") return "thangs";
|
||||
if (host === "cults3d.com" || host === "www.cults3d.com") return "cults3d";
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -221,6 +223,69 @@ export function isThangsListUrl(url: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/** Cults3D's path segments, decoded and lower-cased: it writes them in each of its languages, accents and all. */
|
||||
function cults3dSegments(url: string): string[] | null {
|
||||
const parsed = parseUrl(url);
|
||||
if (!parsed || !/^(www\.)?cults3d\.com$/i.test(parsed.hostname)) return null;
|
||||
const parts = parsed.pathname.split("/").filter(Boolean);
|
||||
try {
|
||||
return parts.map((part) => decodeURIComponent(part).toLowerCase());
|
||||
} catch {
|
||||
return parts.map((part) => part.toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
// What each of Cults3D's languages calls a model page, likes and printlists ("design collections"). Kept in step
|
||||
// with the backend's cults3dApi.ts.
|
||||
const CULTS3D_MODEL_WORDS = new Set([
|
||||
"3d-model",
|
||||
"modell-3d",
|
||||
"modelo-3d",
|
||||
"modèle-3d",
|
||||
"modello-3d",
|
||||
"3d-moderu",
|
||||
"3d-móxíng",
|
||||
]);
|
||||
const CULTS3D_LIKES_WORDS = new Set(["likes", "mi-piace", "iine-ichiran", "curtidas", "laiki", "beğeniler", "xǐhuān"]);
|
||||
const CULTS3D_PRINTLIST_WORDS = new Set([
|
||||
"design-collections",
|
||||
"drucklisten",
|
||||
"colecciones-de-diseños",
|
||||
"collections-de-designs",
|
||||
"raccolta-progetti",
|
||||
"dezain-korekushon",
|
||||
"designs-colecoes",
|
||||
"dizain-kollektsii",
|
||||
"tasarım-koleksiyonlar",
|
||||
"shèjì-xìliè",
|
||||
]);
|
||||
const CULTS3D_LIST_PAGES = new Set([
|
||||
"latest",
|
||||
"popular",
|
||||
"selected",
|
||||
"trending",
|
||||
"best-sellers",
|
||||
"random",
|
||||
"mine",
|
||||
"new",
|
||||
]);
|
||||
|
||||
/** See isThingiverseThingUrl. `/<locale>/3d-model/<category>/<slug>` in any of its languages, or
|
||||
* `/<locale>/creations/<slug>`. */
|
||||
export function isCults3dModelUrl(url: string): boolean {
|
||||
const parts = cults3dSegments(url);
|
||||
if (!parts || !/^[a-z]{2}$/.test(parts[0] ?? "")) return false;
|
||||
if (CULTS3D_MODEL_WORDS.has(parts[1]) && parts.length >= 4) return true;
|
||||
return parts[1] === "creations" && parts.length === 3 && !CULTS3D_LIST_PAGES.has(parts[2]);
|
||||
}
|
||||
|
||||
/** A user's likes (`/<locale>/users/<nick>/likes`) or a printlist (`/<locale>/design-collections/<nick>/<slug>`). */
|
||||
export function isCults3dListUrl(url: string): boolean {
|
||||
const parts = cults3dSegments(url);
|
||||
if (!parts || parts.length !== 4 || !/^[a-z]{2}$/.test(parts[0])) return false;
|
||||
return CULTS3D_LIKES_WORDS.has(parts[3]) || CULTS3D_PRINTLIST_WORDS.has(parts[1]);
|
||||
}
|
||||
|
||||
export const IMPORT_LINK_EXAMPLES: Record<ImportProviderKey, { model: string; collection: string }> = {
|
||||
makerworld: {
|
||||
model: "https://makerworld.com/en/models/123456-example-model",
|
||||
@@ -258,4 +323,8 @@ export const IMPORT_LINK_EXAMPLES: Record<ImportProviderKey, { model: string; co
|
||||
model: "https://thangs.com/designer/username/3d-model/Example%20Model-1234567",
|
||||
collection: "https://thangs.com/designer/username/collection/Example%20Collection-123456",
|
||||
},
|
||||
cults3d: {
|
||||
model: "https://cults3d.com/en/3d-model/art/example-model",
|
||||
collection: "https://cults3d.com/en/design-collections/username/example-printlist",
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user