feat: add MyMiniFactory as a provider

Import, collection import and synced collections from MyMiniFactory, alongside MakerWorld,
Printables and Thingiverse.

- Backend: API v2 client with an instance-wide API key for metadata, collections and
  authors; files from MyMiniFactory's own download links (free models need no login).
  Paid models, and free ones whose files are archives, download as the user from their
  MyMiniFactory library through their session (PHPSESSID), fetching the model files
  MyMiniFactory unpacked from each archive. Paid models are left out of collection
  listings and syncs.
- One migration: MyMiniFactory category ids, username and session columns, plus a data
  migration giving existing users' starter categories the new category ids. Category
  matching now prefers the most specific site category.
- Frontend: provider everywhere the others appear (badges, Configuration and
  Administration providers, sync Browse tab, user menu, link detection, System Health,
  category ids), shared instance-key form for Thingiverse and MyMiniFactory, en and lt.
- Thingport Grab: new provider sites are allowed once from the popup (optional
  all-sites access, content script registered at runtime), so adding one never makes
  the browser disable the extension; captures the signed-in MyMiniFactory username and
  session; paid-model notice.
- Docs, website, store assets and screenshots updated.
This commit is contained in:
Tautvydas Derzinskas committed 2026-10-18 18:19:43 +02:00
1 parent f49d18c384
commit a7a91902a0
114 files changed
+3064 -356

No files matched your search

