mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-22 14:42:29 -05:00
feat(providers): enable Snapmaker Space provider
This commit is contained in:
1 parent
3f0b511908
commit
ac5bf253c7
72 files changed
+2524
-216
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 and MyMiniFactory](docs/features/importing.md)**
|
||||
- **[Import from MakerWorld, Printables, Thingiverse, MyMiniFactory and Snapmaker Space](docs/features/importing.md)**
|
||||
- **[Synced collections](docs/guides/synced-collections.md)**
|
||||
- **[Bring your existing library](docs/guides/existing-library.md)**
|
||||
- **[Consume folder](docs/guides/consume-folder.md)**
|
||||
@@ -306,11 +306,11 @@ To have Thingport import whatever you drop into a folder on the server, uncommen
|
||||
Thingport ships two small companion tools, each downloadable from the in-app Download page or GitHub Releases:
|
||||
|
||||
- **[Thingport Bridge](bridge/README.md)** — a lightweight desktop helper that makes "Open in {Slicer}" work for slicers (Bambu Studio, PrusaSlicer, Cura, Anycubic Slicer Next) whose own URL-protocol handlers won't accept a link from a self-hosted domain.
|
||||
- **[Thingport Grab](extension/README.md)** — a browser extension for Chrome, Edge and Firefox that imports MakerWorld, Thingiverse, Printables and MyMiniFactory 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 and Snapmaker Space 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. 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. 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,88 @@
|
||||
-- Snapmaker Space as a fifth provider: its site category ids on categories; and the user's Snapmaker login, which
|
||||
-- Thingport Grab keeps fresh and sends here (Snapmaker's refresh tokens work once, so Thingport never refreshes it).
|
||||
ALTER TABLE "Category" ADD COLUMN "snapmakerCatIds" INTEGER[] NOT NULL DEFAULT '{}';
|
||||
CREATE INDEX "Category_snapmakerCatIds_idx" ON "Category" USING GIN ("snapmakerCatIds");
|
||||
|
||||
ALTER TABLE "User" ADD COLUMN "snapmakerToken" TEXT,
|
||||
ADD COLUMN "snapmakerTokenExpiresAt" TIMESTAMP(3),
|
||||
ADD COLUMN "snapmakerUserId" TEXT,
|
||||
ADD COLUMN "snapmakerRejectedAt" TIMESTAMP(3);
|
||||
|
||||
-- Adds Snapmaker's category ids to the starter categories of users who signed up before they were in
|
||||
-- seedData/defaultCategories.ts, so their Snapmaker imports are categorized too. A starter category is found by its
|
||||
-- name, and a subcategory by its top-level parent's name as well; one a user renamed, moved or deleted is skipped,
|
||||
-- and ids a user already set are kept.
|
||||
UPDATE "Category" AS c
|
||||
SET "snapmakerCatIds" = m.ids
|
||||
FROM (VALUES
|
||||
(NULL::text, '3D Printer', ARRAY[103]),
|
||||
('3D Printer', '3D Printer Accessories', ARRAY[116, 115, 117, 118]),
|
||||
('3D Printer', 'Test Models', ARRAY[114]),
|
||||
(NULL::text, 'Art', ARRAY[104]),
|
||||
('Art', '2D Art', ARRAY[119]),
|
||||
('Art', 'Sculptures', ARRAY[121]),
|
||||
('Art', 'Other Art Models', ARRAY[120, 183]),
|
||||
(NULL::text, 'Education', ARRAY[106]),
|
||||
('Education', 'Biology', ARRAY[129]),
|
||||
('Education', 'Chemistry', ARRAY[128]),
|
||||
('Education', 'Engineering', ARRAY[132]),
|
||||
('Education', 'Geography', ARRAY[130]),
|
||||
('Education', 'Mathematics', ARRAY[126]),
|
||||
('Education', 'Physics & Astronomy', ARRAY[131, 127]),
|
||||
('Education', 'Other Education Models', ARRAY[133]),
|
||||
(NULL::text, 'Fashion', ARRAY[107]),
|
||||
('Fashion', 'Bags', ARRAY[135]),
|
||||
('Fashion', 'Clothes', ARRAY[182]),
|
||||
('Fashion', 'Footwear', ARRAY[137]),
|
||||
('Fashion', 'Jewelry', ARRAY[134]),
|
||||
('Fashion', 'Other Fashion Models', ARRAY[136, 138]),
|
||||
(NULL::text, 'Hobby & DIY', ARRAY[109]),
|
||||
('Hobby & DIY', 'Electronics', ARRAY[148]),
|
||||
('Hobby & DIY', 'Music', ARRAY[152]),
|
||||
('Hobby & DIY', 'RC', ARRAY[150]),
|
||||
('Hobby & DIY', 'Robotics', ARRAY[149]),
|
||||
('Hobby & DIY', 'Sport & Outdoors', ARRAY[154]),
|
||||
('Hobby & DIY', 'Vehicles', ARRAY[153]),
|
||||
('Hobby & DIY', 'Other Hobby & DIY', ARRAY[151, 155]),
|
||||
(NULL::text, 'Household', ARRAY[108]),
|
||||
('Household', 'Decor', ARRAY[139]),
|
||||
('Household', 'Festivities', ARRAY[140]),
|
||||
('Household', 'Garden', ARRAY[146]),
|
||||
('Household', 'Office', ARRAY[143]),
|
||||
('Household', 'Pets', ARRAY[147]),
|
||||
('Household', 'Other Household Models', ARRAY[142, 144, 145, 184]),
|
||||
(NULL::text, 'Miniatures', ARRAY[110]),
|
||||
('Miniatures', 'Animals', ARRAY[157]),
|
||||
('Miniatures', 'Architecture', ARRAY[158]),
|
||||
('Miniatures', 'Creatures', ARRAY[159]),
|
||||
('Miniatures', 'People', ARRAY[156]),
|
||||
('Miniatures', 'Other Miniatures', ARRAY[160]),
|
||||
(NULL::text, 'Props & Cosplays', ARRAY[105]),
|
||||
('Props & Cosplays', 'Costumes', ARRAY[123]),
|
||||
('Props & Cosplays', 'Masks & Helmets', ARRAY[122]),
|
||||
('Props & Cosplays', 'Cosplay Weapons', ARRAY[124]),
|
||||
('Props & Cosplays', 'Other Props & Cosplays', ARRAY[125]),
|
||||
(NULL::text, 'Tools', ARRAY[111]),
|
||||
('Tools', 'Hand Tools', ARRAY[161]),
|
||||
('Tools', 'Machine Tools', ARRAY[165]),
|
||||
('Tools', 'Measure Tools', ARRAY[162]),
|
||||
('Tools', 'Organizers', ARRAY[141, 163]),
|
||||
('Tools', 'Other Tools', ARRAY[164, 166]),
|
||||
(NULL::text, 'Toys & Games', ARRAY[112]),
|
||||
('Toys & Games', 'Board Games', ARRAY[172]),
|
||||
('Toys & Games', 'Characters', ARRAY[171]),
|
||||
('Toys & Games', 'Outdoor Toys', ARRAY[173]),
|
||||
('Toys & Games', 'Puzzles', ARRAY[169]),
|
||||
('Toys & Games', 'Construction Sets', ARRAY[170]),
|
||||
('Toys & Games', 'Other Toys & Games', ARRAY[167, 168, 174, 175])
|
||||
) AS m(parent_name, name, ids)
|
||||
WHERE c."kind" = 'category'
|
||||
AND c."name" = m.name
|
||||
AND cardinality(c."snapmakerCatIds") = 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
|
||||
)
|
||||
);
|
||||
@@ -70,6 +70,14 @@ model User {
|
||||
myminifactoryCookie String?
|
||||
// When MyMiniFactory last turned the session down.
|
||||
myminifactoryRejectedAt DateTime?
|
||||
// The user's Snapmaker access token (a JWT, valid a day). Snapmaker's refresh tokens work once, so Thingport never
|
||||
// refreshes it itself: Thingport Grab keeps the browser's login fresh and sends each new one here.
|
||||
snapmakerToken String?
|
||||
snapmakerTokenExpiresAt DateTime?
|
||||
// Their Snapmaker user id, from the token: their likes and bookmarks are synced under it.
|
||||
snapmakerUserId String?
|
||||
// When Snapmaker last turned the token down.
|
||||
snapmakerRejectedAt DateTime?
|
||||
slicer String?
|
||||
// "light" | "dark" | "system"; null means never set.
|
||||
themePreference String?
|
||||
@@ -141,6 +149,7 @@ model Category {
|
||||
thingiverseCatIds Int[] @default([])
|
||||
printablesCatIds Int[] @default([])
|
||||
myminifactoryCatIds Int[] @default([])
|
||||
snapmakerCatIds Int[] @default([])
|
||||
prints Print[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -151,6 +160,7 @@ model Category {
|
||||
@@index([thingiverseCatIds], type: Gin)
|
||||
@@index([printablesCatIds], type: Gin)
|
||||
@@index([myminifactoryCatIds], type: Gin)
|
||||
@@index([snapmakerCatIds], type: Gin)
|
||||
}
|
||||
|
||||
model Print {
|
||||
|
||||
@@ -179,6 +179,7 @@ export type CategoryOut = {
|
||||
thingiverse_cat_ids: string;
|
||||
printables_cat_ids: string;
|
||||
myminifactory_cat_ids: string;
|
||||
snapmaker_cat_ids: string;
|
||||
};
|
||||
|
||||
function plateThumbUrl(plateId: string): string | null {
|
||||
@@ -473,6 +474,7 @@ export function toCategoryOut(category: Category): CategoryOut {
|
||||
thingiverse_cat_ids: formatCatIds(category.thingiverseCatIds),
|
||||
printables_cat_ids: formatCatIds(category.printablesCatIds),
|
||||
myminifactory_cat_ids: formatCatIds(category.myminifactoryCatIds),
|
||||
snapmaker_cat_ids: formatCatIds(category.snapmakerCatIds),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -191,6 +191,7 @@ const categoryMetaSchema = z.object({
|
||||
thingiverse_cat_ids: catIdsField,
|
||||
printables_cat_ids: catIdsField,
|
||||
myminifactory_cat_ids: catIdsField,
|
||||
snapmaker_cat_ids: catIdsField,
|
||||
});
|
||||
|
||||
router.patch(
|
||||
@@ -208,6 +209,7 @@ router.patch(
|
||||
thingiverseCatIds: parseCatIdsInput(body.thingiverse_cat_ids),
|
||||
printablesCatIds: parseCatIdsInput(body.printables_cat_ids),
|
||||
myminifactoryCatIds: parseCatIdsInput(body.myminifactory_cat_ids),
|
||||
snapmakerCatIds: parseCatIdsInput(body.snapmaker_cat_ids),
|
||||
},
|
||||
});
|
||||
res.json(toCategoryOut(updated));
|
||||
|
||||
@@ -12,6 +12,7 @@ import { asyncHandler } from "../utils/asyncHandler";
|
||||
import { requireCaptcha } from "../services/captchaService";
|
||||
import {
|
||||
assertCanImportUrl,
|
||||
SNAPMAKER_SESSION_CODE,
|
||||
checkImportStatus,
|
||||
downloadImportToTemp,
|
||||
findImportedExternalIds,
|
||||
@@ -48,6 +49,18 @@ import {
|
||||
parseMyminifactoryCollectionUrl,
|
||||
} from "../services/myminifactoryApi";
|
||||
import { getUserMakerworldCookie } from "../services/makerworldCookieService";
|
||||
import {
|
||||
fetchSnapmakerList,
|
||||
parseSnapmakerListUrl,
|
||||
parseSnapmakerModelUrl,
|
||||
SnapmakerAuthError,
|
||||
snapmakerListName,
|
||||
} from "../services/snapmakerApi";
|
||||
import {
|
||||
getUserSnapmakerToken,
|
||||
markSnapmakerTokenRejected,
|
||||
SNAPMAKER_CONNECT,
|
||||
} from "../services/snapmakerSessionService";
|
||||
import { listZipEntries } from "../services/zipService";
|
||||
import { createJob, createJobItems, getActiveJob, getJob, listJobItems } from "../services/importJobService";
|
||||
import { queueLink, startLinksJob } from "../services/importQueueService";
|
||||
@@ -58,6 +71,8 @@ import {
|
||||
runMakerworldProfilesImportJob,
|
||||
runMyminifactoryCollectionImportJob,
|
||||
runPrintablesCollectionImportJob,
|
||||
runSnapmakerListImportJob,
|
||||
runSnapmakerProfilesImportJob,
|
||||
runThingiverseCollectionImportJob,
|
||||
runThingiverseLikesImportJob,
|
||||
runZipImportJob,
|
||||
@@ -209,7 +224,7 @@ router.get(
|
||||
);
|
||||
|
||||
const statusBatchSchema = z.object({
|
||||
provider: z.enum(["makerworld", "thingiverse", "printables", "myminifactory"]),
|
||||
provider: z.enum(["makerworld", "thingiverse", "printables", "myminifactory", "snapmaker"]),
|
||||
external_ids: z.array(z.string().max(64)).max(10_000),
|
||||
});
|
||||
|
||||
@@ -444,6 +459,54 @@ router.post(
|
||||
}),
|
||||
);
|
||||
|
||||
/** The user's likes or bookmarks, from a link Thingport Grab or the app made (`?tab=interactions&list=...`). */
|
||||
function snapmakerListOf(url: string) {
|
||||
const parsed = parseSnapmakerListUrl(url);
|
||||
if (!parsed?.list) throw new HttpError(400, "Not a link to your Snapmaker Space likes or bookmarks");
|
||||
return { ...parsed, list: parsed.list };
|
||||
}
|
||||
|
||||
router.post(
|
||||
"/import/snapmaker-list/entries",
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = parseBody(importRequestSchema, req.body);
|
||||
const url = await enabledImportUrl(body.url);
|
||||
const { list } = snapmakerListOf(url);
|
||||
const token = await getUserSnapmakerToken(req.userId!);
|
||||
if (!token) throw new HttpError(400, SNAPMAKER_CONNECT, SNAPMAKER_SESSION_CODE);
|
||||
|
||||
let listing: Awaited<ReturnType<typeof fetchSnapmakerList>>;
|
||||
try {
|
||||
listing = await fetchSnapmakerList(list, token);
|
||||
} catch (err) {
|
||||
if (err instanceof SnapmakerAuthError) {
|
||||
await markSnapmakerTokenRejected(req.userId!);
|
||||
// 400, not 401: the frontend treats any 401 as an expired Thingport session.
|
||||
throw new HttpError(400, err.message, SNAPMAKER_SESSION_CODE);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
if (!listing.entries.length) throw new HttpError(400, `Your ${snapmakerListName(list)} are empty`);
|
||||
|
||||
const alreadyImported = await findImportedExternalIds(
|
||||
req.userId!,
|
||||
"snapmaker",
|
||||
listing.entries.map((e) => e.modelId),
|
||||
);
|
||||
res.json({
|
||||
title: snapmakerListName(list),
|
||||
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),
|
||||
})),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
// Batch imports run in the background and are polled via GET /import/jobs/:id. One RUNNING job
|
||||
// per user doubles as the "already in progress" lock.
|
||||
|
||||
@@ -471,6 +534,20 @@ router.post(
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/import/snapmaker-profiles",
|
||||
requireCaptcha("import"),
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = parseBody(makerworldProfilesImportRequestSchema, req.body);
|
||||
await assertNoActiveJob(req.userId!);
|
||||
const url = await enabledImportUrl(body.url);
|
||||
if (!parseSnapmakerModelUrl(url)) throw new HttpError(400, "Not a Snapmaker model link");
|
||||
const job = await createJob(req.userId!, "PROFILES", { sourceUrl: url, provider: "snapmaker" });
|
||||
void runSnapmakerProfilesImportJob(job.id, req.userId!, { ...body, url });
|
||||
res.status(202).json({ job_id: job.id });
|
||||
}),
|
||||
);
|
||||
|
||||
const collectionImportRequestSchema = importRequestSchema.extend({ design_ids: z.array(z.string()).min(1) });
|
||||
|
||||
router.post(
|
||||
@@ -578,6 +655,26 @@ router.post(
|
||||
}),
|
||||
);
|
||||
|
||||
const snapmakerListImportRequestSchema = importRequestSchema.extend({ model_ids: z.array(z.string()).min(1) });
|
||||
|
||||
router.post(
|
||||
"/import/snapmaker-list",
|
||||
requireCaptcha("import"),
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = parseBody(snapmakerListImportRequestSchema, req.body);
|
||||
await assertNoActiveJob(req.userId!);
|
||||
const url = await enabledImportUrl(body.url);
|
||||
const { list } = snapmakerListOf(url);
|
||||
const job = await createJob(req.userId!, "COLLECTION", {
|
||||
sourceUrl: url,
|
||||
provider: "snapmaker",
|
||||
total: body.model_ids.length,
|
||||
});
|
||||
void runSnapmakerListImportJob(job.id, req.userId!, { ...body, url, list });
|
||||
res.status(202).json({ job_id: job.id });
|
||||
}),
|
||||
);
|
||||
|
||||
const zipExtractRequestSchema = importRequestSchema.extend({ entries: z.array(z.string()) });
|
||||
|
||||
router.post(
|
||||
|
||||
@@ -56,6 +56,12 @@ import {
|
||||
normalizeMyminifactoryCookie,
|
||||
setUserMyminifactoryCookie,
|
||||
} from "../services/myminifactorySessionService";
|
||||
import { checkSnapmakerToken } from "../services/snapmakerApi";
|
||||
import {
|
||||
getSnapmakerConnection,
|
||||
normalizeSnapmakerToken,
|
||||
setUserSnapmakerToken,
|
||||
} from "../services/snapmakerSessionService";
|
||||
import { SLICER_IDS, getUserSlicer, setUserSlicer } from "../services/slicerPreferenceService";
|
||||
import { CATEGORIES_VIEWS, getCategoriesView, setCategoriesView } from "../services/categoriesViewService";
|
||||
import {
|
||||
@@ -611,6 +617,51 @@ router.patch(
|
||||
}),
|
||||
);
|
||||
|
||||
// Per-user: the Snapmaker access token, which Thingport Grab keeps sending as the browser's login is refreshed.
|
||||
router.get(
|
||||
"/settings/snapmaker-session",
|
||||
asyncHandler(async (req, res) => {
|
||||
const connection = await getSnapmakerConnection(req.userId!);
|
||||
res.json({ configured: connection !== "disconnected", expired: connection === "expired" });
|
||||
}),
|
||||
);
|
||||
|
||||
// `verify` is opt-in, as for MyMiniFactory: Thingport Grab sends a token it just read from a signed-in browser.
|
||||
const snapmakerSessionSchema = z.object({
|
||||
token: z.string().max(10_000).nullable(),
|
||||
verify: z.boolean().optional(),
|
||||
});
|
||||
router.patch(
|
||||
"/settings/snapmaker-session",
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = parseBody(snapmakerSessionSchema, req.body);
|
||||
const token = normalizeSnapmakerToken(body.token);
|
||||
if ((body.token ?? "").trim() && !token) {
|
||||
throw new HttpError(
|
||||
422,
|
||||
"That doesn't look like a Snapmaker sign-in: paste the login_access_token cookie's value.",
|
||||
);
|
||||
}
|
||||
if (token?.expiresAt && token.expiresAt.getTime() <= Date.now()) {
|
||||
throw new HttpError(422, "This Snapmaker sign-in has already expired. Copy a fresh one from a signed-in tab.");
|
||||
}
|
||||
if (body.verify && token) {
|
||||
const check = await checkSnapmakerToken(token.token);
|
||||
if (check === "rejected") {
|
||||
throw new HttpError(
|
||||
422,
|
||||
"Snapmaker didn't accept this sign-in -- it may have expired. Copy a fresh login_access_token from a signed-in space.snapmaker.com tab and try again.",
|
||||
);
|
||||
}
|
||||
// 503, not 422: nothing is known to be wrong with the token.
|
||||
if (check === "unreachable")
|
||||
throw new HttpError(503, "Couldn't reach Snapmaker to check this sign-in. Try again in a moment.");
|
||||
}
|
||||
const configured = await setUserSnapmakerToken(req.userId!, token);
|
||||
res.json({ configured });
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/settings/slicer",
|
||||
asyncHandler(async (req, res) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** Starter category tree modeled on MakerWorld's, with matching Thingiverse, Printables and MyMiniFactory ids so
|
||||
* imports are auto-categorized from the start. */
|
||||
/** Starter category tree modeled on MakerWorld's, with matching Thingiverse, Printables, MyMiniFactory and Snapmaker
|
||||
* ids so imports are auto-categorized from the start. */
|
||||
export type DefaultCategoryNode = {
|
||||
name: string;
|
||||
tags?: string[];
|
||||
@@ -9,6 +9,7 @@ export type DefaultCategoryNode = {
|
||||
thingiverseCatIds?: number[];
|
||||
printablesCatIds?: number[];
|
||||
myminifactoryCatIds?: number[];
|
||||
snapmakerCatIds?: number[];
|
||||
children?: DefaultCategoryNode[];
|
||||
};
|
||||
|
||||
@@ -22,6 +23,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
thingiverseCatIds: [73],
|
||||
printablesCatIds: [1],
|
||||
myminifactoryCatIds: [366],
|
||||
snapmakerCatIds: [103],
|
||||
children: [
|
||||
{
|
||||
name: "3D Printer Accessories",
|
||||
@@ -32,6 +34,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
thingiverseCatIds: [127],
|
||||
printablesCatIds: [40],
|
||||
myminifactoryCatIds: [1001, 1000],
|
||||
snapmakerCatIds: [116, 115, 117, 118],
|
||||
},
|
||||
{
|
||||
name: "3D Printer Parts",
|
||||
@@ -55,6 +58,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
thingiverseCatIds: [129],
|
||||
printablesCatIds: [12],
|
||||
myminifactoryCatIds: [960],
|
||||
snapmakerCatIds: [114],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -66,6 +70,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
makerworldCatIds: [100],
|
||||
thingiverseCatIds: [63],
|
||||
printablesCatIds: [13],
|
||||
snapmakerCatIds: [104],
|
||||
children: [
|
||||
{
|
||||
name: "2D Art",
|
||||
@@ -75,6 +80,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
makerworldCatIds: [101],
|
||||
thingiverseCatIds: [144],
|
||||
printablesCatIds: [16],
|
||||
snapmakerCatIds: [119],
|
||||
},
|
||||
{
|
||||
name: "Coin & Badges",
|
||||
@@ -102,6 +108,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
thingiverseCatIds: [80],
|
||||
printablesCatIds: [14],
|
||||
myminifactoryCatIds: [112, 355],
|
||||
snapmakerCatIds: [121],
|
||||
},
|
||||
{
|
||||
name: "Other Art Models",
|
||||
@@ -111,6 +118,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
makerworldCatIds: [105],
|
||||
thingiverseCatIds: [75, 78, 79, 145],
|
||||
printablesCatIds: [15, 41],
|
||||
snapmakerCatIds: [120, 183],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -123,6 +131,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
thingiverseCatIds: [69],
|
||||
printablesCatIds: [90],
|
||||
myminifactoryCatIds: [86],
|
||||
snapmakerCatIds: [106],
|
||||
children: [
|
||||
{
|
||||
name: "Biology",
|
||||
@@ -132,6 +141,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
makerworldCatIds: [503],
|
||||
thingiverseCatIds: [106],
|
||||
myminifactoryCatIds: [154, 373, 997, 1045],
|
||||
snapmakerCatIds: [129],
|
||||
},
|
||||
{
|
||||
name: "Chemistry",
|
||||
@@ -141,6 +151,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
makerworldCatIds: [505],
|
||||
printablesCatIds: [92],
|
||||
myminifactoryCatIds: [162],
|
||||
snapmakerCatIds: [128],
|
||||
},
|
||||
{
|
||||
name: "Engineering",
|
||||
@@ -151,6 +162,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
thingiverseCatIds: [104],
|
||||
printablesCatIds: [93],
|
||||
myminifactoryCatIds: [953, 241, 374, 1042],
|
||||
snapmakerCatIds: [132],
|
||||
},
|
||||
{
|
||||
name: "Geography",
|
||||
@@ -160,6 +172,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
makerworldCatIds: [506],
|
||||
printablesCatIds: [98],
|
||||
myminifactoryCatIds: [164],
|
||||
snapmakerCatIds: [130],
|
||||
},
|
||||
{
|
||||
name: "Mathematics",
|
||||
@@ -170,6 +183,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
thingiverseCatIds: [105],
|
||||
printablesCatIds: [94],
|
||||
myminifactoryCatIds: [163, 1035],
|
||||
snapmakerCatIds: [126],
|
||||
},
|
||||
{
|
||||
name: "Physics & Astronomy",
|
||||
@@ -180,6 +194,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
thingiverseCatIds: [148],
|
||||
printablesCatIds: [91],
|
||||
myminifactoryCatIds: [165, 558, 1036],
|
||||
snapmakerCatIds: [131, 127],
|
||||
},
|
||||
{
|
||||
name: "Other Education Models",
|
||||
@@ -189,6 +204,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
makerworldCatIds: [507],
|
||||
printablesCatIds: [96],
|
||||
myminifactoryCatIds: [344, 166, 109, 1034, 969, 970, 971, 972, 973],
|
||||
snapmakerCatIds: [133],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -201,6 +217,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
thingiverseCatIds: [64],
|
||||
printablesCatIds: [17],
|
||||
myminifactoryCatIds: [58],
|
||||
snapmakerCatIds: [107],
|
||||
children: [
|
||||
{
|
||||
name: "Bags",
|
||||
@@ -209,6 +226,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
"Embrace a unique blend of functionality and fashion-forward design with our durable and eco-friendly 3D printed bags, ranging from trendy handbags to practical backpacks.",
|
||||
makerworldCatIds: [201],
|
||||
myminifactoryCatIds: [143],
|
||||
snapmakerCatIds: [135],
|
||||
},
|
||||
{
|
||||
name: "Clothes",
|
||||
@@ -218,6 +236,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
makerworldCatIds: [202],
|
||||
thingiverseCatIds: [142],
|
||||
myminifactoryCatIds: [169],
|
||||
snapmakerCatIds: [182],
|
||||
},
|
||||
{
|
||||
name: "Earrings",
|
||||
@@ -235,6 +254,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
"Experience the ultimate in comfort and style with our innovative and customizable 3D printed footwear, designed to provide exceptional support and make a fashion statement.",
|
||||
makerworldCatIds: [204],
|
||||
myminifactoryCatIds: [140],
|
||||
snapmakerCatIds: [137],
|
||||
},
|
||||
{
|
||||
name: "Glasses",
|
||||
@@ -253,6 +273,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
makerworldCatIds: [208],
|
||||
thingiverseCatIds: [84, 82],
|
||||
myminifactoryCatIds: [291, 148, 426, 359, 430, 145, 439],
|
||||
snapmakerCatIds: [134],
|
||||
},
|
||||
{
|
||||
name: "Rings",
|
||||
@@ -272,6 +293,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
thingiverseCatIds: [81, 130],
|
||||
printablesCatIds: [18, 20, 42],
|
||||
myminifactoryCatIds: [357],
|
||||
snapmakerCatIds: [136, 138],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -283,6 +305,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
makerworldCatIds: [300],
|
||||
thingiverseCatIds: [66],
|
||||
printablesCatIds: [48],
|
||||
snapmakerCatIds: [109],
|
||||
children: [
|
||||
{
|
||||
name: "Electronics",
|
||||
@@ -295,6 +318,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
myminifactoryCatIds: [
|
||||
59, 943, 328, 378, 329, 941, 275, 113, 326, 647, 375, 488, 489, 490, 491, 492, 648, 507, 633, 670,
|
||||
],
|
||||
snapmakerCatIds: [148],
|
||||
},
|
||||
{
|
||||
name: "Music",
|
||||
@@ -305,6 +329,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
thingiverseCatIds: [94],
|
||||
printablesCatIds: [95],
|
||||
myminifactoryCatIds: [68],
|
||||
snapmakerCatIds: [152],
|
||||
},
|
||||
{
|
||||
name: "RC",
|
||||
@@ -317,6 +342,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
120, 1273, 1274, 1275, 1276, 1277, 1278, 1279, 1280, 1281, 1282, 1283, 1291, 1292, 1299, 360, 975, 983, 984,
|
||||
985, 987, 976, 988, 992, 977, 990, 991,
|
||||
],
|
||||
snapmakerCatIds: [150],
|
||||
},
|
||||
{
|
||||
name: "Robotics",
|
||||
@@ -327,6 +353,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
thingiverseCatIds: [96],
|
||||
printablesCatIds: [64],
|
||||
myminifactoryCatIds: [393, 429, 999],
|
||||
snapmakerCatIds: [149],
|
||||
},
|
||||
{
|
||||
name: "Sport & Outdoors",
|
||||
@@ -337,6 +364,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
thingiverseCatIds: [140],
|
||||
printablesCatIds: [9, 83, 74, 85, 84],
|
||||
myminifactoryCatIds: [56, 69, 71, 966, 967, 968, 114, 342, 550, 551, 962],
|
||||
snapmakerCatIds: [154],
|
||||
},
|
||||
{
|
||||
name: "Vehicles",
|
||||
@@ -346,6 +374,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
makerworldCatIds: [302],
|
||||
thingiverseCatIds: [155],
|
||||
printablesCatIds: [89],
|
||||
snapmakerCatIds: [153],
|
||||
},
|
||||
{
|
||||
name: "Other Hobby & DIY",
|
||||
@@ -355,6 +384,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
makerworldCatIds: [307],
|
||||
thingiverseCatIds: [93],
|
||||
printablesCatIds: [51, 82],
|
||||
snapmakerCatIds: [151, 155],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -367,6 +397,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
thingiverseCatIds: [67],
|
||||
printablesCatIds: [3],
|
||||
myminifactoryCatIds: [57],
|
||||
snapmakerCatIds: [108],
|
||||
children: [
|
||||
{
|
||||
name: "Decor",
|
||||
@@ -377,6 +408,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
thingiverseCatIds: [97],
|
||||
printablesCatIds: [44],
|
||||
myminifactoryCatIds: [335, 389, 399, 372, 398, 362, 336],
|
||||
snapmakerCatIds: [139],
|
||||
},
|
||||
{
|
||||
name: "Festivities",
|
||||
@@ -386,6 +418,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
makerworldCatIds: [403],
|
||||
printablesCatIds: [65, 69, 68, 70],
|
||||
myminifactoryCatIds: [385, 384, 634, 635, 636],
|
||||
snapmakerCatIds: [140],
|
||||
},
|
||||
{
|
||||
name: "Garden",
|
||||
@@ -396,6 +429,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
thingiverseCatIds: [98],
|
||||
printablesCatIds: [53, 71],
|
||||
myminifactoryCatIds: [150],
|
||||
snapmakerCatIds: [146],
|
||||
},
|
||||
{
|
||||
name: "Office",
|
||||
@@ -406,6 +440,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
thingiverseCatIds: [101],
|
||||
printablesCatIds: [29],
|
||||
myminifactoryCatIds: [382, 377],
|
||||
snapmakerCatIds: [143],
|
||||
},
|
||||
{
|
||||
name: "Pets",
|
||||
@@ -415,6 +450,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
makerworldCatIds: [405],
|
||||
thingiverseCatIds: [103],
|
||||
printablesCatIds: [57],
|
||||
snapmakerCatIds: [147],
|
||||
},
|
||||
{
|
||||
name: "Other Household Models",
|
||||
@@ -428,6 +464,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
370, 381, 371, 369, 397, 88, 445, 452, 502, 823, 824, 825, 826, 827, 833, 942, 828, 829, 830, 831, 832, 902,
|
||||
944, 947, 948, 775,
|
||||
],
|
||||
snapmakerCatIds: [142, 144, 145, 184],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -440,6 +477,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
thingiverseCatIds: [70],
|
||||
printablesCatIds: [101],
|
||||
myminifactoryCatIds: [1015],
|
||||
snapmakerCatIds: [110],
|
||||
children: [
|
||||
{
|
||||
name: "Animals",
|
||||
@@ -449,6 +487,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
makerworldCatIds: [601],
|
||||
thingiverseCatIds: [107],
|
||||
printablesCatIds: [61],
|
||||
snapmakerCatIds: [157],
|
||||
},
|
||||
{
|
||||
name: "Architecture",
|
||||
@@ -459,6 +498,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
thingiverseCatIds: [108],
|
||||
printablesCatIds: [103, 62],
|
||||
myminifactoryCatIds: [787, 1153, 1158, 913],
|
||||
snapmakerCatIds: [158],
|
||||
},
|
||||
{
|
||||
name: "Creatures",
|
||||
@@ -468,6 +508,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
makerworldCatIds: [603],
|
||||
thingiverseCatIds: [109],
|
||||
printablesCatIds: [97],
|
||||
snapmakerCatIds: [159],
|
||||
},
|
||||
{
|
||||
name: "People",
|
||||
@@ -478,6 +519,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
thingiverseCatIds: [112],
|
||||
printablesCatIds: [60],
|
||||
myminifactoryCatIds: [785, 780, 782, 783, 784, 1145],
|
||||
snapmakerCatIds: [156],
|
||||
},
|
||||
{
|
||||
name: "Other Miniatures",
|
||||
@@ -488,6 +530,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
thingiverseCatIds: [110, 111, 115, 116],
|
||||
printablesCatIds: [104, 105, 75],
|
||||
myminifactoryCatIds: [617, 786, 1306, 1310, 1311, 1312, 1313, 1304, 1319, 1325, 1324],
|
||||
snapmakerCatIds: [160],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -499,6 +542,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
makerworldCatIds: [1000],
|
||||
printablesCatIds: [76],
|
||||
myminifactoryCatIds: [119],
|
||||
snapmakerCatIds: [105],
|
||||
children: [
|
||||
{
|
||||
name: "Costumes",
|
||||
@@ -508,6 +552,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
makerworldCatIds: [1003],
|
||||
printablesCatIds: [77],
|
||||
myminifactoryCatIds: [340],
|
||||
snapmakerCatIds: [123],
|
||||
},
|
||||
{
|
||||
name: "Masks & Helmets",
|
||||
@@ -517,6 +562,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
makerworldCatIds: [1001],
|
||||
printablesCatIds: [78],
|
||||
myminifactoryCatIds: [155],
|
||||
snapmakerCatIds: [122],
|
||||
},
|
||||
{
|
||||
name: "Cosplay Weapons",
|
||||
@@ -525,6 +571,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
"Dive into the amazing cosplay world with our 3D printed props! We've got finely designed swords and shields, ideal for you to embody your favorite characters. Unleash their power and enjoy immersive role-playing!",
|
||||
makerworldCatIds: [1002],
|
||||
myminifactoryCatIds: [542, 156, 304],
|
||||
snapmakerCatIds: [124],
|
||||
},
|
||||
{
|
||||
name: "Other Props & Cosplays",
|
||||
@@ -534,6 +581,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
makerworldCatIds: [1004],
|
||||
thingiverseCatIds: [114],
|
||||
printablesCatIds: [81, 80],
|
||||
snapmakerCatIds: [125],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -545,6 +593,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
makerworldCatIds: [700],
|
||||
thingiverseCatIds: [71],
|
||||
myminifactoryCatIds: [252],
|
||||
snapmakerCatIds: [111],
|
||||
children: [
|
||||
{
|
||||
name: "Gadgets",
|
||||
@@ -563,6 +612,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
"Enhance your toolbox with our durable and customizable 3D printed hand tools, wrenches, and screwdrivers.",
|
||||
makerworldCatIds: [703],
|
||||
thingiverseCatIds: [118],
|
||||
snapmakerCatIds: [161],
|
||||
},
|
||||
{
|
||||
name: "Machine Tools",
|
||||
@@ -571,6 +621,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
"Upgrade your workshop with our precision-crafted 3D printed machine tools, jigs, and fixtures.",
|
||||
makerworldCatIds: [704],
|
||||
thingiverseCatIds: [117],
|
||||
snapmakerCatIds: [165],
|
||||
},
|
||||
{
|
||||
name: "Measure Tools",
|
||||
@@ -578,6 +629,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
metaDescription:
|
||||
"Ensure precise measurements with our accurate and versatile 3D printed measuring tools, rulers, and calipers.",
|
||||
makerworldCatIds: [702],
|
||||
snapmakerCatIds: [162],
|
||||
},
|
||||
{
|
||||
name: "Medical Tools",
|
||||
@@ -599,6 +651,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
thingiverseCatIds: [102, 120],
|
||||
printablesCatIds: [50],
|
||||
myminifactoryCatIds: [149],
|
||||
snapmakerCatIds: [141, 163],
|
||||
},
|
||||
{
|
||||
name: "Other Tools",
|
||||
@@ -608,6 +661,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
makerworldCatIds: [706],
|
||||
thingiverseCatIds: [141, 86, 87, 88, 90, 91, 119],
|
||||
printablesCatIds: [49],
|
||||
snapmakerCatIds: [164, 166],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -620,6 +674,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
thingiverseCatIds: [72],
|
||||
printablesCatIds: [30],
|
||||
myminifactoryCatIds: [60],
|
||||
snapmakerCatIds: [112],
|
||||
children: [
|
||||
{
|
||||
name: "Board Games",
|
||||
@@ -630,6 +685,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
thingiverseCatIds: [122, 113, 151],
|
||||
printablesCatIds: [31],
|
||||
myminifactoryCatIds: [1014, 523, 1016],
|
||||
snapmakerCatIds: [172],
|
||||
},
|
||||
{
|
||||
name: "Characters",
|
||||
@@ -641,6 +697,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
myminifactoryCatIds: [
|
||||
104, 67, 435, 442, 496, 532, 533, 436, 536, 537, 538, 539, 540, 541, 543, 544, 733, 927, 957, 1159, 1309,
|
||||
],
|
||||
snapmakerCatIds: [171],
|
||||
},
|
||||
{
|
||||
name: "Outdoor Toys",
|
||||
@@ -649,6 +706,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
"Encourage active play and outdoor adventures with our durable and engaging 3D printed outdoor toys, frisbees, and sports equipment.",
|
||||
makerworldCatIds: [803],
|
||||
printablesCatIds: [34],
|
||||
snapmakerCatIds: [173],
|
||||
},
|
||||
{
|
||||
name: "Puzzles",
|
||||
@@ -659,6 +717,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
thingiverseCatIds: [125],
|
||||
printablesCatIds: [33],
|
||||
myminifactoryCatIds: [100],
|
||||
snapmakerCatIds: [169],
|
||||
},
|
||||
{
|
||||
name: "Construction Sets",
|
||||
@@ -668,6 +727,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
makerworldCatIds: [806],
|
||||
thingiverseCatIds: [121],
|
||||
printablesCatIds: [37],
|
||||
snapmakerCatIds: [170],
|
||||
},
|
||||
{
|
||||
name: "Other Toys & Games",
|
||||
@@ -678,6 +738,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
|
||||
thingiverseCatIds: [149, 124, 123],
|
||||
printablesCatIds: [38, 47],
|
||||
myminifactoryCatIds: [462, 1308, 1307, 529],
|
||||
snapmakerCatIds: [167, 168, 174, 175],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -18,12 +18,13 @@ import { resolvePrintablesModel } from "./printablesApi";
|
||||
import { getMyminifactoryApiKey, getThingiverseAccessToken } from "./settingsService";
|
||||
import { MyminifactoryAuthError, MyminifactoryRateLimitError, resolveMyminifactoryObject } from "./myminifactoryApi";
|
||||
import { resolveThingiverseThing, ThingiverseAuthError, ThingiverseRateLimitError } from "./thingiverseApi";
|
||||
import { resolveSnapmakerModel } from "./snapmakerApi";
|
||||
import { getDisabledProviders } from "./providerSettingsService";
|
||||
|
||||
// Links models that only know their author by name: first by name matching, then by looking the
|
||||
// rest up on their site once per distinct creator. Only metadata is fetched.
|
||||
|
||||
const LOOKUP_PROVIDERS = ["makerworld", "thingiverse", "printables", "myminifactory"] as const;
|
||||
const LOOKUP_PROVIDERS = ["makerworld", "thingiverse", "printables", "myminifactory", "snapmaker"] as const;
|
||||
type LookupProvider = (typeof LOOKUP_PROVIDERS)[number];
|
||||
|
||||
export type LookupProblem =
|
||||
@@ -185,6 +186,10 @@ async function lookUpAuthor(
|
||||
await maybeSleep(IMPORT_COLLECTION_DELAY_MS);
|
||||
return (await resolvePrintablesModel(candidate.externalId))?.meta.author ?? null;
|
||||
}
|
||||
case "snapmaker": {
|
||||
await maybeSleep(IMPORT_COLLECTION_DELAY_MS);
|
||||
return (await resolveSnapmakerModel(candidate.externalId))?.meta.author ?? null;
|
||||
}
|
||||
case "makerworld": {
|
||||
const cookie = await getUserMakerworldCookie(candidate.userId);
|
||||
const bearer = extractMakerworldBearerToken(cookie);
|
||||
|
||||
@@ -76,6 +76,7 @@ export async function seedDefaultCategories(tx: Prisma.TransactionClient, userId
|
||||
thingiverseCatIds: node.thingiverseCatIds ?? [],
|
||||
printablesCatIds: node.printablesCatIds ?? [],
|
||||
myminifactoryCatIds: node.myminifactoryCatIds ?? [],
|
||||
snapmakerCatIds: node.snapmakerCatIds ?? [],
|
||||
},
|
||||
});
|
||||
let i = 0;
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
import { COLLECTION_SYNC_ITEM_DELAY_MS, IMPORT_COLLECTION_DELAY_MS, IMPORT_MAKERWORLD_CALL_DELAY_MS } from "../config";
|
||||
import { sleep } from "../utils/concurrency";
|
||||
import { addPrintsToCollection } from "./collectionService";
|
||||
import { findLibraryPrints, modelUrlFor, providerName, sourceDescription } from "./collectionSyncService";
|
||||
import { findLibraryPrints, hasProfiles, modelUrlFor, providerName, sourceDescription } from "./collectionSyncService";
|
||||
import {
|
||||
PRINTABLES_FORGOTTEN,
|
||||
printablesPushIsOff,
|
||||
@@ -35,6 +35,8 @@ import {
|
||||
markMakerworldCookieRejected,
|
||||
} from "./makerworldCookieService";
|
||||
import { checkPrintablesCollection, fetchPrintablesCollectionEntries } from "./printablesApi";
|
||||
import { fetchSnapmakerList, SnapmakerAuthError } from "./snapmakerApi";
|
||||
import { getSnapmakerConnection, getUserSnapmakerToken, markSnapmakerTokenRejected } from "./snapmakerSessionService";
|
||||
import {
|
||||
checkThingiverseCollection,
|
||||
checkThingiverseUser,
|
||||
@@ -103,6 +105,13 @@ export async function fetchListing(
|
||||
const listing = await fetchMyminifactoryCollectionObjects(username, slug, apiKey);
|
||||
return listing ? listing.entries.map((e) => e.objectId) : null;
|
||||
}
|
||||
if (sync.provider === "snapmaker") {
|
||||
// The user's own likes or bookmarks ("likes-<id>"), read as them: they're never gone.
|
||||
const token = await getUserSnapmakerToken(sync.userId);
|
||||
if (!token) throw new SnapmakerAuthError();
|
||||
const { entries } = await fetchSnapmakerList(id.startsWith("likes-") ? "likes" : "bookmarks", token);
|
||||
return entries.map((e) => e.modelId);
|
||||
}
|
||||
throw new Error(`Unknown provider "${sync.provider}"`);
|
||||
}
|
||||
|
||||
@@ -168,6 +177,16 @@ const CONNECTION_PROBLEMS = {
|
||||
reason: "MyMiniFactory isn't connected on this Thingport.",
|
||||
fix: "an admin connects it again",
|
||||
},
|
||||
snapmakerDisconnected: {
|
||||
error: "Snapmaker Space isn't connected",
|
||||
reason: "Snapmaker Space isn't connected.",
|
||||
fix: "you connect it",
|
||||
},
|
||||
snapmakerExpired: {
|
||||
error: "Snapmaker Space sign-in ran out",
|
||||
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",
|
||||
},
|
||||
} satisfies Record<string, ConnectionProblem>;
|
||||
|
||||
/** `"A"`, `"A" and "B"`, or the first few and how many more. */
|
||||
@@ -212,6 +231,11 @@ async function connectionProblem(sync: Pick<CollectionSync, "userId" | "provider
|
||||
if (sync.provider === "myminifactory" && !(await getMyminifactoryApiKey())) {
|
||||
return CONNECTION_PROBLEMS.myminifactoryDisconnected;
|
||||
}
|
||||
if (sync.provider === "snapmaker") {
|
||||
const connection = await getSnapmakerConnection(sync.userId);
|
||||
if (connection === "disconnected") return CONNECTION_PROBLEMS.snapmakerDisconnected;
|
||||
if (connection === "expired") return CONNECTION_PROBLEMS.snapmakerExpired;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -272,6 +296,11 @@ async function runSync(syncId: string): Promise<SyncOutcome> {
|
||||
await holdProvider(userId, sync.provider, CONNECTION_PROBLEMS.makerworldExpired);
|
||||
return "disconnected";
|
||||
}
|
||||
if (err instanceof SnapmakerAuthError) {
|
||||
await markSnapmakerTokenRejected(userId);
|
||||
await holdProvider(userId, sync.provider, CONNECTION_PROBLEMS.snapmakerExpired);
|
||||
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
|
||||
@@ -315,7 +344,7 @@ async function runSync(syncId: string): Promise<SyncOutcome> {
|
||||
const toImport = fresh.filter((id) => !inLibrary.has(id));
|
||||
const itemPayload: Prisma.InputJsonValue = {
|
||||
collection_id: sync.collectionId,
|
||||
scope: sync.provider === "makerworld" ? sync.profileScope : "url",
|
||||
scope: hasProfiles(sync.provider) ? sync.profileScope : "url",
|
||||
title: null,
|
||||
};
|
||||
|
||||
|
||||
@@ -14,6 +14,14 @@ import {
|
||||
myminifactoryCollectionUrl,
|
||||
parseMyminifactoryCollectionUrl,
|
||||
} from "./myminifactoryApi";
|
||||
import {
|
||||
parseSnapmakerListUrl,
|
||||
snapmakerListKey,
|
||||
snapmakerListName,
|
||||
snapmakerListUrl,
|
||||
snapmakerModelUrl,
|
||||
} from "./snapmakerApi";
|
||||
import { getUserSnapmakerId } from "./snapmakerSessionService";
|
||||
import { createLog } from "./auditLog";
|
||||
import { pushedLikes } from "./providerListingsService";
|
||||
import { createNotification } from "./notificationService";
|
||||
@@ -26,7 +34,7 @@ import {
|
||||
type SyncProfileScope,
|
||||
} from "../dto";
|
||||
|
||||
export type SyncProvider = "makerworld" | "printables" | "thingiverse" | "myminifactory";
|
||||
export type SyncProvider = "makerworld" | "printables" | "thingiverse" | "myminifactory" | "snapmaker";
|
||||
|
||||
/** `likesOf` is set for a user's likes (on Thingiverse, or the user's own on Printables), which a
|
||||
* sync follows like a collection. */
|
||||
@@ -37,8 +45,14 @@ const PROVIDER_NAMES: Record<SyncProvider, string> = {
|
||||
printables: "Printables",
|
||||
thingiverse: "Thingiverse",
|
||||
myminifactory: "MyMiniFactory",
|
||||
snapmaker: "Snapmaker Space",
|
||||
};
|
||||
|
||||
/** Providers whose models come with print profiles a sync can pick between. */
|
||||
export function hasProfiles(provider: string): boolean {
|
||||
return provider === "makerworld" || provider === "snapmaker";
|
||||
}
|
||||
|
||||
export function providerName(provider: string): string {
|
||||
return PROVIDER_NAMES[provider as SyncProvider] ?? provider;
|
||||
}
|
||||
@@ -83,6 +97,15 @@ export function parseSyncSource(url: string): SyncSource | null {
|
||||
url: myminifactoryCollectionUrl(username, slug),
|
||||
};
|
||||
}
|
||||
const snapmaker = parseSnapmakerListUrl(url);
|
||||
if (snapmaker?.list) {
|
||||
// The user's own likes or bookmarks; Snapmaker shows them to their owner only.
|
||||
return {
|
||||
provider: "snapmaker",
|
||||
externalId: snapmakerListKey(snapmaker.userId, snapmaker.list),
|
||||
url: snapmakerListUrl(snapmaker.userId, snapmaker.list),
|
||||
};
|
||||
}
|
||||
const printablesLikes = parsePrintablesLikesUrl(url);
|
||||
if (printablesLikes) {
|
||||
// Only Thingport Grab can read these, as their owner (see providerListingsService.ts).
|
||||
@@ -111,6 +134,7 @@ export function modelUrlFor(provider: string, externalId: string): string {
|
||||
if (provider === "makerworld") return `https://makerworld.com/en/models/${externalId}`;
|
||||
if (provider === "printables") return `https://www.printables.com/model/${externalId}`;
|
||||
if (provider === "myminifactory") return `https://www.myminifactory.com/object/3d-print-${externalId}`;
|
||||
if (provider === "snapmaker") return snapmakerModelUrl(externalId);
|
||||
return `https://www.thingiverse.com/thing:${externalId}`;
|
||||
}
|
||||
|
||||
@@ -120,6 +144,7 @@ export function modelIdFromUrl(provider: string, url: string): string | null {
|
||||
makerworld: /\/models\/(\d+)/,
|
||||
printables: /\/model\/(\d+)/,
|
||||
myminifactory: /\/object\/(?:[^/]*-)?(\d+)/,
|
||||
snapmaker: /\/model\/(\d+)/,
|
||||
};
|
||||
const pattern = patterns[provider] ?? /thing:(\d+)/;
|
||||
return url.match(pattern)?.[1] ?? null;
|
||||
@@ -191,9 +216,10 @@ export async function enableSync(userId: string, input: EnableSyncInput): Promis
|
||||
if (!source)
|
||||
throw new HttpError(
|
||||
400,
|
||||
"Only MakerWorld, Printables, Thingiverse and MyMiniFactory collections, and Thingiverse or your own Printables likes, can be synced",
|
||||
"Only MakerWorld, Printables, Thingiverse and MyMiniFactory collections, Thingiverse or your own Printables 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);
|
||||
const knownIds = [...new Set(input.knownIds.map((id) => id.trim()).filter(Boolean))];
|
||||
|
||||
const existing = await findSyncForSource(userId, source);
|
||||
@@ -227,9 +253,12 @@ export async function enableSync(userId: string, input: EnableSyncInput): Promis
|
||||
if ("link" in result) return result.link;
|
||||
created = result.created;
|
||||
} else {
|
||||
const snapmakerList = source.provider === "snapmaker" ? parseSnapmakerListUrl(source.url)?.list : null;
|
||||
const title = source.likesOf
|
||||
? likesCollectionName(source.likesOf, source.provider)
|
||||
: input.title?.trim() || `${providerName(source.provider)} Collection ${source.externalId}`;
|
||||
: snapmakerList
|
||||
? snapmakerListName(snapmakerList)
|
||||
: 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);
|
||||
@@ -268,7 +297,7 @@ async function notifySyncEnabled(sync: CollectionSync & { collection: Collection
|
||||
const name = sync.collection.name;
|
||||
const interval = INTERVAL_PHRASES[sync.intervalHours] ?? `every ${sync.intervalHours} hours`;
|
||||
const profiles =
|
||||
sync.provider === "makerworld" && PROFILE_SCOPE_PHRASES[sync.profileScope]
|
||||
hasProfiles(sync.provider) && PROFILE_SCOPE_PHRASES[sync.profileScope]
|
||||
? `, each with ${PROFILE_SCOPE_PHRASES[sync.profileScope]}`
|
||||
: "";
|
||||
await createNotification(sync.userId, {
|
||||
@@ -295,6 +324,18 @@ async function assertOwnPrintablesLikes(userId: string, handle: string): Promise
|
||||
}
|
||||
}
|
||||
|
||||
/** Snapmaker shows likes and bookmarks to their owner only, and a sync reads them with the user's own sign-in: one
|
||||
* of someone else's would be fed the user's own under the other's name. */
|
||||
async function assertOwnSnapmakerList(userId: string, url: string): Promise<void> {
|
||||
const own = await getUserSnapmakerId(userId);
|
||||
if (!own || own !== parseSnapmakerListUrl(url)?.userId) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
"Snapmaker Space shows likes and bookmarks to their owner only, so only your own can be synced, once Snapmaker is connected",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// How many "Things (MakerWorld 2)"-style names to try before giving up.
|
||||
const MAX_NAME_SUFFIX = 20;
|
||||
|
||||
@@ -309,7 +350,7 @@ function* collectionNamesFor(title: string, provider: string): Generator<string>
|
||||
}
|
||||
|
||||
// A synced collection follows what the designer publishes, so new models bring all of their profiles.
|
||||
const NEW_MAKERWORLD_SYNC_PROFILE_SCOPE: SyncProfileScope = "designer";
|
||||
const NEW_SYNC_PROFILE_SCOPE: SyncProfileScope = "designer";
|
||||
|
||||
/** Links `collection` to `source`. `takenBy` names the provider when the collection already follows
|
||||
* another source; `link` is the existing one when another request just made this same link. */
|
||||
@@ -330,7 +371,7 @@ async function createSync(
|
||||
externalId: source.externalId,
|
||||
sourceUrl: source.url,
|
||||
knownIds,
|
||||
...(source.provider === "makerworld" ? { profileScope: NEW_MAKERWORLD_SYNC_PROFILE_SCOPE } : {}),
|
||||
...(hasProfiles(source.provider) ? { profileScope: NEW_SYNC_PROFILE_SCOPE } : {}),
|
||||
},
|
||||
include: { collection: true },
|
||||
});
|
||||
@@ -383,15 +424,15 @@ export async function disableSync(userId: string, collectionId: string): Promise
|
||||
}
|
||||
|
||||
/** Applies to checks and models found from now on; models already waiting in the import queue
|
||||
* keep the profile scope they were queued with. Only MakerWorld models have print profiles. */
|
||||
* keep the profile scope they were queued with. Only MakerWorld and Snapmaker models have print profiles. */
|
||||
export async function updateSyncSettings(
|
||||
userId: string,
|
||||
collectionId: string,
|
||||
settings: { profileScope?: SyncProfileScope; intervalHours?: SyncIntervalHours },
|
||||
): Promise<CollectionSyncOut> {
|
||||
const sync = await ownedSync(userId, collectionId);
|
||||
if (settings.profileScope && sync.provider !== "makerworld") {
|
||||
throw new HttpError(400, "Only MakerWorld models have print profiles to pick");
|
||||
if (settings.profileScope && !hasProfiles(sync.provider)) {
|
||||
throw new HttpError(400, "Only MakerWorld and Snapmaker Space models have print profiles to pick");
|
||||
}
|
||||
const updated = await prisma.collectionSync.update({
|
||||
where: { id: sync.id },
|
||||
|
||||
@@ -15,7 +15,7 @@ import { getDisabledProviders } from "./providerSettingsService";
|
||||
// Re-reads every imported model's description for one user from its source, replacing the stored
|
||||
// one, e.g. to bring models imported as plain text up to formatted descriptions with images.
|
||||
|
||||
const SOURCE_PROVIDERS = ["makerworld", "thingiverse", "printables", "myminifactory"] as const;
|
||||
const SOURCE_PROVIDERS = ["makerworld", "thingiverse", "printables", "myminifactory", "snapmaker"] as const;
|
||||
type SourceProvider = (typeof SOURCE_PROVIDERS)[number];
|
||||
|
||||
export type DescriptionRefetchRun = {
|
||||
|
||||
@@ -19,6 +19,7 @@ import { resolveThingiverseThing, ThingiverseAuthError, ThingiverseRateLimitErro
|
||||
import { parsePrintablesModelUrl, resolvePrintablesModel } from "./printablesApi";
|
||||
import { getMyminifactoryApiKey, getThingiverseAccessToken } from "./settingsService";
|
||||
import { MyminifactoryAuthError, MyminifactoryRateLimitError, resolveMyminifactoryObject } from "./myminifactoryApi";
|
||||
import { resolveSnapmakerModel } from "./snapmakerApi";
|
||||
import { getUserMakerworldCookie } from "./makerworldCookieService";
|
||||
import { upsertAuthorFromImport } from "./authorService";
|
||||
import { emptyGaps, openSourceGaps, type SourceGap } from "./sourceGaps";
|
||||
@@ -96,6 +97,12 @@ export async function fetchSourceMeta(userId: string, print: Print, sourceUrl: s
|
||||
return { ...emptyImportedPageMetadata(), ...resolved.meta, galleryImages: resolved.galleryImages };
|
||||
}
|
||||
|
||||
if (provider === "snapmaker") {
|
||||
const resolved = await resolveSnapmakerModel(externalId);
|
||||
if (!resolved) throw new HttpError(404, "This Snapmaker 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");
|
||||
}
|
||||
|
||||
|
||||
@@ -23,8 +23,18 @@ import {
|
||||
downloadImportToTemp,
|
||||
fetchMakerworldDesignForImport,
|
||||
importPrintFromUrl,
|
||||
SNAPMAKER_SESSION_CODE,
|
||||
type ImportRequestBody,
|
||||
} from "./importService";
|
||||
import {
|
||||
parseSnapmakerModelUrl,
|
||||
resolveSnapmakerModel,
|
||||
selectSnapmakerProfiles,
|
||||
snapmakerListName,
|
||||
snapmakerModelUrl,
|
||||
type SnapmakerList,
|
||||
} from "./snapmakerApi";
|
||||
import { getUserSnapmakerToken, SNAPMAKER_CONNECT } from "./snapmakerSessionService";
|
||||
import { upsertAuthorFromImport } from "./authorService";
|
||||
import { extractZipEntriesToPrints } from "./zipService";
|
||||
import { fetchThingiverseCollectionTitle } from "./thingiverseApi";
|
||||
@@ -61,6 +71,7 @@ type PrintablesCollectionImportJobBody = ImportRequestBody & {
|
||||
collection_title?: string | null;
|
||||
};
|
||||
type MakerworldProfilesImportJobBody = ImportRequestBody & { scope: Exclude<MakerworldProfileScope, "url"> };
|
||||
type SnapmakerListImportJobBody = ImportRequestBody & { model_ids: string[]; list: SnapmakerList };
|
||||
|
||||
// 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")
|
||||
@@ -77,7 +88,7 @@ const PROVIDER_OFF_TEXT = (name: string) =>
|
||||
function classifyImportFailure(err: unknown): ImportFailureReason {
|
||||
if (err instanceof MakerworldAuthError) return "auth";
|
||||
if (err instanceof HttpError) {
|
||||
if (err.code === MAKERWORLD_AUTH_CODE) return "auth";
|
||||
if (err.code === MAKERWORLD_AUTH_CODE || err.code === SNAPMAKER_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";
|
||||
@@ -239,6 +250,80 @@ export async function runMakerworldProfilesImportJob(
|
||||
}
|
||||
}
|
||||
|
||||
/** The print profiles a scope picks from a Snapmaker model, as links that each name one. */
|
||||
async function snapmakerProfileUrls(
|
||||
url: string,
|
||||
scope: Exclude<MakerworldProfileScope, "url">,
|
||||
): Promise<{ title: string | null; urls: string[] }> {
|
||||
const parsed = parseSnapmakerModelUrl(url);
|
||||
if (!parsed) throw new HttpError(400, "Not a Snapmaker model link");
|
||||
const resolved = await resolveSnapmakerModel(parsed.modelId);
|
||||
if (!resolved) throw new HttpError(404, "This Snapmaker model could not be found, or isn't public.");
|
||||
const profileIds = selectSnapmakerProfiles(resolved, scope, parsed.requestedProfileId);
|
||||
if (!profileIds.length) throw new HttpError(400, "This model has no print profiles to import");
|
||||
return {
|
||||
title: resolved.meta.title ?? null,
|
||||
urls: profileIds.map((profileId) => snapmakerModelUrl(parsed.modelId, profileId)),
|
||||
};
|
||||
}
|
||||
|
||||
/** Imports several print profiles of one Snapmaker model as one model with a file per profile. */
|
||||
export async function runSnapmakerProfilesImportJob(
|
||||
jobId: string,
|
||||
userId: string,
|
||||
body: MakerworldProfilesImportJobBody,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { title, urls } = await snapmakerProfileUrls(body.url, body.scope);
|
||||
await updateJob(jobId, { total: urls.length, sourceLabel: title });
|
||||
log.info("snapmaker profiles job started", { job: jobId, url: body.url, profiles: urls.length });
|
||||
const totals = await importProfileUrlsSequentially(
|
||||
userId,
|
||||
urls,
|
||||
body,
|
||||
(t) =>
|
||||
void updateJob(jobId, {
|
||||
processed: t.processed,
|
||||
imported: t.imported,
|
||||
alreadyInLibrary: t.alreadyInLibrary,
|
||||
failedCount: t.failed,
|
||||
}).catch(() => undefined),
|
||||
);
|
||||
const { imported, alreadyInLibrary, failed, stopReason, printId } = totals;
|
||||
await updateJob(jobId, {
|
||||
status: "DONE",
|
||||
resultPrintId: printId,
|
||||
processed: totals.processed,
|
||||
imported,
|
||||
alreadyInLibrary,
|
||||
failedCount: failed,
|
||||
});
|
||||
void createLog({
|
||||
userId,
|
||||
action: "import_completed",
|
||||
targetId: printId,
|
||||
details: { provider: "snapmaker", sourceLabel: title, imported, alreadyInLibrary, failed },
|
||||
});
|
||||
const bodyParts: string[] = [];
|
||||
if (alreadyInLibrary) bodyParts.push(`${alreadyInLibrary} already on the model`);
|
||||
if (stopReason === "auth") bodyParts.push(`the rest failed because ${SNAPMAKER_AUTH_TEXT}`);
|
||||
else if (failed) bodyParts.push(`${failed} failed`);
|
||||
const label = title ? `"${title}"` : "a Snapmaker model";
|
||||
await createNotification(userId, {
|
||||
type: "import",
|
||||
title: `Imported ${imported} of ${urls.length} print profiles from Snapmaker Space`,
|
||||
body: bodyParts.length ? `Of ${label} — ${bodyParts.join(", ")}.` : `Of ${label}.`,
|
||||
externalUrl: body.url,
|
||||
internalPath: printId ? `/models/${printId}` : null,
|
||||
});
|
||||
} catch (err) {
|
||||
await markJobFailed(jobId, err);
|
||||
}
|
||||
}
|
||||
|
||||
const SNAPMAKER_AUTH_TEXT =
|
||||
"your Snapmaker sign-in ran out — visit space.snapmaker.com with Thingport Grab, then retry";
|
||||
|
||||
export async function runCollectionImportJob(
|
||||
jobId: string,
|
||||
userId: string,
|
||||
@@ -383,9 +468,10 @@ export async function runCollectionImportJob(
|
||||
}
|
||||
}
|
||||
|
||||
/** A provider whose models import one by one with an instance-wide token: Thingiverse, MyMiniFactory. */
|
||||
/** A provider whose models import one by one with a token: Thingiverse's and MyMiniFactory's instance-wide one, or
|
||||
* the user's own Snapmaker sign-in. */
|
||||
type TokenBatchProvider = {
|
||||
provider: "thingiverse" | "myminifactory";
|
||||
provider: "thingiverse" | "myminifactory" | "snapmaker";
|
||||
getToken: () => Promise<string | null>;
|
||||
notConfigured: string;
|
||||
modelUrl: (id: string) => string;
|
||||
@@ -414,7 +500,19 @@ const MYMINIFACTORY_BATCH: TokenBatchProvider = {
|
||||
authText: "failed because the configured API key was rejected",
|
||||
};
|
||||
|
||||
/** Shared by the Thingiverse Likes and Collection imports and MyMiniFactory's collections.
|
||||
function snapmakerBatch(userId: string): TokenBatchProvider {
|
||||
return {
|
||||
provider: "snapmaker",
|
||||
getToken: () => getUserSnapmakerToken(userId),
|
||||
notConfigured: SNAPMAKER_CONNECT,
|
||||
modelUrl: (id) => snapmakerModelUrl(id),
|
||||
rateLimitText: "blocked by Snapmaker's rate limit — wait a while, then retry",
|
||||
authText: `failed because ${SNAPMAKER_AUTH_TEXT}`,
|
||||
};
|
||||
}
|
||||
|
||||
/** Shared by the Thingiverse Likes and Collection imports, MyMiniFactory's collections and Snapmaker's likes and
|
||||
* bookmarks.
|
||||
* 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(
|
||||
@@ -584,6 +682,23 @@ export async function runMyminifactoryCollectionImportJob(
|
||||
);
|
||||
}
|
||||
|
||||
export async function runSnapmakerListImportJob(
|
||||
jobId: string,
|
||||
userId: string,
|
||||
body: SnapmakerListImportJobBody,
|
||||
): Promise<void> {
|
||||
const name = snapmakerListName(body.list);
|
||||
await runTokenBatchImportJob(
|
||||
jobId,
|
||||
userId,
|
||||
snapmakerBatch(userId),
|
||||
body,
|
||||
body.model_ids,
|
||||
async () => name,
|
||||
() => `your ${name}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function runPrintablesCollectionImportJob(
|
||||
jobId: string,
|
||||
userId: string,
|
||||
@@ -789,6 +904,13 @@ export function runnerSettled(jobId: string): Promise<void> {
|
||||
const LINKS_MAX_ATTEMPTS = 3;
|
||||
const LINKS_RETRY_DELAY_MS = 2000;
|
||||
|
||||
/** Why a link's sign-in failed, by its provider. */
|
||||
function authStopText(url: string): string {
|
||||
return parseSnapmakerModelUrl(url)
|
||||
? SNAPMAKER_AUTH_TEXT
|
||||
: "the MakerWorld session expired — update the cookie in Settings, then retry";
|
||||
}
|
||||
|
||||
function isTransientImportFailure(err: unknown): boolean {
|
||||
if (err instanceof MakerworldAuthError) return false;
|
||||
return !(err instanceof HttpError) || err.transient === true;
|
||||
@@ -798,7 +920,7 @@ function failureMessage(err: unknown): string {
|
||||
return err instanceof Error && err.message.trim() ? err.message.trim() : "Import failed";
|
||||
}
|
||||
|
||||
/** One queued link. A MakerWorld model with a multi-profile scope imports each wanted profile onto
|
||||
/** One queued link. A MakerWorld or Snapmaker model with a multi-profile scope imports each wanted profile onto
|
||||
* one print, like the PROFILES job; anything else is a plain single import. `profiles` marks the
|
||||
* former, whose totals count print profiles rather than models. */
|
||||
async function importOneLink(
|
||||
@@ -823,6 +945,10 @@ async function importOneLink(
|
||||
);
|
||||
return { ...totals, profiles: true };
|
||||
}
|
||||
if (parseSnapmakerModelUrl(url) && body.scope && body.scope !== "url") {
|
||||
const { urls } = await snapmakerProfileUrls(url, body.scope);
|
||||
return { ...(await importProfileUrlsSequentially(userId, urls, { ...body, url })), profiles: true };
|
||||
}
|
||||
const result = await importPrintFromUrl(userId, url, { ...body, url });
|
||||
return {
|
||||
processed: 1,
|
||||
@@ -897,7 +1023,7 @@ async function runLinks(jobId: string, userId: string, body: LinksImportJobBody)
|
||||
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",
|
||||
auth: "the MakerWorld session expired — update the cookie in Settings, then retry",
|
||||
auth: authStopText(stopUrl ?? item.url),
|
||||
blocked: BLOCKED_RETRY_TEXT,
|
||||
}[stopReason];
|
||||
settled = true;
|
||||
@@ -927,7 +1053,7 @@ async function runLinks(jobId: string, userId: string, body: LinksImportJobBody)
|
||||
itemError += {
|
||||
rateLimited: " (MakerWorld CAPTCHA challenge — retry once it clears)",
|
||||
downloadLimit: " (MakerWorld's daily download limit — retry tomorrow)",
|
||||
auth: " (the MakerWorld session expired — update the cookie in Settings, then retry)",
|
||||
auth: ` (${authStopText(item.url)})`,
|
||||
blocked: ` (${BLOCKED_RETRY_TEXT})`,
|
||||
}[result.stopReason];
|
||||
}
|
||||
@@ -1039,9 +1165,7 @@ async function runLinks(jobId: string, userId: string, body: LinksImportJobBody)
|
||||
`the rest not downloaded — MakerWorld's daily download limit was reached, retry the failed links tomorrow`,
|
||||
);
|
||||
} else if (stopReason === "auth") {
|
||||
bodyParts.push(
|
||||
`the rest failed because your MakerWorld session expired — update the cookie in Settings, then retry`,
|
||||
);
|
||||
bodyParts.push(`the rest failed because ${authStopText(stopUrl ?? job.sourceUrl)}`);
|
||||
} else if (stopReason === "blocked") {
|
||||
bodyParts.push(`the rest failed: ${BLOCKED_RETRY_TEXT}`);
|
||||
} else if (failedCount) {
|
||||
|
||||
@@ -490,7 +490,7 @@ export type ImportedPageMetadata = {
|
||||
author: ImportedAuthorInfo | null;
|
||||
/** The site's own category ids; each site has its own id namespace. */
|
||||
siteCategoryIds: number[];
|
||||
categorySite: "makerworld" | "thingiverse" | "printables" | "myminifactory" | null;
|
||||
categorySite: "makerworld" | "thingiverse" | "printables" | "myminifactory" | "snapmaker" | null;
|
||||
/** Lets a second profile of an imported design be added as its own plate. MakerWorld only. */
|
||||
makerworldProfile?: MakerworldProfileRef;
|
||||
};
|
||||
|
||||
@@ -83,9 +83,21 @@ import {
|
||||
resolveMyminifactoryObject,
|
||||
} from "./myminifactoryApi";
|
||||
import { getUserMyminifactoryCookie, markMyminifactoryCookieRejected } from "./myminifactorySessionService";
|
||||
import {
|
||||
fetchSnapmakerDownloads,
|
||||
isSnapmakerFileHost,
|
||||
parseSnapmakerModelUrl,
|
||||
resolveSnapmakerModel,
|
||||
selectSnapmakerProfiles,
|
||||
SnapmakerAuthError,
|
||||
type SnapmakerFile,
|
||||
} from "./snapmakerApi";
|
||||
import { getUserSnapmakerToken, markSnapmakerTokenRejected, SNAPMAKER_CONNECT } from "./snapmakerSessionService";
|
||||
|
||||
/** An import refused because the user's MyMiniFactory session has expired. */
|
||||
export const MYMINIFACTORY_SESSION_CODE = "myminifactory_session";
|
||||
/** An import refused because the user hasn't connected Snapmaker, or their sign-in has run out. */
|
||||
export const SNAPMAKER_SESSION_CODE = "snapmaker_session";
|
||||
import { listZipEntries, readZipEntry } from "../utils/zipReader";
|
||||
import { getMyminifactoryApiKey, getThingiverseAccessToken } from "./settingsService";
|
||||
import { upsertAuthorFromImport } from "./authorService";
|
||||
@@ -570,6 +582,7 @@ const CATEGORY_SITE_CAT_IDS_FIELD = {
|
||||
thingiverse: "thingiverseCatIds",
|
||||
printables: "printablesCatIds",
|
||||
myminifactory: "myminifactoryCatIds",
|
||||
snapmaker: "snapmakerCatIds",
|
||||
} as const;
|
||||
|
||||
/** Auto-categorizes an import when a category's `*CatIds` for this site overlap the model's own
|
||||
@@ -607,6 +620,15 @@ export function identifySourceModel(url: string): { provider: string; externalId
|
||||
if (printables) return { provider: "printables", externalId: printables.modelId };
|
||||
const myminifactory = parseMyminifactoryObjectUrl(url);
|
||||
if (myminifactory) return { provider: "myminifactory", externalId: myminifactory.objectId };
|
||||
const snapmaker = parseSnapmakerModelUrl(url);
|
||||
if (snapmaker) return { provider: "snapmaker", externalId: snapmaker.modelId };
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The print profile a MakerWorld or Snapmaker link names, if any. */
|
||||
function requestedProfileOf(provider: string, url: string): string | null {
|
||||
if (provider === "makerworld") return parseMakerworldModelUrl(url)?.requestedInstanceId ?? null;
|
||||
if (provider === "snapmaker") return parseSnapmakerModelUrl(url)?.requestedProfileId ?? null;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -635,8 +657,7 @@ export async function checkImportStatus(userId: string, url: string): Promise<Im
|
||||
const gaps = openSourceGaps(print, print.previewImages);
|
||||
// Without a profile in the URL, any imported profile counts; asking MakerWorld on every page
|
||||
// view isn't worth it.
|
||||
const requestedInstanceId =
|
||||
source.provider === "makerworld" ? parseMakerworldModelUrl(url)?.requestedInstanceId : null;
|
||||
const requestedInstanceId = requestedProfileOf(source.provider, url);
|
||||
if (requestedInstanceId && !print.plates.some((plate) => plate.sourceInstanceId === requestedInstanceId)) {
|
||||
const state = print.plates.some((plate) => plate.sourceInstanceId == null) ? "profile_unknown" : "profile_missing";
|
||||
return { recognized: true, already_imported: false, print_id: print.id, state, gaps };
|
||||
@@ -652,6 +673,7 @@ export function buildImportSourceUrl(provider: string | null, externalId: string
|
||||
if (provider === "printables") return `https://www.printables.com/model/${externalId}`;
|
||||
// MyMiniFactory redirects an object id with any slug, or none, to its page.
|
||||
if (provider === "myminifactory") return `https://www.myminifactory.com/object/3d-print-${externalId}`;
|
||||
if (provider === "snapmaker") return `https://space.snapmaker.com/en/model/${externalId}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -717,6 +739,18 @@ async function addMakerworldProfileToPrint(
|
||||
}
|
||||
|
||||
const { tempPath, filename, mime } = await saveImportResponseToTemp(opened, body);
|
||||
return addProfilePlate(existing, { filename, mime, tempFilePath: tempPath }, instanceId);
|
||||
}
|
||||
|
||||
/** Adds a downloaded profile's file to an imported print as another plate, or, when an untagged plate has the same
|
||||
* content, tags that one with the profile instead. Removes the temp file either way. */
|
||||
async function addProfilePlate(
|
||||
existing: ExistingImportedPrint,
|
||||
file: NewPlateInput,
|
||||
instanceId: string,
|
||||
): Promise<ExistingImportedPrint & { alreadyImported: boolean; profileAdded?: boolean }> {
|
||||
const alreadyImported = { ...existing, alreadyImported: true };
|
||||
const tempPath = file.tempFilePath!;
|
||||
try {
|
||||
const untagged = existing.plates.filter((plate) => plate.sourceInstanceId == null);
|
||||
if (untagged.length) {
|
||||
@@ -734,9 +768,7 @@ async function addMakerworldProfileToPrint(
|
||||
}
|
||||
|
||||
try {
|
||||
await addPlatesToPrint(existing.print.userId, existing.print.id, [
|
||||
{ filename, mime, tempFilePath: tempPath, sourceInstanceId: instanceId },
|
||||
]);
|
||||
await addPlatesToPrint(existing.print.userId, existing.print.id, [{ ...file, sourceInstanceId: instanceId }]);
|
||||
} catch (err) {
|
||||
// Race guard: a concurrent import of this same profile won.
|
||||
if (isUniqueConstraintError(err)) return alreadyImported;
|
||||
@@ -1204,13 +1236,175 @@ async function importMyminifactoryObject(
|
||||
}
|
||||
}
|
||||
|
||||
const SNAPMAKER_BLOCKED =
|
||||
"Snapmaker blocked the file download (rate limit). This usually clears after a while -- wait, then retry the same import.";
|
||||
|
||||
/** One of a Snapmaker model's signed files; a zip is unpacked into its model files. Null when it didn't download. */
|
||||
async function downloadSnapmakerFile(file: SnapmakerFile): Promise<NewPlateInput[] | null> {
|
||||
if (!isSnapmakerFileHost(new URL(file.url).hostname)) return null;
|
||||
const result = await downloadPlainFileToTemp(file.url, file.name);
|
||||
if (result && "rateLimited" in result) throw new HttpError(429, SNAPMAKER_BLOCKED);
|
||||
if (!result) return null;
|
||||
const ext = path.extname(result.input.filename).toLowerCase();
|
||||
if (ext === ".zip") {
|
||||
try {
|
||||
return await modelFilesFromZip(result.input.tempFilePath!);
|
||||
} finally {
|
||||
await fs.rm(result.input.tempFilePath!, { force: true }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
if (MULTI_FILE_PLATE_EXTS.has(ext)) return [result.input];
|
||||
await fs.rm(result.input.tempFilePath!, { force: true }).catch(() => undefined);
|
||||
return [];
|
||||
}
|
||||
|
||||
/** The user's token, or the HttpError that says to connect Snapmaker. */
|
||||
async function requireSnapmakerToken(userId: string): Promise<string> {
|
||||
const token = await getUserSnapmakerToken(userId);
|
||||
if (!token) throw new HttpError(400, SNAPMAKER_CONNECT, SNAPMAKER_SESSION_CODE);
|
||||
return token;
|
||||
}
|
||||
|
||||
/** Snapmaker's errors as the HttpErrors an import reports; a rejected token is remembered, so the app can say so. */
|
||||
async function snapmakerFailure(userId: string, err: unknown): Promise<unknown> {
|
||||
if (err instanceof SnapmakerAuthError) {
|
||||
await markSnapmakerTokenRejected(userId);
|
||||
// 400, not 401: see tryMakerworldCloudApi.
|
||||
return new HttpError(400, err.message, SNAPMAKER_SESSION_CODE);
|
||||
}
|
||||
return err;
|
||||
}
|
||||
|
||||
/** Downloads one print profile's 3MF, tagged with the profile; null when the profile isn't among the downloads. */
|
||||
async function downloadSnapmakerProfile(
|
||||
modelId: string,
|
||||
profileId: string,
|
||||
token: string,
|
||||
): Promise<NewPlateInput | null> {
|
||||
const downloads = await fetchSnapmakerDownloads(modelId, token);
|
||||
if (!downloads) throw new HttpError(404, "This Snapmaker model could not be found, or isn't public.");
|
||||
const file = downloads.profiles.find((profile) => profile.profileId === profileId);
|
||||
if (!file) return null;
|
||||
const inputs = await downloadSnapmakerFile(file);
|
||||
if (!inputs?.length) {
|
||||
throw new HttpError(400, "Couldn't download this print profile from Snapmaker. Try again later.");
|
||||
}
|
||||
// A profile is one 3MF; anything else it unpacked to is dropped.
|
||||
await Promise.all(inputs.slice(1).map((input) => fs.rm(input.tempFilePath!, { force: true }).catch(() => undefined)));
|
||||
return { ...inputs[0], sourceInstanceId: profileId };
|
||||
}
|
||||
|
||||
/** Like MakerWorld: a model is one print, and each print profile its 3MF on a plate of its own, tagged with the
|
||||
* profile. An import brings the profile the link names, else the designer's first; a model without profiles brings
|
||||
* its model files instead. Files only download as the user, so everything needs their Snapmaker sign-in. */
|
||||
async function importSnapmakerModel(
|
||||
userId: string,
|
||||
source: { provider: string; externalId: string },
|
||||
url: string,
|
||||
body: ImportRequestBody,
|
||||
): Promise<ImportResult> {
|
||||
const token = await requireSnapmakerToken(userId);
|
||||
let resolved;
|
||||
const downloaded: NewPlateInput[] = [];
|
||||
try {
|
||||
resolved = await resolveSnapmakerModel(source.externalId);
|
||||
if (!resolved) throw new HttpError(404, "This Snapmaker model could not be found, or isn't public.");
|
||||
const requested = body.resolved_instance_id ?? parseSnapmakerModelUrl(url)?.requestedProfileId ?? null;
|
||||
const [profileId] = selectSnapmakerProfiles(resolved, "url", requested);
|
||||
if (profileId) {
|
||||
const profile = await downloadSnapmakerProfile(source.externalId, profileId, token);
|
||||
if (profile) downloaded.push(profile);
|
||||
}
|
||||
if (!downloaded.length) {
|
||||
const downloads = await fetchSnapmakerDownloads(source.externalId, token);
|
||||
if (!downloads) throw new HttpError(404, "This Snapmaker model could not be found, or isn't public.");
|
||||
for (const file of downloads.modelFiles) downloaded.push(...((await downloadSnapmakerFile(file)) ?? []));
|
||||
}
|
||||
} catch (err) {
|
||||
await Promise.all(downloaded.map((input) => fs.rm(input.tempFilePath!, { force: true }).catch(() => undefined)));
|
||||
throw await snapmakerFailure(userId, err);
|
||||
}
|
||||
if (!downloaded.length) throw new HttpError(400, "This Snapmaker model has no files Thingport can import.");
|
||||
|
||||
const { meta, galleryImages } = resolved;
|
||||
try {
|
||||
const author = await upsertAuthorFromImport(meta.author ?? null);
|
||||
const categoryId =
|
||||
body.category_id ??
|
||||
(await resolveCategoryIdByCategory(userId, meta.categorySite ?? null, meta.siteCategoryIds ?? []));
|
||||
const printMeta: PrintMetaInput = {
|
||||
title: body.title ?? meta.title ?? null,
|
||||
notes: body.notes ?? meta.description ?? null,
|
||||
tags: body.tags && body.tags.length ? body.tags : (meta.tags ?? []),
|
||||
categoryId,
|
||||
categorySource: body.category_id ? "MANUAL" : categoryId ? "RULE" : null,
|
||||
creator: meta.creator ?? null,
|
||||
authorId: author?.id ?? null,
|
||||
sourceProvider: source.provider,
|
||||
sourceExternalId: source.externalId,
|
||||
};
|
||||
let created: { print: Print; plates: Plate[] };
|
||||
try {
|
||||
created = await createPrint(userId, printMeta, meta.title || `snapmaker-${source.externalId}`, downloaded);
|
||||
} catch (err) {
|
||||
// Race guard: a concurrent import of the same source model won.
|
||||
if (isUniqueConstraintError(err)) {
|
||||
const existing = await findExistingImportedPrint(userId, source);
|
||||
if (existing) return { ...existing, alreadyImported: true };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
await attachImportedPreviewImages(
|
||||
created.print.id,
|
||||
created.plates[0]?.id,
|
||||
meta.previewImageUrl ?? null,
|
||||
galleryImages,
|
||||
);
|
||||
await localizeDescriptionImages(created.print.id);
|
||||
const previewImages = await prisma.previewImage.findMany({
|
||||
where: { printId: created.print.id },
|
||||
orderBy: { position: "asc" },
|
||||
});
|
||||
return { ...created, author, previewImages, alreadyImported: false };
|
||||
} finally {
|
||||
for (const input of downloaded) {
|
||||
if (input.tempFilePath && fsSync.existsSync(input.tempFilePath)) {
|
||||
await fs.rm(input.tempFilePath, { force: true }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-import of a Snapmaker model naming a profile it doesn't have yet: that profile's 3MF becomes another plate. A
|
||||
* link without a profile, or with one already there, needs no request at all. */
|
||||
async function addSnapmakerProfileToPrint(
|
||||
existing: ExistingImportedPrint,
|
||||
url: string,
|
||||
body: ImportRequestBody,
|
||||
): Promise<ImportResult> {
|
||||
const wanted = body.resolved_instance_id ?? parseSnapmakerModelUrl(url)?.requestedProfileId ?? null;
|
||||
if (!wanted || existing.plates.some((plate) => plate.sourceInstanceId === wanted)) {
|
||||
return { ...existing, alreadyImported: true };
|
||||
}
|
||||
const userId = existing.print.userId;
|
||||
const token = await requireSnapmakerToken(userId);
|
||||
let profile: NewPlateInput | null;
|
||||
try {
|
||||
profile = await downloadSnapmakerProfile(existing.print.sourceExternalId!, wanted, token);
|
||||
} catch (err) {
|
||||
throw await snapmakerFailure(userId, err);
|
||||
}
|
||||
if (!profile) throw new HttpError(404, "This Snapmaker model has no such print profile.");
|
||||
return addProfilePlate(existing, profile, wanted);
|
||||
}
|
||||
|
||||
type ImportResult = {
|
||||
print: Print;
|
||||
plates: Plate[];
|
||||
author: Author | null;
|
||||
previewImages: PreviewImage[];
|
||||
alreadyImported: boolean;
|
||||
/** Set when an existing MakerWorld print gained another profile's file. */
|
||||
/** Set when an existing MakerWorld or Snapmaker print gained another profile's file. */
|
||||
profileAdded?: boolean;
|
||||
};
|
||||
|
||||
@@ -1224,11 +1418,14 @@ export async function importPrintFromUrl(userId: string, url: string, body: Impo
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Refuses a link from a provider an admin turned off, unless it's a model the user already has (MakerWorld adds a
|
||||
* profile, so fetches). */
|
||||
/** Providers whose re-import of a model may add a print profile, so fetches. */
|
||||
const PROFILE_PROVIDERS = new Set(["makerworld", "snapmaker"]);
|
||||
|
||||
/** Refuses a link from a provider an admin turned off, unless it's a model the user already has (MakerWorld and
|
||||
* Snapmaker add a profile, so fetch). */
|
||||
export async function assertCanImportUrl(userId: string, url: string): Promise<void> {
|
||||
const source = identifySourceModel(url);
|
||||
if (source && source.provider !== "makerworld" && (await findExistingImportedPrint(userId, source))) return;
|
||||
if (source && !PROFILE_PROVIDERS.has(source.provider) && (await findExistingImportedPrint(userId, source))) return;
|
||||
await assertUrlProviderEnabled(url);
|
||||
}
|
||||
|
||||
@@ -1237,8 +1434,9 @@ async function importNewOrExisting(userId: string, url: string, body: ImportRequ
|
||||
const existing = source ? await findExistingImportedPrint(userId, source) : null;
|
||||
// A model already in the library is still reported as such; anything that fetches from the provider is refused,
|
||||
// also for imports queued, or started by Grab, before it was turned off.
|
||||
if (existing && source?.provider !== "makerworld") return { ...existing, alreadyImported: true };
|
||||
if (existing && !PROFILE_PROVIDERS.has(source!.provider)) return { ...existing, alreadyImported: true };
|
||||
await assertUrlProviderEnabled(url);
|
||||
if (existing && source?.provider === "snapmaker") return addSnapmakerProfileToPrint(existing, url, body);
|
||||
if (existing) return addMakerworldProfileToPrint(existing, url, body);
|
||||
|
||||
if (source?.provider === "thingiverse") {
|
||||
@@ -1250,6 +1448,9 @@ async function importNewOrExisting(userId: string, url: string, body: ImportRequ
|
||||
if (source?.provider === "myminifactory") {
|
||||
return importMyminifactoryObject(userId, source, body);
|
||||
}
|
||||
if (source?.provider === "snapmaker") {
|
||||
return importSnapmakerModel(userId, source, url, body);
|
||||
}
|
||||
|
||||
const { tempPath, filename, mime, meta } = await downloadImportToTemp(url, body);
|
||||
const author = await upsertAuthorFromImport(meta.author);
|
||||
|
||||
@@ -7,7 +7,15 @@ import { LIKES_ID_PREFIX, type CollectionSyncOut, type SyncProfileScope } from "
|
||||
import { isSystemCollectionId } from "./collectionService";
|
||||
import { fetchListing, syncCollection } from "./collectionSyncRunner";
|
||||
import type { SyncIntervalHours } from "./collectionSyncSchedule";
|
||||
import { enableSync, parseSyncSource, type SyncProvider, updateSyncSettings } from "./collectionSyncService";
|
||||
import {
|
||||
enableSync,
|
||||
hasProfiles,
|
||||
parseSyncSource,
|
||||
type SyncProvider,
|
||||
updateSyncSettings,
|
||||
} from "./collectionSyncService";
|
||||
import { listSnapmakerCollections, SnapmakerAuthError } from "./snapmakerApi";
|
||||
import { getSnapmakerConnection, getUserSnapmakerId, getUserSnapmakerToken } from "./snapmakerSessionService";
|
||||
import { MakerworldAuthError, MakerworldCaptchaError, extractMakerworldBearerToken } from "./makerworldCloudApi";
|
||||
import { fetchMakerworldCollectionCard, listMakerworldOwnCollections } from "./makerworldCollections";
|
||||
import { getUserMakerworldCookie } from "./makerworldCookieService";
|
||||
@@ -42,7 +50,7 @@ import {
|
||||
} from "./thingiverseApi";
|
||||
import { assertProviderEnabled } from "./providerSettingsService";
|
||||
|
||||
export const BROWSABLE_PROVIDERS = ["makerworld", "printables", "thingiverse", "myminifactory"] as const;
|
||||
export const BROWSABLE_PROVIDERS = ["makerworld", "printables", "thingiverse", "myminifactory", "snapmaker"] as const;
|
||||
export type BrowsableProvider = (typeof BROWSABLE_PROVIDERS)[number];
|
||||
|
||||
/** `connected`: the instance can reach it for this user at all. `account`: who they are there, for
|
||||
@@ -65,7 +73,7 @@ export type ProviderCollectionOut = {
|
||||
};
|
||||
|
||||
export async function getProviderAccounts(userId: string): Promise<ProviderAccountsOut> {
|
||||
const [user, thingiverseToken, myminifactoryKey] = await Promise.all([
|
||||
const [user, thingiverseToken, myminifactoryKey, snapmaker] = await Promise.all([
|
||||
prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
@@ -77,12 +85,14 @@ export async function getProviderAccounts(userId: string): Promise<ProviderAccou
|
||||
}),
|
||||
getThingiverseAccessToken(),
|
||||
getMyminifactoryApiKey(),
|
||||
getSnapmakerConnection(userId),
|
||||
]);
|
||||
return {
|
||||
makerworld: { connected: Boolean(extractMakerworldBearerToken(user?.makerworldCookie)), account: null },
|
||||
printables: { connected: true, account: user?.printablesHandle ?? null },
|
||||
thingiverse: { connected: Boolean(thingiverseToken), account: user?.thingiverseUsername ?? null },
|
||||
myminifactory: { connected: Boolean(myminifactoryKey), account: user?.myminifactoryUsername ?? null },
|
||||
snapmaker: { connected: snapmaker === "connected", account: null },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -125,6 +135,8 @@ export async function setProviderAccount(
|
||||
const user = key ? await providerCall(() => resolveMyminifactoryUser(value!, key)) : null;
|
||||
if (value && !user) throw new HttpError(404, `MyMiniFactory has no user "${value}"`);
|
||||
await prisma.user.update({ where: { id: userId }, data: { myminifactoryUsername: user?.username ?? null } });
|
||||
} else if (provider === "snapmaker") {
|
||||
throw new HttpError(400, "Snapmaker Space is linked through its sign-in in Configuration > Providers");
|
||||
} else {
|
||||
throw new HttpError(400, "MakerWorld is linked through its cookie in Configuration > Providers");
|
||||
}
|
||||
@@ -160,10 +172,20 @@ async function providerCall<T>(call: () => Promise<T>): Promise<T> {
|
||||
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 SnapmakerAuthError) throw new HttpError(400, err.message);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** The user's likes and bookmarks, which only they can read, with their own sign-in. */
|
||||
async function snapmakerCards(userId: string): Promise<ProviderCollectionCard[]> {
|
||||
const [token, snapmakerId] = await Promise.all([getUserSnapmakerToken(userId), getUserSnapmakerId(userId)]);
|
||||
if (!token || !snapmakerId) {
|
||||
throw new HttpError(409, "Connect Snapmaker Space in Configuration > Providers, or visit it with Thingport Grab");
|
||||
}
|
||||
return providerCall(() => listSnapmakerCollections(token, snapmakerId));
|
||||
}
|
||||
|
||||
async function withSyncs(
|
||||
userId: string,
|
||||
provider: SyncProvider,
|
||||
@@ -214,6 +236,8 @@ export async function listProviderCollections(
|
||||
const listedIds = new Set(listed.map((card) => card.id));
|
||||
const privateOnes = (pushed.collections ?? []).filter((card) => card.private && !listedIds.has(card.id));
|
||||
cards = [...(pushed.likes ? [pushed.likes] : []), ...listed, ...privateOnes];
|
||||
} else if (provider === "snapmaker") {
|
||||
cards = await snapmakerCards(userId);
|
||||
} else if (provider === "myminifactory") {
|
||||
const key = await requireMyminifactoryKey();
|
||||
if (!user?.myminifactoryUsername) throw new HttpError(409, "Tell Thingport your MyMiniFactory username first");
|
||||
@@ -239,7 +263,10 @@ export async function listProviderCollections(
|
||||
export async function lookupProviderCollection(userId: string, url: string): Promise<ProviderCollectionOut> {
|
||||
const source = parseSyncSource(url);
|
||||
if (!source)
|
||||
throw new HttpError(400, "That isn't a MakerWorld, Printables, Thingiverse or MyMiniFactory collection link");
|
||||
throw new HttpError(
|
||||
400,
|
||||
"That isn't a MakerWorld, Printables, Thingiverse, MyMiniFactory or Snapmaker Space collection link",
|
||||
);
|
||||
await assertProviderEnabled(source.provider);
|
||||
let card: ProviderCollectionCard | null;
|
||||
if (source.likesOf && source.provider === "thingiverse") {
|
||||
@@ -263,6 +290,9 @@ export async function lookupProviderCollection(userId: string, url: string): Pro
|
||||
(await fetchPrintablesCollectionCard(source.externalId)) ??
|
||||
(await pushedPrintablesCards(userId)).collections?.find((pushed) => pushed.id === source.externalId) ??
|
||||
null;
|
||||
} 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;
|
||||
} else if (source.provider === "myminifactory") {
|
||||
const key = await requireMyminifactoryKey();
|
||||
const [username, slug] = source.externalId.split("/");
|
||||
@@ -291,10 +321,13 @@ export async function enableSyncFromApp(
|
||||
if (isSystemCollectionId(collectionId)) throw new HttpError(400, "This collection can't be synced");
|
||||
const source = parseSyncSource(input.url);
|
||||
if (!source)
|
||||
throw new HttpError(400, "That isn't a MakerWorld, Printables, Thingiverse or MyMiniFactory collection link");
|
||||
throw new HttpError(
|
||||
400,
|
||||
"That isn't a MakerWorld, Printables, Thingiverse, MyMiniFactory or Snapmaker Space collection link",
|
||||
);
|
||||
await assertProviderEnabled(source.provider);
|
||||
if (input.profileScope && source.provider !== "makerworld") {
|
||||
throw new HttpError(400, "Only MakerWorld models have print profiles to pick");
|
||||
if (input.profileScope && !hasProfiles(source.provider)) {
|
||||
throw new HttpError(400, "Only MakerWorld and Snapmaker Space models have print profiles to pick");
|
||||
}
|
||||
|
||||
let knownIds: string[] = [];
|
||||
|
||||
@@ -6,7 +6,7 @@ import { HttpError } from "../utils/fileUtils";
|
||||
// Grab, the queue, collection imports), its collection syncs pause, and background jobs leave it alone. Models
|
||||
// already imported from it stay as they are.
|
||||
|
||||
export const PROVIDERS = ["makerworld", "printables", "thingiverse", "myminifactory"] as const;
|
||||
export const PROVIDERS = ["makerworld", "printables", "thingiverse", "myminifactory", "snapmaker"] as const;
|
||||
export type Provider = (typeof PROVIDERS)[number];
|
||||
|
||||
const PROVIDER_NAMES: Record<Provider, string> = {
|
||||
@@ -14,6 +14,7 @@ const PROVIDER_NAMES: Record<Provider, string> = {
|
||||
printables: "Printables",
|
||||
thingiverse: "Thingiverse",
|
||||
myminifactory: "MyMiniFactory",
|
||||
snapmaker: "Snapmaker Space",
|
||||
};
|
||||
|
||||
const SETTING_KEY = "disabled_providers";
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
import { IMPORT_TIMEOUT_SECONDS } from "../config";
|
||||
import type { ImportedAuthorInfo, ImportedPageMetadata } from "./importResolvers";
|
||||
import { htmlToMarkdown } from "./descriptionMarkdown";
|
||||
import type { ProviderCollectionCard } from "./providerCollectionCard";
|
||||
|
||||
// Snapmaker Space has no public API, so this is the one its own pages call. Every answer is HTTP 200 with a `code` of
|
||||
// its own. Details are public, but their file links come signed for whoever asked, so files only download with the
|
||||
// user's access token.
|
||||
const SITE_ORIGIN = "https://space.snapmaker.com";
|
||||
const API_BASE = "https://api-models.snapmaker.com/api";
|
||||
const API_TIMEOUT_MS = IMPORT_TIMEOUT_SECONDS * 1000;
|
||||
const PROVIDER = "snapmaker";
|
||||
|
||||
const CODE_OK = 200;
|
||||
const CODE_UNAUTHORIZED = 110002;
|
||||
const CODE_NOT_FOUND = 603000;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function str(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function id(value: unknown): string | null {
|
||||
return typeof value === "number" || (typeof value === "string" && /^\d+$/.test(value)) ? String(value) : null;
|
||||
}
|
||||
|
||||
export class SnapmakerAuthError extends Error {
|
||||
constructor() {
|
||||
super(
|
||||
"Snapmaker didn't accept your sign-in; it may have expired. Visit space.snapmaker.com with Thingport Grab, " +
|
||||
"or connect it again in Configuration > Providers.",
|
||||
);
|
||||
this.name = "SnapmakerAuthError";
|
||||
}
|
||||
}
|
||||
|
||||
function parseUrl(url: string): URL | null {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.hostname.toLowerCase() === "space.snapmaker.com" ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** `/en/model/<id>-<slug>`, the slug and language prefix optional; `#profileId-<id>` picks a print profile. */
|
||||
export function parseSnapmakerModelUrl(url: string): { modelId: string; requestedProfileId: string | null } | null {
|
||||
const parsed = parseUrl(url);
|
||||
const m = parsed?.pathname.match(/^\/(?:[a-z]{2}(?:-[a-z]{2})?\/)?model\/(\d+)(?:-[^/]*)?\/?$/i);
|
||||
if (!m) return null;
|
||||
return { modelId: m[1], requestedProfileId: parsed!.hash.match(/profileId-(\d+)/i)?.[1] ?? null };
|
||||
}
|
||||
|
||||
export function snapmakerModelUrl(modelId: string, profileId?: string | null): string {
|
||||
return `${SITE_ORIGIN}/en/model/${modelId}${profileId ? `#profileId-${profileId}` : ""}`;
|
||||
}
|
||||
|
||||
export type SnapmakerList = "likes" | "bookmarks";
|
||||
|
||||
/** The user's likes and bookmarks share one page, `/creator/<id>?tab=interactions`, whose address doesn't change
|
||||
* between the two; `list` tells them apart in the addresses Thingport keeps. */
|
||||
export function parseSnapmakerListUrl(url: string): { userId: string; list: SnapmakerList | null } | null {
|
||||
const parsed = parseUrl(url);
|
||||
const m = parsed?.pathname.match(/^\/(?:[a-z]{2}(?:-[a-z]{2})?\/)?creator\/(\d+)\/?$/i);
|
||||
if (!m || parsed!.searchParams.get("tab") !== "interactions") return null;
|
||||
const list = parsed!.searchParams.get("list");
|
||||
return { userId: m[1], list: list === "likes" || list === "bookmarks" ? list : null };
|
||||
}
|
||||
|
||||
export function snapmakerListUrl(userId: string, list: SnapmakerList): string {
|
||||
return `${SITE_ORIGIN}/en/creator/${userId}?tab=interactions&list=${list}`;
|
||||
}
|
||||
|
||||
/** The id a sync keeps for the user's likes or bookmarks. */
|
||||
export function snapmakerListKey(userId: string, list: SnapmakerList): string {
|
||||
return `${list}-${userId}`;
|
||||
}
|
||||
|
||||
export function snapmakerListName(list: SnapmakerList): string {
|
||||
return list === "likes" ? "Snapmaker Space likes" : "Snapmaker Space bookmarks";
|
||||
}
|
||||
|
||||
async function fetchApi(path: string, token: string | null): Promise<{ code: number | null; data: unknown }> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), API_TIMEOUT_MS);
|
||||
let body: unknown;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
headers: { Accept: "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
||||
signal: controller.signal,
|
||||
});
|
||||
body = await res.json().catch(() => null);
|
||||
if (res.status === 401) throw new SnapmakerAuthError();
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
const code = isRecord(body) && typeof body.code === "number" ? body.code : null;
|
||||
if (code === CODE_UNAUTHORIZED) throw new SnapmakerAuthError();
|
||||
return { code, data: isRecord(body) ? body.data : null };
|
||||
}
|
||||
|
||||
/** Whether Snapmaker takes a token: the user's likes answer only to a signed-in user. */
|
||||
export async function checkSnapmakerToken(token: string): Promise<"ok" | "rejected" | "unreachable"> {
|
||||
try {
|
||||
const { code } = await fetchApi("/model/like/my?page=1&pageSize=1", token);
|
||||
return code === CODE_OK ? "ok" : "unreachable";
|
||||
} catch (err) {
|
||||
return err instanceof SnapmakerAuthError ? "rejected" : "unreachable";
|
||||
}
|
||||
}
|
||||
|
||||
export type SnapmakerProfile = { id: string; title: string; creatorId: string | null };
|
||||
|
||||
export type SnapmakerModelResolution = {
|
||||
meta: Partial<ImportedPageMetadata>;
|
||||
galleryImages: { url: string; filename: string }[];
|
||||
creatorId: string | null;
|
||||
profiles: SnapmakerProfile[];
|
||||
};
|
||||
|
||||
/** The model's category, then its parents up to the top level (Snapmaker's root, "Categories", left out), so a
|
||||
* subcategory beats its parent when both are configured. */
|
||||
function categoryChain(detail: Record<string, unknown>): number[] {
|
||||
const ids: number[] = [];
|
||||
const walk = (node: unknown) => {
|
||||
if (!isRecord(node)) return;
|
||||
if (typeof node.id === "number" && node.parentId != null) ids.unshift(node.id);
|
||||
if (Array.isArray(node.children)) node.children.forEach(walk);
|
||||
};
|
||||
walk(detail.categoryTree);
|
||||
if (!ids.length && typeof detail.categoryId === "number") ids.push(detail.categoryId);
|
||||
return ids;
|
||||
}
|
||||
|
||||
function creatorAuthor(detail: Record<string, unknown>): ImportedAuthorInfo | null {
|
||||
const creatorId = id(detail.creatorId);
|
||||
const name = str(detail.creatorName);
|
||||
if (!creatorId || !name) return null;
|
||||
return {
|
||||
provider: PROVIDER,
|
||||
externalId: creatorId,
|
||||
name,
|
||||
handle: null,
|
||||
bio: null,
|
||||
bioTranslated: null,
|
||||
links: [`${SITE_ORIGIN}/en/creator/${creatorId}`],
|
||||
avatarUrl: str(detail.creatorAvatar),
|
||||
backgroundUrl: null,
|
||||
};
|
||||
}
|
||||
|
||||
function imageName(url: string, index: number): string {
|
||||
return url.split("/").pop()?.split("?")[0] || `image-${index}.jpg`;
|
||||
}
|
||||
|
||||
/** The metadata mapping, apart from the fetch so it can be tested on its own. Snapmaker translates titles,
|
||||
* descriptions and tags into English; the translation is used where there is one. */
|
||||
export function snapmakerMetaFromDetail(detail: Record<string, unknown>): SnapmakerModelResolution {
|
||||
const meta: Partial<ImportedPageMetadata> = {};
|
||||
const title = str(detail.translatedTitle) ?? str(detail.title);
|
||||
if (title) meta.title = title;
|
||||
const description = str(detail.translatedDescription) ?? str(detail.description);
|
||||
if (description) {
|
||||
const markdown = htmlToMarkdown(description)?.trim();
|
||||
if (markdown) meta.description = markdown;
|
||||
}
|
||||
const rawTags =
|
||||
Array.isArray(detail.translatedTags) && detail.translatedTags.length ? detail.translatedTags : detail.tags;
|
||||
if (Array.isArray(rawTags)) {
|
||||
const tags = [...new Set(rawTags.flatMap((tag) => (str(tag) ? [str(tag)!] : [])))];
|
||||
if (tags.length) meta.tags = tags;
|
||||
}
|
||||
const cover = str(detail.coverUrl);
|
||||
if (cover) meta.previewImageUrl = cover;
|
||||
const author = creatorAuthor(detail);
|
||||
if (author) {
|
||||
meta.author = author;
|
||||
meta.creator = author.name;
|
||||
}
|
||||
const categoryIds = categoryChain(detail);
|
||||
if (categoryIds.length) {
|
||||
meta.siteCategoryIds = categoryIds;
|
||||
meta.categorySite = PROVIDER;
|
||||
}
|
||||
const images = Array.isArray(detail.images) ? detail.images.flatMap((url) => (str(url) ? [str(url)!] : [])) : [];
|
||||
const profiles = Array.isArray(detail.printProfiles)
|
||||
? detail.printProfiles.flatMap((profile) => {
|
||||
if (!isRecord(profile) || !id(profile.id)) return [];
|
||||
return [
|
||||
{
|
||||
id: id(profile.id)!,
|
||||
title: str(profile.translatedTitle) ?? str(profile.title) ?? `Profile ${String(profile.id)}`,
|
||||
creatorId: id(profile.creatorId),
|
||||
},
|
||||
];
|
||||
})
|
||||
: [];
|
||||
return {
|
||||
meta,
|
||||
galleryImages: images.map((url, index) => ({ url, filename: imageName(url, index) })),
|
||||
creatorId: id(detail.creatorId),
|
||||
profiles,
|
||||
};
|
||||
}
|
||||
|
||||
/** Null for a model that doesn't exist or isn't public. */
|
||||
export async function resolveSnapmakerModel(modelId: string): Promise<SnapmakerModelResolution | null> {
|
||||
const { code, data } = await fetchApi(`/model/detail/${encodeURIComponent(modelId)}`, null);
|
||||
if (code === CODE_NOT_FOUND) return null;
|
||||
if (code !== CODE_OK || !isRecord(data)) throw new Error(`Couldn't read this Snapmaker model (code ${code})`);
|
||||
return snapmakerMetaFromDetail(data);
|
||||
}
|
||||
|
||||
export type SnapmakerFile = { name: string; url: string };
|
||||
export type SnapmakerDownloads = { modelFiles: SnapmakerFile[]; profiles: (SnapmakerFile & { profileId: string })[] };
|
||||
|
||||
/** Snapmaker writes spaces in file names as `+`. */
|
||||
function fileName(value: unknown, fallback: string): string {
|
||||
return (str(value) ?? fallback).replace(/\+/g, " ");
|
||||
}
|
||||
|
||||
/** Signed links to the model's files and its print profiles' 3MFs, for the token's user. Null for a model that
|
||||
* doesn't exist. Throws SnapmakerAuthError for a token Snapmaker doesn't take. */
|
||||
export async function fetchSnapmakerDownloads(modelId: string, token: string): Promise<SnapmakerDownloads | null> {
|
||||
const { code, data } = await fetchApi(`/model/download/${encodeURIComponent(modelId)}`, token);
|
||||
if (code === CODE_NOT_FOUND) return null;
|
||||
if (code !== CODE_OK || !isRecord(data)) throw new Error(`Couldn't get this Snapmaker model's files (code ${code})`);
|
||||
const modelFiles = Array.isArray(data.modelFiles)
|
||||
? data.modelFiles.flatMap((file, index) =>
|
||||
isRecord(file) && str(file.downloadUrl)
|
||||
? [{ name: fileName(file.fileName, `snapmaker-${modelId}-${index}`), url: str(file.downloadUrl)! }]
|
||||
: [],
|
||||
)
|
||||
: [];
|
||||
const profiles = Array.isArray(data.printProfiles)
|
||||
? data.printProfiles.flatMap((file) =>
|
||||
isRecord(file) && id(file.profileId) && str(file.downloadUrl)
|
||||
? [
|
||||
{
|
||||
profileId: id(file.profileId)!,
|
||||
name: fileName(file.fileName, `snapmaker-${modelId}-${String(file.profileId)}.3mf`),
|
||||
url: str(file.downloadUrl)!,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
)
|
||||
: [];
|
||||
return { modelFiles, profiles };
|
||||
}
|
||||
|
||||
/** The signed hosts Snapmaker's file links point at, and where they redirect. */
|
||||
export function isSnapmakerFileHost(hostname: string): boolean {
|
||||
return /^(models|space)\.snapmaker\.com$/i.test(hostname);
|
||||
}
|
||||
|
||||
export type SnapmakerProfileScope = "url" | "designer" | "all";
|
||||
|
||||
/** Which profiles an import brings, as for MakerWorld: "url" the one the link names, else the designer's first (else
|
||||
* the first); "designer" every one the designer made; "all" every one. */
|
||||
export function selectSnapmakerProfiles(
|
||||
resolution: Pick<SnapmakerModelResolution, "creatorId" | "profiles">,
|
||||
scope: SnapmakerProfileScope,
|
||||
requestedProfileId: string | null,
|
||||
): string[] {
|
||||
const { profiles, creatorId } = resolution;
|
||||
const designers = profiles.filter((profile) => profile.creatorId === creatorId);
|
||||
if (scope === "all") return profiles.map((profile) => profile.id);
|
||||
if (scope === "designer") return (designers.length ? designers : profiles.slice(0, 1)).map((profile) => profile.id);
|
||||
const requested = requestedProfileId && profiles.find((profile) => profile.id === requestedProfileId);
|
||||
const pick = requested || designers[0] || profiles[0];
|
||||
return pick ? [pick.id] : [];
|
||||
}
|
||||
|
||||
export type SnapmakerModelSummary = { modelId: string; title: string; cover: string | null };
|
||||
|
||||
const PAGE_SIZE = 30;
|
||||
const LISTING_MAX_ENTRIES = 600;
|
||||
const CARD_THUMBNAILS = 4;
|
||||
|
||||
function listPath(list: SnapmakerList, page: number, pageSize: number): string {
|
||||
return `/model/${list === "likes" ? "like" : "collect"}/my?page=${page}&pageSize=${pageSize}`;
|
||||
}
|
||||
|
||||
/** The token's user's likes or bookmarks, newest first. Deleted models are left out: they can't be imported. */
|
||||
export async function fetchSnapmakerList(
|
||||
list: SnapmakerList,
|
||||
token: string,
|
||||
maxItems: number = LISTING_MAX_ENTRIES,
|
||||
): Promise<{ entries: SnapmakerModelSummary[]; total: number; truncated: boolean }> {
|
||||
const entries: SnapmakerModelSummary[] = [];
|
||||
const seen = new Set<string>();
|
||||
let total = 0;
|
||||
for (let page = 1; entries.length < maxItems; page++) {
|
||||
const { code, data } = await fetchApi(listPath(list, page, PAGE_SIZE), token);
|
||||
if (code !== CODE_OK || !isRecord(data)) throw new Error(`Couldn't read your Snapmaker ${list} (code ${code})`);
|
||||
total = typeof data.total === "number" ? data.total : total;
|
||||
const items = Array.isArray(data.list) ? data.list.filter(isRecord) : [];
|
||||
for (const item of items) {
|
||||
const modelId = id(item.id);
|
||||
if (!modelId || item.is_deleted === true || seen.has(modelId)) continue;
|
||||
seen.add(modelId);
|
||||
entries.push({
|
||||
modelId,
|
||||
title: str(item.translatedTitle) ?? str(item.title) ?? `Model ${modelId}`,
|
||||
cover: str(item.cover_url),
|
||||
});
|
||||
if (entries.length >= maxItems) break;
|
||||
}
|
||||
if (items.length < PAGE_SIZE || page * PAGE_SIZE >= total) break;
|
||||
}
|
||||
return { entries, total, truncated: entries.length >= maxItems && total > entries.length };
|
||||
}
|
||||
|
||||
/** The user's likes and bookmarks, as the sync dialog's Browse tab shows them. */
|
||||
export async function listSnapmakerCollections(token: string, userId: string): Promise<ProviderCollectionCard[]> {
|
||||
const cards: ProviderCollectionCard[] = [];
|
||||
for (const list of ["likes", "bookmarks"] as const) {
|
||||
const { entries, total } = await fetchSnapmakerList(list, token, CARD_THUMBNAILS);
|
||||
cards.push({
|
||||
id: snapmakerListKey(userId, list),
|
||||
name: snapmakerListName(list),
|
||||
url: snapmakerListUrl(userId, list),
|
||||
modelCount: total,
|
||||
thumbnails: entries.flatMap((entry) => (entry.cover ? [entry.cover] : [])),
|
||||
private: true,
|
||||
owner: null,
|
||||
});
|
||||
}
|
||||
return cards;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { prisma } from "../db";
|
||||
|
||||
// Per-user: the Snapmaker access token that downloads files and reads the user's likes and bookmarks. It lasts a
|
||||
// day. Snapmaker's refresh tokens work once, and using one signs out whoever held it before, so Thingport never
|
||||
// refreshes: Thingport Grab keeps the browser's login fresh and sends each new access token here.
|
||||
|
||||
const ACCESS_COOKIE = "login_access_token";
|
||||
|
||||
export type SnapmakerToken = { token: string; expiresAt: Date | null; userId: string | null };
|
||||
|
||||
function jwtPayload(token: string): Record<string, unknown> | null {
|
||||
const part = token.split(".")[1];
|
||||
if (!part) return null;
|
||||
try {
|
||||
const payload: unknown = JSON.parse(Buffer.from(part, "base64url").toString("utf8"));
|
||||
return payload && typeof payload === "object" && !Array.isArray(payload)
|
||||
? (payload as Record<string, unknown>)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The access token from a bare token, an `Authorization: Bearer` value or a whole Cookie header; null when there's
|
||||
* none. */
|
||||
export function normalizeSnapmakerToken(raw: string | null | undefined): SnapmakerToken | null {
|
||||
let text = (raw ?? "")
|
||||
.replace(/^\s*(cookie|authorization):\s*/i, "")
|
||||
.replace(/^\s*bearer\s+/i, "")
|
||||
.trim();
|
||||
const pair = text
|
||||
.split(";")
|
||||
.map((part) => part.trim())
|
||||
.find((part) => part.startsWith(`${ACCESS_COOKIE}=`));
|
||||
if (pair) text = decodeURIComponent(pair.slice(ACCESS_COOKIE.length + 1));
|
||||
if (!/^[\w-]+\.[\w-]+\.[\w-]+$/.test(text)) return null;
|
||||
const payload = jwtPayload(text);
|
||||
if (!payload) return null;
|
||||
const exp = typeof payload.exp === "number" ? new Date(payload.exp * 1000) : null;
|
||||
const userId =
|
||||
typeof payload.userId === "number" || typeof payload.userId === "string" ? String(payload.userId) : null;
|
||||
return { token: text, expiresAt: exp, userId };
|
||||
}
|
||||
|
||||
/** The user's token while it's still valid; null once it has expired, so nothing asks Snapmaker with it in vain. */
|
||||
export async function getUserSnapmakerToken(userId: string): Promise<string | null> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { snapmakerToken: true, snapmakerTokenExpiresAt: true },
|
||||
});
|
||||
if (!user?.snapmakerToken) return null;
|
||||
if (user.snapmakerTokenExpiresAt && user.snapmakerTokenExpiresAt.getTime() <= Date.now()) return null;
|
||||
return user.snapmakerToken;
|
||||
}
|
||||
|
||||
export async function getUserSnapmakerId(userId: string): Promise<string | null> {
|
||||
const user = await prisma.user.findUnique({ where: { id: userId }, select: { snapmakerUserId: true } });
|
||||
return user?.snapmakerUserId ?? null;
|
||||
}
|
||||
|
||||
/** A new token clears an earlier rejection. The Snapmaker user id is kept on disconnect, so syncs stay addressable. */
|
||||
export async function setUserSnapmakerToken(userId: string, token: SnapmakerToken | null): Promise<boolean> {
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: token
|
||||
? {
|
||||
snapmakerToken: token.token,
|
||||
snapmakerTokenExpiresAt: token.expiresAt,
|
||||
snapmakerRejectedAt: null,
|
||||
...(token.userId ? { snapmakerUserId: token.userId } : {}),
|
||||
}
|
||||
: { snapmakerToken: null, snapmakerTokenExpiresAt: null, snapmakerRejectedAt: null },
|
||||
});
|
||||
return Boolean(token);
|
||||
}
|
||||
|
||||
export async function markSnapmakerTokenRejected(userId: string): Promise<void> {
|
||||
await prisma.user.update({ where: { id: userId }, data: { snapmakerRejectedAt: new Date() } });
|
||||
}
|
||||
|
||||
export type SnapmakerConnection = "connected" | "disconnected" | "expired";
|
||||
|
||||
export async function getSnapmakerConnection(userId: string): Promise<SnapmakerConnection> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { snapmakerToken: true, snapmakerTokenExpiresAt: true, snapmakerRejectedAt: true },
|
||||
});
|
||||
if (!user?.snapmakerToken) return "disconnected";
|
||||
const expired = user.snapmakerTokenExpiresAt && user.snapmakerTokenExpiresAt.getTime() <= Date.now();
|
||||
return user.snapmakerRejectedAt || expired ? "expired" : "connected";
|
||||
}
|
||||
|
||||
/** Shown wherever a Snapmaker download needs a login the user hasn't given, or that has run out. */
|
||||
export const SNAPMAKER_CONNECT =
|
||||
"Snapmaker only gives its files to a signed-in user: visit space.snapmaker.com with Thingport Grab (it keeps your " +
|
||||
"sign-in fresh), or connect it in Configuration > Providers, then import it again.";
|
||||
@@ -30,7 +30,7 @@ beforeAll(async () => {
|
||||
|
||||
// Files share one database: every provider is back on afterwards, even when a test fails.
|
||||
afterAll(async () => {
|
||||
for (const provider of ["makerworld", "printables", "thingiverse", "myminifactory"] as const)
|
||||
for (const provider of ["makerworld", "printables", "thingiverse", "myminifactory", "snapmaker"] as const)
|
||||
await setProviderEnabled(provider, true);
|
||||
});
|
||||
|
||||
@@ -43,6 +43,7 @@ describe("turning providers off", () => {
|
||||
printables: true,
|
||||
thingiverse: true,
|
||||
myminifactory: true,
|
||||
snapmaker: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
@@ -59,6 +60,7 @@ describe("turning providers off", () => {
|
||||
printables: true,
|
||||
thingiverse: false,
|
||||
myminifactory: true,
|
||||
snapmaker: true,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
fetchSnapmakerDownloads,
|
||||
fetchSnapmakerList,
|
||||
parseSnapmakerListUrl,
|
||||
parseSnapmakerModelUrl,
|
||||
selectSnapmakerProfiles,
|
||||
SnapmakerAuthError,
|
||||
snapmakerMetaFromDetail,
|
||||
} from "../src/services/snapmakerApi";
|
||||
import { modelIdFromUrl, modelUrlFor, parseSyncSource } from "../src/services/collectionSyncService";
|
||||
import { normalizeSnapmakerToken } from "../src/services/snapmakerSessionService";
|
||||
import { buildImportSourceUrl, identifySourceModel } from "../src/services/importService";
|
||||
|
||||
// The Snapmaker Space client against the shapes its own site gets back, with fetch mocked. No database.
|
||||
|
||||
function apiResponse(code: number, data: unknown = null, msg = "success"): Response {
|
||||
// Snapmaker answers every call with HTTP 200 and its own code.
|
||||
return new Response(JSON.stringify({ code, msg, data }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function jwt(payload: Record<string, unknown>): string {
|
||||
const part = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url");
|
||||
return `${part({ alg: "RS256" })}.${part(payload)}.signature_part-1`;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("Snapmaker Space URLs", () => {
|
||||
it("recognizes model pages, with a slug, without one, and with a print profile picked", () => {
|
||||
expect(parseSnapmakerModelUrl("https://space.snapmaker.com/en/model/33595-sugar-skull-princess")).toEqual({
|
||||
modelId: "33595",
|
||||
requestedProfileId: null,
|
||||
});
|
||||
expect(parseSnapmakerModelUrl("https://space.snapmaker.com/model/33595")).toEqual({
|
||||
modelId: "33595",
|
||||
requestedProfileId: null,
|
||||
});
|
||||
expect(parseSnapmakerModelUrl("https://space.snapmaker.com/en/model/33595#profileId-37940")).toEqual({
|
||||
modelId: "33595",
|
||||
requestedProfileId: "37940",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores other pages and other sites", () => {
|
||||
expect(parseSnapmakerModelUrl("https://space.snapmaker.com/en/models")).toBeNull();
|
||||
expect(parseSnapmakerModelUrl("https://www.snapmaker.com/en/model/33595")).toBeNull();
|
||||
expect(parseSnapmakerModelUrl("https://space.snapmaker.com/en/creator/119349")).toBeNull();
|
||||
});
|
||||
|
||||
it("tells the likes and bookmarks lists apart on the shared interactions page", () => {
|
||||
expect(parseSnapmakerListUrl("https://space.snapmaker.com/en/creator/183998?tab=interactions")).toEqual({
|
||||
userId: "183998",
|
||||
list: null,
|
||||
});
|
||||
expect(
|
||||
parseSnapmakerListUrl("https://space.snapmaker.com/en/creator/183998?tab=interactions&list=bookmarks"),
|
||||
).toEqual({ userId: "183998", list: "bookmarks" });
|
||||
expect(parseSnapmakerListUrl("https://space.snapmaker.com/en/creator/183998")).toBeNull();
|
||||
});
|
||||
|
||||
it("identifies imported models and links back to them", () => {
|
||||
const source = identifySourceModel("https://space.snapmaker.com/en/model/33595-sugar-skull#profileId-37940");
|
||||
expect(source).toEqual({ provider: "snapmaker", externalId: "33595" });
|
||||
expect(buildImportSourceUrl("snapmaker", "33595")).toBe("https://space.snapmaker.com/en/model/33595");
|
||||
expect(modelIdFromUrl("snapmaker", modelUrlFor("snapmaker", "33595"))).toBe("33595");
|
||||
});
|
||||
|
||||
it("syncs the user's likes and bookmarks as two sources, but not the page without a list", () => {
|
||||
expect(parseSyncSource("https://space.snapmaker.com/en/creator/183998?tab=interactions&list=likes")).toEqual({
|
||||
provider: "snapmaker",
|
||||
externalId: "likes-183998",
|
||||
url: "https://space.snapmaker.com/en/creator/183998?tab=interactions&list=likes",
|
||||
});
|
||||
expect(parseSyncSource("https://space.snapmaker.com/en/creator/183998?tab=interactions")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
const DETAIL = {
|
||||
id: "33595",
|
||||
title: "ПринцессаВГриме",
|
||||
translatedTitle: "Sugar Skull Princess",
|
||||
description: "<p>Оригинал</p>",
|
||||
translatedDescription: "<p>This <strong>Sugar Skull Princess</strong> is a collectible.</p>",
|
||||
coverUrl: "https://cdn-model.snapmaker.com/snapmaker/model/20260918/119349/cover.jpg",
|
||||
images: [
|
||||
"https://cdn-model.snapmaker.com/snapmaker/model/20260918/119349/a.jpg",
|
||||
"https://cdn-model.snapmaker.com/snapmaker/model/20260918/119349/b.jpg",
|
||||
],
|
||||
tags: ["готика"],
|
||||
translatedTags: ["gothic collectible", "halloween decor", "gothic collectible"],
|
||||
categoryId: 159,
|
||||
categoryTree: {
|
||||
id: 1,
|
||||
parentId: null,
|
||||
nameKey: "Categories",
|
||||
children: [
|
||||
{
|
||||
id: 110,
|
||||
parentId: 1,
|
||||
nameKey: "Miniatures",
|
||||
children: [{ id: 159, parentId: 110, nameKey: "Creatures", children: [] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
creatorId: "119349",
|
||||
creatorName: "Decorartor",
|
||||
creatorAvatar: "https://cdn-model.snapmaker.com/snapmaker/avatar/119349/avatar.jpg",
|
||||
printProfiles: [
|
||||
{ id: "37940", title: "snapmaker_2plates", translatedTitle: "2 plates", creatorId: "119349" },
|
||||
{ id: "40001", title: "remix", creatorId: "555" },
|
||||
],
|
||||
};
|
||||
|
||||
describe("Snapmaker model details", () => {
|
||||
it("maps the English translation, the category chain most specific first, the creator and the profiles", () => {
|
||||
const { meta, galleryImages, creatorId, profiles } = snapmakerMetaFromDetail(DETAIL);
|
||||
expect(meta.title).toBe("Sugar Skull Princess");
|
||||
expect(meta.description).toBe("This **Sugar Skull Princess** is a collectible.");
|
||||
expect(meta.tags).toEqual(["gothic collectible", "halloween decor"]);
|
||||
expect(meta.previewImageUrl).toBe(DETAIL.coverUrl);
|
||||
expect(meta.siteCategoryIds).toEqual([159, 110]);
|
||||
expect(meta.categorySite).toBe("snapmaker");
|
||||
expect(meta.author).toMatchObject({
|
||||
provider: "snapmaker",
|
||||
externalId: "119349",
|
||||
name: "Decorartor",
|
||||
links: ["https://space.snapmaker.com/en/creator/119349"],
|
||||
});
|
||||
expect(galleryImages.map((image) => image.filename)).toEqual(["a.jpg", "b.jpg"]);
|
||||
expect(creatorId).toBe("119349");
|
||||
expect(profiles).toEqual([
|
||||
{ id: "37940", title: "2 plates", creatorId: "119349" },
|
||||
{ id: "40001", title: "remix", creatorId: "555" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("picks profiles like MakerWorld: the linked one, else the designer's; the designer's; or all", () => {
|
||||
const resolution = snapmakerMetaFromDetail(DETAIL);
|
||||
expect(selectSnapmakerProfiles(resolution, "url", null)).toEqual(["37940"]);
|
||||
expect(selectSnapmakerProfiles(resolution, "url", "40001")).toEqual(["40001"]);
|
||||
expect(selectSnapmakerProfiles(resolution, "url", "999")).toEqual(["37940"]);
|
||||
expect(selectSnapmakerProfiles(resolution, "designer", null)).toEqual(["37940"]);
|
||||
expect(selectSnapmakerProfiles(resolution, "all", null)).toEqual(["37940", "40001"]);
|
||||
expect(selectSnapmakerProfiles({ creatorId: "1", profiles: [] }, "url", null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Snapmaker downloads", () => {
|
||||
it("returns signed links for the model files and each profile, with Snapmaker's + spaces undone", async () => {
|
||||
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(
|
||||
apiResponse(200, {
|
||||
modelFiles: [{ fileName: "Herald+of+the+Storm.stl", downloadUrl: "https://models.snapmaker.com/a.stl?sign=1" }],
|
||||
printProfiles: [
|
||||
{ profileId: "40699", fileName: "Herald.3mf", downloadUrl: "https://models.snapmaker.com/b.3mf?sign=2" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const downloads = await fetchSnapmakerDownloads("36590", "token");
|
||||
expect(downloads).toEqual({
|
||||
modelFiles: [{ name: "Herald of the Storm.stl", url: "https://models.snapmaker.com/a.stl?sign=1" }],
|
||||
profiles: [{ profileId: "40699", name: "Herald.3mf", url: "https://models.snapmaker.com/b.3mf?sign=2" }],
|
||||
});
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("https://api-models.snapmaker.com/api/model/download/36590");
|
||||
expect((init as RequestInit).headers).toMatchObject({ Authorization: "Bearer token" });
|
||||
});
|
||||
|
||||
it("throws SnapmakerAuthError for a token Snapmaker turns down, and gives null for a missing model", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn<typeof fetch>().mockResolvedValue(apiResponse(110002, null, "Missing authorization")));
|
||||
await expect(fetchSnapmakerDownloads("36590", "expired")).rejects.toBeInstanceOf(SnapmakerAuthError);
|
||||
vi.stubGlobal("fetch", vi.fn<typeof fetch>().mockResolvedValue(apiResponse(603000, null, "Model not found")));
|
||||
expect(await fetchSnapmakerDownloads("1", "token")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Snapmaker likes and bookmarks", () => {
|
||||
it("reads every page of the list, leaving deleted models out", async () => {
|
||||
const item = (id: number, extra: Record<string, unknown> = {}) => ({
|
||||
id: String(id),
|
||||
title: `Model ${id}`,
|
||||
cover_url: `https://cdn-model.snapmaker.com/${id}.jpg`,
|
||||
is_deleted: false,
|
||||
...extra,
|
||||
});
|
||||
const first = Array.from({ length: 30 }, (_, i) => item(i + 1));
|
||||
first[2] = item(3, { is_deleted: true });
|
||||
const fetchMock = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(apiResponse(200, { list: first, total: 32, page: 1, pageSize: 30 }))
|
||||
.mockResolvedValueOnce(apiResponse(200, { list: [item(31), item(32)], total: 32, page: 2, pageSize: 30 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const listing = await fetchSnapmakerList("bookmarks", "token");
|
||||
expect(listing.total).toBe(32);
|
||||
expect(listing.entries).toHaveLength(31);
|
||||
expect(listing.entries.some((entry) => entry.modelId === "3")).toBe(false);
|
||||
expect(fetchMock.mock.calls[0][0]).toBe("https://api-models.snapmaker.com/api/model/collect/my?page=1&pageSize=30");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Snapmaker sign-in", () => {
|
||||
const token = jwt({ exp: 1_800_000_000, userId: 183998 });
|
||||
|
||||
it("takes the access token bare, as a Bearer header or from the whole Cookie header", () => {
|
||||
const expected = { token, expiresAt: new Date(1_800_000_000 * 1000), userId: "183998" };
|
||||
expect(normalizeSnapmakerToken(token)).toEqual(expected);
|
||||
expect(normalizeSnapmakerToken(`Bearer ${token}`)).toEqual(expected);
|
||||
expect(
|
||||
normalizeSnapmakerToken(`login_refresh_token=abc; login_access_token=${encodeURIComponent(token)}; other=1`),
|
||||
).toEqual(expected);
|
||||
});
|
||||
|
||||
it("refuses anything that isn't a token", () => {
|
||||
expect(normalizeSnapmakerToken("")).toBeNull();
|
||||
expect(normalizeSnapmakerToken("not a token")).toBeNull();
|
||||
expect(normalizeSnapmakerToken("a.b.c")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import request from "supertest";
|
||||
import { createApp } from "../src/app";
|
||||
import { importPrintFromUrl } from "../src/services/importService";
|
||||
import { prisma } from "../src/db";
|
||||
import { getSnapmakerConnection, setUserSnapmakerToken } from "../src/services/snapmakerSessionService";
|
||||
|
||||
// Snapmaker Space import end to end: details and signed downloads in the shapes its site gets, fetch mocked.
|
||||
|
||||
const MODEL_ID = "9990033";
|
||||
const MODEL_URL = `https://space.snapmaker.com/en/model/${MODEL_ID}-test-princess`;
|
||||
const API = "https://api-models.snapmaker.com/api";
|
||||
const DESIGNER_3MF = "https://models.snapmaker.com/models/download/snapmaker/model-file/designer.3mf?sign=a";
|
||||
const REMIX_3MF = "https://models.snapmaker.com/models/download/snapmaker/model-file/remix.3mf?sign=b";
|
||||
const MODEL_STL = "https://models.snapmaker.com/models/download/snapmaker/model-file/princess.stl?sign=c";
|
||||
const IMAGE_URL = "https://cdn-model.snapmaker.com/snapmaker/model/test/cover.png";
|
||||
|
||||
const ONE_PIXEL_PNG = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||
"base64",
|
||||
);
|
||||
|
||||
const PROFILES = [
|
||||
{ id: "501", title: "Designer's", creatorId: "777" },
|
||||
{ id: "502", title: "Community remix", creatorId: "888" },
|
||||
];
|
||||
|
||||
function detail(profiles = PROFILES) {
|
||||
return {
|
||||
id: MODEL_ID,
|
||||
title: "Test Princess",
|
||||
description: "<p>A <strong>princess</strong>.</p>",
|
||||
coverUrl: IMAGE_URL,
|
||||
images: [IMAGE_URL],
|
||||
tags: ["princess"],
|
||||
categoryId: 159,
|
||||
categoryTree: {
|
||||
id: 1,
|
||||
parentId: null,
|
||||
children: [{ id: 110, parentId: 1, children: [{ id: 159, parentId: 110, children: [] }] }],
|
||||
},
|
||||
creatorId: "777",
|
||||
creatorName: "Test Designer",
|
||||
printProfiles: profiles,
|
||||
};
|
||||
}
|
||||
|
||||
function apiResponse(code: number, data: unknown = null): Response {
|
||||
return new Response(JSON.stringify({ code, msg: code === 200 ? "success" : "error", data }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function mockSnapmaker(opts: { profiles?: typeof PROFILES; downloadCode?: number } = {}) {
|
||||
return vi.fn<(input: RequestInfo | URL) => Promise<Response>>(async (input) => {
|
||||
const url = String(input);
|
||||
if (url === `${API}/model/detail/${MODEL_ID}`) return apiResponse(200, detail(opts.profiles));
|
||||
if (url === `${API}/model/download/${MODEL_ID}`) {
|
||||
if (opts.downloadCode) return apiResponse(opts.downloadCode);
|
||||
return apiResponse(200, {
|
||||
modelFiles: [{ fileName: "princess.stl", downloadUrl: MODEL_STL }],
|
||||
printProfiles: (opts.profiles ?? PROFILES).map((profile) => ({
|
||||
profileId: profile.id,
|
||||
fileName: `${profile.id}.3mf`,
|
||||
downloadUrl: profile.id === "501" ? DESIGNER_3MF : REMIX_3MF,
|
||||
})),
|
||||
});
|
||||
}
|
||||
// Snapmaker serves files as text/html whatever they are.
|
||||
if (url === DESIGNER_3MF)
|
||||
return new Response("designer 3mf", { status: 200, headers: { "content-type": "text/html" } });
|
||||
if (url === REMIX_3MF) return new Response("remix 3mf", { status: 200, headers: { "content-type": "text/html" } });
|
||||
if (url === MODEL_STL) return new Response("solid princess\nendsolid princess\n", { status: 200 });
|
||||
if (url === IMAGE_URL)
|
||||
return new Response(ONE_PIXEL_PNG, { status: 200, headers: { "content-type": "image/png" } });
|
||||
throw new Error(`Unexpected fetch to ${url}`);
|
||||
}) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
function token(): { token: string; expiresAt: Date; userId: string } {
|
||||
const part = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url");
|
||||
const exp = Math.floor(Date.now() / 1000) + 3600;
|
||||
return {
|
||||
token: `${part({ alg: "RS256" })}.${part({ exp, userId: 183998 })}.sig`,
|
||||
expiresAt: new Date(exp * 1000),
|
||||
userId: "183998",
|
||||
};
|
||||
}
|
||||
|
||||
describe("importPrintFromUrl -- Snapmaker Space", () => {
|
||||
const app = createApp();
|
||||
let userId: string;
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
beforeAll(async () => {
|
||||
const email = `snapmaker-import-test-${Date.now()}@example.com`;
|
||||
const res = await request(app)
|
||||
.post("/api/register")
|
||||
.send({ displayName: "Snapmaker Import Test", email, password: "password123" });
|
||||
if (res.status !== 200) throw new Error(`Failed to register: ${res.status} ${JSON.stringify(res.body)}`);
|
||||
userId = res.body.user.id;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
global.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
await setUserSnapmakerToken(userId, null);
|
||||
await prisma.print.deleteMany({ where: { userId, sourceProvider: "snapmaker" } });
|
||||
});
|
||||
|
||||
it("asks to connect Snapmaker when there's no sign-in, before calling Snapmaker", async () => {
|
||||
const fetchMock = mockSnapmaker();
|
||||
global.fetch = fetchMock;
|
||||
await expect(importPrintFromUrl(userId, MODEL_URL, { url: MODEL_URL, tags: [] })).rejects.toThrow(
|
||||
/Snapmaker.*Configuration > Providers/,
|
||||
);
|
||||
expect(vi.mocked(fetchMock)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("imports the designer's print profile as one plate, tagged, and files it by its category", async () => {
|
||||
await setUserSnapmakerToken(userId, token());
|
||||
const creatures = await prisma.category.findFirst({
|
||||
where: { userId, name: "Creatures", parent: { name: "Miniatures" } },
|
||||
});
|
||||
global.fetch = mockSnapmaker();
|
||||
const result = await importPrintFromUrl(userId, MODEL_URL, { url: MODEL_URL, tags: [] });
|
||||
expect(result.alreadyImported).toBe(false);
|
||||
expect(result.print.title).toBe("Test Princess");
|
||||
expect(result.print.notes).toBe("A **princess**.");
|
||||
expect(result.print.sourceProvider).toBe("snapmaker");
|
||||
expect(result.print.sourceExternalId).toBe(MODEL_ID);
|
||||
expect(creatures).not.toBeNull();
|
||||
expect(result.print.categoryId).toBe(creatures!.id);
|
||||
expect(result.plates.map((plate) => plate.sourceInstanceId)).toEqual(["501"]);
|
||||
expect(result.author?.name).toBe("Test Designer");
|
||||
});
|
||||
|
||||
it("adds another profile on a re-import naming it, and makes no request when nothing new is named", async () => {
|
||||
await setUserSnapmakerToken(userId, token());
|
||||
global.fetch = mockSnapmaker();
|
||||
await importPrintFromUrl(userId, MODEL_URL, { url: MODEL_URL, tags: [] });
|
||||
|
||||
const fetchMock = mockSnapmaker();
|
||||
global.fetch = fetchMock;
|
||||
const again = await importPrintFromUrl(userId, MODEL_URL, { url: MODEL_URL, tags: [] });
|
||||
expect(again.alreadyImported).toBe(true);
|
||||
expect(vi.mocked(fetchMock)).not.toHaveBeenCalled();
|
||||
|
||||
const remixUrl = `${MODEL_URL}#profileId-502`;
|
||||
const added = await importPrintFromUrl(userId, remixUrl, { url: remixUrl, tags: [] });
|
||||
expect(added.profileAdded).toBe(true);
|
||||
expect(added.plates.map((plate) => plate.sourceInstanceId).toSorted()).toEqual(["501", "502"]);
|
||||
});
|
||||
|
||||
it("brings the model files when the model has no print profiles", async () => {
|
||||
await setUserSnapmakerToken(userId, token());
|
||||
global.fetch = mockSnapmaker({ profiles: [] });
|
||||
const result = await importPrintFromUrl(userId, MODEL_URL, { url: MODEL_URL, tags: [] });
|
||||
expect(result.plates).toHaveLength(1);
|
||||
expect(result.plates[0].sourceInstanceId).toBeNull();
|
||||
expect(result.plates[0].filename).toMatch(/princess\.stl$/);
|
||||
});
|
||||
|
||||
it("remembers a sign-in Snapmaker turns down, so the app can say it ran out", async () => {
|
||||
await setUserSnapmakerToken(userId, token());
|
||||
global.fetch = mockSnapmaker({ downloadCode: 110002 });
|
||||
await expect(importPrintFromUrl(userId, MODEL_URL, { url: MODEL_URL, tags: [] })).rejects.toThrow(/Snapmaker/);
|
||||
expect(await getSnapmakerConnection(userId)).toBe("expired");
|
||||
});
|
||||
});
|
||||
+32
-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.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -46,6 +46,36 @@ With [Thingport Grab](../extension/README.md) allowed on MyMiniFactory, opening
|
||||
|
||||
This is your own MyMiniFactory login session -- don't share it. It expires once you sign out of MyMiniFactory, or after a while; Thingport then says so, and opening MyMiniFactory with Thingport Grab connects it again. A paid model you haven't bought can't be imported: Thingport Grab says so on its page.
|
||||
|
||||
## Snapmaker Space sign-in
|
||||
|
||||
Per-user (Configuration > Providers > Snapmaker Space). Snapmaker gives its files only to signed-in users, so every
|
||||
Snapmaker import, and reading your likes and bookmarks, runs as you. A model comes in like a MakerWorld one: its print
|
||||
profile's 3MF, as a file tagged with the profile, and importing it again with another profile (or picking the
|
||||
designer's or all profiles) adds more; a model without print profiles brings its model files instead.
|
||||
|
||||
Snapmaker's sign-in lasts a day, and its renewal can be used only once: whoever renews it signs out anyone else still
|
||||
holding the old one. So Thingport never renews it itself.
|
||||
|
||||
### Easiest: Thingport Grab
|
||||
|
||||
With [Thingport Grab](../extension/README.md) allowed on Snapmaker Space, opening space.snapmaker.com while you're
|
||||
signed in connects your account. From then on Grab renews the sign-in for your browser and Thingport together, every
|
||||
few hours while the browser is open, so imports and synced likes and bookmarks keep working without another visit.
|
||||
If the browser stays closed for a day, Thingport says the sign-in ran out, and the next visit connects it again.
|
||||
|
||||
### Manual: copy it from your browser
|
||||
|
||||
1. Sign in at [space.snapmaker.com](https://space.snapmaker.com).
|
||||
2. Open DevTools (`F12`, or `Cmd+Option+I` on macOS).
|
||||
3. Chrome/Edge: **Application** tab > **Cookies** > `https://space.snapmaker.com`. Firefox: **Storage** tab > **Cookies**.
|
||||
4. Copy the value of the cookie named `login_access_token`. (Pasting the whole `Cookie` header, or an
|
||||
`Authorization: Bearer` value, works too.)
|
||||
5. Right-click **Snapmaker Space** in Thingport's **Configuration > Providers**, choose **Connect** and paste it.
|
||||
Thingport checks it with Snapmaker before storing it.
|
||||
|
||||
A sign-in pasted by hand lasts a day; only Grab renews it. Don't copy `login_refresh_token`: renewing with it would sign
|
||||
your browser out of Snapmaker.
|
||||
|
||||
## 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.
|
||||
@@ -68,7 +98,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 four are on by
|
||||
Admins choose which providers this Thingport imports from, under **Administration → Providers**. All five 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 or MyMiniFactory 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 or Snapmaker Space 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,25 +1,25 @@
|
||||
# Import from MakerWorld, Printables, Thingiverse and MyMiniFactory
|
||||
# Import from MakerWorld, Printables, Thingiverse, MyMiniFactory and Snapmaker Space
|
||||
|
||||
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.
|
||||
|
||||
## Ways to import
|
||||
|
||||
- **Paste a link.** Click **Add → Import** and paste a model link from MakerWorld, Printables, Thingiverse or
|
||||
MyMiniFactory, or any direct link to a model file. Paste several links, one per line, and they're imported one after
|
||||
- **Paste a link.** Click **Add → Import** and paste a model link from MakerWorld, Printables, Thingiverse,
|
||||
MyMiniFactory or Snapmaker Space, 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 four sites (MyMiniFactory once you allow it, 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.
|
||||
all five sites (MyMiniFactory and Snapmaker Space once you allow them, with a single click in its popup), so you can
|
||||
import without leaving the page, file the model into a collection, and import whole collections.
|
||||
- **Import a collection or your likes.** Paste a collection link, or your Thingiverse likes, and pick which models to
|
||||
bring in. MakerWorld collections are imported with Thingport Grab.
|
||||
bring in. Your Snapmaker Space likes and bookmarks import from your profile's Interactions tab with Thingport Grab. MakerWorld collections are imported with Thingport Grab.
|
||||
- **Keep a collection in sync.** A [synced collection](../guides/synced-collections.md) imports models added to it on
|
||||
the provider later, on its own.
|
||||
|
||||
## What comes with a model
|
||||
|
||||
- Every file, and on MakerWorld, its print profiles: the one the link points to, all of the designer's, or the
|
||||
community's too. Each profile becomes its own file on the model.
|
||||
- Every file, and on MakerWorld and Snapmaker Space, its print profiles: the one the link points to, all of the
|
||||
designer's, or the community's too. Each profile becomes its own file on the model.
|
||||
- The photo gallery, the formatted description, tags and the source site's category, which Thingport maps to your own
|
||||
[categories](organize-and-search.md).
|
||||
- The author, with their avatar and profile, linked to an [author page](authors.md).
|
||||
@@ -37,7 +37,8 @@ from Thingport Grab wait in **Administration → Import queue** until they're st
|
||||
## Before you start
|
||||
|
||||
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. See
|
||||
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. 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,6 +1,7 @@
|
||||
# Synced collections
|
||||
|
||||
A synced collection follows a collection on MakerWorld, Printables, Thingiverse or MyMiniFactory. When a model is added
|
||||
A synced collection follows a collection on MakerWorld, Printables, Thingiverse or MyMiniFactory, 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.
|
||||
|
||||
@@ -28,20 +29,23 @@ page again.
|
||||
- Connect the provider under **Configuration → Providers** where it needs it (see
|
||||
[provider setup](../PROVIDER_SETUP.md)). Printables needs nothing. MakerWorld needs your session cookie to import
|
||||
models, and to read a private collection. Thingiverse needs an App Token, and MyMiniFactory an API key. A
|
||||
MyMiniFactory collection's paid models are left out of its sync.
|
||||
MyMiniFactory collection's paid models are left out of its sync. Snapmaker Space needs your own sign-in, which
|
||||
Thingport Grab keeps fresh; if Grab's browser stays closed for a day it runs out, and Snapmaker syncs wait until
|
||||
your next visit there.
|
||||
|
||||
## Turn sync on in Thingport
|
||||
|
||||
1. Open the Thingport collection that should follow one on a provider, or create it first.
|
||||
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. Or paste any
|
||||
on Printables, Thingiverse and MyMiniFactory, the public ones of the username you give the first time; on Snapmaker
|
||||
Space, your likes and bookmarks. 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 which
|
||||
print profiles each new model brings, before you do.
|
||||
4. Pick a collection and click **Sync with "…"**. **Settings** sets how often it's checked, and for MakerWorld and
|
||||
Snapmaker Space 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 and
|
||||
MyMiniFactory they're imported now, one at a time. MakerWorld rate-limits bulk imports quickly, so there only models
|
||||
What happens to the models already in the provider's collection depends on the provider. On Printables, Thingiverse,
|
||||
MyMiniFactory and Snapmaker Space they're imported now, one at a time. MakerWorld rate-limits bulk imports quickly, so there only models
|
||||
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.
|
||||
|
||||
@@ -56,7 +60,8 @@ read it, and its models come in on that next visit.
|
||||
## Turn sync on with Thingport Grab
|
||||
|
||||
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.
|
||||
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.
|
||||
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).
|
||||
|
||||
+24
-13
@@ -1,9 +1,9 @@
|
||||
# Thingport Grab privacy policy
|
||||
|
||||
_Effective 18 October 2026._
|
||||
_Effective 19 October 2026._
|
||||
|
||||
Thingport Grab is a browser extension that imports 3D-printing models from MakerWorld, Printables,
|
||||
Thingiverse and, once you allow it, MyMiniFactory into **your own self-hosted Thingport instance**, and converts MakerWorld files for
|
||||
Thingiverse and, once you allow them, MyMiniFactory and Snapmaker Space 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.
|
||||
|
||||
@@ -28,6 +28,8 @@ the list of recent imports, which is synced between your browsers as described b
|
||||
storage on each device; it isn't synced.
|
||||
- **Your Printables, Thingiverse and MyMiniFactory usernames,** as last sent to your Thingport instance, so they're
|
||||
only sent again when they change, and when it last read your Printables collections and likes.
|
||||
- **Your Snapmaker Space sign-in (its access token),** 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.
|
||||
@@ -40,7 +42,7 @@ the **provider site you're on**.
|
||||
**To your Thingport instance:**
|
||||
|
||||
- Your email and password, to sign in.
|
||||
- The address of each model page you open on MakerWorld, Printables, Thingiverse or MyMiniFactory, to check
|
||||
- The address of each model page you open on MakerWorld, Printables, Thingiverse, MyMiniFactory or Snapmaker Space, 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.
|
||||
@@ -56,6 +58,9 @@ the **provider site you're on**.
|
||||
- If you're logged into MyMiniFactory in this browser, and have allowed the extension there: your MyMiniFactory
|
||||
session cookie (`PHPSESSID`), when you open a MyMiniFactory page and it changed since last sent. Your Thingport
|
||||
instance uses it to download the models in your MyMiniFactory library, such as ones you bought.
|
||||
- If you're signed in to Snapmaker Space in this browser, and have allowed the extension there: your Snapmaker access
|
||||
token, when you open a Snapmaker Space page and every few hours while the browser is open, whenever it changed since
|
||||
last sent. Your Thingport instance uses it to download Snapmaker models and to read your likes and bookmarks there.
|
||||
- If you're logged into Printables, Thingiverse or MyMiniFactory in this browser: your username there, when it
|
||||
first sees it or it changes, so Thingport's web app can list your collections on that site when
|
||||
you pick one to sync with.
|
||||
@@ -64,7 +69,7 @@ the **provider site you're on**.
|
||||
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 and MyMiniFactory:**
|
||||
**To MakerWorld, Printables, Thingiverse, MyMiniFactory and Snapmaker Space:**
|
||||
|
||||
- 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.
|
||||
@@ -80,6 +85,11 @@ the **provider site you're on**.
|
||||
logged in as, the same way the page does, using your existing Printables session. At most once an
|
||||
hour it also asks it, the same way, for your collections, your liked models and the models in
|
||||
the private collections you sync.
|
||||
- On Snapmaker Space, once you've allowed it there: Snapmaker's sign-in lasts a day, so every few hours while the
|
||||
browser is open, when it's running out, the extension renews it at Snapmaker's own sign-in service (id.snapmaker.com)
|
||||
the same way the site does, and writes the renewed sign-in back into the site's own cookies, so you stay signed in
|
||||
there. When you open the panel on a model page, it also asks Snapmaker's public model API which print profiles the
|
||||
model has.
|
||||
|
||||
**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
|
||||
@@ -95,15 +105,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' and MyMiniFactory's session 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 pages, and those of sites added later, once you allow them. |
|
||||
| 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, 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' and MyMiniFactory'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 and Snapmaker Space's pages, and those of sites added later, once you allow them. |
|
||||
| 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, and sites added in later updates). You can take this access back in your browser's extension settings. |
|
||||
| Access to your Thingport instance | Requested for that one address when you set the extension up, so it can talk to your instance. |
|
||||
|
||||
## Your control over your data
|
||||
|
||||
|
||||
+14
-5
@@ -1,7 +1,7 @@
|
||||
# Thingport Grab
|
||||
|
||||
A browser extension for Chrome, Edge and Firefox that imports MakerWorld, Thingiverse, Printables
|
||||
and MyMiniFactory models into your self-hosted Thingport instance without leaving the provider's site.
|
||||
MyMiniFactory and Snapmaker Space 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.
|
||||
@@ -182,12 +182,21 @@ Opening MyMiniFactory while signed in also connects your MyMiniFactory account t
|
||||
bought, and free ones that only download when signed in, import from your library. On a paid model you haven't
|
||||
bought, the panel says so instead of offering an import.
|
||||
|
||||
### Snapmaker Space: a sign-in Grab keeps fresh
|
||||
|
||||
Snapmaker gives its files only to signed-in users, and its sign-in lasts a day. Opening space.snapmaker.com while
|
||||
signed in connects your account to Thingport, and from then on Grab renews the sign-in every few hours while the
|
||||
browser is open -- for the browser and Thingport together, writing the new one back into the site's own cookies. Only
|
||||
Grab may renew it: a Snapmaker sign-in can be renewed once, and renewing it twice would sign the browser out. On your
|
||||
own profile's **Interactions** tab, the panel imports your likes or bookmarks (a switch picks which), and can keep
|
||||
either in sync.
|
||||
|
||||
## What counts as "importable"
|
||||
|
||||
- A single model page (MakerWorld, a Thingiverse Thing, a Printables Model, a MyMiniFactory object) -- hidden automatically
|
||||
once you've already imported that exact page.
|
||||
- A MakerWorld collection, a Thingiverse Collection or Likes page, or a Printables or MyMiniFactory collection --
|
||||
lets you import the listed designs in one go.
|
||||
- A single model page (MakerWorld, a Thingiverse Thing, a Printables Model, a MyMiniFactory object, a Snapmaker Space
|
||||
model) -- hidden automatically once you've already imported that exact page.
|
||||
- A MakerWorld collection, a Thingiverse Collection or Likes page, a Printables or MyMiniFactory collection, 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 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 and Snapmaker Space 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,9 +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. Both are sent only to the user's own server.
|
||||
- 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. All are sent only to the user's own server.
|
||||
- downloads: reads the link from a download MakerWorld's page starts, then cancels it.
|
||||
- scripting: registers the content script on MyMiniFactory, and on model sites added in later versions, once the user allows them.
|
||||
- host access: the three model sites; the user's own server address (requested at setup); and, optionally, newer model sites (such as MyMiniFactory), each requested only when the user clicks "Enable" next to it under Sites 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.
|
||||
- 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 and Snapmaker Space, and on model sites added in later versions, once the user allows them.
|
||||
- host access: the three model sites; the user's own server address (requested at setup); and, optionally, newer model sites (such as MyMiniFactory and Snapmaker Space), 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
|
||||
@@ -29,9 +29,10 @@ export function buildManifest(target: Target, pkg: { version: string; descriptio
|
||||
default_icon: iconSet("dark"),
|
||||
},
|
||||
background: target === "firefox" ? { scripts: ["background.js"] } : { service_worker: "background.js" },
|
||||
// scripting: the content script for sites the user turns on later (src/shared/optionalSites.ts). It carries no
|
||||
// install warning, so adding it didn't need everyone to approve the update.
|
||||
permissions: ["storage", "cookies", "downloads", "scripting"],
|
||||
// scripting: the content script for sites the user turns on later (src/shared/optionalSites.ts). alarms: renewing
|
||||
// the Snapmaker sign-in on a timer (src/background/snapmakerSession.ts). Neither carries an install warning, so
|
||||
// adding them didn't need everyone to approve the update.
|
||||
permissions: ["storage", "cookies", "downloads", "scripting", "alarms"],
|
||||
// chrome.cookies needs these as host permissions; content script matches don't count. Their
|
||||
// session cookies are HttpOnly, so only the background can read them.
|
||||
host_permissions: COOKIE_HOSTS,
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { getPrintablesToken, printablesPushDue, pushPrintables, reportProviderAccount } from "./providerAccounts";
|
||||
import { getRecentImports } from "./recentImports";
|
||||
import { syncMyminifactorySession } from "./myminifactorySession";
|
||||
import { scheduleSnapmakerRenewal, SNAPMAKER_ALARM, syncSnapmakerSession } from "./snapmakerSession";
|
||||
import { onPairingTabClosed, onPairingTabUrl, startPairing } from "./pairing";
|
||||
import { openSetup } from "./setup";
|
||||
import { setTabIconState } from "./tabIcon";
|
||||
@@ -38,6 +39,7 @@ listen<BackgroundMessages>({
|
||||
GET_PRINTABLES_TOKEN: () => getPrintablesToken(),
|
||||
REPORT_PROVIDER_ACCOUNT: ({ provider, value }) => reportProviderAccount(provider, value),
|
||||
SYNC_MYMINIFACTORY_SESSION: () => syncMyminifactorySession(),
|
||||
SYNC_SNAPMAKER_SESSION: () => syncSnapmakerSession(),
|
||||
PRINTABLES_PUSH_DUE: ({ now }) => printablesPushDue({ now }),
|
||||
PUSH_PRINTABLES: (push) => pushPrintables(push),
|
||||
OPEN_SETUP: (_payload, sender) => openSetup(sender.tab?.id),
|
||||
@@ -82,8 +84,18 @@ chrome.permissions.onAdded.addListener(() => {
|
||||
void syncSiteScripts().then(async () => {
|
||||
for (const site of OPTIONAL_SITES) if (await siteEnabled(site)) await injectIntoOpenTabs(site);
|
||||
});
|
||||
void scheduleSnapmakerRenewal();
|
||||
});
|
||||
chrome.permissions.onRemoved.addListener(() => {
|
||||
void syncSiteScripts();
|
||||
void scheduleSnapmakerRenewal();
|
||||
});
|
||||
|
||||
// Snapmaker's sign-in lasts a day: renewed on a timer too, so syncs keep working on days Snapmaker isn't visited.
|
||||
void scheduleSnapmakerRenewal();
|
||||
chrome.alarms.onAlarm.addListener((alarm) => {
|
||||
if (alarm.name === SNAPMAKER_ALARM) void syncSnapmakerSession();
|
||||
});
|
||||
chrome.permissions.onRemoved.addListener(() => void syncSiteScripts());
|
||||
|
||||
chrome.tabs.onRemoved.addListener((tabId) => {
|
||||
void dropJobIfForTab(tabId);
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
// The user's Snapmaker Space sign-in, which downloads its files and reads their likes and bookmarks. Snapmaker's
|
||||
// access token lasts a day, and its refresh tokens work once: whoever renews with one signs out anyone else holding
|
||||
// it. So Grab is the only one that renews, for the browser and Thingport both: it writes the new tokens back into the
|
||||
// browser's cookies, where the site picks them up, and sends the access token on. Thingport never renews it itself.
|
||||
|
||||
import { STORAGE_KEYS } from "../shared/storage";
|
||||
import { OPTIONAL_SITES, siteEnabled } from "../shared/optionalSites";
|
||||
import { apiCall, requireConfig } from "./api";
|
||||
|
||||
const SITE = "https://space.snapmaker.com";
|
||||
const ACCESS_COOKIE = "login_access_token";
|
||||
const REFRESH_COOKIE = "login_refresh_token";
|
||||
const TOKEN_URL = "https://id.snapmaker.com/api/oauth2/token";
|
||||
// The site's own public client, as its pages send it ("mall-app"); it isn't a secret.
|
||||
const CLIENT_AUTH = "Basic bWFsbC1hcHA6MTIzNDU2";
|
||||
// As the site sets them: the access token's cookie for 8 hours, the refresh token's for 30 days.
|
||||
const ACCESS_COOKIE_MS = 8 * 60 * 60 * 1000;
|
||||
const REFRESH_COOKIE_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
// Renewed this long before the token Thingport holds runs out, so a check that misses a beat still keeps it valid.
|
||||
const RENEW_BEFORE_MS = 8 * 60 * 60 * 1000;
|
||||
|
||||
export const SNAPMAKER_ALARM = "snapmaker-session";
|
||||
const ALARM_PERIOD_MINUTES = 120;
|
||||
|
||||
function tokenPayload(token: string): { exp: number | null; userId: string | null } {
|
||||
try {
|
||||
const part = token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/");
|
||||
const payload = JSON.parse(atob(part)) as { exp?: unknown; userId?: unknown };
|
||||
return {
|
||||
exp: typeof payload.exp === "number" ? payload.exp * 1000 : null,
|
||||
userId: typeof payload.userId === "number" || typeof payload.userId === "string" ? String(payload.userId) : null,
|
||||
};
|
||||
} catch {
|
||||
return { exp: null, userId: null };
|
||||
}
|
||||
}
|
||||
|
||||
async function readCookie(name: string): Promise<string | null> {
|
||||
try {
|
||||
const cookie = await chrome.cookies.get({ url: SITE, name });
|
||||
return cookie?.value ? decodeURIComponent(cookie.value) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeCookie(name: string, value: string, lifetimeMs: number): Promise<void> {
|
||||
await chrome.cookies.set({
|
||||
url: SITE,
|
||||
name,
|
||||
value: encodeURIComponent(value),
|
||||
domain: ".snapmaker.com",
|
||||
path: "/",
|
||||
secure: true,
|
||||
sameSite: "no_restriction",
|
||||
expirationDate: (Date.now() + lifetimeMs) / 1000,
|
||||
});
|
||||
}
|
||||
|
||||
/** A new access token for the browser's refresh token, written back where the site reads it. Null when Snapmaker
|
||||
* turns it down: the user signed out, or the site renewed it a moment before. */
|
||||
async function renew(): Promise<string | null> {
|
||||
// Read right before it's spent: a tab of the site may have just renewed it.
|
||||
const refresh = await readCookie(REFRESH_COOKIE);
|
||||
if (!refresh) return null;
|
||||
const res = await fetch(TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: CLIENT_AUTH,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"x-custom-site": "Web-Space",
|
||||
},
|
||||
body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refresh }).toString(),
|
||||
});
|
||||
const body = (await res.json().catch(() => null)) as {
|
||||
code?: number;
|
||||
data?: { access_token?: string; refresh_token?: string };
|
||||
} | null;
|
||||
const access = body?.code === 200 ? body.data?.access_token : undefined;
|
||||
if (!access) return null;
|
||||
await writeCookie(ACCESS_COOKIE, access, ACCESS_COOKIE_MS);
|
||||
if (body?.data?.refresh_token) await writeCookie(REFRESH_COOKIE, body.data.refresh_token, REFRESH_COOKIE_MS);
|
||||
return access;
|
||||
}
|
||||
|
||||
/** Sends Thingport a token that lasts, renewing the browser's when it, or the one Thingport has, is running out.
|
||||
* Best-effort: never throws. Returns the signed-in Snapmaker user's id, or null when there's none. */
|
||||
export async function syncSnapmakerSession(): Promise<{ userId: string | null }> {
|
||||
const site = OPTIONAL_SITES.find((s) => s.id === "snapmaker");
|
||||
if (!site || !(await siteEnabled(site).catch(() => false))) return { userId: null };
|
||||
try {
|
||||
// Not connected to a Thingport: nothing to keep fresh for.
|
||||
const config = await requireConfig().catch(() => null);
|
||||
if (!config) return { userId: null };
|
||||
let token = await readCookie(ACCESS_COOKIE);
|
||||
// The site drops its cookie after 8 hours, well before the token itself runs out; Thingport only ever has a
|
||||
// token Grab sent, so renewing the browser's when it's near its end keeps Thingport's fresh too.
|
||||
const exp = token ? tokenPayload(token).exp : null;
|
||||
if (!token || !exp || exp < Date.now() + RENEW_BEFORE_MS) token = (await renew().catch(() => null)) ?? token;
|
||||
if (!token) return { userId: null };
|
||||
// Per Thingport account, so switching the extension to another one sends it there too.
|
||||
const account = `${config.instanceUrl} ${config.email}`;
|
||||
const last = config.lastSyncedSnapmakerToken;
|
||||
if (last?.account !== account || last.value !== token) {
|
||||
await apiCall("PATCH", "/settings/snapmaker-session", { token });
|
||||
await chrome.storage.local.set({ [STORAGE_KEYS.lastSyncedSnapmakerToken]: { account, value: token } });
|
||||
}
|
||||
return { userId: tokenPayload(token).userId };
|
||||
} catch {
|
||||
return { userId: null };
|
||||
}
|
||||
}
|
||||
|
||||
/** Keeps the sign-in fresh while the browser runs, Snapmaker visited or not. */
|
||||
export async function scheduleSnapmakerRenewal(): Promise<void> {
|
||||
const site = OPTIONAL_SITES.find((s) => s.id === "snapmaker");
|
||||
const enabled = site ? await siteEnabled(site).catch(() => false) : false;
|
||||
const existing = await chrome.alarms.get(SNAPMAKER_ALARM);
|
||||
if (enabled && !existing) {
|
||||
await chrome.alarms.create(SNAPMAKER_ALARM, { delayInMinutes: 1, periodInMinutes: ALARM_PERIOD_MINUTES });
|
||||
} else if (!enabled && existing) {
|
||||
await chrome.alarms.clear(SNAPMAKER_ALARM);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,17 @@
|
||||
// Content script entry point (MakerWorld, Thingiverse, Printables, and MyMiniFactory once turned on). Network calls go through the
|
||||
// background (see runtime.ts).
|
||||
// Content script entry point (MakerWorld, Thingiverse, Printables, and MyMiniFactory and Snapmaker Space once turned
|
||||
// on). Network calls go through the background (see runtime.ts).
|
||||
|
||||
import type { ImportStatus } from "../shared/api";
|
||||
import { listen, send, type ContentMessages } from "../shared/messages";
|
||||
import { CONFIG_CHANGE_KEYS } from "../shared/storage";
|
||||
import { classifyUrl } from "../shared/urls";
|
||||
import { classifyUrl, parseSnapmakerListUrl, snapmakerListUrl } from "../shared/urls";
|
||||
import { setContext, type LibraryState } from "./context";
|
||||
import { isMakerworldBlockingError, resolveMakerworldDownloadUrl } from "./makerworld/downloadResolver";
|
||||
import { offerNormalizedDownload, unmountNormalizedDownload } from "./makerworld/normalizedDownload";
|
||||
import { mountJobOverlay, mountPausedJobOverlay } from "./overlays";
|
||||
import { loadPanel } from "./panels";
|
||||
import { captureProviderAccount } from "./providerAccount";
|
||||
import { snapmakerUserId } from "./snapmaker";
|
||||
import { providerEnabled } from "./providerSettings";
|
||||
import { errorHtml } from "./panels/results";
|
||||
import { api } from "./runtime";
|
||||
@@ -119,6 +120,19 @@ async function init(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Snapmaker shows likes and bookmarks to their owner only, and the instance reads them as the user: offered on
|
||||
// their own page alone, as the address that names the list.
|
||||
let pageUrl = url;
|
||||
if (classification.provider === "snapmaker" && classification.kind === "batch") {
|
||||
const own = await snapmakerUserId();
|
||||
if (isStale()) return;
|
||||
if (!own || own !== parseSnapmakerListUrl(url)?.userId) {
|
||||
reportTabIconState(false);
|
||||
return;
|
||||
}
|
||||
pageUrl = snapmakerListUrl(own, classification.type);
|
||||
}
|
||||
|
||||
let library: LibraryState | null = null;
|
||||
if (classification.kind === "single") {
|
||||
try {
|
||||
@@ -142,7 +156,7 @@ async function init(): Promise<void> {
|
||||
if (isStale()) return;
|
||||
|
||||
setContext({
|
||||
url,
|
||||
url: pageUrl,
|
||||
instanceUrl: stateRes.data.instanceUrl,
|
||||
classification,
|
||||
library,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// Batch flow for Thingiverse Likes/Collections and Printables and MyMiniFactory Collections. MakerWorld collections
|
||||
// use the guided flow in makerworldCollection.ts.
|
||||
// Batch flow for Thingiverse Likes/Collections, Printables and MyMiniFactory Collections, and Snapmaker Space likes
|
||||
// and bookmarks. MakerWorld collections use the guided flow in makerworldCollection.ts.
|
||||
|
||||
import type { BatchEntriesResult, ImportJob, SyncLookup } from "../../shared/api";
|
||||
import { ctx } from "../context";
|
||||
import { ctx, setContext } from "../context";
|
||||
import { parseSnapmakerListUrl, snapmakerListUrl, type SnapmakerList } from "../../shared/urls";
|
||||
import { api, escapeHtml, sleep } from "../runtime";
|
||||
import { isPanelMounted, onPanelAction, panelQueryAll, renderPanel } from "../shell";
|
||||
import { errorHtml, statusHtml, successHtml } from "./results";
|
||||
@@ -11,7 +12,19 @@ import { applySyncToggle, loadSyncState, readSyncToggle, syncChangeNote, syncTog
|
||||
import { printablesToken, readCollectionForPanel } from "../printables";
|
||||
import { pushPrintablesNow } from "../providerAccount";
|
||||
|
||||
type BatchKey = "thingiverse:likes" | "thingiverse:collection" | "printables:collection" | "myminifactory:collection";
|
||||
type BatchKey =
|
||||
| "thingiverse:likes"
|
||||
| "thingiverse:collection"
|
||||
| "printables:collection"
|
||||
| "myminifactory:collection"
|
||||
| "snapmaker:likes"
|
||||
| "snapmaker:bookmarks";
|
||||
|
||||
const SNAPMAKER_LIST = {
|
||||
entries: "/import/snapmaker-list/entries",
|
||||
start: "/import/snapmaker-list",
|
||||
idField: "model_ids",
|
||||
};
|
||||
|
||||
const BATCH_ENDPOINTS: Record<BatchKey, { entries: string; start: string; idField: string }> = {
|
||||
"thingiverse:likes": {
|
||||
@@ -34,8 +47,42 @@ const BATCH_ENDPOINTS: Record<BatchKey, { entries: string; start: string; idFiel
|
||||
start: "/import/myminifactory-collection",
|
||||
idField: "object_ids",
|
||||
},
|
||||
"snapmaker:likes": SNAPMAKER_LIST,
|
||||
"snapmaker:bookmarks": SNAPMAKER_LIST,
|
||||
};
|
||||
|
||||
const SNAPMAKER_LISTS: { list: SnapmakerList; label: string }[] = [
|
||||
{ list: "likes", label: "Likes" },
|
||||
{ list: "bookmarks", label: "Bookmarks" },
|
||||
];
|
||||
|
||||
/** Snapmaker shows likes and bookmarks on one page without changing its address, so the panel picks which. */
|
||||
function snapmakerListSwitchHtml(): string {
|
||||
const { classification } = ctx();
|
||||
if (classification.provider !== "snapmaker" || classification.kind !== "batch") return "";
|
||||
const options = SNAPMAKER_LISTS.map(
|
||||
({ list, label }) =>
|
||||
`<button class="tg-segmented__option" type="button" data-action="list-${list}" aria-pressed="${classification.type === list}">${label}</button>`,
|
||||
).join("");
|
||||
return `<div class="tg-segmented" role="group" aria-label="Which list">${options}</div>`;
|
||||
}
|
||||
|
||||
function wireSnapmakerListSwitch(): void {
|
||||
for (const { list } of SNAPMAKER_LISTS) {
|
||||
onPanelAction(`list-${list}`, () => {
|
||||
const current = ctx();
|
||||
const userId = parseSnapmakerListUrl(current.url)?.userId;
|
||||
if (!userId || (current.classification.kind === "batch" && current.classification.type === list)) return;
|
||||
setContext({
|
||||
...current,
|
||||
url: snapmakerListUrl(userId, list),
|
||||
classification: { kind: "batch", provider: "snapmaker", type: list },
|
||||
});
|
||||
void loadBatchEntries();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function endpoints() {
|
||||
const { provider, type } = ctx().classification;
|
||||
const config = BATCH_ENDPOINTS[`${provider}:${type}` as BatchKey];
|
||||
@@ -110,7 +157,8 @@ export async function loadBatchEntries(): Promise<void> {
|
||||
try {
|
||||
[result, sync] = await Promise.all([loadEntries(), loadSyncState()]);
|
||||
} catch (err) {
|
||||
renderPanel(errorHtml(err));
|
||||
renderPanel(`${snapmakerListSwitchHtml()}${errorHtml(err)}`);
|
||||
wireSnapmakerListSwitch();
|
||||
return;
|
||||
}
|
||||
const allImported = result.entries.every((entry) => entry.already_imported);
|
||||
@@ -127,6 +175,7 @@ export async function loadBatchEntries(): Promise<void> {
|
||||
.join("");
|
||||
const found = `${result.entries.length} models found${result.truncated ? " (more available on the site)" : ""}`;
|
||||
renderPanel(`
|
||||
${snapmakerListSwitchHtml()}
|
||||
<div class="tg-title">${escapeHtml(result.title || "Import models")}</div>
|
||||
<div class="tg-hint">${allImported && result.entries.length ? `${found}, all already in your library.` : found}</div>
|
||||
<div class="tg-entries">${rows}</div>
|
||||
@@ -134,6 +183,7 @@ export async function loadBatchEntries(): Promise<void> {
|
||||
<button class="tg-btn" type="button" data-action="import">${allImported && sync ? "Save" : "Import selected"}</button>
|
||||
`);
|
||||
onPanelAction("import", () => void runBatchImport(result, sync));
|
||||
wireSnapmakerListSwitch();
|
||||
}
|
||||
|
||||
async function runBatchImport(result: PanelEntries, sync: SyncLookup | null): Promise<void> {
|
||||
|
||||
@@ -12,6 +12,7 @@ export const SITE_NAMES: Record<Provider, string> = {
|
||||
printables: "Printables",
|
||||
thingiverse: "Thingiverse",
|
||||
myminifactory: "MyMiniFactory",
|
||||
snapmaker: "Snapmaker Space",
|
||||
};
|
||||
|
||||
/** The instance inspects the link: finds the file, reads its title, checks whether it's a zip. */
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Thingiverse, Printables and MyMiniFactory import directly; other links go through /import/inspect first, like
|
||||
// the web app's useUploadImport.tsx.
|
||||
// Thingiverse, Printables, MyMiniFactory and Snapmaker Space import directly; other links go through /import/inspect
|
||||
// first, like the web app's useUploadImport.tsx.
|
||||
|
||||
import type { FillGapsResult, InspectResult, SourceGap, ZipEntriesResult } from "../../shared/api";
|
||||
import { request } from "../../shared/messages";
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type MakerworldProfileScope,
|
||||
} from "../makerworld/pageData";
|
||||
import { api, escapeHtml } from "../runtime";
|
||||
import { loadSnapmakerProfiles } from "../snapmaker";
|
||||
import { onPanelAction, panelQuery, panelQueryAll, renderPanel } from "../shell";
|
||||
import { collectionIdFor, collectionPickerHtml, readCollectionChoice, wireCollectionPicker } from "./collectionPicker";
|
||||
import { errorHtml, statusHtml, successHtml } from "./results";
|
||||
@@ -47,15 +48,23 @@ function isPaidMyminifactoryObject(): boolean {
|
||||
// Several profile resolutions in a quick burst still look automated to MakerWorld.
|
||||
const PROFILE_GAP_MS = 2000;
|
||||
|
||||
/** The print profile ids a scope brings, the page's own first: MakerWorld's from its page data, Snapmaker's from its
|
||||
* API. Null on other sites, or when they can't be read. */
|
||||
async function profileIdsFor(): Promise<((scope: MakerworldProfileScope) => string[]) | null> {
|
||||
const { url, classification } = ctx();
|
||||
if (classification.provider === "snapmaker" && classification.type === "model") return loadSnapmakerProfiles(url);
|
||||
if (classification.provider !== "makerworld" || classification.type !== "model") return null;
|
||||
const page = await loadMakerworldDesignForPage(url);
|
||||
return page ? (scope) => makerworldProfileIds(page.design, scope, page.requestedInstanceId) : null;
|
||||
}
|
||||
|
||||
/** The page's selected profile first, then "designer's" and "all" where they'd import more. */
|
||||
async function profilesPickerHtml(): Promise<string> {
|
||||
const { url, classification } = ctx();
|
||||
if (classification.provider !== "makerworld" || classification.type !== "model") return "";
|
||||
const page = await loadMakerworldDesignForPage(url);
|
||||
if (!page) return "";
|
||||
const all = makerworldProfileIds(page.design, "all", page.requestedInstanceId).length;
|
||||
const ids = await profileIdsFor();
|
||||
if (!ids) return "";
|
||||
const all = ids("all").length;
|
||||
if (all < 2) return "";
|
||||
const designer = makerworldProfileIds(page.design, "designer", page.requestedInstanceId).length;
|
||||
const designer = ids("designer").length;
|
||||
const options = [`<option value="url">Currently selected print profile</option>`];
|
||||
if (designer > 1) options.push(`<option value="designer">All designer print profiles (${designer})</option>`);
|
||||
if (all > designer) options.push(`<option value="all">Designer & community print profiles (${all})</option>`);
|
||||
@@ -124,7 +133,8 @@ export async function loadSingleItem(): Promise<void> {
|
||||
const skipInspect =
|
||||
(provider === "thingiverse" && type === "thing") ||
|
||||
(provider === "printables" && type === "model") ||
|
||||
(provider === "myminifactory" && type === "object");
|
||||
(provider === "myminifactory" && type === "object") ||
|
||||
(provider === "snapmaker" && type === "model");
|
||||
|
||||
let zipFilename: string | null = null;
|
||||
if (skipInspect) {
|
||||
@@ -341,9 +351,9 @@ function nullUnlessBlocking(err: unknown): null {
|
||||
/** The first profile creates (or finds) the model; later ones are added as files. Stops on a
|
||||
* CAPTCHA or the daily download limit, which would fail every later one. */
|
||||
async function runProfilesImport(scope: MakerworldProfileScope, collectionId: string | null): Promise<void> {
|
||||
const { url, instanceUrl, title } = ctx();
|
||||
const page = await loadMakerworldDesignForPage(url);
|
||||
const ids = page ? makerworldProfileIds(page.design, scope, page.requestedInstanceId) : [];
|
||||
const { url, instanceUrl, title, classification } = ctx();
|
||||
const makerworld = classification.provider === "makerworld";
|
||||
const ids = (await profileIdsFor())?.(scope) ?? [];
|
||||
if (!ids.length) {
|
||||
renderPanel(errorHtml(new Error("Couldn't read this model's print profiles. Reload the page and try again.")));
|
||||
return;
|
||||
@@ -357,14 +367,16 @@ async function runProfilesImport(scope: MakerworldProfileScope, collectionId: st
|
||||
for (const [index, instanceId] of ids.entries()) {
|
||||
renderPanel(statusHtml(`Importing print profile ${index + 1} of ${ids.length}…`));
|
||||
if (index > 0) await new Promise((resolve) => setTimeout(resolve, PROFILE_GAP_MS));
|
||||
// The page's Download button gives exactly the link's profile.
|
||||
// The page's Download button gives exactly the link's profile. Snapmaker's the instance fetches itself.
|
||||
try {
|
||||
const resolved =
|
||||
index === 0
|
||||
const resolved = !makerworld
|
||||
? null
|
||||
: index === 0
|
||||
? await resolveMakerworldDownloadUrl(url).catch(nullUnlessBlocking)
|
||||
: await resolveMakerworldProfileDownload(url, instanceId).catch(nullUnlessBlocking);
|
||||
const print = await request("IMPORT_SINGLE", {
|
||||
url: index === 0 ? url : profileUrl(url, instanceId),
|
||||
// MakerWorld's page link carries its profile; Snapmaker's is named, so a model already in the library gets it.
|
||||
url: index === 0 && makerworld ? url : profileUrl(url, instanceId),
|
||||
collectionId,
|
||||
resolved,
|
||||
title,
|
||||
|
||||
@@ -12,6 +12,7 @@ const PROVIDER_NAMES: Record<string, string> = {
|
||||
printables: "Printables",
|
||||
thingiverse: "Thingiverse",
|
||||
myminifactory: "MyMiniFactory",
|
||||
snapmaker: "Snapmaker Space",
|
||||
};
|
||||
|
||||
/** A Thingiverse user's Likes page, which a sync follows like a collection. */
|
||||
|
||||
@@ -74,6 +74,12 @@ async function syncPrintables(): Promise<void> {
|
||||
|
||||
export function captureProviderAccount(hostname: string): void {
|
||||
if (captured) 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.
|
||||
void send("SYNC_SNAPMAKER_SESSION");
|
||||
return;
|
||||
}
|
||||
const provider = hostname.endsWith("printables.com")
|
||||
? "printables"
|
||||
: hostname.endsWith("thingiverse.com")
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// Snapmaker Space's own model data, as its pages read it: which print profiles a model has, and whose they are.
|
||||
|
||||
import { request } from "../shared/messages";
|
||||
import { parseSnapmakerModelUrl } from "../shared/urls";
|
||||
|
||||
export type SnapmakerProfileScope = "url" | "designer" | "all";
|
||||
|
||||
type Profile = { id: string; creatorId: string | null };
|
||||
type Model = { creatorId: string | null; profiles: Profile[] };
|
||||
|
||||
const API = "https://api-models.snapmaker.com/api";
|
||||
|
||||
const asId = (value: unknown) => (value == null ? null : String(value));
|
||||
|
||||
/** The model's print profiles, from the same public API the page calls. Null when it can't be read. */
|
||||
async function loadModel(modelId: string): Promise<Model | null> {
|
||||
try {
|
||||
const res = await fetch(`${API}/model/detail/${encodeURIComponent(modelId)}`, { credentials: "omit" });
|
||||
const body = (await res.json()) as { code?: number; data?: Record<string, unknown> };
|
||||
if (body.code !== 200 || !body.data) return null;
|
||||
const profiles = Array.isArray(body.data.printProfiles)
|
||||
? (body.data.printProfiles as Record<string, unknown>[]).flatMap((p) =>
|
||||
p && p.id != null ? [{ id: String(p.id), creatorId: asId(p.creatorId) }] : [],
|
||||
)
|
||||
: [];
|
||||
return { creatorId: asId(body.data.creatorId), profiles };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Mirrors the backend's selectSnapmakerProfiles, the linked (or designer's first) profile first. */
|
||||
function profileIds(model: Model, scope: SnapmakerProfileScope, requested: string | null): string[] {
|
||||
const designers = model.profiles.filter((p) => p.creatorId === model.creatorId);
|
||||
const primary =
|
||||
model.profiles.find((p) => p.id === requested)?.id ?? designers[0]?.id ?? model.profiles[0]?.id ?? null;
|
||||
if (!primary) return [];
|
||||
if (scope === "url") return [primary];
|
||||
const wanted = (scope === "all" ? model.profiles : designers).map((p) => p.id);
|
||||
return [primary, ...wanted.filter((id) => id !== primary)];
|
||||
}
|
||||
|
||||
/** Profile ids for each scope, for the panel's picker and its import. Null when the model can't be read. */
|
||||
export async function loadSnapmakerProfiles(url: string): Promise<((scope: SnapmakerProfileScope) => string[]) | null> {
|
||||
const parsed = parseSnapmakerModelUrl(url);
|
||||
const model = parsed ? await loadModel(parsed.modelId) : null;
|
||||
if (!parsed || !model) return null;
|
||||
return (scope) => profileIds(model, scope, parsed.requestedProfileId);
|
||||
}
|
||||
|
||||
/** Who's signed in on Snapmaker, so likes and bookmarks are only offered on their own page. */
|
||||
export async function snapmakerUserId(): Promise<string | null> {
|
||||
const res = await request("SYNC_SNAPMAKER_SESSION").catch(() => null);
|
||||
return res?.userId ?? null;
|
||||
}
|
||||
@@ -54,3 +54,39 @@
|
||||
color: t.c("text-muted");
|
||||
}
|
||||
}
|
||||
|
||||
// A choice between a few views of the same page, e.g. Snapmaker's likes and bookmarks.
|
||||
.tg-segmented {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 2px;
|
||||
margin-bottom: t.$space-2;
|
||||
border-radius: t.$radius-md;
|
||||
background: t.c("surface-sunken");
|
||||
|
||||
&__option {
|
||||
@include m.focus-ring;
|
||||
flex: 1;
|
||||
padding: 5px t.$space-2;
|
||||
border: 0;
|
||||
border-radius: t.$radius-sm;
|
||||
background: transparent;
|
||||
color: t.c("text-muted");
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 150ms,
|
||||
color 150ms;
|
||||
|
||||
&[aria-pressed="true"] {
|
||||
background: t.c("surface-raised");
|
||||
color: t.c("text");
|
||||
box-shadow: 0 1px 2px t.c("shadow");
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,8 @@ import { clearError, els, showError } from "./dom";
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
type Logo = { path: string; color: string };
|
||||
/** `viewBox` defaults to 0 0 24 24, Simple Icons' box. */
|
||||
type Logo = { path: string; color: string; viewBox?: string; fillRule?: "evenodd" };
|
||||
|
||||
// Same glyphs as the web app's providerLogos.tsx.
|
||||
const LOGOS: Record<"makerworld" | "printables" | "thingiverse" | OptionalSite["id"], Logo> = {
|
||||
@@ -27,6 +28,13 @@ const LOGOS: Record<"makerworld" | "printables" | "thingiverse" | OptionalSite["
|
||||
color: "#00C4A6",
|
||||
path: "M24 0.241V23.759a0.241 0.241 0 0 1 -0.241 0.241H14.4V19.201h4.801V7.2H14.4V14.4H9.6V7.2H4.799v12.001H9.6v4.799H0.481A0.481 0.481 0 0 1 0 23.519V0.241A0.241 0.241 0 0 1 0.241 0H23.759A0.241 0.241 0 0 1 24 0.241Z",
|
||||
},
|
||||
// A black-and-white mark, so it takes the text colour to show in both themes; the S is cut out of the disc.
|
||||
snapmaker: {
|
||||
color: "currentColor",
|
||||
viewBox: "0 0 36 36",
|
||||
fillRule: "evenodd",
|
||||
path: "M18 0a18 18 0 1 0 0 36a18 18 0 1 0 0-36ZM23.2899 9.13976C21.7574 8.82421 20.5055 9.02036 19.5311 9.72254C18.5567 10.4247 17.8864 11.6016 17.5232 13.2505L16.3835 18.396H16.3864L15.3677 22.9928C15.1758 23.8627 14.8864 24.4711 14.5026 24.8207C14.1187 25.1704 13.5842 25.2727 12.9022 25.1334C12.4593 25.0425 11.9868 24.8776 11.4789 24.6388L10.9238 27.1291C11.414 27.3792 12.0104 27.5754 12.7132 27.7175C14.2457 28.0331 15.4976 27.8369 16.472 27.1348C17.4464 26.4326 18.1167 25.2557 18.4799 23.6068L19.6197 18.4613H19.6167L20.6354 13.8645C20.8273 12.9946 21.1167 12.3863 21.5006 12.0366C21.8844 11.6869 22.4189 11.5846 23.1009 11.7239C23.5439 11.8148 24.0163 11.9797 24.5242 12.2185L25.0763 9.72538C24.5891 9.4809 23.9927 9.28475 23.2899 9.13976Z",
|
||||
},
|
||||
};
|
||||
|
||||
type Site = { name: string; logo: Logo; optional?: OptionalSite };
|
||||
@@ -38,9 +46,9 @@ const SITES: Site[] = [
|
||||
...OPTIONAL_SITES.map((site) => ({ name: site.name, logo: LOGOS[site.id], optional: site })),
|
||||
];
|
||||
|
||||
function svg(path: string, attrs: Record<string, string>): SVGSVGElement {
|
||||
function svg(path: string, attrs: Record<string, string>, viewBox = "0 0 24 24"): SVGSVGElement {
|
||||
const el = document.createElementNS(SVG_NS, "svg");
|
||||
el.setAttribute("viewBox", "0 0 24 24");
|
||||
el.setAttribute("viewBox", viewBox);
|
||||
el.setAttribute("aria-hidden", "true");
|
||||
el.setAttribute("focusable", "false");
|
||||
const p = document.createElementNS(SVG_NS, "path");
|
||||
@@ -103,7 +111,8 @@ export async function loadSites(): Promise<void> {
|
||||
name.className = "site__name";
|
||||
name.textContent = site.name;
|
||||
const status = enabled[index] || !site.optional ? tick(site) : enableButton(site, site.optional);
|
||||
row.append(svg(site.logo.path, { fill: site.logo.color }), name, status);
|
||||
const { path, color, viewBox, fillRule } = site.logo;
|
||||
row.append(svg(path, { fill: color, ...(fillRule ? { "fill-rule": fillRule } : {}) }, viewBox), name, status);
|
||||
return row;
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -110,6 +110,8 @@ export type BackgroundMessages = {
|
||||
REPORT_PROVIDER_ACCOUNT: { payload: { provider: AccountProvider; value: string }; result: null };
|
||||
/** On a MyMiniFactory page while signed in: send the session on, if it changed. */
|
||||
SYNC_MYMINIFACTORY_SESSION: { payload: void; result: null };
|
||||
/** On a Snapmaker Space page: keep the sign-in fresh and send it on. Says who's signed in there. */
|
||||
SYNC_SNAPMAKER_SESSION: { payload: void; result: { userId: string | null } };
|
||||
/** What to read on Printables now, or null when it isn't time to read anything. */
|
||||
/** `now`: skip the hourly wait, e.g. right after a private collection's sync was turned on. */
|
||||
PRINTABLES_PUSH_DUE: { payload: { now?: boolean }; result: PrintablesPushPlan | null };
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
// asked for at runtime, when the user enables it in the popup (within the manifest's optional_host_permissions), and
|
||||
// the background registers the content script there. It only ever runs on the sites listed here.
|
||||
|
||||
export type OptionalSite = { id: "myminifactory"; name: string; origins: string[] };
|
||||
export type OptionalSite = { id: "myminifactory" | "snapmaker"; name: string; origins: string[] };
|
||||
|
||||
export const OPTIONAL_SITES: readonly OptionalSite[] = [
|
||||
{ id: "myminifactory", name: "MyMiniFactory", origins: ["*://*.myminifactory.com/*"] },
|
||||
// id.snapmaker.com renews the sign-in (see snapmakerSession.ts); Grab's panel only ever shows on Snapmaker Space.
|
||||
{ id: "snapmaker", name: "Snapmaker Space", origins: ["*://space.snapmaker.com/*", "*://id.snapmaker.com/*"] },
|
||||
];
|
||||
|
||||
const scriptId = (site: OptionalSite) => `site-${site.id}`;
|
||||
|
||||
@@ -20,6 +20,8 @@ export const STORAGE_KEYS = {
|
||||
lastSyncedMakerworldCookie: "lastSyncedMakerworldCookie",
|
||||
// The MyMiniFactory session last sent, and to which Thingport account.
|
||||
lastSyncedMyminifactorySession: "lastSyncedMyminifactorySession",
|
||||
// The Snapmaker access token last sent, and to which Thingport account.
|
||||
lastSyncedSnapmakerToken: "lastSyncedSnapmakerToken",
|
||||
// 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.
|
||||
@@ -43,6 +45,7 @@ export type StoredConfig = {
|
||||
loginVerifiedAt?: number;
|
||||
lastSyncedMakerworldCookie?: string;
|
||||
lastSyncedMyminifactorySession?: { account: string; value: string };
|
||||
lastSyncedSnapmakerToken?: { account: string; value: string };
|
||||
/** `account` is the Thingport instance and email they were sent to. */
|
||||
lastSentProviderAccounts?: { account: string; values: Record<string, string> };
|
||||
lastPrintablesPushAt?: number;
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
// Provider URL recognition, duplicated from the frontend and backend parsers. Keep in sync by hand.
|
||||
|
||||
export type Provider = "makerworld" | "thingiverse" | "printables" | "myminifactory";
|
||||
export type Provider = "makerworld" | "thingiverse" | "printables" | "myminifactory" | "snapmaker";
|
||||
|
||||
export type Classification =
|
||||
| { kind: "single"; provider: "makerworld"; type: "model" }
|
||||
| { kind: "single"; provider: "thingiverse"; type: "thing" }
|
||||
| { kind: "single"; provider: "printables"; type: "model" }
|
||||
| { kind: "single"; provider: "myminifactory"; type: "object" }
|
||||
| { kind: "single"; provider: "snapmaker"; type: "model" }
|
||||
| { kind: "batch"; provider: "makerworld"; type: "collection" }
|
||||
| { kind: "batch"; provider: "thingiverse"; type: "likes" | "collection" }
|
||||
| { kind: "batch"; provider: "printables"; type: "collection" }
|
||||
| { kind: "batch"; provider: "myminifactory"; type: "collection" };
|
||||
| { kind: "batch"; provider: "myminifactory"; type: "collection" }
|
||||
| { kind: "batch"; provider: "snapmaker"; type: SnapmakerList };
|
||||
|
||||
export type SnapmakerList = "likes" | "bookmarks";
|
||||
|
||||
function parse(url: string): URL | null {
|
||||
try {
|
||||
@@ -96,6 +100,34 @@ export function isMyminifactoryCollectionUrl(url: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function isSnapmaker(parsed: URL): boolean {
|
||||
return parsed.hostname.toLowerCase() === "space.snapmaker.com";
|
||||
}
|
||||
|
||||
/** `/en/model/<id>-<slug>`; `#profileId-<id>` picks a print profile. */
|
||||
export function parseSnapmakerModelUrl(url: string): { modelId: string; requestedProfileId: string | null } | null {
|
||||
const parsed = parse(url);
|
||||
if (!parsed || !isSnapmaker(parsed)) return null;
|
||||
const m = parsed.pathname.match(/^\/(?:[a-z]{2}(?:-[a-z]{2})?\/)?model\/(\d+)(?:-[^/]*)?\/?$/i);
|
||||
if (!m) return null;
|
||||
return { modelId: m[1], requestedProfileId: parsed.hash.match(/profileId-(\d+)/i)?.[1] ?? null };
|
||||
}
|
||||
|
||||
/** The user's likes and bookmarks share one page, `/creator/<id>?tab=interactions`, whose address stays the same
|
||||
* whichever is shown; `list` in the address tells them apart where Thingport keeps it. */
|
||||
export function parseSnapmakerListUrl(url: string): { userId: string; list: SnapmakerList } | null {
|
||||
const parsed = parse(url);
|
||||
if (!parsed || !isSnapmaker(parsed) || parsed.searchParams.get("tab") !== "interactions") return null;
|
||||
const m = parsed.pathname.match(/^\/(?:[a-z]{2}(?:-[a-z]{2})?\/)?creator\/(\d+)\/?$/i);
|
||||
if (!m) return null;
|
||||
return { userId: m[1], list: parsed.searchParams.get("list") === "bookmarks" ? "bookmarks" : "likes" };
|
||||
}
|
||||
|
||||
/** Mirrors the backend's snapmakerListUrl. */
|
||||
export function snapmakerListUrl(userId: string, list: SnapmakerList): string {
|
||||
return `https://space.snapmaker.com/en/creator/${userId}?tab=interactions&list=${list}`;
|
||||
}
|
||||
|
||||
/** 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" };
|
||||
@@ -103,9 +135,12 @@ export function classifyUrl(url: string): Classification | null {
|
||||
if (isThingiverseCollectionUrl(url)) return { kind: "batch", provider: "thingiverse", type: "collection" };
|
||||
if (isPrintablesCollectionUrl(url)) return { kind: "batch", provider: "printables", type: "collection" };
|
||||
if (isMyminifactoryCollectionUrl(url)) return { kind: "batch", provider: "myminifactory", type: "collection" };
|
||||
const snapmakerList = parseSnapmakerListUrl(url);
|
||||
if (snapmakerList) return { kind: "batch", provider: "snapmaker", type: snapmakerList.list };
|
||||
if (parseMakerworldModelUrl(url)) return { kind: "single", provider: "makerworld", type: "model" };
|
||||
if (parseThingiverseThingUrl(url)) return { kind: "single", provider: "thingiverse", type: "thing" };
|
||||
if (parsePrintablesModelUrl(url)) return { kind: "single", provider: "printables", type: "model" };
|
||||
if (parseMyminifactoryObjectUrl(url)) return { kind: "single", provider: "myminifactory", type: "object" };
|
||||
if (parseSnapmakerModelUrl(url)) return { kind: "single", provider: "snapmaker", type: "model" };
|
||||
return null;
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export type Category = {
|
||||
thingiverse_cat_ids: string;
|
||||
printables_cat_ids: string;
|
||||
myminifactory_cat_ids: string;
|
||||
snapmaker_cat_ids: string;
|
||||
};
|
||||
|
||||
export type CategoryMetaInput = {
|
||||
@@ -28,6 +29,7 @@ export type CategoryMetaInput = {
|
||||
thingiverseCatIds: string;
|
||||
printablesCatIds: string;
|
||||
myminifactoryCatIds: string;
|
||||
snapmakerCatIds: string;
|
||||
};
|
||||
|
||||
export const categoriesApi = {
|
||||
@@ -75,6 +77,7 @@ export const categoriesApi = {
|
||||
thingiverse_cat_ids: meta.thingiverseCatIds,
|
||||
printables_cat_ids: meta.printablesCatIds,
|
||||
myminifactory_cat_ids: meta.myminifactoryCatIds,
|
||||
snapmaker_cat_ids: meta.snapmakerCatIds,
|
||||
}),
|
||||
});
|
||||
if (res.status === 401) throw new UnauthorizedError();
|
||||
|
||||
@@ -181,6 +181,20 @@ export const importsApi = {
|
||||
return res.json();
|
||||
},
|
||||
|
||||
/** fromMakerworldProfiles for a Snapmaker Space model. */
|
||||
fromSnapmakerProfiles: async (
|
||||
payload: ImportLinkPayload & { scope: Exclude<MakerworldProfileScope, "url"> },
|
||||
): Promise<{ job_id: string }> => {
|
||||
const res = await fetch(`${apiBase()}/import/snapmaker-profiles`, {
|
||||
method: "POST",
|
||||
headers: authHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (res.status === 401) throw new UnauthorizedError();
|
||||
if (!res.ok) throw new Error(await readErrorMessage(res, "Import failed"));
|
||||
return res.json();
|
||||
},
|
||||
|
||||
/** Returns immediately; ImportJobContext polls the job. A pasted list of links, imported one at
|
||||
* a time; what fails can be rerun with retryImportJob. */
|
||||
fromLinks: async (
|
||||
@@ -362,6 +376,30 @@ export const importsApi = {
|
||||
return res.json();
|
||||
},
|
||||
|
||||
/** The user's own Snapmaker Space likes or bookmarks, read with their sign-in. */
|
||||
listSnapmakerListEntries: async (payload: ImportLinkPayload): Promise<ImportCollectionEntriesResult> => {
|
||||
const res = await fetch(`${apiBase()}/import/snapmaker-list/entries`, {
|
||||
method: "POST",
|
||||
headers: authHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (res.status === 401) throw new UnauthorizedError();
|
||||
if (!res.ok) throw new Error(await readErrorMessage(res, "Could not load this collection"));
|
||||
return res.json();
|
||||
},
|
||||
|
||||
/** Returns immediately; ImportJobContext polls the job. */
|
||||
fromSnapmakerList: async (payload: ImportLinkPayload & { model_ids: string[] }): Promise<{ job_id: string }> => {
|
||||
const res = await fetch(`${apiBase()}/import/snapmaker-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, "Snapmaker Space import failed"));
|
||||
return res.json();
|
||||
},
|
||||
|
||||
getActiveImportJob: async (): Promise<ImportJob | null> => {
|
||||
const res = await fetch(`${apiBase()}/import/jobs/active`, { headers: authHeaders() });
|
||||
if (res.status === 401) throw new UnauthorizedError();
|
||||
|
||||
@@ -2,7 +2,7 @@ import { authHeaders } from "../utils/auth";
|
||||
import { apiBase, readErrorMessage, UnauthorizedError } from "./client";
|
||||
|
||||
/** The providers a collection can be synced with from the app. */
|
||||
export const BROWSABLE_PROVIDERS = ["makerworld", "printables", "thingiverse", "myminifactory"] as const;
|
||||
export const BROWSABLE_PROVIDERS = ["makerworld", "printables", "thingiverse", "myminifactory", "snapmaker"] as const;
|
||||
export type BrowsableProvider = (typeof BROWSABLE_PROVIDERS)[number];
|
||||
|
||||
/** `connected`: the instance can reach the provider for this user. `account`: who they are there,
|
||||
|
||||
@@ -146,7 +146,7 @@ export type DatabaseCredentialsInput = {
|
||||
password: string;
|
||||
};
|
||||
|
||||
export type ImportProvider = "makerworld" | "printables" | "thingiverse" | "myminifactory";
|
||||
export type ImportProvider = "makerworld" | "printables" | "thingiverse" | "myminifactory" | "snapmaker";
|
||||
/** An admin can turn a provider off for the whole instance; menus hide it then. */
|
||||
export type ProviderSetting = { id: ImportProvider; enabled: boolean };
|
||||
|
||||
@@ -372,6 +372,25 @@ export const settingsApi = {
|
||||
return res.json();
|
||||
},
|
||||
|
||||
// The user's own Snapmaker Space sign-in (its access token), which Thingport Grab keeps fresh. Write-only.
|
||||
getSnapmakerSession: async (): Promise<{ configured: boolean; expired: boolean }> => {
|
||||
const res = await fetch(`${apiBase()}/settings/snapmaker-session`, { headers: authHeaders() });
|
||||
assertOk(res, "Failed to load Snapmaker Space settings");
|
||||
return res.json();
|
||||
},
|
||||
|
||||
/** Checked with Snapmaker before it's saved. */
|
||||
updateSnapmakerSession: async (token: string | null): Promise<{ configured: boolean }> => {
|
||||
const res = await fetch(`${apiBase()}/settings/snapmaker-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 Snapmaker Space settings"));
|
||||
return res.json();
|
||||
},
|
||||
|
||||
getAuth: async (): Promise<AuthSettings> => {
|
||||
const res = await fetch(`${apiBase()}/settings/auth`, { headers: authHeaders() });
|
||||
assertOk(res, "Failed to load session settings");
|
||||
|
||||
@@ -10,7 +10,7 @@ import { ProviderLogo, type ProviderId } from "./Layout/ConfigurationDialog/prov
|
||||
import { useDisabledProviders } from "../hooks/useDisabledProviders";
|
||||
import type { ImportProvider } from "../api/settings";
|
||||
|
||||
const LOGO_PROVIDERS = new Set<string>(["makerworld", "thingiverse", "printables", "myminifactory"]);
|
||||
const LOGO_PROVIDERS = new Set<string>(["makerworld", "thingiverse", "printables", "myminifactory", "snapmaker"]);
|
||||
|
||||
type Props = {
|
||||
sync: CollectionSync;
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { ImportProvider } from "../api/settings";
|
||||
|
||||
const ICON_SIZE = 18;
|
||||
const BUBBLE_SIZE = 10;
|
||||
const LOGO_PROVIDERS = new Set<string>(["makerworld", "thingiverse", "printables", "myminifactory"]);
|
||||
const LOGO_PROVIDERS = new Set<string>(["makerworld", "thingiverse", "printables", "myminifactory", "snapmaker"]);
|
||||
|
||||
/** The provider's logo with a small green tick, slim enough for a toolbar row. Opens the
|
||||
* collection's sync configuration. */
|
||||
|
||||
@@ -35,6 +35,8 @@ import {
|
||||
isMakerworldModelUrl,
|
||||
isMyminifactoryCollectionUrl,
|
||||
isPrintablesCollectionUrl,
|
||||
isSnapmakerListUrl,
|
||||
isSnapmakerModelUrl,
|
||||
isThingiverseCollectionUrl,
|
||||
isThingiverseLikesUrl,
|
||||
type ImportProviderKey,
|
||||
@@ -42,7 +44,7 @@ import {
|
||||
import type { MakerworldProfileScope } from "../../api/imports";
|
||||
import { allProvidersOff, useDisabledProviders } from "../../hooks/useDisabledProviders";
|
||||
|
||||
const IMPORT_PROVIDERS: ImportProviderKey[] = ["makerworld", "thingiverse", "printables", "myminifactory"];
|
||||
const IMPORT_PROVIDERS: ImportProviderKey[] = ["makerworld", "thingiverse", "printables", "myminifactory", "snapmaker"];
|
||||
|
||||
/** What the search palette's !import command calls. */
|
||||
export type AddMenuHandle = {
|
||||
@@ -106,10 +108,12 @@ const AddMenu = React.forwardRef<AddMenuHandle, Props>(function AddMenu(
|
||||
isThingiverseLikesUrl(link) ||
|
||||
isThingiverseCollectionUrl(link) ||
|
||||
isPrintablesCollectionUrl(link) ||
|
||||
isMyminifactoryCollectionUrl(link),
|
||||
isMyminifactoryCollectionUrl(link) ||
|
||||
isSnapmakerListUrl(link),
|
||||
)
|
||||
: isMakerworldCollectionUrl(linkValue);
|
||||
const isMakerworldModel = links.some((link) => isMakerworldModelUrl(link));
|
||||
// Models with print profiles to pick between.
|
||||
const hasProfiles = links.some((link) => isMakerworldModelUrl(link) || isSnapmakerModelUrl(link));
|
||||
|
||||
const closeMenu = () => setAnchorEl(null);
|
||||
|
||||
@@ -156,11 +160,11 @@ const AddMenu = React.forwardRef<AddMenuHandle, Props>(function AddMenu(
|
||||
if (needsCaptcha && !captcha?.captcha_answer.trim()) return;
|
||||
// Each captcha works once; reopening shows a fresh one.
|
||||
if (isBatch) {
|
||||
await upload.submitImportMany(links, needsCaptcha ? captcha : null, isMakerworldModel ? profileScope : "url");
|
||||
await upload.submitImportMany(links, needsCaptcha ? captcha : null, hasProfiles ? profileScope : "url");
|
||||
setImportOpen(false);
|
||||
return;
|
||||
}
|
||||
await upload.submitImport(linkValue, needsCaptcha ? captcha : null, isMakerworldModel ? profileScope : "url");
|
||||
await upload.submitImport(linkValue, needsCaptcha ? captcha : null, hasProfiles ? profileScope : "url");
|
||||
setImportOpen(false);
|
||||
};
|
||||
|
||||
@@ -270,7 +274,7 @@ const AddMenu = React.forwardRef<AddMenuHandle, Props>(function AddMenu(
|
||||
{t("addMenu.linksDetected", { count: links.length })}
|
||||
</Typography>
|
||||
)}
|
||||
{isMakerworldModel && (
|
||||
{hasProfiles && (
|
||||
<TextField
|
||||
select
|
||||
fullWidth
|
||||
|
||||
@@ -29,15 +29,18 @@ const PROVIDERS: { id: ProviderId; name: string }[] = [
|
||||
{ id: "thingiverse", name: "Thingiverse" },
|
||||
{ id: "printables", name: "Printables" },
|
||||
{ id: "myminifactory", name: "MyMiniFactory" },
|
||||
{ id: "snapmaker", name: "Snapmaker Space" },
|
||||
...COMING_SOON_PROVIDERS,
|
||||
];
|
||||
const isComingSoon = (id: ProviderId) => COMING_SOON_PROVIDERS.some((provider) => provider.id === id);
|
||||
// Connected with one key for the whole instance, which only an admin sets.
|
||||
const SHARED = new Set<ProviderId>(["thingiverse"]);
|
||||
// Connected with the user's own sign-in, which the site can turn down once it expires. MyMiniFactory's also has
|
||||
// the instance's API key, set in Administration; the user's sign-in adds paid and login-only models.
|
||||
type SessionProvider = "makerworld" | "myminifactory";
|
||||
const isSessionProvider = (id: ProviderId): id is SessionProvider => id === "makerworld" || id === "myminifactory";
|
||||
// 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.
|
||||
type SessionProvider = "makerworld" | "myminifactory" | "snapmaker";
|
||||
const isSessionProvider = (id: ProviderId): id is SessionProvider =>
|
||||
id === "makerworld" || id === "myminifactory" || id === "snapmaker";
|
||||
|
||||
type Props = {
|
||||
isAdmin: boolean;
|
||||
@@ -64,12 +67,13 @@ export default function ProvidersPanel({ isAdmin, cookie, onUpdateMakerWorld, on
|
||||
makeronline: false,
|
||||
crealitycloud: false,
|
||||
nexprint: false,
|
||||
snapmakerspace: false,
|
||||
snapmaker: null,
|
||||
});
|
||||
const [menu, setMenu] = React.useState<{ id: ProviderId; top: number; left: number } | null>(null);
|
||||
const [expired, setExpired] = React.useState<Record<SessionProvider, boolean>>({
|
||||
makerworld: false,
|
||||
myminifactory: false,
|
||||
snapmaker: false,
|
||||
});
|
||||
const isExpired = (id: ProviderId) => isSessionProvider(id) && Boolean(connected[id]) && expired[id];
|
||||
const [connecting, setConnecting] = React.useState<ProviderId | null>(null);
|
||||
@@ -98,6 +102,14 @@ export default function ProvidersPanel({ isAdmin, cookie, onUpdateMakerWorld, on
|
||||
setExpired((current) => ({ ...current, myminifactory: res.expired }));
|
||||
})
|
||||
.catch(() => undefined);
|
||||
settingsApi
|
||||
.getSnapmakerSession()
|
||||
.then((res) => {
|
||||
if (!active) return;
|
||||
setConnected((current) => ({ ...current, snapmaker: res.configured }));
|
||||
setExpired((current) => ({ ...current, snapmaker: res.expired }));
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
@@ -126,6 +138,10 @@ export default function ProvidersPanel({ isAdmin, cookie, onUpdateMakerWorld, on
|
||||
const result = await settingsApi.updateMyminifactorySession(value);
|
||||
setConnected((current) => ({ ...current, myminifactory: result.configured }));
|
||||
setExpired((current) => ({ ...current, myminifactory: false }));
|
||||
} else if (id === "snapmaker") {
|
||||
const result = await settingsApi.updateSnapmakerSession(value);
|
||||
setConnected((current) => ({ ...current, snapmaker: result.configured }));
|
||||
setExpired((current) => ({ ...current, snapmaker: false }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -196,8 +212,8 @@ export default function ProvidersPanel({ isAdmin, cookie, onUpdateMakerWorld, on
|
||||
? locked
|
||||
: isConnected
|
||||
? t("providers.connected")
|
||||
: id === "myminifactory"
|
||||
? t("providers.myminifactory.notConnected")
|
||||
: id === "myminifactory" || id === "snapmaker"
|
||||
? t(`providers.${id}.notConnected`)
|
||||
: t("providers.notConnected");
|
||||
return (
|
||||
<AppTile
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// Glyphs from Simple Icons (CC0). MakerWorld has none of its own there, so it takes Bambu Lab's,
|
||||
// whose site it is. MyMiniFactory isn't there either; its glyph is its own favicon, scaled to 24.
|
||||
// The coming-soon sites aren't there either; theirs are the marks from their own sites, cut from the wordmark
|
||||
// where the two are one drawing. MakerOnline and Snapmaker Space are black-and-white marks, so they take the
|
||||
// text colour to show in both themes.
|
||||
// Snapmaker Space and the coming-soon sites aren't there either; theirs are the marks from their own sites, cut from
|
||||
// the wordmark where the two are one drawing. MakerOnline and Snapmaker Space are black-and-white marks, so they take
|
||||
// the text colour to show in both themes.
|
||||
export type ProviderId =
|
||||
| "makerworld"
|
||||
| "thingiverse"
|
||||
@@ -13,7 +13,7 @@ export type ProviderId =
|
||||
| "makeronline"
|
||||
| "crealitycloud"
|
||||
| "nexprint"
|
||||
| "snapmakerspace";
|
||||
| "snapmaker";
|
||||
|
||||
/** Sites shown as "Coming soon": listed, but nothing can be done with them yet. */
|
||||
export const COMING_SOON_PROVIDERS: { id: ProviderId; name: string }[] = [
|
||||
@@ -22,7 +22,6 @@ export const COMING_SOON_PROVIDERS: { id: ProviderId; name: string }[] = [
|
||||
{ id: "makeronline", name: "MakerOnline" },
|
||||
{ id: "crealitycloud", name: "Creality Cloud" },
|
||||
{ id: "nexprint", name: "NextPrint" },
|
||||
{ id: "snapmakerspace", name: "Snapmaker Space" },
|
||||
];
|
||||
|
||||
type Logo = {
|
||||
@@ -91,7 +90,7 @@ const LOGOS: Record<ProviderId, Logo> = {
|
||||
path: "M15 18.5519L19.1113 12.0919H25.6592C26.2106 12.0921 26.7007 12.4444 26.877 12.9669L29.9307 22.0236C30.2114 22.8566 29.592 23.7198 28.7129 23.7198H1.28613C0.407131 23.7196 -0.212382 22.8566 0.0683594 22.0236L3.12305 12.9669C3.29939 12.4443 3.78924 12.0919 4.34082 12.0919H10.8877L7.21191 6.31555C6.66766 5.45993 7.2819 4.34031 8.2959 4.33997H21.7002C22.7143 4.34001 23.3292 5.45985 22.7852 6.31555L19.1094 12.0919H10.8887L15 18.5519Z",
|
||||
},
|
||||
// A white S on a black disc; the S is cut out instead, so the disc can take the text colour.
|
||||
snapmakerspace: {
|
||||
snapmaker: {
|
||||
viewBox: "0 0 36 36",
|
||||
color: "currentColor",
|
||||
fillRule: "evenodd",
|
||||
|
||||
@@ -84,6 +84,7 @@ export const SEARCH_PAGES: SearchPage[] = [
|
||||
"printables",
|
||||
"thingiverse",
|
||||
"myminifactory",
|
||||
"snapmaker",
|
||||
"app token",
|
||||
"api key",
|
||||
"access token",
|
||||
|
||||
@@ -11,6 +11,8 @@ type StartThingiverseCollectionImportPayload = Parameters<typeof importsApi.from
|
||||
type StartPrintablesCollectionImportPayload = Parameters<typeof importsApi.fromPrintablesCollection>[0];
|
||||
type StartMyminifactoryCollectionImportPayload = Parameters<typeof importsApi.fromMyminifactoryCollection>[0];
|
||||
type StartMakerworldProfilesImportPayload = Parameters<typeof importsApi.fromMakerworldProfiles>[0];
|
||||
type StartSnapmakerProfilesImportPayload = Parameters<typeof importsApi.fromSnapmakerProfiles>[0];
|
||||
type StartSnapmakerListImportPayload = Parameters<typeof importsApi.fromSnapmakerList>[0];
|
||||
type StartLinksImportPayload = Parameters<typeof importsApi.fromLinks>[0];
|
||||
|
||||
type ImportJobContextValue = {
|
||||
@@ -24,6 +26,8 @@ type ImportJobContextValue = {
|
||||
startPrintablesCollectionImport: (payload: StartPrintablesCollectionImportPayload) => Promise<void>;
|
||||
startMyminifactoryCollectionImport: (payload: StartMyminifactoryCollectionImportPayload) => Promise<void>;
|
||||
startMakerworldProfilesImport: (payload: StartMakerworldProfilesImportPayload) => Promise<void>;
|
||||
startSnapmakerProfilesImport: (payload: StartSnapmakerProfilesImportPayload) => Promise<void>;
|
||||
startSnapmakerListImport: (payload: StartSnapmakerListImportPayload) => Promise<void>;
|
||||
startLinksImport: (payload: StartLinksImportPayload) => Promise<void>;
|
||||
/** Reruns the links that failed on the finished job shown in the progress bar. */
|
||||
retryJob: () => Promise<void>;
|
||||
@@ -191,6 +195,26 @@ export function ImportJobProvider({
|
||||
[startPolling],
|
||||
);
|
||||
|
||||
const startSnapmakerProfilesImport = useCallback(
|
||||
async (payload: StartSnapmakerProfilesImportPayload) => {
|
||||
const { job_id } = await importsApi.fromSnapmakerProfiles(payload);
|
||||
const job = await importsApi.getImportJob(job_id);
|
||||
setActiveJob(job);
|
||||
startPolling(job_id);
|
||||
},
|
||||
[startPolling],
|
||||
);
|
||||
|
||||
const startSnapmakerListImport = useCallback(
|
||||
async (payload: StartSnapmakerListImportPayload) => {
|
||||
const { job_id } = await importsApi.fromSnapmakerList(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);
|
||||
@@ -225,6 +249,8 @@ export function ImportJobProvider({
|
||||
startPrintablesCollectionImport,
|
||||
startMyminifactoryCollectionImport,
|
||||
startMakerworldProfilesImport,
|
||||
startSnapmakerProfilesImport,
|
||||
startSnapmakerListImport,
|
||||
startLinksImport,
|
||||
retryJob,
|
||||
dismissJob,
|
||||
@@ -238,6 +264,8 @@ export function ImportJobProvider({
|
||||
startPrintablesCollectionImport,
|
||||
startMyminifactoryCollectionImport,
|
||||
startMakerworldProfilesImport,
|
||||
startSnapmakerProfilesImport,
|
||||
startSnapmakerListImport,
|
||||
startLinksImport,
|
||||
retryJob,
|
||||
dismissJob,
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import Avatar from "@mui/material/Avatar";
|
||||
import Box from "@mui/material/Box";
|
||||
import ButtonBase from "@mui/material/ButtonBase";
|
||||
import Divider from "@mui/material/Divider";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import ListItemIcon from "@mui/material/ListItemIcon";
|
||||
@@ -19,15 +18,15 @@ import LogoutIcon from "@mui/icons-material/Logout";
|
||||
import type { AuthUser } from "../../api/auth";
|
||||
import type { ConfigurationTab } from "./ConfigurationDialog";
|
||||
import { IMPORT_PROVIDER_INFO } from "../../constants/importProviders";
|
||||
import { COMING_SOON_PROVIDERS, ProviderLogo, type ProviderId } from "./ConfigurationDialog/providerLogos";
|
||||
import { SELF_AUTHOR_ID } from "../../constants/selfAuthor";
|
||||
import { useGravatarUrl } from "../../hooks/useGravatarUrl";
|
||||
import { settingsApi } from "../../api/settings";
|
||||
import { useDisabledProviders } from "../../hooks/useDisabledProviders";
|
||||
|
||||
type ServiceChipDef = {
|
||||
key: "makerworld" | "thingiverse" | "printables" | "myminifactory";
|
||||
key: "makerworld" | "thingiverse" | "printables" | "myminifactory" | "snapmaker";
|
||||
label: string;
|
||||
color: string;
|
||||
connected: boolean;
|
||||
/** Explains what's missing when not connected. */
|
||||
disabledReason?: string;
|
||||
@@ -40,7 +39,7 @@ type Props = {
|
||||
onLogout: () => void;
|
||||
};
|
||||
|
||||
/** Identity, quick settings and import-provider connection chips. Connection status comes from the
|
||||
/** Identity, quick settings and import-provider connection icons. Connection status comes from the
|
||||
* backend, not localStorage, which may disagree with the DB. */
|
||||
export function UserMenu({ user, onOpenConfiguration, onOpenProfile, onLogout }: Props) {
|
||||
const { t } = useTranslation(["app", "common"]);
|
||||
@@ -51,6 +50,8 @@ export function UserMenu({ user, onOpenConfiguration, onOpenProfile, onLogout }:
|
||||
const [makerworldExpired, setMakerworldExpired] = React.useState(false);
|
||||
const [thingiverseConfigured, setThingiverseConfigured] = React.useState(false);
|
||||
const [myminifactoryConfigured, setMyminifactoryConfigured] = React.useState(false);
|
||||
const [snapmakerConfigured, setSnapmakerConfigured] = React.useState(false);
|
||||
const [snapmakerExpired, setSnapmakerExpired] = React.useState(false);
|
||||
|
||||
const menuOpen = Boolean(anchorEl);
|
||||
// Checked on every open: the cookie can change in Configuration while the menu stays mounted.
|
||||
@@ -77,6 +78,14 @@ export function UserMenu({ user, onOpenConfiguration, onOpenProfile, onLogout }:
|
||||
if (active) setMyminifactoryConfigured(res.configured);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
settingsApi
|
||||
.getSnapmakerSession()
|
||||
.then((res) => {
|
||||
if (!active) return;
|
||||
setSnapmakerConfigured(res.configured);
|
||||
setSnapmakerExpired(res.expired);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
@@ -106,9 +115,32 @@ export function UserMenu({ user, onOpenConfiguration, onOpenProfile, onLogout }:
|
||||
connected: myminifactoryConfigured,
|
||||
disabledReason: t("userMenu.myminifactoryDisabledReason"),
|
||||
},
|
||||
{
|
||||
key: "snapmaker",
|
||||
...IMPORT_PROVIDER_INFO.snapmaker,
|
||||
connected: snapmakerConfigured && !snapmakerExpired,
|
||||
disabledReason: snapmakerExpired ? t("providers.snapmaker.expired") : t("userMenu.snapmakerDisabledReason"),
|
||||
},
|
||||
];
|
||||
// Turned off by an admin: no icon. Coming-soon sites follow the live ones.
|
||||
const services: { id: ProviderId; label: string; connected: boolean; tooltip: string }[] = [
|
||||
...allServices
|
||||
.filter((service) => !disabledProviders.has(service.key))
|
||||
.map((svc) => ({
|
||||
id: svc.key,
|
||||
label: svc.label,
|
||||
connected: svc.connected,
|
||||
tooltip: svc.connected
|
||||
? t("userMenu.serviceConnected", { service: svc.label })
|
||||
: (svc.disabledReason ?? t("userMenu.serviceNotConnected", { service: svc.label })),
|
||||
})),
|
||||
...COMING_SOON_PROVIDERS.map(({ id, name }) => ({
|
||||
id,
|
||||
label: name,
|
||||
connected: false,
|
||||
tooltip: `${name}: ${t("providers.comingSoon")}`,
|
||||
})),
|
||||
];
|
||||
// Turned off by an admin: no chip, and with none left, no row.
|
||||
const services = allServices.filter((service) => !disabledProviders.has(service.key));
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -188,42 +220,33 @@ export function UserMenu({ user, onOpenConfiguration, onOpenProfile, onLogout }:
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t("configuration.title")}</ListItemText>
|
||||
</MenuItem>
|
||||
{services.length > 0 && [
|
||||
<Divider key="divider" />,
|
||||
<Box key="services" sx={{ px: 2, py: 1.25, display: "flex", gap: 0.75 }}>
|
||||
{services.map((svc) => (
|
||||
<ButtonBase
|
||||
key={svc.key}
|
||||
<Divider />
|
||||
<Box sx={{ px: 1.5, py: 1, display: "flex", flexWrap: "wrap", gap: 0.5, maxWidth: 260 }}>
|
||||
{services.map((svc) => (
|
||||
<Tooltip key={svc.id} title={svc.tooltip}>
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label={svc.tooltip}
|
||||
onClick={() => {
|
||||
closeMenu();
|
||||
onOpenConfiguration("providers");
|
||||
}}
|
||||
title={
|
||||
svc.connected
|
||||
? t("userMenu.serviceConnected", { service: svc.label })
|
||||
: (svc.disabledReason ?? t("userMenu.serviceNotConnected", { service: svc.label }))
|
||||
}
|
||||
sx={{
|
||||
px: 1,
|
||||
py: 0.375,
|
||||
borderRadius: 1,
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.4,
|
||||
whiteSpace: "nowrap",
|
||||
color: "#fff",
|
||||
// Grey rather than dimmed so the two states are unmistakable.
|
||||
bgcolor: svc.connected ? svc.color : "grey.500",
|
||||
opacity: svc.connected ? 1 : 0.5,
|
||||
transition: "filter 150ms",
|
||||
"&:hover": { filter: "brightness(1.1)" },
|
||||
p: 0.625,
|
||||
// Greyed out so connected logos stand out at a glance.
|
||||
"& svg": {
|
||||
filter: svc.connected ? "none" : "grayscale(1)",
|
||||
opacity: svc.connected ? 1 : 0.4,
|
||||
transition: "filter 150ms, opacity 150ms",
|
||||
},
|
||||
"&:hover svg, &:focus-visible svg": { opacity: svc.connected ? 1 : 0.7 },
|
||||
}}
|
||||
>
|
||||
{svc.label}
|
||||
</ButtonBase>
|
||||
))}
|
||||
</Box>,
|
||||
]}
|
||||
<ProviderLogo id={svc.id} size={18} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Box>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -27,6 +27,8 @@ import {
|
||||
isMyminifactoryObjectUrl,
|
||||
isPrintablesCollectionUrl,
|
||||
isPrintablesModelUrl,
|
||||
isSnapmakerListUrl,
|
||||
isSnapmakerModelUrl,
|
||||
isThingiverseCollectionUrl,
|
||||
isThingiverseLikesUrl,
|
||||
isThingiverseThingUrl,
|
||||
@@ -64,6 +66,8 @@ export function useUploadImport({ onUploaded, categoryId, makerworldCookie, onUn
|
||||
startPrintablesCollectionImport,
|
||||
startMyminifactoryCollectionImport,
|
||||
startMakerworldProfilesImport,
|
||||
startSnapmakerProfilesImport,
|
||||
startSnapmakerListImport,
|
||||
startLinksImport,
|
||||
} = useImportJob();
|
||||
const isBusy = uploading || importing || zipPrompt.isOpen || collectionPrompt.isOpen || importModePrompt.isOpen;
|
||||
@@ -251,7 +255,7 @@ export function useUploadImport({ onUploaded, categoryId, makerworldCookie, onUn
|
||||
showToast({ message: t(key, { name }) });
|
||||
};
|
||||
|
||||
/** `profileScope`: which MakerWorld print profiles to import; more than the link's runs as a job. */
|
||||
/** `profileScope`: which MakerWorld or Snapmaker print profiles to import; more than the link's runs as a job. */
|
||||
const submitImport = async (
|
||||
rawUrl: string,
|
||||
captcha?: CaptchaAnswer | null,
|
||||
@@ -280,6 +284,11 @@ export function useUploadImport({ onUploaded, categoryId, makerworldCookie, onUn
|
||||
return;
|
||||
}
|
||||
|
||||
if (profileScope !== "url" && isSnapmakerModelUrl(url)) {
|
||||
await startSnapmakerProfilesImport({ ...payload, scope: profileScope });
|
||||
return;
|
||||
}
|
||||
|
||||
if (isThingiverseLikesUrl(url)) {
|
||||
setImporting(false);
|
||||
await collectionPrompt.prompt({
|
||||
@@ -388,7 +397,39 @@ export function useUploadImport({ onUploaded, categoryId, makerworldCookie, onUn
|
||||
return;
|
||||
}
|
||||
|
||||
if (isThingiverseThingUrl(url) || isPrintablesModelUrl(url) || isMyminifactoryObjectUrl(url)) {
|
||||
if (isSnapmakerListUrl(url)) {
|
||||
setImporting(false);
|
||||
await collectionPrompt.prompt({
|
||||
label: url,
|
||||
loadEntries: async () => {
|
||||
try {
|
||||
return await importsApi.listSnapmakerListEntries(payload);
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedError) onUnauthorized?.();
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
onImportSelected: async (modelIds: string[]) => {
|
||||
try {
|
||||
await startSnapmakerListImport({ ...payload, model_ids: modelIds });
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedError) {
|
||||
onUnauthorized?.();
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
isThingiverseThingUrl(url) ||
|
||||
isPrintablesModelUrl(url) ||
|
||||
isMyminifactoryObjectUrl(url) ||
|
||||
isSnapmakerModelUrl(url)
|
||||
) {
|
||||
// The backend splits these into plates itself.
|
||||
const imported = await importsApi.fromLink(payload);
|
||||
showImportedToast(imported);
|
||||
|
||||
@@ -10,6 +10,12 @@ export const IMPORT_PROVIDER_INFO: Record<string, ImportProviderInfo> = {
|
||||
printables: { label: "Printables", color: "#FA6831" },
|
||||
// A darker shade of MyMiniFactory's #00C4A6, so the white label stays readable.
|
||||
myminifactory: { label: "MyMiniFactory", color: "#00866F" },
|
||||
// Snapmaker's mark is black and white, so it follows the theme like Thingport's own.
|
||||
snapmaker: {
|
||||
label: "Snapmaker Space",
|
||||
color: "text.primary",
|
||||
textColor: (theme) => (theme.palette.mode === "dark" ? "#FFFFFF" : theme.palette.background.paper),
|
||||
},
|
||||
// Direct uploads (source_provider is null). Monochrome so it doesn't compete with provider badges;
|
||||
// dark mode uses flat white for contrast against the chip.
|
||||
thingport: {
|
||||
|
||||
@@ -20,7 +20,7 @@ export function publishProviders(providers: ProviderSetting[]): void {
|
||||
}
|
||||
|
||||
const NONE: ReadonlySet<ImportProvider> = new Set();
|
||||
const ALL: readonly ImportProvider[] = ["makerworld", "printables", "thingiverse", "myminifactory"];
|
||||
const ALL: readonly ImportProvider[] = ["makerworld", "printables", "thingiverse", "myminifactory", "snapmaker"];
|
||||
|
||||
/** With every provider off there's nothing to import from or connect to, so those parts of the app hide. */
|
||||
export function allProvidersOff(disabled: ReadonlySet<ImportProvider>): boolean {
|
||||
|
||||
@@ -269,6 +269,7 @@
|
||||
"makerworldDisabledReason": "MakerWorld: not connected",
|
||||
"thingiverseDisabledReason": "Thingiverse: the admin hasn't configured an API key yet",
|
||||
"myminifactoryDisabledReason": "MyMiniFactory: the admin hasn't configured an API key yet",
|
||||
"snapmakerDisabledReason": "Snapmaker Space: connect your account in Configuration → Providers",
|
||||
"myModels": "My models"
|
||||
},
|
||||
"notifications": {
|
||||
@@ -531,7 +532,8 @@
|
||||
"makerworld": "Bambu Lab's model site: imports with print profiles, and collections to sync.",
|
||||
"printables": "Prusa's model site: imports, and public and private collections to sync.",
|
||||
"thingiverse": "Imports, collections and liked models, through this Thingport's App Token.",
|
||||
"myminifactory": "Models and public collections, through this Thingport's API key; bought models through each user's own MyMiniFactory account."
|
||||
"myminifactory": "Models and public collections, through this Thingport's API key; bought models through each user's own MyMiniFactory account.",
|
||||
"snapmaker": "Models with their print profiles, and each user's likes and bookmarks, through each user's own Snapmaker account."
|
||||
},
|
||||
"keyStatus": {
|
||||
"thingiverse": {
|
||||
@@ -1606,6 +1608,17 @@
|
||||
"expired": "Sign-in expired. Visit myminifactory.com with Thingport Grab, or connect MyMiniFactory again in Configuration → Providers.",
|
||||
"expiredStatus": "Sign-in expired"
|
||||
},
|
||||
"snapmaker": {
|
||||
"placeholder": "login_access_token value, or the whole Cookie header",
|
||||
"steps": [
|
||||
"Easiest: with Thingport Grab, just open space.snapmaker.com while signed in. It connects your account and keeps the sign-in fresh, since Snapmaker's lasts only a day.",
|
||||
"Or by hand: sign in at space.snapmaker.com and open your browser's developer tools (F12).",
|
||||
"Under Application (Chrome, Edge) or Storage (Firefox) → Cookies → space.snapmaker.com, copy the value of login_access_token and paste it below. Pasted by hand, it lasts a day."
|
||||
],
|
||||
"notConnected": "Snapmaker gives its files to signed-in users only. Connect your account to import its models, and your likes and bookmarks.",
|
||||
"expired": "Sign-in expired. Visit space.snapmaker.com with Thingport Grab, or connect Snapmaker Space again in Configuration → Providers.",
|
||||
"expiredStatus": "Sign-in expired"
|
||||
},
|
||||
"printables": {
|
||||
"alwaysConnected": "Printables is connected by default: it needs no account or key."
|
||||
},
|
||||
@@ -2556,14 +2569,15 @@
|
||||
"takenBy": "Synced with \"{{name}}\"",
|
||||
"loadFailed": "Couldn't load the collections.",
|
||||
"lookupFailed": "Couldn't find that collection.",
|
||||
"notALink": "Paste a MakerWorld, Printables, Thingiverse or MyMiniFactory collection link.",
|
||||
"notALink": "Paste a MakerWorld, Printables, Thingiverse, MyMiniFactory or Snapmaker Space collection link.",
|
||||
"usernameFailed": "Couldn't save the username.",
|
||||
"openProviders": "Open Providers",
|
||||
"notConnected": {
|
||||
"makerworld": "Connect MakerWorld in Configuration > Providers to see your collections here. Its cookie tells Thingport who you are there.",
|
||||
"printables": "Printables can't be reached right now.",
|
||||
"thingiverse": "Thingiverse isn't connected on this Thingport yet. An admin can connect it in Configuration > Providers.",
|
||||
"myminifactory": "MyMiniFactory isn't connected on this Thingport yet. An admin can connect it in Configuration > Providers."
|
||||
"myminifactory": "MyMiniFactory isn't connected on this Thingport yet. An admin can connect it in Configuration > Providers.",
|
||||
"snapmaker": "Connect Snapmaker Space in Configuration > Providers, or visit it with Thingport Grab, to see your likes and bookmarks here."
|
||||
},
|
||||
"usernameLabel": "Your {{provider}} username",
|
||||
"usernameHint": {
|
||||
@@ -2592,6 +2606,7 @@
|
||||
"startedImporting": "\"{{name}}\" now follows its {{provider}} collection. Its models are being imported; you'll get a notification.",
|
||||
"yoursLabel": "Your collections on {{provider}}",
|
||||
"likes": "Liked models",
|
||||
"bookmarks": "Bookmarked models",
|
||||
"startedWaiting": "\"{{name}}\" now syncs with \"{{source}}\". Its models come in the next time you open {{provider}} with Thingport Grab.",
|
||||
"pickPrompt": "Pick a collection above, or paste a link.",
|
||||
"startNothing": "Sync",
|
||||
@@ -2678,6 +2693,7 @@
|
||||
"thingiverseCatIdLabel": "Thingiverse category IDs",
|
||||
"printablesCatIdLabel": "Printables category IDs",
|
||||
"myminifactoryCatIdLabel": "MyMiniFactory category IDs",
|
||||
"snapmakerCatIdLabel": "Snapmaker Space category IDs",
|
||||
"catIdsPlaceholder": "e.g. 800;71;1001",
|
||||
"catIdsHelp": "Separate multiple IDs with \";\" -- matches if an import's category is any of these",
|
||||
"dragToMove": "Drag to move"
|
||||
|
||||
@@ -269,6 +269,7 @@
|
||||
"makerworldDisabledReason": "MakerWorld: neprijungta",
|
||||
"thingiverseDisabledReason": "Thingiverse: administratorius dar nesukonfigūravo API rakto",
|
||||
"myminifactoryDisabledReason": "MyMiniFactory: administratorius dar nesukonfigūravo API rakto",
|
||||
"snapmakerDisabledReason": "Snapmaker Space: prijunkite paskyrą skiltyje Konfigūracija → Šaltiniai",
|
||||
"myModels": "Mano modeliai"
|
||||
},
|
||||
"notifications": {
|
||||
@@ -531,7 +532,8 @@
|
||||
"makerworld": "Bambu Lab modelių svetainė: importai su spausdinimo profiliais ir kolekcijų sinchronizavimas.",
|
||||
"printables": "Prusa modelių svetainė: importai bei viešų ir privačių kolekcijų sinchronizavimas.",
|
||||
"thingiverse": "Importai, kolekcijos ir patikę modeliai, naudojant šio Thingport programėlės raktą.",
|
||||
"myminifactory": "Modeliai ir viešos kolekcijos, naudojant šio Thingport API raktą; nusipirkti modeliai – per kiekvieno naudotojo MyMiniFactory paskyrą."
|
||||
"myminifactory": "Modeliai ir viešos kolekcijos, naudojant šio Thingport API raktą; nusipirkti modeliai – per kiekvieno naudotojo MyMiniFactory paskyrą.",
|
||||
"snapmaker": "Modeliai su spausdinimo profiliais bei kiekvieno naudotojo pamėgti ir pažymėti modeliai, per kiekvieno naudotojo Snapmaker paskyrą."
|
||||
},
|
||||
"keyStatus": {
|
||||
"thingiverse": {
|
||||
@@ -1661,6 +1663,17 @@
|
||||
"expired": "Prisijungimo galiojimas baigėsi. Atverkite myminifactory.com su Thingport Grab arba vėl prijunkite MyMiniFactory skiltyje Konfigūracija → Šaltiniai.",
|
||||
"expiredStatus": "Prisijungimas nebegalioja"
|
||||
},
|
||||
"snapmaker": {
|
||||
"placeholder": "login_access_token reikšmė arba visa Cookie antraštė",
|
||||
"steps": [
|
||||
"Paprasčiausia: su Thingport Grab tiesiog atverkite space.snapmaker.com prisijungę. Jis prijungs jūsų paskyrą ir palaikys prisijungimą galiojantį, nes Snapmaker prisijungimas galioja tik parą.",
|
||||
"Arba rankiniu būdu: prisijunkite prie space.snapmaker.com ir atverkite naršyklės kūrėjo įrankius (F12).",
|
||||
"Skiltyje Application (Chrome, Edge) arba Storage (Firefox) → Cookies → space.snapmaker.com nukopijuokite login_access_token reikšmę ir įklijuokite ją žemiau. Įklijuotas rankiniu būdu, jis galioja parą."
|
||||
],
|
||||
"notConnected": "Snapmaker failus duoda tik prisijungusiems naudotojams. Prijunkite paskyrą, kad galėtumėte importuoti jo modelius bei savo pamėgtus ir pažymėtus modelius.",
|
||||
"expired": "Prisijungimo galiojimas baigėsi. Atverkite space.snapmaker.com su Thingport Grab arba vėl prijunkite Snapmaker Space skiltyje Konfigūracija → Šaltiniai.",
|
||||
"expiredStatus": "Prisijungimas nebegalioja"
|
||||
},
|
||||
"printables": {
|
||||
"alwaysConnected": "Printables prijungta iš karto: nereikia paskyros ar rakto."
|
||||
},
|
||||
@@ -2626,14 +2639,15 @@
|
||||
"takenBy": "Sinchronizuojama su „{{name}}“",
|
||||
"loadFailed": "Nepavyko įkelti kolekcijų.",
|
||||
"lookupFailed": "Nepavyko rasti šios kolekcijos.",
|
||||
"notALink": "Įklijuokite MakerWorld, Printables, Thingiverse arba MyMiniFactory kolekcijos nuorodą.",
|
||||
"notALink": "Įklijuokite MakerWorld, Printables, Thingiverse, MyMiniFactory arba Snapmaker Space kolekcijos nuorodą.",
|
||||
"usernameFailed": "Nepavyko išsaugoti vartotojo vardo.",
|
||||
"openProviders": "Atidaryti tiekėjus",
|
||||
"notConnected": {
|
||||
"makerworld": "Prijunkite MakerWorld per Konfigūracija > Šaltiniai, kad čia matytumėte savo kolekcijas. Pagal slapuką Thingport žino, kas jūs ten esate.",
|
||||
"printables": "Printables šiuo metu nepasiekiamas.",
|
||||
"thingiverse": "Thingiverse šiame Thingport dar neprijungtas. Administratorius gali jį prijungti per Konfigūracija > Tiekėjai.",
|
||||
"myminifactory": "MyMiniFactory šiame Thingport dar neprijungtas. Administratorius gali jį prijungti per Konfigūracija > Tiekėjai."
|
||||
"myminifactory": "MyMiniFactory šiame Thingport dar neprijungtas. Administratorius gali jį prijungti per Konfigūracija > Tiekėjai.",
|
||||
"snapmaker": "Prijunkite Snapmaker Space per Konfigūracija > Šaltiniai arba atverkite jį su Thingport Grab, kad čia matytumėte savo pamėgtus ir pažymėtus modelius."
|
||||
},
|
||||
"usernameLabel": "Jūsų {{provider}} vartotojo vardas",
|
||||
"usernameHint": {
|
||||
@@ -2664,6 +2678,7 @@
|
||||
"startedImporting": "„{{name}}“ dabar sinchronizuojama su {{provider}} kolekcija. Jos modeliai importuojami; gausite pranešimą.",
|
||||
"yoursLabel": "Jūsų {{provider}} kolekcijos",
|
||||
"likes": "Pamėgti modeliai",
|
||||
"bookmarks": "Pažymėti modeliai",
|
||||
"startedWaiting": "„{{name}}“ dabar sinchronizuojama su „{{source}}“. Jos modeliai pateks, kai kitą kartą atidarysite {{provider}} su Thingport Grab.",
|
||||
"pickPrompt": "Pasirinkite kolekciją aukščiau arba įklijuokite nuorodą.",
|
||||
"startNothing": "Sinchronizuoti",
|
||||
@@ -2752,6 +2767,7 @@
|
||||
"thingiverseCatIdLabel": "Thingiverse kategorijų ID",
|
||||
"printablesCatIdLabel": "Printables kategorijų ID",
|
||||
"myminifactoryCatIdLabel": "MyMiniFactory kategorijų ID",
|
||||
"snapmakerCatIdLabel": "Snapmaker Space kategorijų ID",
|
||||
"catIdsPlaceholder": "pvz. 800;71;1001",
|
||||
"catIdsHelp": "Kelis ID atskirkite „;“ -- atitiks, jei importo kategorija sutampa su bet kuriuo iš jų",
|
||||
"dragToMove": "Vilkite, kad perkeltumėte"
|
||||
|
||||
@@ -32,6 +32,7 @@ const NAMES: Record<ImportProvider, string> = {
|
||||
printables: "Printables",
|
||||
thingiverse: "Thingiverse",
|
||||
myminifactory: "MyMiniFactory",
|
||||
snapmaker: "Snapmaker Space",
|
||||
};
|
||||
|
||||
const KEYED = new Set<ImportProvider>(["thingiverse", "myminifactory"]);
|
||||
@@ -114,7 +115,10 @@ export default function ProvidersPage({ onUnauthorized }: Props) {
|
||||
<Paper variant="outlined" sx={{ borderRadius: "12px", overflow: "hidden" }}>
|
||||
{(
|
||||
providers ??
|
||||
(["makerworld", "printables", "thingiverse", "myminifactory"] as const).map((id) => ({ id, enabled: true }))
|
||||
(["makerworld", "printables", "thingiverse", "myminifactory", "snapmaker"] as const).map((id) => ({
|
||||
id,
|
||||
enabled: true,
|
||||
}))
|
||||
).map(({ id, enabled }) => {
|
||||
const keySetHere = isKeyed(id) ? keySet[id] : undefined;
|
||||
return (
|
||||
|
||||
@@ -15,6 +15,8 @@ export const PROVIDER_ACCENTS: Record<BrowsableProvider, string> = {
|
||||
printables: "#FA6831",
|
||||
thingiverse: "#5575FE",
|
||||
myminifactory: "#00C4A6",
|
||||
// Snapmaker's own blue: its mark is black, which a dark theme would lose.
|
||||
snapmaker: "#2563EB",
|
||||
};
|
||||
|
||||
type Props = {
|
||||
@@ -242,20 +244,60 @@ function MyMiniFactoryTile({ collection }: { collection: ProviderCollection }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Snapmaker Space's model card: the newest model large, a row of three more, then name and count.
|
||||
function SnapmakerTile({ collection }: { collection: ProviderCollection }) {
|
||||
const [cover, ...rest] = collection.thumbnails;
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: "12px",
|
||||
border: 1,
|
||||
borderColor: "divider",
|
||||
bgcolor: "background.paper",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Thumb src={cover} sx={{ aspectRatio: "4 / 3" }} />
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: "2px", mt: "2px" }}>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<Thumb key={i} src={rest[i]} sx={{ aspectRatio: "1 / 1" }} />
|
||||
))}
|
||||
</Box>
|
||||
<Box sx={{ px: 1.25, py: 1 }}>
|
||||
<Typography noWrap sx={{ fontWeight: 600, fontSize: "0.875rem" }}>
|
||||
{collection.name}
|
||||
</Typography>
|
||||
<Box sx={{ display: "flex", alignItems: "center", fontSize: "0.8rem", color: "text.secondary" }}>
|
||||
<ModelCount count={collection.model_count} provider="snapmaker" />
|
||||
{collection.private && <LockOutlinedIcon sx={{ fontSize: 14, ml: "auto" }} />}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const TILES: Record<BrowsableProvider, (props: { collection: ProviderCollection }) => JSX.Element> = {
|
||||
makerworld: MakerWorldTile,
|
||||
printables: PrintablesTile,
|
||||
thingiverse: ThingiverseTile,
|
||||
myminifactory: MyMiniFactoryTile,
|
||||
snapmaker: SnapmakerTile,
|
||||
};
|
||||
|
||||
/** The name to show: a user's likes, and Snapmaker's likes and bookmarks, come named in English by the instance. */
|
||||
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");
|
||||
return collection.name;
|
||||
}
|
||||
|
||||
/** A provider's collection to pick, drawn in that provider's colours and manner. One already synced
|
||||
* with another Thingport collection can't be picked, and says with which. */
|
||||
export default function ProviderCollectionTile({ provider, collection: given, selected, onSelect }: Props) {
|
||||
const { t } = useTranslation("models");
|
||||
const Tile = TILES[provider];
|
||||
// A user's likes come as a collection the instance names in English.
|
||||
const collection = given.likes ? { ...given, name: t("collections.sync.browse.likes") } : given;
|
||||
const collection = { ...given, name: displayName(provider, given, t) };
|
||||
const taken = collection.synced !== null;
|
||||
return (
|
||||
<Box>
|
||||
@@ -349,6 +391,19 @@ export function ProviderCollectionTileSkeleton({ provider }: { provider: Browsab
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (provider === "snapmaker") {
|
||||
return (
|
||||
<Box aria-hidden sx={{ borderRadius: "12px", border: 1, borderColor: "divider", overflow: "hidden" }}>
|
||||
<Skeleton variant="rectangular" sx={{ width: "100%", height: "auto", aspectRatio: "4 / 3" }} />
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: "2px", mt: "2px" }}>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<Skeleton key={i} variant="rectangular" sx={{ width: "100%", height: "auto", aspectRatio: "1 / 1" }} />
|
||||
))}
|
||||
</Box>
|
||||
<Box sx={{ px: 1.25, py: 1 }}>{lines}</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (provider === "myminifactory") {
|
||||
return (
|
||||
<Box
|
||||
|
||||
@@ -45,8 +45,12 @@ const PASTE_EXAMPLES: Record<BrowsableProvider, string> = {
|
||||
printables: "https://www.printables.com/@…/collections/…",
|
||||
thingiverse: "https://www.thingiverse.com/…/collections/…",
|
||||
myminifactory: "https://www.myminifactory.com/users/…/collection/…",
|
||||
snapmaker: "https://space.snapmaker.com/en/creator/…?tab=interactions&list=likes",
|
||||
};
|
||||
|
||||
// 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"]);
|
||||
|
||||
/** 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. */
|
||||
function defaultProvider(
|
||||
@@ -121,8 +125,9 @@ export default function SyncBrowse({ provider, onProviderChange, picked, onPick,
|
||||
}, [accounts, provider, slicer, onProviderChange, available]);
|
||||
|
||||
const account = provider && accounts ? accounts[provider] : null;
|
||||
const needsAccount = provider !== null && provider !== "makerworld" && account?.connected && !account.account;
|
||||
const canList = Boolean(account?.connected && (provider === "makerworld" || account.account));
|
||||
const signedIn = provider !== null && SIGNED_IN.has(provider);
|
||||
const needsAccount = provider !== null && !signedIn && account?.connected && !account.account;
|
||||
const canList = Boolean(account?.connected && (signedIn || account.account));
|
||||
|
||||
useEffect(() => {
|
||||
if (!provider || !canList || collections[provider]) return;
|
||||
@@ -350,7 +355,7 @@ export default function SyncBrowse({ provider, onProviderChange, picked, onPick,
|
||||
{canList && !editingAccount && (
|
||||
<Box>
|
||||
<SectionLabel>{t("models:collections.sync.browse.yoursLabel", { provider: name })}</SectionLabel>
|
||||
{provider !== "makerworld" && account?.account && (
|
||||
{!signedIn && account?.account && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
|
||||
{t(`models:collections.sync.browse.showing.${provider}`, { user: account.account })}
|
||||
{provider === "printables" && (
|
||||
|
||||
@@ -17,7 +17,7 @@ import { collectionDisplayName } from "../../utils/collectionDisplay";
|
||||
// One trip of the pulse: provider to Thingport in the first half, Thingport to the collection in
|
||||
// the second, each node lighting up as it arrives.
|
||||
const CYCLE = "2.8s";
|
||||
const LOGOS = new Set<string>(["makerworld", "thingiverse", "printables", "myminifactory"]);
|
||||
const LOGOS = new Set<string>(["makerworld", "thingiverse", "printables", "myminifactory", "snapmaker"]);
|
||||
|
||||
// The pulse is 40% of its segment wide, so 250% starts it past the right end and -100% ends it
|
||||
// past the left one.
|
||||
|
||||
@@ -162,6 +162,7 @@ export default function LogsPage({ onUnauthorized }: Props) {
|
||||
printables: "Printables",
|
||||
thingiverse: "Thingiverse",
|
||||
myminifactory: "MyMiniFactory",
|
||||
snapmaker: "Snapmaker Space",
|
||||
};
|
||||
const provider = typeof d.provider === "string" ? (names[d.provider] ?? d.provider) : "";
|
||||
return t(d.enabled ? "adminSettings.logs.providerOn" : "adminSettings.logs.providerOff", { provider });
|
||||
|
||||
@@ -71,7 +71,8 @@ function hasMeta(category: Category): boolean {
|
||||
category.makerworld_cat_ids ||
|
||||
category.thingiverse_cat_ids ||
|
||||
category.printables_cat_ids ||
|
||||
category.myminifactory_cat_ids,
|
||||
category.myminifactory_cat_ids ||
|
||||
category.snapmaker_cat_ids,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ export default function CategoryMetaDialog({ category, showSiteCatIds, onClose,
|
||||
const [thingiverseCatIds, setThingiverseCatIds] = useState(category.thingiverse_cat_ids);
|
||||
const [printablesCatIds, setPrintablesCatIds] = useState(category.printables_cat_ids);
|
||||
const [myminifactoryCatIds, setMyminifactoryCatIds] = useState(category.myminifactory_cat_ids);
|
||||
const [snapmakerCatIds, setSnapmakerCatIds] = useState(category.snapmaker_cat_ids);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -45,6 +46,7 @@ export default function CategoryMetaDialog({ category, showSiteCatIds, onClose,
|
||||
thingiverseCatIds,
|
||||
printablesCatIds,
|
||||
myminifactoryCatIds,
|
||||
snapmakerCatIds,
|
||||
});
|
||||
onClose();
|
||||
} catch (err) {
|
||||
@@ -80,7 +82,7 @@ export default function CategoryMetaDialog({ category, showSiteCatIds, onClose,
|
||||
disabled={saving}
|
||||
/>
|
||||
{showSiteCatIds && (
|
||||
// Two by two, one column on a phone.
|
||||
// Two to a row, one column on a phone.
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" }, gap: 2 }}>
|
||||
<TextField
|
||||
label={t("models:categories.manager.makerworldCatIdLabel")}
|
||||
@@ -118,6 +120,15 @@ export default function CategoryMetaDialog({ category, showSiteCatIds, onClose,
|
||||
onChange={(e) => setMyminifactoryCatIds(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
<TextField
|
||||
label={t("models:categories.manager.snapmakerCatIdLabel")}
|
||||
placeholder={t("models:categories.manager.catIdsPlaceholder") ?? undefined}
|
||||
helperText={t("models:categories.manager.catIdsHelp")}
|
||||
fullWidth
|
||||
value={snapmakerCatIds}
|
||||
onChange={(e) => setSnapmakerCatIds(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{error && <Alert severity="error">{error}</Alert>}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** URL-shape detection shared by useUploadImport and AddMenu so they can't drift apart. */
|
||||
|
||||
export type ImportProviderKey = "makerworld" | "thingiverse" | "printables" | "myminifactory";
|
||||
export type ImportProviderKey = "makerworld" | "thingiverse" | "printables" | "myminifactory" | "snapmaker";
|
||||
|
||||
function parseUrl(url: string): URL | null {
|
||||
try {
|
||||
@@ -18,6 +18,7 @@ export function detectImportProvider(url: string): ImportProviderKey | null {
|
||||
if (host === "thingiverse.com" || host === "www.thingiverse.com") return "thingiverse";
|
||||
if (host === "printables.com" || host === "www.printables.com") return "printables";
|
||||
if (host === "myminifactory.com" || host === "www.myminifactory.com") return "myminifactory";
|
||||
if (host === "space.snapmaker.com") return "snapmaker";
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -96,6 +97,23 @@ export function isMyminifactoryCollectionUrl(url: string): boolean {
|
||||
return /^\/(?:[a-z]{2}\/)?users\/[^/]+\/collection\/[^/]+\/?$/i.test(parsed.pathname);
|
||||
}
|
||||
|
||||
/** See isThingiverseThingUrl. `#profileId-<id>` may pick a print profile. */
|
||||
export function isSnapmakerModelUrl(url: string): boolean {
|
||||
const parsed = parseUrl(url);
|
||||
if (!parsed || parsed.hostname.toLowerCase() !== "space.snapmaker.com") return false;
|
||||
return /^\/(?:[a-z]{2}(?:-[a-z]{2})?\/)?model\/\d+(?:-[^/]*)?\/?$/i.test(parsed.pathname);
|
||||
}
|
||||
|
||||
/** The user's own likes or bookmarks: one page, `/creator/<id>?tab=interactions`, with `list` saying which. */
|
||||
export function isSnapmakerListUrl(url: string): boolean {
|
||||
const parsed = parseUrl(url);
|
||||
if (!parsed || parsed.hostname.toLowerCase() !== "space.snapmaker.com") return false;
|
||||
return (
|
||||
/^\/(?:[a-z]{2}(?:-[a-z]{2})?\/)?creator\/\d+\/?$/i.test(parsed.pathname) &&
|
||||
parsed.searchParams.get("tab") === "interactions"
|
||||
);
|
||||
}
|
||||
|
||||
export const IMPORT_LINK_EXAMPLES: Record<ImportProviderKey, { model: string; collection: string }> = {
|
||||
makerworld: {
|
||||
model: "https://makerworld.com/en/models/123456-example-model",
|
||||
@@ -113,4 +131,8 @@ export const IMPORT_LINK_EXAMPLES: Record<ImportProviderKey, { model: string; co
|
||||
model: "https://www.myminifactory.com/object/3d-print-example-model-123456",
|
||||
collection: "https://www.myminifactory.com/users/username/collection/example-collection",
|
||||
},
|
||||
snapmaker: {
|
||||
model: "https://space.snapmaker.com/en/model/12345-example-model",
|
||||
collection: "https://space.snapmaker.com/en/creator/12345?tab=interactions&list=likes",
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user