mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-22 14:42:29 -05:00
feat: add Thangs provider
Imports Thangs models, likes and collections, and syncs them. Metadata is public; files need the user's own sign-in, which Thingport Grab sends and Thingport renews from the refresh token. Paid models download once owned; print-only ones are refused before any download. Free accounts' daily download limit stops a batch and defers the rest of a sync to its next check.
This commit is contained in:
1 parent
b9aea99e65
commit
2591714c0c
70 files changed
+2469
-128
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 and NexPrint](docs/features/importing.md)**
|
||||
- **[Import from MakerWorld, Printables, Thingiverse, MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud, NexPrint and Thangs](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 and NexPrint models straight from their own pages, without leaving the site (see the screenshots above). Even without a Thingport instance, it adds a "Download normalized" button to MakerWorld model pages that converts Bambu Studio projects into 3MFs other slicers open with their colours and settings. Get it from the [Chrome Web Store](https://chromewebstore.google.com/detail/nmblahmglpbplmfcggghdgohohlaeiee), [Firefox Add-ons](https://addons.mozilla.org/firefox/addon/thingport-grab/) or [Microsoft Edge Add-ons](https://microsoftedge.microsoft.com/addons/detail/kahfidpmojfocohinlmglnfoaimocbol).
|
||||
- **[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).
|
||||
|
||||
## 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, and NexPrint for its files; Thingport Grab sends both on. Connect them under **Configuration → Providers** (from the user menu): right-click a provider to connect or disconnect it. Admins can turn any provider off for the whole instance under **Administration → Providers**. See **[docs/PROVIDER_SETUP.md](docs/PROVIDER_SETUP.md)** for how to create a Thingiverse App Token or a MyMiniFactory API key, and how to grab a MakerWorld session cookie.
|
||||
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.
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
-- Thangs as a ninth provider: its category paths on categories ("Parent/Child"; Thangs has no category ids), and the
|
||||
-- user's Thangs sign-in: the accessToken cookie and the refreshToken that renews it, sent by Thingport Grab or pasted.
|
||||
ALTER TABLE "Category" ADD COLUMN "thangsCatIds" TEXT[] NOT NULL DEFAULT '{}';
|
||||
CREATE INDEX "Category_thangsCatIds_idx" ON "Category" USING GIN ("thangsCatIds");
|
||||
|
||||
ALTER TABLE "User" ADD COLUMN "thangsToken" TEXT,
|
||||
ADD COLUMN "thangsRefreshToken" TEXT,
|
||||
ADD COLUMN "thangsExpiresAt" TIMESTAMP(3),
|
||||
ADD COLUMN "thangsUserId" TEXT,
|
||||
ADD COLUMN "thangsRejectedAt" TIMESTAMP(3);
|
||||
|
||||
-- Adds Thangs' category paths to the starter categories of users who signed up before they were in
|
||||
-- seedData/defaultCategories.ts, so their Thangs 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
|
||||
-- paths a user already set are kept.
|
||||
UPDATE "Category" AS c
|
||||
SET "thangsCatIds" = m.ids
|
||||
FROM (VALUES
|
||||
(NULL::text, '3D Printer', ARRAY['3D Printer Parts & Accessories']),
|
||||
('3D Printer', '3D Printer Accessories', ARRAY['3D Printer Parts & Accessories/Enclosures & Racks', '3D Printer Parts & Accessories/Filament Management', '3D Printer Parts & Accessories/Maintenance Tools', '3D Printer Parts & Accessories/Print Bed Accessories']),
|
||||
('3D Printer', '3D Printer Parts', ARRAY['3D Printer Parts & Accessories/Printer Upgrades']),
|
||||
('3D Printer', 'Test Models', ARRAY['3D Printer Parts & Accessories/Test Prints & Calibration']),
|
||||
(NULL::text, 'Art', ARRAY['Art & Decor']),
|
||||
('Art', '2D Art', ARRAY['Art & Decor/Wall Art']),
|
||||
('Art', 'Coin & Badges', ARRAY['Art & Decor/Trophies & Awards']),
|
||||
('Art', 'Sculptures', ARRAY['Art & Decor/Sculptures & Statues', 'Art & Decor/Busts']),
|
||||
('Art', 'Other Art Models', ARRAY['Art & Decor/Textured & Patterned']),
|
||||
(NULL::text, 'Education', ARRAY['Educational & Scientific']),
|
||||
('Education', 'Biology', ARRAY['Educational & Scientific/Biology', 'Educational & Scientific/Human Anatomy & Medical']),
|
||||
('Education', 'Chemistry', ARRAY['Educational & Scientific/Chemistry']),
|
||||
('Education', 'Geography', ARRAY['Educational & Scientific/Geology']),
|
||||
('Education', 'Physics & Astronomy', ARRAY['Educational & Scientific/Physics', 'Educational & Scientific/Astronomy']),
|
||||
(NULL::text, 'Fashion', ARRAY['Fashion & Jewelry']),
|
||||
('Fashion', 'Clothes', ARRAY['Fashion & Jewelry/Clothing']),
|
||||
('Fashion', 'Earrings', ARRAY['Fashion & Jewelry/Earrings']),
|
||||
('Fashion', 'Jewelry', ARRAY['Fashion & Jewelry/Necklaces', 'Fashion & Jewelry/Bracelets', 'Fashion & Jewelry/Jewelry Storage']),
|
||||
('Fashion', 'Rings', ARRAY['Fashion & Jewelry/Rings']),
|
||||
('Fashion', 'Other Fashion Models', ARRAY['Fashion & Jewelry/Accessories']),
|
||||
(NULL::text, 'Hobby & DIY', ARRAY['Hobby & DIY']),
|
||||
('Hobby & DIY', 'Electronics', ARRAY['Hobby & DIY/Electronics']),
|
||||
('Hobby & DIY', 'Music', ARRAY['Hobby & DIY/Music & Audio']),
|
||||
('Hobby & DIY', 'RC', ARRAY['Hobby & DIY/RC']),
|
||||
('Hobby & DIY', 'Robotics', ARRAY['Hobby & DIY/Robotics']),
|
||||
('Hobby & DIY', 'Sport & Outdoors', ARRAY['Hobby & DIY/Outdoor', 'Health & Fitness/Sports']),
|
||||
('Hobby & DIY', 'Vehicles', ARRAY['Hobby & DIY/Automotive']),
|
||||
('Hobby & DIY', 'Other Hobby & DIY', ARRAY['Hobby & DIY/Baking and Cooking', 'Hobby & DIY/Photography & Video']),
|
||||
(NULL::text, 'Household', ARRAY['Home & Garden']),
|
||||
('Household', 'Decor', ARRAY['Home & Garden/Home Decor & Accessories', 'Home & Garden/Living Room', 'Home & Garden/Bedroom', 'Art & Decor/Lamps & Lighting', 'Art & Decor/Vases & Planters']),
|
||||
('Household', 'Festivities', ARRAY['Seasonal', 'Seasonal/Beach & Summer', 'Seasonal/Easter & Spring', 'Seasonal/Halloween & Fall', 'Seasonal/Holidays & Winter']),
|
||||
('Household', 'Garden', ARRAY['Home & Garden/Outdoor & Garden']),
|
||||
('Household', 'Office', ARRAY['Tools & Organizers/Office & Desk']),
|
||||
('Household', 'Pets', ARRAY['Home & Garden/Pet Accessories']),
|
||||
('Household', 'Other Household Models', ARRAY['Home & Garden/Bathroom', 'Home & Garden/Kitchen & Dining', 'Health & Fitness', 'Health & Fitness/Wellness', 'Health & Fitness/Accessibility']),
|
||||
(NULL::text, 'Miniatures', ARRAY['Miniatures & Tabletop']),
|
||||
('Miniatures', 'Architecture', ARRAY['Miniatures & Tabletop/Dioramas & Scenery']),
|
||||
('Miniatures', 'Creatures', ARRAY['Miniatures & Tabletop/Fantasy Miniatures']),
|
||||
('Miniatures', 'Other Miniatures', ARRAY['Miniatures & Tabletop/Sci-Fi Miniatures', 'Miniatures & Tabletop/War & Tactics', 'Miniatures & Tabletop/Naval', 'Miniatures & Tabletop/Airplanes', 'Miniatures & Tabletop/Trains', 'Miniatures & Tabletop/Vehicles', 'Miniatures & Tabletop/Accessories']),
|
||||
(NULL::text, 'Props & Cosplays', ARRAY['Costumes & Cosplay']),
|
||||
('Props & Cosplays', 'Costumes', ARRAY['Costumes & Cosplay/Full Outfits', 'Costumes & Cosplay/Character Specific']),
|
||||
('Props & Cosplays', 'Masks & Helmets', ARRAY['Costumes & Cosplay/Masks & Helmets']),
|
||||
('Props & Cosplays', 'Cosplay Weapons', ARRAY['Costumes & Cosplay/Armor & Props']),
|
||||
('Props & Cosplays', 'Other Props & Cosplays', ARRAY['Costumes & Cosplay/Accessories']),
|
||||
(NULL::text, 'Tools', ARRAY['Tools & Organizers', 'Functional Prints']),
|
||||
('Tools', 'Hand Tools', ARRAY['Tools & Organizers/Utility & Tools', 'Functional Prints/Tools']),
|
||||
('Tools', 'Machine Tools', ARRAY['Functional Prints/Jigs & Fixtures']),
|
||||
('Tools', 'Organizers', ARRAY['Tools & Organizers/Gridfinity', 'Tools & Organizers/Multiboard', 'Tools & Organizers/Storage Solutions', 'Tools & Organizers/Shop & Garage']),
|
||||
('Tools', 'Other Tools', ARRAY['Functional Prints/Mechanical Parts', 'Functional Prints/Replacement Parts', 'Functional Prints/Customizable']),
|
||||
(NULL::text, 'Toys & Games', ARRAY['Toys & Games']),
|
||||
('Toys & Games', 'Board Games', ARRAY['Toys & Games/Board Game Accessories']),
|
||||
('Toys & Games', 'Characters', ARRAY['Toys & Games/Action Figures & Collectibles', 'Toys & Games/Articulated']),
|
||||
('Toys & Games', 'Outdoor Toys', ARRAY['Toys & Games/Outdoor Toys']),
|
||||
('Toys & Games', 'Puzzles', ARRAY['Toys & Games/Puzzles']),
|
||||
('Toys & Games', 'Other Toys & Games', ARRAY['Toys & Games/Educational Toys', 'Toys & Games/Fidget Toys', 'Toys & Games/Keychains', 'Toys & Games/Video Games']),
|
||||
('Generative 3D Model', 'Hueforge & Lithophane', ARRAY['Art & Decor/Hueforge', 'Art & Decor/Lithophanes'])
|
||||
) AS m(parent_name, name, ids)
|
||||
WHERE c."kind" = 'category'
|
||||
AND c."name" = m.name
|
||||
AND cardinality(c."thangsCatIds") = 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
|
||||
)
|
||||
);
|
||||
@@ -100,6 +100,16 @@ model User {
|
||||
nexprintExpiresAt DateTime?
|
||||
// When NexPrint last turned the token down.
|
||||
nexprintRejectedAt DateTime?
|
||||
// The user's Thangs sign-in: the accessToken cookie (a 7-day JWT, sent as a Bearer token) and the refreshToken
|
||||
// cookie that renews it, which lasts about a year and isn't replaced on use. Thingport renews the access token
|
||||
// itself; Thingport Grab sends both whenever the browser's change.
|
||||
thangsToken String?
|
||||
thangsRefreshToken String?
|
||||
// The access token's own expiry, from its JWT.
|
||||
thangsExpiresAt DateTime?
|
||||
thangsUserId String?
|
||||
// When Thangs last turned the sign-in down, refresh token included.
|
||||
thangsRejectedAt DateTime?
|
||||
slicer String?
|
||||
// "light" | "dark" | "system"; null means never set.
|
||||
themePreference String?
|
||||
@@ -177,6 +187,8 @@ model Category {
|
||||
crealitycloudCatIds Int[] @default([])
|
||||
// Text: NexPrint's ids are 16 digits, past Int, and a BigInt would break JSON serialization of the row.
|
||||
nexprintCatIds String[] @default([])
|
||||
// Thangs has no category ids, only paths: "Tools & Organizers", or "Tools & Organizers/Multiboard".
|
||||
thangsCatIds String[] @default([])
|
||||
prints Print[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -191,6 +203,7 @@ model Category {
|
||||
@@index([makeronlineCatIds], type: Gin)
|
||||
@@index([crealitycloudCatIds], type: Gin)
|
||||
@@index([nexprintCatIds], type: Gin)
|
||||
@@index([thangsCatIds], type: Gin)
|
||||
}
|
||||
|
||||
model Print {
|
||||
|
||||
@@ -183,6 +183,7 @@ export type CategoryOut = {
|
||||
makeronline_cat_ids: string;
|
||||
crealitycloud_cat_ids: string;
|
||||
nexprint_cat_ids: string;
|
||||
thangs_cat_ids: string;
|
||||
};
|
||||
|
||||
function plateThumbUrl(plateId: string): string | null {
|
||||
@@ -481,6 +482,7 @@ export function toCategoryOut(category: Category): CategoryOut {
|
||||
makeronline_cat_ids: formatCatIds(category.makeronlineCatIds),
|
||||
crealitycloud_cat_ids: formatCatIds(category.crealitycloudCatIds),
|
||||
nexprint_cat_ids: formatCatIds(category.nexprintCatIds),
|
||||
thangs_cat_ids: formatCatIds(category.thangsCatIds),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -184,7 +184,37 @@ function parseCatIdsInput(raw: string | null | undefined): number[] {
|
||||
return ids;
|
||||
}
|
||||
|
||||
/** Thangs' category paths, "Parent;Parent/Child", as written on the site: blank clears, duplicates are dropped. */
|
||||
function parseCatPathsInput(raw: string | null | undefined): string[] {
|
||||
const paths = [
|
||||
...new Set(
|
||||
(raw ?? "")
|
||||
.split(";")
|
||||
.map((path) =>
|
||||
path
|
||||
.split("/")
|
||||
.map((part) => part.trim())
|
||||
.join("/"),
|
||||
)
|
||||
.filter(Boolean),
|
||||
),
|
||||
];
|
||||
for (const path of paths) {
|
||||
if (path.length > 200 || path.split("/").some((part) => !part)) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`Invalid category path "${path}" -- use paths separated by ";", e.g. Toys & Games/Puzzles`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (paths.length > MAX_CAT_IDS) {
|
||||
throw new HttpError(400, `Too many category paths -- at most ${MAX_CAT_IDS} allowed`);
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
const catIdsField = z.string().trim().max(1000).nullable().optional();
|
||||
const catPathsField = z.string().trim().max(5000).nullable().optional();
|
||||
|
||||
/** A field the request leaves out is left as it is (Prisma skips undefined), so a client from before a provider was
|
||||
* added can't wipe that provider's ids; blank or null still clears them. */
|
||||
@@ -202,6 +232,7 @@ const categoryMetaSchema = z.object({
|
||||
makeronline_cat_ids: catIdsField,
|
||||
crealitycloud_cat_ids: catIdsField,
|
||||
nexprint_cat_ids: catIdsField,
|
||||
thangs_cat_ids: catPathsField,
|
||||
});
|
||||
|
||||
router.patch(
|
||||
@@ -223,6 +254,7 @@ router.patch(
|
||||
makeronlineCatIds: catIdsUpdate(body.makeronline_cat_ids),
|
||||
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),
|
||||
},
|
||||
});
|
||||
res.json(toCategoryOut(updated));
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
SNAPMAKER_SESSION_CODE,
|
||||
CREALITYCLOUD_SESSION_CODE,
|
||||
NEXPRINT_SESSION_CODE,
|
||||
THANGS_SESSION_CODE,
|
||||
checkImportStatus,
|
||||
downloadImportToTemp,
|
||||
findImportedExternalIds,
|
||||
@@ -110,6 +111,25 @@ import {
|
||||
parseNexprintModelUrl,
|
||||
} from "../services/nexprintApi";
|
||||
import { markNexprintSessionRejected } from "../services/nexprintSessionService";
|
||||
import {
|
||||
fetchThangsCollection,
|
||||
fetchThangsLikes,
|
||||
fetchThangsPurchases,
|
||||
isThangsLikesUrl,
|
||||
parseThangsCollectionUrl,
|
||||
ThangsAuthError,
|
||||
ThangsBlockedError,
|
||||
type ThangsListing,
|
||||
THANGS_LIKES_NAME,
|
||||
ThangsPrivateCollectionError,
|
||||
} from "../services/thangsApi";
|
||||
import {
|
||||
getUserThangsId,
|
||||
getUserThangsToken,
|
||||
markThangsSessionRejected,
|
||||
THANGS_CONNECT,
|
||||
withThangsToken,
|
||||
} from "../services/thangsSessionService";
|
||||
import { addPrintsToCollection, findOrCreateCollectionByName } from "../services/collectionService";
|
||||
import { listZipEntries } from "../services/zipService";
|
||||
import { createJob, createJobItems, getActiveJob, getJob, listJobItems } from "../services/importJobService";
|
||||
@@ -121,6 +141,7 @@ import {
|
||||
runCrealitycloudProfilesImportJob,
|
||||
runNexprintCollectionImportJob,
|
||||
runNexprintProfilesImportJob,
|
||||
runThangsListImportJob,
|
||||
runLinksImportJob,
|
||||
runMakerworldProfilesImportJob,
|
||||
runMyminifactoryCollectionImportJob,
|
||||
@@ -298,6 +319,7 @@ const statusBatchSchema = z.object({
|
||||
"makeronline",
|
||||
"crealitycloud",
|
||||
"nexprint",
|
||||
"thangs",
|
||||
]),
|
||||
// Creality Cloud's ids are its models' aliases, made of their titles.
|
||||
external_ids: z.array(z.string().max(200)).max(10_000),
|
||||
@@ -684,6 +706,60 @@ router.post(
|
||||
}),
|
||||
);
|
||||
|
||||
/** The user's likes, or a collection, from a Thangs link, read with their sign-in: every file needs it anyway, and it
|
||||
* says which paid models they own, which the listing keeps. */
|
||||
async function thangsListOf(userId: string, url: string): Promise<{ title: string; listing: ThangsListing }> {
|
||||
const likes = isThangsLikesUrl(url);
|
||||
const collection = likes ? null : parseThangsCollectionUrl(url);
|
||||
if (!likes && !collection) throw new HttpError(400, "Not a link to a Thangs collection or your likes");
|
||||
try {
|
||||
const [token, thangsId] = await Promise.all([getUserThangsToken(userId), getUserThangsId(userId)]);
|
||||
if (!token || !thangsId) throw new HttpError(400, THANGS_CONNECT, THANGS_SESSION_CODE);
|
||||
const owned = await withThangsToken(userId, token, fetchThangsPurchases);
|
||||
if (likes) return { title: THANGS_LIKES_NAME, listing: await fetchThangsLikes(thangsId, owned) };
|
||||
const found = await withThangsToken(userId, token, (fresh) =>
|
||||
fetchThangsCollection(collection!.collectionId, fresh, owned),
|
||||
);
|
||||
if (!found) throw new HttpError(404, "That collection can't be found. It may have been deleted.");
|
||||
return { title: found.info.name, listing: found };
|
||||
} catch (err) {
|
||||
if (err instanceof ThangsAuthError) {
|
||||
await markThangsSessionRejected(userId);
|
||||
// 400, not 401: the frontend treats any 401 as an expired Thingport session.
|
||||
throw new HttpError(400, err.message, THANGS_SESSION_CODE);
|
||||
}
|
||||
if (err instanceof ThangsPrivateCollectionError) throw new HttpError(403, err.message);
|
||||
if (err instanceof ThangsBlockedError) throw new HttpError(429, err.message);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
router.post(
|
||||
"/import/thangs-list/entries",
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = parseBody(importRequestSchema, req.body);
|
||||
const url = await enabledImportUrl(body.url);
|
||||
const { title, listing } = await thangsListOf(req.userId!, url);
|
||||
if (!listing.entries.length) throw new HttpError(400, `"${title}" has no models Thingport can import`);
|
||||
const alreadyImported = await findImportedExternalIds(
|
||||
req.userId!,
|
||||
"thangs",
|
||||
listing.entries.map((e) => e.modelId),
|
||||
);
|
||||
res.json({
|
||||
title,
|
||||
total: listing.total,
|
||||
truncated: listing.truncated,
|
||||
entries: listing.entries.map((e) => ({
|
||||
design_id: e.modelId,
|
||||
title: e.title,
|
||||
cover: e.cover,
|
||||
already_imported: alreadyImported.has(e.modelId),
|
||||
})),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
// ---- MakerOnline: files only through Thingport Grab ----
|
||||
// MakerOnline signs its download links only for a page its own anti-bot script runs on. Grab asks what a model needs
|
||||
// (plan), gets it signed on makeronline.com, and imports with the signed links; imports started anywhere else wait
|
||||
@@ -1132,6 +1208,28 @@ router.post(
|
||||
}),
|
||||
);
|
||||
|
||||
const thangsListImportRequestSchema = importRequestSchema.extend({
|
||||
model_ids: z.array(z.string().regex(/^\d+$/).max(20)).min(1).max(1000),
|
||||
});
|
||||
|
||||
router.post(
|
||||
"/import/thangs-list",
|
||||
requireCaptcha("import"),
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = parseBody(thangsListImportRequestSchema, req.body);
|
||||
await assertNoActiveJob(req.userId!);
|
||||
const url = await enabledImportUrl(body.url);
|
||||
const { title } = await thangsListOf(req.userId!, url);
|
||||
const job = await createJob(req.userId!, "COLLECTION", {
|
||||
sourceUrl: url,
|
||||
provider: "thangs",
|
||||
total: body.model_ids.length,
|
||||
});
|
||||
void runThangsListImportJob(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(
|
||||
|
||||
@@ -80,6 +80,8 @@ import {
|
||||
normalizeNexprintSession,
|
||||
setUserNexprintSession,
|
||||
} from "../services/nexprintSessionService";
|
||||
import { checkThangsAuth, refreshThangsToken, ThangsAuthError } from "../services/thangsApi";
|
||||
import { getThangsConnection, normalizeThangsSession, setUserThangsSession } from "../services/thangsSessionService";
|
||||
import { SLICER_IDS, getUserSlicer, setUserSlicer } from "../services/slicerPreferenceService";
|
||||
import { CATEGORIES_VIEWS, getCategoriesView, setCategoriesView } from "../services/categoriesViewService";
|
||||
import {
|
||||
@@ -811,6 +813,60 @@ router.patch(
|
||||
}),
|
||||
);
|
||||
|
||||
// Per-user: the Thangs sign-in, which Thingport Grab sends whenever the browser's changes. Thingport renews the access
|
||||
// token itself from the refresh token.
|
||||
router.get(
|
||||
"/settings/thangs-session",
|
||||
asyncHandler(async (req, res) => {
|
||||
const connection = await getThangsConnection(req.userId!);
|
||||
res.json({ configured: connection !== "disconnected", expired: connection === "expired" });
|
||||
}),
|
||||
);
|
||||
|
||||
// `token`: the access token, or anything with it in (a Cookie header, copied request headers); `refresh_token` the
|
||||
// refresh token, which may come in `token` as well. Either one alone will do.
|
||||
const thangsSessionSchema = z.object({
|
||||
token: z.string().max(20_000).nullable(),
|
||||
refresh_token: z.string().max(10_000).nullable().optional(),
|
||||
verify: z.boolean().optional(),
|
||||
});
|
||||
router.patch(
|
||||
"/settings/thangs-session",
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = parseBody(thangsSessionSchema, req.body);
|
||||
const raw = [body.token, body.refresh_token].filter(Boolean).join(" ");
|
||||
let session = normalizeThangsSession(raw);
|
||||
if (raw.trim() && !session) {
|
||||
throw new HttpError(
|
||||
422,
|
||||
"That doesn't look like a Thangs sign-in: paste the accessToken or refreshToken cookie, or the Authorization header of a signed-in thangs.com request.",
|
||||
);
|
||||
}
|
||||
if (body.verify && session) {
|
||||
const rejected = new HttpError(
|
||||
422,
|
||||
"Thangs didn't accept this sign-in -- it may have expired. Copy it again from a signed-in thangs.com tab and try again.",
|
||||
);
|
||||
// 503, not 422: nothing is known to be wrong with the sign-in.
|
||||
const unreachable = new HttpError(503, "Couldn't reach Thangs to check this sign-in. Try again in a moment.");
|
||||
const expired = !session.expiresAt || session.expiresAt.getTime() <= Date.now();
|
||||
if (session.refreshToken && (!session.token || expired)) {
|
||||
try {
|
||||
const renewed = await refreshThangsToken(session.refreshToken);
|
||||
session = { ...session, token: renewed.token, expiresAt: renewed.expiresAt };
|
||||
} catch (err) {
|
||||
throw err instanceof ThangsAuthError ? rejected : unreachable;
|
||||
}
|
||||
}
|
||||
const check = await checkThangsAuth(session.token!);
|
||||
if (check === "rejected") throw rejected;
|
||||
if (check === "unreachable") throw unreachable;
|
||||
}
|
||||
const configured = await setUserThangsSession(req.userId!, session);
|
||||
res.json({ configured });
|
||||
}),
|
||||
);
|
||||
|
||||
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 and NexPrint ids so imports are auto-categorized from the start. NexPrint's are text:
|
||||
* they're 16 digits long. */
|
||||
* 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"). */
|
||||
export type DefaultCategoryNode = {
|
||||
name: string;
|
||||
tags?: string[];
|
||||
@@ -14,6 +14,7 @@ export type DefaultCategoryNode = {
|
||||
makeronlineCatIds?: number[];
|
||||
crealitycloudCatIds?: number[];
|
||||
nexprintCatIds?: string[];
|
||||
thangsCatIds?: string[];
|
||||
children?: DefaultCategoryNode[];
|
||||
};
|
||||
|
||||
@@ -31,6 +32,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
makeronlineCatIds: [33],
|
||||
crealitycloudCatIds: [1731, 6006],
|
||||
nexprintCatIds: ["1422473859022863"],
|
||||
thangsCatIds: ["3D Printer Parts & Accessories"],
|
||||
children: [
|
||||
{
|
||||
name: "3D Printer Accessories",
|
||||
@@ -45,6 +47,12 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
makeronlineCatIds: [34],
|
||||
crealitycloudCatIds: [1904],
|
||||
nexprintCatIds: ["1422473859022858"],
|
||||
thangsCatIds: [
|
||||
"3D Printer Parts & Accessories/Enclosures & Racks",
|
||||
"3D Printer Parts & Accessories/Filament Management",
|
||||
"3D Printer Parts & Accessories/Maintenance Tools",
|
||||
"3D Printer Parts & Accessories/Print Bed Accessories",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "3D Printer Parts",
|
||||
@@ -61,6 +69,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
makeronlineCatIds: [35],
|
||||
crealitycloudCatIds: [1316],
|
||||
nexprintCatIds: ["2"],
|
||||
thangsCatIds: ["3D Printer Parts & Accessories/Printer Upgrades"],
|
||||
},
|
||||
{
|
||||
name: "Test Models",
|
||||
@@ -75,6 +84,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
makeronlineCatIds: [36],
|
||||
crealitycloudCatIds: [1645],
|
||||
nexprintCatIds: ["1422473859022859"],
|
||||
thangsCatIds: ["3D Printer Parts & Accessories/Test Prints & Calibration"],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -90,6 +100,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
makeronlineCatIds: [70],
|
||||
crealitycloudCatIds: [1670],
|
||||
nexprintCatIds: ["1422473858908165"],
|
||||
thangsCatIds: ["Art & Decor"],
|
||||
children: [
|
||||
{
|
||||
name: "2D Art",
|
||||
@@ -103,6 +114,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
makeronlineCatIds: [75],
|
||||
crealitycloudCatIds: [1662],
|
||||
nexprintCatIds: ["1422473859022850"],
|
||||
thangsCatIds: ["Art & Decor/Wall Art"],
|
||||
},
|
||||
{
|
||||
name: "Coin & Badges",
|
||||
@@ -114,6 +126,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
makeronlineCatIds: [72],
|
||||
crealitycloudCatIds: [1341],
|
||||
nexprintCatIds: ["1422473859022853"],
|
||||
thangsCatIds: ["Art & Decor/Trophies & Awards"],
|
||||
},
|
||||
{
|
||||
name: "Signs & Logos",
|
||||
@@ -139,6 +152,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
makeronlineCatIds: [74],
|
||||
crealitycloudCatIds: [1584],
|
||||
nexprintCatIds: ["1422473859022854"],
|
||||
thangsCatIds: ["Art & Decor/Sculptures & Statues", "Art & Decor/Busts"],
|
||||
},
|
||||
{
|
||||
name: "Other Art Models",
|
||||
@@ -152,6 +166,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
makeronlineCatIds: [71, 105],
|
||||
crealitycloudCatIds: [1997, 6005],
|
||||
nexprintCatIds: ["1422473859022851", "1422473859022852", "1422473859022857"],
|
||||
thangsCatIds: ["Art & Decor/Textured & Patterned"],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -167,6 +182,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
snapmakerCatIds: [106],
|
||||
makeronlineCatIds: [82],
|
||||
crealitycloudCatIds: [1501],
|
||||
thangsCatIds: ["Educational & Scientific"],
|
||||
children: [
|
||||
{
|
||||
name: "Biology",
|
||||
@@ -178,6 +194,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
myminifactoryCatIds: [154, 373, 997, 1045],
|
||||
snapmakerCatIds: [129],
|
||||
makeronlineCatIds: [89],
|
||||
thangsCatIds: ["Educational & Scientific/Biology", "Educational & Scientific/Human Anatomy & Medical"],
|
||||
},
|
||||
{
|
||||
name: "Chemistry",
|
||||
@@ -189,6 +206,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
myminifactoryCatIds: [162],
|
||||
snapmakerCatIds: [128],
|
||||
makeronlineCatIds: [88],
|
||||
thangsCatIds: ["Educational & Scientific/Chemistry"],
|
||||
},
|
||||
{
|
||||
name: "Engineering",
|
||||
@@ -212,6 +230,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
myminifactoryCatIds: [164],
|
||||
snapmakerCatIds: [130],
|
||||
makeronlineCatIds: [87],
|
||||
thangsCatIds: ["Educational & Scientific/Geology"],
|
||||
},
|
||||
{
|
||||
name: "Mathematics",
|
||||
@@ -236,6 +255,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
myminifactoryCatIds: [165, 558, 1036],
|
||||
snapmakerCatIds: [131, 127],
|
||||
makeronlineCatIds: [85],
|
||||
thangsCatIds: ["Educational & Scientific/Physics", "Educational & Scientific/Astronomy"],
|
||||
},
|
||||
{
|
||||
name: "Other Education Models",
|
||||
@@ -265,6 +285,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
makeronlineCatIds: [76],
|
||||
crealitycloudCatIds: [1175],
|
||||
nexprintCatIds: ["1422473858908164"],
|
||||
thangsCatIds: ["Fashion & Jewelry"],
|
||||
children: [
|
||||
{
|
||||
name: "Bags",
|
||||
@@ -289,6 +310,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
makeronlineCatIds: [79],
|
||||
crealitycloudCatIds: [1598],
|
||||
nexprintCatIds: ["1422473859014674"],
|
||||
thangsCatIds: ["Fashion & Jewelry/Clothing"],
|
||||
},
|
||||
{
|
||||
name: "Earrings",
|
||||
@@ -298,6 +320,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
makerworldCatIds: [206],
|
||||
thingiverseCatIds: [139],
|
||||
myminifactoryCatIds: [405],
|
||||
thangsCatIds: ["Fashion & Jewelry/Earrings"],
|
||||
},
|
||||
{
|
||||
name: "Footwear",
|
||||
@@ -329,6 +352,11 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
snapmakerCatIds: [134],
|
||||
makeronlineCatIds: [78],
|
||||
crealitycloudCatIds: [1693],
|
||||
thangsCatIds: [
|
||||
"Fashion & Jewelry/Necklaces",
|
||||
"Fashion & Jewelry/Bracelets",
|
||||
"Fashion & Jewelry/Jewelry Storage",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Rings",
|
||||
@@ -338,6 +366,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
makerworldCatIds: [205],
|
||||
thingiverseCatIds: [85],
|
||||
myminifactoryCatIds: [406],
|
||||
thangsCatIds: ["Fashion & Jewelry/Rings"],
|
||||
},
|
||||
{
|
||||
name: "Other Fashion Models",
|
||||
@@ -352,6 +381,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
makeronlineCatIds: [77, 81],
|
||||
crealitycloudCatIds: [1647, 6002],
|
||||
nexprintCatIds: ["1422473859014675", "1422473859022849"],
|
||||
thangsCatIds: ["Fashion & Jewelry/Accessories"],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -367,6 +397,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
makeronlineCatIds: [46],
|
||||
crealitycloudCatIds: [1519],
|
||||
nexprintCatIds: ["1422473858908163"],
|
||||
thangsCatIds: ["Hobby & DIY"],
|
||||
children: [
|
||||
{
|
||||
name: "Electronics",
|
||||
@@ -390,6 +421,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
"1422473859014659",
|
||||
"1422473859014660",
|
||||
],
|
||||
thangsCatIds: ["Hobby & DIY/Electronics"],
|
||||
},
|
||||
{
|
||||
name: "Music",
|
||||
@@ -404,6 +436,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
makeronlineCatIds: [49],
|
||||
crealitycloudCatIds: [1420],
|
||||
nexprintCatIds: ["1422473859022856"],
|
||||
thangsCatIds: ["Hobby & DIY/Music & Audio"],
|
||||
},
|
||||
{
|
||||
name: "RC",
|
||||
@@ -419,6 +452,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
snapmakerCatIds: [150],
|
||||
makeronlineCatIds: [51],
|
||||
nexprintCatIds: ["1422473859014656"],
|
||||
thangsCatIds: ["Hobby & DIY/RC"],
|
||||
},
|
||||
{
|
||||
name: "Robotics",
|
||||
@@ -432,6 +466,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
snapmakerCatIds: [149],
|
||||
makeronlineCatIds: [52],
|
||||
crealitycloudCatIds: [1648],
|
||||
thangsCatIds: ["Hobby & DIY/Robotics"],
|
||||
},
|
||||
{
|
||||
name: "Sport & Outdoors",
|
||||
@@ -446,6 +481,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
makeronlineCatIds: [50],
|
||||
crealitycloudCatIds: [1246],
|
||||
nexprintCatIds: ["1422473859014671"],
|
||||
thangsCatIds: ["Hobby & DIY/Outdoor", "Health & Fitness/Sports"],
|
||||
},
|
||||
{
|
||||
name: "Vehicles",
|
||||
@@ -459,6 +495,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
makeronlineCatIds: [47],
|
||||
crealitycloudCatIds: [1194],
|
||||
nexprintCatIds: ["1422473859014672"],
|
||||
thangsCatIds: ["Hobby & DIY/Automotive"],
|
||||
},
|
||||
{
|
||||
name: "Other Hobby & DIY",
|
||||
@@ -472,6 +509,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
makeronlineCatIds: [54, 53],
|
||||
crealitycloudCatIds: [6004],
|
||||
nexprintCatIds: ["1422473859014673"],
|
||||
thangsCatIds: ["Hobby & DIY/Baking and Cooking", "Hobby & DIY/Photography & Video"],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -488,6 +526,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
makeronlineCatIds: [37],
|
||||
crealitycloudCatIds: [1010],
|
||||
nexprintCatIds: ["1422473858908162"],
|
||||
thangsCatIds: ["Home & Garden"],
|
||||
children: [
|
||||
{
|
||||
name: "Decor",
|
||||
@@ -502,6 +541,13 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
makeronlineCatIds: [38, 43],
|
||||
crealitycloudCatIds: [1150, 1096],
|
||||
nexprintCatIds: ["1422473859014662"],
|
||||
thangsCatIds: [
|
||||
"Home & Garden/Home Decor & Accessories",
|
||||
"Home & Garden/Living Room",
|
||||
"Home & Garden/Bedroom",
|
||||
"Art & Decor/Lamps & Lighting",
|
||||
"Art & Decor/Vases & Planters",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Festivities",
|
||||
@@ -513,6 +559,13 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
myminifactoryCatIds: [385, 384, 634, 635, 636],
|
||||
snapmakerCatIds: [140],
|
||||
makeronlineCatIds: [42],
|
||||
thangsCatIds: [
|
||||
"Seasonal",
|
||||
"Seasonal/Beach & Summer",
|
||||
"Seasonal/Easter & Spring",
|
||||
"Seasonal/Halloween & Fall",
|
||||
"Seasonal/Holidays & Winter",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Garden",
|
||||
@@ -526,6 +579,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
snapmakerCatIds: [146],
|
||||
makeronlineCatIds: [39],
|
||||
nexprintCatIds: ["1422473859014668"],
|
||||
thangsCatIds: ["Home & Garden/Outdoor & Garden"],
|
||||
},
|
||||
{
|
||||
name: "Office",
|
||||
@@ -539,6 +593,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
snapmakerCatIds: [143],
|
||||
makeronlineCatIds: [41],
|
||||
nexprintCatIds: ["1422473859014664"],
|
||||
thangsCatIds: ["Tools & Organizers/Office & Desk"],
|
||||
},
|
||||
{
|
||||
name: "Pets",
|
||||
@@ -552,6 +607,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
makeronlineCatIds: [44],
|
||||
crealitycloudCatIds: [1151],
|
||||
nexprintCatIds: ["1422473859014669"],
|
||||
thangsCatIds: ["Home & Garden/Pet Accessories"],
|
||||
},
|
||||
{
|
||||
name: "Other Household Models",
|
||||
@@ -569,6 +625,13 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
makeronlineCatIds: [45, 40],
|
||||
crealitycloudCatIds: [1775, 6000],
|
||||
nexprintCatIds: ["1422473859014663", "1422473859014666", "1422473859014667", "1422473859014670"],
|
||||
thangsCatIds: [
|
||||
"Home & Garden/Bathroom",
|
||||
"Home & Garden/Kitchen & Dining",
|
||||
"Health & Fitness",
|
||||
"Health & Fitness/Wellness",
|
||||
"Health & Fitness/Accessibility",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -585,6 +648,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
makeronlineCatIds: [95],
|
||||
crealitycloudCatIds: [1952],
|
||||
nexprintCatIds: ["1422473859006464"],
|
||||
thangsCatIds: ["Miniatures & Tabletop"],
|
||||
children: [
|
||||
{
|
||||
name: "Animals",
|
||||
@@ -608,6 +672,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
snapmakerCatIds: [158],
|
||||
makeronlineCatIds: [100],
|
||||
crealitycloudCatIds: [1846],
|
||||
thangsCatIds: ["Miniatures & Tabletop/Dioramas & Scenery"],
|
||||
},
|
||||
{
|
||||
name: "Creatures",
|
||||
@@ -620,6 +685,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
snapmakerCatIds: [159],
|
||||
makeronlineCatIds: [99],
|
||||
crealitycloudCatIds: [1025],
|
||||
thangsCatIds: ["Miniatures & Tabletop/Fantasy Miniatures"],
|
||||
},
|
||||
{
|
||||
name: "People",
|
||||
@@ -645,6 +711,15 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
snapmakerCatIds: [160],
|
||||
makeronlineCatIds: [96, 97, 98],
|
||||
crealitycloudCatIds: [1888, 1982, 6008],
|
||||
thangsCatIds: [
|
||||
"Miniatures & Tabletop/Sci-Fi Miniatures",
|
||||
"Miniatures & Tabletop/War & Tactics",
|
||||
"Miniatures & Tabletop/Naval",
|
||||
"Miniatures & Tabletop/Airplanes",
|
||||
"Miniatures & Tabletop/Trains",
|
||||
"Miniatures & Tabletop/Vehicles",
|
||||
"Miniatures & Tabletop/Accessories",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -660,6 +735,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
makeronlineCatIds: [90],
|
||||
crealitycloudCatIds: [1966],
|
||||
nexprintCatIds: ["1422473859006467"],
|
||||
thangsCatIds: ["Costumes & Cosplay"],
|
||||
children: [
|
||||
{
|
||||
name: "Costumes",
|
||||
@@ -671,6 +747,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
myminifactoryCatIds: [340],
|
||||
snapmakerCatIds: [123],
|
||||
makeronlineCatIds: [94],
|
||||
thangsCatIds: ["Costumes & Cosplay/Full Outfits", "Costumes & Cosplay/Character Specific"],
|
||||
},
|
||||
{
|
||||
name: "Masks & Helmets",
|
||||
@@ -682,6 +759,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
myminifactoryCatIds: [155],
|
||||
snapmakerCatIds: [122],
|
||||
makeronlineCatIds: [93],
|
||||
thangsCatIds: ["Costumes & Cosplay/Masks & Helmets"],
|
||||
},
|
||||
{
|
||||
name: "Cosplay Weapons",
|
||||
@@ -691,6 +769,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
makerworldCatIds: [1002],
|
||||
myminifactoryCatIds: [542, 156, 304],
|
||||
snapmakerCatIds: [124],
|
||||
thangsCatIds: ["Costumes & Cosplay/Armor & Props"],
|
||||
},
|
||||
{
|
||||
name: "Other Props & Cosplays",
|
||||
@@ -702,6 +781,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
printablesCatIds: [81, 80],
|
||||
snapmakerCatIds: [125],
|
||||
makeronlineCatIds: [92, 91],
|
||||
thangsCatIds: ["Costumes & Cosplay/Accessories"],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -717,6 +797,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
makeronlineCatIds: [55],
|
||||
crealitycloudCatIds: [1671],
|
||||
nexprintCatIds: ["1422473858908166"],
|
||||
thangsCatIds: ["Tools & Organizers", "Functional Prints"],
|
||||
children: [
|
||||
{
|
||||
name: "Gadgets",
|
||||
@@ -738,6 +819,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
thingiverseCatIds: [118],
|
||||
snapmakerCatIds: [161],
|
||||
makeronlineCatIds: [60],
|
||||
thangsCatIds: ["Tools & Organizers/Utility & Tools", "Functional Prints/Tools"],
|
||||
},
|
||||
{
|
||||
name: "Machine Tools",
|
||||
@@ -748,6 +830,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
thingiverseCatIds: [117],
|
||||
snapmakerCatIds: [165],
|
||||
makeronlineCatIds: [59],
|
||||
thangsCatIds: ["Functional Prints/Jigs & Fixtures"],
|
||||
},
|
||||
{
|
||||
name: "Measure Tools",
|
||||
@@ -782,6 +865,12 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
snapmakerCatIds: [141, 163],
|
||||
makeronlineCatIds: [61],
|
||||
nexprintCatIds: ["1422473859014661"],
|
||||
thangsCatIds: [
|
||||
"Tools & Organizers/Gridfinity",
|
||||
"Tools & Organizers/Multiboard",
|
||||
"Tools & Organizers/Storage Solutions",
|
||||
"Tools & Organizers/Shop & Garage",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Other Tools",
|
||||
@@ -794,6 +883,11 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
snapmakerCatIds: [164, 166],
|
||||
makeronlineCatIds: [57],
|
||||
nexprintCatIds: ["1422473859022860", "1422473859022861", "1422473859022862"],
|
||||
thangsCatIds: [
|
||||
"Functional Prints/Mechanical Parts",
|
||||
"Functional Prints/Replacement Parts",
|
||||
"Functional Prints/Customizable",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -810,6 +904,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
makeronlineCatIds: [62],
|
||||
crealitycloudCatIds: [1809],
|
||||
nexprintCatIds: ["1422473858908160"],
|
||||
thangsCatIds: ["Toys & Games"],
|
||||
children: [
|
||||
{
|
||||
name: "Board Games",
|
||||
@@ -824,6 +919,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
makeronlineCatIds: [68],
|
||||
crealitycloudCatIds: [1575],
|
||||
nexprintCatIds: ["1422473859006466"],
|
||||
thangsCatIds: ["Toys & Games/Board Game Accessories"],
|
||||
},
|
||||
{
|
||||
name: "Characters",
|
||||
@@ -837,6 +933,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
],
|
||||
snapmakerCatIds: [171],
|
||||
makeronlineCatIds: [104, 69, 110],
|
||||
thangsCatIds: ["Toys & Games/Action Figures & Collectibles", "Toys & Games/Articulated"],
|
||||
},
|
||||
{
|
||||
name: "Outdoor Toys",
|
||||
@@ -847,6 +944,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
printablesCatIds: [34],
|
||||
snapmakerCatIds: [173],
|
||||
makeronlineCatIds: [66],
|
||||
thangsCatIds: ["Toys & Games/Outdoor Toys"],
|
||||
},
|
||||
{
|
||||
name: "Puzzles",
|
||||
@@ -859,6 +957,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
myminifactoryCatIds: [100],
|
||||
snapmakerCatIds: [169],
|
||||
makeronlineCatIds: [65],
|
||||
thangsCatIds: ["Toys & Games/Puzzles"],
|
||||
},
|
||||
{
|
||||
name: "Construction Sets",
|
||||
@@ -886,6 +985,12 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
makeronlineCatIds: [63, 64],
|
||||
crealitycloudCatIds: [1793, 6007],
|
||||
nexprintCatIds: ["1422473859006468"],
|
||||
thangsCatIds: [
|
||||
"Toys & Games/Educational Toys",
|
||||
"Toys & Games/Fidget Toys",
|
||||
"Toys & Games/Keychains",
|
||||
"Toys & Games/Video Games",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -904,6 +1009,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
metaDescription:
|
||||
"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"],
|
||||
},
|
||||
{
|
||||
name: "Make My Sign",
|
||||
|
||||
@@ -22,6 +22,7 @@ import { resolveSnapmakerModel } from "./snapmakerApi";
|
||||
import { resolveMakeronlineModel } from "./makeronlineApi";
|
||||
import { resolveCrealitycloudModel } from "./crealitycloudApi";
|
||||
import { resolveNexprintModel } from "./nexprintApi";
|
||||
import { resolveThangsModel } from "./thangsApi";
|
||||
import { getDisabledProviders } from "./providerSettingsService";
|
||||
|
||||
// Links models that only know their author by name: first by name matching, then by looking the
|
||||
@@ -36,6 +37,7 @@ const LOOKUP_PROVIDERS = [
|
||||
"makeronline",
|
||||
"crealitycloud",
|
||||
"nexprint",
|
||||
"thangs",
|
||||
] as const;
|
||||
type LookupProvider = (typeof LOOKUP_PROVIDERS)[number];
|
||||
|
||||
@@ -214,6 +216,10 @@ async function lookUpAuthor(
|
||||
await maybeSleep(IMPORT_COLLECTION_DELAY_MS);
|
||||
return (await resolveNexprintModel(candidate.externalId))?.meta.author ?? null;
|
||||
}
|
||||
case "thangs": {
|
||||
await maybeSleep(IMPORT_COLLECTION_DELAY_MS);
|
||||
return (await resolveThangsModel(candidate.externalId))?.meta.author ?? null;
|
||||
}
|
||||
case "makerworld": {
|
||||
const cookie = await getUserMakerworldCookie(candidate.userId);
|
||||
const bearer = extractMakerworldBearerToken(cookie);
|
||||
|
||||
@@ -80,6 +80,7 @@ export async function seedDefaultCategories(tx: Prisma.TransactionClient, userId
|
||||
makeronlineCatIds: node.makeronlineCatIds ?? [],
|
||||
crealitycloudCatIds: node.crealitycloudCatIds ?? [],
|
||||
nexprintCatIds: node.nexprintCatIds ?? [],
|
||||
thangsCatIds: node.thangsCatIds ?? [],
|
||||
},
|
||||
});
|
||||
let i = 0;
|
||||
|
||||
@@ -59,6 +59,20 @@ import {
|
||||
markCrealitycloudSessionRejected,
|
||||
} from "./crealitycloudSessionService";
|
||||
import { fetchNexprintCollection } from "./nexprintApi";
|
||||
import {
|
||||
fetchThangsCollection,
|
||||
fetchThangsLikes,
|
||||
fetchThangsPurchases,
|
||||
THANGS_LIKES_KEY,
|
||||
ThangsAuthError,
|
||||
} from "./thangsApi";
|
||||
import {
|
||||
getThangsConnection,
|
||||
getUserThangsId,
|
||||
getUserThangsToken,
|
||||
markThangsSessionRejected,
|
||||
withThangsToken,
|
||||
} from "./thangsSessionService";
|
||||
import { notifyGrabImportsQueued, queueGrabImport } from "./grabImportService";
|
||||
import { getSnapmakerConnection, getUserSnapmakerToken, markSnapmakerTokenRejected } from "./snapmakerSessionService";
|
||||
import {
|
||||
@@ -179,6 +193,21 @@ export async function fetchListing(
|
||||
const listing = await fetchNexprintCollection(id, Number.POSITIVE_INFINITY);
|
||||
return listing ? listing.entries.map((e) => e.modelId) : null;
|
||||
}
|
||||
if (sync.provider === "thangs") {
|
||||
// Every file is the user's to download, so the sign-in is there (see connectionProblem). Paid models they own
|
||||
// are kept in, the rest of the paid ones left out.
|
||||
const token = await getUserThangsToken(sync.userId);
|
||||
const thangsId = await getUserThangsId(sync.userId);
|
||||
if (!token || !thangsId) throw new ThangsAuthError();
|
||||
const owned = await withThangsToken(sync.userId, token, fetchThangsPurchases);
|
||||
if (id === THANGS_LIKES_KEY) {
|
||||
return (await fetchThangsLikes(thangsId, owned, Number.POSITIVE_INFINITY)).entries.map((e) => e.modelId);
|
||||
}
|
||||
const listing = await withThangsToken(sync.userId, token, (fresh) =>
|
||||
fetchThangsCollection(id, fresh, owned, Number.POSITIVE_INFINITY),
|
||||
);
|
||||
return listing ? listing.entries.map((e) => e.modelId) : null;
|
||||
}
|
||||
throw new Error(`Unknown provider "${sync.provider}"`);
|
||||
}
|
||||
|
||||
@@ -279,6 +308,16 @@ const CONNECTION_PROBLEMS = {
|
||||
reason: "Your Snapmaker Space sign-in ran out; Thingport Grab renews it while the browser it's in is open.",
|
||||
fix: "you open space.snapmaker.com with Thingport Grab, or connect it again",
|
||||
},
|
||||
thangsDisconnected: {
|
||||
error: "Thangs isn't connected",
|
||||
reason: "Thangs gives its files to a signed-in user only, and it isn't connected.",
|
||||
fix: "you open thangs.com with Thingport Grab, or connect it",
|
||||
},
|
||||
thangsExpired: {
|
||||
error: "Thangs sign-in ran out",
|
||||
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",
|
||||
},
|
||||
} satisfies Record<string, ConnectionProblem>;
|
||||
|
||||
/** `"A"`, `"A" and "B"`, or the first few and how many more. */
|
||||
@@ -341,6 +380,11 @@ async function connectionProblem(
|
||||
if (connection === "disconnected") return CONNECTION_PROBLEMS.crealitycloudLikesDisconnected;
|
||||
if (connection === "expired") return CONNECTION_PROBLEMS.crealitycloudExpired;
|
||||
}
|
||||
if (sync.provider === "thangs") {
|
||||
const connection = await getThangsConnection(sync.userId);
|
||||
if (connection === "disconnected") return CONNECTION_PROBLEMS.thangsDisconnected;
|
||||
if (connection === "expired") return CONNECTION_PROBLEMS.thangsExpired;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -426,6 +470,11 @@ async function runSync(syncId: string): Promise<SyncOutcome> {
|
||||
await holdProvider(userId, sync.provider, CONNECTION_PROBLEMS.crealitycloudExpired, sync.externalId);
|
||||
return "disconnected";
|
||||
}
|
||||
if (err instanceof ThangsAuthError) {
|
||||
await markThangsSessionRejected(userId);
|
||||
await holdProvider(userId, sync.provider, CONNECTION_PROBLEMS.thangsExpired);
|
||||
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
|
||||
|
||||
@@ -43,6 +43,15 @@ import {
|
||||
} from "./crealitycloudApi";
|
||||
import { getUserCrealitycloudId } from "./crealitycloudSessionService";
|
||||
import { nexprintCollectionUrl, nexprintModelUrl, parseNexprintCollectionUrl } from "./nexprintApi";
|
||||
import {
|
||||
isThangsLikesUrl,
|
||||
parseThangsCollectionUrl,
|
||||
THANGS_LIKES_KEY,
|
||||
THANGS_LIKES_NAME,
|
||||
THANGS_LIKES_URL,
|
||||
thangsModelUrl,
|
||||
} from "./thangsApi";
|
||||
import { getThangsConnection } from "./thangsSessionService";
|
||||
import { createLog } from "./auditLog";
|
||||
import { pushedLikes } from "./providerListingsService";
|
||||
import { createNotification } from "./notificationService";
|
||||
@@ -63,7 +72,8 @@ export type SyncProvider =
|
||||
| "snapmaker"
|
||||
| "makeronline"
|
||||
| "crealitycloud"
|
||||
| "nexprint";
|
||||
| "nexprint"
|
||||
| "thangs";
|
||||
|
||||
/** `likesOf` is set for a user's likes (on Thingiverse, or the user's own on Printables), which a
|
||||
* sync follows like a collection. */
|
||||
@@ -78,6 +88,7 @@ const PROVIDER_NAMES: Record<SyncProvider, string> = {
|
||||
makeronline: "MakerOnline",
|
||||
crealitycloud: "Creality Cloud",
|
||||
nexprint: "NexPrint",
|
||||
thangs: "Thangs",
|
||||
};
|
||||
|
||||
/** Providers whose models come with print profiles a sync can pick between. */
|
||||
@@ -169,6 +180,12 @@ export function parseSyncSource(url: string): SyncSource | null {
|
||||
const { collectionId, ownerId } = nexprint;
|
||||
return { provider: "nexprint", externalId: collectionId, url: nexprintCollectionUrl(collectionId, ownerId) };
|
||||
}
|
||||
const thangs = parseThangsCollectionUrl(url);
|
||||
if (thangs) return { provider: "thangs", externalId: thangs.collectionId, url: thangs.url };
|
||||
if (isThangsLikesUrl(url)) {
|
||||
// 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 printablesLikes = parsePrintablesLikesUrl(url);
|
||||
if (printablesLikes) {
|
||||
// Only Thingport Grab can read these, as their owner (see providerListingsService.ts).
|
||||
@@ -201,6 +218,7 @@ export function modelUrlFor(provider: string, externalId: string): string {
|
||||
if (provider === "makeronline") return makeronlineModelUrl(externalId);
|
||||
if (provider === "crealitycloud") return crealitycloudModelUrl(externalId);
|
||||
if (provider === "nexprint") return nexprintModelUrl(externalId);
|
||||
if (provider === "thangs") return thangsModelUrl(externalId);
|
||||
return `https://www.thingiverse.com/thing:${externalId}`;
|
||||
}
|
||||
|
||||
@@ -214,6 +232,7 @@ export function modelIdFromUrl(provider: string, url: string): string | null {
|
||||
makeronline: /\/model\/(?:[^/#]+\/)?(\d+)/,
|
||||
crealitycloud: /\/model-detail\/([\w-]+)/,
|
||||
nexprint: /\/models\/(G\d+)/i,
|
||||
thangs: /\/m\/(\d+)/,
|
||||
};
|
||||
const pattern = patterns[provider] ?? /thing:(\d+)/;
|
||||
return url.match(pattern)?.[1] ?? null;
|
||||
@@ -285,13 +304,14 @@ export async function enableSync(userId: string, input: EnableSyncInput): Promis
|
||||
if (!source)
|
||||
throw new HttpError(
|
||||
400,
|
||||
"Only MakerWorld, Printables, Thingiverse, MyMiniFactory, MakerOnline, Creality Cloud and NexPrint collections, Thingiverse or your own Printables, MakerOnline and Creality Cloud likes, and your own Snapmaker Space likes and bookmarks, can be synced",
|
||||
"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",
|
||||
);
|
||||
if (source.provider === "printables" && source.likesOf) await assertOwnPrintablesLikes(userId, source.likesOf);
|
||||
if (source.provider === "snapmaker") await assertOwnSnapmakerList(userId, source.url);
|
||||
if (source.provider === "makeronline" && source.externalId === MAKERONLINE_LIKES_KEY) {
|
||||
await assertMakeronlineConnected(userId);
|
||||
}
|
||||
if (source.provider === "thangs") await assertThangsConnected(userId);
|
||||
const crealitycloudLikes = source.provider === "crealitycloud" ? parseCrealitycloudLikesUrl(source.url) : null;
|
||||
if (crealitycloudLikes) await assertOwnCrealitycloudLikes(userId, crealitycloudLikes.userId);
|
||||
const knownIds = [...new Set(input.knownIds.map((id) => id.trim()).filter(Boolean))];
|
||||
@@ -336,7 +356,9 @@ export async function enableSync(userId: string, input: EnableSyncInput): Promis
|
||||
? MAKERONLINE_LIKES_NAME
|
||||
: crealitycloudLikes
|
||||
? CREALITYCLOUD_LIKES_NAME
|
||||
: input.title?.trim() || `${providerName(source.provider)} Collection ${source.externalId}`;
|
||||
: source.provider === "thangs" && source.externalId === THANGS_LIKES_KEY
|
||||
? THANGS_LIKES_NAME
|
||||
: input.title?.trim() || `${providerName(source.provider)} Collection ${source.externalId}`;
|
||||
let found: (CollectionSync & { collection: Collection }) | null = null;
|
||||
for (const name of collectionNamesFor(title, source.provider)) {
|
||||
const collection = await findOrCreateCollectionByName(userId, name);
|
||||
@@ -424,6 +446,16 @@ async function assertMakeronlineConnected(userId: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Every Thangs download is the user's, and its likes page names no user: a sync needs their sign-in either way. */
|
||||
async function assertThangsConnected(userId: string): Promise<void> {
|
||||
if ((await getThangsConnection(userId)) !== "connected") {
|
||||
throw new HttpError(
|
||||
400,
|
||||
"Thangs gives its files to a signed-in user only: connect Thangs first, or visit it with Thingport Grab",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** 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> {
|
||||
|
||||
@@ -24,6 +24,7 @@ const SOURCE_PROVIDERS = [
|
||||
"makeronline",
|
||||
"crealitycloud",
|
||||
"nexprint",
|
||||
"thangs",
|
||||
] as const;
|
||||
type SourceProvider = (typeof SOURCE_PROVIDERS)[number];
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import { resolveSnapmakerModel } from "./snapmakerApi";
|
||||
import { resolveMakeronlineModel } from "./makeronlineApi";
|
||||
import { resolveCrealitycloudModel } from "./crealitycloudApi";
|
||||
import { resolveNexprintModel } from "./nexprintApi";
|
||||
import { resolveThangsModel, ThangsBlockedError } from "./thangsApi";
|
||||
import { getUserMakerworldCookie } from "./makerworldCookieService";
|
||||
import { upsertAuthorFromImport } from "./authorService";
|
||||
import { emptyGaps, openSourceGaps, type SourceGap } from "./sourceGaps";
|
||||
@@ -124,6 +125,12 @@ export async function fetchSourceMeta(userId: string, print: Print, sourceUrl: s
|
||||
return { ...emptyImportedPageMetadata(), ...resolved.meta, galleryImages: resolved.galleryImages };
|
||||
}
|
||||
|
||||
if (provider === "thangs") {
|
||||
const resolved = await resolveThangsModel(externalId);
|
||||
if (!resolved) throw new HttpError(404, "This Thangs 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");
|
||||
}
|
||||
|
||||
@@ -178,6 +185,7 @@ export async function fillSourceGaps(userId: string, printId: string): Promise<F
|
||||
if (err instanceof ThingiverseAuthError) throw new HttpError(400, err.message);
|
||||
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);
|
||||
throw err;
|
||||
}
|
||||
const wanted = new Set(gaps);
|
||||
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
SNAPMAKER_SESSION_CODE,
|
||||
CREALITYCLOUD_SESSION_CODE,
|
||||
NEXPRINT_SESSION_CODE,
|
||||
THANGS_SESSION_CODE,
|
||||
THANGS_DOWNLOAD_LIMIT_CODE,
|
||||
type ImportRequestBody,
|
||||
} from "./importService";
|
||||
import {
|
||||
@@ -47,6 +49,8 @@ import {
|
||||
import { CREALITYCLOUD_CONNECT, getUserCrealitycloudAuth } from "./crealitycloudSessionService";
|
||||
import { nexprintModelUrl, parseNexprintModelUrl, resolveNexprintModel, selectNexprintProfiles } from "./nexprintApi";
|
||||
import { getUserNexprintToken, NEXPRINT_CONNECT } from "./nexprintSessionService";
|
||||
import { parseThangsModelUrl, thangsModelUrl } from "./thangsApi";
|
||||
import { getUserThangsToken, THANGS_CONNECT } from "./thangsSessionService";
|
||||
import { queueGrabImport } from "./grabImportService";
|
||||
import { upsertAuthorFromImport } from "./authorService";
|
||||
import { extractZipEntriesToPrints } from "./zipService";
|
||||
@@ -87,6 +91,7 @@ type MakerworldProfilesImportJobBody = ImportRequestBody & { scope: Exclude<Make
|
||||
type SnapmakerListImportJobBody = ImportRequestBody & { model_ids: string[]; list: SnapmakerList };
|
||||
type CrealitycloudListImportJobBody = ImportRequestBody & { model_ids: string[]; title: string };
|
||||
type NexprintCollectionImportJobBody = ImportRequestBody & { model_ids: string[]; title: string };
|
||||
type ThangsListImportJobBody = 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")
|
||||
@@ -107,14 +112,19 @@ function classifyImportFailure(err: unknown): ImportFailureReason {
|
||||
err.code === MAKERWORLD_AUTH_CODE ||
|
||||
err.code === SNAPMAKER_SESSION_CODE ||
|
||||
err.code === CREALITYCLOUD_SESSION_CODE ||
|
||||
err.code === NEXPRINT_SESSION_CODE
|
||||
err.code === NEXPRINT_SESSION_CODE ||
|
||||
err.code === THANGS_SESSION_CODE
|
||||
) {
|
||||
return "auth";
|
||||
}
|
||||
if (err.code === CLOUDFLARE_BLOCKED_CODE) return "blocked";
|
||||
if (err.code === PROVIDER_DISABLED_CODE) return "disabled";
|
||||
if (err.status === 403 || err.status === 404) return "unavailable";
|
||||
if (err.status === 429) return err.code === MAKERWORLD_DOWNLOAD_LIMIT_CODE ? "downloadLimit" : "rateLimited";
|
||||
if (err.status === 429) {
|
||||
return err.code === MAKERWORLD_DOWNLOAD_LIMIT_CODE || err.code === THANGS_DOWNLOAD_LIMIT_CODE
|
||||
? "downloadLimit"
|
||||
: "rateLimited";
|
||||
}
|
||||
if (err.status === 401) return "auth";
|
||||
}
|
||||
return "other";
|
||||
@@ -329,6 +339,7 @@ const CREALITYCLOUD_AUTH_TEXT =
|
||||
"Creality Cloud turned your sign-in down — visit crealitycloud.com signed in with Thingport Grab, then retry";
|
||||
const NEXPRINT_AUTH_TEXT =
|
||||
"NexPrint turned your sign-in down — visit nexprint.com signed in with Thingport Grab, then retry";
|
||||
const THANGS_AUTH_TEXT = "Thangs turned your sign-in down — visit thangs.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 = {
|
||||
@@ -583,9 +594,9 @@ export async function runCollectionImportJob(
|
||||
}
|
||||
|
||||
/** A provider whose models import one by one with a token: Thingiverse's and MyMiniFactory's instance-wide one, or
|
||||
* the user's own Snapmaker or Creality Cloud sign-in. */
|
||||
* the user's own Snapmaker, Creality Cloud, NexPrint or Thangs sign-in. */
|
||||
type TokenBatchProvider = {
|
||||
provider: "thingiverse" | "myminifactory" | "snapmaker" | "crealitycloud" | "nexprint";
|
||||
provider: "thingiverse" | "myminifactory" | "snapmaker" | "crealitycloud" | "nexprint" | "thangs";
|
||||
getToken: () => Promise<string | null>;
|
||||
notConfigured: string;
|
||||
modelUrl: (id: string) => string;
|
||||
@@ -647,8 +658,20 @@ function nexprintBatch(userId: string): TokenBatchProvider {
|
||||
};
|
||||
}
|
||||
|
||||
function thangsBatch(userId: string): TokenBatchProvider {
|
||||
return {
|
||||
provider: "thangs",
|
||||
getToken: () => getUserThangsToken(userId),
|
||||
notConfigured: THANGS_CONNECT,
|
||||
modelUrl: (id) => thangsModelUrl(id),
|
||||
rateLimitText: "blocked by Thangs' rate limit — wait a while, then retry",
|
||||
authText: `failed because ${THANGS_AUTH_TEXT}`,
|
||||
};
|
||||
}
|
||||
|
||||
/** Shared by the Thingiverse Likes and Collection imports, MyMiniFactory's collections, Snapmaker's likes and
|
||||
* bookmarks, Creality Cloud's likes and collections, and NexPrint's collections.
|
||||
* bookmarks, Creality Cloud's likes and collections, NexPrint's collections, and Thangs' likes and collections.
|
||||
* 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. */
|
||||
async function runTokenBatchImportJob(
|
||||
@@ -672,12 +695,21 @@ async function runTokenBatchImportJob(
|
||||
let providerOff = 0;
|
||||
let rateLimited = 0;
|
||||
let authFailed = 0;
|
||||
let downloadLimited = 0;
|
||||
const failed: string[] = [];
|
||||
const successPrintIds: string[] = [];
|
||||
log.info(`${source.provider} job started`, { job: jobId, url: body.url, models: ids.length });
|
||||
|
||||
await mapWithConcurrency(ids, COLLECTION_IMPORT_CONCURRENCY, async (thingId, index) => {
|
||||
const thingUrl = source.modelUrl(thingId);
|
||||
if (downloadLimited) {
|
||||
// The limit is the account's for the day: every other download would meet it too.
|
||||
failed.push(thingId);
|
||||
downloadLimited++;
|
||||
processed++;
|
||||
await updateJob(jobId, { processed, failedCount: failed.length }).catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
const itemBody: ImportRequestBody = {
|
||||
url: thingUrl,
|
||||
notes: body.notes ?? null,
|
||||
@@ -697,13 +729,14 @@ async function runTokenBatchImportJob(
|
||||
else if (reason === "rateLimited") rateLimited++;
|
||||
else if (reason === "auth") authFailed++;
|
||||
else if (reason === "disabled") providerOff++;
|
||||
else if (reason === "downloadLimit") downloadLimited++;
|
||||
} finally {
|
||||
processed++;
|
||||
await updateJob(jobId, { processed, imported, alreadyInLibrary, failedCount: failed.length }).catch(
|
||||
() => undefined,
|
||||
);
|
||||
}
|
||||
if (index < ids.length - 1) await sleep(IMPORT_COLLECTION_DELAY_MS);
|
||||
if (index < ids.length - 1 && !downloadLimited) await sleep(IMPORT_COLLECTION_DELAY_MS);
|
||||
});
|
||||
|
||||
let resultCollectionId: string | null = null;
|
||||
@@ -747,11 +780,16 @@ async function runTokenBatchImportJob(
|
||||
|
||||
const bodyParts: string[] = [];
|
||||
if (alreadyInLibrary) bodyParts.push(`${alreadyInLibrary} already in your library`);
|
||||
const otherFailed = failed.length - unavailable - rateLimited - authFailed - providerOff;
|
||||
if (unavailable) bodyParts.push(`${unavailable} unavailable (private, deleted, or hidden)`);
|
||||
const otherFailed = failed.length - unavailable - rateLimited - authFailed - providerOff - downloadLimited;
|
||||
if (unavailable) bodyParts.push(`${unavailable} unavailable (private, deleted, paid, or hidden)`);
|
||||
if (providerOff) bodyParts.push(`${providerOff} skipped — ${PROVIDER_OFF_TEXT(name)}`);
|
||||
if (rateLimited) bodyParts.push(`${rateLimited} ${source.rateLimitText}`);
|
||||
if (authFailed) bodyParts.push(`${authFailed} ${source.authText}`);
|
||||
if (downloadLimited) {
|
||||
bodyParts.push(
|
||||
`${downloadLimited} not downloaded — ${name}'s daily download limit was reached, retry the same collection tomorrow`,
|
||||
);
|
||||
}
|
||||
if (otherFailed) bodyParts.push(`${otherFailed} failed`);
|
||||
const label = sourceLabel(collectionTitle);
|
||||
await createNotification(userId, {
|
||||
@@ -869,6 +907,23 @@ export async function runNexprintCollectionImportJob(
|
||||
);
|
||||
}
|
||||
|
||||
/** The user's Thangs likes, or a collection, filed into a collection named after it. */
|
||||
export async function runThangsListImportJob(
|
||||
jobId: string,
|
||||
userId: string,
|
||||
body: ThangsListImportJobBody,
|
||||
): Promise<void> {
|
||||
await runTokenBatchImportJob(
|
||||
jobId,
|
||||
userId,
|
||||
thangsBatch(userId),
|
||||
body,
|
||||
body.model_ids,
|
||||
async () => body.title,
|
||||
(collectionTitle) => `"${collectionTitle}"`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function runPrintablesCollectionImportJob(
|
||||
jobId: string,
|
||||
userId: string,
|
||||
@@ -1079,9 +1134,15 @@ function authStopText(url: string): string {
|
||||
if (parseSnapmakerModelUrl(url)) return SNAPMAKER_AUTH_TEXT;
|
||||
if (parseCrealitycloudModelUrl(url)) return CREALITYCLOUD_AUTH_TEXT;
|
||||
if (parseNexprintModelUrl(url)) return NEXPRINT_AUTH_TEXT;
|
||||
if (parseThangsModelUrl(url)) return THANGS_AUTH_TEXT;
|
||||
return "the MakerWorld session expired — update the cookie in Settings, then retry";
|
||||
}
|
||||
|
||||
/** Whose daily download limit a link met: Thangs' or MakerWorld's, the only ones with one. */
|
||||
function downloadLimitSite(url: string | null): string {
|
||||
return url && parseThangsModelUrl(url) ? "Thangs" : "MakerWorld";
|
||||
}
|
||||
|
||||
function isTransientImportFailure(err: unknown): boolean {
|
||||
if (err instanceof MakerworldAuthError) return false;
|
||||
return !(err instanceof HttpError) || err.transient === true;
|
||||
@@ -1217,7 +1278,7 @@ async function runLinks(jobId: string, userId: string, body: LinksImportJobBody)
|
||||
if (stopReason) {
|
||||
itemError = {
|
||||
rateLimited: "blocked by a MakerWorld CAPTCHA challenge — retry once it clears (usually 1-4 hours)",
|
||||
downloadLimit: "MakerWorld's daily download limit was reached — retry tomorrow",
|
||||
downloadLimit: `${downloadLimitSite(stopUrl)}'s daily download limit was reached — retry tomorrow`,
|
||||
auth: authStopText(stopUrl ?? item.url),
|
||||
blocked: BLOCKED_RETRY_TEXT,
|
||||
}[stopReason];
|
||||
@@ -1247,7 +1308,7 @@ async function runLinks(jobId: string, userId: string, body: LinksImportJobBody)
|
||||
stopReason = result.stopReason;
|
||||
itemError += {
|
||||
rateLimited: " (MakerWorld CAPTCHA challenge — retry once it clears)",
|
||||
downloadLimit: " (MakerWorld's daily download limit — retry tomorrow)",
|
||||
downloadLimit: ` (${downloadLimitSite(item.url)}'s daily download limit — retry tomorrow)`,
|
||||
auth: ` (${authStopText(item.url)})`,
|
||||
blocked: ` (${BLOCKED_RETRY_TEXT})`,
|
||||
}[result.stopReason];
|
||||
@@ -1280,12 +1341,15 @@ async function runLinks(jobId: string, userId: string, body: LinksImportJobBody)
|
||||
if (stopReason && !stopUrl) stopUrl = item.url;
|
||||
// The connection failed, not the models: the rest go back to unseen, for the next sync to find
|
||||
// again once it works. A link that got some of its profiles in is done.
|
||||
// A sync's provider turned off: they come in once it's back on.
|
||||
// A sync's provider turned off: they come in once it's back on. Thangs' daily download limit resets by itself,
|
||||
// so its models wait for a later sync too, rather than for someone to start them again.
|
||||
const deferReason = providerOff
|
||||
? "disabled"
|
||||
: stopReason === "auth" || stopReason === "blocked"
|
||||
? stopReason
|
||||
: null;
|
||||
: stopReason === "downloadLimit" && downloadLimitSite(stopUrl) === "Thangs"
|
||||
? "downloadLimit"
|
||||
: null;
|
||||
if (job.collectionSyncId && deferReason) {
|
||||
const nothingIn = itemError !== null && !partlyIn;
|
||||
if (!nothingIn) {
|
||||
@@ -1362,7 +1426,7 @@ async function runLinks(jobId: string, userId: string, body: LinksImportJobBody)
|
||||
);
|
||||
} else if (stopReason === "downloadLimit") {
|
||||
bodyParts.push(
|
||||
`the rest not downloaded — MakerWorld's daily download limit was reached, retry the failed links tomorrow`,
|
||||
`the rest not downloaded — ${downloadLimitSite(stopUrl)}'s daily download limit was reached, retry the failed links tomorrow`,
|
||||
);
|
||||
} else if (stopReason === "auth") {
|
||||
bodyParts.push(`the rest failed because ${authStopText(stopUrl ?? job.sourceUrl)}`);
|
||||
@@ -1412,7 +1476,7 @@ async function modelNames(printIds: string[]): Promise<string> {
|
||||
}
|
||||
|
||||
function syncStopText(reason: StopReason, provider: string): string {
|
||||
if (reason === "downloadLimit") return "MakerWorld's daily download limit was reached";
|
||||
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 === "auth") {
|
||||
return provider === "makerworld"
|
||||
@@ -1466,7 +1530,7 @@ async function noteMakerworldRejected(userId: string): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
type SyncDeferral = { count: number; reason: "auth" | "blocked" | "disabled" };
|
||||
type SyncDeferral = { count: number; reason: "auth" | "blocked" | "disabled" | "downloadLimit" };
|
||||
|
||||
/** Takes the sync job's links that haven't run (and `currentItemId`'s, which got nothing in) out of
|
||||
* the job, and out of the sync's seen ids, so its next check queues them again. How many. */
|
||||
@@ -1497,9 +1561,11 @@ async function deferSyncRest(job: ImportJob, currentItemId: string | null, reaso
|
||||
? `${providerName(sync.provider)} is turned off on this Thingport`
|
||||
: reason === "blocked"
|
||||
? `Cloudflare blocked ${providerName(sync.provider)} and FlareSolverr didn't get past it`
|
||||
: sync.provider === "makerworld"
|
||||
? MAKERWORLD_REJECTED_ERROR
|
||||
: `${providerName(sync.provider)} turned Thingport's sign-in down`,
|
||||
: reason === "downloadLimit"
|
||||
? `${providerName(sync.provider)}'s daily download limit was reached`
|
||||
: sync.provider === "makerworld"
|
||||
? MAKERWORLD_REJECTED_ERROR
|
||||
: `${providerName(sync.provider)} turned Thingport's sign-in down`,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1536,9 +1602,11 @@ async function notifySyncJobDone(
|
||||
? `${more} will come in on the next sync once you connect MakerWorld again in Configuration → Providers: MakerWorld turned your sign-in down, and it may have expired.`
|
||||
: deferred.reason === "disabled"
|
||||
? `${more} will come in on a later sync: ${PROVIDER_OFF_TEXT(providerName(sync.provider))}.`
|
||||
: 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.`,
|
||||
: deferred.reason === "downloadLimit"
|
||||
? `${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.`,
|
||||
);
|
||||
}
|
||||
if (failedCount) {
|
||||
|
||||
@@ -488,8 +488,8 @@ export type ImportedPageMetadata = {
|
||||
galleryImages: { url: string; filename: string }[];
|
||||
/** Structured creator record; `creator` stays a plain display string. */
|
||||
author: ImportedAuthorInfo | null;
|
||||
/** The site's own category ids; each site has its own id namespace. */
|
||||
siteCategoryIds: number[];
|
||||
/** The site's own category ids; each site has its own id namespace. Thangs', which has no ids, are category paths. */
|
||||
siteCategoryIds: (number | string)[];
|
||||
categorySite:
|
||||
| "makerworld"
|
||||
| "thingiverse"
|
||||
@@ -499,6 +499,7 @@ export type ImportedPageMetadata = {
|
||||
| "makeronline"
|
||||
| "crealitycloud"
|
||||
| "nexprint"
|
||||
| "thangs"
|
||||
| null;
|
||||
/** Lets a second profile of an imported design be added as its own plate. MakerWorld only. */
|
||||
makerworldProfile?: MakerworldProfileRef;
|
||||
|
||||
@@ -127,6 +127,18 @@ import {
|
||||
selectNexprintProfiles,
|
||||
} from "./nexprintApi";
|
||||
import { getUserNexprintToken, markNexprintSessionRejected, NEXPRINT_CONNECT } from "./nexprintSessionService";
|
||||
import {
|
||||
fetchThangsDownload,
|
||||
isThangsFileHost,
|
||||
parseThangsModelUrl,
|
||||
resolveThangsModel,
|
||||
ThangsAuthError,
|
||||
ThangsBlockedError,
|
||||
ThangsDownloadLimitError,
|
||||
thangsModelUrl,
|
||||
type ThangsModelResolution,
|
||||
} from "./thangsApi";
|
||||
import { getUserThangsToken, markThangsSessionRejected, THANGS_CONNECT, withThangsToken } from "./thangsSessionService";
|
||||
|
||||
/** An import refused because the user's MyMiniFactory session has expired. */
|
||||
export const MYMINIFACTORY_SESSION_CODE = "myminifactory_session";
|
||||
@@ -136,6 +148,10 @@ export const SNAPMAKER_SESSION_CODE = "snapmaker_session";
|
||||
export const CREALITYCLOUD_SESSION_CODE = "crealitycloud_session";
|
||||
/** An import refused because the user hasn't connected NexPrint, or it turned their sign-in down. */
|
||||
export const NEXPRINT_SESSION_CODE = "nexprint_session";
|
||||
/** An import refused because the user hasn't connected Thangs, or it turned their sign-in down. */
|
||||
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 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 =
|
||||
@@ -796,8 +812,12 @@ const CATEGORY_SITE_CAT_IDS_FIELD = {
|
||||
makeronline: "makeronlineCatIds",
|
||||
crealitycloud: "crealitycloudCatIds",
|
||||
nexprint: "nexprintCatIds",
|
||||
thangs: "thangsCatIds",
|
||||
} as const;
|
||||
|
||||
// Columns of text: NexPrint's ids are past Int, and Thangs' are category paths.
|
||||
const TEXT_CAT_IDS_FIELDS = new Set<string>(["nexprintCatIds", "thangsCatIds"]);
|
||||
|
||||
/** Auto-categorizes an import when a category's `*CatIds` for this site overlap the model's own
|
||||
* category ids. Those come most specific first (a model's own category, then its parent), and the
|
||||
* category matching the earliest one wins, so a subcategory beats the category its parent maps to.
|
||||
@@ -805,18 +825,20 @@ const CATEGORY_SITE_CAT_IDS_FIELD = {
|
||||
export async function resolveCategoryIdByCategory(
|
||||
userId: string,
|
||||
categorySite: ImportedPageMetadata["categorySite"],
|
||||
siteCategoryIds: number[],
|
||||
siteCategoryIds: (number | string)[],
|
||||
): Promise<string | null> {
|
||||
if (!categorySite || !siteCategoryIds.length) return null;
|
||||
const field = CATEGORY_SITE_CAT_IDS_FIELD[categorySite];
|
||||
// NexPrint's column holds text (its ids are past Int); compare everything as text so one path serves all.
|
||||
// Compared as text, so one path serves the text columns and the Int ones alike.
|
||||
const wanted = siteCategoryIds.map(String);
|
||||
const numeric = siteCategoryIds.map(Number).filter((n) => Number.isSafeInteger(n));
|
||||
if (!TEXT_CAT_IDS_FIELDS.has(field) && !numeric.length) return null;
|
||||
const candidates = await prisma.category.findMany({
|
||||
// Folders are the user's own tree; only categories take imports by site category id.
|
||||
where: {
|
||||
userId,
|
||||
kind: "category",
|
||||
[field]: { hasSome: field === "nexprintCatIds" ? wanted : siteCategoryIds },
|
||||
[field]: { hasSome: TEXT_CAT_IDS_FIELDS.has(field) ? wanted : numeric },
|
||||
},
|
||||
orderBy: { position: "asc" },
|
||||
});
|
||||
@@ -847,6 +869,8 @@ export function identifySourceModel(url: string): { provider: string; externalId
|
||||
if (crealitycloud) return { provider: "crealitycloud", externalId: crealitycloud.modelId };
|
||||
const nexprint = parseNexprintModelUrl(url);
|
||||
if (nexprint) return { provider: "nexprint", externalId: nexprint.modelId };
|
||||
const thangs = parseThangsModelUrl(url);
|
||||
if (thangs) return { provider: "thangs", externalId: thangs.modelId };
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -905,6 +929,7 @@ export function buildImportSourceUrl(provider: string | null, externalId: string
|
||||
if (provider === "makeronline") return `https://www.makeronline.com/en/model/${externalId}`;
|
||||
if (provider === "crealitycloud") return `https://www.crealitycloud.com/model-detail/${externalId}`;
|
||||
if (provider === "nexprint") return `https://www.nexprint.com/en/models/${externalId}`;
|
||||
if (provider === "thangs") return thangsModelUrl(externalId);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1924,6 +1949,88 @@ async function addNexprintProfileToPrint(
|
||||
return addProfilePlateWithDetails(existing, profile, wanted, profileDetailsOf(model.profiles, { names: false }));
|
||||
}
|
||||
|
||||
const THANGS_BLOCKED =
|
||||
"Thangs blocked the file download (rate limit). This usually clears after a while -- wait, then retry the same import.";
|
||||
const THANGS_MISSING = "This Thangs model could not be found, or isn't public.";
|
||||
|
||||
/** The user's access token, renewed when it's out, or the HttpError that says to connect Thangs. */
|
||||
async function requireThangsToken(userId: string): Promise<string> {
|
||||
let token: string | null;
|
||||
try {
|
||||
token = await getUserThangsToken(userId);
|
||||
} catch (err) {
|
||||
throw await thangsFailure(userId, err);
|
||||
}
|
||||
if (!token) throw new HttpError(400, THANGS_CONNECT, THANGS_SESSION_CODE);
|
||||
return token;
|
||||
}
|
||||
|
||||
/** Thangs' errors as the HttpErrors an import reports; a rejected sign-in is remembered, so the app can say so. */
|
||||
async function thangsFailure(userId: string, err: unknown): Promise<unknown> {
|
||||
if (err instanceof ThangsAuthError) {
|
||||
await markThangsSessionRejected(userId);
|
||||
// 400, not 401: see tryMakerworldCloudApi.
|
||||
return new HttpError(400, err.message, THANGS_SESSION_CODE);
|
||||
}
|
||||
if (err instanceof ThangsDownloadLimitError) return new HttpError(429, err.message, THANGS_DOWNLOAD_LIMIT_CODE);
|
||||
if (err instanceof ThangsBlockedError) return new HttpError(429, err.message);
|
||||
return err;
|
||||
}
|
||||
|
||||
/** Why Thangs won't hand over a model's files, by what kind of model it is. */
|
||||
function thangsUnavailable(resolved: ThangsModelResolution): HttpError {
|
||||
if (resolved.access === "printOnly") {
|
||||
return new HttpError(403, "This Thangs model is sold only as a print: its designer doesn't offer its files.");
|
||||
}
|
||||
if (resolved.access === "paid") {
|
||||
const price = resolved.price !== null ? ` ($${resolved.price})` : "";
|
||||
return new HttpError(
|
||||
403,
|
||||
`This is a paid Thangs model${price}, so it downloads only for an account that bought it, or a member of a ` +
|
||||
"designer membership that includes it. Get it on Thangs with the account connected to Thingport, then import it again.",
|
||||
);
|
||||
}
|
||||
if (resolved.access === "membership") {
|
||||
return new HttpError(
|
||||
403,
|
||||
"This Thangs model is for members of its designer's membership only: join it on Thangs with the account " +
|
||||
"connected to Thingport, then import it again.",
|
||||
);
|
||||
}
|
||||
return new HttpError(403, "Thangs doesn't let your account download this model.");
|
||||
}
|
||||
|
||||
/** Like MyMiniFactory: metadata from the public API, then the files as the user, in one download -- the model file, or
|
||||
* a zip whose model files each become a plate. Every download counts towards the account's daily ones, so a model
|
||||
* that can't be had (print-only) is refused before asking. */
|
||||
async function importThangsModel(
|
||||
userId: string,
|
||||
source: { provider: string; externalId: string },
|
||||
body: ImportRequestBody,
|
||||
): Promise<ImportResult> {
|
||||
let resolved: ThangsModelResolution | null;
|
||||
try {
|
||||
resolved = await resolveThangsModel(source.externalId);
|
||||
} catch (err) {
|
||||
throw await thangsFailure(userId, err);
|
||||
}
|
||||
if (!resolved) throw new HttpError(404, THANGS_MISSING);
|
||||
if (resolved.access === "printOnly") throw thangsUnavailable(resolved);
|
||||
const token = await requireThangsToken(userId);
|
||||
const downloaded: NewPlateInput[] = [];
|
||||
try {
|
||||
const file = await withThangsToken(userId, token, (fresh) => fetchThangsDownload(source.externalId, fresh));
|
||||
if (!file) throw thangsUnavailable(resolved);
|
||||
downloaded.push(...((await downloadSignedFile(file, isThangsFileHost, THANGS_BLOCKED)) ?? []));
|
||||
} catch (err) {
|
||||
await Promise.all(downloaded.map((input) => fs.rm(input.tempFilePath!, { force: true }).catch(() => undefined)));
|
||||
throw await thangsFailure(userId, err);
|
||||
}
|
||||
if (!downloaded.length) throw new HttpError(400, "This Thangs model has no files Thingport can import.");
|
||||
|
||||
return createSourcedPrint(userId, source, body, resolved, downloaded, `thangs-${source.externalId}`);
|
||||
}
|
||||
|
||||
const MAKERONLINE_BLOCKED =
|
||||
"MakerOnline blocked the file download (rate limit). This usually clears after a while -- wait, then try again.";
|
||||
|
||||
@@ -2088,6 +2195,9 @@ async function importNewOrExisting(userId: string, url: string, body: ImportRequ
|
||||
if (source?.provider === "nexprint") {
|
||||
return importNexprintModel(userId, source, url, body);
|
||||
}
|
||||
if (source?.provider === "thangs") {
|
||||
return importThangsModel(userId, source, body);
|
||||
}
|
||||
|
||||
const { tempPath, filename, mime, meta } = await downloadImportToTemp(url, body);
|
||||
const author = await upsertAuthorFromImport(meta.author);
|
||||
|
||||
@@ -39,6 +39,23 @@ import {
|
||||
nexprintCollectionUrl,
|
||||
} from "./nexprintApi";
|
||||
import { getNexprintConnection, getUserNexprintToken } from "./nexprintSessionService";
|
||||
import {
|
||||
fetchThangsCollection,
|
||||
fetchThangsPurchases,
|
||||
listThangsCollections,
|
||||
THANGS_LIKES_KEY,
|
||||
ThangsAuthError,
|
||||
ThangsBlockedError,
|
||||
thangsCollectionUrl,
|
||||
ThangsPrivateCollectionError,
|
||||
} from "./thangsApi";
|
||||
import {
|
||||
getThangsConnection,
|
||||
getUserThangsId,
|
||||
getUserThangsToken,
|
||||
markThangsSessionRejected,
|
||||
withThangsToken,
|
||||
} from "./thangsSessionService";
|
||||
import { MakerworldAuthError, MakerworldCaptchaError, extractMakerworldBearerToken } from "./makerworldCloudApi";
|
||||
import { fetchMakerworldCollectionCard, listMakerworldOwnCollections } from "./makerworldCollections";
|
||||
import { getUserMakerworldCookie } from "./makerworldCookieService";
|
||||
@@ -82,6 +99,7 @@ export const BROWSABLE_PROVIDERS = [
|
||||
"makeronline",
|
||||
"crealitycloud",
|
||||
"nexprint",
|
||||
"thangs",
|
||||
] as const;
|
||||
export type BrowsableProvider = (typeof BROWSABLE_PROVIDERS)[number];
|
||||
|
||||
@@ -105,8 +123,8 @@ export type ProviderCollectionOut = {
|
||||
};
|
||||
|
||||
export async function getProviderAccounts(userId: string): Promise<ProviderAccountsOut> {
|
||||
const [user, thingiverseToken, myminifactoryKey, snapmaker, makeronline, crealitycloud, nexprint] = await Promise.all(
|
||||
[
|
||||
const [user, thingiverseToken, myminifactoryKey, snapmaker, makeronline, crealitycloud, nexprint, thangs] =
|
||||
await Promise.all([
|
||||
prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
@@ -122,8 +140,8 @@ export async function getProviderAccounts(userId: string): Promise<ProviderAccou
|
||||
getMakeronlineConnection(userId),
|
||||
getCrealitycloudConnection(userId),
|
||||
getNexprintConnection(userId),
|
||||
],
|
||||
);
|
||||
getThangsConnection(userId),
|
||||
]);
|
||||
return {
|
||||
makerworld: { connected: Boolean(extractMakerworldBearerToken(user?.makerworldCookie)), account: null },
|
||||
printables: { connected: true, account: user?.printablesHandle ?? null },
|
||||
@@ -133,6 +151,7 @@ export async function getProviderAccounts(userId: string): Promise<ProviderAccou
|
||||
makeronline: { connected: makeronline === "connected", account: null },
|
||||
crealitycloud: { connected: crealitycloud === "connected", account: null },
|
||||
nexprint: { connected: nexprint === "connected", account: null },
|
||||
thangs: { connected: thangs === "connected", account: null },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -183,6 +202,8 @@ export async function setProviderAccount(
|
||||
throw new HttpError(400, "Creality Cloud is linked through its sign-in in Configuration > Providers");
|
||||
} else if (provider === "nexprint") {
|
||||
throw new HttpError(400, "NexPrint is linked through its sign-in in Configuration > Providers");
|
||||
} else if (provider === "thangs") {
|
||||
throw new HttpError(400, "Thangs is linked through its sign-in in Configuration > Providers");
|
||||
} else {
|
||||
throw new HttpError(400, "MakerWorld is linked through its cookie in Configuration > Providers");
|
||||
}
|
||||
@@ -222,6 +243,9 @@ async function providerCall<T>(call: () => Promise<T>): Promise<T> {
|
||||
if (err instanceof MakeronlineAuthError) throw new HttpError(400, err.message);
|
||||
if (err instanceof CrealitycloudAuthError) throw new HttpError(400, err.message);
|
||||
if (err instanceof NexprintAuthError) throw new HttpError(400, err.message);
|
||||
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);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -314,6 +338,48 @@ async function nexprintCollectionCard(collectionId: string): Promise<ProviderCol
|
||||
});
|
||||
}
|
||||
|
||||
/** Runs `call` with the user's Thangs sign-in, renewed when it's out, and what they bought; remembers a rejection. */
|
||||
async function withThangs<T>(
|
||||
userId: string,
|
||||
call: (session: { token: string; thangsId: string; owned: Set<string> }) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const connect = "Connect Thangs in Configuration > Providers, or visit it with Thingport Grab";
|
||||
return providerCall(async () => {
|
||||
try {
|
||||
const [token, thangsId] = await Promise.all([getUserThangsToken(userId), getUserThangsId(userId)]);
|
||||
if (!token || !thangsId) throw new HttpError(409, connect);
|
||||
const owned = await withThangsToken(userId, token, fetchThangsPurchases);
|
||||
return await withThangsToken(userId, token, (fresh) => call({ token: fresh, thangsId, owned }));
|
||||
} catch (err) {
|
||||
if (err instanceof ThangsAuthError) await markThangsSessionRejected(userId);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** The user's likes and their own collections, read with their sign-in. */
|
||||
function thangsCards(userId: string): Promise<ProviderCollectionCard[]> {
|
||||
return withThangs(userId, ({ token, thangsId, owned }) => listThangsCollections(thangsId, token, owned));
|
||||
}
|
||||
|
||||
/** Any collection the user can see: public ones, and their own private ones. */
|
||||
function thangsCollectionCard(userId: string, collectionId: string): Promise<ProviderCollectionCard | null> {
|
||||
return withThangs(userId, async ({ token, owned }) => {
|
||||
const collection = await fetchThangsCollection(collectionId, token, owned, 4);
|
||||
if (!collection) return null;
|
||||
const { info } = collection;
|
||||
return {
|
||||
id: collectionId,
|
||||
name: info.name,
|
||||
url: thangsCollectionUrl(collectionId, info.owner, info.name),
|
||||
modelCount: info.modelCount,
|
||||
thumbnails: collection.entries.flatMap((entry) => (entry.cover ? [entry.cover] : [])),
|
||||
private: info.private,
|
||||
owner: info.owner,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function withSyncs(
|
||||
userId: string,
|
||||
provider: SyncProvider,
|
||||
@@ -372,6 +438,8 @@ export async function listProviderCollections(
|
||||
cards = await crealitycloudCards(userId);
|
||||
} else if (provider === "nexprint") {
|
||||
cards = await nexprintCards(userId);
|
||||
} else if (provider === "thangs") {
|
||||
cards = await thangsCards(userId);
|
||||
} else if (provider === "myminifactory") {
|
||||
const key = await requireMyminifactoryKey();
|
||||
if (!user?.myminifactoryUsername) throw new HttpError(409, "Tell Thingport your MyMiniFactory username first");
|
||||
@@ -399,7 +467,7 @@ export async function lookupProviderCollection(userId: string, url: string): Pro
|
||||
if (!source)
|
||||
throw new HttpError(
|
||||
400,
|
||||
"That isn't a MakerWorld, Printables, Thingiverse, MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud or NexPrint collection link",
|
||||
"That isn't a MakerWorld, Printables, Thingiverse, MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud, NexPrint or Thangs collection link",
|
||||
);
|
||||
await assertProviderEnabled(source.provider);
|
||||
let card: ProviderCollectionCard | null;
|
||||
@@ -435,6 +503,11 @@ export async function lookupProviderCollection(userId: string, url: string): Pro
|
||||
: await crealitycloudCollectionCard(source.externalId);
|
||||
} else if (source.provider === "nexprint") {
|
||||
card = await nexprintCollectionCard(source.externalId);
|
||||
} else if (source.provider === "thangs") {
|
||||
card =
|
||||
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 === "snapmaker") {
|
||||
// Only the user's own likes and bookmarks are there to find.
|
||||
card = (await snapmakerCards(userId)).find((own) => own.id === source.externalId) ?? null;
|
||||
@@ -468,7 +541,7 @@ export async function enableSyncFromApp(
|
||||
if (!source)
|
||||
throw new HttpError(
|
||||
400,
|
||||
"That isn't a MakerWorld, Printables, Thingiverse, MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud or NexPrint collection link",
|
||||
"That isn't a MakerWorld, Printables, Thingiverse, MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud, NexPrint or Thangs collection link",
|
||||
);
|
||||
await assertProviderEnabled(source.provider);
|
||||
if (input.profileScope && !hasProfiles(source.provider)) {
|
||||
|
||||
@@ -15,6 +15,7 @@ export const PROVIDERS = [
|
||||
"makeronline",
|
||||
"crealitycloud",
|
||||
"nexprint",
|
||||
"thangs",
|
||||
] as const;
|
||||
export type Provider = (typeof PROVIDERS)[number];
|
||||
|
||||
@@ -27,6 +28,7 @@ const PROVIDER_NAMES: Record<Provider, string> = {
|
||||
makeronline: "MakerOnline",
|
||||
crealitycloud: "Creality Cloud",
|
||||
nexprint: "NexPrint",
|
||||
thangs: "Thangs",
|
||||
};
|
||||
|
||||
const SETTING_KEY = "disabled_providers";
|
||||
|
||||
@@ -0,0 +1,546 @@
|
||||
import { IMPORT_DOWNLOAD_RETRY_DELAY_MS, IMPORT_TIMEOUT_SECONDS } from "../config";
|
||||
import { browserFetch } from "../utils/browserFetch";
|
||||
import { sleep } from "../utils/concurrency";
|
||||
import type { ImportedAuthorInfo, ImportedPageMetadata } from "./importResolvers";
|
||||
import type { ProviderCollectionCard } from "./providerCollectionCard";
|
||||
import { extractJsonFromBrowserBody, fetchViaFlaresolverr, isCloudflareChallenge } from "./flaresolverr";
|
||||
import { getFlaresolverrUrl } from "./settingsService";
|
||||
|
||||
// Thangs has no public API, so this is the one its own pages call. Details, the likes of any user and public
|
||||
// collections need no sign-in; downloads, private collections and purchases need the user's access token, a 7-day JWT
|
||||
// sent as a Bearer token, which the refresh token renews. Free accounts have a daily download cap. Like Thingiverse,
|
||||
// it's behind a Cloudflare that checks the TLS handshake, hence browserFetch.
|
||||
const SITE_ORIGIN = "https://thangs.com";
|
||||
const API_BASE = `${SITE_ORIGIN}/api`;
|
||||
const API_TIMEOUT_MS = IMPORT_TIMEOUT_SECONDS * 1000;
|
||||
const PROVIDER = "thangs";
|
||||
const CHALLENGE_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;
|
||||
}
|
||||
|
||||
/** Thangs' ids come as numbers or numeric strings, depending on the endpoint. */
|
||||
function id(value: unknown): string | null {
|
||||
if (typeof value === "number" && Number.isSafeInteger(value) && value > 0) return String(value);
|
||||
return typeof value === "string" && /^\d+$/.test(value) ? value : null;
|
||||
}
|
||||
|
||||
function count(value: unknown): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
function records(value: unknown): Record<string, unknown>[] {
|
||||
return Array.isArray(value) ? value.filter(isRecord) : [];
|
||||
}
|
||||
|
||||
export class ThangsAuthError extends Error {
|
||||
constructor() {
|
||||
super(
|
||||
"Thangs didn't accept your sign-in; it may have expired. Visit thangs.com signed in with Thingport Grab, " +
|
||||
"or connect it again in Configuration > Providers.",
|
||||
);
|
||||
this.name = "ThangsAuthError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ThangsBlockedError extends Error {
|
||||
constructor(flaresolverr?: "missing" | "failed") {
|
||||
super(
|
||||
"Thangs blocked this request (rate limit or Cloudflare challenge). This usually clears after a while -- wait, " +
|
||||
"then try again." +
|
||||
(flaresolverr === "missing"
|
||||
? " Setting up FlareSolverr in Administration > Connections gets past it."
|
||||
: flaresolverr === "failed"
|
||||
? " FlareSolverr couldn't get past it either: check it in Administration > Connections."
|
||||
: ""),
|
||||
);
|
||||
this.name = "ThangsBlockedError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Free Thangs accounts download a limited number of models a day. */
|
||||
export class ThangsDownloadLimitError extends Error {
|
||||
constructor() {
|
||||
super("Thangs' daily download limit for your account was reached. It resets tomorrow -- import it again then.");
|
||||
this.name = "ThangsDownloadLimitError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Someone's private collection, which only its owner's sign-in reads. */
|
||||
export class ThangsPrivateCollectionError extends Error {
|
||||
constructor() {
|
||||
super("That Thangs collection is private: only its owner can import it, with their Thangs account connected.");
|
||||
this.name = "ThangsPrivateCollectionError";
|
||||
}
|
||||
}
|
||||
|
||||
function parseUrl(url: string): URL | null {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return /^(www\.)?thangs\.com$/i.test(parsed.hostname) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// `/designer/<user>/3d-model/<name>-<id>`, also under `/prints` (print-only models) and with a subpage after it
|
||||
// (`/3d-print/<sku>`, `/image/<id>`, ...); the name may be left out. `/m/<id>` is the site's own short link.
|
||||
const MODEL_PATH = /^\/(?:prints\/)?designer\/[^/]+\/3d-model\/(?:[^/]*-)?(\d+)(?:\/.*)?$/i;
|
||||
const SHORT_MODEL_PATH = /^\/m\/(\d+)\/?$/i;
|
||||
const COLLECTION_PATH = /^(\/designer\/[^/]+\/collection\/(?:[^/]*-)?(\d+))(?:\/.*)?$/i;
|
||||
const OWN_COLLECTION_PATH = /^(\/(?:mythangs\/collections|c)\/(\d+))\/?$/i;
|
||||
|
||||
export const THANGS_LIKES_KEY = "likes";
|
||||
export const THANGS_LIKES_URL = `${SITE_ORIGIN}/mythangs/liked-models`;
|
||||
export const THANGS_LIKES_NAME = "Thangs likes";
|
||||
|
||||
export function parseThangsModelUrl(url: string): { modelId: string } | null {
|
||||
const parsed = parseUrl(url);
|
||||
const m = parsed?.pathname.match(MODEL_PATH) ?? parsed?.pathname.match(SHORT_MODEL_PATH);
|
||||
return m ? { modelId: m[1] } : null;
|
||||
}
|
||||
|
||||
/** Thangs redirects its short link to the model's page, whatever its name and designer. */
|
||||
export function thangsModelUrl(modelId: string): string {
|
||||
return `${SITE_ORIGIN}/m/${modelId}`;
|
||||
}
|
||||
|
||||
/** A collection: `/designer/<user>/collection/<name>-<id>`, or the owner's own `/mythangs/collections/<id>`. `url` is
|
||||
* the same page without a sort, scope or query. */
|
||||
export function parseThangsCollectionUrl(url: string): { collectionId: string; url: string } | null {
|
||||
const parsed = parseUrl(url);
|
||||
const m = parsed?.pathname.match(COLLECTION_PATH) ?? parsed?.pathname.match(OWN_COLLECTION_PATH);
|
||||
return m ? { collectionId: m[2], url: `${SITE_ORIGIN}${m[1]}` } : null;
|
||||
}
|
||||
|
||||
/** The page the site itself links a collection at: its owner's, named after it. */
|
||||
export function thangsCollectionUrl(collectionId: string, owner: string | null, name: string | null): string {
|
||||
if (!owner) return `${SITE_ORIGIN}/mythangs/collections/${collectionId}`;
|
||||
const slug = name ? `${encodeURIComponent(name)}-` : "";
|
||||
return `${SITE_ORIGIN}/designer/${encodeURIComponent(owner)}/collection/${slug}${collectionId}`;
|
||||
}
|
||||
|
||||
/** The user's own liked models; the page names no user, as it's always the signed-in one's. */
|
||||
export function isThangsLikesUrl(url: string): boolean {
|
||||
return /^\/mythangs\/liked-models\/?$/i.test(parseUrl(url)?.pathname ?? "");
|
||||
}
|
||||
|
||||
type ApiResult = { status: number; data: unknown };
|
||||
|
||||
async function fetchApi(
|
||||
path: string,
|
||||
{ token = null, method = "GET", body }: { token?: string | null; method?: "GET" | "POST"; body?: unknown } = {},
|
||||
): Promise<ApiResult> {
|
||||
const url = `${API_BASE}${path}`;
|
||||
const send = () =>
|
||||
browserFetch(url, {
|
||||
method,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
...(body === undefined ? {} : { "Content-Type": "application/json" }),
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
timeoutMs: API_TIMEOUT_MS,
|
||||
});
|
||||
let res = await send();
|
||||
// Cloudflare challenges single calls of a quick run now and then, and lets the same one through a moment later.
|
||||
for (let retry = 1; retry <= CHALLENGE_RETRIES && isCloudflareChallenge(res.headers); retry++) {
|
||||
await res.body?.cancel().catch(() => undefined);
|
||||
await sleep(IMPORT_DOWNLOAD_RETRY_DELAY_MS * retry);
|
||||
res = await send();
|
||||
}
|
||||
let viaBrowser = false;
|
||||
// FlareSolverr can't send a token, so only what reads without one goes through it. Only a challenge: Thangs' own
|
||||
// refusals ("Forbidden" for a hidden model) come through Cloudflare too.
|
||||
if (!token && method === "GET" && (res.status === 429 || isCloudflareChallenge(res.headers))) {
|
||||
const solved = await fetchViaFlaresolverr(url);
|
||||
if (solved) {
|
||||
viaBrowser = true;
|
||||
res = new Response(solved.body, { status: solved.status });
|
||||
}
|
||||
}
|
||||
if (!viaBrowser && (res.status === 429 || isCloudflareChallenge(res.headers))) {
|
||||
await res.body?.cancel().catch(() => undefined);
|
||||
throw new ThangsBlockedError(token ? undefined : (await getFlaresolverrUrl()) ? "failed" : "missing");
|
||||
}
|
||||
if (res.status === 401 && token) {
|
||||
await res.body?.cancel().catch(() => undefined);
|
||||
throw new ThangsAuthError();
|
||||
}
|
||||
const text = await res.text();
|
||||
let data: unknown = null;
|
||||
if (text.trim()) {
|
||||
try {
|
||||
data = viaBrowser ? extractJsonFromBrowserBody(text) : JSON.parse(text);
|
||||
} catch {
|
||||
// Refusals come as plain text ("Forbidden"); the status says it all.
|
||||
}
|
||||
}
|
||||
return { status: res.status, data };
|
||||
}
|
||||
|
||||
function failure(what: string, status: number, data: unknown): Error {
|
||||
const message = isRecord(data) ? str(data.message) : null;
|
||||
return new Error(`Couldn't read ${what} on Thangs${message ? `: ${message}` : ` (HTTP ${status})`}`);
|
||||
}
|
||||
|
||||
export type ThangsJwt = { userId: string; username: string | null; expiresAt: Date | null; refresh: boolean };
|
||||
|
||||
/** What a Thangs token says about itself: whose it is, until when, and whether it's a refresh token. Read without
|
||||
* checking its signature, which only Thangs can; null for anything that isn't one. */
|
||||
export function decodeThangsJwt(token: string): ThangsJwt | null {
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) return null;
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!isRecord(payload)) return null;
|
||||
const issuer = str(payload.iss) ?? "";
|
||||
const userId = id(payload.sub);
|
||||
if (!userId || !/(^|\.)thangs\.com/i.test(issuer)) return null;
|
||||
const exp = typeof payload.exp === "number" ? new Date(payload.exp * 1000) : null;
|
||||
return { userId, username: str(payload.username), expiresAt: exp, refresh: /refresh-tokens/i.test(issuer) };
|
||||
}
|
||||
|
||||
/** A new access token from a refresh token, which Thangs keeps as it is. Throws ThangsAuthError when it's turned
|
||||
* down. */
|
||||
export async function refreshThangsToken(refreshToken: string): Promise<{ token: string; expiresAt: Date | null }> {
|
||||
const res = await browserFetch(`${API_BASE}/auth/v2/refresh-tokens`, {
|
||||
method: "POST",
|
||||
headers: { Accept: "application/json", "Content-Type": "application/json", Cookie: `refreshToken=${refreshToken}` },
|
||||
timeoutMs: API_TIMEOUT_MS,
|
||||
});
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
await res.body?.cancel().catch(() => undefined);
|
||||
if (isCloudflareChallenge(res.headers)) throw new ThangsBlockedError();
|
||||
throw new ThangsAuthError();
|
||||
}
|
||||
if (res.status === 429 || isCloudflareChallenge(res.headers)) {
|
||||
await res.body?.cancel().catch(() => undefined);
|
||||
throw new ThangsBlockedError();
|
||||
}
|
||||
const data: unknown = await res.json().catch(() => null);
|
||||
const token = isRecord(data) && isRecord(data.token) ? str(data.token.TOKEN) : null;
|
||||
if (!res.ok || !token) throw failure("a new sign-in", res.status, data);
|
||||
return { token, expiresAt: decodeThangsJwt(token)?.expiresAt ?? null };
|
||||
}
|
||||
|
||||
/** Who the token belongs to, or "rejected"/"unreachable". */
|
||||
export async function checkThangsAuth(
|
||||
token: string,
|
||||
): Promise<{ userId: string; username: string | null } | "rejected" | "unreachable"> {
|
||||
try {
|
||||
const { status, data } = await fetchApi("/users/current?likes=false", { token });
|
||||
if (status !== 200 || !isRecord(data) || !id(data.id)) return "unreachable";
|
||||
return { userId: id(data.id)!, username: str(data.username) };
|
||||
} catch (err) {
|
||||
return err instanceof ThangsAuthError ? "rejected" : "unreachable";
|
||||
}
|
||||
}
|
||||
|
||||
/** Who may download a model: anyone signed in ("free"), its buyers or a designer membership's members ("paid"),
|
||||
* a membership's members only ("membership"), or no one, as it's sold printed ("printOnly"). */
|
||||
export type ThangsAccess = "free" | "paid" | "membership" | "printOnly";
|
||||
|
||||
function accessOf(model: Record<string, unknown>): ThangsAccess {
|
||||
const visibility = str(model.visibility)?.toLowerCase();
|
||||
if (visibility === "print-only") return "printOnly";
|
||||
if (visibility === "paid") return "membership";
|
||||
if (visibility === "market-paid" || isRecord(model.marketplaceInfo) || model.isPaid === true) return "paid";
|
||||
return "free";
|
||||
}
|
||||
|
||||
export type ThangsModelResolution = {
|
||||
meta: Partial<ImportedPageMetadata>;
|
||||
galleryImages: { url: string; filename: string }[];
|
||||
modelId: string;
|
||||
access: ThangsAccess;
|
||||
/** In US dollars, when it's for sale. */
|
||||
price: number | null;
|
||||
};
|
||||
|
||||
function authorOf(owner: unknown): ImportedAuthorInfo | null {
|
||||
if (!isRecord(owner)) return null;
|
||||
const userId = id(owner.id);
|
||||
const username = str(owner.username);
|
||||
if (!userId || !username) return null;
|
||||
const profile = isRecord(owner.profile) ? owner.profile : {};
|
||||
return {
|
||||
provider: PROVIDER,
|
||||
externalId: userId,
|
||||
// The site shows designers by their username.
|
||||
name: username,
|
||||
handle: username,
|
||||
bio: str(profile.description),
|
||||
bioTranslated: null,
|
||||
links: [`${SITE_ORIGIN}/designer/${encodeURIComponent(username)}`],
|
||||
avatarUrl: str(profile.avatarUrl) ?? str(owner.avatarUrl),
|
||||
backgroundUrl: null,
|
||||
};
|
||||
}
|
||||
|
||||
function imageName(url: string, index: number): string {
|
||||
let name = "";
|
||||
try {
|
||||
name = decodeURIComponent(new URL(url).pathname.split("/").pop() ?? "");
|
||||
} catch {}
|
||||
return name || `image-${index}.jpg`;
|
||||
}
|
||||
|
||||
/** The pictures the designer put up; the parts' renders only when there are none. */
|
||||
function imagesOf(model: Record<string, unknown>): string[] {
|
||||
const photos = records(model.attachments).flatMap((a) =>
|
||||
(a.attachmentType ?? "image") === "image" && a.isApproved !== false && a.isNsfwFlagged !== true
|
||||
? (str(a.imageUrl) ?? [])
|
||||
: [],
|
||||
);
|
||||
const renders = records(model.parts).flatMap((part) => str(part.thumbnailUrl) ?? []);
|
||||
return [...new Set(photos.length ? photos : renders)];
|
||||
}
|
||||
|
||||
/** Its category paths, most specific first: its own, its other ones, then their parents, so a subcategory beats its
|
||||
* parent and the model's own beats the rest. */
|
||||
export function thangsCategoryPaths(model: Record<string, unknown>): string[] {
|
||||
const paths = [
|
||||
str(model.category),
|
||||
...(Array.isArray(model.v3Categories) ? model.v3Categories.map(str) : []),
|
||||
str(model.secondaryCategory),
|
||||
].filter((path): path is string => !!path);
|
||||
const parents = paths.flatMap((path) => (path.includes("/") ? [path.slice(0, path.indexOf("/"))] : []));
|
||||
return [...new Set([...paths, ...parents])];
|
||||
}
|
||||
|
||||
/** The metadata mapping, apart from the fetch so it can be tested on its own. */
|
||||
export function thangsMetaFromModel(model: Record<string, unknown>): ThangsModelResolution | null {
|
||||
const modelId = id(model.id) ?? id(model.modelId);
|
||||
if (!modelId) return null;
|
||||
const meta: Partial<ImportedPageMetadata> = {};
|
||||
const title = str(model.name);
|
||||
if (title) meta.title = title;
|
||||
// Already Markdown, as the site writes and shows it.
|
||||
const description = str(model.description);
|
||||
if (description) meta.description = description;
|
||||
const tags = [...new Set((Array.isArray(model.tags) ? model.tags : []).flatMap((t) => str(t) ?? []))];
|
||||
if (tags.length) meta.tags = tags;
|
||||
const images = imagesOf(model);
|
||||
if (images[0]) meta.previewImageUrl = images[0];
|
||||
const author = authorOf(model.owner);
|
||||
if (author) {
|
||||
meta.author = author;
|
||||
meta.creator = author.name;
|
||||
}
|
||||
const categories = thangsCategoryPaths(model);
|
||||
if (categories.length) {
|
||||
meta.siteCategoryIds = categories;
|
||||
meta.categorySite = PROVIDER;
|
||||
}
|
||||
const marketplace = isRecord(model.marketplaceInfo) ? model.marketplaceInfo : null;
|
||||
return {
|
||||
meta,
|
||||
galleryImages: images.map((url, index) => ({ url, filename: imageName(url, index) })),
|
||||
modelId,
|
||||
access: accessOf(model),
|
||||
price: marketplace && typeof marketplace.priceInUSD === "number" ? marketplace.priceInUSD : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** By its id. Null for a model that doesn't exist or isn't public: Thangs answers both with a 403. */
|
||||
export async function resolveThangsModel(modelId: string): Promise<ThangsModelResolution | null> {
|
||||
const { status, data } = await fetchApi(`/models/v2/${encodeURIComponent(modelId)}?likes=false`);
|
||||
if (status === 403 || status === 404) return null;
|
||||
if (status !== 200 || !isRecord(data)) throw failure("this model", status, data);
|
||||
return thangsMetaFromModel(data);
|
||||
}
|
||||
|
||||
export type ThangsFile = { name: string; url: string };
|
||||
|
||||
/** The extension a signed link's file has, when its name lacks one: Thangs names the file after the model. */
|
||||
function withExtension(name: string, url: string): string {
|
||||
let ext = "";
|
||||
try {
|
||||
ext = decodeURIComponent(new URL(url).pathname).match(/\.[a-z0-9]{2,5}$/i)?.[0] ?? "";
|
||||
} catch {}
|
||||
return ext && !name.toLowerCase().endsWith(ext.toLowerCase()) ? `${name}${ext}` : name;
|
||||
}
|
||||
|
||||
/** The model's files as the signed-in user may have them: one model file, or a zip of all of them. Null when Thangs
|
||||
* doesn't let them (a paid model they haven't bought, a print-only one). Each call counts towards their daily
|
||||
* downloads; past those it throws ThangsDownloadLimitError. */
|
||||
export async function fetchThangsDownload(modelId: string, token: string): Promise<ThangsFile | null> {
|
||||
const { status, data } = await fetchApi(`/v2/models/${encodeURIComponent(modelId)}/download-url`, { token });
|
||||
if (status === 403 || status === 404) return null;
|
||||
if (isRecord(data) && isRecord(data.userDownloadData) && data.userDownloadData.downloadLimitReached === true) {
|
||||
throw new ThangsDownloadLimitError();
|
||||
}
|
||||
const url = isRecord(data) ? str(data.signedUrl) : null;
|
||||
if (status !== 200 || !url || (isRecord(data) && data.fileName === "Error")) {
|
||||
throw failure("this model's files", status, data);
|
||||
}
|
||||
const name =
|
||||
(isRecord(data) ? str(data.fileName) : null)?.replace(/[\\/:*?"<>|]+/g, " ").trim() || `thangs-${modelId}`;
|
||||
return { name: withExtension(name, url), url };
|
||||
}
|
||||
|
||||
/** Thangs' signed file links, and its prebuilt zips, are on Google Cloud Storage. */
|
||||
export function isThangsFileHost(hostname: string): boolean {
|
||||
return hostname.toLowerCase() === "storage.googleapis.com";
|
||||
}
|
||||
|
||||
/** The paid models the user bought, alone or in a bundle, which download like free ones. */
|
||||
export async function fetchThangsPurchases(token: string): Promise<Set<string>> {
|
||||
const { status, data } = await fetchApi("/users/current/purchases?pageSize=1000", { token });
|
||||
if (status !== 200 || !isRecord(data)) throw failure("your purchases", status, data);
|
||||
const ids = [
|
||||
...(Array.isArray(data.models) ? data.models : []),
|
||||
...(Array.isArray(data.bundleModels) ? data.bundleModels : []),
|
||||
].flatMap((value) => id(isRecord(value) ? value.id : value) ?? []);
|
||||
return new Set(ids);
|
||||
}
|
||||
|
||||
export type ThangsModelSummary = { modelId: string; title: string; cover: string | null };
|
||||
export type ThangsListing = { entries: ThangsModelSummary[]; total: number; truncated: boolean };
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
const LISTING_MAX_ENTRIES = 600;
|
||||
const CARD_THUMBNAILS = 4;
|
||||
|
||||
/** What a listing shows of a model. Print-only ones never download, and paid ones only once bought (`owned`), so
|
||||
* they're left out: a sync would otherwise fail them on every round. */
|
||||
function summaryOf(model: Record<string, unknown>, owned: Set<string>): ThangsModelSummary | null {
|
||||
const modelId = id(model.id) ?? id(model.modelId);
|
||||
if (!modelId) return null;
|
||||
const access = accessOf(model);
|
||||
if (access === "printOnly" || (access !== "free" && !owned.has(modelId))) return null;
|
||||
const cover = imagesOf(model)[0] ?? (Array.isArray(model.thumbnails) ? str(model.thumbnails[0]) : null);
|
||||
return { modelId, title: str(model.name) ?? `Model ${modelId}`, cover: cover ?? str(model.image) };
|
||||
}
|
||||
|
||||
function listingOf(models: Record<string, unknown>[], owned: Set<string>, maxItems: number): ThangsListing {
|
||||
const seen = new Set<string>();
|
||||
const entries: ThangsModelSummary[] = [];
|
||||
for (const model of models) {
|
||||
const entry = summaryOf(model, owned);
|
||||
if (!entry || seen.has(entry.modelId)) continue;
|
||||
seen.add(entry.modelId);
|
||||
entries.push(entry);
|
||||
}
|
||||
return { entries: entries.slice(0, maxItems), total: entries.length, truncated: entries.length > maxItems };
|
||||
}
|
||||
|
||||
/** A user's liked models, which Thangs shows anyone, all in one answer. */
|
||||
export async function fetchThangsLikes(
|
||||
userId: string,
|
||||
owned: Set<string>,
|
||||
maxItems: number = LISTING_MAX_ENTRIES,
|
||||
): Promise<ThangsListing> {
|
||||
const { status, data } = await fetchApi(`/users/v4/${encodeURIComponent(userId)}/likes`);
|
||||
if (status !== 200 || !isRecord(data)) throw failure("your likes", status, data);
|
||||
return listingOf(records(data.models), owned, maxItems);
|
||||
}
|
||||
|
||||
export type ThangsCollectionInfo = {
|
||||
id: string;
|
||||
name: string;
|
||||
owner: string | null;
|
||||
private: boolean;
|
||||
modelCount: number;
|
||||
};
|
||||
|
||||
/** A collection's models, as the site orders them. Private ones need their owner's `token`; someone else's throws
|
||||
* ThangsPrivateCollectionError. Null for one that doesn't exist. */
|
||||
export async function fetchThangsCollection(
|
||||
collectionId: string,
|
||||
token: string | null,
|
||||
owned: Set<string>,
|
||||
maxItems: number = LISTING_MAX_ENTRIES,
|
||||
): Promise<(ThangsListing & { info: ThangsCollectionInfo }) | null> {
|
||||
const models: Record<string, unknown>[] = [];
|
||||
let info: ThangsCollectionInfo | null = null;
|
||||
for (let page = 0; models.length < maxItems; page++) {
|
||||
const query = new URLSearchParams({
|
||||
page: String(page),
|
||||
pageSize: String(PAGE_SIZE),
|
||||
sortBy: "date",
|
||||
costType: "collection",
|
||||
showContent: "all",
|
||||
...(token ? { isLoggedIn: "true" } : {}),
|
||||
});
|
||||
const { status, data } = await fetchApi(`/collections/${encodeURIComponent(collectionId)}?${query}`, { token });
|
||||
if (status === 404) return null;
|
||||
if (status === 403) throw new ThangsPrivateCollectionError();
|
||||
if (status !== 200 || !isRecord(data)) throw failure("this collection", status, data);
|
||||
const owner = isRecord(data.owner) ? str(data.owner.username) : null;
|
||||
info ??= {
|
||||
id: collectionId,
|
||||
name: str(data.name) ?? "Thangs collection",
|
||||
owner,
|
||||
private: data.private === true,
|
||||
modelCount: count(data.totalCount),
|
||||
};
|
||||
const items = records(data.items);
|
||||
models.push(...items);
|
||||
if (items.length < PAGE_SIZE || models.length >= info.modelCount) break;
|
||||
}
|
||||
if (!info) return null;
|
||||
return { ...listingOf(models, owned, maxItems), info };
|
||||
}
|
||||
|
||||
/** The user's likes, then their own collections, as the sync dialog's Browse tab shows them. */
|
||||
export async function listThangsCollections(
|
||||
userId: string,
|
||||
token: string,
|
||||
owned: Set<string>,
|
||||
): Promise<ProviderCollectionCard[]> {
|
||||
const likes = await fetchThangsLikes(userId, owned);
|
||||
const cards: ProviderCollectionCard[] = [
|
||||
{
|
||||
id: THANGS_LIKES_KEY,
|
||||
name: THANGS_LIKES_NAME,
|
||||
url: THANGS_LIKES_URL,
|
||||
modelCount: likes.total,
|
||||
thumbnails: likes.entries.slice(0, CARD_THUMBNAILS).flatMap((entry) => entry.cover ?? []),
|
||||
private: true,
|
||||
owner: null,
|
||||
},
|
||||
];
|
||||
for (let page = 0; ; page++) {
|
||||
const query = new URLSearchParams({
|
||||
page: String(page),
|
||||
pageSize: String(PAGE_SIZE),
|
||||
sortBy: "date",
|
||||
costType: "collection",
|
||||
showContent: "all",
|
||||
isLoggedIn: "true",
|
||||
});
|
||||
const { status, data } = await fetchApi(`/collections?${query}`, { token });
|
||||
if (status !== 200 || !isRecord(data)) throw failure("your collections", status, data);
|
||||
const items = records(data.items);
|
||||
for (const item of items) {
|
||||
const collectionId = id(item.id);
|
||||
if (!collectionId) continue;
|
||||
const owner = isRecord(item.owner) ? str(item.owner.username) : null;
|
||||
const name = str(item.name) ?? "Thangs collection";
|
||||
cards.push({
|
||||
id: collectionId,
|
||||
name,
|
||||
url: thangsCollectionUrl(collectionId, owner, name),
|
||||
modelCount: Array.isArray(item.modelIds) ? item.modelIds.length : 0,
|
||||
// The list has one picture per collection, its newest model's.
|
||||
thumbnails: str(item.image) ? [str(item.image)!] : [],
|
||||
private: item.private === true,
|
||||
owner,
|
||||
});
|
||||
}
|
||||
if (items.length < PAGE_SIZE) break;
|
||||
}
|
||||
return cards;
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { prisma } from "../db";
|
||||
import { createLogger } from "../logger";
|
||||
import { decodeThangsJwt, refreshThangsToken, ThangsAuthError } from "./thangsApi";
|
||||
|
||||
// Per-user: the Thangs sign-in, its accessToken cookie (a 7-day JWT, sent as a Bearer token) and the refreshToken
|
||||
// cookie that renews it. The refresh token lasts about a year and Thangs keeps it as it is on use, so Thingport renews
|
||||
// the access token itself as it runs out; Thingport Grab sends both whenever the browser's change.
|
||||
|
||||
const log = createLogger("thangs");
|
||||
|
||||
// Renewed a little before it runs out, so a long import never starts with a token about to.
|
||||
const RENEW_BEFORE_MS = 10 * 60 * 1000;
|
||||
|
||||
export type ThangsSession = {
|
||||
token: string | null;
|
||||
refreshToken: string | null;
|
||||
userId: string;
|
||||
expiresAt: Date | null;
|
||||
};
|
||||
|
||||
const JWT = /eyJ[\w-]+\.eyJ[\w-]+\.[\w-]+/g;
|
||||
|
||||
/** The tokens in a Cookie header, copied request headers or a bare token: an access token, a refresh token, or both.
|
||||
* Null without one, or with two that are different accounts'. */
|
||||
export function normalizeThangsSession(raw: string | null | undefined): ThangsSession | null {
|
||||
let token: string | null = null;
|
||||
let refreshToken: string | null = null;
|
||||
let userId: string | null = null;
|
||||
let expiresAt: Date | null = null;
|
||||
for (const candidate of (raw ?? "").match(JWT) ?? []) {
|
||||
const jwt = decodeThangsJwt(candidate);
|
||||
if (!jwt) continue;
|
||||
if (userId && jwt.userId !== userId) return null;
|
||||
userId = jwt.userId;
|
||||
if (jwt.refresh) refreshToken ??= candidate;
|
||||
else if (!token) {
|
||||
token = candidate;
|
||||
expiresAt = jwt.expiresAt;
|
||||
}
|
||||
}
|
||||
return userId && (token || refreshToken) ? { token, refreshToken, userId, expiresAt } : null;
|
||||
}
|
||||
|
||||
const SESSION_SELECT = {
|
||||
thangsToken: true,
|
||||
thangsRefreshToken: true,
|
||||
thangsExpiresAt: true,
|
||||
thangsUserId: true,
|
||||
thangsRejectedAt: true,
|
||||
} as const;
|
||||
|
||||
/** A new sign-in clears an earlier rejection. One without a refresh token keeps the one already there for the same
|
||||
* Thangs account, so pasting a fresh access token doesn't lose the renewals. */
|
||||
export async function setUserThangsSession(userId: string, session: ThangsSession | null): Promise<boolean> {
|
||||
if (!session) {
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
thangsToken: null,
|
||||
thangsRefreshToken: null,
|
||||
thangsExpiresAt: null,
|
||||
thangsUserId: null,
|
||||
thangsRejectedAt: null,
|
||||
},
|
||||
});
|
||||
return false;
|
||||
}
|
||||
const current = await prisma.user.findUnique({ where: { id: userId }, select: SESSION_SELECT });
|
||||
const sameAccount = current?.thangsUserId === session.userId;
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
thangsToken: session.token ?? (sameAccount ? current?.thangsToken : null) ?? null,
|
||||
thangsRefreshToken: session.refreshToken ?? (sameAccount ? current?.thangsRefreshToken : null) ?? null,
|
||||
thangsExpiresAt: session.token ? session.expiresAt : sameAccount ? (current?.thangsExpiresAt ?? null) : null,
|
||||
thangsUserId: session.userId,
|
||||
thangsRejectedAt: null,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function markThangsSessionRejected(userId: string): Promise<void> {
|
||||
await prisma.user.update({ where: { id: userId }, data: { thangsRejectedAt: new Date() } });
|
||||
}
|
||||
|
||||
export type ThangsConnection = "connected" | "disconnected" | "expired";
|
||||
|
||||
export async function getThangsConnection(userId: string): Promise<ThangsConnection> {
|
||||
const user = await prisma.user.findUnique({ where: { id: userId }, select: SESSION_SELECT });
|
||||
if (!user?.thangsToken && !user?.thangsRefreshToken) return "disconnected";
|
||||
return user.thangsRejectedAt ? "expired" : "connected";
|
||||
}
|
||||
|
||||
/** The Thangs account the user connected, whose likes are theirs; null when none is. */
|
||||
export async function getUserThangsId(userId: string): Promise<string | null> {
|
||||
const user = await prisma.user.findUnique({ where: { id: userId }, select: SESSION_SELECT });
|
||||
if (!user?.thangsUserId || user.thangsRejectedAt) return null;
|
||||
return user.thangsToken || user.thangsRefreshToken ? user.thangsUserId : null;
|
||||
}
|
||||
|
||||
// One renewal at a time per user, however many imports ask at once.
|
||||
const renewing = new Map<string, Promise<string>>();
|
||||
|
||||
/** A new access token from the stored refresh token, saved. Throws ThangsAuthError (and remembers it) when Thangs turns
|
||||
* the refresh token down, or there's none to renew with. */
|
||||
function renewToken(userId: string, refreshToken: string | null): Promise<string> {
|
||||
const running = renewing.get(userId);
|
||||
if (running) return running;
|
||||
const run = (async () => {
|
||||
if (!refreshToken) {
|
||||
await markThangsSessionRejected(userId);
|
||||
throw new ThangsAuthError();
|
||||
}
|
||||
try {
|
||||
const { token, expiresAt } = await refreshThangsToken(refreshToken);
|
||||
await prisma.user.update({ where: { id: userId }, data: { thangsToken: token, thangsExpiresAt: expiresAt } });
|
||||
log.info("access token renewed", { user: userId });
|
||||
return token;
|
||||
} catch (err) {
|
||||
if (err instanceof ThangsAuthError) {
|
||||
log.warn("refresh token turned down", { user: userId });
|
||||
await markThangsSessionRejected(userId);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
})().finally(() => renewing.delete(userId));
|
||||
renewing.set(userId, run);
|
||||
return run;
|
||||
}
|
||||
|
||||
/** The user's access token, renewed first when it's out or nearly; null when they haven't connected Thangs or it
|
||||
* turned them down. Throws ThangsAuthError when the renewal is turned down. */
|
||||
export async function getUserThangsToken(userId: string): Promise<string | null> {
|
||||
const user = await prisma.user.findUnique({ where: { id: userId }, select: SESSION_SELECT });
|
||||
if (!user || user.thangsRejectedAt || (!user.thangsToken && !user.thangsRefreshToken)) return null;
|
||||
const fresh = user.thangsExpiresAt && user.thangsExpiresAt.getTime() - Date.now() > RENEW_BEFORE_MS;
|
||||
if (user.thangsToken && (fresh || !user.thangsRefreshToken)) return user.thangsToken;
|
||||
return renewToken(userId, user.thangsRefreshToken);
|
||||
}
|
||||
|
||||
/** Runs `call` with the user's access token; when Thangs turns it down, renews it and tries once more. Throws
|
||||
* ThangsAuthError once the sign-in itself is turned down, and remembers it. `token` must come from
|
||||
* getUserThangsToken. */
|
||||
export async function withThangsToken<T>(
|
||||
userId: string,
|
||||
token: string,
|
||||
call: (token: string) => Promise<T>,
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await call(token);
|
||||
} catch (err) {
|
||||
if (!(err instanceof ThangsAuthError)) throw err;
|
||||
const user = await prisma.user.findUnique({ where: { id: userId }, select: SESSION_SELECT });
|
||||
// Renewed by a concurrent call meanwhile: that one's token is tried instead.
|
||||
const renewed =
|
||||
user?.thangsToken && user.thangsToken !== token
|
||||
? user.thangsToken
|
||||
: await renewToken(userId, user?.thangsRefreshToken ?? null);
|
||||
try {
|
||||
return await call(renewed);
|
||||
} catch (retryErr) {
|
||||
if (retryErr instanceof ThangsAuthError) await markThangsSessionRejected(userId);
|
||||
throw retryErr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Shown wherever a Thangs download needs a sign-in the user hasn't given, or that ran out. */
|
||||
export const THANGS_CONNECT =
|
||||
"Thangs only gives its files to a signed-in user: visit thangs.com signed in with Thingport Grab, " +
|
||||
"or connect it in Configuration > Providers, then import it again.";
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Impit } from "impit";
|
||||
|
||||
// Thingiverse's and MyMiniFactory's Cloudflare challenge clients by their TLS handshake, whatever the
|
||||
// Thingiverse's, MyMiniFactory's and Thangs' Cloudflare challenge clients by their TLS handshake, whatever the
|
||||
// headers or cookies, and Node's fetch is on its list. impit sends Chrome's handshake and headers.
|
||||
const following = new Impit({ browser: "chrome", timeout: null });
|
||||
const manual = new Impit({ browser: "chrome", timeout: null, followRedirects: false });
|
||||
@@ -11,7 +11,9 @@ const NULL_BODY_STATUSES = new Set([204, 205, 304]);
|
||||
export function needsBrowserFetch(url: string): boolean {
|
||||
try {
|
||||
const host = new URL(url).hostname.toLowerCase();
|
||||
return ["thingiverse.com", "myminifactory.com"].some((domain) => host === domain || host.endsWith(`.${domain}`));
|
||||
return ["thingiverse.com", "myminifactory.com", "thangs.com"].some(
|
||||
(domain) => host === domain || host.endsWith(`.${domain}`),
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -23,11 +25,20 @@ export function needsBrowserFetch(url: string): boolean {
|
||||
export async function browserFetch(
|
||||
url: string,
|
||||
{
|
||||
method = "GET",
|
||||
body,
|
||||
headers = {},
|
||||
timeoutMs,
|
||||
signal,
|
||||
redirect = "follow",
|
||||
}: { headers?: Record<string, string>; timeoutMs?: number; signal?: AbortSignal; redirect?: "follow" | "manual" },
|
||||
}: {
|
||||
method?: "GET" | "POST";
|
||||
body?: string;
|
||||
headers?: Record<string, string>;
|
||||
timeoutMs?: number;
|
||||
signal?: AbortSignal;
|
||||
redirect?: "follow" | "manual";
|
||||
},
|
||||
): Promise<Response> {
|
||||
const sent = Object.fromEntries(Object.entries(headers).filter(([name]) => name.toLowerCase() !== "user-agent"));
|
||||
const controller = new AbortController();
|
||||
@@ -36,10 +47,10 @@ export async function browserFetch(
|
||||
const timeout = timeoutMs ? setTimeout(onAbort, timeoutMs) : undefined;
|
||||
try {
|
||||
const client = redirect === "follow" ? following : manual;
|
||||
const res = await client.fetch(url, { headers: sent, signal: controller.signal });
|
||||
const res = await client.fetch(url, { method, body, headers: sent, signal: controller.signal });
|
||||
// A standard Response, so callers and helpers typed for fetch's take it as is.
|
||||
const body = NULL_BODY_STATUSES.has(res.status) ? null : res.body;
|
||||
const out = new Response(body, { status: res.status, statusText: res.statusText, headers: res.headers });
|
||||
const resBody = NULL_BODY_STATUSES.has(res.status) ? null : res.body;
|
||||
const out = new Response(resBody, { status: res.status, statusText: res.statusText, headers: res.headers });
|
||||
Object.defineProperty(out, "url", { value: res.url || url });
|
||||
return out;
|
||||
} finally {
|
||||
|
||||
@@ -39,6 +39,7 @@ afterAll(async () => {
|
||||
"makeronline",
|
||||
"crealitycloud",
|
||||
"nexprint",
|
||||
"thangs",
|
||||
] as const)
|
||||
await setProviderEnabled(provider, true);
|
||||
});
|
||||
@@ -56,6 +57,7 @@ describe("turning providers off", () => {
|
||||
makeronline: true,
|
||||
crealitycloud: true,
|
||||
nexprint: true,
|
||||
thangs: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
@@ -76,6 +78,7 @@ describe("turning providers off", () => {
|
||||
makeronline: true,
|
||||
crealitycloud: true,
|
||||
nexprint: true,
|
||||
thangs: true,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+10
-2
@@ -53,9 +53,17 @@ vi.mock("../src/utils/browserFetch", async (importOriginal) => ({
|
||||
browserFetch: (
|
||||
url: string,
|
||||
{
|
||||
method,
|
||||
body,
|
||||
headers,
|
||||
signal,
|
||||
redirect,
|
||||
}: { headers?: Record<string, string>; signal?: AbortSignal; redirect?: RequestRedirect },
|
||||
) => fetch(url, { headers, signal, redirect }),
|
||||
}: {
|
||||
method?: string;
|
||||
body?: string;
|
||||
headers?: Record<string, string>;
|
||||
signal?: AbortSignal;
|
||||
redirect?: RequestRedirect;
|
||||
},
|
||||
) => fetch(url, { method, body, headers, signal, redirect }),
|
||||
}));
|
||||
@@ -0,0 +1,372 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
decodeThangsJwt,
|
||||
fetchThangsCollection,
|
||||
fetchThangsDownload,
|
||||
fetchThangsLikes,
|
||||
isThangsFileHost,
|
||||
isThangsLikesUrl,
|
||||
listThangsCollections,
|
||||
parseThangsCollectionUrl,
|
||||
parseThangsModelUrl,
|
||||
refreshThangsToken,
|
||||
resolveThangsModel,
|
||||
ThangsAuthError,
|
||||
ThangsBlockedError,
|
||||
ThangsDownloadLimitError,
|
||||
thangsMetaFromModel,
|
||||
ThangsPrivateCollectionError,
|
||||
} from "../src/services/thangsApi";
|
||||
import { modelIdFromUrl, modelUrlFor, parseSyncSource } from "../src/services/collectionSyncService";
|
||||
import { normalizeThangsSession } from "../src/services/thangsSessionService";
|
||||
import { buildImportSourceUrl, identifySourceModel } from "../src/services/importService";
|
||||
|
||||
// The Thangs client against the shapes its own site gets back, with fetch mocked. No database.
|
||||
|
||||
function jwt(payload: Record<string, unknown>): string {
|
||||
return `eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.${Buffer.from(JSON.stringify(payload)).toString("base64url")}.c2ln`;
|
||||
}
|
||||
|
||||
const EXP = 1792695884;
|
||||
const ACCESS = jwt({ iss: "www.thangs.com", sub: "6509374", exp: EXP, username: "maker" });
|
||||
const REFRESH = jwt({ iss: "www.thangs.com/api/auth/refresh-tokens", sub: "6509374", exp: 1804314692 });
|
||||
|
||||
function json(data: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(data), { status, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
|
||||
function text(body: string, status: number): Response {
|
||||
return new Response(body, { status, headers: { "content-type": "text/plain; charset=utf-8" } });
|
||||
}
|
||||
|
||||
type Call = { path: string; query: URLSearchParams; method: string; headers: Record<string, string> };
|
||||
|
||||
/** Answers each call by its path; records what was sent. */
|
||||
function stubApi(answers: Record<string, (call: Call) => Response>) {
|
||||
const calls: Call[] = [];
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: URL | string, init: RequestInit) => {
|
||||
const parsed = new URL(url);
|
||||
const call: Call = {
|
||||
path: parsed.pathname.replace(/^\/api/, ""),
|
||||
query: parsed.searchParams,
|
||||
method: init.method ?? "GET",
|
||||
headers: (init.headers ?? {}) as Record<string, string>,
|
||||
};
|
||||
calls.push(call);
|
||||
const answer = answers[call.path];
|
||||
if (!answer) throw new Error(`unexpected call ${call.path}`);
|
||||
return answer(call);
|
||||
}),
|
||||
);
|
||||
return calls;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("Thangs URLs", () => {
|
||||
it("recognizes model pages, their subpages and short links", () => {
|
||||
expect(
|
||||
parseThangsModelUrl("https://thangs.com/designer/MultiBuild/3d-model/10x10%20MU%20Top%20Edge%20-%20Tile-1320962"),
|
||||
).toEqual({ modelId: "1320962" });
|
||||
expect(
|
||||
parseThangsModelUrl(
|
||||
"https://thangs.com/prints/designer/Bathsheba/3d-model/Quintrino%20Pendant-1582292/3d-print/57288",
|
||||
),
|
||||
).toEqual({ modelId: "1582292" });
|
||||
expect(parseThangsModelUrl("https://thangs.com/designer/x/3d-model/1320962")).toEqual({ modelId: "1320962" });
|
||||
expect(parseThangsModelUrl("https://www.thangs.com/m/1137615")).toEqual({ modelId: "1137615" });
|
||||
expect(parseThangsModelUrl("https://thangs.com/designer/MultiBuild")).toBeNull();
|
||||
expect(parseThangsModelUrl("https://thangs.co/m/1137615")).toBeNull();
|
||||
});
|
||||
|
||||
it("identifies imported models and links back to them by their short link", () => {
|
||||
const source = identifySourceModel("https://thangs.com/designer/a/3d-model/Dragon-1060509/image/7");
|
||||
expect(source).toEqual({ provider: "thangs", externalId: "1060509" });
|
||||
expect(buildImportSourceUrl("thangs", "1060509")).toBe("https://thangs.com/m/1060509");
|
||||
expect(modelIdFromUrl("thangs", modelUrlFor("thangs", "1060509"))).toBe("1060509");
|
||||
});
|
||||
|
||||
it("recognizes collections and the user's likes, and syncs them", () => {
|
||||
const page = "https://thangs.com/designer/tautvydasuk/collection/Thangs%20collection-493183/sort/date?x=1";
|
||||
expect(parseThangsCollectionUrl(page)).toEqual({
|
||||
collectionId: "493183",
|
||||
url: "https://thangs.com/designer/tautvydasuk/collection/Thangs%20collection-493183",
|
||||
});
|
||||
expect(parseThangsCollectionUrl("https://thangs.com/mythangs/collections/493183")?.collectionId).toBe("493183");
|
||||
expect(parseSyncSource(page)).toEqual({
|
||||
provider: "thangs",
|
||||
externalId: "493183",
|
||||
url: "https://thangs.com/designer/tautvydasuk/collection/Thangs%20collection-493183",
|
||||
});
|
||||
expect(isThangsLikesUrl("https://thangs.com/mythangs/liked-models")).toBe(true);
|
||||
expect(parseSyncSource("https://thangs.com/mythangs/liked-models")).toEqual({
|
||||
provider: "thangs",
|
||||
externalId: "likes",
|
||||
url: "https://thangs.com/mythangs/liked-models",
|
||||
});
|
||||
});
|
||||
|
||||
it("only takes files from Google Cloud Storage", () => {
|
||||
expect(isThangsFileHost("storage.googleapis.com")).toBe(true);
|
||||
expect(isThangsFileHost("storage.googleapis.com.evil.net")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Thangs sign-in", () => {
|
||||
it("reads who a token is for and whether it renews others", () => {
|
||||
expect(decodeThangsJwt(ACCESS)).toEqual({
|
||||
userId: "6509374",
|
||||
username: "maker",
|
||||
expiresAt: new Date(EXP * 1000),
|
||||
refresh: false,
|
||||
});
|
||||
expect(decodeThangsJwt(REFRESH)?.refresh).toBe(true);
|
||||
expect(decodeThangsJwt(jwt({ iss: "example.com", sub: "1" }))).toBeNull();
|
||||
expect(decodeThangsJwt("not.a.token")).toBeNull();
|
||||
});
|
||||
|
||||
it("takes the tokens from a Cookie header, an Authorization header or on their own", () => {
|
||||
expect(
|
||||
normalizeThangsSession(`Cookie: fp_val=x; refreshToken=${REFRESH}; ld_context=default; accessToken=${ACCESS}`),
|
||||
).toEqual({
|
||||
token: ACCESS,
|
||||
refreshToken: REFRESH,
|
||||
userId: "6509374",
|
||||
expiresAt: new Date(EXP * 1000),
|
||||
});
|
||||
expect(normalizeThangsSession(`authorization\nBearer ${ACCESS}`)).toMatchObject({
|
||||
token: ACCESS,
|
||||
refreshToken: null,
|
||||
});
|
||||
expect(normalizeThangsSession(REFRESH)).toMatchObject({ token: null, refreshToken: REFRESH, expiresAt: null });
|
||||
// Two accounts' tokens can't be told apart from a mix-up.
|
||||
expect(normalizeThangsSession(`${ACCESS} ${jwt({ iss: "www.thangs.com", sub: "1", exp: EXP })}`)).toBeNull();
|
||||
expect(normalizeThangsSession("accessToken=undefined")).toBeNull();
|
||||
});
|
||||
|
||||
it("renews the access token with the refresh token as a cookie", async () => {
|
||||
const calls = stubApi({
|
||||
"/auth/v2/refresh-tokens": () => json({ token: { expires: EXP * 1000, TOKEN: ACCESS, user: { id: "6509374" } } }),
|
||||
});
|
||||
expect(await refreshThangsToken(REFRESH)).toEqual({ token: ACCESS, expiresAt: new Date(EXP * 1000) });
|
||||
expect(calls[0].method).toBe("POST");
|
||||
expect(calls[0].headers).toMatchObject({ Cookie: `refreshToken=${REFRESH}` });
|
||||
|
||||
stubApi({ "/auth/v2/refresh-tokens": () => text("Unauthorized", 401) });
|
||||
await expect(refreshThangsToken(REFRESH)).rejects.toBeInstanceOf(ThangsAuthError);
|
||||
});
|
||||
});
|
||||
|
||||
const MODEL = {
|
||||
id: "1060509",
|
||||
name: "Crystal Dragon",
|
||||
description: "**Print-in-place** dragon",
|
||||
tags: ["dragon", "articulated", "dragon"],
|
||||
category: "Toys & Games/Articulated",
|
||||
v3Categories: ["Art & Decor/Sculptures & Statues", "Toys & Games/Articulated"],
|
||||
secondaryCategory: "",
|
||||
visibility: "public",
|
||||
marketplaceInfo: null,
|
||||
owner: {
|
||||
id: 3457741,
|
||||
username: "Cinderwing3D",
|
||||
profile: {
|
||||
avatarUrl: "https://storage.googleapis.com/production-thangs-public/uploads/avatars/a",
|
||||
description: "",
|
||||
},
|
||||
},
|
||||
attachments: [
|
||||
{
|
||||
attachmentType: "image",
|
||||
isApproved: true,
|
||||
imageUrl: "https://storage.googleapis.com/p/uploads/attachments/1/Main%201.png",
|
||||
},
|
||||
{ attachmentType: "video", imageUrl: "https://storage.googleapis.com/p/uploads/attachments/2/clip.mp4" },
|
||||
{
|
||||
attachmentType: "image",
|
||||
isNsfwFlagged: true,
|
||||
imageUrl: "https://storage.googleapis.com/p/uploads/attachments/3/x.png",
|
||||
},
|
||||
],
|
||||
parts: [{ thumbnailUrl: "https://storage.googleapis.com/thangs-thumbnails/production/part.png" }],
|
||||
};
|
||||
|
||||
describe("Thangs model details", () => {
|
||||
it("maps the details, its categories most specific first and the designer", () => {
|
||||
const resolved = thangsMetaFromModel(MODEL)!;
|
||||
expect(resolved.modelId).toBe("1060509");
|
||||
expect(resolved.access).toBe("free");
|
||||
expect(resolved.meta.title).toBe("Crystal Dragon");
|
||||
expect(resolved.meta.description).toBe("**Print-in-place** dragon");
|
||||
expect(resolved.meta.tags).toEqual(["dragon", "articulated"]);
|
||||
expect(resolved.galleryImages).toEqual([
|
||||
{ url: "https://storage.googleapis.com/p/uploads/attachments/1/Main%201.png", filename: "Main 1.png" },
|
||||
]);
|
||||
expect(resolved.meta.siteCategoryIds).toEqual([
|
||||
"Toys & Games/Articulated",
|
||||
"Art & Decor/Sculptures & Statues",
|
||||
"Toys & Games",
|
||||
"Art & Decor",
|
||||
]);
|
||||
expect(resolved.meta.categorySite).toBe("thangs");
|
||||
expect(resolved.meta.author).toMatchObject({
|
||||
provider: "thangs",
|
||||
externalId: "3457741",
|
||||
name: "Cinderwing3D",
|
||||
links: ["https://thangs.com/designer/Cinderwing3D"],
|
||||
});
|
||||
});
|
||||
|
||||
it("tells paid, membership and print-only models apart", () => {
|
||||
const paid = thangsMetaFromModel({
|
||||
...MODEL,
|
||||
visibility: "market-paid",
|
||||
marketplaceInfo: { priceInUSD: 4, stripePriceId: "price_x" },
|
||||
})!;
|
||||
expect([paid.access, paid.price]).toEqual(["paid", 4]);
|
||||
expect(thangsMetaFromModel({ ...MODEL, visibility: "paid" })?.access).toBe("membership");
|
||||
expect(thangsMetaFromModel({ ...MODEL, visibility: "print-only" })?.access).toBe("printOnly");
|
||||
// Without photos, the parts' renders stand in.
|
||||
expect(thangsMetaFromModel({ ...MODEL, attachments: [] })?.meta.previewImageUrl).toBe(
|
||||
"https://storage.googleapis.com/thangs-thumbnails/production/part.png",
|
||||
);
|
||||
});
|
||||
|
||||
it("reads a model by its id, and one that isn't there as missing", async () => {
|
||||
const calls = stubApi({ "/models/v2/1060509": () => json(MODEL) });
|
||||
expect((await resolveThangsModel("1060509"))?.meta.title).toBe("Crystal Dragon");
|
||||
expect(calls[0].headers.Authorization).toBeUndefined();
|
||||
|
||||
// Thangs answers a missing or hidden model with a plain-text 403.
|
||||
stubApi({ "/models/v2/999": () => text("Forbidden", 403) });
|
||||
expect(await resolveThangsModel("999")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Thangs downloads", () => {
|
||||
const SIGNED =
|
||||
"https://storage.googleapis.com/gcp-and-physna.appspot.com/uploads/models/a/b/Crystal%20Dragon.stl?X-Goog-Signature=s";
|
||||
|
||||
it("gets the user's signed link, named with the file's extension", async () => {
|
||||
const calls = stubApi({
|
||||
"/v2/models/1060509/download-url": () =>
|
||||
json({ fileName: "Crystal Dragon", signedUrl: SIGNED, modelSource: 0, downloadId: 1 }),
|
||||
});
|
||||
expect(await fetchThangsDownload("1060509", ACCESS)).toEqual({ name: "Crystal Dragon.stl", url: SIGNED });
|
||||
expect(calls[0].headers).toMatchObject({ Authorization: `Bearer ${ACCESS}` });
|
||||
});
|
||||
|
||||
it("tells a refused model, the daily limit, a rejected sign-in and a block apart", async () => {
|
||||
stubApi({ "/v2/models/1582292/download-url": () => text("Forbidden", 403) });
|
||||
expect(await fetchThangsDownload("1582292", ACCESS)).toBeNull();
|
||||
|
||||
stubApi({ "/v2/models/1/download-url": () => json({ userDownloadData: { downloadLimitReached: true } }) });
|
||||
await expect(fetchThangsDownload("1", ACCESS)).rejects.toBeInstanceOf(ThangsDownloadLimitError);
|
||||
|
||||
stubApi({ "/v2/models/1/download-url": () => text("Unauthorized", 401) });
|
||||
await expect(fetchThangsDownload("1", ACCESS)).rejects.toBeInstanceOf(ThangsAuthError);
|
||||
|
||||
stubApi({
|
||||
"/v2/models/1/download-url": () =>
|
||||
new Response("<html>Just a moment...</html>", {
|
||||
status: 403,
|
||||
headers: { "content-type": "text/html", "cf-mitigated": "challenge" },
|
||||
}),
|
||||
});
|
||||
await expect(fetchThangsDownload("1", ACCESS)).rejects.toBeInstanceOf(ThangsBlockedError);
|
||||
});
|
||||
});
|
||||
|
||||
const item = (id: string, extra: Record<string, unknown> = {}) => ({
|
||||
id,
|
||||
name: `Model ${id}`,
|
||||
visibility: "public",
|
||||
attachments: [{ attachmentType: "image", imageUrl: `https://storage.googleapis.com/p/${id}.png` }],
|
||||
...extra,
|
||||
});
|
||||
|
||||
describe("Thangs listings", () => {
|
||||
it("lists a user's likes, leaving out print-only and paid models they don't own", async () => {
|
||||
stubApi({
|
||||
"/users/v4/6509374/likes": () =>
|
||||
json({
|
||||
models: [
|
||||
item("1"),
|
||||
item("2", { visibility: "print-only" }),
|
||||
item("3", { visibility: "market-paid", marketplaceInfo: { priceInUSD: 4 } }),
|
||||
item("4", { visibility: "market-paid", marketplaceInfo: { priceInUSD: 2 } }),
|
||||
item("1"),
|
||||
],
|
||||
folders: [],
|
||||
}),
|
||||
});
|
||||
const likes = await fetchThangsLikes("6509374", new Set(["4"]));
|
||||
expect(likes.entries.map((entry) => entry.modelId)).toEqual(["1", "4"]);
|
||||
expect(likes.entries[0]).toEqual({
|
||||
modelId: "1",
|
||||
title: "Model 1",
|
||||
cover: "https://storage.googleapis.com/p/1.png",
|
||||
});
|
||||
});
|
||||
|
||||
it("pages through a collection, and tells a private one from a missing one", async () => {
|
||||
const calls = stubApi({
|
||||
"/collections/493183": (call) =>
|
||||
json({
|
||||
id: "493183",
|
||||
name: "Thangs collection",
|
||||
private: true,
|
||||
owner: { username: "tautvydasuk" },
|
||||
totalCount: 51,
|
||||
items:
|
||||
call.query.get("page") === "0" ? Array.from({ length: 50 }, (_, i) => item(String(i + 1))) : [item("51")],
|
||||
}),
|
||||
});
|
||||
const collection = await fetchThangsCollection("493183", ACCESS, new Set());
|
||||
expect(collection?.info).toEqual({
|
||||
id: "493183",
|
||||
name: "Thangs collection",
|
||||
owner: "tautvydasuk",
|
||||
private: true,
|
||||
modelCount: 51,
|
||||
});
|
||||
expect(collection?.entries).toHaveLength(51);
|
||||
expect(calls.map((call) => call.query.get("page"))).toEqual(["0", "1"]);
|
||||
expect(calls[0].query.get("isLoggedIn")).toBe("true");
|
||||
|
||||
stubApi({ "/collections/1": () => json({ message: "Collection is private" }, 403) });
|
||||
await expect(fetchThangsCollection("1", null, new Set())).rejects.toBeInstanceOf(ThangsPrivateCollectionError);
|
||||
|
||||
stubApi({ "/collections/2": () => json({ message: "Invalid collectionId" }, 404) });
|
||||
expect(await fetchThangsCollection("2", null, new Set())).toBeNull();
|
||||
});
|
||||
|
||||
it("lists the likes, then the user's own collections, on their owner's page", async () => {
|
||||
stubApi({
|
||||
"/users/v4/6509374/likes": () => json({ models: [item("1")], folders: [] }),
|
||||
"/collections": () =>
|
||||
json({
|
||||
items: [
|
||||
{
|
||||
id: "493186",
|
||||
name: "My Collection",
|
||||
private: true,
|
||||
default: true,
|
||||
owner: { username: "tautvydasuk" },
|
||||
image: "https://storage.googleapis.com/p/c.png",
|
||||
modelIds: ["973831"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
const cards = await listThangsCollections("6509374", ACCESS, new Set());
|
||||
expect(cards.map((card) => [card.id, card.url, card.modelCount, card.private])).toEqual([
|
||||
["likes", "https://thangs.com/mythangs/liked-models", 1, true],
|
||||
["493186", "https://thangs.com/designer/tautvydasuk/collection/My%20Collection-493186", 1, true],
|
||||
]);
|
||||
});
|
||||
});
|
||||
+37
-2
@@ -1,6 +1,6 @@
|
||||
# Provider setup
|
||||
|
||||
Importing from **Printables** works out of the box (its public API needs no credential). **MakerWorld** needs each user's own session cookie, and **Thingiverse** an App Token an admin sets once for the whole instance. **MyMiniFactory** needs an API key an admin sets once for free models; each user connects their own MyMiniFactory session only to import paid or sign-in-only models from their library. **Snapmaker Space** needs each user's own sign-in, which [Thingport Grab](../extension/README.md) keeps fresh. **MakerOnline** reads its models and public collections with nothing, but hands out files only through Thingport Grab; each user's own sign-in adds their likes and private collections. **Creality Cloud** reads its models and collections with nothing, but gives files and likes only to each user's own sign-in, which Thingport Grab sends on. **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.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -158,6 +158,41 @@ sign-in ran out, and your next visit to nexprint.com connects it again.
|
||||
4. Right-click **NexPrint** in Thingport's **Configuration > Providers**, choose **Connect** and paste it. Thingport
|
||||
checks it with NexPrint before storing it.
|
||||
|
||||
## Thangs
|
||||
|
||||
Per-user (Configuration > Providers > Thangs). Thangs' model details, categories, anyone's liked models and public
|
||||
collections need nothing; its files are given only to a signed-in user, so every Thangs import runs as you. A model
|
||||
comes in as one file, or as the zip of all its parts that Thangs builds, each part its own file in Thingport. Your own
|
||||
likes and collections, private ones included, can be imported and synced.
|
||||
|
||||
- **Daily downloads.** Free Thangs accounts download a limited number of models a day. Once yours are used up, a
|
||||
collection or likes import stops asking and says so, and a synced collection leaves the rest for its next check.
|
||||
- **Paid models.** A model for sale, or for a designer membership's members, downloads only once your account owns it:
|
||||
bought on its own or in a bundle, or included in a membership you're in. Paid models you don't own are left out of
|
||||
collection imports and syncs.
|
||||
- **Print-only models.** Some designers sell a model only as a print, and Thangs offers no files for it, so it can't be
|
||||
imported.
|
||||
|
||||
Its sign-in is two cookies: `accessToken`, a token that lasts a week and goes to its API as a Bearer token, and
|
||||
`refreshToken`, which renews it and lasts about a year. Thingport renews the access token itself, so it stays connected
|
||||
until you sign out of Thangs or the refresh token runs out.
|
||||
|
||||
### Easiest: Thingport Grab
|
||||
|
||||
With [Thingport Grab](../extension/README.md) allowed on Thangs, opening thangs.com while you're signed in connects
|
||||
your account. If Thangs turns the sign-in down, Thingport says it ran out, and your next visit to thangs.com connects it
|
||||
again.
|
||||
|
||||
### Manual: copy it from your browser
|
||||
|
||||
1. Sign in at [thangs.com](https://thangs.com).
|
||||
2. Open DevTools (`F12`, or `Cmd+Option+I` on macOS) and reload the page.
|
||||
3. On the **Network** tab, pick any request to `thangs.com/api` and copy its `Cookie` request header, which holds both
|
||||
tokens. The `refreshToken` cookie alone works too, as does the `Authorization` header, though that one lasts only a
|
||||
week.
|
||||
4. Right-click **Thangs** in Thingport's **Configuration > Providers**, choose **Connect** and paste it. Thingport checks
|
||||
it with Thangs before storing it.
|
||||
|
||||
## MakerWorld cookie
|
||||
|
||||
Per-user (Configuration > Providers > MakerWorld) -- MakerWorld has no developer API, so importing from it runs as _your own_ logged-in MakerWorld session, captured as a cookie value.
|
||||
@@ -180,7 +215,7 @@ This is your own MakerWorld login session -- don't share it, and expect to redo
|
||||
|
||||
## Turning a provider off
|
||||
|
||||
Admins choose which providers this Thingport imports from, under **Administration → Providers**. All eight are on by
|
||||
Admins choose which providers this Thingport imports from, under **Administration → Providers**. All nine are on by
|
||||
default. Turn one off and nothing new comes from it for anyone: links to it, Thingport Grab and the import queue are
|
||||
refused, its synced collections pause, and background jobs skip it. It also disappears from everyone's
|
||||
**Configuration → Providers**. Models already imported from it stay as they are, and turning it back on picks up where
|
||||
|
||||
@@ -3,20 +3,20 @@
|
||||
Every instance-wide setting lives under **Administration**, in the sidebar, with a page for each part of Thingport. The
|
||||
first account on an instance is its admin, and there's no config file to edit after the first start.
|
||||
|
||||
| Page | What it's for |
|
||||
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Overview | [System Health](../guides/system-health.md) for everything Thingport relies on, disk space used, and the update check, which says when newer Thingport images are out. |
|
||||
| Providers | Turn MakerWorld, Printables, Thingiverse, MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud or NexPrint off for everyone, and set the Thingiverse App Token and the MyMiniFactory API key. See [turning a provider off](../PROVIDER_SETUP.md#turning-a-provider-off). |
|
||||
| Storage | The folder layout on disk, reorganizing existing files, browsing the storage folder, and the [consume folder](../guides/consume-folder.md). See [plain files on your disk](storage.md). |
|
||||
| Auth | The public URL, open or invite-only registration, [single sign-on](../guides/single-sign-on.md) providers, and how long a sign-in lasts. |
|
||||
| Rendering | How card thumbnails are made, and simplifying the previews of very detailed models. See [3D previews](3d-previews.md). |
|
||||
| AI | AI models and the [AI features](../guides/ai.md): categorization and improved descriptions. |
|
||||
| Users | Everyone's accounts, roles and sign-in methods. See [multi-user](multi-user.md). |
|
||||
| Import queue | Imports waiting to run, such as links sent from Thingport Grab: start, pause, retry or clear them. |
|
||||
| Logs | What each person did, by person and date: sign-ins, imports, edits, deletions, syncs, role changes, provider switches and AI runs. |
|
||||
| Triggers | One-off maintenance jobs: link missing authors, refetch imported descriptions, or delete all of a user's models. |
|
||||
| Connections | FlareSolverr, email (SMTP) and the database, each tested before it's saved. |
|
||||
| Captcha | Where to ask for a captcha: sign-in, registration, import, and password and email changes. |
|
||||
| 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. |
|
||||
|
||||
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 and NexPrint
|
||||
# Import from MakerWorld, Printables, Thingiverse, MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud, NexPrint and Thangs
|
||||
|
||||
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 or NexPrint, 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 or Thangs, 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 eight sites (MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud and NexPrint once you allow them, with a single click in its popup), so you can
|
||||
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
|
||||
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 likes from their page, in Thingport or with Grab. MakerWorld collections are imported 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.
|
||||
- **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.
|
||||
@@ -46,7 +46,9 @@ from Thingport Grab wait in **Administration → Import queue** until they're st
|
||||
Printables works with no setup. MakerWorld needs your session cookie, and Thingiverse and MyMiniFactory a key set by an
|
||||
admin; MyMiniFactory models you bought come in once you connect your MyMiniFactory account. Snapmaker Space needs your
|
||||
own sign-in, which Thingport Grab keeps fresh; MakerOnline'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. See
|
||||
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
|
||||
[provider setup](../PROVIDER_SETUP.md). Admins can also
|
||||
[turn a provider off](../PROVIDER_SETUP.md#turning-a-provider-off) for the whole instance, and then nothing is imported
|
||||
from it.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Synced collections
|
||||
|
||||
A synced collection follows a collection on MakerWorld, Printables, Thingiverse, MyMiniFactory, MakerOnline, Creality
|
||||
Cloud or NexPrint, your MakerOnline or Creality Cloud likes, or your likes or bookmarks on Snapmaker Space. When a model is added
|
||||
Cloud, NexPrint or Thangs, your MakerOnline, Creality Cloud or Thangs 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.
|
||||
|
||||
@@ -35,7 +35,9 @@ page again.
|
||||
your sign-in, and its new models always wait for Thingport Grab (see below). Creality Cloud reads collections with
|
||||
nothing, but its files and your likes need your sign-in, which lasts a week from when you signed in there; Grab
|
||||
sends the new one on your next visit. NexPrint reads collections, private ones too, with nothing, but its files need
|
||||
your sign-in, which Grab sends on as you visit nexprint.com.
|
||||
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.
|
||||
|
||||
## Turn sync on in Thingport
|
||||
|
||||
@@ -43,13 +45,13 @@ page again.
|
||||
2. Choose **⋮ → Sync configuration**. A collection that isn't synced yet opens on **Browse**.
|
||||
3. Pick the provider. Your own collections there are listed: on MakerWorld through your cookie, private ones included;
|
||||
on Printables, Thingiverse and MyMiniFactory, the public ones of the username you give the first time; on Snapmaker
|
||||
Space, your likes and bookmarks; on MakerOnline and Creality Cloud, your likes and your collections; 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. 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 and NexPrint they're imported now, one at a time. MakerWorld rate-limits bulk imports quickly, so there only models
|
||||
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
|
||||
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.
|
||||
|
||||
@@ -72,7 +74,7 @@ read it, and its models come in on that next visit.
|
||||
1. Open the collection's page on MakerWorld, Printables, Thingiverse or MyMiniFactory (allowed once from Thingport
|
||||
Grab's popup first). A Thingiverse user's **Likes** page works too, and so does your own **Interactions** tab on
|
||||
Snapmaker Space, where the panel's switch picks your likes or bookmarks. On MakerOnline and Creality Cloud, a
|
||||
collection page or your own **Likes** page; on NexPrint, a collection open on its owner's 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.
|
||||
2. Click the Thingport Grab button in the bottom-right corner. The panel lists the models in the collection.
|
||||
3. Tick **Keep this collection in sync** (**Keep these likes in sync** on a Likes page). It's off by default.
|
||||
4. Pick the models to import now and click **Import selected** (**Start import** on MakerWorld).
|
||||
|
||||
+22
-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 and NexPrint 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 and Thangs 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.
|
||||
|
||||
@@ -36,6 +36,8 @@ the list of recent imports, which is synced between your browsers as described b
|
||||
instance, so it's only sent again when it changes.
|
||||
- **Your NexPrint sign-in (its `auth_token` cookie),** as last sent to your Thingport instance, so it's only sent again
|
||||
when it changes.
|
||||
- **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.
|
||||
- **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.
|
||||
@@ -51,7 +53,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 or NexPrint, to check
|
||||
MakerOnline, Creality Cloud, NexPrint or Thangs, 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.
|
||||
@@ -80,6 +82,10 @@ Snapmaker sign-in at id.snapmaker.com every few hours while the browser is open,
|
||||
- If you're signed in to NexPrint in this browser, and have allowed the extension there: your NexPrint sign-in (its
|
||||
`auth_token` cookie, and when it expires), when you open a NexPrint page and it changed since last sent. Your
|
||||
Thingport instance uses it to download NexPrint models.
|
||||
- If you're signed in to Thangs in this browser, and have allowed the extension there: your Thangs sign-in (its
|
||||
`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.
|
||||
- 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.
|
||||
@@ -91,7 +97,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 and NexPrint:**
|
||||
**To MakerWorld, Printables, Thingiverse, MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud, NexPrint and Thangs:**
|
||||
|
||||
- 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.
|
||||
@@ -120,6 +126,9 @@ Snapmaker sign-in at id.snapmaker.com every few hours while the browser is open,
|
||||
Cloud's public model API which print profiles the model has, the same way the page does, without your sign-in.
|
||||
- On NexPrint, once you've allowed it there: when you open the panel on a model page, the extension asks NexPrint's
|
||||
public model API which print profiles the model has, the same way the page does, without your sign-in.
|
||||
- On Thangs, once you've allowed it there: when you open the panel on a model page, the extension asks Thangs whether
|
||||
your account may download the model, with your existing Thangs sign-in, the same way the page's own Download button
|
||||
does. This doesn't download anything.
|
||||
|
||||
**The extension reads, on supported pages only:** the model's title, MakerWorld's page data for the
|
||||
model, the model links listed on collection pages, and, on Thingiverse and MyMiniFactory, your username from the
|
||||
@@ -135,16 +144,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 and NexPrint's session cookies, and reads and renews Snapmaker Space's sign-in cookies, only on their sites (see above). |
|
||||
| Downloads | Reads the link from the MakerWorld download it starts, then cancels and removes that download. |
|
||||
| Scripting | Adds the import button to MyMiniFactory's, Snapmaker Space's, MakerOnline's, Creality Cloud's and NexPrint's pages, and those of sites added later, once you allow them; on MakerOnline, also asks for file links from within the page (see above). |
|
||||
| Alarms | Renews the Snapmaker Space sign-in every few hours, once you've allowed Snapmaker Space. |
|
||||
| Access to MakerWorld, Printables and Thingiverse | Shows the import button, and MakerWorld's Download normalized button, on their pages. |
|
||||
| Access to newer model sites (optional) | Asked for one site at a time, only when you click **Enable** next to it under **Providers** in the popup (MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud, NexPrint, and sites added in later updates). You can take this access back in your browser's extension settings. |
|
||||
| Access to your Thingport instance | Requested for that one address when you set the extension up, so it can talk to your instance. |
|
||||
| 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. |
|
||||
|
||||
## Your control over your data
|
||||
|
||||
|
||||
+11
-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 and NexPrint models into your self-hosted Thingport instance without leaving the provider's site.
|
||||
MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud, NexPrint and Thangs 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.
|
||||
@@ -213,12 +213,20 @@ NexPrint gives its files only to a signed-in user. Opening nexprint.com signed i
|
||||
and sends the new token whenever the site renews it. On a collection, open on its owner's page, the panel imports the
|
||||
listed models and can keep them in sync; NexPrint has no page of liked models, so there are no likes to import.
|
||||
|
||||
### Thangs: a sign-in Thingport keeps renewed
|
||||
|
||||
Thangs gives its files only to a signed-in user. Opening thangs.com signed in connects your account to Thingport, which
|
||||
renews the sign-in itself for about a year. On a model page the panel first asks Thangs whether your account may
|
||||
download it, which costs none of your daily downloads, and says so up front for a model sold only as a print, or a paid
|
||||
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.
|
||||
|
||||
## What counts as "importable"
|
||||
|
||||
- A single model page (MakerWorld, a Thingiverse Thing, a Printables Model, a MyMiniFactory object, a Snapmaker Space,
|
||||
MakerOnline, Creality Cloud or NexPrint model) -- hidden automatically once you've already imported that exact page.
|
||||
MakerOnline, Creality Cloud, NexPrint or Thangs model) -- hidden automatically once you've already imported that exact page.
|
||||
- A MakerWorld collection, a Thingiverse Collection or Likes page, a Printables, MyMiniFactory, MakerOnline, Creality
|
||||
Cloud or NexPrint collection, your MakerOnline or Creality Cloud likes, or your own Snapmaker Space likes and bookmarks -- lets you import the listed designs in one go.
|
||||
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.
|
||||
|
||||
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 and NexPrint into the user's own self-hosted Thingport server (open source: https://github.com/TautvydasDerzinskas/Thingport). It has no servers of its own and sends nothing to the developer or any third party.
|
||||
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.
|
||||
|
||||
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. The MakerWorld and MyMiniFactory cookies and the Snapmaker, MakerOnline, Creality Cloud and NexPrint sign-ins are sent only to the user's own server; the Snapmaker refresh token is sent only to Snapmaker's own sign-in service (id.snapmaker.com), to renew the sign-in.
|
||||
- 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.
|
||||
- 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 and NexPrint, and on model sites added in later versions, once the user allows them. On MakerOnline it also runs a function in the page itself (world: MAIN) that calls the site's own download endpoint, as its Download button does: MakerOnline signs file links only for requests carrying a token from its own in-page security script.
|
||||
- host access: the three model sites; the user's own server address (requested at setup); and, optionally, newer model sites (such as MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud and NexPrint), each requested only when the user clicks "Enable" next to it under Providers in the popup, so model sites added in later versions need no new required permission. The content script is only ever registered on supported model sites.
|
||||
- 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.
|
||||
|
||||
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
|
||||
@@ -22,6 +22,7 @@ import { scheduleSnapmakerRenewal, SNAPMAKER_ALARM, syncSnapmakerSession } from
|
||||
import { importMakeronlineModel, runWaitingImports, syncMakeronlineSession } from "./makeronline";
|
||||
import { syncCrealitycloudSession } from "./crealitycloudSession";
|
||||
import { syncNexprintSession } from "./nexprintSession";
|
||||
import { syncThangsSession } from "./thangsSession";
|
||||
import { onPairingTabClosed, onPairingTabUrl, startPairing } from "./pairing";
|
||||
import { openSetup } from "./setup";
|
||||
import { setTabIconState } from "./tabIcon";
|
||||
@@ -46,6 +47,7 @@ listen<BackgroundMessages>({
|
||||
SYNC_MAKERONLINE_SESSION: () => syncMakeronlineSession(),
|
||||
SYNC_CREALITYCLOUD_SESSION: () => syncCrealitycloudSession(),
|
||||
SYNC_NEXPRINT_SESSION: () => syncNexprintSession(),
|
||||
SYNC_THANGS_SESSION: () => syncThangsSession(),
|
||||
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,45 @@
|
||||
// The user's Thangs sign-in, which downloads its files: the accessToken cookie (a week-long token, sent as a Bearer
|
||||
// token) and the refreshToken cookie that renews it. Thingport renews the access token itself, so Grab only sends them
|
||||
// on when the browser's change: a sign-in, or the site renewing its own.
|
||||
|
||||
import { STORAGE_KEYS } from "../shared/storage";
|
||||
import { apiCall, requireConfig } from "./api";
|
||||
|
||||
const SITE = "https://thangs.com";
|
||||
const JWT = /^eyJ[\w-]+\.eyJ[\w-]+\.[\w-]+$/;
|
||||
|
||||
async function cookieValue(name: string): Promise<string> {
|
||||
try {
|
||||
const cookie = await chrome.cookies.get({ url: SITE, name });
|
||||
const value = cookie?.value ? decodeURIComponent(cookie.value) : "";
|
||||
return JWT.test(value) ? value : "";
|
||||
} catch {
|
||||
return ""; // Unreadable, or malformed percent-encoding: not a token.
|
||||
}
|
||||
}
|
||||
|
||||
/** Sends the instance the browser's Thangs sign-in when it changed. Best-effort: never throws. */
|
||||
export async function syncThangsSession(): Promise<void> {
|
||||
const config = await requireConfig().catch(() => null);
|
||||
if (!config) return;
|
||||
const [token, refreshToken] = await Promise.all([cookieValue("accessToken"), cookieValue("refreshToken")]);
|
||||
// Per Thingport account, so switching the extension to another one sends it there too.
|
||||
const account = `${config.instanceUrl} ${config.email}`;
|
||||
const last = config.lastSyncedThangsSession;
|
||||
if (!token && !refreshToken) {
|
||||
// Signed out there: clear the sign-in Grab sent, but not one pasted in Thingport.
|
||||
if (last?.account === account) {
|
||||
try {
|
||||
await apiCall("PATCH", "/settings/thangs-session", { token: null });
|
||||
await chrome.storage.local.remove(STORAGE_KEYS.lastSyncedThangsSession);
|
||||
} catch {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
const value = `${token} ${refreshToken}`;
|
||||
if (last?.account === account && last.value === value) return;
|
||||
try {
|
||||
await apiCall("PATCH", "/settings/thangs-session", { token: token || null, refresh_token: refreshToken || null });
|
||||
await chrome.storage.local.set({ [STORAGE_KEYS.lastSyncedThangsSession]: { account, value } });
|
||||
} catch {}
|
||||
}
|
||||
@@ -1,5 +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, and NexPrint collections.
|
||||
// bookmarks, MakerOnline collections and likes, Creality Cloud collections and likes, NexPrint collections, and Thangs
|
||||
// collections and likes.
|
||||
// MakerWorld collections use the guided flow in
|
||||
// makerworldCollection.ts.
|
||||
|
||||
@@ -26,7 +27,11 @@ type BatchKey =
|
||||
| "makeronline:likes"
|
||||
| "crealitycloud:collection"
|
||||
| "crealitycloud:likes"
|
||||
| "nexprint:collection";
|
||||
| "nexprint:collection"
|
||||
| "thangs:collection"
|
||||
| "thangs:likes";
|
||||
|
||||
const THANGS_LIST = { entries: "/import/thangs-list/entries", start: "/import/thangs-list", idField: "model_ids" };
|
||||
|
||||
const SNAPMAKER_LIST = {
|
||||
entries: "/import/snapmaker-list/entries",
|
||||
@@ -79,6 +84,8 @@ const BATCH_ENDPOINTS: Record<BatchKey, { entries: string; start: string; idFiel
|
||||
start: "/import/nexprint-collection",
|
||||
idField: "model_ids",
|
||||
},
|
||||
"thangs:collection": THANGS_LIST,
|
||||
"thangs:likes": THANGS_LIST,
|
||||
};
|
||||
|
||||
const SNAPMAKER_LISTS: { list: SnapmakerList; label: string }[] = [
|
||||
|
||||
@@ -16,6 +16,7 @@ export const SITE_NAMES: Record<Provider, string> = {
|
||||
makeronline: "MakerOnline",
|
||||
crealitycloud: "Creality Cloud",
|
||||
nexprint: "NexPrint",
|
||||
thangs: "Thangs",
|
||||
};
|
||||
|
||||
/** The instance inspects the link: finds the file, reads its title, checks whether it's a zip. */
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Thingiverse, Printables, MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud and NexPrint import directly; other links go through
|
||||
// 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).
|
||||
|
||||
@@ -21,6 +21,7 @@ import { loadSnapmakerProfiles } from "../snapmaker";
|
||||
import { loadMakeronlineProfiles } from "../makeronline";
|
||||
import { loadCrealitycloudProfiles } from "../crealitycloud";
|
||||
import { loadNexprintProfiles } from "../nexprint";
|
||||
import { thangsDownloadBlock } from "../thangs";
|
||||
import { onPanelAction, panelQuery, panelQueryAll, renderPanel } from "../shell";
|
||||
import { collectionIdFor, collectionPickerHtml, readCollectionChoice, wireCollectionPicker } from "./collectionPicker";
|
||||
import { errorHtml, statusHtml, successHtml } from "./results";
|
||||
@@ -147,6 +148,15 @@ export async function loadSingleItem(): Promise<void> {
|
||||
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;
|
||||
if (thangsBlock) {
|
||||
renderPanel(`
|
||||
<div class="tg-title">${escapeHtml(thangsBlock.title)}</div>
|
||||
<div class="tg-hint">${escapeHtml(thangsBlock.hint)}</div>
|
||||
`);
|
||||
return;
|
||||
}
|
||||
const skipInspect =
|
||||
(provider === "thingiverse" && type === "thing") ||
|
||||
(provider === "printables" && type === "model") ||
|
||||
@@ -154,7 +164,8 @@ export async function loadSingleItem(): Promise<void> {
|
||||
(provider === "snapmaker" && type === "model") ||
|
||||
(provider === "makeronline" && type === "model") ||
|
||||
(provider === "crealitycloud" && type === "model") ||
|
||||
(provider === "nexprint" && type === "model");
|
||||
(provider === "nexprint" && type === "model") ||
|
||||
(provider === "thangs" && type === "model");
|
||||
|
||||
let zipFilename: string | null = null;
|
||||
if (skipInspect) {
|
||||
|
||||
@@ -16,6 +16,7 @@ const PROVIDER_NAMES: Record<string, string> = {
|
||||
makeronline: "MakerOnline",
|
||||
crealitycloud: "Creality Cloud",
|
||||
nexprint: "NexPrint",
|
||||
thangs: "Thangs",
|
||||
};
|
||||
|
||||
/** A Thingiverse user's Likes page, which a sync follows like a collection. */
|
||||
|
||||
@@ -99,6 +99,12 @@ export function captureProviderAccount(hostname: string): void {
|
||||
void send("SYNC_NEXPRINT_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.
|
||||
void send("SYNC_THANGS_SESSION");
|
||||
return;
|
||||
}
|
||||
if (hostname === "space.snapmaker.com") {
|
||||
captured = true;
|
||||
// Its sign-in downloads the files, and lasts a day: kept fresh by the background, and sent on.
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// Whether Thangs lets the signed-in user download a model, asked the way its own page asks. It costs none of their
|
||||
// daily downloads, so the panel can say why before an import is tried.
|
||||
|
||||
import { parseThangsModelUrl } from "../shared/urls";
|
||||
|
||||
export type ThangsBlock = { title: string; hint: string };
|
||||
|
||||
function accessToken(): string | null {
|
||||
const pair = document.cookie
|
||||
.split(";")
|
||||
.map((part) => part.trim())
|
||||
.find((part) => part.startsWith("accessToken="));
|
||||
return pair ? decodeURIComponent(pair.slice("accessToken=".length)) : null;
|
||||
}
|
||||
|
||||
/** Why the model can't be imported, or null when it can, or that can't be told (signed out there: the import says). */
|
||||
export async function thangsDownloadBlock(url: string): Promise<ThangsBlock | null> {
|
||||
const parsed = parseThangsModelUrl(url);
|
||||
const token = accessToken();
|
||||
if (!parsed || !token) return null;
|
||||
try {
|
||||
const res = await fetch(`/api/v4/models/${parsed.modelId}/can-download`, {
|
||||
headers: { Accept: "application/json", Authorization: `Bearer ${token}` },
|
||||
credentials: "omit",
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const json = (await res.json()) as { canDownload?: boolean; disabledCode?: string | null };
|
||||
if (json.canDownload !== false) return null;
|
||||
if (json.disabledCode === "PrintOnly") {
|
||||
return { title: "Sold as a print", hint: "Its designer sells it printed only: Thangs doesn't offer its files." };
|
||||
}
|
||||
return {
|
||||
title: "Paid model",
|
||||
hint: "Buy it on Thangs, or join the designer's membership that includes it. Then Thingport can import it.",
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -17,10 +17,7 @@ type Logo = {
|
||||
};
|
||||
|
||||
/** Listed so people know they're on the way; Grab does nothing on them yet. */
|
||||
const COMING_SOON = [
|
||||
{ id: "thangs", name: "Thangs" },
|
||||
{ id: "cults3d", name: "Cults3D" },
|
||||
] as const;
|
||||
const COMING_SOON = [{ id: "cults3d", name: "Cults3D" }] as const;
|
||||
|
||||
// Same glyphs as the web app's providerLogos.tsx.
|
||||
const LOGOS: Record<
|
||||
|
||||
@@ -124,6 +124,8 @@ export type BackgroundMessages = {
|
||||
SYNC_CREALITYCLOUD_SESSION: { payload: void; result: { userId: string | null } };
|
||||
/** On a NexPrint page: send the sign-in on, if it changed. */
|
||||
SYNC_NEXPRINT_SESSION: { payload: void; result: void };
|
||||
/** On a Thangs page: send the sign-in on, if it changed. */
|
||||
SYNC_THANGS_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";
|
||||
id: "myminifactory" | "snapmaker" | "makeronline" | "crealitycloud" | "nexprint" | "thangs";
|
||||
name: string;
|
||||
origins: string[];
|
||||
};
|
||||
@@ -16,6 +16,7 @@ export const OPTIONAL_SITES: readonly OptionalSite[] = [
|
||||
{ id: "makeronline", name: "MakerOnline", origins: ["*://*.makeronline.com/*"] },
|
||||
{ id: "crealitycloud", name: "Creality Cloud", origins: ["*://*.crealitycloud.com/*"] },
|
||||
{ id: "nexprint", name: "NexPrint", origins: ["*://*.nexprint.com/*"] },
|
||||
{ id: "thangs", name: "Thangs", origins: ["*://thangs.com/*", "*://*.thangs.com/*"] },
|
||||
];
|
||||
|
||||
const scriptId = (site: OptionalSite) => `site-${site.id}`;
|
||||
|
||||
@@ -28,6 +28,8 @@ export const STORAGE_KEYS = {
|
||||
lastSyncedCrealitycloudSession: "lastSyncedCrealitycloudSession",
|
||||
// The NexPrint token last sent, and to which Thingport account.
|
||||
lastSyncedNexprintSession: "lastSyncedNexprintSession",
|
||||
// The Thangs tokens last sent, and to which Thingport account.
|
||||
lastSyncedThangsSession: "lastSyncedThangsSession",
|
||||
// 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.
|
||||
@@ -55,6 +57,7 @@ export type StoredConfig = {
|
||||
lastSyncedMakeronlineToken?: { account: string; value: string };
|
||||
lastSyncedCrealitycloudSession?: { account: string; value: string };
|
||||
lastSyncedNexprintSession?: { account: string; value: string };
|
||||
lastSyncedThangsSession?: { account: string; value: string };
|
||||
/** `account` is the Thingport instance and email they were sent to. */
|
||||
lastSentProviderAccounts?: { account: string; values: Record<string, string> };
|
||||
lastPrintablesPushAt?: number;
|
||||
|
||||
@@ -8,7 +8,8 @@ export type Provider =
|
||||
| "snapmaker"
|
||||
| "makeronline"
|
||||
| "crealitycloud"
|
||||
| "nexprint";
|
||||
| "nexprint"
|
||||
| "thangs";
|
||||
|
||||
export type Classification =
|
||||
| { kind: "single"; provider: "makerworld"; type: "model" }
|
||||
@@ -19,6 +20,7 @@ export type Classification =
|
||||
| { kind: "single"; provider: "makeronline"; type: "model" }
|
||||
| { kind: "single"; provider: "crealitycloud"; type: "model" }
|
||||
| { kind: "single"; provider: "nexprint"; type: "model" }
|
||||
| { kind: "single"; provider: "thangs"; type: "model" }
|
||||
| { kind: "batch"; provider: "makerworld"; type: "collection" }
|
||||
| { kind: "batch"; provider: "thingiverse"; type: "likes" | "collection" }
|
||||
| { kind: "batch"; provider: "printables"; type: "collection" }
|
||||
@@ -26,7 +28,8 @@ export type Classification =
|
||||
| { kind: "batch"; provider: "snapmaker"; type: SnapmakerList }
|
||||
| { kind: "batch"; provider: "makeronline"; type: "collection" | "likes" }
|
||||
| { kind: "batch"; provider: "crealitycloud"; type: "collection" | "likes" }
|
||||
| { kind: "batch"; provider: "nexprint"; type: "collection" };
|
||||
| { kind: "batch"; provider: "nexprint"; type: "collection" }
|
||||
| { kind: "batch"; provider: "thangs"; type: "collection" | "likes" };
|
||||
|
||||
export type SnapmakerList = "likes" | "bookmarks";
|
||||
|
||||
@@ -248,6 +251,36 @@ export function isNexprintCollectionUrl(url: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function isThangs(parsed: URL): boolean {
|
||||
return /^(www\.)?thangs\.com$/i.test(parsed.hostname);
|
||||
}
|
||||
|
||||
/** `/designer/<user>/3d-model/<name>-<id>` (also under `/prints`, and with a subpage after it), or `/m/<id>`. */
|
||||
export function parseThangsModelUrl(url: string): { modelId: string } | null {
|
||||
const parsed = parse(url);
|
||||
if (!parsed || !isThangs(parsed)) return null;
|
||||
const m =
|
||||
parsed.pathname.match(/^\/(?:prints\/)?designer\/[^/]+\/3d-model\/(?:[^/]*-)?(\d+)(?:\/.*)?$/i) ??
|
||||
parsed.pathname.match(/^\/m\/(\d+)\/?$/i);
|
||||
return m ? { modelId: m[1] } : null;
|
||||
}
|
||||
|
||||
/** A collection: `/designer/<user>/collection/<name>-<id>`, or the owner's own `/mythangs/collections/<id>`. */
|
||||
export function isThangsCollectionUrl(url: string): boolean {
|
||||
const parsed = parse(url);
|
||||
if (!parsed || !isThangs(parsed)) return false;
|
||||
return (
|
||||
/^\/designer\/[^/]+\/collection\/(?:[^/]*-)?\d+(?:\/.*)?$/i.test(parsed.pathname) ||
|
||||
/^\/(?:mythangs\/collections|c)\/\d+\/?$/i.test(parsed.pathname)
|
||||
);
|
||||
}
|
||||
|
||||
/** The user's own liked models; the page names no user. */
|
||||
export function isThangsLikesUrl(url: string): boolean {
|
||||
const parsed = parse(url);
|
||||
return !!parsed && isThangs(parsed) && /^\/mythangs\/liked-models\/?$/i.test(parsed.pathname);
|
||||
}
|
||||
|
||||
/** 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" };
|
||||
@@ -262,6 +295,8 @@ export function classifyUrl(url: string): Classification | null {
|
||||
if (isCrealitycloudCollectionUrl(url)) return { kind: "batch", provider: "crealitycloud", type: "collection" };
|
||||
if (parseCrealitycloudLikesUrl(url)) return { kind: "batch", provider: "crealitycloud", type: "likes" };
|
||||
if (isNexprintCollectionUrl(url)) return { kind: "batch", provider: "nexprint", type: "collection" };
|
||||
if (isThangsCollectionUrl(url)) return { kind: "batch", provider: "thangs", type: "collection" };
|
||||
if (isThangsLikesUrl(url)) return { kind: "batch", provider: "thangs", 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" };
|
||||
@@ -270,5 +305,6 @@ export function classifyUrl(url: string): Classification | null {
|
||||
if (parseMakeronlineModelUrl(url)) return { kind: "single", provider: "makeronline", type: "model" };
|
||||
if (parseCrealitycloudModelUrl(url)) return { kind: "single", provider: "crealitycloud", type: "model" };
|
||||
if (parseNexprintModelUrl(url)) return { kind: "single", provider: "nexprint", type: "model" };
|
||||
if (parseThangsModelUrl(url)) return { kind: "single", provider: "thangs", type: "model" };
|
||||
return null;
|
||||
}
|
||||
@@ -23,6 +23,7 @@ export type Category = {
|
||||
makeronline_cat_ids: string;
|
||||
crealitycloud_cat_ids: string;
|
||||
nexprint_cat_ids: string;
|
||||
thangs_cat_ids: string;
|
||||
};
|
||||
|
||||
export type CategoryMetaInput = {
|
||||
@@ -36,6 +37,7 @@ export type CategoryMetaInput = {
|
||||
makeronlineCatIds: string;
|
||||
crealitycloudCatIds: string;
|
||||
nexprintCatIds: string;
|
||||
thangsCatIds: string;
|
||||
};
|
||||
|
||||
export const categoriesApi = {
|
||||
@@ -87,6 +89,7 @@ export const categoriesApi = {
|
||||
makeronline_cat_ids: meta.makeronlineCatIds,
|
||||
crealitycloud_cat_ids: meta.crealitycloudCatIds,
|
||||
nexprint_cat_ids: meta.nexprintCatIds,
|
||||
thangs_cat_ids: meta.thangsCatIds,
|
||||
}),
|
||||
});
|
||||
if (res.status === 401) throw new UnauthorizedError();
|
||||
|
||||
@@ -476,6 +476,30 @@ export const importsApi = {
|
||||
return res.json();
|
||||
},
|
||||
|
||||
/** The user's Thangs likes, or a collection, read with their Thangs sign-in. */
|
||||
listThangsListEntries: async (payload: ImportLinkPayload): Promise<ImportCollectionEntriesResult> => {
|
||||
const res = await fetch(`${apiBase()}/import/thangs-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. */
|
||||
fromThangsList: async (payload: ImportLinkPayload & { model_ids: string[] }): Promise<{ job_id: string }> => {
|
||||
const res = await fetch(`${apiBase()}/import/thangs-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, "Thangs 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 },
|
||||
|
||||
@@ -11,6 +11,7 @@ export const BROWSABLE_PROVIDERS = [
|
||||
"makeronline",
|
||||
"crealitycloud",
|
||||
"nexprint",
|
||||
"thangs",
|
||||
] as const;
|
||||
export type BrowsableProvider = (typeof BROWSABLE_PROVIDERS)[number];
|
||||
|
||||
|
||||
@@ -154,7 +154,8 @@ export type ImportProvider =
|
||||
| "snapmaker"
|
||||
| "makeronline"
|
||||
| "crealitycloud"
|
||||
| "nexprint";
|
||||
| "nexprint"
|
||||
| "thangs";
|
||||
/** An admin can turn a provider off for the whole instance; menus hide it then. */
|
||||
export type ProviderSetting = { id: ImportProvider; enabled: boolean };
|
||||
|
||||
@@ -457,6 +458,25 @@ export const settingsApi = {
|
||||
return res.json();
|
||||
},
|
||||
|
||||
// The user's own Thangs sign-in (its accessToken and refreshToken cookies), which Thingport Grab sends on. Write-only.
|
||||
getThangsSession: async (): Promise<{ configured: boolean; expired: boolean }> => {
|
||||
const res = await fetch(`${apiBase()}/settings/thangs-session`, { headers: authHeaders() });
|
||||
assertOk(res, "Failed to load Thangs settings");
|
||||
return res.json();
|
||||
},
|
||||
|
||||
/** Checked with Thangs before it's saved. Takes a token, either cookie, or a whole Cookie header. */
|
||||
updateThangsSession: async (token: string | null): Promise<{ configured: boolean }> => {
|
||||
const res = await fetch(`${apiBase()}/settings/thangs-session`, {
|
||||
method: "PATCH",
|
||||
headers: authHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify({ token, verify: true }),
|
||||
});
|
||||
if (res.status === 401) throw new UnauthorizedError();
|
||||
if (!res.ok) throw new Error(await readErrorMessage(res, "Failed to update Thangs settings"));
|
||||
return res.json();
|
||||
},
|
||||
|
||||
getAuth: async (): Promise<AuthSettings> => {
|
||||
const res = await fetch(`${apiBase()}/settings/auth`, { headers: authHeaders() });
|
||||
assertOk(res, "Failed to load session settings");
|
||||
|
||||
@@ -19,6 +19,7 @@ const LOGO_PROVIDERS = new Set<string>([
|
||||
"makeronline",
|
||||
"crealitycloud",
|
||||
"nexprint",
|
||||
"thangs",
|
||||
]);
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -21,6 +21,7 @@ const LOGO_PROVIDERS = new Set<string>([
|
||||
"makeronline",
|
||||
"crealitycloud",
|
||||
"nexprint",
|
||||
"thangs",
|
||||
]);
|
||||
|
||||
/** The provider's logo with a small green tick, slim enough for a toolbar row. Opens the
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
isCrealitycloudModelUrl,
|
||||
isNexprintCollectionUrl,
|
||||
isNexprintModelUrl,
|
||||
isThangsListUrl,
|
||||
isThingiverseCollectionUrl,
|
||||
isThingiverseLikesUrl,
|
||||
type ImportProviderKey,
|
||||
@@ -64,6 +65,7 @@ const IMPORT_PROVIDERS: ImportProviderKey[] = [
|
||||
"makeronline",
|
||||
"crealitycloud",
|
||||
"nexprint",
|
||||
"thangs",
|
||||
];
|
||||
|
||||
/** What the search palette's !import command calls. */
|
||||
@@ -133,7 +135,8 @@ const AddMenu = React.forwardRef<AddMenuHandle, Props>(function AddMenu(
|
||||
isSnapmakerListUrl(link) ||
|
||||
isMakeronlineListUrl(link) ||
|
||||
isCrealitycloudListUrl(link) ||
|
||||
isNexprintCollectionUrl(link),
|
||||
isNexprintCollectionUrl(link) ||
|
||||
isThangsListUrl(link),
|
||||
)
|
||||
: isMakerworldCollectionUrl(linkValue);
|
||||
// Models with print profiles to pick between.
|
||||
|
||||
@@ -34,6 +34,7 @@ const PROVIDERS: { id: ProviderId; name: string }[] = [
|
||||
{ id: "makeronline", name: "MakerOnline" },
|
||||
{ id: "crealitycloud", name: "Creality Cloud" },
|
||||
{ id: "nexprint", name: "NexPrint" },
|
||||
{ id: "thangs", name: "Thangs" },
|
||||
...COMING_SOON_PROVIDERS,
|
||||
];
|
||||
const isComingSoon = (id: ProviderId) => COMING_SOON_PROVIDERS.some((provider) => provider.id === id);
|
||||
@@ -43,8 +44,9 @@ 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.
|
||||
type SessionProvider = "makerworld" | "myminifactory" | "snapmaker" | "makeronline" | "crealitycloud" | "nexprint";
|
||||
// renewed by the site as the user visits it. Thangs' renews itself on the server, for about a year.
|
||||
type SessionProvider =
|
||||
"makerworld" | "myminifactory" | "snapmaker" | "makeronline" | "crealitycloud" | "nexprint" | "thangs";
|
||||
const SESSION_PROVIDERS = new Set<ProviderId>([
|
||||
"makerworld",
|
||||
"myminifactory",
|
||||
@@ -52,6 +54,7 @@ const SESSION_PROVIDERS = new Set<ProviderId>([
|
||||
"makeronline",
|
||||
"crealitycloud",
|
||||
"nexprint",
|
||||
"thangs",
|
||||
]);
|
||||
const isSessionProvider = (id: ProviderId): id is SessionProvider => SESSION_PROVIDERS.has(id);
|
||||
|
||||
@@ -75,7 +78,7 @@ export default function ProvidersPanel({ isAdmin, cookie, onUpdateMakerWorld, on
|
||||
thingiverse: null,
|
||||
printables: true,
|
||||
myminifactory: null,
|
||||
thangs: false,
|
||||
thangs: null,
|
||||
cults3d: false,
|
||||
makeronline: null,
|
||||
crealitycloud: null,
|
||||
@@ -90,6 +93,7 @@ export default function ProvidersPanel({ isAdmin, cookie, onUpdateMakerWorld, on
|
||||
makeronline: false,
|
||||
crealitycloud: false,
|
||||
nexprint: false,
|
||||
thangs: false,
|
||||
});
|
||||
const isExpired = (id: ProviderId) => isSessionProvider(id) && Boolean(connected[id]) && expired[id];
|
||||
const [connecting, setConnecting] = React.useState<ProviderId | null>(null);
|
||||
@@ -150,6 +154,14 @@ export default function ProvidersPanel({ isAdmin, cookie, onUpdateMakerWorld, on
|
||||
setExpired((current) => ({ ...current, nexprint: res.expired }));
|
||||
})
|
||||
.catch(() => undefined);
|
||||
settingsApi
|
||||
.getThangsSession()
|
||||
.then((res) => {
|
||||
if (!active) return;
|
||||
setConnected((current) => ({ ...current, thangs: res.configured }));
|
||||
setExpired((current) => ({ ...current, thangs: res.expired }));
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
@@ -194,6 +206,10 @@ export default function ProvidersPanel({ isAdmin, cookie, onUpdateMakerWorld, on
|
||||
const result = await settingsApi.updateNexprintSession(value);
|
||||
setConnected((current) => ({ ...current, nexprint: result.configured }));
|
||||
setExpired((current) => ({ ...current, nexprint: false }));
|
||||
} else if (id === "thangs") {
|
||||
const result = await settingsApi.updateThangsSession(value);
|
||||
setConnected((current) => ({ ...current, thangs: result.configured }));
|
||||
setExpired((current) => ({ ...current, thangs: false }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -394,8 +410,9 @@ function ConnectBox({ id, name, onCancel, onConnect, onUnauthorized }: ConnectBo
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const steps = t(`providers.${id}.steps`, { returnObjects: true }) as string[];
|
||||
// MakerWorld's cookie is long; Creality Cloud's sign-in may be pasted as two copied request headers.
|
||||
const multiline = id === "makerworld" || id === "crealitycloud";
|
||||
// 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";
|
||||
const rootRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// It opens below the icons, often past the bottom of the panel.
|
||||
|
||||
@@ -16,10 +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: "thangs", name: "Thangs" },
|
||||
{ id: "cults3d", name: "Cults3D" },
|
||||
];
|
||||
export const COMING_SOON_PROVIDERS: { id: ProviderId; name: string }[] = [{ id: "cults3d", name: "Cults3D" }];
|
||||
|
||||
type Logo = {
|
||||
/** Defaults to 0 0 24 24, Simple Icons' box. */
|
||||
|
||||
@@ -88,6 +88,7 @@ export const SEARCH_PAGES: SearchPage[] = [
|
||||
"makeronline",
|
||||
"creality cloud",
|
||||
"nexprint",
|
||||
"thangs",
|
||||
"app token",
|
||||
"api key",
|
||||
"access token",
|
||||
|
||||
@@ -17,6 +17,7 @@ type StartCrealitycloudProfilesImportPayload = Parameters<typeof importsApi.from
|
||||
type StartCrealitycloudListImportPayload = Parameters<typeof importsApi.fromCrealitycloudList>[0];
|
||||
type StartNexprintProfilesImportPayload = Parameters<typeof importsApi.fromNexprintProfiles>[0];
|
||||
type StartNexprintCollectionImportPayload = Parameters<typeof importsApi.fromNexprintCollection>[0];
|
||||
type StartThangsListImportPayload = Parameters<typeof importsApi.fromThangsList>[0];
|
||||
type StartLinksImportPayload = Parameters<typeof importsApi.fromLinks>[0];
|
||||
|
||||
type ImportJobContextValue = {
|
||||
@@ -36,6 +37,7 @@ type ImportJobContextValue = {
|
||||
startCrealitycloudListImport: (payload: StartCrealitycloudListImportPayload) => Promise<void>;
|
||||
startNexprintProfilesImport: (payload: StartNexprintProfilesImportPayload) => Promise<void>;
|
||||
startNexprintCollectionImport: (payload: StartNexprintCollectionImportPayload) => Promise<void>;
|
||||
startThangsListImport: (payload: StartThangsListImportPayload) => Promise<void>;
|
||||
startLinksImport: (payload: StartLinksImportPayload) => Promise<void>;
|
||||
/** Reruns the links that failed on the finished job shown in the progress bar. */
|
||||
retryJob: () => Promise<void>;
|
||||
@@ -263,6 +265,16 @@ export function ImportJobProvider({
|
||||
[startPolling],
|
||||
);
|
||||
|
||||
const startThangsListImport = useCallback(
|
||||
async (payload: StartThangsListImportPayload) => {
|
||||
const { job_id } = await importsApi.fromThangsList(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);
|
||||
@@ -303,6 +315,7 @@ export function ImportJobProvider({
|
||||
startCrealitycloudListImport,
|
||||
startNexprintProfilesImport,
|
||||
startNexprintCollectionImport,
|
||||
startThangsListImport,
|
||||
startLinksImport,
|
||||
retryJob,
|
||||
dismissJob,
|
||||
@@ -322,6 +335,7 @@ export function ImportJobProvider({
|
||||
startCrealitycloudListImport,
|
||||
startNexprintProfilesImport,
|
||||
startNexprintCollectionImport,
|
||||
startThangsListImport,
|
||||
startLinksImport,
|
||||
retryJob,
|
||||
dismissJob,
|
||||
|
||||
@@ -33,7 +33,8 @@ type ServiceChipDef = {
|
||||
| "snapmaker"
|
||||
| "makeronline"
|
||||
| "crealitycloud"
|
||||
| "nexprint";
|
||||
| "nexprint"
|
||||
| "thangs";
|
||||
label: string;
|
||||
connected: boolean;
|
||||
/** Explains what's missing when not connected. */
|
||||
@@ -66,6 +67,8 @@ export function UserMenu({ user, onOpenConfiguration, onOpenProfile, onLogout }:
|
||||
const [crealitycloudExpired, setCrealitycloudExpired] = React.useState(false);
|
||||
const [nexprintConfigured, setNexprintConfigured] = React.useState(false);
|
||||
const [nexprintExpired, setNexprintExpired] = React.useState(false);
|
||||
const [thangsConfigured, setThangsConfigured] = React.useState(false);
|
||||
const [thangsExpired, setThangsExpired] = React.useState(false);
|
||||
|
||||
const menuOpen = Boolean(anchorEl);
|
||||
// Checked on every open: the cookie can change in Configuration while the menu stays mounted.
|
||||
@@ -124,6 +127,14 @@ export function UserMenu({ user, onOpenConfiguration, onOpenProfile, onLogout }:
|
||||
setNexprintExpired(res.expired);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
settingsApi
|
||||
.getThangsSession()
|
||||
.then((res) => {
|
||||
if (!active) return;
|
||||
setThangsConfigured(res.configured);
|
||||
setThangsExpired(res.expired);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
@@ -179,6 +190,12 @@ export function UserMenu({ user, onOpenConfiguration, onOpenProfile, onLogout }:
|
||||
connected: nexprintConfigured && !nexprintExpired,
|
||||
disabledReason: nexprintExpired ? t("providers.nexprint.expired") : t("userMenu.nexprintDisabledReason"),
|
||||
},
|
||||
{
|
||||
key: "thangs",
|
||||
...IMPORT_PROVIDER_INFO.thangs,
|
||||
connected: thangsConfigured && !thangsExpired,
|
||||
disabledReason: thangsExpired ? t("providers.thangs.expired") : t("userMenu.thangsDisabledReason"),
|
||||
},
|
||||
];
|
||||
// Turned off by an admin: no icon. Coming-soon sites follow the live ones.
|
||||
const services: { id: ProviderId; label: string; connected: boolean; tooltip: string }[] = [
|
||||
|
||||
@@ -35,6 +35,8 @@ import {
|
||||
isCrealitycloudModelUrl,
|
||||
isNexprintCollectionUrl,
|
||||
isNexprintModelUrl,
|
||||
isThangsListUrl,
|
||||
isThangsModelUrl,
|
||||
isThingiverseCollectionUrl,
|
||||
isThingiverseLikesUrl,
|
||||
isThingiverseThingUrl,
|
||||
@@ -78,6 +80,7 @@ export function useUploadImport({ onUploaded, categoryId, makerworldCookie, onUn
|
||||
startCrealitycloudListImport,
|
||||
startNexprintProfilesImport,
|
||||
startNexprintCollectionImport,
|
||||
startThangsListImport,
|
||||
startLinksImport,
|
||||
} = useImportJob();
|
||||
const isBusy = uploading || importing || zipPrompt.isOpen || collectionPrompt.isOpen || importModePrompt.isOpen;
|
||||
@@ -453,6 +456,33 @@ export function useUploadImport({ onUploaded, categoryId, makerworldCookie, onUn
|
||||
return;
|
||||
}
|
||||
|
||||
if (isThangsListUrl(url)) {
|
||||
setImporting(false);
|
||||
await collectionPrompt.prompt({
|
||||
label: url,
|
||||
loadEntries: async () => {
|
||||
try {
|
||||
return await importsApi.listThangsListEntries(payload);
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedError) onUnauthorized?.();
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
onImportSelected: async (modelIds: string[]) => {
|
||||
try {
|
||||
await startThangsListImport({ ...payload, model_ids: modelIds });
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedError) {
|
||||
onUnauthorized?.();
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCrealitycloudListUrl(url)) {
|
||||
setImporting(false);
|
||||
await collectionPrompt.prompt({
|
||||
@@ -540,7 +570,8 @@ export function useUploadImport({ onUploaded, categoryId, makerworldCookie, onUn
|
||||
isMyminifactoryObjectUrl(url) ||
|
||||
isSnapmakerModelUrl(url) ||
|
||||
isCrealitycloudModelUrl(url) ||
|
||||
isNexprintModelUrl(url)
|
||||
isNexprintModelUrl(url) ||
|
||||
isThangsModelUrl(url)
|
||||
) {
|
||||
// The backend splits these into plates itself.
|
||||
const imported = await importsApi.fromLink(payload);
|
||||
|
||||
@@ -24,6 +24,8 @@ export const IMPORT_PROVIDER_INFO: Record<string, ImportProviderInfo> = {
|
||||
},
|
||||
crealitycloud: { label: "Creality Cloud", color: "#1AC686" },
|
||||
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" },
|
||||
// Direct uploads (source_provider is null).
|
||||
thingport: { label: "Thingport", color: "common.white", textColor: "common.black", outlined: true },
|
||||
};
|
||||
|
||||
@@ -29,6 +29,7 @@ const ALL: readonly ImportProvider[] = [
|
||||
"makeronline",
|
||||
"crealitycloud",
|
||||
"nexprint",
|
||||
"thangs",
|
||||
];
|
||||
|
||||
/** With every provider off there's nothing to import from or connect to, so those parts of the app hide. */
|
||||
|
||||
@@ -276,6 +276,7 @@
|
||||
"makeronlineDisabledReason": "MakerOnline: connect your account in Configuration → Providers",
|
||||
"crealitycloudDisabledReason": "Creality Cloud: connect your account in Configuration → Providers",
|
||||
"nexprintDisabledReason": "NexPrint: connect your account in Configuration → Providers",
|
||||
"thangsDisabledReason": "Thangs: connect your account in Configuration → Providers",
|
||||
"myModels": "My models"
|
||||
},
|
||||
"notifications": {
|
||||
@@ -542,7 +543,8 @@
|
||||
"snapmaker": "Models with their print profiles, and each user's likes and bookmarks, through each user's own Snapmaker account.",
|
||||
"makeronline": "Models with their print profiles, collections and each user's likes. Files come through Thingport Grab: MakerOnline gives them only to its own pages.",
|
||||
"crealitycloud": "Models with their print profiles, collections and each user's likes, through each user's own Creality Cloud account.",
|
||||
"nexprint": "Models with their print profiles, and collections, through each user's own NexPrint 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."
|
||||
},
|
||||
"keyStatus": {
|
||||
"thingiverse": {
|
||||
@@ -1661,6 +1663,17 @@
|
||||
"expired": "Sign-in expired. Sign in on nexprint.com with Thingport Grab, or connect NexPrint again in Configuration → Providers.",
|
||||
"expiredStatus": "Sign-in expired"
|
||||
},
|
||||
"thangs": {
|
||||
"placeholder": "The accessToken or refreshToken cookie, or the whole Cookie header",
|
||||
"steps": [
|
||||
"Easiest: with Thingport Grab, just open thangs.com while signed in. It connects your account, and Thingport keeps it connected for about a year.",
|
||||
"Or by hand: sign in at thangs.com, open your browser's developer tools (F12) and reload the page.",
|
||||
"Under Network, pick any request to thangs.com/api, copy its Cookie request header (it holds both tokens) and paste it below."
|
||||
],
|
||||
"notConnected": "Thangs gives its files to signed-in users only. Connect your account to import its models.",
|
||||
"expired": "Sign-in expired. Sign in on thangs.com with Thingport Grab, or connect Thangs again in Configuration → Providers.",
|
||||
"expiredStatus": "Sign-in expired"
|
||||
},
|
||||
"printables": {
|
||||
"alwaysConnected": "Printables is connected by default: it needs no account or key."
|
||||
},
|
||||
@@ -2633,7 +2646,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 or NexPrint collection link.",
|
||||
"notALink": "Paste a MakerWorld, Printables, Thingiverse, MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud, NexPrint or Thangs collection link.",
|
||||
"usernameFailed": "Couldn't save the username.",
|
||||
"openProviders": "Open Providers",
|
||||
"notConnected": {
|
||||
@@ -2644,7 +2657,8 @@
|
||||
"snapmaker": "Connect Snapmaker Space in Configuration > Providers, or visit it with Thingport Grab, to see your likes and bookmarks here.",
|
||||
"makeronline": "Connect MakerOnline in Configuration > Providers, or visit it with Thingport Grab, to see your likes and collections here.",
|
||||
"crealitycloud": "Connect Creality Cloud in Configuration > Providers, or visit it with Thingport Grab, to see your likes and collections here.",
|
||||
"nexprint": "Connect NexPrint in Configuration > Providers, or visit it with Thingport Grab, to see your 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."
|
||||
},
|
||||
"usernameLabel": "Your {{provider}} username",
|
||||
"usernameHint": {
|
||||
@@ -2764,6 +2778,9 @@
|
||||
"makeronlineCatIdLabel": "MakerOnline category IDs",
|
||||
"crealitycloudCatIdLabel": "Creality Cloud category IDs",
|
||||
"nexprintCatIdLabel": "NexPrint category IDs",
|
||||
"thangsCatIdLabel": "Thangs category paths",
|
||||
"thangsCatPathsPlaceholder": "Toys & Games/Puzzles;Toys & Games",
|
||||
"thangsCatPathsHelp": "Thangs names categories rather than numbering them: separate paths with ;",
|
||||
"catIdsPlaceholder": "e.g. 800;71;1001",
|
||||
"catIdsHelp": "Separate multiple IDs with \";\" -- matches if an import's category is any of these",
|
||||
"dragToMove": "Drag to move"
|
||||
|
||||
@@ -277,6 +277,7 @@
|
||||
"makeronlineDisabledReason": "MakerOnline: prijunkite paskyrą skiltyje Konfigūracija → Šaltiniai",
|
||||
"crealitycloudDisabledReason": "Creality Cloud: prijunkite paskyrą skiltyje Konfigūracija → Šaltiniai",
|
||||
"nexprintDisabledReason": "NexPrint: prijunkite paskyrą skiltyje Konfigūracija → Šaltiniai",
|
||||
"thangsDisabledReason": "Thangs: prijunkite paskyrą skiltyje Konfigūracija → Šaltiniai",
|
||||
"myModels": "Mano modeliai"
|
||||
},
|
||||
"notifications": {
|
||||
@@ -543,7 +544,8 @@
|
||||
"snapmaker": "Modeliai su spausdinimo profiliais bei kiekvieno naudotojo pamėgti ir pažymėti modeliai, per kiekvieno naudotojo Snapmaker paskyrą.",
|
||||
"makeronline": "Modeliai su spausdinimo profiliais, kolekcijos ir kiekvieno naudotojo pamėgti modeliai. Failai atkeliauja per Thingport Grab: MakerOnline juos duoda tik savo puslapiams.",
|
||||
"crealitycloud": "Modeliai su spausdinimo profiliais, kolekcijos ir kiekvieno naudotojo pamėgti modeliai, per kiekvieno naudotojo Creality Cloud paskyrą.",
|
||||
"nexprint": "Modeliai su spausdinimo profiliais ir kolekcijos, per kiekvieno naudotojo NexPrint 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ą."
|
||||
},
|
||||
"keyStatus": {
|
||||
"thingiverse": {
|
||||
@@ -1717,6 +1719,17 @@
|
||||
"expired": "Prisijungimo galiojimas baigėsi. Prisijunkite nexprint.com su Thingport Grab arba vėl prijunkite NexPrint skiltyje Konfigūracija → Šaltiniai.",
|
||||
"expiredStatus": "Prisijungimas nebegalioja"
|
||||
},
|
||||
"thangs": {
|
||||
"placeholder": "Slapukas accessToken arba refreshToken, arba visa Cookie antraštė",
|
||||
"steps": [
|
||||
"Paprasčiausia: su Thingport Grab tiesiog atverkite thangs.com prisijungę. Jis prijungs jūsų paskyrą, o Thingport palaikys ją prijungtą maždaug metus.",
|
||||
"Arba rankiniu būdu: prisijunkite prie thangs.com, atverkite naršyklės kūrėjo įrankius (F12) ir perkraukite puslapį.",
|
||||
"Skiltyje Network pasirinkite bet kurią užklausą į thangs.com/api, nukopijuokite jos Cookie antraštę (joje yra abu raktai) ir įklijuokite žemiau."
|
||||
],
|
||||
"notConnected": "Thangs failus duoda tik prisijungusiems naudotojams. Prijunkite paskyrą, kad galėtumėte importuoti jo modelius.",
|
||||
"expired": "Prisijungimo galiojimas baigėsi. Prisijunkite thangs.com su Thingport Grab arba vėl prijunkite Thangs skiltyje Konfigūracija → Šaltiniai.",
|
||||
"expiredStatus": "Prisijungimas nebegalioja"
|
||||
},
|
||||
"printables": {
|
||||
"alwaysConnected": "Printables prijungta iš karto: nereikia paskyros ar rakto."
|
||||
},
|
||||
@@ -2704,7 +2717,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 arba NexPrint kolekcijos nuorodą.",
|
||||
"notALink": "Įklijuokite MakerWorld, Printables, Thingiverse, MyMiniFactory, Snapmaker Space, MakerOnline, Creality Cloud, NexPrint arba Thangs kolekcijos nuorodą.",
|
||||
"usernameFailed": "Nepavyko išsaugoti vartotojo vardo.",
|
||||
"openProviders": "Atidaryti tiekėjus",
|
||||
"notConnected": {
|
||||
@@ -2715,7 +2728,8 @@
|
||||
"snapmaker": "Prijunkite Snapmaker Space per Konfigūracija > Šaltiniai arba atverkite jį su Thingport Grab, kad čia matytumėte savo pamėgtus ir pažymėtus modelius.",
|
||||
"makeronline": "Prijunkite MakerOnline per Konfigūracija > Šaltiniai arba atverkite jį su Thingport Grab, kad čia matytumėte savo pamėgtus modelius ir kolekcijas.",
|
||||
"crealitycloud": "Prijunkite Creality Cloud per Konfigūracija > Šaltiniai arba atverkite jį su Thingport Grab, kad čia matytumėte savo pamėgtus modelius ir kolekcijas.",
|
||||
"nexprint": "Prijunkite NexPrint per Konfigūracija > Šaltiniai arba atverkite jį su Thingport Grab, kad čia matytumėte savo 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."
|
||||
},
|
||||
"usernameLabel": "Jūsų {{provider}} vartotojo vardas",
|
||||
"usernameHint": {
|
||||
@@ -2839,6 +2853,9 @@
|
||||
"makeronlineCatIdLabel": "MakerOnline kategorijų ID",
|
||||
"crealitycloudCatIdLabel": "Creality Cloud kategorijų ID",
|
||||
"nexprintCatIdLabel": "NexPrint kategorijų ID",
|
||||
"thangsCatIdLabel": "Thangs kategorijų keliai",
|
||||
"thangsCatPathsPlaceholder": "Toys & Games/Puzzles;Toys & Games",
|
||||
"thangsCatPathsHelp": "Thangs kategorijas įvardija, o ne numeruoja: kelius atskirkite ;",
|
||||
"catIdsPlaceholder": "pvz. 800;71;1001",
|
||||
"catIdsHelp": "Kelis ID atskirkite „;“ -- atitiks, jei importo kategorija sutampa su bet kuriuo iš jų",
|
||||
"dragToMove": "Vilkite, kad perkeltumėte"
|
||||
|
||||
@@ -36,6 +36,7 @@ const NAMES: Record<ImportProvider, string> = {
|
||||
makeronline: "MakerOnline",
|
||||
crealitycloud: "Creality Cloud",
|
||||
nexprint: "NexPrint",
|
||||
thangs: "Thangs",
|
||||
};
|
||||
|
||||
const KEYED = new Set<ImportProvider>(["thingiverse", "myminifactory"]);
|
||||
@@ -128,6 +129,7 @@ export default function ProvidersPage({ onUnauthorized }: Props) {
|
||||
"makeronline",
|
||||
"crealitycloud",
|
||||
"nexprint",
|
||||
"thangs",
|
||||
] as const
|
||||
).map((id) => ({
|
||||
id,
|
||||
|
||||
@@ -20,6 +20,7 @@ export const PROVIDER_ACCENTS: Record<BrowsableProvider, string> = {
|
||||
makeronline: "#1F7AE0",
|
||||
crealitycloud: "#1AC686",
|
||||
nexprint: "#7A55E3",
|
||||
thangs: "#FFBC00",
|
||||
};
|
||||
|
||||
type Props = {
|
||||
@@ -381,6 +382,48 @@ function NexprintTile({ collection }: { collection: ProviderCollection }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Thangs: its one cover, the count in a pill of its yellow over the corner, the name below. */
|
||||
function ThangsTile({ collection }: { collection: ProviderCollection }) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: "8px",
|
||||
border: 1,
|
||||
borderColor: "divider",
|
||||
bgcolor: "background.paper",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ position: "relative" }}>
|
||||
<Thumb src={collection.thumbnails[0]} sx={{ aspectRatio: "4 / 3" }} />
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
left: 8,
|
||||
px: 1,
|
||||
py: 0.25,
|
||||
borderRadius: "999px",
|
||||
bgcolor: PROVIDER_ACCENTS.thangs,
|
||||
// Its yellow is light in both themes.
|
||||
color: "common.black",
|
||||
fontSize: "0.7rem",
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
<ModelCount count={collection.model_count} provider="thangs" />
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5, px: 1.25, py: 1 }}>
|
||||
<Typography noWrap sx={{ flex: 1, fontWeight: 700, fontSize: "0.875rem" }}>
|
||||
{collection.name}
|
||||
</Typography>
|
||||
{collection.private && <LockOutlinedIcon sx={{ fontSize: 14, color: "text.secondary" }} />}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const TILES: Record<BrowsableProvider, (props: { collection: ProviderCollection }) => JSX.Element> = {
|
||||
makerworld: MakerWorldTile,
|
||||
printables: PrintablesTile,
|
||||
@@ -390,16 +433,18 @@ const TILES: Record<BrowsableProvider, (props: { collection: ProviderCollection
|
||||
makeronline: MakeronlineTile,
|
||||
crealitycloud: CrealitycloudTile,
|
||||
nexprint: NexprintTile,
|
||||
thangs: ThangsTile,
|
||||
};
|
||||
|
||||
/** The name to show: a user's likes, and Snapmaker's likes and bookmarks, come named in English by the instance, as
|
||||
* do MakerOnline's and Creality Cloud's likes. */
|
||||
* do MakerOnline's, Creality Cloud's and Thangs' likes. */
|
||||
function displayName(provider: BrowsableProvider, collection: ProviderCollection, t: (key: string) => string): string {
|
||||
if (collection.likes) return t("collections.sync.browse.likes");
|
||||
if (provider === "snapmaker" && collection.id.startsWith("likes-")) return t("collections.sync.browse.likes");
|
||||
if (provider === "snapmaker" && collection.id.startsWith("bookmarks-")) return t("collections.sync.browse.bookmarks");
|
||||
if (provider === "makeronline" && collection.id === "likes") return t("collections.sync.browse.likes");
|
||||
if (provider === "crealitycloud" && collection.id.startsWith("likes-")) return t("collections.sync.browse.likes");
|
||||
if (provider === "thangs" && collection.id === "likes") return t("collections.sync.browse.likes");
|
||||
return collection.name;
|
||||
}
|
||||
|
||||
@@ -514,6 +559,16 @@ export function ProviderCollectionTileSkeleton({ provider }: { provider: Browsab
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (provider === "thangs") {
|
||||
return (
|
||||
<Box aria-hidden sx={{ borderRadius: "8px", border: 1, borderColor: "divider", overflow: "hidden" }}>
|
||||
<Skeleton variant="rectangular" sx={{ width: "100%", height: "auto", aspectRatio: "4 / 3" }} />
|
||||
<Box sx={{ px: 1.25, py: 1 }}>
|
||||
<Skeleton variant="text" sx={{ fontSize: "0.875rem", width: "70%" }} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (provider === "nexprint") {
|
||||
return (
|
||||
<Box aria-hidden sx={{ borderRadius: "10px", overflow: "hidden" }}>
|
||||
|
||||
@@ -49,10 +49,18 @@ const PASTE_EXAMPLES: Record<BrowsableProvider, string> = {
|
||||
makeronline: "https://www.makeronline.com/en/user/collectionDetail/…/….html",
|
||||
crealitycloud: "https://www.crealitycloud.com/collection/…",
|
||||
nexprint: "https://www.nexprint.com/en/…/home?component=UserCollect&subComp=UserCollectDetail&collectionId=G…",
|
||||
thangs: "https://thangs.com/designer/…/collection/…",
|
||||
};
|
||||
|
||||
// Listed with the user's own sign-in, which already says who they are, so no username is asked for.
|
||||
const SIGNED_IN = new Set<BrowsableProvider>(["makerworld", "snapmaker", "makeronline", "crealitycloud", "nexprint"]);
|
||||
const SIGNED_IN = new Set<BrowsableProvider>([
|
||||
"makerworld",
|
||||
"snapmaker",
|
||||
"makeronline",
|
||||
"crealitycloud",
|
||||
"nexprint",
|
||||
"thangs",
|
||||
]);
|
||||
|
||||
/** The one the user's slicer goes with, unless it isn't connected; then the first that is. Only from `available`,
|
||||
* the ones an admin hasn't turned off. */
|
||||
|
||||
@@ -26,6 +26,7 @@ const LOGOS = new Set<string>([
|
||||
"makeronline",
|
||||
"crealitycloud",
|
||||
"nexprint",
|
||||
"thangs",
|
||||
]);
|
||||
|
||||
// The pulse is 40% of its segment wide, so 250% starts it past the right end and -100% ends it
|
||||
|
||||
@@ -166,6 +166,7 @@ export default function LogsPage({ onUnauthorized }: Props) {
|
||||
makeronline: "MakerOnline",
|
||||
crealitycloud: "Creality Cloud",
|
||||
nexprint: "NexPrint",
|
||||
thangs: "Thangs",
|
||||
};
|
||||
const provider = typeof d.provider === "string" ? (names[d.provider] ?? d.provider) : "";
|
||||
return t(d.enabled ? "adminSettings.logs.providerOn" : "adminSettings.logs.providerOff", { provider });
|
||||
|
||||
@@ -75,7 +75,8 @@ function hasMeta(category: Category): boolean {
|
||||
category.snapmaker_cat_ids ||
|
||||
category.makeronline_cat_ids ||
|
||||
category.crealitycloud_cat_ids ||
|
||||
category.nexprint_cat_ids,
|
||||
category.nexprint_cat_ids ||
|
||||
category.thangs_cat_ids,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ export default function CategoryMetaDialog({ category, showSiteCatIds, onClose,
|
||||
const [makeronlineCatIds, setMakeronlineCatIds] = useState(category.makeronline_cat_ids);
|
||||
const [crealitycloudCatIds, setCrealitycloudCatIds] = useState(category.crealitycloud_cat_ids);
|
||||
const [nexprintCatIds, setNexprintCatIds] = useState(category.nexprint_cat_ids);
|
||||
const [thangsCatIds, setThangsCatIds] = useState(category.thangs_cat_ids);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -53,6 +54,7 @@ export default function CategoryMetaDialog({ category, showSiteCatIds, onClose,
|
||||
makeronlineCatIds,
|
||||
crealitycloudCatIds,
|
||||
nexprintCatIds,
|
||||
thangsCatIds,
|
||||
});
|
||||
onClose();
|
||||
} catch (err) {
|
||||
@@ -162,6 +164,15 @@ export default function CategoryMetaDialog({ category, showSiteCatIds, onClose,
|
||||
onChange={(e) => setNexprintCatIds(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
<TextField
|
||||
label={t("models:categories.manager.thangsCatIdLabel")}
|
||||
placeholder={t("models:categories.manager.thangsCatPathsPlaceholder") ?? undefined}
|
||||
helperText={t("models:categories.manager.thangsCatPathsHelp")}
|
||||
fullWidth
|
||||
value={thangsCatIds}
|
||||
onChange={(e) => setThangsCatIds(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{error && <Alert severity="error">{error}</Alert>}
|
||||
|
||||
@@ -8,7 +8,8 @@ export type ImportProviderKey =
|
||||
| "snapmaker"
|
||||
| "makeronline"
|
||||
| "crealitycloud"
|
||||
| "nexprint";
|
||||
| "nexprint"
|
||||
| "thangs";
|
||||
|
||||
function parseUrl(url: string): URL | null {
|
||||
try {
|
||||
@@ -30,6 +31,7 @@ export function detectImportProvider(url: string): ImportProviderKey | null {
|
||||
if (host === "makeronline.com" || host === "www.makeronline.com") return "makeronline";
|
||||
if (host === "crealitycloud.com" || host === "www.crealitycloud.com") return "crealitycloud";
|
||||
if (host === "nexprint.com" || host === "www.nexprint.com") return "nexprint";
|
||||
if (host === "thangs.com" || host === "www.thangs.com") return "thangs";
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -192,6 +194,33 @@ export function isNexprintCollectionUrl(url: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function isThangsHost(parsed: URL): boolean {
|
||||
return /^(www\.)?thangs\.com$/i.test(parsed.hostname);
|
||||
}
|
||||
|
||||
/** See isThingiverseThingUrl. `/designer/<user>/3d-model/<name>-<id>` (also under `/prints`, and with a subpage), or
|
||||
* `/m/<id>`. */
|
||||
export function isThangsModelUrl(url: string): boolean {
|
||||
const parsed = parseUrl(url);
|
||||
if (!parsed || !isThangsHost(parsed)) return false;
|
||||
return (
|
||||
/^\/(?:prints\/)?designer\/[^/]+\/3d-model\/(?:[^/]*-)?\d+(?:\/.*)?$/i.test(parsed.pathname) ||
|
||||
/^\/m\/\d+\/?$/i.test(parsed.pathname)
|
||||
);
|
||||
}
|
||||
|
||||
/** A Thangs collection (`/designer/<user>/collection/<name>-<id>`, `/mythangs/collections/<id>`) or the user's own
|
||||
* likes (`/mythangs/liked-models`). */
|
||||
export function isThangsListUrl(url: string): boolean {
|
||||
const parsed = parseUrl(url);
|
||||
if (!parsed || !isThangsHost(parsed)) return false;
|
||||
return (
|
||||
/^\/designer\/[^/]+\/collection\/(?:[^/]*-)?\d+(?:\/.*)?$/i.test(parsed.pathname) ||
|
||||
/^\/(?:mythangs\/collections|c)\/\d+\/?$/i.test(parsed.pathname) ||
|
||||
/^\/mythangs\/liked-models\/?$/i.test(parsed.pathname)
|
||||
);
|
||||
}
|
||||
|
||||
export const IMPORT_LINK_EXAMPLES: Record<ImportProviderKey, { model: string; collection: string }> = {
|
||||
makerworld: {
|
||||
model: "https://makerworld.com/en/models/123456-example-model",
|
||||
@@ -225,4 +254,8 @@ export const IMPORT_LINK_EXAMPLES: Record<ImportProviderKey, { model: string; co
|
||||
model: "https://www.nexprint.com/en/models/G1234567",
|
||||
collection: "https://www.nexprint.com/en/collection/G12345678",
|
||||
},
|
||||
thangs: {
|
||||
model: "https://thangs.com/designer/username/3d-model/Example%20Model-1234567",
|
||||
collection: "https://thangs.com/designer/username/collection/Example%20Collection-123456",
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user