+5 -5
View File
@@ -31,7 +31,7 @@ from the places where you discover them.
## About
To put it simply, if you're into 3D printing, you probably look for models across multiple platforms, with **MakerWorld, Printables, and Thingiverse** being some of the most popular ones. And, if you're anything like me, you've probably built up quite a collection of bookmarked models across these sites.
To put it simply, if you're into 3D printing, you probably look for models across multiple platforms, with **MakerWorld, Printables, Thingiverse, and MyMiniFactory** being some of the most popular ones. And, if you're anything like me, you've probably built up quite a collection of bookmarked models across these sites.
But there's a problem.
@@ -90,7 +90,7 @@ No more worrying about losing access to your favourite models. Your library stay
<tr>
<td width="33%"><a href="extension/docs/screenshots/panel-makerworld.jpg" target="_blank"><img src="extension/docs/screenshots/panel-makerworld.jpg" width="100%" alt="Thingport Grab on MakerWorld"></a><br><sub><b>Thingport Grab — MakerWorld</b></sub></td>
<td width="33%"><a href="extension/docs/screenshots/collection-makerworld.jpg" target="_blank"><img src="extension/docs/screenshots/collection-makerworld.jpg" width="100%" alt="Thingport Grab syncing a MakerWorld collection"></a><br><sub><b>Thingport Grab — Sync a Collection</b></sub></td>
<td width="33%"></td>
<td width="33%"><a href="extension/docs/screenshots/panel-myminifactory.jpg" target="_blank"><img src="extension/docs/screenshots/panel-myminifactory.jpg" width="100%" alt="Thingport Grab on MyMiniFactory"></a><br><sub><b>Thingport Grab — MyMiniFactory</b></sub></td>
</tr>
</table>
@@ -278,7 +278,7 @@ To have Thingport import whatever you drop into a folder on the server, uncommen
## Features
- **[Import from MakerWorld, Printables and Thingiverse](docs/features/importing.md)**
- **[Import from MakerWorld, Printables, Thingiverse and MyMiniFactory](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, and Printables 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 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).
## Provider Setup
Printables imports work with no setup. MakerWorld and Thingiverse each need a credential from your own account first. 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 and how to grab a MakerWorld session cookie.
Printables imports work with no setup. MakerWorld, Thingiverse and MyMiniFactory each need a credential from your own account first. 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,78 @@
-- MyMiniFactory as a fourth provider: its site category ids on categories; who the user is there; and their
-- MyMiniFactory session, for downloading the paid and login-only models in their library.
ALTER TABLE "Category" ADD COLUMN "myminifactoryCatIds" INTEGER[] NOT NULL DEFAULT '{}';
CREATE INDEX "Category_myminifactoryCatIds_idx" ON "Category" USING GIN ("myminifactoryCatIds");
ALTER TABLE "User" ADD COLUMN "myminifactoryUsername" TEXT,
ADD COLUMN "myminifactoryCookie" TEXT,
ADD COLUMN "myminifactoryRejectedAt" TIMESTAMP(3);
-- Adds MyMiniFactory's category ids to the starter categories of users who signed up before they were in
-- seedData/defaultCategories.ts, so their MyMiniFactory 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 "myminifactoryCatIds" = m.ids
FROM (VALUES
(NULL::text, '3D Printer', ARRAY[366]),
('3D Printer', '3D Printer Accessories', ARRAY[1001, 1000]),
('3D Printer', '3D Printer Parts', ARRAY[493, 494, 495, 588, 590, 591, 592, 593, 594, 595, 596, 597, 598, 599, 600, 601, 602, 603, 604, 605, 606, 607, 608, 672, 949, 1271]),
('3D Printer', 'Test Models', ARRAY[960]),
('Art', 'Signs & Logos', ARRAY[105]),
('Art', 'Sculptures', ARRAY[112, 355]),
(NULL::text, 'Education', ARRAY[86]),
('Education', 'Biology', ARRAY[154, 373, 997, 1045]),
('Education', 'Chemistry', ARRAY[162]),
('Education', 'Engineering', ARRAY[953, 241, 374, 1042]),
('Education', 'Geography', ARRAY[164]),
('Education', 'Mathematics', ARRAY[163, 1035]),
('Education', 'Physics & Astronomy', ARRAY[165, 558, 1036]),
('Education', 'Other Education Models', ARRAY[344, 166, 109, 1034, 969, 970, 971, 972, 973]),
(NULL::text, 'Fashion', ARRAY[58]),
('Fashion', 'Bags', ARRAY[143]),
('Fashion', 'Clothes', ARRAY[169]),
('Fashion', 'Footwear', ARRAY[140]),
('Fashion', 'Glasses', ARRAY[147]),
('Fashion', 'Earrings', ARRAY[405]),
('Fashion', 'Rings', ARRAY[406]),
('Fashion', 'Jewelry', ARRAY[291, 148, 426, 359, 430, 145, 439]),
('Fashion', 'Other Fashion Models', ARRAY[357]),
('Hobby & DIY', 'Electronics', ARRAY[59, 943, 328, 378, 329, 941, 275, 113, 326, 647, 375, 488, 489, 490, 491, 492, 648, 507, 633, 670]),
('Hobby & DIY', 'Music', ARRAY[68]),
('Hobby & DIY', 'RC', ARRAY[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]),
('Hobby & DIY', 'Robotics', ARRAY[393, 429, 999]),
('Hobby & DIY', 'Sport & Outdoors', ARRAY[56, 69, 71, 966, 967, 968, 114, 342, 550, 551, 962]),
(NULL::text, 'Household', ARRAY[57]),
('Household', 'Decor', ARRAY[335, 389, 399, 372, 398, 362, 336]),
('Household', 'Festivities', ARRAY[385, 384, 634, 635, 636]),
('Household', 'Garden', ARRAY[150]),
('Household', 'Office', ARRAY[382, 377]),
('Household', 'Other Household Models', ARRAY[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]),
(NULL::text, 'Miniatures', ARRAY[1015]),
('Miniatures', 'Architecture', ARRAY[787, 1153, 1158, 913]),
('Miniatures', 'People', ARRAY[785, 780, 782, 783, 784, 1145]),
('Miniatures', 'Other Miniatures', ARRAY[617, 786, 1306, 1310, 1311, 1312, 1313, 1304, 1319, 1325, 1324]),
(NULL::text, 'Props & Cosplays', ARRAY[119]),
('Props & Cosplays', 'Costumes', ARRAY[340]),
('Props & Cosplays', 'Masks & Helmets', ARRAY[155]),
('Props & Cosplays', 'Cosplay Weapons', ARRAY[542, 156, 304]),
(NULL::text, 'Tools', ARRAY[252]),
('Tools', 'Gadgets', ARRAY[94, 330, 368, 388]),
('Tools', 'Organizers', ARRAY[149]),
('Tools', 'Medical Tools', ARRAY[288, 110, 1259, 1004, 1022, 1023, 1024, 1006, 1005, 1007, 1008, 1025, 1028, 1029, 1031, 1032, 1033, 1091]),
(NULL::text, 'Toys & Games', ARRAY[60]),
('Toys & Games', 'Board Games', ARRAY[1014, 523, 1016]),
('Toys & Games', 'Characters', ARRAY[104, 67, 435, 442, 496, 532, 533, 436, 536, 537, 538, 539, 540, 541, 543, 544, 733, 927, 957, 1159, 1309]),
('Toys & Games', 'Puzzles', ARRAY[100]),
('Toys & Games', 'Other Toys & Games', ARRAY[462, 1308, 1307, 529])
) AS m(parent_name, name, ids)
WHERE c."kind" = 'category'
AND c."name" = m.name
AND cardinality(c."myminifactoryCatIds") = 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
)
);
+26 -18
View File
@@ -64,6 +64,12 @@ model User {
// private collections and likes are refused until they give one again.
printablesPushOff Boolean @default(false)
thingiverseUsername String?
myminifactoryUsername String?
// Per-user, beside the instance-wide API key: the MyMiniFactory session (PHPSESSID) that downloads
// paid and login-only models from the user's own library.
myminifactoryCookie String?
// When MyMiniFactory last turned the session down.
myminifactoryRejectedAt DateTime?
slicer String?
// "light" | "dark" | "system"; null means never set.
themePreference String?
@@ -114,35 +120,37 @@ model Invitation {
}
model Category {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String
tags String[] @default([])
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String
tags String[] @default([])
// "category" (the starter categories imports sort into) or "folder" (the user's own tree, e.g.
// from uploading folders). Always the same as the top-level ancestor's.
kind String @default("folder")
parentId String?
parent Category? @relation("CategoryTree", fields: [parentId], references: [id], onDelete: SetNull)
children Category[] @relation("CategoryTree")
kind String @default("folder")
parentId String?
parent Category? @relation("CategoryTree", fields: [parentId], references: [id], onDelete: SetNull)
children Category[] @relation("CategoryTree")
// Order among siblings. No unique index: Postgres treats NULL parentIds as distinct.
position Int @default(0)
metaTitle String?
metaDescription String?
position Int @default(0)
metaTitle String?
metaDescription String?
// Imports whose site category id matches any of these land in this category. Each site has its
// own id namespace.
makerworldCatIds Int[] @default([])
thingiverseCatIds Int[] @default([])
printablesCatIds Int[] @default([])
prints Print[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
makerworldCatIds Int[] @default([])
thingiverseCatIds Int[] @default([])
printablesCatIds Int[] @default([])
myminifactoryCatIds Int[] @default([])
prints Print[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId, parentId, position])
@@index([tags], type: Gin)
@@index([makerworldCatIds], type: Gin)
@@index([thingiverseCatIds], type: Gin)
@@index([printablesCatIds], type: Gin)
@@index([myminifactoryCatIds], type: Gin)
}
model Print {
+2
View File
@@ -178,6 +178,7 @@ export type CategoryOut = {
makerworld_cat_ids: string;
thingiverse_cat_ids: string;
printables_cat_ids: string;
myminifactory_cat_ids: string;
};
function plateThumbUrl(plateId: string): string | null {
@@ -471,6 +472,7 @@ export function toCategoryOut(category: Category): CategoryOut {
makerworld_cat_ids: formatCatIds(category.makerworldCatIds),
thingiverse_cat_ids: formatCatIds(category.thingiverseCatIds),
printables_cat_ids: formatCatIds(category.printablesCatIds),
myminifactory_cat_ids: formatCatIds(category.myminifactoryCatIds),
};
}
+2
View File
@@ -190,6 +190,7 @@ const categoryMetaSchema = z.object({
makerworld_cat_ids: catIdsField,
thingiverse_cat_ids: catIdsField,
printables_cat_ids: catIdsField,
myminifactory_cat_ids: catIdsField,
});
router.patch(
@@ -206,6 +207,7 @@ router.patch(
makerworldCatIds: parseCatIdsInput(body.makerworld_cat_ids),
thingiverseCatIds: parseCatIdsInput(body.thingiverse_cat_ids),
printablesCatIds: parseCatIdsInput(body.printables_cat_ids),
myminifactoryCatIds: parseCatIdsInput(body.myminifactory_cat_ids),
},
});
res.json(toCategoryOut(updated));
+81 -2
View File
@@ -40,7 +40,13 @@ import {
parseThingiverseLikesUrl,
} from "../services/thingiverseApi";
import { fetchPrintablesCollectionEntries, parsePrintablesCollectionUrl } from "../services/printablesApi";
import { getThingiverseAccessToken } from "../services/settingsService";
import { getMyminifactoryApiKey, getThingiverseAccessToken } from "../services/settingsService";
import {
fetchMyminifactoryCollectionObjects,
MyminifactoryAuthError,
MyminifactoryRateLimitError,
parseMyminifactoryCollectionUrl,
} from "../services/myminifactoryApi";
import { getUserMakerworldCookie } from "../services/makerworldCookieService";
import { listZipEntries } from "../services/zipService";
import { createJob, createJobItems, getActiveJob, getJob, listJobItems } from "../services/importJobService";
@@ -50,6 +56,7 @@ import {
runCollectionImportJob,
runLinksImportJob,
runMakerworldProfilesImportJob,
runMyminifactoryCollectionImportJob,
runPrintablesCollectionImportJob,
runThingiverseCollectionImportJob,
runThingiverseLikesImportJob,
@@ -202,7 +209,7 @@ router.get(
);
const statusBatchSchema = z.object({
provider: z.enum(["makerworld", "thingiverse", "printables"]),
provider: z.enum(["makerworld", "thingiverse", "printables", "myminifactory"]),
external_ids: z.array(z.string().max(64)).max(10_000),
});
@@ -388,6 +395,55 @@ router.post(
}),
);
const MYMINIFACTORY_NOT_CONFIGURED =
"MyMiniFactory import isn't configured for this instance yet -- ask an admin to add an API key in Administration > Providers.";
router.post(
"/import/myminifactory-collection/entries",
asyncHandler(async (req, res) => {
const body = parseBody(importRequestSchema, req.body);
const url = await enabledImportUrl(body.url);
const parsed = parseMyminifactoryCollectionUrl(url);
if (!parsed) throw new HttpError(400, "Not a MyMiniFactory collection URL");
const apiKey = await getMyminifactoryApiKey();
if (!apiKey) throw new HttpError(503, MYMINIFACTORY_NOT_CONFIGURED);
let listing: Awaited<ReturnType<typeof fetchMyminifactoryCollectionObjects>>;
try {
listing = await fetchMyminifactoryCollectionObjects(parsed.username, parsed.slug, apiKey);
} catch (err) {
if (err instanceof MyminifactoryRateLimitError) throw new HttpError(429, err.message);
// 400, not 401: the frontend treats any 401 as an expired Thingport session.
if (err instanceof MyminifactoryAuthError) throw new HttpError(400, err.message);
throw err;
}
if (listing && !listing.entries.length && listing.paid) {
throw new HttpError(
400,
"Every model in this collection is paid, and Thingport can import MyMiniFactory's free models only.",
);
}
if (!listing?.entries.length) throw new HttpError(400, "Could not load this collection's models");
const alreadyImported = await findImportedExternalIds(
req.userId!,
"myminifactory",
listing.entries.map((e) => e.objectId),
);
res.json({
title: listing.title,
total: listing.entries.length,
truncated: listing.truncated,
entries: listing.entries.map((e) => ({
design_id: e.objectId,
title: e.title,
cover: e.cover,
already_imported: alreadyImported.has(e.objectId),
})),
});
}),
);
// 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.
@@ -499,6 +555,29 @@ router.post(
}),
);
const myminifactoryCollectionImportRequestSchema = importRequestSchema.extend({
object_ids: z.array(z.string()).min(1),
});
router.post(
"/import/myminifactory-collection",
requireCaptcha("import"),
asyncHandler(async (req, res) => {
const body = parseBody(myminifactoryCollectionImportRequestSchema, req.body);
await assertNoActiveJob(req.userId!);
const url = await enabledImportUrl(body.url);
const parsed = parseMyminifactoryCollectionUrl(url);
if (!parsed) throw new HttpError(400, "Not a MyMiniFactory collection URL");
const job = await createJob(req.userId!, "COLLECTION", {
sourceUrl: url,
provider: "myminifactory",
total: body.object_ids.length,
});
void runMyminifactoryCollectionImportJob(job.id, req.userId!, { ...body, url, ...parsed });
res.status(202).json({ job_id: job.id });
}),
);
const zipExtractRequestSchema = importRequestSchema.extend({ entries: z.array(z.string()) });
router.post(
+80
View File
@@ -27,6 +27,8 @@ import {
setSimplifyPreviews,
setSmtpSettings,
setThingiverseAccessToken,
getMyminifactoryApiKey,
setMyminifactoryApiKey,
type SmtpSettings,
getAiCategorizationPrefs,
setAiCategorizationPrefs,
@@ -48,6 +50,12 @@ import {
import { getMakerworldConnection, setUserMakerworldCookie } from "../services/makerworldCookieService";
import { type MakerworldCookieCheck, verifyMakerworldCookie } from "../services/makerworldCloudApi";
import { verifyThingiverseAccessToken } from "../services/thingiverseApi";
import { checkMyminifactoryApiKey, checkMyminifactorySession } from "../services/myminifactoryApi";
import {
getMyminifactoryConnection,
normalizeMyminifactoryCookie,
setUserMyminifactoryCookie,
} from "../services/myminifactorySessionService";
import { SLICER_IDS, getUserSlicer, setUserSlicer } from "../services/slicerPreferenceService";
import { CATEGORIES_VIEWS, getCategoriesView, setCategoriesView } from "../services/categoriesViewService";
import {
@@ -346,6 +354,37 @@ router.post(
}),
);
// Like Thingiverse's: readable by every user, and GET only says whether a key is set.
router.get(
"/settings/myminifactory",
asyncHandler(async (_req, res) => {
res.json({ configured: Boolean(await getMyminifactoryApiKey()) });
}),
);
const MYMINIFACTORY_KEY_PROBLEMS = {
rejected: "MyMiniFactory rejected this API key. Check it in your MyMiniFactory account settings and try again.",
blocked:
"MyMiniFactory blocked the check (rate limit or Cloudflare challenge), so the key couldn't be verified. Wait a while and try again.",
unreachable: "Couldn't reach MyMiniFactory to verify this API key. Try again in a moment.",
} as const;
const myminifactorySettingsSchema = z.object({ api_key: z.string().nullable() });
router.post(
"/settings/myminifactory",
requireAdmin,
asyncHandler(async (req, res) => {
const body = parseBody(myminifactorySettingsSchema, req.body);
const trimmed = (body.api_key ?? "").trim();
if (trimmed) {
const result = await checkMyminifactoryApiKey(trimmed);
if (result !== "ok") throw new HttpError(422, MYMINIFACTORY_KEY_PROBLEMS[result]);
}
await setMyminifactoryApiKey(body.api_key);
res.json({ configured: Boolean(trimmed) });
}),
);
function smtpSettingsOut(smtp: SmtpSettings) {
return {
host: smtp.host,
@@ -531,6 +570,47 @@ router.patch(
}),
);
// Per-user, beside the instance's MyMiniFactory API key: the session that downloads paid and login-only models.
router.get(
"/settings/myminifactory-session",
asyncHandler(async (req, res) => {
const connection = await getMyminifactoryConnection(req.userId!);
res.json({ configured: connection !== "disconnected", expired: connection === "expired" });
}),
);
// `verify` is opt-in, as for MakerWorld: Thingport Grab sends a session it just read from a signed-in browser.
const myminifactorySessionSchema = z.object({
cookie: z.string().max(10_000).nullable(),
verify: z.boolean().optional(),
});
router.patch(
"/settings/myminifactory-session",
asyncHandler(async (req, res) => {
const body = parseBody(myminifactorySessionSchema, req.body);
const cookie = normalizeMyminifactoryCookie(body.cookie);
if ((body.cookie ?? "").trim() && !cookie) {
throw new HttpError(422, "That doesn't look like a MyMiniFactory session: paste the PHPSESSID cookie's value.");
}
let verified = false;
if (body.verify && cookie) {
const check = await checkMyminifactorySession(cookie);
if (check === "rejected") {
throw new HttpError(
422,
"MyMiniFactory didn't accept this session -- it may have expired. Copy a fresh PHPSESSID from a signed-in myminifactory.com tab and try again.",
);
}
// 503, not 422: nothing is known to be wrong with the session.
if (check === "unreachable")
throw new HttpError(503, "Couldn't reach MyMiniFactory to check this session. Try again in a moment.");
verified = true;
}
const configured = await setUserMyminifactoryCookie(req.userId!, cookie, { verified });
res.json({ configured });
}),
);
router.get(
"/settings/slicer",
asyncHandler(async (req, res) => {
+68 -1
View File
@@ -1,4 +1,4 @@
/** Starter category tree modeled on MakerWorld's, with matching Thingiverse and Printables ids so
/** Starter category tree modeled on MakerWorld's, with matching Thingiverse, Printables and MyMiniFactory ids so
* imports are auto-categorized from the start. */
export type DefaultCategoryNode = {
name: string;
@@ -8,6 +8,7 @@ export type DefaultCategoryNode = {
makerworldCatIds?: number[];
thingiverseCatIds?: number[];
printablesCatIds?: number[];
myminifactoryCatIds?: number[];
children?: DefaultCategoryNode[];
};
@@ -20,6 +21,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
makerworldCatIds: [900],
thingiverseCatIds: [73],
printablesCatIds: [1],
myminifactoryCatIds: [366],
children: [
{
name: "3D Printer Accessories",
@@ -29,6 +31,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
makerworldCatIds: [901],
thingiverseCatIds: [127],
printablesCatIds: [40],
myminifactoryCatIds: [1001, 1000],
},
{
name: "3D Printer Parts",
@@ -38,6 +41,10 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
makerworldCatIds: [902],
thingiverseCatIds: [128, 152, 126],
printablesCatIds: [134, 138, 136, 135, 2, 137],
myminifactoryCatIds: [
493, 494, 495, 588, 590, 591, 592, 593, 594, 595, 596, 597, 598, 599, 600, 601, 602, 603, 604, 605, 606, 607,
608, 672, 949, 1271,
],
},
{
name: "Test Models",
@@ -47,6 +54,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
makerworldCatIds: [903],
thingiverseCatIds: [129],
printablesCatIds: [12],
myminifactoryCatIds: [960],
},
],
},
@@ -83,6 +91,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
"Elevate your branding and signage with our collection of impactful 3D printed signs and logos. Create custom signage for businesses, events, or home décor that stands out from the crowd.",
makerworldCatIds: [102],
thingiverseCatIds: [76],
myminifactoryCatIds: [105],
},
{
name: "Sculptures",
@@ -92,6 +101,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
makerworldCatIds: [104],
thingiverseCatIds: [80],
printablesCatIds: [14],
myminifactoryCatIds: [112, 355],
},
{
name: "Other Art Models",
@@ -112,6 +122,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
makerworldCatIds: [500],
thingiverseCatIds: [69],
printablesCatIds: [90],
myminifactoryCatIds: [86],
children: [
{
name: "Biology",
@@ -120,6 +131,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
"Explore the intricate world of life with our meticulously crafted 3D printed biology models, visualizing and understanding anatomy, molecular structures, and biological processes.",
makerworldCatIds: [503],
thingiverseCatIds: [106],
myminifactoryCatIds: [154, 373, 997, 1045],
},
{
name: "Chemistry",
@@ -128,6 +140,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
"Unravel the mysteries of matter with our captivating 3D printed chemistry models, visualizing and understanding molecular structures, chemical compounds, and complex reactions.",
makerworldCatIds: [505],
printablesCatIds: [92],
myminifactoryCatIds: [162],
},
{
name: "Engineering",
@@ -137,6 +150,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
makerworldCatIds: [501],
thingiverseCatIds: [104],
printablesCatIds: [93],
myminifactoryCatIds: [953, 241, 374, 1042],
},
{
name: "Geography",
@@ -145,6 +159,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
"Embark on a geographical journey around the world with our captivating 3D printed models, visualizing mountain ranges, continents, oceans, rivers, and climate patterns.",
makerworldCatIds: [506],
printablesCatIds: [98],
myminifactoryCatIds: [164],
},
{
name: "Mathematics",
@@ -154,6 +169,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
makerworldCatIds: [502],
thingiverseCatIds: [105],
printablesCatIds: [94],
myminifactoryCatIds: [163, 1035],
},
{
name: "Physics & Astronomy",
@@ -163,6 +179,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
makerworldCatIds: [504],
thingiverseCatIds: [148],
printablesCatIds: [91],
myminifactoryCatIds: [165, 558, 1036],
},
{
name: "Other Education Models",
@@ -171,6 +188,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
"Discover a world of endless learning possibilities with our diverse collection of 3D printed educational models, exploring history, social studies, art, design, language, and literature.",
makerworldCatIds: [507],
printablesCatIds: [96],
myminifactoryCatIds: [344, 166, 109, 1034, 969, 970, 971, 972, 973],
},
],
},
@@ -182,6 +200,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
makerworldCatIds: [200],
thingiverseCatIds: [64],
printablesCatIds: [17],
myminifactoryCatIds: [58],
children: [
{
name: "Bags",
@@ -189,6 +208,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
metaDescription:
"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],
},
{
name: "Clothes",
@@ -197,6 +217,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
"Elevate your style with our eye-catching and personalized 3D printed clothes accessories, from statement-making necklaces and bracelets to bold earrings and scarves.",
makerworldCatIds: [202],
thingiverseCatIds: [142],
myminifactoryCatIds: [169],
},
{
name: "Earrings",
@@ -205,6 +226,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
"Make a statement with our collection of exquisite and unique 3D printed earrings, featuring a wide variety of designs from delicate and feminine to bold and geometric.",
makerworldCatIds: [206],
thingiverseCatIds: [139],
myminifactoryCatIds: [405],
},
{
name: "Footwear",
@@ -212,6 +234,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
metaDescription:
"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],
},
{
name: "Glasses",
@@ -220,6 +243,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
"See the world in a new light with our stylish and functional 3D printed glasses, offering a variety of frames and lenses to create both fashionable and practical eyewear.",
makerworldCatIds: [203],
thingiverseCatIds: [83],
myminifactoryCatIds: [147],
},
{
name: "Jewelry",
@@ -228,6 +252,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
"Make one-of-a-kind pieces of jewelry with our amazing 3D printed jewelry, featuring a wide range of designs from delicate and intricate to bold and statement-making.",
makerworldCatIds: [208],
thingiverseCatIds: [84, 82],
myminifactoryCatIds: [291, 148, 426, 359, 430, 145, 439],
},
{
name: "Rings",
@@ -236,6 +261,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
"Find the perfect ring to symbolize your love or commitment with our unique and personalized 3D printed rings, including classic wedding bands, modern statement rings, and personalized rings with custom engravings.",
makerworldCatIds: [205],
thingiverseCatIds: [85],
myminifactoryCatIds: [406],
},
{
name: "Other Fashion Models",
@@ -245,6 +271,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
makerworldCatIds: [207],
thingiverseCatIds: [81, 130],
printablesCatIds: [18, 20, 42],
myminifactoryCatIds: [357],
},
],
},
@@ -265,6 +292,9 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
makerworldCatIds: [301],
thingiverseCatIds: [92],
printablesCatIds: [52],
myminifactoryCatIds: [
59, 943, 328, 378, 329, 941, 275, 113, 326, 647, 375, 488, 489, 490, 491, 492, 648, 507, 633, 670,
],
},
{
name: "Music",
@@ -274,6 +304,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
makerworldCatIds: [303],
thingiverseCatIds: [94],
printablesCatIds: [95],
myminifactoryCatIds: [68],
},
{
name: "RC",
@@ -282,6 +313,10 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
"Experience the thrill of crafting and controlling your own 3D printed RC vehicles, drones, and airplanes.",
makerworldCatIds: [304],
thingiverseCatIds: [95],
myminifactoryCatIds: [
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,
],
},
{
name: "Robotics",
@@ -291,6 +326,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
makerworldCatIds: [305],
thingiverseCatIds: [96],
printablesCatIds: [64],
myminifactoryCatIds: [393, 429, 999],
},
{
name: "Sport & Outdoors",
@@ -300,6 +336,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
makerworldCatIds: [306],
thingiverseCatIds: [140],
printablesCatIds: [9, 83, 74, 85, 84],
myminifactoryCatIds: [56, 69, 71, 966, 967, 968, 114, 342, 550, 551, 962],
},
{
name: "Vehicles",
@@ -329,6 +366,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
makerworldCatIds: [400],
thingiverseCatIds: [67],
printablesCatIds: [3],
myminifactoryCatIds: [57],
children: [
{
name: "Decor",
@@ -338,6 +376,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
makerworldCatIds: [401],
thingiverseCatIds: [97],
printablesCatIds: [44],
myminifactoryCatIds: [335, 389, 399, 372, 398, 362, 336],
},
{
name: "Festivities",
@@ -346,6 +385,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
"Celebrate special occasions and holidays with our festive 3D printed decorations, ornaments, and figurines.",
makerworldCatIds: [403],
printablesCatIds: [65, 69, 68, 70],
myminifactoryCatIds: [385, 384, 634, 635, 636],
},
{
name: "Garden",
@@ -355,6 +395,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
makerworldCatIds: [402],
thingiverseCatIds: [98],
printablesCatIds: [53, 71],
myminifactoryCatIds: [150],
},
{
name: "Office",
@@ -364,6 +405,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
makerworldCatIds: [404],
thingiverseCatIds: [101],
printablesCatIds: [29],
myminifactoryCatIds: [382, 377],
},
{
name: "Pets",
@@ -382,6 +424,10 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
makerworldCatIds: [406],
thingiverseCatIds: [99, 147, 146, 100, 153],
printablesCatIds: [5, 6, 139, 4, 7, 45],
myminifactoryCatIds: [
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,
],
},
],
},
@@ -393,6 +439,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
makerworldCatIds: [600],
thingiverseCatIds: [70],
printablesCatIds: [101],
myminifactoryCatIds: [1015],
children: [
{
name: "Animals",
@@ -411,6 +458,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
makerworldCatIds: [602],
thingiverseCatIds: [108],
printablesCatIds: [103, 62],
myminifactoryCatIds: [787, 1153, 1158, 913],
},
{
name: "Creatures",
@@ -429,6 +477,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
makerworldCatIds: [604],
thingiverseCatIds: [112],
printablesCatIds: [60],
myminifactoryCatIds: [785, 780, 782, 783, 784, 1145],
},
{
name: "Other Miniatures",
@@ -438,6 +487,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
makerworldCatIds: [605],
thingiverseCatIds: [110, 111, 115, 116],
printablesCatIds: [104, 105, 75],
myminifactoryCatIds: [617, 786, 1306, 1310, 1311, 1312, 1313, 1304, 1319, 1325, 1324],
},
],
},
@@ -448,6 +498,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
"Even if it's not Halloween, there's always time for printing a cool mask, helmet or other cosplay-related prints. Don't wait until it's too late, and start printing your awesome new outfit!",
makerworldCatIds: [1000],
printablesCatIds: [76],
myminifactoryCatIds: [119],
children: [
{
name: "Costumes",
@@ -456,6 +507,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
"Create stunning and authentic 3D printed costumes, from superhero suits to fantasy armor, for your next cosplay event or Halloween.",
makerworldCatIds: [1003],
printablesCatIds: [77],
myminifactoryCatIds: [340],
},
{
name: "Masks & Helmets",
@@ -464,6 +516,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
"Elevate your cosplay with our detailed and realistic 3D printed masks, helmets, and other facial accessories.",
makerworldCatIds: [1001],
printablesCatIds: [78],
myminifactoryCatIds: [155],
},
{
name: "Cosplay Weapons",
@@ -471,6 +524,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
metaDescription:
"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],
},
{
name: "Other Props & Cosplays",
@@ -490,6 +544,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
"There's never enough tools to have, or a way to organize those tools in an efficient manner. But this section is the perfect location to find the files you need to print a specific jig or an organizer for your latest gadget.",
makerworldCatIds: [700],
thingiverseCatIds: [71],
myminifactoryCatIds: [252],
children: [
{
name: "Gadgets",
@@ -499,6 +554,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
makerworldCatIds: [705],
thingiverseCatIds: [65],
printablesCatIds: [21, 25, 27, 26, 28, 100, 140, 43],
myminifactoryCatIds: [94, 330, 368, 388],
},
{
name: "Hand Tools",
@@ -530,6 +586,9 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
"Experience the future of medicine with our innovative 3D printed medical tools, surgical guides, and implants.",
makerworldCatIds: [707],
printablesCatIds: [87, 88, 99],
myminifactoryCatIds: [
288, 110, 1259, 1004, 1022, 1023, 1024, 1006, 1005, 1007, 1008, 1025, 1028, 1029, 1031, 1032, 1033, 1091,
],
},
{
name: "Organizers",
@@ -539,6 +598,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
makerworldCatIds: [701],
thingiverseCatIds: [102, 120],
printablesCatIds: [50],
myminifactoryCatIds: [149],
},
{
name: "Other Tools",
@@ -559,6 +619,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
makerworldCatIds: [800],
thingiverseCatIds: [72],
printablesCatIds: [30],
myminifactoryCatIds: [60],
children: [
{
name: "Board Games",
@@ -568,6 +629,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
makerworldCatIds: [802],
thingiverseCatIds: [122, 113, 151],
printablesCatIds: [31],
myminifactoryCatIds: [1014, 523, 1016],
},
{
name: "Characters",
@@ -576,6 +638,9 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
"Create your own personalized 3D printed characters, action figures, and dolls for imaginative play and storytelling.",
makerworldCatIds: [801],
printablesCatIds: [36],
myminifactoryCatIds: [
104, 67, 435, 442, 496, 532, 533, 436, 536, 537, 538, 539, 540, 541, 543, 544, 733, 927, 957, 1159, 1309,
],
},
{
name: "Outdoor Toys",
@@ -593,6 +658,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
makerworldCatIds: [804],
thingiverseCatIds: [125],
printablesCatIds: [33],
myminifactoryCatIds: [100],
},
{
name: "Construction Sets",
@@ -611,6 +677,7 @@ export const DEFAULT_CATEGORIES: DefaultCategoryNode[] = [
makerworldCatIds: [805],
thingiverseCatIds: [149, 124, 123],
printablesCatIds: [38, 47],
myminifactoryCatIds: [462, 1308, 1307, 529],
},
],
},
+20 -5
View File
@@ -15,20 +15,24 @@ import {
MakerworldCaptchaError,
} from "./makerworldCloudApi";
import { resolvePrintablesModel } from "./printablesApi";
import { getThingiverseAccessToken } from "./settingsService";
import { getMyminifactoryApiKey, getThingiverseAccessToken } from "./settingsService";
import { MyminifactoryAuthError, MyminifactoryRateLimitError, resolveMyminifactoryObject } from "./myminifactoryApi";
import { resolveThingiverseThing, ThingiverseAuthError, ThingiverseRateLimitError } from "./thingiverseApi";
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"] as const;
const LOOKUP_PROVIDERS = ["makerworld", "thingiverse", "printables", "myminifactory"] as const;
type LookupProvider = (typeof LOOKUP_PROVIDERS)[number];
export type LookupProblem =
| "thingiverse_no_token"
| "thingiverse_token_rejected"
| "thingiverse_rate_limited"
| "myminifactory_no_key"
| "myminifactory_key_rejected"
| "myminifactory_rate_limited"
| "makerworld_captcha"
| "makerworld_login_rejected";
@@ -126,6 +130,8 @@ export async function runAuthorLinking(run: AuthorLinkingRun): Promise<void> {
const thingiverseToken = await getThingiverseAccessToken();
// Only for providers still on: one turned off isn't missing a token or held by a captcha.
if (!skipped.has("thingiverse") && !thingiverseToken) skip("thingiverse", "thingiverse_no_token");
const myminifactoryKey = await getMyminifactoryApiKey();
if (!skipped.has("myminifactory") && !myminifactoryKey) skip("myminifactory", "myminifactory_no_key");
if (!skipped.has("makerworld") && makerworldCaptchaCooloffActive()) skip("makerworld", "makerworld_captcha");
for (const candidate of candidates) {
@@ -135,12 +141,14 @@ export async function runAuthorLinking(run: AuthorLinkingRun): Promise<void> {
}
let author: ImportedAuthorInfo | null = null;
try {
author = await lookUpAuthor(candidate, thingiverseToken);
author = await lookUpAuthor(candidate, { thingiverseToken, myminifactoryKey });
} catch (err) {
if (err instanceof MakerworldCaptchaError) skip("makerworld", "makerworld_captcha");
else if (err instanceof MakerworldAuthError) skip("makerworld", "makerworld_login_rejected");
else if (err instanceof ThingiverseAuthError) skip("thingiverse", "thingiverse_token_rejected");
else if (err instanceof ThingiverseRateLimitError) skip("thingiverse", "thingiverse_rate_limited");
else if (err instanceof MyminifactoryAuthError) skip("myminifactory", "myminifactory_key_rejected");
else if (err instanceof MyminifactoryRateLimitError) skip("myminifactory", "myminifactory_rate_limited");
else console.warn(`Couldn't look up the author of model ${candidate.printId}`, err);
}
const saved = author ? await upsertAuthorFromImport(author) : null;
@@ -160,11 +168,18 @@ export async function runAuthorLinking(run: AuthorLinkingRun): Promise<void> {
}
}
async function lookUpAuthor(candidate: Candidate, thingiverseToken: string | null): Promise<ImportedAuthorInfo | null> {
async function lookUpAuthor(
candidate: Candidate,
keys: { thingiverseToken: string | null; myminifactoryKey: string | null },
): Promise<ImportedAuthorInfo | null> {
switch (candidate.provider) {
case "thingiverse": {
await maybeSleep(IMPORT_COLLECTION_DELAY_MS);
return (await resolveThingiverseThing(candidate.externalId, thingiverseToken!))?.meta.author ?? null;
return (await resolveThingiverseThing(candidate.externalId, keys.thingiverseToken!))?.meta.author ?? null;
}
case "myminifactory": {
await maybeSleep(IMPORT_COLLECTION_DELAY_MS);
return (await resolveMyminifactoryObject(candidate.externalId, keys.myminifactoryKey!))?.meta.author ?? null;
}
case "printables": {
await maybeSleep(IMPORT_COLLECTION_DELAY_MS);
+1
View File
@@ -75,6 +75,7 @@ export async function seedDefaultCategories(tx: Prisma.TransactionClient, userId
makerworldCatIds: node.makerworldCatIds ?? [],
thingiverseCatIds: node.thingiverseCatIds ?? [],
printablesCatIds: node.printablesCatIds ?? [],
myminifactoryCatIds: node.myminifactoryCatIds ?? [],
},
});
let i = 0;
+18 -1
View File
@@ -41,8 +41,9 @@ import {
fetchThingiverseCollectionThings,
fetchThingiverseUserLikes,
} from "./thingiverseApi";
import { checkMyminifactoryCollection, fetchMyminifactoryCollectionObjects } from "./myminifactoryApi";
import { syncLikesUsername } from "../dto";
import { getThingiverseAccessToken } from "./settingsService";
import { getMyminifactoryApiKey, getThingiverseAccessToken } from "./settingsService";
import { createNotification } from "./notificationService";
import { createLog } from "./auditLog";
import { getDisabledProviders, isProviderEnabled, type Provider } from "./providerSettingsService";
@@ -94,6 +95,14 @@ export async function fetchListing(
const { entries } = await fetchThingiverseCollectionThings(id, token);
return entries.map((e) => e.thingId);
}
if (sync.provider === "myminifactory") {
const apiKey = await getMyminifactoryApiKey();
if (!apiKey) throw new Error("MyMiniFactory isn't connected on this Thingport");
const [username, slug] = id.split("/");
if (!(await checkMyminifactoryCollection(username, slug, apiKey))) return null;
const listing = await fetchMyminifactoryCollectionObjects(username, slug, apiKey);
return listing ? listing.entries.map((e) => e.objectId) : null;
}
throw new Error(`Unknown provider "${sync.provider}"`);
}
@@ -154,6 +163,11 @@ const CONNECTION_PROBLEMS = {
reason: "Thingiverse isn't connected on this Thingport.",
fix: "an admin connects it again",
},
myminifactoryDisconnected: {
error: "MyMiniFactory isn't connected on this Thingport",
reason: "MyMiniFactory isn't connected on this Thingport.",
fix: "an admin connects it again",
},
} satisfies Record<string, ConnectionProblem>;
/** `"A"`, `"A" and "B"`, or the first few and how many more. */
@@ -195,6 +209,9 @@ async function connectionProblem(sync: Pick<CollectionSync, "userId" | "provider
if (sync.provider === "thingiverse" && !(await getThingiverseAccessToken())) {
return CONNECTION_PROBLEMS.thingiverseDisconnected;
}
if (sync.provider === "myminifactory" && !(await getMyminifactoryApiKey())) {
return CONNECTION_PROBLEMS.myminifactoryDisconnected;
}
return null;
}
+25 -4
View File
@@ -9,6 +9,11 @@ import { addPrintsToCollection, findOrCreateCollectionByName, isSystemCollection
import { parseMakerworldCollectionUrl } from "./makerworldCollections";
import { parsePrintablesCollectionUrl, parsePrintablesLikesUrl } from "./printablesApi";
import { parseThingiverseCollectionUrl, parseThingiverseLikesUrl } from "./thingiverseApi";
import {
myminifactoryCollectionKey,
myminifactoryCollectionUrl,
parseMyminifactoryCollectionUrl,
} from "./myminifactoryApi";
import { createLog } from "./auditLog";
import { pushedLikes } from "./providerListingsService";
import { createNotification } from "./notificationService";
@@ -21,7 +26,7 @@ import {
type SyncProfileScope,
} from "../dto";
export type SyncProvider = "makerworld" | "printables" | "thingiverse";
export type SyncProvider = "makerworld" | "printables" | "thingiverse" | "myminifactory";
/** `likesOf` is set for a user's likes (on Thingiverse, or the user's own on Printables), which a
* sync follows like a collection. */
@@ -31,6 +36,7 @@ const PROVIDER_NAMES: Record<SyncProvider, string> = {
makerworld: "MakerWorld",
printables: "Printables",
thingiverse: "Thingiverse",
myminifactory: "MyMiniFactory",
};
export function providerName(provider: string): string {
@@ -67,6 +73,16 @@ export function parseSyncSource(url: string): SyncSource | null {
const id = thingiverse.collectionId;
return { provider: "thingiverse", externalId: id, url: `https://www.thingiverse.com/collections/${id}` };
}
const myminifactory = parseMyminifactoryCollectionUrl(url);
if (myminifactory) {
// Addressed by owner and slug: MyMiniFactory's collection pages carry no id.
const { username, slug } = myminifactory;
return {
provider: "myminifactory",
externalId: myminifactoryCollectionKey(username, slug),
url: myminifactoryCollectionUrl(username, slug),
};
}
const printablesLikes = parsePrintablesLikesUrl(url);
if (printablesLikes) {
// Only Thingport Grab can read these, as their owner (see providerListingsService.ts).
@@ -94,13 +110,18 @@ export function parseSyncSource(url: string): SyncSource | null {
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}`;
return `https://www.thingiverse.com/thing:${externalId}`;
}
/** The model id in a link modelUrlFor made. */
export function modelIdFromUrl(provider: string, url: string): string | null {
const pattern =
provider === "makerworld" ? /\/models\/(\d+)/ : provider === "printables" ? /\/model\/(\d+)/ : /thing:(\d+)/;
const patterns: Record<string, RegExp> = {
makerworld: /\/models\/(\d+)/,
printables: /\/model\/(\d+)/,
myminifactory: /\/object\/(?:[^/]*-)?(\d+)/,
};
const pattern = patterns[provider] ?? /thing:(\d+)/;
return url.match(pattern)?.[1] ?? null;
}
@@ -170,7 +191,7 @@ export async function enableSync(userId: string, input: EnableSyncInput): Promis
if (!source)
throw new HttpError(
400,
"Only MakerWorld, Printables and Thingiverse collections, and Thingiverse or your own Printables likes, can be synced",
"Only MakerWorld, Printables, Thingiverse and MyMiniFactory collections, and Thingiverse or your own Printables likes, can be synced",
);
if (source.provider === "printables" && source.likesOf) await assertOwnPrintablesLikes(userId, source.likesOf);
const knownIds = [...new Set(input.knownIds.map((id) => id.trim()).filter(Boolean))];
@@ -6,7 +6,8 @@ import { localizeDescriptionImages } from "./descriptionImageService";
import { fetchSourceMeta } from "./fillGapsService";
import { buildImportSourceUrl } from "./importService";
import { makerworldCaptchaCooloffActive, MakerworldAuthError, MakerworldCaptchaError } from "./makerworldCloudApi";
import { getThingiverseAccessToken } from "./settingsService";
import { getMyminifactoryApiKey, getThingiverseAccessToken } from "./settingsService";
import { MyminifactoryAuthError, MyminifactoryRateLimitError } from "./myminifactoryApi";
import { ThingiverseAuthError, ThingiverseRateLimitError } from "./thingiverseApi";
import type { LookupProblem } from "./authorLinkingService";
import { getDisabledProviders } from "./providerSettingsService";
@@ -14,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"] as const;
const SOURCE_PROVIDERS = ["makerworld", "thingiverse", "printables", "myminifactory"] as const;
type SourceProvider = (typeof SOURCE_PROVIDERS)[number];
export type DescriptionRefetchRun = {
@@ -107,6 +108,9 @@ export async function runDescriptionRefetch(run: DescriptionRefetchRun): Promise
// Only warned about for sites this user's models actually come from.
const providers = new Set(prints.map((print) => print.sourceProvider));
if (providers.has("thingiverse") && !(await getThingiverseAccessToken())) skip("thingiverse", "thingiverse_no_token");
if (providers.has("myminifactory") && !(await getMyminifactoryApiKey())) {
skip("myminifactory", "myminifactory_no_key");
}
if (providers.has("makerworld") && makerworldCaptchaCooloffActive()) skip("makerworld", "makerworld_captcha");
for (const print of prints) {
@@ -139,6 +143,8 @@ export async function runDescriptionRefetch(run: DescriptionRefetchRun): Promise
else if (err instanceof ThingiverseAuthError) skip("thingiverse", "thingiverse_token_rejected");
// Cloudflare's 429 is sticky, so every further Thingiverse request would fail the same way.
else if (err instanceof ThingiverseRateLimitError) skip("thingiverse", "thingiverse_rate_limited");
else if (err instanceof MyminifactoryAuthError) skip("myminifactory", "myminifactory_key_rejected");
else if (err instanceof MyminifactoryRateLimitError) skip("myminifactory", "myminifactory_rate_limited");
else console.warn(`Couldn't refetch the description of model ${print.id}`, err);
}
run.done++;
+18 -2
View File
@@ -17,7 +17,8 @@ import {
} from "./importResolvers";
import { resolveThingiverseThing, ThingiverseAuthError, ThingiverseRateLimitError } from "./thingiverseApi";
import { parsePrintablesModelUrl, resolvePrintablesModel } from "./printablesApi";
import { getThingiverseAccessToken } from "./settingsService";
import { getMyminifactoryApiKey, getThingiverseAccessToken } from "./settingsService";
import { MyminifactoryAuthError, MyminifactoryRateLimitError, resolveMyminifactoryObject } from "./myminifactoryApi";
import { getUserMakerworldCookie } from "./makerworldCookieService";
import { upsertAuthorFromImport } from "./authorService";
import { emptyGaps, openSourceGaps, type SourceGap } from "./sourceGaps";
@@ -31,7 +32,7 @@ export type FillGapsResult = {
};
/** Reads the source's metadata. Nothing is downloaded but the images that fill an empty slot.
* Thingiverse's own errors are passed through, so callers can tell a rejected token apart. */
* Thingiverse's and MyMiniFactory's own errors are passed through, so callers can tell a rejected token apart. */
export async function fetchSourceMeta(userId: string, print: Print, sourceUrl: string): Promise<ImportedPageMetadata> {
const provider = print.sourceProvider!;
const externalId = print.sourceExternalId!;
@@ -82,6 +83,19 @@ export async function fetchSourceMeta(userId: string, print: Print, sourceUrl: s
};
}
if (provider === "myminifactory") {
const apiKey = await getMyminifactoryApiKey();
if (!apiKey) {
throw new HttpError(
503,
"MyMiniFactory import isn't configured for this instance yet -- ask an admin to add an API key in Administration > Providers.",
);
}
const resolved = await resolveMyminifactoryObject(externalId, apiKey);
if (!resolved) throw new HttpError(404, "This MyMiniFactory object 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");
}
@@ -134,6 +148,8 @@ export async function fillSourceGaps(userId: string, printId: string): Promise<F
} catch (err) {
if (err instanceof ThingiverseRateLimitError) throw new HttpError(429, err.message);
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);
throw err;
}
const wanted = new Set(gaps);
+81 -28
View File
@@ -28,7 +28,8 @@ import {
import { upsertAuthorFromImport } from "./authorService";
import { extractZipEntriesToPrints } from "./zipService";
import { fetchThingiverseCollectionTitle } from "./thingiverseApi";
import { getThingiverseAccessToken } from "./settingsService";
import { getMyminifactoryApiKey, getThingiverseAccessToken } from "./settingsService";
import { fetchMyminifactoryCollectionObjects } from "./myminifactoryApi";
import { fetchPrintablesCollectionTitle } from "./printablesApi";
import { createLog } from "./auditLog";
import { MAKERWORLD_REJECTED_ERROR, markMakerworldCookieRejected } from "./makerworldCookieService";
@@ -49,6 +50,11 @@ type CollectionImportJobBody = ImportRequestBody & { design_ids: string[] };
type ZipImportJobBody = ImportRequestBody & { entries: string[] };
type ThingiverseLikesImportJobBody = ImportRequestBody & { thing_ids: string[]; username: string };
type ThingiverseCollectionImportJobBody = ImportRequestBody & { thing_ids: string[]; collectionId: string };
type MyminifactoryCollectionImportJobBody = ImportRequestBody & {
object_ids: string[];
username: string;
slug: string;
};
type PrintablesCollectionImportJobBody = ImportRequestBody & {
model_ids: string[];
collectionId: string;
@@ -377,24 +383,53 @@ export async function runCollectionImportJob(
}
}
/** Shared by the Thingiverse Likes and Collection imports. 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 runThingiverseThingsImportJob(
/** A provider whose models import one by one with an instance-wide token: Thingiverse, MyMiniFactory. */
type TokenBatchProvider = {
provider: "thingiverse" | "myminifactory";
getToken: () => Promise<string | null>;
notConfigured: string;
modelUrl: (id: string) => string;
rateLimitText: string;
authText: string;
};
const THINGIVERSE_BATCH: TokenBatchProvider = {
provider: "thingiverse",
getToken: getThingiverseAccessToken,
notConfigured:
"Thingiverse import isn't configured for this instance yet -- ask an admin to add an App Token in Administration > Providers.",
modelUrl: (id) => `https://www.thingiverse.com/thing:${id}`,
rateLimitText:
"blocked by Thingiverse's rate-limit protection (too many requests at once) — wait a while, then retry",
authText: "failed because the configured App Token was rejected",
};
const MYMINIFACTORY_BATCH: TokenBatchProvider = {
provider: "myminifactory",
getToken: getMyminifactoryApiKey,
notConfigured:
"MyMiniFactory import isn't configured for this instance yet -- ask an admin to add an API key in Administration > Providers.",
modelUrl: (id) => `https://www.myminifactory.com/object/3d-print-${id}`,
rateLimitText: "blocked by MyMiniFactory's rate-limit protection — wait a while, then retry",
authText: "failed because the configured API key was rejected",
};
/** Shared by the Thingiverse Likes and Collection imports and MyMiniFactory's collections.
* Successful imports are filed into the collection named by `resolveCollectionTitle`, which runs
* afterwards so it costs no API call when nothing was imported. */
async function runTokenBatchImportJob(
jobId: string,
userId: string,
body: ImportRequestBody & { thing_ids: string[] },
source: TokenBatchProvider,
body: ImportRequestBody,
ids: string[],
resolveCollectionTitle: (accessToken: string) => Promise<string>,
sourceLabel: (collectionTitle: string) => string,
): Promise<void> {
const name = providerName(source.provider);
try {
const accessToken = await getThingiverseAccessToken();
if (!accessToken) {
throw new HttpError(
503,
"Thingiverse import isn't configured for this instance yet -- ask an admin to add an App Token in Administration > Providers.",
);
}
const accessToken = await source.getToken();
if (!accessToken) throw new HttpError(503, source.notConfigured);
let imported = 0;
let alreadyInLibrary = 0;
@@ -405,10 +440,10 @@ async function runThingiverseThingsImportJob(
let authFailed = 0;
const failed: string[] = [];
const successPrintIds: string[] = [];
log.info("thingiverse job started", { job: jobId, url: body.url, models: body.thing_ids.length });
log.info(`${source.provider} job started`, { job: jobId, url: body.url, models: ids.length });
await mapWithConcurrency(body.thing_ids, COLLECTION_IMPORT_CONCURRENCY, async (thingId, index) => {
const thingUrl = `https://www.thingiverse.com/thing:${thingId}`;
await mapWithConcurrency(ids, COLLECTION_IMPORT_CONCURRENCY, async (thingId, index) => {
const thingUrl = source.modelUrl(thingId);
const itemBody: ImportRequestBody = {
url: thingUrl,
notes: body.notes ?? null,
@@ -434,7 +469,7 @@ async function runThingiverseThingsImportJob(
() => undefined,
);
}
if (index < body.thing_ids.length - 1) await sleep(IMPORT_COLLECTION_DELAY_MS);
if (index < ids.length - 1) await sleep(IMPORT_COLLECTION_DELAY_MS);
});
let resultCollectionId: string | null = null;
@@ -468,7 +503,7 @@ async function runThingiverseThingsImportJob(
action: "import_completed",
targetId: resultCollectionId,
details: {
provider: "thingiverse",
provider: source.provider,
sourceLabel: collectionTitle,
imported,
alreadyInLibrary,
@@ -480,18 +515,14 @@ async function runThingiverseThingsImportJob(
if (alreadyInLibrary) bodyParts.push(`${alreadyInLibrary} already in your library`);
const otherFailed = failed.length - unavailable - rateLimited - authFailed - providerOff;
if (unavailable) bodyParts.push(`${unavailable} unavailable (private, deleted, or hidden)`);
if (providerOff) bodyParts.push(`${providerOff} skipped — ${PROVIDER_OFF_TEXT("Thingiverse")}`);
if (rateLimited) {
bodyParts.push(
`${rateLimited} blocked by Thingiverse's rate-limit protection (too many requests at once) — wait a while, then retry`,
);
}
if (authFailed) bodyParts.push(`${authFailed} failed because the configured App Token was rejected`);
if (providerOff) bodyParts.push(`${providerOff} skipped — ${PROVIDER_OFF_TEXT(name)}`);
if (rateLimited) bodyParts.push(`${rateLimited} ${source.rateLimitText}`);
if (authFailed) bodyParts.push(`${authFailed} ${source.authText}`);
if (otherFailed) bodyParts.push(`${otherFailed} failed`);
const label = sourceLabel(collectionTitle);
await createNotification(userId, {
type: "import",
title: `Imported ${imported} of ${body.thing_ids.length} models from Thingiverse`,
title: `Imported ${imported} of ${ids.length} models from ${name}`,
body: bodyParts.length ? `From ${label} — ${bodyParts.join(", ")}.` : `From ${label}.`,
externalUrl: body.url,
internalPath: resultCollectionId ? `/models/collections/${resultCollectionId}` : null,
@@ -506,10 +537,12 @@ export async function runThingiverseLikesImportJob(
userId: string,
body: ThingiverseLikesImportJobBody,
): Promise<void> {
await runThingiverseThingsImportJob(
await runTokenBatchImportJob(
jobId,
userId,
THINGIVERSE_BATCH,
body,
body.thing_ids,
async () => likesCollectionName(body.username),
() => `@${body.username}'s Likes`,
);
@@ -520,10 +553,12 @@ export async function runThingiverseCollectionImportJob(
userId: string,
body: ThingiverseCollectionImportJobBody,
): Promise<void> {
await runThingiverseThingsImportJob(
await runTokenBatchImportJob(
jobId,
userId,
THINGIVERSE_BATCH,
body,
body.thing_ids,
async (accessToken) =>
(await fetchThingiverseCollectionTitle(body.collectionId, accessToken)) ??
`Thingiverse Collection ${body.collectionId}`,
@@ -531,6 +566,24 @@ export async function runThingiverseCollectionImportJob(
);
}
export async function runMyminifactoryCollectionImportJob(
jobId: string,
userId: string,
body: MyminifactoryCollectionImportJobBody,
): Promise<void> {
await runTokenBatchImportJob(
jobId,
userId,
MYMINIFACTORY_BATCH,
body,
body.object_ids,
async (apiKey) =>
(await fetchMyminifactoryCollectionObjects(body.username, body.slug, apiKey, 1).catch(() => null))?.title ??
`MyMiniFactory Collection ${body.slug}`,
(collectionTitle) => `"${collectionTitle}"`,
);
}
export async function runPrintablesCollectionImportJob(
jobId: string,
userId: string,
+1 -1
View File
@@ -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" | null;
categorySite: "makerworld" | "thingiverse" | "printables" | "myminifactory" | null;
/** Lets a second profile of an imported design be added as its own plate. MakerWorld only. */
makerworldProfile?: MakerworldProfileRef;
};
+231 -4
View File
@@ -71,7 +71,23 @@ import {
resolvePrintablesModel,
type PrintablesPlateFile,
} from "./printablesApi";
import { getThingiverseAccessToken } from "./settingsService";
import {
isMyminifactoryFileHost,
listMyminifactoryLibraryFiles,
MyminifactoryAuthError,
MyminifactoryRateLimitError,
MyminifactorySessionExpiredError,
parseMyminifactoryObjectUrl,
resolveMyminifactoryDownload,
resolveMyminifactoryLibraryDownload,
resolveMyminifactoryObject,
} from "./myminifactoryApi";
import { getUserMyminifactoryCookie, markMyminifactoryCookieRejected } from "./myminifactorySessionService";
/** An import refused because the user's MyMiniFactory session has expired. */
export const MYMINIFACTORY_SESSION_CODE = "myminifactory_session";
import { listZipEntries, readZipEntry } from "../utils/zipReader";
import { getMyminifactoryApiKey, getThingiverseAccessToken } from "./settingsService";
import { upsertAuthorFromImport } from "./authorService";
import {
addPlatesToPrint,
@@ -553,10 +569,13 @@ const CATEGORY_SITE_CAT_IDS_FIELD = {
makerworld: "makerworldCatIds",
thingiverse: "thingiverseCatIds",
printables: "printablesCatIds",
myminifactory: "myminifactoryCatIds",
} as const;
/** Auto-categorizes an import when a category's `*CatIds` for this site overlap the model's own
* category ids. Best-effort. */
* category ids. Those come most specific first (a model's own category, then its parent), and the
* category matching the earliest one wins, so a subcategory beats the category its parent maps to.
* Best-effort. */
export async function resolveCategoryIdByCategory(
userId: string,
categorySite: ImportedPageMetadata["categorySite"],
@@ -564,12 +583,18 @@ export async function resolveCategoryIdByCategory(
): Promise<string | null> {
if (!categorySite || !siteCategoryIds.length) return null;
const field = CATEGORY_SITE_CAT_IDS_FIELD[categorySite];
const category = await prisma.category.findFirst({
const candidates = await prisma.category.findMany({
// Folders are the user's own tree; only categories take imports by site category id.
where: { userId, kind: "category", [field]: { hasSome: siteCategoryIds } },
orderBy: { position: "asc" },
});
return category?.id ?? null;
const rank = (category: (typeof candidates)[number]) => {
const ids = category[field];
const index = siteCategoryIds.findIndex((id) => ids.includes(id));
return index === -1 ? siteCategoryIds.length : index;
};
// Stable, so equally specific matches keep their position order.
return candidates.toSorted((a, b) => rank(a) - rank(b))[0]?.id ?? null;
}
/** Provider + stable external id for a model URL, used to dedupe imports. Null if unknown. */
@@ -580,6 +605,8 @@ export function identifySourceModel(url: string): { provider: string; externalId
if (thingiverse) return { provider: "thingiverse", externalId: thingiverse.thingId };
const printables = parsePrintablesModelUrl(url);
if (printables) return { provider: "printables", externalId: printables.modelId };
const myminifactory = parseMyminifactoryObjectUrl(url);
if (myminifactory) return { provider: "myminifactory", externalId: myminifactory.objectId };
return null;
}
@@ -623,6 +650,8 @@ export function buildImportSourceUrl(provider: string | null, externalId: string
if (provider === "makerworld") return `https://makerworld.com/en/models/${externalId}`;
if (provider === "thingiverse") return `https://www.thingiverse.com/thing:${externalId}`;
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}`;
return null;
}
@@ -978,6 +1007,201 @@ async function importPrintablesModel(
}
}
const MYMINIFACTORY_NOT_CONFIGURED =
"MyMiniFactory import isn't configured for this instance yet -- ask an admin to add an API key in Administration > Providers.";
/** The model files in a downloaded zip, each as its own plate; the zip itself is left for the caller. */
async function modelFilesFromZip(zipPath: string): Promise<NewPlateInput[]> {
const inputs: NewPlateInput[] = [];
try {
for (const entry of await listZipEntries(zipPath)) {
if (entry.isDirectory) continue;
const filename = sanitizeFilename(path.basename(entry.name.replace(/\\/g, "/")));
if (!MULTI_FILE_PLATE_EXTS.has(path.extname(filename).toLowerCase())) continue;
const buffer = await readZipEntry(zipPath, entry.name, IMPORT_MAX_BYTES);
if (!buffer) continue;
const tempFilePath = path.join(
os.tmpdir(),
`thingport-mmf-${crypto.randomBytes(8).toString("hex")}${path.extname(filename)}`,
);
await fs.writeFile(tempFilePath, buffer);
inputs.push({ filename, mime: guessMimeFromPath(filename), tempFilePath });
}
} catch (err) {
await Promise.all(inputs.map((input) => fs.rm(input.tempFilePath!, { force: true }).catch(() => undefined)));
throw err;
}
return inputs;
}
const MYMINIFACTORY_BLOCKED =
"MyMiniFactory blocked the file download (rate limit or Cloudflare challenge). This usually clears after a while -- wait, then retry the same import.";
/** One signed MyMiniFactory download, unpacked into model files when it's a zip. */
async function downloadMyminifactoryFile(url: string, suggestedName?: string): Promise<NewPlateInput[]> {
const fileUrl = new URL(url);
if (!isMyminifactoryFileHost(fileUrl.hostname)) return [];
const name = suggestedName ?? decodeURIComponent(fileUrl.pathname.split("/").pop() || "myminifactory-file");
const result = await downloadPlainFileToTemp(url, name);
if (result && "rateLimited" in result) throw new HttpError(429, MYMINIFACTORY_BLOCKED);
if (!result) return [];
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 [];
}
const MYMINIFACTORY_CONNECT =
"Connect your MyMiniFactory account in Configuration > Providers (or visit myminifactory.com with Thingport Grab), then import it again.";
/** As the user, from their MyMiniFactory library: what Thingport can't download anonymously. */
async function downloadMyminifactoryFromLibrary(
userId: string,
objectId: string,
paid: boolean,
): Promise<NewPlateInput[]> {
const cookie = await getUserMyminifactoryCookie(userId);
if (!cookie) {
throw new HttpError(
403,
paid
? `This is a paid MyMiniFactory model, so it downloads only from the account that bought it. ${MYMINIFACTORY_CONNECT}`
: `This MyMiniFactory model downloads only when signed in. ${MYMINIFACTORY_CONNECT}`,
);
}
const isModel = (name: string) =>
MULTI_FILE_PLATE_EXTS.has(path.extname(name).toLowerCase()) || path.extname(name).toLowerCase() === ".zip";
const downloaded: NewPlateInput[] = [];
try {
const files = await listMyminifactoryLibraryFiles(objectId, cookie, isModel);
if (!files) {
throw new HttpError(
403,
paid
? "This paid MyMiniFactory model isn't in your library: buy it on MyMiniFactory first, with the account connected to Thingport."
: "MyMiniFactory doesn't let your account download this model.",
);
}
for (const file of files) {
await maybeSleep(MYMINIFACTORY_FILE_DELAY_MS);
const url = await resolveMyminifactoryLibraryDownload(file.path, cookie);
if (url) downloaded.push(...(await downloadMyminifactoryFile(url, file.name)));
}
if (!downloaded.length && files.length) {
throw new HttpError(
400,
"This model's files are in an archive Thingport can't open (such as RAR), and MyMiniFactory hasn't unpacked it.",
);
}
return downloaded;
} catch (err) {
await Promise.all(downloaded.map((input) => fs.rm(input.tempFilePath!, { force: true }).catch(() => undefined)));
if (err instanceof MyminifactorySessionExpiredError) {
await markMyminifactoryCookieRejected(userId);
throw new HttpError(400, err.message, MYMINIFACTORY_SESSION_CODE);
}
throw err;
}
}
/** Every model file is its own request; spaced out a little, as the site's own pages would be. */
const MYMINIFACTORY_FILE_DELAY_MS = 500;
/** Metadata from the API (the instance's key). Files from /download when anyone may have them: one file, or a zip
* whose model files each become a plate; else, paid and login-only ones, from the user's own library. */
async function importMyminifactoryObject(
userId: string,
source: { provider: string; externalId: string },
body: ImportRequestBody,
): Promise<{
print: Print;
plates: Plate[];
author: Author | null;
previewImages: PreviewImage[];
alreadyImported: boolean;
}> {
const apiKey = await getMyminifactoryApiKey();
if (!apiKey) throw new HttpError(503, MYMINIFACTORY_NOT_CONFIGURED);
let resolved;
let downloaded: NewPlateInput[];
try {
resolved = await resolveMyminifactoryObject(source.externalId, apiKey);
if (!resolved) throw new HttpError(404, "This MyMiniFactory object could not be found, or isn't public.");
// Paid models, and free ones whose files are archives, only download as the user, from their library.
const anonymous = resolved.paid || resolved.archives ? null : await resolveMyminifactoryDownload(source.externalId);
if (anonymous?.kind === "missing") {
throw new HttpError(404, "This MyMiniFactory object could not be found, or isn't public.");
}
downloaded =
anonymous?.kind === "file"
? await downloadMyminifactoryFile(anonymous.url)
: await downloadMyminifactoryFromLibrary(userId, source.externalId, resolved.paid);
} catch (err) {
if (err instanceof MyminifactoryRateLimitError) throw new HttpError(429, err.message);
// 400, not 401: see tryMakerworldCloudApi.
if (err instanceof MyminifactoryAuthError) throw new HttpError(400, err.message);
throw err;
}
const { meta, galleryImages } = resolved;
if (!downloaded.length)
throw new HttpError(400, "This MyMiniFactory object has no model files Thingport can import.");
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 || `myminifactory-${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);
}
}
}
}
type ImportResult = {
print: Print;
plates: Plate[];
@@ -1021,6 +1245,9 @@ async function importNewOrExisting(userId: string, url: string, body: ImportRequ
if (source?.provider === "printables") {
return importPrintablesModel(userId, source, body);
}
if (source?.provider === "myminifactory") {
return importMyminifactoryObject(userId, source, body);
}
const { tempPath, filename, mime, meta } = await downloadImportToTemp(url, body);
const author = await upsertAuthorFromImport(meta.author);
+557
View File
@@ -0,0 +1,557 @@
import { IMPORT_TIMEOUT_SECONDS } from "../config";
import { browserFetch } from "../utils/browserFetch";
import type { ImportedAuthorInfo, ImportedPageMetadata } from "./importResolvers";
import { htmlToMarkdown } from "./descriptionMarkdown";
import type { ProviderCollectionCard } from "./providerCollectionCard";
import {
extractJsonFromBrowserBody,
fetchViaFlaresolverr,
isCloudflareChallenge,
looksLikeCloudflareBlock,
} from "./flaresolverr";
import { getFlaresolverrUrl } from "./settingsService";
// The official API takes an API key as `?key=`. Its file download links are for OAuth users only,
// so files come from the site's own /download/{id} instead, which free objects allow anonymously.
// Like Thingiverse, it's behind a Cloudflare that checks the TLS handshake, hence browserFetch.
const MYMINIFACTORY_ORIGIN = "https://www.myminifactory.com";
const API_BASE = `${MYMINIFACTORY_ORIGIN}/api/v2`;
const API_TIMEOUT_MS = IMPORT_TIMEOUT_SECONDS * 1000;
const PROVIDER = "myminifactory";
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;
}
export class MyminifactoryAuthError extends Error {
constructor() {
super(
"The MyMiniFactory API key configured for this instance was rejected. Ask an admin to update it in " +
"Administration > Providers (a key can be created in your MyMiniFactory account settings).",
);
this.name = "MyminifactoryAuthError";
}
}
export class MyminifactoryRateLimitError extends Error {
constructor(flaresolverr?: "missing" | "failed") {
super(
"MyMiniFactory blocked this request (rate limit or Cloudflare challenge). This usually clears after a " +
"while -- wait, then try again." +
(flaresolverr === "missing"
? " Setting up FlareSolverr in Administration > Connections gets past it."
: flaresolverr === "failed"
? " FlareSolverr couldn't get past it either: check it in Administration > Connections."
: ""),
);
this.name = "MyminifactoryRateLimitError";
}
}
function parseUrl(url: string): URL | null {
try {
const parsed = new URL(url);
const host = parsed.hostname.toLowerCase();
return host === "myminifactory.com" || host === "www.myminifactory.com" ? parsed : null;
} catch {
return null;
}
}
/** `/object/3d-print-<slug>-<id>`, optionally behind a language prefix (`/it/object/...`). */
export function parseMyminifactoryObjectUrl(url: string): { objectId: string } | null {
const parsed = parseUrl(url);
const m = parsed?.pathname.match(/^\/(?:[a-z]{2}\/)?object\/(?:[^/]*-)?(\d+)\/?$/i);
return m ? { objectId: m[1] } : null;
}
/** `/users/<username>/collection/<slug>`. A collection is only addressable by its owner and slug. */
export function parseMyminifactoryCollectionUrl(url: string): { username: string; slug: string } | null {
const parsed = parseUrl(url);
const m = parsed?.pathname.match(/^\/(?:[a-z]{2}\/)?users\/([^/]+)\/collection\/([^/]+)\/?$/i);
if (!m) return null;
return { username: decodeURIComponent(m[1]), slug: decodeURIComponent(m[2]).toLowerCase() };
}
/** The id a sync keeps for a collection: usernames aren't case-sensitive, so one collection syncs once. */
export function myminifactoryCollectionKey(username: string, slug: string): string {
return `${username.toLowerCase()}/${slug.toLowerCase()}`;
}
export function myminifactoryCollectionUrl(username: string, slug: string): string {
return `${MYMINIFACTORY_ORIGIN}/users/${encodeURIComponent(username)}/collection/${encodeURIComponent(slug)}`;
}
export function myminifactoryObjectUrl(objectId: string): string {
return `${MYMINIFACTORY_ORIGIN}/object/3d-print-${objectId}`;
}
async function fetchApi(path: string, apiKey: string): Promise<{ ok: boolean; status: number; data: unknown }> {
const url = `${API_BASE}${path}${path.includes("?") ? "&" : "?"}key=${encodeURIComponent(apiKey)}`;
let res = await browserFetch(url, { headers: { Accept: "application/json" }, timeoutMs: API_TIMEOUT_MS });
let viaBrowser = false;
if ((res.status === 429 || res.status === 403) && looksLikeCloudflareBlock(res.headers)) {
const solved = await fetchViaFlaresolverr(url);
if (solved) {
viaBrowser = true;
res = new Response(solved.body, { status: solved.status });
}
}
const text = await res.text();
let data: unknown = null;
if (text.trim()) {
try {
data = viaBrowser ? extractJsonFromBrowserBody(text) : JSON.parse(text);
} catch {
data = null;
}
}
if (!viaBrowser && (res.status === 429 || isCloudflareChallenge(res.headers))) {
throw new MyminifactoryRateLimitError((await getFlaresolverrUrl()) ? "failed" : "missing");
}
if (res.status === 429) throw new MyminifactoryRateLimitError();
if (res.status === 401) throw new MyminifactoryAuthError();
return { ok: res.ok, status: res.status, data };
}
/** Tells a rejected key from Cloudflare's block and an unreachable API. */
export async function checkMyminifactoryApiKey(apiKey: string): Promise<"ok" | "rejected" | "blocked" | "unreachable"> {
try {
const { ok, data } = await fetchApi("/search?q=benchy&per_page=1", apiKey);
return ok && isRecord(data) ? "ok" : "unreachable";
} catch (err) {
if (err instanceof MyminifactoryAuthError) return "rejected";
if (err instanceof MyminifactoryRateLimitError) return "blocked";
return "unreachable";
}
}
type ApiImage = { url: string; primary: boolean };
function objectImages(object: Record<string, unknown>): ApiImage[] {
if (!Array.isArray(object.images)) return [];
return object.images.flatMap((image) => {
if (!isRecord(image)) return [];
const pick = (key: string) => (isRecord(image[key]) ? str((image[key] as Record<string, unknown>).url) : null);
const url = pick("original") ?? pick("standard") ?? pick("thumbnail");
return url ? [{ url, primary: image.is_primary === true }] : [];
});
}
/** The image a listing shows for an object: its primary one's thumbnail. */
function objectThumbnail(object: Record<string, unknown>): string | null {
if (!Array.isArray(object.images)) return null;
const images = object.images.filter(isRecord);
const image = images.find((i) => i.is_primary === true) ?? images[0];
if (!image) return null;
for (const key of ["thumbnail", "standard", "original"]) {
const url = isRecord(image[key]) ? str((image[key] as Record<string, unknown>).url) : null;
if (url) return url;
}
return null;
}
/** Every category the object sits in, with their parents, so a category configured for a parent matches. */
function objectCategoryIds(object: Record<string, unknown>): number[] {
// A `{ total_count, items }` page, though the spec says a plain list.
const categories = isRecord(object.categories) ? object.categories.items : object.categories;
if (!Array.isArray(categories)) return [];
const ids: number[] = [];
const add = (value: unknown) => {
const id =
typeof value === "number" ? value : typeof value === "string" && /^\d+$/.test(value) ? Number(value) : NaN;
if (Number.isInteger(id) && !ids.includes(id)) ids.push(id);
};
for (const category of categories) {
if (!isRecord(category)) continue;
add(category.id);
if (isRecord(category.parent)) add(category.parent.id);
}
return ids;
}
function designerAuthor(designer: Record<string, unknown>): ImportedAuthorInfo | null {
// Keyed by the numeric id, which stays put when the designer renames their account.
const username = str(designer.username);
const id = designer.id != null ? String(designer.id) : null;
if (!username || !id) return null;
const profileUrl = str(designer.profile_url) ?? `${MYMINIFACTORY_ORIGIN}/users/${encodeURIComponent(username)}`;
return {
provider: PROVIDER,
externalId: id,
name: str(designer.name) ?? username,
handle: username,
bio: str(designer.bio),
bioTranslated: null,
links: [profileUrl, ...(str(designer.website) ? [str(designer.website)!] : [])],
avatarUrl: str(designer.avatar_url),
backgroundUrl: str(designer.cover_url),
};
}
/** The object's description, with the designer's printing details (when given) appended. */
function objectDescription(object: Record<string, unknown>): string | null {
const description = str(object.description_html)
? htmlToMarkdown(object.description_html as string)
: str(object.description);
const details = str(object.printing_details_html)
? htmlToMarkdown(object.printing_details_html as string)
: str(object.printing_details);
if (!details) return description;
return [description, `## Printing details\n\n${details}`].filter(Boolean).join("\n\n");
}
export type MyminifactoryObjectResolution = {
meta: Partial<ImportedPageMetadata>;
galleryImages: { url: string; filename: string }[];
/** On sale in the store: downloading it needs a login and a purchase, so it can't be imported. */
paid: boolean;
/** Its files are archives the designer uploaded, which only download with a login, free or not. */
archives: boolean;
};
/** Store objects carry a price; free ones have none. */
function isPaid(object: Record<string, unknown>): boolean {
return isRecord(object.price) && Number(object.price.value) > 0;
}
/** The metadata mapping, apart from the fetch so it can be tested on its own. */
export function myminifactoryMetaFromObject(object: Record<string, unknown>): MyminifactoryObjectResolution {
const meta: Partial<ImportedPageMetadata> = {};
const title = str(object.name);
if (title) meta.title = title;
const description = objectDescription(object);
if (description) meta.description = description;
if (Array.isArray(object.tags)) {
const tags = object.tags.flatMap((tag) => {
const name = typeof tag === "string" ? tag.trim() : isRecord(tag) ? str(tag.name) : null;
return name ? [name] : [];
});
if (tags.length) meta.tags = tags;
}
const images = objectImages(object);
const cover = images.find((image) => image.primary) ?? images[0];
if (cover) meta.previewImageUrl = cover.url;
if (isRecord(object.designer)) {
const author = designerAuthor(object.designer);
if (author) {
meta.author = author;
meta.creator = author.name;
}
}
const categoryIds = objectCategoryIds(object);
if (categoryIds.length) {
meta.siteCategoryIds = categoryIds;
meta.categorySite = PROVIDER;
}
const galleryImages = images.map((image, index) => ({
url: image.url,
filename: image.url.split("/").pop()?.split("?")[0] || `image-${index}.jpg`,
}));
return { meta, galleryImages, paid: isPaid(object), archives: object.file_mode === 1 };
}
/** Null for an object that doesn't exist or isn't public; throws MyminifactoryAuthError for a rejected key. */
export async function resolveMyminifactoryObject(
objectId: string,
apiKey: string,
): Promise<MyminifactoryObjectResolution | null> {
const { ok, data } = await fetchApi(`/objects/${encodeURIComponent(objectId)}`, apiKey);
if (!ok || !isRecord(data)) return null;
return myminifactoryMetaFromObject(data);
}
export type MyminifactoryDownload = { kind: "file"; url: string } | { kind: "login" } | { kind: "missing" };
/** Where /download/{id} sends an anonymous visitor: a signed link to the file (or a zip of all of
* them), or the login page for a paid or members-only object. */
export async function resolveMyminifactoryDownload(objectId: string): Promise<MyminifactoryDownload> {
const res = await browserFetch(`${MYMINIFACTORY_ORIGIN}/download/${encodeURIComponent(objectId)}`, {
redirect: "manual",
timeoutMs: API_TIMEOUT_MS,
});
await res.body?.cancel().catch(() => undefined);
if (res.status === 429 || isCloudflareChallenge(res.headers)) throw new MyminifactoryRateLimitError();
const location = res.headers.get("location");
if (res.status >= 300 && res.status < 400 && location) {
const target = new URL(location, MYMINIFACTORY_ORIGIN);
if (isMyminifactoryFileHost(target.hostname)) return { kind: "file", url: target.toString() };
// The login page, or, for a free model whose files need a login, its own page.
if (/\/(login|object)\b/i.test(target.pathname)) return { kind: "login" };
}
return { kind: "missing" };
}
export class MyminifactorySessionExpiredError extends Error {
constructor() {
super(
"MyMiniFactory didn't accept your saved sign-in; it may have expired. Visit myminifactory.com with Thingport Grab, " +
"or connect it again in Configuration > Providers.",
);
this.name = "MyminifactorySessionExpiredError";
}
}
/** A request the site's own pages make with the user's session. A redirect to the login page means it's no longer
* accepted. */
async function sessionFetch(path: string, cookie: string): Promise<Response> {
const res = await browserFetch(`${MYMINIFACTORY_ORIGIN}${path}`, {
headers: { Cookie: cookie, Accept: "application/json", "X-Requested-With": "XMLHttpRequest" },
redirect: "manual",
timeoutMs: API_TIMEOUT_MS,
});
if (res.status === 429 || isCloudflareChallenge(res.headers)) {
await res.body?.cancel().catch(() => undefined);
throw new MyminifactoryRateLimitError();
}
const location = res.headers.get("location");
if (location && /\/login\b/i.test(new URL(location, MYMINIFACTORY_ORIGIN).pathname)) {
await res.body?.cancel().catch(() => undefined);
throw new MyminifactorySessionExpiredError();
}
return res;
}
/** Whether MyMiniFactory accepts a session: its library answers only to a signed-in user. */
export async function checkMyminifactorySession(cookie: string): Promise<"ok" | "rejected" | "unreachable"> {
try {
const res = await sessionFetch("/api/data-library/collections", cookie);
await res.body?.cancel().catch(() => undefined);
return res.ok ? "ok" : "unreachable";
} catch (err) {
return err instanceof MyminifactorySessionExpiredError ? "rejected" : "unreachable";
}
}
/** A file in the user's library, and where its download starts. */
export type MyminifactoryLibraryFile = { name: string; path: string };
/** What's in an archive's MyMiniFactory unpacked, or the archive itself when it wasn't. */
type LibraryArchive = { id: string | null; name: string; files: { id: string; path: string }[] };
function libraryArchive(value: unknown): LibraryArchive | null {
if (!isRecord(value) || !str(value.name)) return null;
const files = Array.isArray(value.extractedFiles)
? value.extractedFiles.flatMap((file) =>
isRecord(file) && file.id != null && str(file.path) ? [{ id: String(file.id), path: str(file.path)! }] : [],
)
: [];
return { id: value.id != null ? String(value.id) : null, name: str(value.name)!, files };
}
/** The model files of an object the user can download (bought, or free with a login), as their library lists them:
* its single files, else the files MyMiniFactory unpacked from its archives, else the archives themselves. `null`
* when the library won't give them (not bought). Throws MyminifactorySessionExpiredError for a session it no longer
* takes. `isModelFile` decides what's worth downloading. */
export async function listMyminifactoryLibraryFiles(
objectId: string,
cookie: string,
isModelFile: (name: string) => boolean,
): Promise<MyminifactoryLibraryFile[] | null> {
const res = await sessionFetch(
`/api/data-library/myObjects/object-${encodeURIComponent(objectId)}/downloadables`,
cookie,
);
if (res.status === 401 || res.status === 403 || res.status === 404) {
await res.body?.cancel().catch(() => undefined);
return null;
}
if (!res.ok) throw new Error(`Couldn't read this model from your MyMiniFactory library (HTTP ${res.status})`);
const data: unknown = await res.json().catch(() => null);
if (!isRecord(data)) throw new Error("Couldn't read this model from your MyMiniFactory library");
const id = encodeURIComponent(objectId);
const parts = Array.isArray(data.parts)
? data.parts.flatMap((part) => {
const name = isRecord(part) ? str(part.name) : null;
return name && isModelFile(name)
? [{ name, path: `/download/${id}?downloadfile=${encodeURIComponent(name)}` }]
: [];
})
: [];
if (parts.length) return parts;
const archives = Array.isArray(data.archives) ? data.archives.flatMap((a) => libraryArchive(a) ?? []) : [];
const unpacked = archives.flatMap((archive) =>
archive.files.flatMap((file) => {
const name = file.path.split("/").pop()!;
return isModelFile(name)
? [{ name, path: `/api/data-library/archive-file-contents/${encodeURIComponent(file.id)}/download` }]
: [];
}),
);
if (unpacked.length) return unpacked;
// Not unpacked by MyMiniFactory: the archive itself, for the caller to open if it can.
return archives.flatMap((archive) =>
archive.id ? [{ name: archive.name, path: `/download/${id}?archive_id=${encodeURIComponent(archive.id)}` }] : [],
);
}
/** The signed link a library download redirects to. */
export async function resolveMyminifactoryLibraryDownload(path: string, cookie: string): Promise<string | null> {
const res = await sessionFetch(path, cookie);
await res.body?.cancel().catch(() => undefined);
const location = res.headers.get("location");
if (!location) return null;
const target = new URL(location, MYMINIFACTORY_ORIGIN);
return isMyminifactoryFileHost(target.hostname) ? target.toString() : null;
}
/** The signed file hosts /download redirects to (dl4.myminifactory.com and its siblings). */
export function isMyminifactoryFileHost(hostname: string): boolean {
return /^dl\d*\.myminifactory\.com$/i.test(hostname);
}
export type MyminifactoryObjectSummary = { objectId: string; title: string; cover: string | null };
/** Paid objects are left out of listings: they can't be imported, and a sync would fail on them every round. */
const PAGE_SIZE = 30;
const LISTING_MAX_ENTRIES = 600;
function objectSummary(item: unknown): MyminifactoryObjectSummary | null {
if (!isRecord(item) || item.id == null || isPaid(item)) return null;
const objectId = String(item.id);
return { objectId, title: str(item.name) ?? `Object ${objectId}`, cover: objectThumbnail(item) };
}
/** Null when there's no such collection. Throws on any other failure, so an outage is never taken
* for a deleted collection. */
async function fetchCollectionPage(
username: string,
slug: string,
apiKey: string,
page: number,
perPage: number,
): Promise<Record<string, unknown> | null> {
const { ok, status, data } = await fetchApi(
`/users/${encodeURIComponent(username)}/collections/${encodeURIComponent(slug)}?page=${page}&per_page=${perPage}`,
apiKey,
);
if (status === 404) return null;
if (!ok || !isRecord(data)) throw new Error(`Couldn't read the MyMiniFactory collection (HTTP ${status})`);
return data;
}
function collectionObjects(collection: Record<string, unknown>): { items: unknown[]; total: number | null } {
const objects = isRecord(collection.objects) ? collection.objects : null;
const items = Array.isArray(objects?.items) ? objects.items : [];
const total = typeof objects?.total_count === "number" ? objects.total_count : null;
return { items, total };
}
export async function fetchMyminifactoryCollectionObjects(
username: string,
slug: string,
apiKey: string,
maxItems: number = LISTING_MAX_ENTRIES,
): Promise<{ title: string | null; entries: MyminifactoryObjectSummary[]; truncated: boolean; paid: number } | null> {
const entries: MyminifactoryObjectSummary[] = [];
const seen = new Set<string>();
let title: string | null = null;
let paid = 0;
for (let page = 1; entries.length < maxItems; page++) {
const collection = await fetchCollectionPage(username, slug, apiKey, page, PAGE_SIZE);
if (!collection) return page === 1 ? null : { title, entries, truncated: false, paid };
title ??= str(collection.name);
const { items, total } = collectionObjects(collection);
for (const item of items) {
if (isRecord(item) && isPaid(item)) paid++;
const summary = objectSummary(item);
if (!summary || seen.has(summary.objectId)) continue;
seen.add(summary.objectId);
entries.push(summary);
if (entries.length >= maxItems) break;
}
if (items.length < PAGE_SIZE || (total !== null && page * PAGE_SIZE >= total)) break;
}
return { title, entries, truncated: entries.length >= maxItems, paid };
}
/** For a sync: null when MyMiniFactory answers 404 for the collection. */
export async function checkMyminifactoryCollection(
username: string,
slug: string,
apiKey: string,
): Promise<{ title: string | null } | null> {
const collection = await fetchCollectionPage(username, slug, apiKey, 1, 1);
return collection ? { title: str(collection.name) } : null;
}
const CARD_THUMBNAILS = 4;
/** From a collection as either the listing or the collection itself gives it: both carry `obj_img`, a few of its
* models' images, and `total_objects`. */
function collectionCard(collection: Record<string, unknown>, fallbackOwner: string): ProviderCollectionCard | null {
const slug = str(collection.slug);
if (!slug) return null;
const owner = (isRecord(collection.owner) && str(collection.owner.username)) || fallbackOwner;
const { items, total } = collectionObjects(collection);
const images = Array.isArray(collection.obj_img)
? collection.obj_img.flatMap((url) => (str(url) ? [str(url)!] : []))
: [];
const thumbnails = items.flatMap((item) => {
const url = isRecord(item) ? objectThumbnail(item) : null;
return url ? [url] : [];
});
const count = typeof collection.total_objects === "number" ? collection.total_objects : total;
return {
id: myminifactoryCollectionKey(owner, slug),
name: str(collection.name) ?? slug,
url: myminifactoryCollectionUrl(owner, slug),
modelCount: count ?? items.length,
thumbnails: [...new Set([...images, ...thumbnails])].slice(0, CARD_THUMBNAILS),
private: collection.public === false || collection.is_public === false,
owner,
};
}
export async function fetchMyminifactoryCollectionCard(
username: string,
slug: string,
apiKey: string,
): Promise<ProviderCollectionCard | null> {
const collection = await fetchCollectionPage(username, slug, apiKey, 1, CARD_THUMBNAILS);
return collection ? collectionCard(collection, username) : null;
}
const COLLECTION_LIST_MAX_PAGES = 10;
/** The user's public collections. */
export async function listMyminifactoryUserCollections(
username: string,
apiKey: string,
): Promise<ProviderCollectionCard[]> {
const cards: ProviderCollectionCard[] = [];
for (let page = 1; page <= COLLECTION_LIST_MAX_PAGES; page++) {
const { ok, status, data } = await fetchApi(
`/users/${encodeURIComponent(username)}/collections?page=${page}&per_page=${PAGE_SIZE}`,
apiKey,
);
if (!ok || !isRecord(data)) throw new Error(`Couldn't load the MyMiniFactory collections (HTTP ${status})`);
const items = Array.isArray(data.items) ? data.items.filter(isRecord) : [];
for (const item of items) {
const card = collectionCard(item, username);
if (card) cards.push(card);
}
if (items.length < PAGE_SIZE) break;
}
return cards;
}
/** A profile link or a username. Null when MyMiniFactory knows no such user. */
export async function resolveMyminifactoryUser(input: string, apiKey: string): Promise<{ username: string } | null> {
let value = input.trim();
const parsed = parseUrl(value);
if (parsed) value = parsed.pathname.match(/^\/(?:[a-z]{2}\/)?users\/([^/]+)/i)?.[1] ?? "";
else if (/^https?:\/\//i.test(value)) return null;
// Usernames can have spaces ("Scan The World").
value = decodeURIComponent(value).replace(/^@/, "").trim();
if (!value || !/^[\w. -]+$/.test(value)) return null;
const { ok, status, data } = await fetchApi(`/users/${encodeURIComponent(value)}`, apiKey);
if (status === 404) return null;
if (!ok || !isRecord(data)) throw new Error(`Couldn't read the MyMiniFactory user (HTTP ${status})`);
return { username: str(data.username) ?? value };
}
@@ -0,0 +1,57 @@
import { prisma } from "../db";
// Per-user, beside the instance-wide API key: the user's MyMiniFactory session downloads the paid and login-only
// models in their library, which the API key can't.
const SESSION_COOKIE = "PHPSESSID";
/** "PHPSESSID=<value>" from a whole Cookie header, a "PHPSESSID=..." pair or a bare value; null when there's none.
* The other cookies a browser sends are Cloudflare's and tied to that browser, so only this one is kept. */
export function normalizeMyminifactoryCookie(raw: string | null | undefined): string | null {
const text = (raw ?? "").replace(/^\s*cookie:\s*/i, "").trim();
if (!text) return null;
const pair = text
.split(";")
.map((part) => part.trim())
.find((part) => part.toUpperCase().startsWith(`${SESSION_COOKIE}=`));
const value = pair ? pair.slice(SESSION_COOKIE.length + 1).trim() : text.includes("=") ? "" : text;
return /^[\w,-]+$/.test(value) ? `${SESSION_COOKIE}=${value}` : null;
}
export async function getUserMyminifactoryCookie(userId: string): Promise<string | null> {
const user = await prisma.user.findUnique({ where: { id: userId }, select: { myminifactoryCookie: true } });
return user?.myminifactoryCookie ?? null;
}
/** `verified`: MyMiniFactory just accepted it, so an earlier rejection no longer stands. A different cookie clears
* it too. */
export async function setUserMyminifactoryCookie(
userId: string,
cookie: string | null,
{ verified = false } = {},
): Promise<boolean> {
const current = await getUserMyminifactoryCookie(userId);
await prisma.user.update({
where: { id: userId },
data: {
myminifactoryCookie: cookie,
...(verified || cookie !== current ? { myminifactoryRejectedAt: null } : {}),
},
});
return Boolean(cookie);
}
export async function markMyminifactoryCookieRejected(userId: string): Promise<void> {
await prisma.user.update({ where: { id: userId }, data: { myminifactoryRejectedAt: new Date() } });
}
export type MyminifactoryConnection = "connected" | "disconnected" | "expired";
export async function getMyminifactoryConnection(userId: string): Promise<MyminifactoryConnection> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { myminifactoryCookie: true, myminifactoryRejectedAt: true },
});
if (!user?.myminifactoryCookie) return "disconnected";
return user.myminifactoryRejectedAt ? "expired" : "connected";
}
@@ -24,7 +24,14 @@ import {
pushedPrintablesModels,
writePrintablesListing,
} from "./providerListingsService";
import { getThingiverseAccessToken } from "./settingsService";
import { getMyminifactoryApiKey, getThingiverseAccessToken } from "./settingsService";
import {
fetchMyminifactoryCollectionCard,
listMyminifactoryUserCollections,
MyminifactoryAuthError,
MyminifactoryRateLimitError,
resolveMyminifactoryUser,
} from "./myminifactoryApi";
import {
fetchThingiverseCollectionCard,
fetchThingiverseLikesSummary,
@@ -35,7 +42,7 @@ import {
} from "./thingiverseApi";
import { assertProviderEnabled } from "./providerSettingsService";
export const BROWSABLE_PROVIDERS = ["makerworld", "printables", "thingiverse"] as const;
export const BROWSABLE_PROVIDERS = ["makerworld", "printables", "thingiverse", "myminifactory"] 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
@@ -58,17 +65,24 @@ export type ProviderCollectionOut = {
};
export async function getProviderAccounts(userId: string): Promise<ProviderAccountsOut> {
const [user, thingiverseToken] = await Promise.all([
const [user, thingiverseToken, myminifactoryKey] = await Promise.all([
prisma.user.findUnique({
where: { id: userId },
select: { makerworldCookie: true, printablesHandle: true, thingiverseUsername: true },
select: {
makerworldCookie: true,
printablesHandle: true,
thingiverseUsername: true,
myminifactoryUsername: true,
},
}),
getThingiverseAccessToken(),
getMyminifactoryApiKey(),
]);
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 },
};
}
@@ -78,6 +92,12 @@ async function requireThingiverseToken(): Promise<string> {
return token;
}
async function requireMyminifactoryKey(): Promise<string> {
const key = await getMyminifactoryApiKey();
if (!key) throw new HttpError(409, "MyMiniFactory isn't connected on this Thingport yet");
return key;
}
/** `input` is a username, handle or profile link; null forgets it. */
export async function setProviderAccount(
userId: string,
@@ -100,6 +120,11 @@ export async function setProviderAccount(
const user = token ? await providerCall(() => resolveThingiverseUser(value!, token)) : null;
if (value && !user) throw new HttpError(404, `Thingiverse has no user "${value}"`);
await prisma.user.update({ where: { id: userId }, data: { thingiverseUsername: user?.username ?? null } });
} else if (provider === "myminifactory") {
const key = value ? await requireMyminifactoryKey() : null;
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 {
throw new HttpError(400, "MakerWorld is linked through its cookie in Configuration > Providers");
}
@@ -133,6 +158,8 @@ async function providerCall<T>(call: () => Promise<T>): Promise<T> {
}
if (err instanceof ThingiverseRateLimitError) throw new HttpError(429, err.message);
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);
throw err;
}
}
@@ -170,7 +197,7 @@ export async function listProviderCollections(
await assertProviderEnabled(provider);
const user = await prisma.user.findUnique({
where: { id: userId },
select: { makerworldCookie: true, printablesUserId: true, thingiverseUsername: true },
select: { makerworldCookie: true, printablesUserId: true, thingiverseUsername: true, myminifactoryUsername: true },
});
let cards: ProviderCollectionCard[];
if (provider === "makerworld") {
@@ -187,6 +214,11 @@ 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 === "myminifactory") {
const key = await requireMyminifactoryKey();
if (!user?.myminifactoryUsername) throw new HttpError(409, "Tell Thingport your MyMiniFactory username first");
const username = user.myminifactoryUsername;
cards = await providerCall(() => listMyminifactoryUserCollections(username, key));
} else {
const token = await requireThingiverseToken();
if (!user?.thingiverseUsername) throw new HttpError(409, "Tell Thingport your Thingiverse username first");
@@ -206,7 +238,8 @@ export async function listProviderCollections(
/** A pasted collection link, as the same card a listing shows. */
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 or Thingiverse collection link");
if (!source)
throw new HttpError(400, "That isn't a MakerWorld, Printables, Thingiverse or MyMiniFactory collection link");
await assertProviderEnabled(source.provider);
let card: ProviderCollectionCard | null;
if (source.likesOf && source.provider === "thingiverse") {
@@ -230,6 +263,10 @@ 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 === "myminifactory") {
const key = await requireMyminifactoryKey();
const [username, slug] = source.externalId.split("/");
card = await providerCall(() => fetchMyminifactoryCollectionCard(username, slug, key));
} else {
const token = await requireThingiverseToken();
card = await providerCall(() => fetchThingiverseCollectionCard(source.externalId, token));
@@ -253,7 +290,8 @@ export async function enableSyncFromApp(
): Promise<EnableFromAppResult> {
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 or Thingiverse collection link");
if (!source)
throw new HttpError(400, "That isn't a MakerWorld, Printables, Thingiverse or MyMiniFactory collection link");
await assertProviderEnabled(source.provider);
if (input.profileScope && source.provider !== "makerworld") {
throw new HttpError(400, "Only MakerWorld models have print profiles to pick");
@@ -6,13 +6,14 @@ 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"] as const;
export const PROVIDERS = ["makerworld", "printables", "thingiverse", "myminifactory"] as const;
export type Provider = (typeof PROVIDERS)[number];
const PROVIDER_NAMES: Record<Provider, string> = {
makerworld: "MakerWorld",
printables: "Printables",
thingiverse: "Thingiverse",
myminifactory: "MyMiniFactory",
};
const SETTING_KEY = "disabled_providers";
+22
View File
@@ -107,6 +107,28 @@ export async function setThingiverseAccessToken(value: string | null): Promise<v
});
}
const MYMINIFACTORY_API_KEY_KEY = "myminifactory_api_key";
// Instance-wide, like the Thingiverse token: the key belongs to whichever account created it.
export async function getMyminifactoryApiKey(): Promise<string | null> {
const row = await prisma.setting.findUnique({ where: { key: MYMINIFACTORY_API_KEY_KEY } });
const value = row?.value;
return typeof value === "string" && value.trim() ? value.trim() : null;
}
export async function setMyminifactoryApiKey(value: string | null): Promise<void> {
const trimmed = (value ?? "").trim();
if (!trimmed) {
await prisma.setting.deleteMany({ where: { key: MYMINIFACTORY_API_KEY_KEY } });
return;
}
await prisma.setting.upsert({
where: { key: MYMINIFACTORY_API_KEY_KEY },
create: { key: MYMINIFACTORY_API_KEY_KEY, value: trimmed },
update: { value: trimmed },
});
}
export type SmtpSettings = {
host: string | null;
port: number;
+22 -1
View File
@@ -15,8 +15,14 @@ import { clientLastSeen } from "./clientActivityService";
import { listAiProviderModels } from "./aiCategorizationService";
import { resolveAiConnection } from "./aiConnectionService";
import { testFlaresolverr } from "./flaresolverr";
import { getFlaresolverrUrl, getSmtpSettings, getThingiverseAccessToken } from "./settingsService";
import {
getFlaresolverrUrl,
getMyminifactoryApiKey,
getSmtpSettings,
getThingiverseAccessToken,
} from "./settingsService";
import { checkThingiverseAccessToken } from "./thingiverseApi";
import { checkMyminifactoryApiKey } from "./myminifactoryApi";
import { isProviderEnabled } from "./providerSettingsService";
// Administration's System Health: each component Thingport relies on, tested only when an admin asks. Results are
@@ -30,6 +36,7 @@ export const HEALTH_COMPONENTS = [
"internet",
"flaresolverr",
"thingiverse",
"myminifactory",
"grab",
"ai",
"smtp",
@@ -194,6 +201,19 @@ async function checkThingiverse(): Promise<Found> {
return found("error", "unreachable");
}
async function checkMyminifactory(): Promise<Found> {
if (!(await isProviderEnabled("myminifactory"))) return found("not_configured", "disabled");
const apiKey = await getMyminifactoryApiKey();
if (!apiKey) return found("not_configured", "not_configured");
const start = Date.now();
const result = await checkMyminifactoryApiKey(apiKey);
const latency_ms = Date.now() - start;
if (result === "ok") return { ...found("ok", "ok"), latency_ms };
if (result === "rejected") return found("error", "token_rejected");
if (result === "blocked") return found("warning", "blocked", { flaresolverr: (await getFlaresolverrUrl()) ? 1 : 0 });
return found("error", "unreachable");
}
async function checkAi(): Promise<Found> {
const connection = await resolveAiConnection(null);
if (!connection) return found("not_configured", "not_configured");
@@ -251,6 +271,7 @@ const CHECKS: Record<HealthComponent, () => Promise<Found>> = {
internet: checkInternet,
flaresolverr: checkFlaresolverr,
thingiverse: checkThingiverse,
myminifactory: checkMyminifactory,
grab: checkGrab,
ai: checkAi,
smtp: checkSmtp,
+3 -3
View File
@@ -1,7 +1,7 @@
import { Impit } from "impit";
// Thingiverse's Cloudflare challenges clients by their TLS handshake, whatever the headers or
// cookies, and Node's fetch is on its list. impit sends Chrome's handshake and headers.
// Thingiverse's and MyMiniFactory's Cloudflare challenge clients by their TLS handshake, whatever the
// headers or cookies, and Node's fetch is on its list. impit sends Chrome's handshake and headers.
const following = new Impit({ browser: "chrome", timeout: null });
const manual = new Impit({ browser: "chrome", timeout: null, followRedirects: false });
@@ -11,7 +11,7 @@ const NULL_BODY_STATUSES = new Set([204, 205, 304]);
export function needsBrowserFetch(url: string): boolean {
try {
const host = new URL(url).hostname.toLowerCase();
return host === "thingiverse.com" || host.endsWith(".thingiverse.com");
return ["thingiverse.com", "myminifactory.com"].some((domain) => host === domain || host.endsWith(`.${domain}`));
} catch {
return false;
}
+21
View File
@@ -2,6 +2,7 @@ import { beforeAll, describe, expect, it } from "vitest";
import request from "supertest";
import { createApp } from "../src/app";
import { prisma } from "../src/db";
import { resolveCategoryIdByCategory } from "../src/services/importService";
// Free-text "800;71;1001" input for the *_cat_ids fields.
@@ -115,3 +116,23 @@ describe("PATCH /category/:id/meta -- multi-value category ids", () => {
expect(res.body.printables_cat_ids).toBe("5;6");
});
});
describe("resolveCategoryIdByCategory -- the most specific site category wins", () => {
it("files a model under the category its own site category maps to, not the one its parent's does", async () => {
const email = `category-specificity-${Date.now()}@example.com`;
const res = await request(app)
.post("/api/register")
.send({ displayName: "Specificity Test", email, password: "password123" });
expect(res.status).toBe(200);
const userId: string = res.body.user.id;
const named = async (name: string) =>
(await prisma.category.findFirstOrThrow({ where: { userId, name, kind: "category" } })).id;
// MyMiniFactory's Phones & Tablets (94) maps to Gadgets; its parent, Gadgets & Electronics (59), to Electronics.
expect(await resolveCategoryIdByCategory(userId, "myminifactory", [94, 59])).toBe(await named("Gadgets"));
expect(await resolveCategoryIdByCategory(userId, "myminifactory", [59, 94])).toBe(await named("Electronics"));
// Vases (362) under Home Decor (335), which the starter Decor category both lists.
expect(await resolveCategoryIdByCategory(userId, "myminifactory", [362, 335])).toBe(await named("Decor"));
expect(await resolveCategoryIdByCategory(userId, "myminifactory", [358])).toBeNull();
});
});
+216
View File
@@ -0,0 +1,216 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
fetchMyminifactoryCollectionObjects,
myminifactoryMetaFromObject,
parseMyminifactoryCollectionUrl,
parseMyminifactoryObjectUrl,
resolveMyminifactoryDownload,
} from "../src/services/myminifactoryApi";
import { modelIdFromUrl, modelUrlFor, parseSyncSource } from "../src/services/collectionSyncService";
import { normalizeMyminifactoryCookie } from "../src/services/myminifactorySessionService";
import { buildImportSourceUrl, identifySourceModel } from "../src/services/importService";
// The MyMiniFactory client against the official API v2's documented shapes, with fetch mocked. No database.
const API_KEY = "test-api-key";
function jsonResponse(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
}
function redirectResponse(location: string): Response {
return new Response(null, { status: 302, headers: { location } });
}
function image(name: string, primary = false) {
const url = (size: string) => `https://assets.myminifactory.com/object-images/abc/images/${size}-${name}.jpg`;
return {
id: name,
is_primary: primary,
original: { url: url("original") },
standard: { url: url("standard") },
thumbnail: { url: url("thumb") },
};
}
describe("MyMiniFactory URLs", () => {
it("recognizes object pages, with a slug, without one, and behind a language prefix", () => {
expect(
parseMyminifactoryObjectUrl("https://www.myminifactory.com/object/3d-print-diana-at-the-louvre-16357"),
).toEqual({ objectId: "16357" });
expect(parseMyminifactoryObjectUrl("https://www.myminifactory.com/object/3d-print-16357")).toEqual({
objectId: "16357",
});
expect(parseMyminifactoryObjectUrl("https://myminifactory.com/it/object/3d-print-skull-100000/")).toEqual({
objectId: "100000",
});
});
it("rejects users, collections and other hosts as objects", () => {
expect(parseMyminifactoryObjectUrl("https://www.myminifactory.com/users/schmick101")).toBeNull();
expect(parseMyminifactoryObjectUrl("https://www.myminifactory.com/users/a/collection/b")).toBeNull();
expect(parseMyminifactoryObjectUrl("https://example.com/object/3d-print-16357")).toBeNull();
});
it("reads a collection's owner and slug", () => {
expect(
parseMyminifactoryCollectionUrl("https://www.myminifactory.com/users/schmick101/collection/steampunk"),
).toEqual({ username: "schmick101", slug: "steampunk" });
expect(parseMyminifactoryCollectionUrl("https://www.myminifactory.com/users/schmick101")).toBeNull();
});
it("round-trips a model id through the import and sync links", () => {
const url = buildImportSourceUrl("myminifactory", "16357")!;
expect(identifySourceModel(url)).toEqual({ provider: "myminifactory", externalId: "16357" });
expect(modelIdFromUrl("myminifactory", modelUrlFor("myminifactory", "16357"))).toBe("16357");
});
it("syncs one collection once, however the owner's name is typed", () => {
const a = parseSyncSource("https://www.myminifactory.com/users/Schmick101/collection/steampunk");
const b = parseSyncSource("https://www.myminifactory.com/it/users/schmick101/collection/steampunk");
expect(a).toMatchObject({ provider: "myminifactory", externalId: "schmick101/steampunk" });
expect(b?.externalId).toBe(a?.externalId);
});
});
describe("myminifactoryMetaFromObject", () => {
it("maps an object's details, designer, images and categories", () => {
const { meta, galleryImages } = myminifactoryMetaFromObject({
id: 16357,
name: "Diana at the Louvre",
description_html: "<p>A <strong>marble</strong> statue.</p>",
printing_details_html: "<p>Print with supports.</p>",
tags: ["statue", " louvre "],
images: [image("side"), image("front", true)],
designer: {
id: 20099,
username: "ScanTheWorld",
name: "Scan The World",
profile_url: "https://www.myminifactory.com/users/ScanTheWorld",
avatar_url: "https://assets.myminifactory.com/avatar.jpg",
website: "https://example.com",
},
// A page, as the API answers, though its spec says a plain list.
categories: { total_count: 1, items: [{ id: 112, name: "Paris", parent: { id: 7, name: "Europe" } }] },
});
expect(meta.title).toBe("Diana at the Louvre");
expect(meta.description).toContain("**marble**");
expect(meta.description).toContain("## Printing details");
expect(meta.tags).toEqual(["statue", "louvre"]);
expect(meta.previewImageUrl).toContain("original-front");
expect(galleryImages.map((img) => img.url)).toEqual([
expect.stringContaining("original-side"),
expect.stringContaining("original-front"),
]);
expect(meta.author).toMatchObject({
provider: "myminifactory",
externalId: "20099",
handle: "ScanTheWorld",
name: "Scan The World",
links: ["https://www.myminifactory.com/users/ScanTheWorld", "https://example.com"],
});
expect(meta.creator).toBe("Scan The World");
expect(meta.siteCategoryIds).toEqual([112, 7]);
expect(meta.categorySite).toBe("myminifactory");
});
it("tells a store object by its price", () => {
expect(myminifactoryMetaFromObject({ id: 1, name: "Free" }).paid).toBe(false);
expect(
myminifactoryMetaFromObject({ id: 2, name: "Paid", price: { currency: "USD", symbol: "$", value: "1.50" } }).paid,
).toBe(true);
});
});
describe("resolveMyminifactoryDownload", () => {
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
it("follows a free object to its signed file link", async () => {
const signed = "https://dl4.myminifactory.com/object-assets/abc/threedfiles/diana.stl?X-Amz-Signature=x";
global.fetch = vi.fn<(input: RequestInfo | URL) => Promise<Response>>(async () =>
redirectResponse(signed),
) as unknown as typeof fetch;
expect(await resolveMyminifactoryDownload("16357")).toEqual({ kind: "file", url: signed });
});
it("tells a paid or members-only object by its redirect to the login page", async () => {
global.fetch = vi.fn<(input: RequestInfo | URL) => Promise<Response>>(async () =>
redirectResponse("https://www.myminifactory.com/login"),
) as unknown as typeof fetch;
expect(await resolveMyminifactoryDownload("300000")).toEqual({ kind: "login" });
});
it("ignores a redirect anywhere but MyMiniFactory's file hosts", async () => {
global.fetch = vi.fn<(input: RequestInfo | URL) => Promise<Response>>(async () =>
redirectResponse("https://evil.example.com/x.stl"),
) as unknown as typeof fetch;
expect(await resolveMyminifactoryDownload("16357")).toEqual({ kind: "missing" });
});
});
describe("fetchMyminifactoryCollectionObjects", () => {
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
it("pages through a collection and reads its name", async () => {
const object = (id: number) => ({ id, name: `Object ${id}`, images: [image(String(id), true)] });
const pages: Record<string, unknown[]> = {
"1": Array.from({ length: 30 }, (_, i) => object(i + 1)),
// A store object, left out as it can't be imported.
"2": [object(31), { ...object(32), price: { currency: "USD", symbol: "$", value: "2.00" } }],
};
global.fetch = vi.fn<(input: RequestInfo | URL) => Promise<Response>>(async (input) => {
const url = new URL(String(input));
expect(url.pathname).toBe("/api/v2/users/schmick101/collections/steampunk");
expect(url.searchParams.get("key")).toBe(API_KEY);
const page = url.searchParams.get("page")!;
return jsonResponse(200, { name: "Steampunk", objects: { total_count: 32, items: pages[page] } });
}) as unknown as typeof fetch;
const listing = await fetchMyminifactoryCollectionObjects("schmick101", "steampunk", API_KEY);
expect(listing?.title).toBe("Steampunk");
expect(listing?.entries).toHaveLength(31);
expect(listing?.entries[0]).toEqual({
objectId: "1",
title: "Object 1",
cover: "https://assets.myminifactory.com/object-images/abc/images/thumb-1.jpg",
});
expect(listing?.truncated).toBe(false);
expect(listing?.paid).toBe(1);
});
it("is null for a collection that doesn't exist, and throws on a rejected key", async () => {
global.fetch = vi.fn<(input: RequestInfo | URL) => Promise<Response>>(async () =>
jsonResponse(404, { status: 404 }),
) as unknown as typeof fetch;
expect(await fetchMyminifactoryCollectionObjects("a", "b", API_KEY)).toBeNull();
global.fetch = vi.fn<(input: RequestInfo | URL) => Promise<Response>>(async () =>
jsonResponse(401, { error: "access_denied", error_description: "Invalid API key." }),
) as unknown as typeof fetch;
await expect(fetchMyminifactoryCollectionObjects("a", "b", API_KEY)).rejects.toThrow(/API key/);
});
});
describe("normalizeMyminifactoryCookie", () => {
it("keeps only the session, from a Cookie header, a pair or a bare value", () => {
const header = "hl=en; __cflb=02Di; PHPSESSID=1fd6e084e0ad; cf_clearance=JXx-1.2; __cf_bm=wK";
expect(normalizeMyminifactoryCookie(header)).toBe("PHPSESSID=1fd6e084e0ad");
expect(normalizeMyminifactoryCookie("Cookie: PHPSESSID=abc")).toBe("PHPSESSID=abc");
expect(normalizeMyminifactoryCookie(" abc123 ")).toBe("PHPSESSID=abc123");
});
it("is null without a session", () => {
expect(normalizeMyminifactoryCookie("hl=en; __cflb=02Di")).toBeNull();
expect(normalizeMyminifactoryCookie("")).toBeNull();
expect(normalizeMyminifactoryCookie("PHPSESSID=bad value;")).toBeNull();
});
});
+266
View File
@@ -0,0 +1,266 @@
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import request from "supertest";
import JSZip from "jszip";
import { createApp } from "../src/app";
import { importPrintFromUrl } from "../src/services/importService";
import { setMyminifactoryApiKey } from "../src/services/settingsService";
import { prisma } from "../src/db";
import { getMyminifactoryConnection, setUserMyminifactoryCookie } from "../src/services/myminifactorySessionService";
// MyMiniFactory import end to end: metadata from the API v2 shapes, files from /download, fetch mocked.
const OBJECT_ID = "9990077";
const OBJECT_URL = `https://www.myminifactory.com/object/3d-print-test-skull-${OBJECT_ID}`;
const API_KEY = "test-api-key";
const SIGNED_ZIP = "https://dl4.myminifactory.com/object-assets/test/generated-zips/skull.zip?X-Amz-Signature=x";
const SIGNED_STL = "https://dl4.myminifactory.com/object-assets/test/threedfiles/skull.stl?X-Amz-Signature=x";
const IMAGE_URL = "https://assets.myminifactory.com/object-images/test/images/front.jpg";
const ONE_PIXEL_PNG = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
"base64",
);
const OBJECT = {
id: Number(OBJECT_ID),
name: "Test Skull Pen Holder",
description_html: "<p>A <strong>skull</strong> pen holder.</p>",
tags: ["skull", "desk"],
images: [{ is_primary: true, original: { url: IMAGE_URL }, thumbnail: { url: IMAGE_URL } }],
designer: { id: 777, username: "TestDesigner", name: "Test Designer", avatar_url: IMAGE_URL },
categories: { total_count: 1, items: [{ id: 4242, name: "Desk", parent: null }] },
};
function jsonResponse(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
}
async function zipBody(): Promise<Buffer> {
const zip = new JSZip();
zip.file("skull/skull.stl", "solid skull\nendsolid skull\n");
zip.file("skull/moustache.stl", "solid moustache\nendsolid moustache\n");
zip.file("skull/readme.pdf", "%PDF-1.4 fake");
return zip.generateAsync({ type: "nodebuffer" });
}
function mockMyminifactory(download: string, opts: { objectStatus?: number; price?: string } = {}) {
return vi.fn<(input: RequestInfo | URL) => Promise<Response>>(async (input) => {
const url = String(input);
if (url.startsWith(`https://www.myminifactory.com/api/v2/objects/${OBJECT_ID}?`)) {
if (opts.objectStatus) return jsonResponse(opts.objectStatus, { error: "x" });
return jsonResponse(
200,
opts.price ? { ...OBJECT, price: { currency: "USD", symbol: "$", value: opts.price } } : OBJECT,
);
}
if (url === `https://www.myminifactory.com/download/${OBJECT_ID}`) {
return new Response(null, { status: 302, headers: { location: download } });
}
if (url === SIGNED_ZIP) {
return new Response(await zipBody(), { status: 200, headers: { "content-type": "application/zip" } });
}
if (url === SIGNED_STL) return new Response("solid skull\nendsolid skull\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;
}
describe("importPrintFromUrl -- MyMiniFactory", () => {
const app = createApp();
let userId: string;
const originalFetch = global.fetch;
beforeAll(async () => {
const email = `myminifactory-import-test-${Date.now()}@example.com`;
const res = await request(app)
.post("/api/register")
.send({ displayName: "MyMiniFactory 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 setMyminifactoryApiKey(null);
await prisma.print.deleteMany({ where: { userId, sourceProvider: "myminifactory" } });
});
it("fails clearly when no API key is configured", async () => {
await expect(importPrintFromUrl(userId, OBJECT_URL, { url: OBJECT_URL, tags: [] })).rejects.toThrow(
/isn't configured/i,
);
});
it("names the API key when MyMiniFactory rejects it", async () => {
await setMyminifactoryApiKey(API_KEY);
global.fetch = mockMyminifactory(SIGNED_STL, { objectStatus: 401 });
await expect(importPrintFromUrl(userId, OBJECT_URL, { url: OBJECT_URL, tags: [] })).rejects.toThrow(/API key/);
});
it("sends a login-only object to the account connection, without a session", async () => {
await setMyminifactoryApiKey(API_KEY);
global.fetch = mockMyminifactory("https://www.myminifactory.com/login");
await expect(importPrintFromUrl(userId, OBJECT_URL, { url: OBJECT_URL, tags: [] })).rejects.toThrow(
/MyMiniFactory.*Configuration > Providers/,
);
});
it("tells a store object by its price, without asking for the anonymous download", async () => {
await setMyminifactoryApiKey(API_KEY);
const fetchMock = mockMyminifactory(SIGNED_STL, { price: "1.50" });
global.fetch = fetchMock;
await expect(importPrintFromUrl(userId, OBJECT_URL, { url: OBJECT_URL, tags: [] })).rejects.toThrow(
/MyMiniFactory.*Configuration > Providers/,
);
expect(vi.mocked(fetchMock).mock.calls.some(([input]) => String(input).includes("/download/"))).toBe(false);
});
it("imports a zipped object as one model with a plate per model file", async () => {
await setMyminifactoryApiKey(API_KEY);
global.fetch = mockMyminifactory(SIGNED_ZIP);
const result = await importPrintFromUrl(userId, OBJECT_URL, { url: OBJECT_URL, tags: [] });
expect(result.alreadyImported).toBe(false);
expect(result.print).toMatchObject({
title: "Test Skull Pen Holder",
creator: "Test Designer",
sourceProvider: "myminifactory",
sourceExternalId: OBJECT_ID,
tags: ["Skull", "Desk"],
});
expect(result.print.notes).toContain("**skull**");
expect(result.plates.map((plate) => plate.filename).toSorted()).toEqual(["moustache.stl", "skull.stl"]);
expect(result.author).toMatchObject({ id: "myminifactory:777", name: "Test Designer" });
expect(result.previewImages.length).toBeGreaterThan(0);
const again = await importPrintFromUrl(userId, OBJECT_URL, { url: OBJECT_URL, tags: [] });
expect(again).toMatchObject({ alreadyImported: true, print: { id: result.print.id } });
});
it("imports a single-file object as one plate", async () => {
await setMyminifactoryApiKey(API_KEY);
global.fetch = mockMyminifactory(SIGNED_STL);
const result = await importPrintFromUrl(userId, OBJECT_URL, { url: OBJECT_URL, tags: [] });
expect(result.plates.map((plate) => plate.filename)).toEqual(["skull.stl"]);
});
});
// Paid models, and free ones whose files are archives, download as the user from their library: the session lists
// the files MyMiniFactory unpacked from each archive, and each one redirects to its own signed link.
describe("importPrintFromUrl -- MyMiniFactory, from the user's library", () => {
const app = createApp();
let userId: string;
const originalFetch = global.fetch;
const SESSION = "PHPSESSID=abc123";
const SIGNED_PART = (id: string) =>
`https://dl4.myminifactory.com/object-assets/test/extracted-files/${id}.stl?sig=x`;
beforeAll(async () => {
const email = `myminifactory-library-test-${Date.now()}@example.com`;
const res = await request(app)
.post("/api/register")
.send({ displayName: "MyMiniFactory Library 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 setMyminifactoryApiKey(null);
await setUserMyminifactoryCookie(userId, null);
await prisma.print.deleteMany({ where: { userId, sourceProvider: "myminifactory" } });
});
/** `library`: what downloadables answers -- the unpacked archive, "not owned" (401), or "signed out" (to login). */
function mockLibrary({
paid = false,
library = "owned",
}: {
paid?: boolean;
library?: "owned" | "not-owned" | "expired";
}) {
return vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(async (input, init) => {
const url = String(input);
const cookie = new Headers(init?.headers).get("cookie");
if (url.startsWith(`https://www.myminifactory.com/api/v2/objects/${OBJECT_ID}?`)) {
return jsonResponse(200, {
...OBJECT,
file_mode: 1,
...(paid ? { price: { currency: "USD", symbol: "$", value: "4.00" } } : {}),
});
}
if (url.startsWith("https://www.myminifactory.com/api/data-library/") || url.includes("/download/")) {
if (cookie !== SESSION) throw new Error(`Library request without the session: ${url}`);
if (library === "expired") {
return new Response(null, { status: 302, headers: { location: "https://www.myminifactory.com/login" } });
}
}
if (url === `https://www.myminifactory.com/api/data-library/myObjects/object-${OBJECT_ID}/downloadables`) {
if (library === "not-owned") return jsonResponse(401, { error: "access_denied" });
return jsonResponse(200, {
archives: [
{
id: 55,
name: "Skull.rar",
extractedFiles: [
{ id: 901, path: "Skull/skull.stl", size: "100" },
{ id: 902, path: "Skull/skull.chitubox", size: "100" },
{ id: 903, path: "Skull/moustache.stl", size: "100" },
],
},
],
pdfs: [],
parts: [],
});
}
const contents = url.match(/\/api\/data-library\/archive-file-contents\/(\d+)\/download$/);
if (contents) return new Response(null, { status: 302, headers: { location: SIGNED_PART(contents[1]) } });
const part = url.match(/extracted-files\/(\d+)\.stl/);
if (part) return new Response(`solid p${part[1]}\nendsolid\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}`);
});
}
it("asks for a MyMiniFactory sign-in when there's none", async () => {
await setMyminifactoryApiKey(API_KEY);
global.fetch = mockLibrary({}) as unknown as typeof fetch;
await expect(importPrintFromUrl(userId, OBJECT_URL, { url: OBJECT_URL, tags: [] })).rejects.toThrow(
/downloads only when signed in.*Configuration > Providers/,
);
});
it("imports the model files MyMiniFactory unpacked from its archives, one plate each", async () => {
await setMyminifactoryApiKey(API_KEY);
await setUserMyminifactoryCookie(userId, SESSION);
global.fetch = mockLibrary({ paid: true }) as unknown as typeof fetch;
const result = await importPrintFromUrl(userId, OBJECT_URL, { url: OBJECT_URL, tags: [] });
expect(result.plates.map((plate) => plate.filename).toSorted()).toEqual(["moustache.stl", "skull.stl"]);
expect(result.print).toMatchObject({ sourceProvider: "myminifactory", sourceExternalId: OBJECT_ID });
});
it("says so when a paid model isn't in the user's library", async () => {
await setMyminifactoryApiKey(API_KEY);
await setUserMyminifactoryCookie(userId, SESSION);
global.fetch = mockLibrary({ paid: true, library: "not-owned" }) as unknown as typeof fetch;
await expect(importPrintFromUrl(userId, OBJECT_URL, { url: OBJECT_URL, tags: [] })).rejects.toThrow(
/isn't in your library/,
);
});
it("marks an expired session, so the app can ask for a new one", async () => {
await setMyminifactoryApiKey(API_KEY);
await setUserMyminifactoryCookie(userId, SESSION);
global.fetch = mockLibrary({ library: "expired" }) as unknown as typeof fetch;
await expect(importPrintFromUrl(userId, OBJECT_URL, { url: OBJECT_URL, tags: [] })).rejects.toThrow(
/didn't accept your saved sign-in/,
);
expect(await getMyminifactoryConnection(userId)).toBe("expired");
});
});
+14 -3
View File
@@ -30,14 +30,20 @@ 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"] as const) await setProviderEnabled(provider, true);
for (const provider of ["makerworld", "printables", "thingiverse", "myminifactory"] as const)
await setProviderEnabled(provider, true);
});
describe("turning providers off", () => {
it("has every provider on, and only lets admins switch them", async () => {
const res = await request(app).get("/api/settings/providers").set(as(member));
expect(res.status).toBe(200);
expect(enabledMap(res.body)).toEqual({ makerworld: true, printables: true, thingiverse: true });
expect(enabledMap(res.body)).toEqual({
makerworld: true,
printables: true,
thingiverse: true,
myminifactory: true,
});
expect(
(await request(app).patch("/api/settings/providers/thingiverse").set(as(member)).send({ enabled: false })).status,
@@ -48,7 +54,12 @@ describe("turning providers off", () => {
const off = await request(app).patch("/api/settings/providers/thingiverse").set(as(admin)).send({ enabled: false });
expect(off.status).toBe(200);
expect(enabledMap(off.body)).toEqual({ makerworld: true, printables: true, thingiverse: false });
expect(enabledMap(off.body)).toEqual({
makerworld: true,
printables: true,
thingiverse: false,
myminifactory: true,
});
});
it("refuses new imports from a provider that's off, and only from it", async () => {
+1
View File
@@ -65,6 +65,7 @@ describe("system health", () => {
"internet",
"flaresolverr",
"thingiverse",
"myminifactory",
"grab",
"ai",
"smtp",
+3 -3
View File
@@ -94,8 +94,8 @@ Open <http://localhost:5173>. **The first account you register becomes the admin
## Optional: FlareSolverr
Thingiverse sits behind Cloudflare, and some imports need FlareSolverr to get through. It's
optional -- without it those imports will simply fail.
Thingiverse and MyMiniFactory sit behind Cloudflare, and some imports need FlareSolverr to get through. It's optional --
without it those imports will simply fail.
```bash
docker run -d --name thingport-flaresolverr -p 8191:8191 ghcr.io/flaresolverr/flaresolverr:latest
@@ -259,4 +259,4 @@ ordinary notification (the System tab). To post one, add an entry to `entries` a
## Related documents
- [CONTRIBUTING.md](../CONTRIBUTING.md) -- branch, commit and pull request workflow
- [PROVIDER_SETUP.md](PROVIDER_SETUP.md) -- MakerWorld / Thingiverse / Printables credentials
- [PROVIDER_SETUP.md](PROVIDER_SETUP.md) -- MakerWorld / Thingiverse / Printables / MyMiniFactory credentials
+33 -4
View File
@@ -1,10 +1,10 @@
# Provider setup
Importing from **Printables** works out of the box (its public API needs no credential). **MakerWorld** and **Thingiverse** each need a credential from your own account before Thingport can import from them.
Importing from **Printables** works out of the box (its public API needs no credential). **MakerWorld**, **Thingiverse** and **MyMiniFactory** each need a credential from your own account before Thingport can import from them.
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.
![Configuration, Providers section with MakerWorld, Thingiverse and Printables connected](../frontend/src/assets/screenshots/11_configuration_providers.png)
![Configuration, Providers section with MakerWorld, Thingiverse, Printables and MyMiniFactory connected](../frontend/src/assets/screenshots/11_configuration_providers.png)
## Thingiverse App Token
@@ -17,6 +17,35 @@ Instance-wide, admin-configured (Configuration > Providers > Thingiverse, or Adm
Treat this token like a password: anyone who has it can use it to act as your Thingiverse app. If it's ever exposed, revoke it from your Thingiverse account's Apps page and generate a new one.
## MyMiniFactory API key
Instance-wide and admin-configured, like the Thingiverse token (Configuration > Providers > MyMiniFactory, or Administration > Providers > MyMiniFactory API key). Thingport uses it to read models' details, collections and authors; the files of free models download without it.
1. Sign in to your MyMiniFactory account, then go to [myminifactory.com/settings/developer](https://www.myminifactory.com/settings/developer).
2. Create an application. Its name doesn't matter.
3. Copy the application's **API key** -- not its client ID or client secret, which are for signing users in and won't work here.
4. Save it under **Administration > Providers > MyMiniFactory API key**. Thingport checks the key against MyMiniFactory before storing it.
With the key alone, free models import for everyone. **Paid models** you bought, and free ones whose files only download when signed in, come from your own MyMiniFactory library, so each user connects their MyMiniFactory account too.
## MyMiniFactory account
Per-user (Configuration > Providers > MyMiniFactory). Thingport downloads your bought and sign-in-only models as you, from your MyMiniFactory library, using your browser's MyMiniFactory session. Where MyMiniFactory has unpacked a model's archives, Thingport fetches just the model files from them, so RAR archives and very large zips are no problem.
### Easiest: Thingport Grab
With [Thingport Grab](../extension/README.md) allowed on MyMiniFactory, opening any myminifactory.com page while you're signed in connects your account, and keeps it connected as MyMiniFactory renews your session.
### Manual: copy it from your browser
1. Sign in at [myminifactory.com](https://www.myminifactory.com).
2. Open DevTools (`F12`, or `Cmd+Option+I` on macOS).
3. Chrome/Edge: **Application** tab > **Cookies** > `https://www.myminifactory.com`. Firefox: **Storage** tab > **Cookies**.
4. Copy the value of the cookie named `PHPSESSID`. (Pasting the whole `Cookie` request header works too: Thingport keeps only `PHPSESSID`.)
5. Right-click **MyMiniFactory** in Thingport's **Configuration > Providers**, choose **Connect** and paste it. Thingport checks it with MyMiniFactory before storing it.
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.
## 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.
@@ -39,8 +68,8 @@ 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 three are on by
Admins choose which providers this Thingport imports from, under **Administration → Providers**. All four 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
it left off. The Thingiverse App Token is set on the same page.
it left off. The Thingiverse App Token and the MyMiniFactory API key are set on the same page.
+14 -14
View File
@@ -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 or Thingiverse off for everyone, and set the Thingiverse App Token. 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 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. |
After installing, and whenever something stops working, start with
[System Health](../guides/system-health.md): ideally everything there is green.
+11 -9
View File
@@ -1,16 +1,16 @@
# Import from MakerWorld, Printables and Thingiverse
# Import from MakerWorld, Printables, Thingiverse and MyMiniFactory
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 or Thingiverse, 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.
- **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
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 three sites, so you can import without leaving the page, file the model into a collection, and import whole
collections.
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.
- **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.
- **Keep a collection in sync.** A [synced collection](../guides/synced-collections.md) imports models added to it on
@@ -36,9 +36,11 @@ 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 an App Token set by an admin.
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.
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
[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.
Imported models can be categorized and their descriptions cleaned up on the way in with the optional
[AI features](../guides/ai.md).
+2 -2
View File
@@ -18,8 +18,8 @@ Manage both from **Manage categories & folders** on the Models page. Deleting on
## Collections and tags
- **Collections** group models however you like, and a model can be in as many as you want. Give a collection a
description and tags, or [sync it](../guides/synced-collections.md) with a collection on MakerWorld, Printables or
Thingiverse.
description and tags, or [sync it](../guides/synced-collections.md) with a collection on MakerWorld, Printables,
Thingiverse or MyMiniFactory.
- **Tags** come with imported models, and you can add your own. Browse every tag with a picture made from its models,
hide tags used only once, or delete a tag from every model at once.
- **Favourites** and your **Browsing history** are kept as collections of their own.
+16 -13
View File
@@ -1,17 +1,18 @@
# Synced collections
A synced collection follows a collection on MakerWorld, Printables or Thingiverse. 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.
A synced collection follows a collection on MakerWorld, Printables, Thingiverse or MyMiniFactory. 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.
<picture>
<source media="(prefers-color-scheme: dark)" srcset="../assets/collection-sync-dark.gif">
<img src="../assets/collection-sync-light.gif" alt="Models flowing from a MakerWorld, Printables or Thingiverse collection, through Thingport, into a synced collection">
<img src="../assets/collection-sync-light.gif" alt="Models flowing from a MakerWorld, Printables, Thingiverse or MyMiniFactory collection, through Thingport, into a synced collection">
</picture>
## Why sync a collection
- **Keep collecting where you already do.** Save models to a collection on MakerWorld, Printables or Thingiverse as you
browse, even from your phone, and they turn up in your library later.
- **Keep collecting where you already do.** Save models to a collection on MakerWorld, Printables, Thingiverse or
MyMiniFactory as you browse, even from your phone, and they turn up in your library later.
- **Follow other people's collections.** Sync a designer's or a friend's public collection, and get each model they
add to it.
- **A real copy of everything.** Each new model is imported with its files, photos, description, tags and author, so
@@ -26,22 +27,23 @@ Thingport imports that model into the matching Thingport collection on its own,
extension, on the provider collection's page.
- 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.
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.
## 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 and Thingiverse, the public ones of the username you give the first time. Or paste any
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
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.
What happens to the models already in the provider's collection depends on the provider. On Printables and Thingiverse
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.
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
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.
### Private Printables collections and your Printables likes
@@ -53,7 +55,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 or Thingiverse. A Thingiverse user's **Likes** page works too.
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.
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).
+17 -14
View File
@@ -16,16 +16,17 @@ for.
## What's checked
| Part | Needed | What it's for, and what breaks without it |
| -------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Database | Required | Holds every model, collection, user and setting. Without it nothing in Thingport works. |
| File storage | Required | Where model files, pictures and 3D previews are kept. Uploads, imports and downloads fail if it isn't writable or runs out of space. |
| Memory | Required | Building 3D previews of large models takes the most. With too little, previews fail or the server restarts. |
| FlareSolverr | Recommended | Gets past Cloudflare's checks, mostly on Thingiverse and Printables. Without it, imports and syncs from those sites are blocked whenever Cloudflare challenges the server. |
| Thingiverse | Recommended | The instance's Thingiverse App Token, used for every Thingiverse import, synced collection and author lookup. Not checked while Thingiverse is [turned off](../PROVIDER_SETUP.md#turning-a-provider-off). |
| Thingport Grab | Recommended | The [browser extension](../../extension/README.md) for importing while you browse MakerWorld, Printables and Thingiverse, whole collections included, and for bringing over Printables likes. Shown as seen once any browser uses it with this instance. |
| AI provider | Optional | The default AI model behind [AI categorization and improved descriptions](ai.md). Everything else works without it. |
| Email (SMTP) | Optional | Sends invitations, email confirmations and password resets. Without it, invitation links are shown to copy instead, and new accounts don't confirm their address. |
| Part | Needed | What it's for, and what breaks without it |
| -------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Database | Required | Holds every model, collection, user and setting. Without it nothing in Thingport works. |
| File storage | Required | Where model files, pictures and 3D previews are kept. Uploads, imports and downloads fail if it isn't writable or runs out of space. |
| Memory | Required | Building 3D previews of large models takes the most. With too little, previews fail or the server restarts. |
| FlareSolverr | Recommended | Gets past Cloudflare's checks, mostly on Thingiverse and Printables. Without it, imports and syncs from those sites are blocked whenever Cloudflare challenges the server. |
| Thingiverse | Recommended | The instance's Thingiverse App Token, used for every Thingiverse import, synced collection and author lookup. Not checked while Thingiverse is [turned off](../PROVIDER_SETUP.md#turning-a-provider-off). |
| MyMiniFactory | Optional | The instance's MyMiniFactory API key, used to read models, synced collections and authors from MyMiniFactory. Not checked while MyMiniFactory is [turned off](../PROVIDER_SETUP.md#turning-a-provider-off). |
| Thingport Grab | Recommended | The [browser extension](../../extension/README.md) for importing while you browse MakerWorld, Printables, Thingiverse and MyMiniFactory, whole collections included, and for bringing over Printables likes. Shown as seen once any browser uses it with this instance. |
| AI provider | Optional | The default AI model behind [AI categorization and improved descriptions](ai.md). Everything else works without it. |
| Email (SMTP) | Optional | Sends invitations, email confirmations and password resets. Without it, invitation links are shown to copy instead, and new accounts don't confirm their address. |
## What the colours mean
@@ -34,10 +35,9 @@ for.
an error.
- **Error** (red): not working. For a Required or Recommended part, Thingport or the features that depend on it are
broken until it's fixed.
- **Not configured** (grey): not set up. Fine for an optional part you don't use. For FlareSolverr or Thingiverse,
imports from those sites will fail sooner or later.
Thingport can't look into anyone's browser, so Grab stays grey until it's first used with this instance; get it
from the **Downloads** page.
- **Not configured** (grey): not set up. Fine for an optional part you don't use. For FlareSolverr, Thingiverse or
MyMiniFactory, imports from those sites will fail sooner or later. Thingport can't look into anyone's browser, so Grab
stays grey until it's first used with this instance; get it from the **Downloads** page.
- **Not tested**: hasn't been checked yet. Click **Test** or **Run full test**.
Every problem comes with what to do about it, for example: check that the database container is running, make sure
@@ -54,6 +54,9 @@ pull the AI model on Ollama.
Connections → FlareSolverr** is right, port included (usually `http://flaresolverr:8191`).
- **Thingiverse token rejected**: generate a new one at [thingiverse.com/apps/create](https://www.thingiverse.com/apps/create)
and save it under **Administration → Providers** (see [provider setup](../PROVIDER_SETUP.md#thingiverse-app-token)).
- **MyMiniFactory key rejected**: check that you copied the application's API key, not its client ID, under
[myminifactory.com/settings/developer](https://www.myminifactory.com/settings/developer), and save it under
**Administration → Providers** (see [provider setup](../PROVIDER_SETUP.md#myminifactory-api-key)).
- **AI provider not answering or model missing**: see [troubleshooting the AI features](ai.md#troubleshooting).
- **Email not connecting**: check the host, port, security setting and login under **Administration → Connections →
SMTP**.
+1 -1
View File
@@ -55,7 +55,7 @@ A few details worth knowing:
- If model files sit directly in the folder you picked, that folder itself becomes the model.
- Files in a folder without any model files are uploaded one per file, as in the other mode.
- This is the right choice for downloads from MakerWorld, Printables and Thingiverse that you unpacked into a folder
- This is the right choice for downloads from MakerWorld, Printables, Thingiverse and MyMiniFactory that you unpacked into a folder
each. Every download ends up as one tidy model.
## ZIPs inside the folder
+1 -1
View File
@@ -52,7 +52,7 @@ Recommended should be green; anything that isn't means part of Thingport won't w
it by that exact name over the compose network; renaming it breaks `/api/*` requests.
- If the backend can't write to `/DATA/AppData/thingport/storage`, check that folder's
owning uid/gid and set `PUID`/`PGID` in your `.env` to match.
- Provider setup (MakerWorld / Thingiverse credentials) works the same as any other install
- Provider setup (MakerWorld / Thingiverse / MyMiniFactory credentials) works the same as any other install
-- see [`docs/PROVIDER_SETUP.md`](../../PROVIDER_SETUP.md) in the main repo.
- To pin a specific build instead of `:latest`, see the tagging note in
[`docker-compose.deploy.yml`](../../../docker-compose.deploy.yml) and set `BACKEND_IMAGE`/
+1 -1
View File
@@ -55,7 +55,7 @@ Recommended should be green; anything that isn't means part of Thingport won't w
it by that exact name over the compose network; renaming it breaks `/api/*` requests.
- If the backend can't write to `$APPDATA_PATH/storage`, check the dataset's owning
uid/gid (`ls -n` on the parent folder) and set `PUID`/`PGID` in your `.env` to match.
- Provider setup (MakerWorld / Thingiverse credentials) works the same as any other install
- Provider setup (MakerWorld / Thingiverse / MyMiniFactory credentials) works the same as any other install
-- see [`docs/PROVIDER_SETUP.md`](../../PROVIDER_SETUP.md) in the main repo.
- To pin a specific build instead of `:latest`, see the tagging note in
[`docker-compose.deploy.yml`](../../../docker-compose.deploy.yml) and set `BACKEND_IMAGE`/
+1 -1
View File
@@ -48,7 +48,7 @@ Recommended should be green; anything that isn't means part of Thingport won't w
- **Do not rename the `backend` service/container.** The frontend's nginx config resolves
it by that exact name over the compose network; renaming it breaks `/api/*` requests.
- Provider setup (MakerWorld / Thingiverse credentials) works the same as any other install
- Provider setup (MakerWorld / Thingiverse / MyMiniFactory credentials) works the same as any other install
-- see [`docs/PROVIDER_SETUP.md`](../../PROVIDER_SETUP.md) in the main repo.
- To pin a specific build instead of `:latest`, see the tagging note in
[`docker-compose.deploy.yml`](../../../docker-compose.deploy.yml) and set `BACKEND_IMAGE`/
+12 -1
View File
@@ -58,6 +58,14 @@ Colors, spacing and type live in `src/styles/_tokens.scss`. Colors are CSS custo
light and a dark palette: the popup follows the browser's color scheme, while the in-page UI always
uses the light one.
### Adding a provider site
Don't add a new site to `PROVIDER_MATCHES` in `scripts/manifest.ts`: new host access in an update makes Chrome and
Edge switch the extension off for every user until they approve it. Add it to `OPTIONAL_SITES` in
`src/shared/optionalSites.ts` instead. Users who clicked **Turn on** under **More sites** in the popup already granted
all-sites access, so the background registers the content script there on its own; for everyone else, the button
comes back, naming the new site.
### Before opening a pull request
- `npm run verify` passes (typecheck, lint, all three builds).
@@ -162,7 +170,10 @@ templates in `store-assets/` (styled like the website's social card):
Run `npm run screenshots` first when the screenshots need refreshing. They show live provider
pages, so check them before uploading: a front-page model can be someone else's trademarked
character, and Printables pages carry its own ads. Pick neutral models with
`npm run screenshots -- --makerworld=<url> --printables=<url> --thingiverse=<url>`.
`npm run screenshots -- --makerworld=<url> --printables=<url> --thingiverse=<url>`. MyMiniFactory uses a fixed free
model and collection (its front page is mostly paid miniatures, some not safe for work); `--myminifactory=<url>` and
`--myminifactory-collection=<url>` change them. The screenshot build has all-sites access already granted, since
Playwright can't click the browser's permission prompt.
### Regenerating the screenshots
+22 -16
View File
@@ -1,9 +1,9 @@
# Thingport Grab privacy policy
_Effective 12 October 2026._
_Effective 18 October 2026._
Thingport Grab is a browser extension that imports 3D-printing models from MakerWorld, Printables
and Thingiverse into **your own self-hosted Thingport instance**, and converts MakerWorld files for
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
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.
@@ -16,7 +16,8 @@ the list of recent imports, which is synced between your browsers as described b
you sign in to it with. They're stored as you entered them, so the extension can sign in again
when its session expires.
- **A session token** your Thingport instance issues when the extension signs in.
- **Your settings:** whether the extension is switched on.
- **Your settings:** whether the extension is switched on. Which optional sites you turned on is kept by your browser,
as the access you granted.
- **Recent imports:** the title and a link for the last five models you imported through the
extension, shown in its toolbar popup. This list is kept in your browser's sync storage, so it
appears in the extension on your other browsers and devices where you're signed in to the same
@@ -25,7 +26,7 @@ the list of recent imports, which is synced between your browsers as described b
password: entries are tied to your Thingport account by a one-way hash of your instance address
and email. Each model's thumbnail is fetched from your Thingport instance and kept in local
storage on each device; it isn't synced.
- **Your Printables and Thingiverse usernames,** as last sent to your Thingport instance, so they're
- **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.
- **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
@@ -39,7 +40,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 or Thingiverse, to check
- The address of each model page you open on MakerWorld, Printables, Thingiverse or MyMiniFactory, 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.
@@ -52,7 +53,10 @@ the **provider site you're on**.
- If you're logged into MakerWorld in this browser: your MakerWorld session cookie, when you import
from MakerWorld. MakerWorld requires it to download files. The extension also saves it to your
Thingport account's MakerWorld setting, so imports made from Thingport's own web app work too.
- If you're logged into Printables or Thingiverse in this browser: your username there, when it
- 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 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.
- If you're logged into Printables in this browser, at most once an hour while you're on its pages:
@@ -60,7 +64,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 and Thingiverse:**
**To MakerWorld, Printables, Thingiverse and MyMiniFactory:**
- 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.
@@ -78,7 +82,7 @@ the **provider site you're on**.
the private collections you sync.
**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, your username from the
model, the model links listed on collection pages, and, on Thingiverse and MyMiniFactory, your username from the
page's header. It doesn't read any other websites.
## What the extension doesn't do
@@ -91,13 +95,15 @@ 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 and Printables' session cookies, only on their sites (see above). |
| Downloads | Reads the link from the MakerWorld download it starts, then cancels and removes that download. |
| Access to MakerWorld, Printables and Thingiverse | Shows the import button, and MakerWorld's Download normalized button, on their pages. |
| 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, 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 all websites (optional) | Asked for once, only when you click **Turn on** under **More sites** in the popup, so sites added in later updates work without asking again. The extension still only runs on the model sites it supports, and 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
+22 -5
View File
@@ -1,7 +1,7 @@
# Thingport Grab
A browser extension for Chrome, Edge and Firefox that imports MakerWorld, Thingiverse, and
Printables models into your self-hosted Thingport instance without leaving the provider's site.
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.
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.
@@ -33,6 +33,10 @@ it stores and sends.
<td width="50%"><a href="docs/screenshots/collection-printables.jpg"><img src="docs/screenshots/collection-printables.jpg" alt="Import panel on a Printables collection, listing its models"></a><br><sub><b>Printables collection</b> -- pick which models to import</sub></td>
<td width="50%"><a href="docs/screenshots/collection-makerworld.jpg"><img src="docs/screenshots/collection-makerworld.jpg" alt="Guided import of a MakerWorld collection, waiting 30 seconds between models"></a><br><sub><b>MakerWorld collection</b> -- guided import, paced between models</sub></td>
</tr>
<tr>
<td width="50%"><a href="docs/screenshots/panel-myminifactory.jpg"><img src="docs/screenshots/panel-myminifactory.jpg" alt="Import panel on a MyMiniFactory object page"></a><br><sub><b>MyMiniFactory</b> -- import panel on a free model's page</sub></td>
<td width="50%"><a href="docs/screenshots/collection-myminifactory.jpg"><img src="docs/screenshots/collection-myminifactory.jpg" alt="Import panel on a MyMiniFactory collection, with sync ticked"></a><br><sub><b>MyMiniFactory collection</b> -- pick models, keep it in sync</sub></td>
</tr>
<tr>
<td width="50%"><a href="docs/screenshots/setup-dialog-makerworld.jpg"><img src="docs/screenshots/setup-dialog-makerworld.jpg" alt="Setup dialog opened from the grayed-out icon"></a><br><sub><b>Not set up yet</b> -- the grayed-out icon's setup dialog</sub></td>
<td width="50%"></td>
@@ -166,11 +170,24 @@ account doesn't already have a MakerWorld cookie saved, the extension also conne
Configuration > Providers for you, so the plain web app's own imports benefit too, not just ones started
from the extension.
### MyMiniFactory and newer sites: allow them once
Sites added after MakerWorld, Printables and Thingiverse, starting with MyMiniFactory, need one click first: **Turn
on** under **More sites** in the popup. Your browser then asks once for access to all websites, so every site added in
a later update works straight away, with no new prompt. The extension still only runs on the model sites it supports.
(An update that added each site to the extension itself would have the browser switch the extension off for everyone
until they approved it, every time.) The button is gone once allowed, and only comes back if you take the access back
in your browser's extension settings.
Opening MyMiniFactory while signed in also connects your MyMiniFactory account to Thingport, so the models you
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.
## What counts as "importable"
- A single model page (MakerWorld, a Thingiverse Thing, a Printables Model) -- hidden automatically
- 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 collection --
- A MakerWorld collection, a Thingiverse Collection or Likes page, or a Printables or MyMiniFactory collection --
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
@@ -182,7 +199,7 @@ just stops showing progress, it doesn't cancel anything.
### Synced collections
On a MakerWorld, Printables or Thingiverse collection page, the panel's **Keep this collection in
On a MakerWorld, Printables, Thingiverse or MyMiniFactory collection page, the panel's **Keep this collection in
sync** box (off by default) links the collection to its Thingport counterpart. A Thingiverse user's
Likes page works the same way (**Keep these likes in sync**), filing into "Thingiverse likes
(@username)" and picking up models they like later. Thingport then checks
Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 503 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 KiB

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 40 KiB

+1 -1
View File
@@ -2,7 +2,7 @@
"name": "thingport-grab",
"private": true,
"version": "1.6.0",
"description": "Import MakerWorld, Thingiverse and Printables models into Thingport, and convert MakerWorld 3MFs for PrusaSlicer, Cura and more.",
"description": "Import MakerWorld, Thingiverse, Printables and MyMiniFactory models into Thingport, and convert MakerWorld 3MFs for other slicers.",
"license": "AGPL-3.0-only",
"type": "module",
"scripts": {
@@ -1,7 +1,7 @@
Thingport Grab {{version}}. Changes in this version: {{changes}}
WHAT IT DOES
Imports 3D-printing models from MakerWorld, Printables and Thingiverse 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 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,8 +11,9 @@ 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.
- 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.
- downloads: reads the link from a download MakerWorld's page starts, then cancels it.
- host access: the three model sites, and the user's own server address (requested at setup).
- 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, all websites, requested only when the user clicks "Turn on" under More sites in the popup, so model sites added in later versions need no new 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
+4 -2
View File
@@ -29,11 +29,13 @@ export function buildManifest(target: Target, pkg: { version: string; descriptio
default_icon: iconSet("dark"),
},
background: target === "firefox" ? { scripts: ["background.js"] } : { service_worker: "background.js" },
permissions: ["storage", "cookies", "downloads"],
// 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"],
// 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,
// The user's instance origin, requested at setup.
// The user's instance origin, requested at setup, and the optional sites, requested when turned on.
optional_host_permissions: ["*://*/*"],
content_scripts: [{ matches: PROVIDER_MATCHES, js: ["content.js"], run_at: "document_idle" }],
};
+70 -7
View File
@@ -8,13 +8,13 @@
//
// npm run screenshots -- --makerworld=https://makerworld.com/en/models/123 --printables=...
//
// The collection shots use fixed collections, changeable with --printables-collection=<url> and
// --makerworld-collection=<url>. --only=<names> refreshes just those shots, e.g.
// The collection shots use fixed collections, changeable with --printables-collection=<url>,
// --makerworld-collection=<url> and --myminifactory-collection=<url>. --only=<names> refreshes just those shots, e.g.
// --only=collection-printables,collection-makerworld.
//
// Needs Playwright's Chromium once: `npx playwright install chromium`.
import { mkdir, rm } from "node:fs/promises";
import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import http from "node:http";
import type { AddressInfo } from "node:net";
import os from "node:os";
@@ -32,9 +32,14 @@ const POPUP_WIDTH = 320;
const NEW_COLLECTION_NAME = "Desk organizers";
const PRINTABLES_COLLECTION = "https://www.printables.com/@Thinkable/collections/342542";
const MAKERWORLD_COLLECTION = "https://makerworld.com/en/collections/15756490-little-coin";
// Free models, so the shots never need a MyMiniFactory account with purchases.
const MYMINIFACTORY_COLLECTION =
"https://www.myminifactory.com/users/Scan%20The%20World/collection/egyptian-collection";
type ProviderName = "makerworld" | "thingiverse" | "printables";
const PROVIDERS: { name: ProviderName; label: string; home: string; modelLink: RegExp }[] = [
type ProviderName = "makerworld" | "thingiverse" | "printables" | "myminifactory";
// `fixed`: always this model, unless one's given. MyMiniFactory's front page is mostly paid miniatures, some not
// safe for work.
const PROVIDERS: { name: ProviderName; label: string; home: string; modelLink: RegExp; fixed?: string }[] = [
{
name: "makerworld",
label: "MakerWorld",
@@ -53,6 +58,13 @@ const PROVIDERS: { name: ProviderName; label: string; home: string; modelLink: R
home: "https://www.printables.com/",
modelLink: /^https:\/\/www\.printables\.com\/model\/\d+-[^/?#]+$/,
},
{
name: "myminifactory",
label: "MyMiniFactory",
home: "https://www.myminifactory.com/",
modelLink: /^https:\/\/www\.myminifactory\.com\/object\/3d-print-[^/?#]+-\d+$/,
fixed: "https://www.myminifactory.com/object/3d-print-funny-pen-holder-or-vase-skull-100000",
},
];
/** CORS is open, so the extension reaches it without a host permission. */
@@ -83,6 +95,7 @@ function startMockInstance(state: MockState): Promise<{ url: string; close: () =
case "POST /api/import/inspect":
return json({ title: state.inspectTitle, is_zip: false });
case "POST /api/import/printables-collection/entries":
case "POST /api/import/myminifactory-collection/entries":
return json(state.batch ?? { title: null, entries: [] });
case "GET /api/collections":
return json(collections);
@@ -119,15 +132,29 @@ function wanted(...files: string[]): boolean {
return !ONLY.length || files.some((file) => ONLY.includes(file.replace(/\.\w+$/, "")));
}
/** dist/chrome with access to all sites already granted, as the popup's "More sites" button leaves it: Playwright
* can't click the browser's permission prompt. Grab still registers its content script on the optional sites itself. */
async function extensionWithOptionalSites(): Promise<string> {
const dir = path.join(os.tmpdir(), `thingport-grab-screenshots-ext-${process.pid}`);
await rm(dir, { recursive: true, force: true });
await cp(EXTENSION_DIR, dir, { recursive: true });
const manifestPath = path.join(dir, "manifest.json");
const manifest = JSON.parse(await readFile(manifestPath, "utf8")) as { host_permissions: string[] };
manifest.host_permissions.push("*://*/*");
await writeFile(manifestPath, JSON.stringify(manifest, null, 2));
return dir;
}
async function launch(mockPort: number): Promise<{ context: BrowserContext; worker: Worker; extensionId: string }> {
const userDataDir = path.join(os.tmpdir(), `thingport-grab-screenshots-${process.pid}`);
const extensionDir = await extensionWithOptionalSites();
const context = await chromium.launchPersistentContext(userDataDir, {
headless: false,
viewport: PAGE_VIEWPORT,
deviceScaleFactor: 2,
args: [
`--disable-extensions-except=${EXTENSION_DIR}`,
`--load-extension=${EXTENSION_DIR}`,
`--disable-extensions-except=${extensionDir}`,
`--load-extension=${extensionDir}`,
// Otherwise Cloudflare (Printables) recognizes the automated browser.
"--disable-blink-features=AutomationControlled",
`--host-resolver-rules=MAP ${DEMO_HOST}:80 127.0.0.1:${mockPort}`,
@@ -157,6 +184,8 @@ async function openPage(page: Page, url: string): Promise<void> {
/** The first one is replaced by --<provider>=<url> when given. */
async function findModelUrls(page: Page, provider: (typeof PROVIDERS)[number], count: number): Promise<string[]> {
const fixed = argValue(provider.name) ?? provider.fixed;
if (fixed) return [fixed];
await openPage(page, provider.home);
const links = await page.$$eval("a[href]", (as) => as.map((a) => (a as HTMLAnchorElement).href.split(/[?#]/)[0]));
const found = [...new Set(links.filter((href) => provider.modelLink.test(href)))];
@@ -212,6 +241,29 @@ async function readPrintablesCollection(page: Page): Promise<BatchEntriesResult>
return { title, entries };
}
/** The same for a MyMiniFactory collection, whose cards link each object twice too. */
async function readMyminifactoryCollection(page: Page): Promise<BatchEntriesResult> {
await page.locator('a[href*="/object/3d-print-"]').first().waitFor({ timeout: 30000 });
const { title, links } = await page.evaluate(() => ({
title: document.querySelector(".collection-title, h1")?.textContent?.trim() || null,
links: [...document.querySelectorAll<HTMLAnchorElement>('a[href*="/object/3d-print-"]')].map((a) => ({
href: a.href,
text: a.getAttribute("title") || a.textContent?.trim() || "",
})),
}));
const titles = new Map<string, string>();
for (const { href, text } of links) {
const id = href.match(/-(\d+)\/?$/)?.[1];
if (id && !titles.get(id)) titles.set(id, text);
}
const entries = [...titles].map(([id, name], i) => ({
design_id: id,
title: name || null,
already_imported: i === 2,
}));
return { title, entries };
}
async function toDataUrl(imageUrl: string | null): Promise<string | null> {
if (!imageUrl) return null;
try {
@@ -348,6 +400,17 @@ async function main(): Promise<void> {
await screenshotViewport(page, "collection-makerworld.jpg");
}
if (wanted("collection-myminifactory")) {
const myminifactoryCollection = argValue("myminifactory-collection") ?? MYMINIFACTORY_COLLECTION;
await openPage(page, myminifactoryCollection);
mockState.batch = await readMyminifactoryCollection(page);
console.log(`MyMiniFactory collection: ${myminifactoryCollection} -- ${mockState.batch.entries.length} models`);
await page.locator(".tg-fab:not(.tg-fab--inactive)").click();
await page.locator('.tg-panel [data-action="import"]').waitFor({ timeout: 20000 });
await page.locator("#tg-sync").check();
await screenshotViewport(page, "collection-myminifactory.jpg");
}
console.log(`Screenshots written to ${path.relative(process.cwd(), OUT_DIR)}/`);
} finally {
await context.close();
+14
View File
@@ -17,9 +17,11 @@ import {
} from "./makerworldJob";
import { getPrintablesToken, printablesPushDue, pushPrintables, reportProviderAccount } from "./providerAccounts";
import { getRecentImports } from "./recentImports";
import { syncMyminifactorySession } from "./myminifactorySession";
import { onPairingTabClosed, onPairingTabUrl, startPairing } from "./pairing";
import { openSetup } from "./setup";
import { setTabIconState } from "./tabIcon";
import { injectIntoOpenTabs, OPTIONAL_SITES, siteEnabled, syncSiteScripts } from "../shared/optionalSites";
function senderTabId(sender: chrome.runtime.MessageSender): number {
if (!sender.tab?.id) throw new Error("This action is only available from a page");
@@ -35,6 +37,7 @@ listen<BackgroundMessages>({
CHECK_CONNECTION: () => checkConnection(),
GET_PRINTABLES_TOKEN: () => getPrintablesToken(),
REPORT_PROVIDER_ACCOUNT: ({ provider, value }) => reportProviderAccount(provider, value),
SYNC_MYMINIFACTORY_SESSION: () => syncMyminifactorySession(),
PRINTABLES_PUSH_DUE: ({ now }) => printablesPushDue({ now }),
PUSH_PRINTABLES: (push) => pushPrintables(push),
OPEN_SETUP: (_payload, sender) => openSetup(sender.tab?.id),
@@ -71,6 +74,17 @@ chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
chrome.runtime.onStartup.addListener(() => void checkConnection());
chrome.runtime.onInstalled.addListener(() => void checkConnection());
// Optional sites: on every wake (a Firefox event page loses its registrations with the browser session), and
// whenever access is granted or taken back, from the popup or the browser's own settings. Tabs already open on a
// site get Grab straight away; it doesn't run twice in one.
void syncSiteScripts();
chrome.permissions.onAdded.addListener(() => {
void syncSiteScripts().then(async () => {
for (const site of OPTIONAL_SITES) if (await siteEnabled(site)) await injectIntoOpenTabs(site);
});
});
chrome.permissions.onRemoved.addListener(() => void syncSiteScripts());
chrome.tabs.onRemoved.addListener((tabId) => {
void dropJobIfForTab(tabId);
void onPairingTabClosed(tabId);
@@ -0,0 +1,29 @@
// The user's MyMiniFactory session, which downloads the paid and login-only models in their library. The cookie is
// HttpOnly, so only the background can read it; it's sent to the instance whenever it changes, so imports, the web
// app's included, keep working as MyMiniFactory renews it.
import { STORAGE_KEYS } from "../shared/storage";
import { apiCall, requireConfig } from "./api";
/** Best-effort: never throws, and an instance without the endpoint just doesn't take it. */
export async function syncMyminifactorySession(): Promise<null> {
const config = await requireConfig().catch(() => null);
if (!config) return null;
let session: chrome.cookies.Cookie | null;
try {
session = await chrome.cookies.get({ url: "https://www.myminifactory.com", name: "PHPSESSID" });
} catch {
return null;
}
if (!session?.value) return null;
const value = `PHPSESSID=${session.value}`;
// Per Thingport account, so switching the extension to another one sends it there too.
const account = `${config.instanceUrl} ${config.email}`;
const last = config.lastSyncedMyminifactorySession;
if (last?.account === account && last.value === value) return null;
try {
await apiCall("PATCH", "/settings/myminifactory-session", { cookie: value });
await chrome.storage.local.set({ [STORAGE_KEYS.lastSyncedMyminifactorySession]: { account, value } });
} catch {}
return null;
}
+2 -2
View File
@@ -1,4 +1,4 @@
// Who the user is signed in as on Printables and Thingiverse, read on their pages, so the web app's
// Who the user is signed in as on Printables, Thingiverse and MyMiniFactory, read on their pages, so the web app's
// Browse tab lists their collections without asking. Sent only when it changes; the signed-in
// account replaces a username typed into the app.
@@ -16,7 +16,7 @@ export async function getPrintablesToken(): Promise<string | null> {
}
}
/** Best-effort: an instance without the endpoint, or without Thingiverse connected, just says no. */
/** Best-effort: an instance without the endpoint, or without Thingiverse or MyMiniFactory connected, just says no. */
export async function reportProviderAccount(provider: AccountProvider, value: string): Promise<null> {
const config = await requireConfig().catch(() => null);
if (!config) return null;
+1 -1
View File
@@ -1,4 +1,4 @@
// Content script entry point (MakerWorld, Thingiverse, Printables). Network calls go through the
// Content script entry point (MakerWorld, Thingiverse, Printables, and MyMiniFactory once turned on). Network calls go through the
// background (see runtime.ts).
import type { ImportStatus } from "../shared/api";
+7 -2
View File
@@ -1,4 +1,4 @@
// Batch flow for Thingiverse Likes/Collections and Printables Collections. MakerWorld collections
// Batch flow for Thingiverse Likes/Collections and Printables and MyMiniFactory Collections. MakerWorld collections
// use the guided flow in makerworldCollection.ts.
import type { BatchEntriesResult, ImportJob, SyncLookup } from "../../shared/api";
@@ -11,7 +11,7 @@ import { applySyncToggle, loadSyncState, readSyncToggle, syncChangeNote, syncTog
import { printablesToken, readCollectionForPanel } from "../printables";
import { pushPrintablesNow } from "../providerAccount";
type BatchKey = "thingiverse:likes" | "thingiverse:collection" | "printables:collection";
type BatchKey = "thingiverse:likes" | "thingiverse:collection" | "printables:collection" | "myminifactory:collection";
const BATCH_ENDPOINTS: Record<BatchKey, { entries: string; start: string; idField: string }> = {
"thingiverse:likes": {
@@ -29,6 +29,11 @@ const BATCH_ENDPOINTS: Record<BatchKey, { entries: string; start: string; idFiel
start: "/import/printables-collection",
idField: "model_ids",
},
"myminifactory:collection": {
entries: "/import/myminifactory-collection/entries",
start: "/import/myminifactory-collection",
idField: "object_ids",
},
};
function endpoints() {
@@ -11,6 +11,7 @@ export const SITE_NAMES: Record<Provider, string> = {
makerworld: "MakerWorld",
printables: "Printables",
thingiverse: "Thingiverse",
myminifactory: "MyMiniFactory",
};
/** The instance inspects the link: finds the file, reads its title, checks whether it's a zip. */
+18 -2
View File
@@ -1,4 +1,4 @@
// Thingiverse and Printables import directly; other links go through /import/inspect first, like
// Thingiverse, Printables and MyMiniFactory 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";
@@ -37,6 +37,13 @@ function guessPageTitle(): string | null {
return og || document.title.trim() || null;
}
/** A MyMiniFactory object the user hasn't bought: its page has a cart button where free and bought ones have their
* download button. A bought one imports from the user's library. */
function isPaidMyminifactoryObject(): boolean {
const { provider } = ctx().classification;
return provider === "myminifactory" && !document.querySelector('[data-component-name="DownloadButton"]');
}
// Several profile resolutions in a quick burst still look automated to MakerWorld.
const PROFILE_GAP_MS = 2000;
@@ -106,9 +113,18 @@ export async function loadSingleItem(): Promise<void> {
return;
}
const { provider, type } = ctx().classification;
if (isPaidMyminifactoryObject()) {
renderPanel(`
<div class="tg-title">Paid model</div>
<div class="tg-hint">Buy it on MyMiniFactory first. Once it's in your library, Thingport imports it from there.</div>
`);
return;
}
renderFunStatus(checkingLinkPhrases(provider));
const skipInspect =
(provider === "thingiverse" && type === "thing") || (provider === "printables" && type === "model");
(provider === "thingiverse" && type === "thing") ||
(provider === "printables" && type === "model") ||
(provider === "myminifactory" && type === "object");
let zipFilename: string | null = null;
if (skipInspect) {
@@ -11,6 +11,7 @@ const PROVIDER_NAMES: Record<string, string> = {
makerworld: "MakerWorld",
printables: "Printables",
thingiverse: "Thingiverse",
myminifactory: "MyMiniFactory",
};
/** A Thingiverse user's Likes page, which a sync follows like a collection. */
+24 -2
View File
@@ -1,4 +1,4 @@
// Reads who the user is signed in as on Printables and Thingiverse, once per tab, and tells the
// Reads who the user is signed in as on Printables, Thingiverse and MyMiniFactory, once per tab, and tells the
// background (see background/providerAccounts.ts). Only the signed-in account: these pages link to
// plenty of other people's profiles too.
@@ -25,6 +25,18 @@ async function readThingiverseUsername(): Promise<string | null> {
return null;
}
/** MyMiniFactory renders its top menu from data in the page, which names the signed-in user;
* `userDetails` is null when signed out. */
function readMyminifactoryUsername(): string | null {
try {
const data = document.querySelector('[data-component-name="TopMenu"]')?.textContent;
const user = data ? (JSON.parse(data) as { userDetails?: { username?: unknown } | null }).userDetails : null;
return typeof user?.username === "string" && user.username.trim() ? user.username.trim() : null;
} catch {
return null;
}
}
/** Reads and sends what only the user sees on Printables, when it's time (or `now`). */
async function pushPrintables(
token: string,
@@ -66,13 +78,23 @@ export function captureProviderAccount(hostname: string): void {
? "printables"
: hostname.endsWith("thingiverse.com")
? "thingiverse"
: null;
: hostname.endsWith("myminifactory.com")
? "myminifactory"
: null;
if (!provider) return;
captured = true;
if (provider === "printables") {
void syncPrintables();
return;
}
if (provider === "myminifactory") {
const value = readMyminifactoryUsername();
if (!value) return;
void send("REPORT_PROVIDER_ACCOUNT", { provider, value });
// Signed in: its session downloads the paid and login-only models in the user's library.
void send("SYNC_MYMINIFACTORY_SESSION");
return;
}
void readThingiverseUsername().then((value) => {
if (value) void send("REPORT_PROVIDER_ACCOUNT", { provider, value });
});
+3
View File
@@ -13,6 +13,9 @@ export const els = {
enabledSwitch: byId<HTMLInputElement>("enabled-switch"),
recentSection: byId("recent-section"),
recentGrid: byId("recent-grid"),
sitesSection: byId("sites-section"),
sitesText: byId("sites-text"),
sitesBtn: byId<HTMLButtonElement>("sites-btn"),
configuredError: byId("configured-error"),
setupForm: byId<HTMLFormElement>("setup-form"),
instanceUrlInput: byId<HTMLInputElement>("instance-url"),
+2
View File
@@ -6,6 +6,7 @@ import { send, type Connection, type ExtensionState } from "../shared/messages";
import { STORAGE_KEYS, apiUrl, normalizeInstanceUrl } from "../shared/storage";
import { clearError, els, showError } from "./dom";
import { loadRecentImports } from "./recentImports";
import { loadSites } from "./sites";
import "./styles/popup.scss";
// When opened as a tab from a page's setup dialog, return there once saved.
@@ -29,6 +30,7 @@ function showConfiguredView(state: ExtensionState): void {
els.enabledSwitch.checked = !state.disabled;
clearError(els.configuredError);
void loadRecentImports();
void loadSites();
}
function showSetupForm(prefillUrl = "", prefillEmail = ""): void {
+9
View File
@@ -46,6 +46,15 @@
<h2 class="section__title">Recent imports</h2>
<div class="recent-grid" id="recent-grid"></div>
</div>
<div class="section sites" id="sites-section" hidden>
<h2 class="section__title">More sites</h2>
<p class="sites__text" id="sites-text"></p>
<button type="button" class="btn" id="sites-btn">Turn on</button>
<p class="note">
Your browser asks once to allow access to all websites, so sites added in later updates work without asking
again. Thingport Grab only runs on the model sites it supports.
</p>
</div>
<div class="error" id="configured-error" role="alert"></div>
</section>
+44
View File
@@ -0,0 +1,44 @@
// "More sites": a one-time button, shown while some site Grab supports isn't allowed yet. It asks the browser for
// access to all sites, so later ones need no new prompt; registering Grab there follows in the background, from the
// browser's permission event. It comes back only if that access is taken back.
import { ALL_SITES_ORIGINS, sitesWithoutAccess } from "../shared/optionalSites";
import { clearError, els, showError } from "./dom";
const LEAVE_MS = 200;
function listNames(names: string[]): string {
return names.length < 2 ? (names[0] ?? "") : `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
}
function hideSection(): void {
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
els.sitesSection.classList.add("sites--leaving");
window.setTimeout(
() => {
els.sitesSection.hidden = true;
els.sitesSection.classList.remove("sites--leaving");
},
reduced ? 0 : LEAVE_MS,
);
}
export async function loadSites(): Promise<void> {
const missing = await sitesWithoutAccess();
els.sitesSection.hidden = missing.length === 0;
els.sitesText.textContent = `Use Thingport Grab on ${listNames(missing.map((site) => site.name))} too, and on sites added in later updates.`;
}
els.sitesBtn.addEventListener("click", async () => {
clearError(els.configuredError);
els.sitesBtn.disabled = true;
try {
// Straight away: permissions.request() needs this click's user gesture.
if (await chrome.permissions.request({ origins: ALL_SITES_ORIGINS })) hideSection();
else showError("Thingport Grab needs your browser's permission to run on these sites.", els.configuredError);
} catch (err) {
showError(err instanceof Error ? err.message : String(err), els.configuredError);
} finally {
els.sitesBtn.disabled = false;
}
});
+19
View File
@@ -107,6 +107,25 @@ body {
}
}
// Gone for good once access is granted, fading out as it goes.
.sites {
transition: opacity 0.2s ease;
&--leaving {
opacity: 0;
}
&__text {
margin: 0;
font-size: t.$font-size-sm;
line-height: 1.4;
}
@media (prefers-reduced-motion: reduce) {
transition: none;
}
}
.error {
display: none;
margin-top: t.$space-2;
+3 -1
View File
@@ -27,7 +27,7 @@ export type Connection =
/** Providers whose signed-in user the extension tells the instance, for its Browse tab. MakerWorld
* needs none: the instance has its session cookie. */
export type AccountProvider = "printables" | "thingiverse";
export type AccountProvider = "printables" | "thingiverse" | "myminifactory";
/** `wanted`: the private collections whose models the instance asked for. `likes`: whether to read
* every page of the user's likes this time. */
@@ -108,6 +108,8 @@ export type BackgroundMessages = {
GET_PRINTABLES_TOKEN: { payload: void; result: string | null };
/** Who the user is signed in as on a provider, read from its pages. */
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 };
/** 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 };
+60
View File
@@ -0,0 +1,60 @@
// Sites Grab works on only once the user allows it. A new provider every week, each added to the manifest, would
// have the browser disable Grab for everyone after every update until they approved it again. So access to these is
// asked for once, at runtime, for all sites (the manifest's optional_host_permissions), and each release just
// registers the content script for its new site. It only ever runs on the sites listed here.
export type OptionalSite = { id: "myminifactory"; name: string; origins: string[] };
export const OPTIONAL_SITES: readonly OptionalSite[] = [
{ id: "myminifactory", name: "MyMiniFactory", origins: ["*://*.myminifactory.com/*"] },
];
/** What the popup's one-time button asks for: every site, so sites added in later updates need no new prompt. */
export const ALL_SITES_ORIGINS = ["*://*/*"];
const scriptId = (site: OptionalSite) => `site-${site.id}`;
export function siteEnabled(site: OptionalSite): Promise<boolean> {
return chrome.permissions.contains({ origins: site.origins });
}
/** The sites Grab can't run on yet: none once all-sites access is granted, or after only some were. */
export async function sitesWithoutAccess(): Promise<OptionalSite[]> {
const access = await Promise.all(OPTIONAL_SITES.map(siteEnabled));
return OPTIONAL_SITES.filter((_, index) => !access[index]);
}
/** Registers the content script for every site whose access is granted, and drops it for the rest. Safe to run
* any time: from startup, and whenever access is granted or taken back, in the browser's own settings too. */
export async function syncSiteScripts(): Promise<void> {
const registered = new Set((await chrome.scripting.getRegisteredContentScripts()).map((script) => script.id));
for (const site of OPTIONAL_SITES) {
const id = scriptId(site);
const granted = await siteEnabled(site);
try {
if (granted && !registered.has(id)) {
await chrome.scripting.registerContentScripts([
{ id, matches: site.origins, js: ["content.js"], runAt: "document_idle" },
]);
} else if (!granted && registered.has(id)) {
await chrome.scripting.unregisterContentScripts({ ids: [id] });
}
} catch (err) {
// Two wake-ups racing to register the same script; the other one did it.
console.warn(`[sites] ${site.id}:`, err);
}
}
}
/** Runs Grab in the site's tabs already open: a registration only reaches pages loaded after it. The content
* script guards against running twice. */
export async function injectIntoOpenTabs(site: OptionalSite): Promise<void> {
const tabs = await chrome.tabs.query({ url: site.origins });
await Promise.all(
tabs.map((tab) =>
tab.id == null
? undefined
: chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ["content.js"] }).catch(() => undefined),
),
);
}
+3
View File
@@ -18,6 +18,8 @@ export const STORAGE_KEYS = {
loginVerifiedAt: "loginVerifiedAt",
// Lets the cookie sync skip a redundant PATCH.
lastSyncedMakerworldCookie: "lastSyncedMakerworldCookie",
// The MyMiniFactory session last sent, and to which Thingport account.
lastSyncedMyminifactorySession: "lastSyncedMyminifactorySession",
// 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.
@@ -40,6 +42,7 @@ export type StoredConfig = {
tokenExpiresAt?: number;
loginVerifiedAt?: number;
lastSyncedMakerworldCookie?: string;
lastSyncedMyminifactorySession?: { account: string; value: string };
/** `account` is the Thingport instance and email they were sent to. */
lastSentProviderAccounts?: { account: string; values: Record<string, string> };
lastPrintablesPushAt?: number;
+23 -2
View File
@@ -1,14 +1,16 @@
// Provider URL recognition, duplicated from the frontend and backend parsers. Keep in sync by hand.
export type Provider = "makerworld" | "thingiverse" | "printables";
export type Provider = "makerworld" | "thingiverse" | "printables" | "myminifactory";
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: "batch"; provider: "makerworld"; type: "collection" }
| { kind: "batch"; provider: "thingiverse"; type: "likes" | "collection" }
| { kind: "batch"; provider: "printables"; type: "collection" };
| { kind: "batch"; provider: "printables"; type: "collection" }
| { kind: "batch"; provider: "myminifactory"; type: "collection" };
function parse(url: string): URL | null {
try {
@@ -77,14 +79,33 @@ export function isPrintablesCollectionUrl(url: string): boolean {
return Boolean(parsed && isHost(parsed, "printables.com") && /\/collections\/\d+/i.test(parsed.pathname));
}
/** `/object/3d-print-<slug>-<id>`, optionally behind a language prefix (`/it/object/...`). */
export function parseMyminifactoryObjectUrl(url: string): { objectId: string } | null {
const parsed = parse(url);
if (!parsed || !isHost(parsed, "myminifactory.com")) return null;
const m = parsed.pathname.match(/^\/(?:[a-z]{2}\/)?object\/(?:[^/]*-)?(\d+)\/?$/i);
return m ? { objectId: m[1] } : null;
}
export function isMyminifactoryCollectionUrl(url: string): boolean {
const parsed = parse(url);
return Boolean(
parsed &&
isHost(parsed, "myminifactory.com") &&
/^\/(?:[a-z]{2}\/)?users\/[^/]+\/collection\/[^/]+\/?$/i.test(parsed.pathname),
);
}
/** Null keeps the icon hidden. "single" pages get a dedup check; "batch" pages always show it. */
export function classifyUrl(url: string): Classification | null {
if (isMakerworldCollectionUrl(url)) return { kind: "batch", provider: "makerworld", type: "collection" };
if (isThingiverseLikesUrl(url)) return { kind: "batch", provider: "thingiverse", type: "likes" };
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" };
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" };
return null;
}
+1 -1
View File
@@ -164,7 +164,7 @@
</div>
<div class="pills">
<span class="pill">MakerWorld</span><span class="pill alt">Printables</span
><span class="pill ghost">Thingiverse</span>
><span class="pill ghost">Thingiverse</span><span class="pill ghost">MyMiniFactory</span>
</div>
<div class="window tg-scope">
+6 -5
View File
@@ -28,18 +28,19 @@
font-size: 34px;
line-height: 1.08;
}
/* Four sites to fit in 376px. */
.pills {
position: absolute;
left: 32px;
bottom: 28px;
gap: 8px;
gap: 6px;
}
.pill {
font-size: 14px;
padding: 5px 11px;
font-size: 12.5px;
padding: 5px 8px;
}
.pill.ghost {
padding: 3px 9px;
padding: 3px 6px;
}
</style>
</head>
@@ -51,7 +52,7 @@
</div>
<div class="pills">
<span class="pill">MakerWorld</span><span class="pill alt">Printables</span
><span class="pill ghost">Thingiverse</span>
><span class="pill ghost">Thingiverse</span><span class="pill ghost">MyMiniFactory</span>
</div>
</body>
</html>
+1 -1
View File
@@ -25,7 +25,7 @@
"sizes": "1400x790",
"type": "image/jpeg",
"form_factor": "wide",
"label": "Models from MakerWorld, Printables and Thingiverse in one library"
"label": "Models from MakerWorld, Printables, Thingiverse and MyMiniFactory in one library"
},
{
"src": "/screenshots/model-3d-preview.jpg",
+13 -1
View File
@@ -22,7 +22,16 @@ export type AdminUser = {
};
export type HealthComponent =
"database" | "storage" | "memory" | "internet" | "flaresolverr" | "thingiverse" | "grab" | "ai" | "smtp";
| "database"
| "storage"
| "memory"
| "internet"
| "flaresolverr"
| "thingiverse"
| "myminifactory"
| "grab"
| "ai"
| "smtp";
export type HealthStatus = "ok" | "warning" | "error" | "not_configured";
export type HealthResult = {
@@ -131,6 +140,9 @@ export type AuthorLookupProblem =
| "thingiverse_no_token"
| "thingiverse_token_rejected"
| "thingiverse_rate_limited"
| "myminifactory_no_key"
| "myminifactory_key_rejected"
| "myminifactory_rate_limited"
| "makerworld_captcha"
| "makerworld_login_rejected";
+3
View File
@@ -18,6 +18,7 @@ export type Category = {
makerworld_cat_ids: string;
thingiverse_cat_ids: string;
printables_cat_ids: string;
myminifactory_cat_ids: string;
};
export type CategoryMetaInput = {
@@ -26,6 +27,7 @@ export type CategoryMetaInput = {
makerworldCatIds: string;
thingiverseCatIds: string;
printablesCatIds: string;
myminifactoryCatIds: string;
};
export const categoriesApi = {
@@ -72,6 +74,7 @@ export const categoriesApi = {
makerworld_cat_ids: meta.makerworldCatIds,
thingiverse_cat_ids: meta.thingiverseCatIds,
printables_cat_ids: meta.printablesCatIds,
myminifactory_cat_ids: meta.myminifactoryCatIds,
}),
});
if (res.status === 401) throw new UnauthorizedError();
+35
View File
@@ -327,6 +327,41 @@ export const importsApi = {
return res.json();
},
listMyminifactoryCollectionEntries: async (payload: ImportLinkPayload): Promise<ImportCollectionEntriesResult> => {
const res = await fetch(`${apiBase()}/import/myminifactory-collection/entries`, {
method: "POST",
headers: authHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify(payload),
});
if (res.status === 401) {
throw new UnauthorizedError();
}
if (!res.ok) {
const message = await readErrorMessage(res, "Could not load this collection");
throw new Error(message);
}
return res.json();
},
/** Returns immediately; ImportJobContext polls the job. */
fromMyminifactoryCollection: async (
payload: ImportLinkPayload & { object_ids: string[] },
): Promise<{ job_id: string }> => {
const res = await fetch(`${apiBase()}/import/myminifactory-collection`, {
method: "POST",
headers: authHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify(payload),
});
if (res.status === 401) {
throw new UnauthorizedError();
}
if (!res.ok) {
const message = await readErrorMessage(res, "MyMiniFactory collection import failed");
throw new Error(message);
}
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();
+1 -1
View File
@@ -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"] as const;
export const BROWSABLE_PROVIDERS = ["makerworld", "printables", "thingiverse", "myminifactory"] as const;
export type BrowsableProvider = (typeof BROWSABLE_PROVIDERS)[number];
/** `connected`: the instance can reach the provider for this user. `account`: who they are there,
+40 -1
View File
@@ -146,7 +146,7 @@ export type DatabaseCredentialsInput = {
password: string;
};
export type ImportProvider = "makerworld" | "printables" | "thingiverse";
export type ImportProvider = "makerworld" | "printables" | "thingiverse" | "myminifactory";
/** An admin can turn a provider off for the whole instance; menus hide it then. */
export type ProviderSetting = { id: ImportProvider; enabled: boolean };
@@ -333,6 +333,45 @@ export const settingsApi = {
return res.json();
},
// Write-only, like Thingiverse's token: GET only reports whether a key is configured.
getMyminifactory: async (): Promise<{ configured: boolean }> => {
const res = await fetch(`${apiBase()}/settings/myminifactory`, { headers: authHeaders() });
assertOk(res, "Failed to load MyMiniFactory settings");
return res.json();
},
updateMyminifactory: async (apiKey: string | null): Promise<{ configured: boolean }> => {
const res = await fetch(`${apiBase()}/settings/myminifactory`, {
method: "POST",
headers: authHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ api_key: apiKey }),
});
if (res.status === 401) throw new UnauthorizedError();
if (!res.ok) {
throw new Error(await readErrorMessage(res, "Failed to update MyMiniFactory settings"));
}
return res.json();
},
// The user's own MyMiniFactory sign-in, for paid and login-only models. Write-only, like MakerWorld's cookie.
getMyminifactorySession: async (): Promise<{ configured: boolean; expired: boolean }> => {
const res = await fetch(`${apiBase()}/settings/myminifactory-session`, { headers: authHeaders() });
assertOk(res, "Failed to load MyMiniFactory settings");
return res.json();
},
/** Checked with MyMiniFactory before it's saved. */
updateMyminifactorySession: async (cookie: string | null): Promise<{ configured: boolean }> => {
const res = await fetch(`${apiBase()}/settings/myminifactory-session`, {
method: "PATCH",
headers: authHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ cookie, verify: true }),
});
if (res.status === 401) throw new UnauthorizedError();
if (!res.ok) throw new Error(await readErrorMessage(res, "Failed to update MyMiniFactory 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"]);
const LOGO_PROVIDERS = new Set<string>(["makerworld", "thingiverse", "printables", "myminifactory"]);
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"]);
const LOGO_PROVIDERS = new Set<string>(["makerworld", "thingiverse", "printables", "myminifactory"]);
/** The provider's logo with a small green tick, slim enough for a toolbar row. Opens the
* collection's sync configuration. */
+4 -2
View File
@@ -33,6 +33,7 @@ import {
IMPORT_LINK_EXAMPLES,
isMakerworldCollectionUrl,
isMakerworldModelUrl,
isMyminifactoryCollectionUrl,
isPrintablesCollectionUrl,
isThingiverseCollectionUrl,
isThingiverseLikesUrl,
@@ -41,7 +42,7 @@ import {
import type { MakerworldProfileScope } from "../../api/imports";
import { allProvidersOff, useDisabledProviders } from "../../hooks/useDisabledProviders";
const IMPORT_PROVIDERS: ImportProviderKey[] = ["makerworld", "thingiverse", "printables"];
const IMPORT_PROVIDERS: ImportProviderKey[] = ["makerworld", "thingiverse", "printables", "myminifactory"];
/** What the search palette's !import command calls. */
export type AddMenuHandle = {
@@ -104,7 +105,8 @@ const AddMenu = React.forwardRef<AddMenuHandle, Props>(function AddMenu(
isMakerworldCollectionUrl(link) ||
isThingiverseLikesUrl(link) ||
isThingiverseCollectionUrl(link) ||
isPrintablesCollectionUrl(link),
isPrintablesCollectionUrl(link) ||
isMyminifactoryCollectionUrl(link),
)
: isMakerworldCollectionUrl(linkValue);
const isMakerworldModel = links.some((link) => isMakerworldModelUrl(link));
@@ -11,6 +11,13 @@ const BADGE = 16;
const hoverBg = (theme: Theme) => (theme.palette.mode === "dark" ? "rgba(255, 255, 255, 0.06)" : "rgba(0, 0, 0, 0.04)");
/** A one-word name like "MyMiniFactory" may break between its capitalised parts, so it wraps onto the second line
* rather than being cut short. */
function breakableName(name: string): React.ReactNode {
const parts = name.split(/(?<=[a-z])(?=[A-Z])/);
return parts.length < 2 ? name : parts.flatMap((part, i) => (i ? [<wbr key={`before-${part}`} />, part] : [part]));
}
export function AppGrid({ children }: { children: React.ReactNode }) {
return <Box sx={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, 76px)", gap: 1 }}>{children}</Box>;
}
@@ -107,7 +114,7 @@ export function AppTile({
color: on ? "text.primary" : "text.secondary",
}}
>
{name}
{breakableName(name)}
</Typography>
{subtitle && (
<Typography
@@ -28,9 +28,16 @@ const PROVIDERS: { id: ProviderId; name: string; comingSoon?: boolean }[] = [
{ id: "makerworld", name: "MakerWorld" },
{ id: "thingiverse", name: "Thingiverse" },
{ id: "printables", name: "Printables" },
{ id: "myminifactory", name: "MyMiniFactory" },
{ id: "thangs", name: "Thangs", comingSoon: true },
];
const isComingSoon = (id: ProviderId) => PROVIDERS.some((provider) => provider.id === id && provider.comingSoon);
// 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";
type Props = {
isAdmin: boolean;
@@ -46,15 +53,20 @@ export default function ProvidersPanel({ isAdmin, cookie, onUpdateMakerWorld, on
const disabledProviders = useDisabledProviders();
const { t } = useTranslation(["app", "common"]);
const confirm = useConfirm();
// Thingiverse is unknown until loaded, so a non-admin doesn't briefly see it locked.
// The shared ones are unknown until loaded, so a non-admin doesn't briefly see them locked.
const [connected, setConnected] = React.useState<Record<ProviderId, boolean | null>>({
makerworld: Boolean(cookie.trim()),
thingiverse: null,
printables: true,
myminifactory: null,
thangs: false,
});
const [menu, setMenu] = React.useState<{ id: ProviderId; top: number; left: number } | null>(null);
const [makerworldExpired, setMakerworldExpired] = React.useState(false);
const [expired, setExpired] = React.useState<Record<SessionProvider, boolean>>({
makerworld: false,
myminifactory: false,
});
const isExpired = (id: ProviderId) => isSessionProvider(id) && Boolean(connected[id]) && expired[id];
const [connecting, setConnecting] = React.useState<ProviderId | null>(null);
const [error, setError] = React.useState<string | null>(null);
const [notice, setNotice] = React.useState<string | null>(null);
@@ -66,13 +78,21 @@ export default function ProvidersPanel({ isAdmin, cookie, onUpdateMakerWorld, on
.then((res) => {
if (!active) return;
setConnected((current) => ({ ...current, makerworld: res.configured }));
setMakerworldExpired(res.expired);
setExpired((current) => ({ ...current, makerworld: res.expired }));
})
.catch(() => undefined);
settingsApi
.getThingiverse()
.then((res) => active && setConnected((current) => ({ ...current, thingiverse: res.configured })))
.catch(() => undefined);
settingsApi
.getMyminifactorySession()
.then((res) => {
if (!active) return;
setConnected((current) => ({ ...current, myminifactory: res.configured }));
setExpired((current) => ({ ...current, myminifactory: res.expired }));
})
.catch(() => undefined);
return () => {
active = false;
};
@@ -83,7 +103,7 @@ export default function ProvidersPanel({ isAdmin, cookie, onUpdateMakerWorld, on
const lockedReason = (id: ProviderId) => {
if (isComingSoon(id)) return t("providers.comingSoon");
if (id === "printables") return t("providers.printables.alwaysConnected");
if (id === "thingiverse" && !isAdmin) return t("providers.thingiverse.adminOnly");
if (SHARED.has(id) && !isAdmin) return t(`providers.${id}.adminOnly`);
return null;
};
@@ -93,10 +113,14 @@ export default function ProvidersPanel({ isAdmin, cookie, onUpdateMakerWorld, on
const result = await settingsApi.updateMakerworld(value);
onUpdateMakerWorld({ cookie: value ?? "" });
setConnected((current) => ({ ...current, makerworld: result.configured }));
setMakerworldExpired(false);
setExpired((current) => ({ ...current, makerworld: false }));
} else if (id === "thingiverse") {
const result = await settingsApi.updateThingiverse(value);
setConnected((current) => ({ ...current, thingiverse: result.configured }));
} else if (id === "myminifactory") {
const result = await settingsApi.updateMyminifactorySession(value);
setConnected((current) => ({ ...current, myminifactory: result.configured }));
setExpired((current) => ({ ...current, myminifactory: false }));
}
};
@@ -113,7 +137,7 @@ export default function ProvidersPanel({ isAdmin, cookie, onUpdateMakerWorld, on
if (locked) {
setConnecting(null);
setNotice(locked);
} else if (connected[id] === false || (id === "makerworld" && makerworldExpired)) {
} else if (connected[id] === false || isExpired(id)) {
startConnect(id);
}
};
@@ -122,10 +146,9 @@ export default function ProvidersPanel({ isAdmin, cookie, onUpdateMakerWorld, on
const name = nameOf(id);
const ok = await confirm({
title: t("providers.disconnectTitle", { name }),
message:
id === "thingiverse"
? t("providers.disconnectMessageShared", { name })
: t("providers.disconnectMessage", { name }),
message: SHARED.has(id)
? t("providers.disconnectMessageShared", { name })
: t("providers.disconnectMessage", { name }),
confirmLabel: t("providers.disconnect"),
destructive: true,
});
@@ -148,7 +171,7 @@ export default function ProvidersPanel({ isAdmin, cookie, onUpdateMakerWorld, on
const menuId = menu?.id;
const menuConnected = menuId ? Boolean(connected[menuId]) : false;
// Still connected, so it can be disconnected, but offered a fresh connect first.
const menuExpired = menuId === "makerworld" && menuConnected && makerworldExpired;
const menuExpired = menuId ? isExpired(menuId) : false;
const menuLocked = menuId ? lockedReason(menuId) : null;
return (
@@ -158,17 +181,19 @@ export default function ProvidersPanel({ isAdmin, cookie, onUpdateMakerWorld, on
{PROVIDERS.filter(({ id }) => !disabledProviders.has(id as ImportProvider)).map(({ id, name }) => {
const isConnected = Boolean(connected[id]);
const locked = lockedReason(id);
const expired = id === "makerworld" && isConnected && makerworldExpired;
const status = connected[id] === null ? null : isConnected && !expired;
const tileExpired = isExpired(id);
const status = connected[id] === null ? null : isConnected && !tileExpired;
const tooltip = isComingSoon(id)
? t("providers.comingSoon")
: expired
? t("providers.makerworld.expired")
: id === "thingiverse" && locked && !isConnected
: tileExpired
? t(`providers.${id}.expired`)
: SHARED.has(id) && locked && !isConnected
? locked
: isConnected
? t("providers.connected")
: t("providers.notConnected");
: id === "myminifactory"
? t("providers.myminifactory.notConnected")
: t("providers.notConnected");
return (
<AppTile
key={id}
@@ -178,7 +203,7 @@ export default function ProvidersPanel({ isAdmin, cookie, onUpdateMakerWorld, on
badge={status === null || isComingSoon(id) ? undefined : status}
highlighted={connecting === id || menuId === id}
tooltip={status === null ? undefined : tooltip}
ariaLabel={`${name}: ${expired ? t("providers.makerworld.expired") : isConnected ? t("providers.connected") : t("providers.notConnected")}`}
ariaLabel={`${name}: ${tileExpired ? t(`providers.${id}.expired`) : isConnected ? t("providers.connected") : t("providers.notConnected")}`}
onContextMenu={(event) => openMenu(id, event)}
onActivate={() => activate(id)}
/>
@@ -202,7 +227,7 @@ export default function ProvidersPanel({ isAdmin, cookie, onUpdateMakerWorld, on
isComingSoon(menuId)
? t("providers.comingSoon")
: menuExpired
? t("providers.makerworld.expiredStatus")
? t(`providers.${menuId}.expiredStatus`)
: menuConnected
? t("providers.connected")
: t("providers.notConnected")
@@ -337,9 +362,9 @@ function ConnectBox({ id, name, onCancel, onConnect, onUnauthorized }: ConnectBo
<li key={step}>{step}</li>
))}
</Box>
{id === "thingiverse" && (
{SHARED.has(id) && (
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mb: 1.5 }}>
{t("providers.thingiverse.shared")}
{t(`providers.${id}.shared`)}
</Typography>
)}
<TextField
@@ -1,6 +1,6 @@
// Glyphs from Simple Icons (CC0). MakerWorld has none of its own there, so it takes Bambu Lab's,
// whose site it is.
export type ProviderId = "makerworld" | "thingiverse" | "printables" | "thangs";
// whose site it is. MyMiniFactory isn't there either; its glyph is its own favicon, scaled to 24.
export type ProviderId = "makerworld" | "thingiverse" | "printables" | "myminifactory" | "thangs";
const LOGOS: Record<ProviderId, { path: string; color: string }> = {
makerworld: {
@@ -19,6 +19,10 @@ const LOGOS: Record<ProviderId, { path: string; color: string }> = {
color: "#FA6831",
path: "M3.678 4.8 12 9.6v9.6l8.322-4.8V4.8L12 0ZM12 19.2l-8.322-4.8V24Z",
},
myminifactory: {
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",
},
};
export function ProviderLogo({ id, size }: { id: ProviderId; size: number }) {
@@ -83,7 +83,9 @@ export const SEARCH_PAGES: SearchPage[] = [
"makerworld",
"printables",
"thingiverse",
"myminifactory",
"app token",
"api key",
"access token",
]),
adminPage("adminStorage", "/admin-storage", "app:adminSettings.storageSettings.heading", [
@@ -9,6 +9,7 @@ type StartZipImportPayload = Parameters<typeof importsApi.zipFromLink>[0];
type StartThingiverseLikesImportPayload = Parameters<typeof importsApi.fromThingiverseLikes>[0];
type StartThingiverseCollectionImportPayload = Parameters<typeof importsApi.fromThingiverseCollection>[0];
type StartPrintablesCollectionImportPayload = Parameters<typeof importsApi.fromPrintablesCollection>[0];
type StartMyminifactoryCollectionImportPayload = Parameters<typeof importsApi.fromMyminifactoryCollection>[0];
type StartMakerworldProfilesImportPayload = Parameters<typeof importsApi.fromMakerworldProfiles>[0];
type StartLinksImportPayload = Parameters<typeof importsApi.fromLinks>[0];
@@ -21,6 +22,7 @@ type ImportJobContextValue = {
startThingiverseLikesImport: (payload: StartThingiverseLikesImportPayload) => Promise<void>;
startThingiverseCollectionImport: (payload: StartThingiverseCollectionImportPayload) => Promise<void>;
startPrintablesCollectionImport: (payload: StartPrintablesCollectionImportPayload) => Promise<void>;
startMyminifactoryCollectionImport: (payload: StartMyminifactoryCollectionImportPayload) => Promise<void>;
startMakerworldProfilesImport: (payload: StartMakerworldProfilesImportPayload) => Promise<void>;
startLinksImport: (payload: StartLinksImportPayload) => Promise<void>;
/** Reruns the links that failed on the finished job shown in the progress bar. */
@@ -169,6 +171,16 @@ export function ImportJobProvider({
[startPolling],
);
const startMyminifactoryCollectionImport = useCallback(
async (payload: StartMyminifactoryCollectionImportPayload) => {
const { job_id } = await importsApi.fromMyminifactoryCollection(payload);
const job = await importsApi.getImportJob(job_id);
setActiveJob(job);
startPolling(job_id);
},
[startPolling],
);
const startMakerworldProfilesImport = useCallback(
async (payload: StartMakerworldProfilesImportPayload) => {
const { job_id } = await importsApi.fromMakerworldProfiles(payload);
@@ -211,6 +223,7 @@ export function ImportJobProvider({
startThingiverseLikesImport,
startThingiverseCollectionImport,
startPrintablesCollectionImport,
startMyminifactoryCollectionImport,
startMakerworldProfilesImport,
startLinksImport,
retryJob,
@@ -223,6 +236,7 @@ export function ImportJobProvider({
startThingiverseLikesImport,
startThingiverseCollectionImport,
startPrintablesCollectionImport,
startMyminifactoryCollectionImport,
startMakerworldProfilesImport,
startLinksImport,
retryJob,
+14 -1
View File
@@ -25,7 +25,7 @@ import { settingsApi } from "../../api/settings";
import { useDisabledProviders } from "../../hooks/useDisabledProviders";
type ServiceChipDef = {
key: "makerworld" | "thingiverse" | "printables";
key: "makerworld" | "thingiverse" | "printables" | "myminifactory";
label: string;
color: string;
connected: boolean;
@@ -50,6 +50,7 @@ export function UserMenu({ user, onOpenConfiguration, onOpenProfile, onLogout }:
const [makerworldConfigured, setMakerworldConfigured] = React.useState(false);
const [makerworldExpired, setMakerworldExpired] = React.useState(false);
const [thingiverseConfigured, setThingiverseConfigured] = React.useState(false);
const [myminifactoryConfigured, setMyminifactoryConfigured] = React.useState(false);
const menuOpen = Boolean(anchorEl);
// Checked on every open: the cookie can change in Configuration while the menu stays mounted.
@@ -70,6 +71,12 @@ export function UserMenu({ user, onOpenConfiguration, onOpenProfile, onLogout }:
if (active) setThingiverseConfigured(res.configured);
})
.catch(() => undefined);
settingsApi
.getMyminifactory()
.then((res) => {
if (active) setMyminifactoryConfigured(res.configured);
})
.catch(() => undefined);
return () => {
active = false;
};
@@ -93,6 +100,12 @@ export function UserMenu({ user, onOpenConfiguration, onOpenProfile, onLogout }:
},
// Printables' public API needs no configuration.
{ key: "printables", ...IMPORT_PROVIDER_INFO.printables, connected: true },
{
key: "myminifactory",
...IMPORT_PROVIDER_INFO.myminifactory,
connected: myminifactoryConfigured,
disabledReason: t("userMenu.myminifactoryDisabledReason"),
},
];
// Turned off by an admin: no chip, and with none left, no row.
const services = allServices.filter((service) => !disabledProviders.has(service.key));
@@ -23,6 +23,8 @@ import { useToast } from "../ToastProvider";
import {
isMakerworldModelUrl,
isMakerworldCollectionUrl,
isMyminifactoryCollectionUrl,
isMyminifactoryObjectUrl,
isPrintablesCollectionUrl,
isPrintablesModelUrl,
isThingiverseCollectionUrl,
@@ -60,6 +62,7 @@ export function useUploadImport({ onUploaded, categoryId, makerworldCookie, onUn
startThingiverseLikesImport,
startThingiverseCollectionImport,
startPrintablesCollectionImport,
startMyminifactoryCollectionImport,
startMakerworldProfilesImport,
startLinksImport,
} = useImportJob();
@@ -358,7 +361,34 @@ export function useUploadImport({ onUploaded, categoryId, makerworldCookie, onUn
return;
}
if (isThingiverseThingUrl(url) || isPrintablesModelUrl(url)) {
if (isMyminifactoryCollectionUrl(url)) {
setImporting(false);
await collectionPrompt.prompt({
label: url,
loadEntries: async () => {
try {
return await importsApi.listMyminifactoryCollectionEntries(payload);
} catch (err) {
if (err instanceof UnauthorizedError) onUnauthorized?.();
throw err;
}
},
onImportSelected: async (objectIds: string[]) => {
try {
await startMyminifactoryCollectionImport({ ...payload, object_ids: objectIds });
} catch (err) {
if (err instanceof UnauthorizedError) {
onUnauthorized?.();
return;
}
throw err;
}
},
});
return;
}
if (isThingiverseThingUrl(url) || isPrintablesModelUrl(url) || isMyminifactoryObjectUrl(url)) {
// The backend splits these into plates itself.
const imported = await importsApi.fromLink(payload);
showImportedToast(imported);
@@ -8,6 +8,8 @@ export const IMPORT_PROVIDER_INFO: Record<string, ImportProviderInfo> = {
makerworld: { label: "MakerWorld", color: "#00B800" },
thingiverse: { label: "Thingiverse", color: "#2B78FE" },
printables: { label: "Printables", color: "#FA6831" },
// A darker shade of MyMiniFactory's #00C4A6, so the white label stays readable.
myminifactory: { label: "MyMiniFactory", color: "#00866F" },
// 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: {
+1 -1
View File
@@ -20,7 +20,7 @@ export function publishProviders(providers: ProviderSetting[]): void {
}
const NONE: ReadonlySet<ImportProvider> = new Set();
const ALL: readonly ImportProvider[] = ["makerworld", "printables", "thingiverse"];
const ALL: readonly ImportProvider[] = ["makerworld", "printables", "thingiverse", "myminifactory"];
/** 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 {
+73 -15
View File
@@ -95,7 +95,7 @@
},
"uploadBar": {
"uploading": "Uploading…",
"linkPlaceholder": "Paste model link (MakerWorld, Printables, Thingiverse...)",
"linkPlaceholder": "Paste model link (MakerWorld, Printables, Thingiverse, MyMiniFactory...)",
"importing": "Importing…",
"importFailed": "Import failed",
"uploadFailed": "Failed to upload: {{files}}",
@@ -268,6 +268,7 @@
"serviceNotConnected": "{{service}}: not connected",
"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",
"myModels": "My models"
},
"notifications": {
@@ -330,7 +331,7 @@
},
"import": "Import from a link",
"importFrom": "Import from {{provider}}",
"importNotSupported": "Not a MakerWorld, Printables or Thingiverse link; opens the import window",
"importNotSupported": "Not a MakerWorld, Printables, Thingiverse or MyMiniFactory link; opens the import window",
"language": "Language: {{name}}",
"appearance": "Open Appearance",
"providers": "Open Providers",
@@ -529,11 +530,19 @@
"descriptions": {
"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."
"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."
},
"keyStatus": {
"thingiverse": {
"set": "App Token set",
"missing": "No App Token: Thingiverse imports won't work until one is set"
},
"myminifactory": {
"set": "API key set",
"missing": "No API key: MyMiniFactory imports won't work until one is set"
}
},
"tokenSet": "App Token set",
"tokenMissing": "No App Token: Thingiverse imports won't work until one is set",
"tokenSettings": "Thingiverse App Token",
"turnOff": "Turn {{name}} off",
"turnOn": "Turn {{name}} on",
"disableTitle": "Turn {{name}} off?",
@@ -646,9 +655,21 @@
"timeout": "Thingiverse didn't answer in time. Try again later."
}
},
"myminifactory": {
"name": "MyMiniFactory",
"description": "The instance's MyMiniFactory API key, used to read models, sync collections and link authors from MyMiniFactory. Free models download without it.",
"problems": {
"disabled": "Turned off under Providers, so nothing is checked. Turn MyMiniFactory back on there to use it.",
"not_configured": "No API key set. MyMiniFactory imports and syncs won't work until one is added in Settings (create an app at myminifactory.com/settings/developer/application).",
"token_rejected": "MyMiniFactory rejected the API key. Check it under myminifactory.com/settings/developer and save it in Settings again.",
"blocked": "MyMiniFactory answered with a Cloudflare challenge, which says nothing about the key. Set up FlareSolverr, or wait: it usually clears after a while.",
"unreachable": "Thingport couldn't reach MyMiniFactory. Check the server's internet access, or try again later.",
"timeout": "MyMiniFactory didn't answer in time. Try again later."
}
},
"grab": {
"name": "Thingport Grab",
"description": "The browser extension for importing models from MakerWorld, Printables and Thingiverse while you browse them, including whole collections, with no links to copy. It also brings over Printables likes, which only you can see.",
"description": "The browser extension for importing models from MakerWorld, Printables, Thingiverse and MyMiniFactory while you browse them, including whole collections, with no links to copy. It also brings over Printables likes, which only you can see.",
"problems": {
"not_seen": "No browser has used Grab with this instance yet. Install it from Downloads and connect it to Thingport. It's the easiest way to import, and some sites only let a browser get their files."
}
@@ -839,11 +860,14 @@
"thingiverse_no_token": "Thingiverse models were skipped: add a Thingiverse App Token in Administration > Providers.",
"thingiverse_token_rejected": "Thingiverse models were skipped: Thingiverse rejected the App Token in Administration > Providers.",
"thingiverse_rate_limited": "Thingiverse models were skipped: Thingiverse started blocking requests with a rate limit. It usually clears after a while — try again later.",
"myminifactory_no_key": "MyMiniFactory models were skipped: add a MyMiniFactory API key in Administration > Providers.",
"myminifactory_key_rejected": "MyMiniFactory models were skipped: MyMiniFactory rejected the API key in Administration > Providers.",
"myminifactory_rate_limited": "MyMiniFactory models were skipped: MyMiniFactory started blocking requests. It usually clears after a while — try again later.",
"makerworld_captcha": "MakerWorld models were skipped: MakerWorld asked for a CAPTCHA, and blocks further requests for about 2 hours. Try again later.",
"makerworld_login_rejected": "MakerWorld models were skipped: MakerWorld rejected the saved MakerWorld login. Update it in Configuration > Providers."
},
"refetchTitle": "Refetch model descriptions",
"refetchDesc": "Replaces the description of every model a user imported from MakerWorld, Printables or Thingiverse with the current one from that site, with its formatting and images. Use it to bring models imported before formatted descriptions were supported up to date. Changes the user made to those descriptions are lost. Models whose source is gone keep their description. It runs in the background — MakerWorld requests are spaced out to avoid its CAPTCHA, so a large library can take a while.",
"refetchDesc": "Replaces the description of every model a user imported from MakerWorld, Printables, Thingiverse or MyMiniFactory with the current one from that site, with its formatting and images. Use it to bring models imported before formatted descriptions were supported up to date. Changes the user made to those descriptions are lost. Models whose source is gone keep their description. It runs in the background — MakerWorld requests are spaced out to avoid its CAPTCHA, so a large library can take a while.",
"refetchUserOption": "{{name}} ({{email}}) — {{count}} imported models",
"refetchButton": "Refetch descriptions…",
"refetchConfirm": "Replace the descriptions of {{count}} imported model(s) belonging to {{email}} with the ones from their source sites? Any changes made to those descriptions will be lost.",
@@ -868,6 +892,21 @@
"cleared": "App Token removed.",
"failed": "Failed to save Thingiverse settings."
},
"myminifactory": {
"heading": "MyMiniFactory API key",
"helpText": "Create an app at myminifactory.com/settings/developer/application and paste the API key it shows you. Free models download without it, but it's needed to read their details and your collections.",
"statusConfigured": "An API key is currently configured.",
"statusNotConfigured": "No API key is configured yet -- MyMiniFactory imports will fail until one is set.",
"tokenLabel": "API key",
"tokenPlaceholder": "Paste a new key to replace the current one",
"tokenHelperConfigured": "For security, the current key is never shown again -- leave this blank to keep it, or paste a new one to replace it.",
"save": "Save",
"saving": "Saving…",
"clear": "Remove key",
"saved": "Saved.",
"cleared": "API key removed.",
"failed": "Failed to save MyMiniFactory settings."
},
"ai": {
"heading": "AI",
"connection": {
@@ -1417,7 +1456,7 @@
"intro": "Companion tools that work alongside your Thingport instance.",
"extension": {
"heading": "Thingport Grab",
"intro": "A browser extension that imports MakerWorld, Thingiverse, and Printables models straight from their own pages.",
"intro": "A browser extension that imports MakerWorld, Thingiverse, Printables and MyMiniFactory models straight from their own pages.",
"name": "Thingport Grab",
"store": {
"chrome": "Get from Chrome Web Store",
@@ -1556,6 +1595,17 @@
"shared": "This token is shared by everyone on this Thingport.",
"adminOnly": "Only an admin of this Thingport can set this up."
},
"myminifactory": {
"placeholder": "PHPSESSID value, or the whole Cookie header",
"steps": [
"Easiest: with Thingport Grab, just open myminifactory.com while signed in, and it connects your account for you.",
"Or by hand: sign in at myminifactory.com and open your browser's developer tools (F12).",
"Under Application (Chrome, Edge) or Storage (Firefox) → Cookies → www.myminifactory.com, copy the value of PHPSESSID and paste it below."
],
"notConnected": "Free models import without it. Connect your account to import the models you bought, and free ones that need a sign-in.",
"expired": "Sign-in expired. Visit myminifactory.com with Thingport Grab, or connect MyMiniFactory again in Configuration → Providers.",
"expiredStatus": "Sign-in expired"
},
"printables": {
"alwaysConnected": "Printables is connected by default: it needs no account or key."
},
@@ -2501,29 +2551,35 @@
"models_other": "{{count}} models",
"things_one": "{{count}} Thing",
"things_other": "{{count}} Things",
"objects_one": "{{count}} object",
"objects_other": "{{count}} objects",
"takenBy": "Synced with \"{{name}}\"",
"loadFailed": "Couldn't load the collections.",
"lookupFailed": "Couldn't find that collection.",
"notALink": "Paste a MakerWorld, Printables or Thingiverse collection link.",
"notALink": "Paste a MakerWorld, Printables, Thingiverse or MyMiniFactory 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."
"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."
},
"usernameLabel": "Your {{provider}} username",
"usernameHint": {
"printables": "Thingport lists your public Printables collections. It only needs asking once.",
"thingiverse": "Thingport lists your public Thingiverse collections. It only needs asking once."
"thingiverse": "Thingport lists your public Thingiverse collections. It only needs asking once.",
"myminifactory": "Thingport lists your public MyMiniFactory collections. It only needs asking once."
},
"usernamePlaceholder": {
"printables": "@handle or profile link",
"thingiverse": "Username or profile link"
"thingiverse": "Username or profile link",
"myminifactory": "Username or profile link"
},
"showing": {
"printables": "Showing @{{user}}'s collections.",
"thingiverse": "Showing @{{user}}'s public collections."
"thingiverse": "Showing @{{user}}'s public collections.",
"myminifactory": "Showing @{{user}}'s public collections."
},
"change": "Change",
"empty": "No collections on {{provider}} yet.",
@@ -2545,7 +2601,8 @@
"forgetTitle": "Forget your {{provider}} account?",
"forgetMessage": {
"printables": "Thingport stops listing your Printables collections, and Thingport Grab stops sending your private collections and likes, until you give your username again. Syncs already set up keep their models; those of private collections and likes pause until then.",
"thingiverse": "Thingport stops listing your Thingiverse collections until you give your username again. Syncs already set up keep their models."
"thingiverse": "Thingport stops listing your Thingiverse collections until you give your username again. Syncs already set up keep their models.",
"myminifactory": "Thingport stops listing your MyMiniFactory collections until you give your username again. Syncs already set up keep their models."
}
}
}
@@ -2620,6 +2677,7 @@
"makerworldCatIdLabel": "MakerWorld category IDs",
"thingiverseCatIdLabel": "Thingiverse category IDs",
"printablesCatIdLabel": "Printables category IDs",
"myminifactoryCatIdLabel": "MyMiniFactory 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"
+75 -15
View File
@@ -95,7 +95,7 @@
},
"uploadBar": {
"uploading": "Įkeliama…",
"linkPlaceholder": "Įklijuokite modelio nuorodą (MakerWorld, Printables, Thingiverse...)",
"linkPlaceholder": "Įklijuokite modelio nuorodą (MakerWorld, Printables, Thingiverse, MyMiniFactory...)",
"importing": "Importuojama…",
"importFailed": "Importuoti nepavyko",
"uploadFailed": "Nepavyko įkelti: {{files}}",
@@ -268,6 +268,7 @@
"serviceNotConnected": "{{service}}: neprijungta",
"makerworldDisabledReason": "MakerWorld: neprijungta",
"thingiverseDisabledReason": "Thingiverse: administratorius dar nesukonfigūravo API rakto",
"myminifactoryDisabledReason": "MyMiniFactory: administratorius dar nesukonfigūravo API rakto",
"myModels": "Mano modeliai"
},
"notifications": {
@@ -330,7 +331,7 @@
},
"import": "Importuoti iš nuorodos",
"importFrom": "Importuoti iš {{provider}}",
"importNotSupported": "Ne MakerWorld, Printables ar Thingiverse nuoroda; atidaromas importavimo langas",
"importNotSupported": "Ne MakerWorld, Printables, Thingiverse ar MyMiniFactory nuoroda; atidaromas importavimo langas",
"language": "Kalba: {{name}}",
"appearance": "Atidaryti išvaizdą",
"providers": "Atidaryti šaltinius",
@@ -529,11 +530,19 @@
"descriptions": {
"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ą."
"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ą."
},
"keyStatus": {
"thingiverse": {
"set": "Programėlės raktas nustatytas",
"missing": "Nėra programėlės rakto: Thingiverse importai neveiks, kol jo nenustatysite"
},
"myminifactory": {
"set": "API raktas nustatytas",
"missing": "Nėra API rakto: MyMiniFactory importai neveiks, kol jo nenustatysite"
}
},
"tokenSet": "Programėlės raktas nustatytas",
"tokenMissing": "Nėra programėlės rakto: Thingiverse importai neveiks, kol jo nenustatysite",
"tokenSettings": "Thingiverse programėlės raktas",
"turnOff": "Išjungti {{name}}",
"turnOn": "Įjungti {{name}}",
"disableTitle": "Išjungti {{name}}?",
@@ -647,9 +656,21 @@
"timeout": "Thingiverse laiku neatsakė. Bandykite vėliau."
}
},
"myminifactory": {
"name": "MyMiniFactory",
"description": "Sistemos MyMiniFactory API raktas – modeliams nuskaityti, kolekcijoms sinchronizuoti ir autoriams susieti. Nemokami modeliai atsisiunčiami ir be jo.",
"problems": {
"disabled": "Išjungta skiltyje „Tiekėjai“, todėl netikrinama. Įjunkite MyMiniFactory ten, kad galėtumėte naudoti.",
"not_configured": "API raktas nenurodytas. MyMiniFactory importai ir sinchronizacijos neveiks, kol jo nepridėsite nustatymuose (sukurkite programėlę myminifactory.com/settings/developer/application).",
"token_rejected": "MyMiniFactory atmetė API raktą. Patikrinkite jį myminifactory.com/settings/developer ir vėl išsaugokite nustatymuose.",
"blocked": "MyMiniFactory atsakė Cloudflare patikra – tai nieko nesako apie raktą. Įdiekite FlareSolverr arba palaukite: dažniausiai praeina po kurio laiko.",
"unreachable": "Thingport nepasiekė MyMiniFactory. Patikrinkite serverio interneto ryšį arba bandykite vėliau.",
"timeout": "MyMiniFactory laiku neatsakė. Bandykite vėliau."
}
},
"grab": {
"name": "Thingport Grab",
"description": "Naršyklės plėtinys, kuriuo modelius iš MakerWorld, Printables ir Thingiverse importuojate tiesiog naršydami, net ištisas kolekcijas, be nuorodų kopijavimo. Jis taip pat perkelia Printables patiktukus, kuriuos matote tik jūs.",
"description": "Naršyklės plėtinys, kuriuo modelius iš MakerWorld, Printables, Thingiverse ir MyMiniFactory importuojate tiesiog naršydami, net ištisas kolekcijas, be nuorodų kopijavimo. Jis taip pat perkelia Printables patiktukus, kuriuos matote tik jūs.",
"problems": {
"not_seen": "Su šiuo serveriu Grab dar nenaudota nė vienoje naršyklėje. Įdiekite jį skiltyje „Atsisiuntimai“ ir prijunkite prie Thingport. Taip importuoti paprasčiausia, o kai kurios svetainės failus duoda tik naršyklei."
}
@@ -847,11 +868,14 @@
"thingiverse_no_token": "Thingiverse modeliai praleisti: pridėkite Thingiverse programėlės raktą skiltyje Administravimas > Tiekėjai.",
"thingiverse_token_rejected": "Thingiverse modeliai praleisti: Thingiverse atmetė programėlės raktą, nurodytą skiltyje Administravimas > Tiekėjai.",
"thingiverse_rate_limited": "Thingiverse modeliai praleisti: Thingiverse pradėjo riboti užklausas. Paprastai tai praeina po kurio laiko — bandykite vėliau.",
"myminifactory_no_key": "MyMiniFactory modeliai praleisti: pridėkite MyMiniFactory API raktą skiltyje Administravimas > Tiekėjai.",
"myminifactory_key_rejected": "MyMiniFactory modeliai praleisti: MyMiniFactory atmetė API raktą, nurodytą skiltyje Administravimas > Tiekėjai.",
"myminifactory_rate_limited": "MyMiniFactory modeliai praleisti: MyMiniFactory pradėjo riboti užklausas. Paprastai tai praeina po kurio laiko — bandykite vėliau.",
"makerworld_captcha": "MakerWorld modeliai praleisti: MakerWorld paprašė CAPTCHA ir maždaug 2 valandoms blokuoja tolesnes užklausas. Bandykite vėliau.",
"makerworld_login_rejected": "MakerWorld modeliai praleisti: MakerWorld atmetė išsaugotą MakerWorld prisijungimą. Atnaujinkite jį skiltyje Konfigūracija > Šaltiniai."
},
"refetchTitle": "Iš naujo gauti modelių aprašymus",
"refetchDesc": "Pakeičia kiekvieno naudotojo iš MakerWorld, Printables ar Thingiverse importuoto modelio aprašymą dabartiniu aprašymu iš tos svetainės, su formatavimu ir paveikslėliais. Naudokite tai modeliams, importuotiems prieš atsirandant formatuotų aprašymų palaikymui, atnaujinti. Naudotojo atlikti tų aprašymų pakeitimai bus prarasti. Modeliai, kurių šaltinio nebėra, išlaiko savo aprašymą. Vykdoma fone — MakerWorld užklausos atliekamos su pertraukomis, kad būtų išvengta CAPTCHA, todėl didelė biblioteka gali užtrukti.",
"refetchDesc": "Pakeičia kiekvieno naudotojo iš MakerWorld, Printables, Thingiverse ar MyMiniFactory importuoto modelio aprašymą dabartiniu aprašymu iš tos svetainės, su formatavimu ir paveikslėliais. Naudokite tai modeliams, importuotiems prieš atsirandant formatuotų aprašymų palaikymui, atnaujinti. Naudotojo atlikti tų aprašymų pakeitimai bus prarasti. Modeliai, kurių šaltinio nebėra, išlaiko savo aprašymą. Vykdoma fone — MakerWorld užklausos atliekamos su pertraukomis, kad būtų išvengta CAPTCHA, todėl didelė biblioteka gali užtrukti.",
"refetchUserOption": "{{name}} ({{email}}) — importuotų modelių: {{count}}",
"refetchButton": "Gauti aprašymus iš naujo…",
"refetchConfirm": "Pakeisti {{email}} priklausančių importuotų modelių ({{count}}) aprašymus aprašymais iš jų šaltinių? Visi tų aprašymų pakeitimai bus prarasti.",
@@ -876,6 +900,21 @@
"cleared": "Programėlės raktas pašalintas.",
"failed": "Nepavyko išsaugoti Thingiverse nustatymų."
},
"myminifactory": {
"heading": "MyMiniFactory API raktas",
"helpText": "Sukurkite programėlę adresu myminifactory.com/settings/developer/application ir įklijuokite ten parodytą API raktą. Nemokami modeliai atsisiunčiami ir be jo, bet jis reikalingas jų informacijai ir jūsų kolekcijoms nuskaityti.",
"statusConfigured": "Šiuo metu sukonfigūruotas API raktas.",
"statusNotConfigured": "API raktas dar nesukonfigūruotas -- MyMiniFactory importas neveiks, kol jis nebus nustatytas.",
"tokenLabel": "API raktas",
"tokenPlaceholder": "Įklijuokite naują raktą, kad pakeistumėte dabartinį",
"tokenHelperConfigured": "Saugumo sumetimais dabartinis raktas daugiau nerodomas -- palikite tuščią, kad jį išsaugotumėte, arba įklijuokite naują, kad jį pakeistumėte.",
"save": "Išsaugoti",
"saving": "Saugoma…",
"clear": "Pašalinti raktą",
"saved": "Išsaugota.",
"cleared": "API raktas pašalintas.",
"failed": "Nepavyko išsaugoti MyMiniFactory nustatymų."
},
"ai": {
"heading": "DI",
"connection": {
@@ -1447,7 +1486,7 @@
"intro": "Papildomi įrankiai, veikiantys kartu su jūsų Thingport aplinka.",
"extension": {
"heading": "Thingport Grab",
"intro": "Naršyklės plėtinys, importuojantis MakerWorld, Thingiverse ir Printables modelius tiesiai iš jų puslapių.",
"intro": "Naršyklės plėtinys, importuojantis MakerWorld, Thingiverse, Printables ir MyMiniFactory modelius tiesiai iš jų puslapių.",
"name": "Thingport Grab",
"store": {
"chrome": "Gauti iš Chrome Web Store",
@@ -1611,6 +1650,17 @@
"shared": "Šis raktas bendras visiems šio Thingport naudotojams.",
"adminOnly": "Tai nustatyti gali tik šio Thingport administratorius."
},
"myminifactory": {
"placeholder": "PHPSESSID reikšmė arba visa Cookie antraštė",
"steps": [
"Paprasčiausia: su Thingport Grab tiesiog atverkite myminifactory.com prisijungę, ir jis prijungs jūsų paskyrą pats.",
"Arba rankiniu būdu: prisijunkite prie myminifactory.com ir atverkite naršyklės kūrėjo įrankius (F12).",
"Skiltyje Application (Chrome, Edge) arba Storage (Firefox) → Cookies → www.myminifactory.com nukopijuokite PHPSESSID reikšmę ir įklijuokite ją žemiau."
],
"notConnected": "Nemokami modeliai importuojami ir be jos. Prijunkite paskyrą, kad galėtumėte importuoti nusipirktus modelius ir nemokamus, kuriems reikia prisijungti.",
"expired": "Prisijungimo galiojimas baigėsi. Atverkite myminifactory.com su Thingport Grab arba vėl prijunkite MyMiniFactory skiltyje Konfigūracija → Šaltiniai.",
"expiredStatus": "Prisijungimas nebegalioja"
},
"printables": {
"alwaysConnected": "Printables prijungta iš karto: nereikia paskyros ar rakto."
},
@@ -2569,29 +2619,37 @@
"things_few": "{{count}} modeliai",
"things_many": "{{count}} modelio",
"things_other": "{{count}} modelių",
"objects_one": "{{count}} objektas",
"objects_few": "{{count}} objektai",
"objects_many": "{{count}} objekto",
"objects_other": "{{count}} objektų",
"takenBy": "Sinchronizuojama su „{{name}}“",
"loadFailed": "Nepavyko įkelti kolekcijų.",
"lookupFailed": "Nepavyko rasti šios kolekcijos.",
"notALink": "Įklijuokite MakerWorld, Printables arba Thingiverse kolekcijos nuorodą.",
"notALink": "Įklijuokite MakerWorld, Printables, Thingiverse arba MyMiniFactory 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."
"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."
},
"usernameLabel": "Jūsų {{provider}} vartotojo vardas",
"usernameHint": {
"printables": "Thingport parodys jūsų viešas Printables kolekcijas. Paklausiama tik kartą.",
"thingiverse": "Thingport parodys jūsų viešas Thingiverse kolekcijas. Paklausiama tik kartą."
"thingiverse": "Thingport parodys jūsų viešas Thingiverse kolekcijas. Paklausiama tik kartą.",
"myminifactory": "Thingport parodys jūsų viešas MyMiniFactory kolekcijas. Paklausiama tik kartą."
},
"usernamePlaceholder": {
"printables": "@vardas arba profilio nuoroda",
"thingiverse": "Vartotojo vardas arba profilio nuoroda"
"thingiverse": "Vartotojo vardas arba profilio nuoroda",
"myminifactory": "Vartotojo vardas arba profilio nuoroda"
},
"showing": {
"printables": "Rodomos @{{user}} kolekcijos.",
"thingiverse": "Rodomos viešos @{{user}} kolekcijos."
"thingiverse": "Rodomos viešos @{{user}} kolekcijos.",
"myminifactory": "Rodomos viešos @{{user}} kolekcijos."
},
"change": "Keisti",
"empty": "{{provider}} kolekcijų dar nėra.",
@@ -2615,7 +2673,8 @@
"forgetTitle": "Pamiršti jūsų {{provider}} paskyrą?",
"forgetMessage": {
"printables": "Thingport neberodys jūsų Printables kolekcijų, o Thingport Grab nebesiųs jūsų privačių kolekcijų ir pamėgtų modelių, kol vėl nurodysite vartotojo vardą. Jau nustatyti sinchronizavimai išsaugos savo modelius; privačių kolekcijų ir pamėgtų modelių sinchronizavimas iki tol sustos.",
"thingiverse": "Thingport neberodys jūsų Thingiverse kolekcijų, kol vėl nurodysite vartotojo vardą. Jau nustatyti sinchronizavimai išsaugos savo modelius."
"thingiverse": "Thingport neberodys jūsų Thingiverse kolekcijų, kol vėl nurodysite vartotojo vardą. Jau nustatyti sinchronizavimai išsaugos savo modelius.",
"myminifactory": "Thingport neberodys jūsų MyMiniFactory kolekcijų, kol vėl nurodysite vartotojo vardą. Jau nustatyti sinchronizavimai išsaugos savo modelius."
}
}
}
@@ -2692,6 +2751,7 @@
"makerworldCatIdLabel": "MakerWorld kategorijų ID",
"thingiverseCatIdLabel": "Thingiverse kategorijų ID",
"printablesCatIdLabel": "Printables kategorijų ID",
"myminifactoryCatIdLabel": "MyMiniFactory 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"
@@ -50,6 +50,7 @@ const COMPONENTS: Record<HealthComponent, ComponentInfo> = {
internet: { icon: <LanguageIcon />, need: "recommended" },
flaresolverr: { icon: <ShieldOutlinedIcon />, need: "recommended", settings: "/admin-connections?tab=flaresolverr" },
thingiverse: { icon: <CloudOutlinedIcon />, need: "recommended", settings: "/admin-providers" },
myminifactory: { icon: <CloudOutlinedIcon />, need: "optional", settings: "/admin-providers" },
grab: { icon: <ExtensionOutlinedIcon />, need: "recommended", download: "/downloads" },
ai: { icon: <AutoAwesomeOutlinedIcon />, need: "optional", settings: "/admin-ai" },
smtp: { icon: <MailOutlineIcon />, need: "optional", settings: "/admin-connections?tab=smtp" },
@@ -9,16 +9,30 @@ import Typography from "@mui/material/Typography";
import { UnauthorizedError } from "../../api/client";
import { settingsApi } from "../../api/settings";
/** The providers whose access is one key for the whole instance. */
export type KeyedProvider = "thingiverse" | "myminifactory";
const KEY_API: Record<
KeyedProvider,
{ get: () => Promise<{ configured: boolean }>; update: (key: string | null) => Promise<{ configured: boolean }> }
> = {
thingiverse: { get: settingsApi.getThingiverse, update: settingsApi.updateThingiverse },
myminifactory: { get: settingsApi.getMyminifactory, update: settingsApi.updateMyminifactory },
};
type Props = {
provider: KeyedProvider;
onUnauthorized?: () => void;
/** Whether a token is set, whenever that's learned or changes. */
/** Whether a key is set, whenever that's learned or changes. */
onConfiguredChange?: (configured: boolean) => void;
};
/** The Thingiverse token form, in the Providers page's dialog. Instance-wide and write-only: the server only reports
* whether a token is set. */
export default function ThingiverseSection({ onUnauthorized, onConfiguredChange }: Props) {
/** A provider's instance key form (Thingiverse's App Token, MyMiniFactory's API key), in the Providers page's dialog.
* Write-only: the server only reports whether a key is set. */
export default function ProviderKeySection({ provider, onUnauthorized, onConfiguredChange }: Props) {
const { t } = useTranslation("app");
const api = KEY_API[provider];
const k = (key: string) => t(`adminSettings.${provider}.${key}`);
const [configured, setConfigured] = React.useState(false);
const [loading, setLoading] = React.useState(false);
const [draft, setDraft] = React.useState("");
@@ -31,7 +45,7 @@ export default function ThingiverseSection({ onUnauthorized, onConfiguredChange
setLoading(true);
void (async () => {
try {
const data = await settingsApi.getThingiverse();
const data = await api.get();
if (active) setConfigured(data.configured);
} catch (err) {
if (err instanceof UnauthorizedError) onUnauthorized?.();
@@ -42,7 +56,7 @@ export default function ThingiverseSection({ onUnauthorized, onConfiguredChange
return () => {
active = false;
};
}, [onUnauthorized]);
}, [api, onUnauthorized]);
React.useEffect(() => {
if (!loading) onConfiguredChange?.(configured);
@@ -55,15 +69,15 @@ export default function ThingiverseSection({ onUnauthorized, onConfiguredChange
setStatus(null);
setError(null);
try {
const data = await settingsApi.updateThingiverse(trimmed);
const data = await api.update(trimmed);
setConfigured(data.configured);
setDraft("");
setStatus(t("adminSettings.thingiverse.saved"));
setStatus(k("saved"));
} catch (err) {
if (err instanceof UnauthorizedError) {
onUnauthorized?.();
} else {
setError(err instanceof Error ? err.message : t("adminSettings.thingiverse.failed"));
setError(err instanceof Error ? err.message : k("failed"));
}
} finally {
setSaving(false);
@@ -75,15 +89,15 @@ export default function ThingiverseSection({ onUnauthorized, onConfiguredChange
setStatus(null);
setError(null);
try {
const data = await settingsApi.updateThingiverse(null);
const data = await api.update(null);
setConfigured(data.configured);
setDraft("");
setStatus(t("adminSettings.thingiverse.cleared"));
setStatus(k("cleared"));
} catch (err) {
if (err instanceof UnauthorizedError) {
onUnauthorized?.();
} else {
setError(err instanceof Error ? err.message : t("adminSettings.thingiverse.failed"));
setError(err instanceof Error ? err.message : k("failed"));
}
} finally {
setSaving(false);
@@ -93,21 +107,19 @@ export default function ThingiverseSection({ onUnauthorized, onConfiguredChange
return (
<Stack spacing={2}>
<Typography variant="caption" color="text.secondary">
{t("adminSettings.thingiverse.helpText")}
{k("helpText")}
</Typography>
<Alert severity={configured ? "success" : "warning"}>
{configured
? t("adminSettings.thingiverse.statusConfigured")
: t("adminSettings.thingiverse.statusNotConfigured")}
{configured ? k("statusConfigured") : k("statusNotConfigured")}
</Alert>
<TextField
label={t("adminSettings.thingiverse.tokenLabel")}
label={k("tokenLabel")}
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder={t("adminSettings.thingiverse.tokenPlaceholder") ?? undefined}
helperText={configured ? t("adminSettings.thingiverse.tokenHelperConfigured") : undefined}
placeholder={k("tokenPlaceholder") ?? undefined}
helperText={configured ? k("tokenHelperConfigured") : undefined}
disabled={loading || saving}
fullWidth
autoComplete="off"
@@ -115,11 +127,11 @@ export default function ThingiverseSection({ onUnauthorized, onConfiguredChange
<Stack direction="row" spacing={1.5} alignItems="center" flexWrap="wrap" useFlexGap>
<Button variant="contained" onClick={save} disabled={loading || saving || !draft.trim()}>
{saving ? t("adminSettings.thingiverse.saving") : t("adminSettings.thingiverse.save")}
{saving ? k("saving") : k("save")}
</Button>
{configured && (
<Button variant="outlined" color="error" onClick={clear} disabled={loading || saving}>
{t("adminSettings.thingiverse.clear")}
{k("clear")}
</Button>
)}
{saving && <CircularProgress size={14} />}
@@ -20,7 +20,7 @@ import { useConfirm } from "../../components/ConfirmProvider";
import { ProviderLogo } from "../../components/Layout/ConfigurationDialog/providerLogos";
import SectionHeader from "../../components/SectionHeader";
import { publishProviders } from "../../hooks/useDisabledProviders";
import ThingiverseSection from "./ThingiverseSection";
import ProviderKeySection, { type KeyedProvider } from "./ProviderKeySection";
type Props = {
onUnauthorized?: () => void;
@@ -30,17 +30,26 @@ const NAMES: Record<ImportProvider, string> = {
makerworld: "MakerWorld",
printables: "Printables",
thingiverse: "Thingiverse",
myminifactory: "MyMiniFactory",
};
/** The sites Thingport imports from and syncs with: each can be turned off for everyone, and Thingiverse's token set. */
const KEYED = new Set<ImportProvider>(["thingiverse", "myminifactory"]);
const isKeyed = (provider: ImportProvider): provider is KeyedProvider => KEYED.has(provider);
/** The sites Thingport imports from and syncs with: each can be turned off for everyone, and the instance keys of
* the ones that need one set. */
export default function ProvidersPage({ onUnauthorized }: Props) {
const { t } = useTranslation(["app", "common"]);
const confirm = useConfirm();
const [providers, setProviders] = React.useState<ProviderSetting[] | null>(null);
const [saving, setSaving] = React.useState<ImportProvider | null>(null);
const [error, setError] = React.useState<string | null>(null);
const [tokenOpen, setTokenOpen] = React.useState(false);
const [tokenSet, setTokenSet] = React.useState<boolean | null>(null);
const [keyOpen, setKeyOpen] = React.useState<KeyedProvider | null>(null);
const [keySet, setKeySet] = React.useState<Partial<Record<KeyedProvider, boolean>>>({});
const onKeyConfigured = React.useCallback(
(configured: boolean) => keyOpen && setKeySet((current) => ({ ...current, [keyOpen]: configured })),
[keyOpen],
);
const fail = React.useCallback(
(err: unknown) => {
@@ -57,7 +66,11 @@ export default function ProvidersPage({ onUnauthorized }: Props) {
(err: unknown) => active && fail(err),
);
settingsApi.getThingiverse().then(
(data) => active && setTokenSet(data.configured),
(data) => active && setKeySet((current) => ({ ...current, thingiverse: data.configured })),
() => undefined,
);
settingsApi.getMyminifactory().then(
(data) => active && setKeySet((current) => ({ ...current, myminifactory: data.configured })),
() => undefined,
);
return () => {
@@ -98,8 +111,12 @@ export default function ProvidersPage({ onUnauthorized }: Props) {
</Alert>
)}
<Paper variant="outlined" sx={{ borderRadius: "12px", overflow: "hidden" }}>
{(providers ?? (["makerworld", "printables", "thingiverse"] as const).map((id) => ({ id, enabled: true }))).map(
({ id, enabled }) => (
{(
providers ??
(["makerworld", "printables", "thingiverse", "myminifactory"] as const).map((id) => ({ id, enabled: true }))
).map(({ id, enabled }) => {
const keySetHere = isKeyed(id) ? keySet[id] : undefined;
return (
<Stack
key={id}
direction="row"
@@ -117,18 +134,15 @@ export default function ProvidersPage({ onUnauthorized }: Props) {
<Typography variant="body2" color="text.secondary">
{t(`adminSettings.providers.descriptions.${id}`)}
</Typography>
{id === "thingiverse" && tokenSet !== null && (
<Typography variant="caption" color={tokenSet ? "success.main" : "warning.main"}>
{t(tokenSet ? "adminSettings.providers.tokenSet" : "adminSettings.providers.tokenMissing")}
{isKeyed(id) && keySetHere !== undefined && (
<Typography variant="caption" color={keySetHere ? "success.main" : "warning.main"}>
{t(`adminSettings.providers.keyStatus.${id}.${keySetHere ? "set" : "missing"}`)}
</Typography>
)}
</Box>
{id === "thingiverse" && (
<Tooltip title={t("adminSettings.providers.tokenSettings")}>
<IconButton
aria-label={t("adminSettings.providers.tokenSettings")}
onClick={() => setTokenOpen(true)}
>
{isKeyed(id) && (
<Tooltip title={t(`adminSettings.${id}.heading`)}>
<IconButton aria-label={t(`adminSettings.${id}.heading`)} onClick={() => setKeyOpen(id)}>
<SettingsOutlinedIcon />
</IconButton>
</Tooltip>
@@ -150,23 +164,30 @@ export default function ProvidersPage({ onUnauthorized }: Props) {
<Skeleton variant="rounded" width={34} height={14} sx={{ mx: 1.5 }} />
)}
</Stack>
),
)}
);
})}
</Paper>
<Dialog open={tokenOpen} onClose={() => setTokenOpen(false)} fullWidth maxWidth="sm">
<Dialog open={Boolean(keyOpen)} onClose={() => setKeyOpen(null)} fullWidth maxWidth="sm">
<DialogTitle sx={{ pr: 6 }}>
{t("adminSettings.thingiverse.heading")}
{keyOpen && t(`adminSettings.${keyOpen}.heading`)}
<IconButton
aria-label={t("common:close")}
onClick={() => setTokenOpen(false)}
onClick={() => setKeyOpen(null)}
sx={{ position: "absolute", right: 8, top: 8 }}
>
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent>
<ThingiverseSection onUnauthorized={onUnauthorized} onConfiguredChange={setTokenSet} />
{keyOpen && (
<ProviderKeySection
key={keyOpen}
provider={keyOpen}
onUnauthorized={onUnauthorized}
onConfiguredChange={onKeyConfigured}
/>
)}
</DialogContent>
</Dialog>
</Stack>
@@ -14,6 +14,7 @@ export const PROVIDER_ACCENTS: Record<BrowsableProvider, string> = {
makerworld: "#00AE42",
printables: "#FA6831",
thingiverse: "#5575FE",
myminifactory: "#00C4A6",
};
type Props = {
@@ -37,11 +38,9 @@ function Thumb({ src, sx }: { src: string | undefined; sx?: object }) {
function ModelCount({ count, provider }: { count: number; provider: BrowsableProvider }) {
const { t } = useTranslation("models");
return (
<>
{t(provider === "thingiverse" ? "collections.sync.browse.things" : "collections.sync.browse.models", { count })}
</>
);
// Each site's own word for what it holds.
const noun = provider === "thingiverse" ? "things" : provider === "myminifactory" ? "objects" : "models";
return <>{t(`collections.sync.browse.${noun}`, { count })}</>;
}
/** MakerWorld: a 2x2 mosaic, "+N" over the last tile, the deck edges below. */
@@ -212,10 +211,42 @@ function ThingiverseTile({ collection }: { collection: ProviderCollection }) {
);
}
// MyMiniFactory's card: name and count, then the cover model large, then a row of three more.
function MyMiniFactoryTile({ collection }: { collection: ProviderCollection }) {
const [cover, ...rest] = collection.thumbnails;
return (
<Box
sx={{
borderRadius: "4px",
border: 1,
borderColor: "divider",
borderBottom: `3px solid ${PROVIDER_ACCENTS.myminifactory}`,
bgcolor: "background.paper",
p: 1.25,
}}
>
<Typography noWrap sx={{ fontWeight: 700, fontSize: "0.875rem" }}>
{collection.name}
</Typography>
<Box sx={{ display: "flex", alignItems: "center", fontSize: "0.8rem", color: "text.secondary" }}>
<ModelCount count={collection.model_count} provider="myminifactory" />
{collection.private && <LockOutlinedIcon sx={{ fontSize: 14, ml: "auto" }} />}
</Box>
<Thumb src={cover} sx={{ aspectRatio: "16 / 9", mt: 1, borderRadius: "2px" }} />
<Box sx={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: "4px", mt: "4px" }}>
{[0, 1, 2].map((i) => (
<Thumb key={i} src={rest[i]} sx={{ aspectRatio: "1 / 1", borderRadius: "2px" }} />
))}
</Box>
</Box>
);
}
const TILES: Record<BrowsableProvider, (props: { collection: ProviderCollection }) => JSX.Element> = {
makerworld: MakerWorldTile,
printables: PrintablesTile,
thingiverse: ThingiverseTile,
myminifactory: MyMiniFactoryTile,
};
/** A provider's collection to pick, drawn in that provider's colours and manner. One already synced
@@ -318,6 +349,28 @@ export function ProviderCollectionTileSkeleton({ provider }: { provider: Browsab
</Box>
);
}
if (provider === "myminifactory") {
return (
<Box
aria-hidden
sx={{
borderRadius: "4px",
border: 1,
borderColor: "divider",
borderBottom: `3px solid ${PROVIDER_ACCENTS.myminifactory}`,
p: 1.25,
}}
>
{lines}
<Skeleton variant="rectangular" sx={{ width: "100%", height: "auto", aspectRatio: "16 / 9", mt: 1 }} />
<Box sx={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: "4px", mt: "4px" }}>
{[0, 1, 2].map((i) => (
<Skeleton key={i} variant="rectangular" sx={{ width: "100%", height: "auto", aspectRatio: "1 / 1" }} />
))}
</Box>
</Box>
);
}
return (
<Box
aria-hidden
@@ -44,6 +44,7 @@ const PASTE_EXAMPLES: Record<BrowsableProvider, string> = {
makerworld: "https://makerworld.com/en/collections/…",
printables: "https://www.printables.com/@…/collections/…",
thingiverse: "https://www.thingiverse.com/…/collections/…",
myminifactory: "https://www.myminifactory.com/users/…/collection/…",
};
/** The one the user's slicer goes with, unless it isn't connected; then the first that is. Only from `available`,
Loaded 100 of 114 files, more files were not shown because too many files have changed in this diff. Show more