feat(collections): turn sync on from Thingport, picking the provider's collection

Every collection's Sync configuration now works before it's synced: a
Browse tab lists the user's own collections on MakerWorld (through
their cookie, private ones included), Printables and Thingiverse (by a
username asked once), and takes any collection's link. Each provider's
collections are drawn in its own colours and manner.

On Printables and Thingiverse the collection's current models are
imported by the sync's first check. MakerWorld rate-limits bulk
imports, so there only models added from now on come in. Settings can
be picked before turning sync on.
This commit is contained in:
Tautvydas Derzinskas committed 2026-10-12 05:31:01 +02:00
1 parent 9334438a52
commit 1131700ec5
25 files changed
+1657 -46

No files matched your search

+4 -4
View File
@@ -284,10 +284,10 @@ out as the backend's user, so `PUID`/`PGID` need write access to the folder. The
## Synced Collections
Link a MakerWorld, Printables or Thingiverse collection to Thingport once, with one tick in Thingport Grab, and every
model added to it later is imported into your library on its own. Thingport checks it once a day, or every 6 hours or every hour,
imports new models one at a time and sends a notification saying what came in. Sync only ever adds: nothing is deleted
on either side. See the **[synced collections guide](docs/guides/synced-collections.md)**.
Link a MakerWorld, Printables or Thingiverse collection to Thingport once, from the collection in Thingport or with one
tick in Thingport Grab, and every model added to it later is imported into your library on its own. Thingport checks it
once a day, or every 6 hours or every hour, imports new models one at a time and sends a notification saying what came
in. Sync only ever adds: nothing is deleted on either side. See the **[synced collections guide](docs/guides/synced-collections.md)**.
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/collection-sync-dark.gif">
@@ -0,0 +1,3 @@
ALTER TABLE "User" ADD COLUMN "printablesUserId" TEXT,
ADD COLUMN "printablesHandle" TEXT,
ADD COLUMN "thingiverseUsername" TEXT;
+5
View File
@@ -43,6 +43,11 @@ model User {
apiTokenLastUsedAt DateTime?
// Per-user, unlike the instance-wide Thingiverse token.
makerworldCookie String?
// Who the user is on providers without a per-user login, so their collections can be listed for
// syncing. Printables keeps its numeric id (the API's key) with the handle to show.
printablesUserId String?
printablesHandle String?
thingiverseUsername String?
slicer String?
// "light" | "dark" | "system"; null means never set.
themePreference String?
+2
View File
@@ -17,6 +17,7 @@ import settingsRoutes from "./routes/settings";
import importsRoutes from "./routes/imports";
import collectionsRoutes from "./routes/collections";
import collectionSyncsRoutes from "./routes/collectionSyncs";
import providerCollectionsRoutes from "./routes/providerCollections";
import bookmarksRoutes from "./routes/bookmarks";
import searchRoutes from "./routes/search";
import notificationsRoutes from "./routes/notifications";
@@ -55,6 +56,7 @@ export function createApp(): Express {
app.use("/api", importsRoutes);
app.use("/api", collectionsRoutes);
app.use("/api", collectionSyncsRoutes);
app.use("/api", providerCollectionsRoutes);
app.use("/api", bookmarksRoutes);
app.use("/api", searchRoutes);
app.use("/api", notificationsRoutes);
+27
View File
@@ -15,6 +15,7 @@ import { releaseSync, reserveSync, runManualSync } from "../services/collectionS
import { getActiveJob } from "../services/importJobService";
import { SYNC_INTERVAL_HOURS, syncNowAvailableAt, type SyncIntervalHours } from "../services/collectionSyncSchedule";
import { toCollectionSyncOut } from "../dto";
import { enableSyncFromApp } from "../services/providerCollectionsService";
import { prisma } from "../db";
const router = Router();
@@ -82,6 +83,32 @@ router.patch(
}),
);
const enableFromAppSchema = z.object({
url: z.string().min(1),
profile_scope: z.enum(["url", "designer", "all"]).optional(),
interval_hours: z
.number()
.int()
.refine((hours): hours is SyncIntervalHours => (SYNC_INTERVAL_HOURS as readonly number[]).includes(hours))
.optional(),
});
// The app's Browse tab: links this collection to a provider's. Thingport Grab uses POST
// /collection-sync instead, with the model ids it saw on the page.
router.post(
"/collection/:id/sync",
asyncHandler(async (req, res) => {
const body = parseBody(enableFromAppSchema, req.body);
res.json(
await enableSyncFromApp(req.userId!, req.params.id, {
url: body.url,
profileScope: body.profile_scope,
intervalHours: body.interval_hours,
}),
);
}),
);
// Answers once the sync has started; how it went arrives as a notification, since reading a large
// collection is paced and can take minutes.
router.post(
+59
View File
@@ -0,0 +1,59 @@
import { Router } from "express";
import { z } from "zod";
import { requireAuth } from "../auth";
import { HttpError } from "../utils/fileUtils";
import { parseBody } from "../utils/validate";
import { asyncHandler } from "../utils/asyncHandler";
import {
BROWSABLE_PROVIDERS,
getProviderAccounts,
listProviderCollections,
lookupProviderCollection,
setProviderAccount,
type BrowsableProvider,
} from "../services/providerCollectionsService";
const router = Router();
router.use(requireAuth);
function parseProvider(raw: unknown): BrowsableProvider {
if (typeof raw === "string" && (BROWSABLE_PROVIDERS as readonly string[]).includes(raw)) {
return raw as BrowsableProvider;
}
throw new HttpError(400, `Pick one of ${BROWSABLE_PROVIDERS.join(", ")}`);
}
router.get(
"/provider-collections/accounts",
asyncHandler(async (req, res) => {
res.json(await getProviderAccounts(req.userId!));
}),
);
const accountSchema = z.object({ value: z.string().max(300).nullable() });
router.put(
"/provider-collections/accounts/:provider",
asyncHandler(async (req, res) => {
const body = parseBody(accountSchema, req.body);
res.json(await setProviderAccount(req.userId!, parseProvider(req.params.provider), body.value));
}),
);
router.get(
"/provider-collections",
asyncHandler(async (req, res) => {
res.json(await listProviderCollections(req.userId!, parseProvider(req.query.provider)));
}),
);
router.get(
"/provider-collections/lookup",
asyncHandler(async (req, res) => {
const url = typeof req.query.url === "string" ? req.query.url.trim() : "";
if (!url) throw new HttpError(400, "url is required");
res.json(await lookupProviderCollection(req.userId!, url));
}),
);
export default router;
+3 -1
View File
@@ -44,7 +44,9 @@ const capitalize = (text: string) => text.charAt(0).toUpperCase() + text.slice(1
/** The provider collection's model ids, or null when the provider says it no longer exists. Throws
* when there's no clear answer, so an outage never unsyncs anything. */
async function fetchListing(sync: SyncWithCollection): Promise<string[] | null> {
export async function fetchListing(
sync: Pick<CollectionSync, "userId" | "provider" | "externalId" | "sourceUrl">,
): Promise<string[] | null> {
const id = sync.externalId;
if (sync.provider === "makerworld") {
// A public collection lists without a login; a private one needs the owner's.
@@ -6,6 +6,7 @@ import {
looksLikeCloudflareBlock,
} from "./flaresolverr";
import { maybeSleep } from "../utils/concurrency";
import type { ProviderCollectionCard } from "./providerCollectionCard";
import {
isCaptchaChallenge,
makerworldCaptchaCooloffActive,
@@ -17,6 +18,8 @@ import {
// Not behind Cloudflare. Private collections return 403 without the bearer token, so it's sent
// whenever available.
const COLLECTION_API_BASE = "https://makerworld.com/api/v1/design-service/favorites";
// The same service without Cloudflare in front, for what the Browse tab of the sync dialog shows.
const BAMBU_DESIGN_API_BASE = "https://api.bambulab.com/v1/design-service";
const COLLECTION_PAGE_SIZE = 20;
const COLLECTION_MAX_ENTRIES = 300;
@@ -192,3 +195,46 @@ export async function fetchMakerworldCollectionEntries(
return { total, entries, truncated: total > entries.length };
}
// How MakerWorld marks a private collection; public ones are 2.
const PRIVATE_STATUS = 1;
function collectionCard(value: unknown): ProviderCollectionCard | null {
if (!isRecord(value) || value.id == null) return null;
const id = String(value.id);
const covers = Array.isArray(value.designCover) ? value.designCover : [];
return {
id,
name: typeof value.title === "string" && value.title.trim() ? value.title.trim() : `Collection ${id}`,
url: `https://makerworld.com/en/collections/${id}`,
modelCount: typeof value.designCnt === "number" ? value.designCnt : 0,
thumbnails: covers.filter((url): url is string => typeof url === "string" && url.trim() !== "").slice(0, 4),
private: value.status === PRIVATE_STATUS,
owner: isRecord(value.creator) && typeof value.creator.handle === "string" ? value.creator.handle : null,
};
}
/** The signed-in user's own collections, private ones included. */
export async function listMakerworldOwnCollections(bearerToken: string): Promise<ProviderCollectionCard[]> {
const data = await fetchCollectionJson(`${BAMBU_DESIGN_API_BASE}/my/favorites/listlite?query=`, bearerToken);
if (!isRecord(data) || !Array.isArray(data.hits)) throw new MakerworldCollectionFetchError("unexpected answer");
return data.hits.flatMap((hit) => {
const card = collectionCard(hit);
return card ? [card] : [];
});
}
/** Null when MakerWorld says there's no such collection. A private one needs its owner's token. */
export async function fetchMakerworldCollectionCard(
collectionId: string,
bearerToken: string | null,
): Promise<ProviderCollectionCard | null> {
const data = await fetchCollectionJson(
`${BAMBU_DESIGN_API_BASE}/favorites/${collectionId}`,
bearerToken,
undefined,
true,
);
if (data === NOT_FOUND) return null;
return collectionCard(data);
}
+69
View File
@@ -2,6 +2,7 @@ import { IMPORT_BROWSER_USER_AGENT, IMPORT_HTML_MAX_BYTES, IMPORT_TIMEOUT_SECOND
import { HttpError } from "../utils/fileUtils";
import { type ImportedAuthorInfo, type ImportedPageMetadata } from "./importResolvers";
import { htmlToMarkdown } from "./descriptionMarkdown";
import type { ProviderCollectionCard } from "./providerCollectionCard";
// Unlike Cloudflare-gated www.printables.com, the GraphQL API answers unauthenticated requests.
const PRINTABLES_GRAPHQL_URL = "https://api.printables.com/graphql/";
@@ -337,3 +338,71 @@ export async function fetchPrintablesCollectionEntries(
]);
return { title, entries, total: entries.length, truncated: entries.length >= COLLECTION_MAX_ENTRIES };
}
const COLLECTION_CARD_FIELDS = "id name private printsCount user { handle } thumbnails11 { image { filePath } }";
const USER_QUERY = `query ($id: ID!) { user(id: $id) { id handle publicUsername } }`;
const USER_COLLECTIONS_QUERY = `query ($userId: ID!) { collections: userCollections(userId: $userId) { ${COLLECTION_CARD_FIELDS} } }`;
const COLLECTION_CARD_QUERY = `query ($id: ID!) { collection(id: $id) { ${COLLECTION_CARD_FIELDS} } }`;
export type PrintablesUser = { id: string; handle: string; name: string | null };
/** A profile link, "@handle", a handle or a numeric id. Null when Printables knows no such user. */
export async function resolvePrintablesUser(input: string): Promise<PrintablesUser | null> {
let value = input.trim();
try {
const url = new URL(value);
const host = url.hostname.toLowerCase();
if (host !== "printables.com" && host !== "www.printables.com") return null;
value = url.pathname.match(/^\/(?:[a-z]{2}\/)?@([^/]+)/i)?.[1] ?? "";
} catch {
// Not a link.
}
value = value.replace(/^@/, "");
if (!value) return null;
// `user(id:)` takes "@handle" as well as the id; a bare handle is an error.
const data = await fetchPrintablesGraphql(USER_QUERY, { id: /^\d+$/.test(value) ? value : `@${value}` });
const user = isRecord(data) && isRecord(data.data) && isRecord(data.data.user) ? data.data.user : null;
if (!user || user.id == null || typeof user.handle !== "string") return null;
const name =
typeof user.publicUsername === "string" && user.publicUsername.trim() ? user.publicUsername.trim() : null;
return { id: String(user.id), handle: user.handle, name };
}
function collectionCard(value: unknown): ProviderCollectionCard | null {
if (!isRecord(value) || value.id == null) return null;
const id = String(value.id);
const owner = isRecord(value.user) && typeof value.user.handle === "string" ? value.user.handle : null;
const thumbnails = Array.isArray(value.thumbnails11)
? value.thumbnails11.flatMap((t) => {
const url = isRecord(t) && isRecord(t.image) ? mediaUrl(t.image.filePath) : null;
return url ? [url] : [];
})
: [];
return {
id,
name: typeof value.name === "string" && value.name.trim() ? value.name.trim() : `Collection ${id}`,
url: owner
? `https://www.printables.com/@${owner}/collections/${id}`
: `https://www.printables.com/collections/${id}`,
modelCount: typeof value.printsCount === "number" ? value.printsCount : 0,
thumbnails,
private: value.private === true,
owner,
};
}
/** Public ones only: Printables shows a user's private collections to them alone. */
export async function listPrintablesUserCollections(userId: string): Promise<ProviderCollectionCard[]> {
const data = await fetchPrintablesGraphql(USER_COLLECTIONS_QUERY, { userId });
const list = isRecord(data) && isRecord(data.data) ? data.data.collections : null;
if (!Array.isArray(list)) throw new HttpError(502, "Couldn't load your Printables collections");
return list.flatMap((item) => {
const card = collectionCard(item);
return card ? [card] : [];
});
}
export async function fetchPrintablesCollectionCard(collectionId: string): Promise<ProviderCollectionCard | null> {
const data = await fetchPrintablesGraphql(COLLECTION_CARD_QUERY, { id: collectionId });
return collectionCard(isRecord(data) && isRecord(data.data) ? data.data.collection : null);
}
@@ -0,0 +1,13 @@
/** A provider's collection as the sync dialog's Browse tab shows it. */
export type ProviderCollectionCard = {
id: string;
name: string;
/** The collection's page, which a sync follows (see parseSyncSource). */
url: string;
modelCount: number;
/** A few model images, in the provider's own order. */
thumbnails: string[];
private: boolean;
/** The owner's handle or username, when the provider says. */
owner: string | null;
};
@@ -0,0 +1,217 @@
// The sync dialog's Browse tab: which providers a user can browse, their collections there, and
// turning sync on for a Thingport collection from the app rather than from Thingport Grab.
import { prisma } from "../db";
import { HttpError } from "../utils/fileUtils";
import type { CollectionSyncOut, SyncProfileScope } from "../dto";
import { isSystemCollectionId } from "./collectionService";
import { fetchListing, syncCollection } from "./collectionSyncRunner";
import type { SyncIntervalHours } from "./collectionSyncSchedule";
import { enableSync, parseSyncSource, type SyncProvider, updateSyncSettings } from "./collectionSyncService";
import { MakerworldAuthError, MakerworldCaptchaError, extractMakerworldBearerToken } from "./makerworldCloudApi";
import { fetchMakerworldCollectionCard, listMakerworldOwnCollections } from "./makerworldCollections";
import { getUserMakerworldCookie } from "./makerworldCookieService";
import { fetchPrintablesCollectionCard, listPrintablesUserCollections, resolvePrintablesUser } from "./printablesApi";
import type { ProviderCollectionCard } from "./providerCollectionCard";
import { getThingiverseAccessToken } from "./settingsService";
import {
fetchThingiverseCollectionCard,
listThingiverseUserCollections,
resolveThingiverseUser,
} from "./thingiverseApi";
export const BROWSABLE_PROVIDERS = ["makerworld", "printables", "thingiverse"] 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
* the providers without a per-user login (MakerWorld's cookie already says). */
export type ProviderAccountOut = { connected: boolean; account: string | null };
export type ProviderAccountsOut = Record<BrowsableProvider, ProviderAccountOut>;
export type ProviderCollectionOut = {
id: string;
name: string;
url: string;
model_count: number;
thumbnails: string[];
private: boolean;
owner: string | null;
/** The Thingport collection already synced with it, which can't take a second one. */
synced: { collection_id: string; collection_name: string } | null;
};
export async function getProviderAccounts(userId: string): Promise<ProviderAccountsOut> {
const [user, thingiverseToken] = await Promise.all([
prisma.user.findUnique({
where: { id: userId },
select: { makerworldCookie: true, printablesHandle: true, thingiverseUsername: true },
}),
getThingiverseAccessToken(),
]);
return {
makerworld: { connected: Boolean(extractMakerworldBearerToken(user?.makerworldCookie)), account: null },
printables: { connected: true, account: user?.printablesHandle ?? null },
thingiverse: { connected: Boolean(thingiverseToken), account: user?.thingiverseUsername ?? null },
};
}
async function requireThingiverseToken(): Promise<string> {
const token = await getThingiverseAccessToken();
if (!token) throw new HttpError(409, "Thingiverse isn't connected on this Thingport yet");
return token;
}
/** `input` is a username, handle or profile link; null forgets it. */
export async function setProviderAccount(
userId: string,
provider: BrowsableProvider,
input: string | null,
): Promise<ProviderAccountsOut> {
const value = input?.trim() || null;
if (provider === "printables") {
const user = value ? await resolvePrintablesUser(value) : null;
if (value && !user) throw new HttpError(404, `Printables has no user "${value}"`);
await prisma.user.update({
where: { id: userId },
data: { printablesUserId: user?.id ?? null, printablesHandle: user?.handle ?? null },
});
} else if (provider === "thingiverse") {
const user = value ? await resolveThingiverseUser(value, await requireThingiverseToken()) : 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 {
throw new HttpError(400, "MakerWorld is linked through its cookie in Configuration > Providers");
}
return getProviderAccounts(userId);
}
/** Provider errors a user can act on, as HTTP errors; anything else is left to the error handler. */
async function providerCall<T>(call: () => Promise<T>): Promise<T> {
try {
return await call();
} catch (err) {
if (err instanceof MakerworldCaptchaError) throw new HttpError(429, err.message);
// 400, not 401: the frontend treats any 401 as an expired Thingport session.
if (err instanceof MakerworldAuthError) {
throw new HttpError(400, "MakerWorld didn't accept your cookie. Update it in Configuration > Providers.");
}
throw err;
}
}
async function withSyncs(
userId: string,
provider: SyncProvider,
cards: ProviderCollectionCard[],
): Promise<ProviderCollectionOut[]> {
const syncs = await prisma.collectionSync.findMany({
where: { userId, provider, externalId: { in: cards.map((card) => card.id) } },
include: { collection: { select: { id: true, name: true } } },
});
const byId = new Map(syncs.map((sync) => [sync.externalId, sync.collection]));
return cards.map((card) => {
const synced = byId.get(card.id);
return {
id: card.id,
name: card.name,
url: card.url,
model_count: card.modelCount,
thumbnails: card.thumbnails,
private: card.private,
owner: card.owner,
synced: synced ? { collection_id: synced.id, collection_name: synced.name } : null,
};
});
}
export async function listProviderCollections(
userId: string,
provider: BrowsableProvider,
): Promise<ProviderCollectionOut[]> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { makerworldCookie: true, printablesUserId: true, thingiverseUsername: true },
});
let cards: ProviderCollectionCard[];
if (provider === "makerworld") {
const bearer = extractMakerworldBearerToken(user?.makerworldCookie);
if (!bearer) throw new HttpError(409, "Connect MakerWorld in Configuration > Providers first");
cards = await providerCall(() => listMakerworldOwnCollections(bearer));
} else if (provider === "printables") {
if (!user?.printablesUserId) throw new HttpError(409, "Tell Thingport your Printables username first");
cards = await listPrintablesUserCollections(user.printablesUserId);
} else {
const token = await requireThingiverseToken();
if (!user?.thingiverseUsername) throw new HttpError(409, "Tell Thingport your Thingiverse username first");
cards = await listThingiverseUserCollections(user.thingiverseUsername, token);
}
return withSyncs(userId, provider, cards);
}
/** 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 || source.likesOf) {
throw new HttpError(400, "That isn't a MakerWorld, Printables or Thingiverse collection link");
}
let card: ProviderCollectionCard | null;
if (source.provider === "makerworld") {
const bearer = extractMakerworldBearerToken(await getUserMakerworldCookie(userId));
card = await providerCall(() => fetchMakerworldCollectionCard(source.externalId, bearer));
} else if (source.provider === "printables") {
card = await fetchPrintablesCollectionCard(source.externalId);
} else {
card = await fetchThingiverseCollectionCard(source.externalId, await requireThingiverseToken());
}
if (!card) throw new HttpError(404, "That collection can't be found. It may be private or deleted.");
return (await withSyncs(userId, source.provider, [card]))[0];
}
export type EnableFromAppResult = { sync: CollectionSyncOut; importing: boolean };
/** Links an existing Thingport collection to a provider's. MakerWorld rate-limits bulk imports
* hard, so there only models added from now on come in; elsewhere the collection's current models
* are imported too, by the sync's first check. Library models are filed into the collection
* either way. */
export async function enableSyncFromApp(
userId: string,
collectionId: string,
input: { url: string; intervalHours?: SyncIntervalHours; profileScope?: SyncProfileScope },
): 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 (input.profileScope && source.provider !== "makerworld") {
throw new HttpError(400, "Only MakerWorld models have print profiles to pick");
}
let knownIds: string[] = [];
if (source.provider === "makerworld") {
const listing = await providerCall(() =>
fetchListing({ userId, provider: source.provider, externalId: source.externalId, sourceUrl: source.url }),
);
if (!listing) throw new HttpError(404, "That collection can't be found. It may be private or deleted.");
knownIds = listing;
}
const link = await enableSync(userId, { url: input.url, collectionId, knownIds });
let sync = link;
if (input.intervalHours || input.profileScope) {
sync = {
...link,
...(await updateSyncSettings(userId, collectionId, {
intervalHours: input.intervalHours,
profileScope: input.profileScope,
})),
};
}
const importing = source.provider !== "makerworld";
if (importing) {
const created = await prisma.collectionSync.findUniqueOrThrow({ where: { collectionId } });
// Never checked yet, so a check that finds the user busy importing is retried on the
// scheduler's next tick rather than a day later.
void syncCollection(created.id).catch((err) => console.error(`[sync] first check of ${created.id} failed:`, err));
}
return { sync, importing };
}
+68
View File
@@ -1,6 +1,7 @@
import { IMPORT_BROWSER_USER_AGENT, IMPORT_TIMEOUT_SECONDS } from "../config";
import type { ImportedAuthorInfo, ImportedPageMetadata } from "./importResolvers";
import { cleanThingiverseMarkdown } from "./descriptionMarkdown";
import type { ProviderCollectionCard } from "./providerCollectionCard";
import {
extractJsonFromBrowserBody,
fetchViaFlaresolverr,
@@ -352,3 +353,70 @@ export function parseThingiverseCollectionUrl(url: string): { collectionId: stri
const m = parsed.pathname.match(/\/collections\/(\d+)/i);
return m ? { collectionId: m[1] } : null;
}
/** A profile link or a username. Null when Thingiverse knows no such user. */
export async function resolveThingiverseUser(input: string, accessToken: string): Promise<{ username: string } | null> {
let value = input.trim();
try {
const url = new URL(value);
const host = url.hostname.toLowerCase();
if (host !== "thingiverse.com" && host !== "www.thingiverse.com") return null;
value = url.pathname.split("/").find(Boolean) ?? "";
} catch {
// Not a link.
}
value = value.replace(/^@/, "");
if (!value || !/^[\w.-]+$/.test(value)) return null;
const { ok, status, data } = await fetchThingiverseApi(`/users/${encodeURIComponent(value)}`, accessToken);
if (status === 404) return null;
if (!ok || !isRecord(data)) throw new Error(`Couldn't read the Thingiverse user (HTTP ${status})`);
return { username: typeof data.name === "string" && data.name.trim() ? data.name.trim() : value };
}
function thingiverseCollectionCard(value: unknown): ProviderCollectionCard | null {
if (!isRecord(value) || value.id == null) return null;
const id = String(value.id);
const owner = isRecord(value.creator) && typeof value.creator.name === "string" ? value.creator.name : null;
// A collection carries up to four model images as thumbnail, thumbnail_1, ...
const thumbnails = ["thumbnail", "thumbnail_1", "thumbnail_2", "thumbnail_3"].flatMap((key) => {
const url = value[key];
return typeof url === "string" && /^https?:\/\//.test(url) ? [url] : [];
});
return {
id,
name: typeof value.name === "string" && value.name.trim() ? value.name.trim() : `Collection ${id}`,
url: `https://www.thingiverse.com/${owner ? `${encodeURIComponent(owner)}/` : ""}collections/${id}`,
modelCount: typeof value.count === "number" ? value.count : Number(value.count) || 0,
thumbnails: [...new Set(thumbnails)],
private: value.is_private === true || value.is_private === 1,
owner,
};
}
export async function listThingiverseUserCollections(
username: string,
accessToken: string,
): Promise<ProviderCollectionCard[]> {
const { ok, status, data } = await fetchThingiverseApi(
`/users/${encodeURIComponent(username)}/collections`,
accessToken,
);
if (!ok || !Array.isArray(data)) throw new Error(`Couldn't load the Thingiverse collections (HTTP ${status})`);
return data.flatMap((item) => {
const card = thingiverseCollectionCard(item);
return card ? [card] : [];
});
}
export async function fetchThingiverseCollectionCard(
collectionId: string,
accessToken: string,
): Promise<ProviderCollectionCard | null> {
const { ok, status, data } = await fetchThingiverseApi(
`/collections/${encodeURIComponent(collectionId)}`,
accessToken,
);
if (status === 404) return null;
if (!ok) throw new Error(`Couldn't read the Thingiverse collection (HTTP ${status})`);
return thingiverseCollectionCard(data);
}
+234
View File
@@ -0,0 +1,234 @@
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import request from "supertest";
// No pacing here. config.ts reads the delays on load, so it's hoisted.
vi.hoisted(() => {
process.env.IMPORT_MAKERWORLD_CALL_DELAY_MS = "0";
});
import { createApp } from "../src/app";
import { prisma } from "../src/db";
import { setUserMakerworldCookie } from "../src/services/makerworldCookieService";
const app = createApp();
const stamp = Date.now();
let token: string;
let userId: string;
const auth = () => ({ Authorization: `Bearer ${token}` });
const originalFetch = global.fetch;
// Ids unique to this run, so reruns against the same database don't collide.
let nextId = (stamp % 1_000_000_000) * 10;
const newId = () => String(nextId++);
const PRINTABLES_USER = { id: "3560603", handle: `Tester_${stamp}`, publicUsername: "Tester" };
const printablesCollections = [
{ id: newId(), name: "Practical finds", printsCount: 23 },
{ id: newId(), name: "Cool toys", printsCount: 8 },
];
const makerworldCollections = [
{ id: newId(), title: "To print", status: 2, designCnt: 3 },
{ id: newId(), title: "Secret", status: 1, designCnt: 1 },
];
const makerworldDesigns: Record<string, string[]> = {
[makerworldCollections[0].id]: [newId(), newId(), newId()],
};
function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
}
function printablesCard(c: { id: string; name: string; printsCount: number }) {
return {
...c,
private: false,
user: { handle: PRINTABLES_USER.handle },
thumbnails11: [{ image: { filePath: `media/${c.id}.jpg` } }],
};
}
function printablesGraphql(query: string, variables: Record<string, unknown>): Response {
if (query.includes("user(id:")) {
const ok = variables.id === `@${PRINTABLES_USER.handle}` || variables.id === PRINTABLES_USER.id;
return json({ data: { user: ok ? PRINTABLES_USER : null } });
}
if (query.includes("userCollections"))
return json({ data: { collections: printablesCollections.map(printablesCard) } });
if (query.includes("moreCollectionModels"))
return json({ data: { moreCollectionModels: { items: [], cursor: "" } } });
if (query.includes("collection(id:")) {
const found = printablesCollections.find((c) => c.id === variables.id);
return json({ data: { collection: found ? printablesCard(found) : null } });
}
return json({ errors: [{ message: "unexpected query" }] });
}
function makerworldCard(c: (typeof makerworldCollections)[number]) {
return { ...c, designCover: [`https://makerworld.bblmw.com/${c.id}.jpg`], creator: { handle: "Tautve" } };
}
beforeAll(async () => {
const res = await request(app)
.post("/api/register")
.send({ displayName: "Browse", email: `browse-${stamp}@example.com`, password: "password123" });
token = res.body.token;
userId = res.body.user.id;
global.fetch = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(async (input, init) => {
const url = String(input);
if (url.startsWith("https://api.printables.com/graphql")) {
const body = JSON.parse(String(init?.body ?? "{}")) as { query: string; variables: Record<string, unknown> };
return printablesGraphql(body.query, body.variables);
}
if (url.startsWith("https://api.bambulab.com/v1/design-service/my/favorites/listlite")) {
return json({ total: makerworldCollections.length, hits: makerworldCollections.map(makerworldCard) });
}
const card = url.match(/^https:\/\/api\.bambulab\.com\/v1\/design-service\/favorites\/(\d+)$/);
if (card) {
const found = makerworldCollections.find((c) => c.id === card[1]);
return found ? json(makerworldCard(found)) : json({ error: "not found" }, 404);
}
const head = url.match(/^https:\/\/makerworld\.com\/api\/v1\/design-service\/favorites\/(\d+)$/);
if (head) return makerworldDesigns[head[1]] ? json({ id: Number(head[1]), title: "To print" }) : json({}, 404);
const listing = url.match(/^https:\/\/makerworld\.com\/api\/v1\/design-service\/favorites\/(\d+)\/designs/);
if (listing) {
const ids = makerworldDesigns[listing[1]] ?? [];
return json({ total: ids.length, hits: ids.map((id) => ({ id: Number(id), title: `Model ${id}` })) });
}
return new Response("not found", { status: 404 });
}) as unknown as typeof fetch;
});
afterAll(() => {
global.fetch = originalFetch;
});
async function newCollection(name: string): Promise<string> {
const res = await request(app)
.post("/api/collections")
.set(auth())
.send({ name: `${name} ${stamp}` });
return res.body.id as string;
}
describe("browsing a provider's collections to sync with", () => {
it("says which providers can be browsed, and who the user is on them", async () => {
const res = await request(app).get("/api/provider-collections/accounts").set(auth());
expect(res.status).toBe(200);
expect(res.body.makerworld).toEqual({ connected: false, account: null });
expect(res.body.printables).toEqual({ connected: true, account: null });
});
it("remembers a Printables user from their profile link, then lists their collections", async () => {
const before = await request(app).get("/api/provider-collections").query({ provider: "printables" }).set(auth());
expect(before.status).toBe(409);
const unknown = await request(app)
.put("/api/provider-collections/accounts/printables")
.set(auth())
.send({ value: "@nobody" });
expect(unknown.status).toBe(404);
const saved = await request(app)
.put("/api/provider-collections/accounts/printables")
.set(auth())
.send({ value: `https://www.printables.com/@${PRINTABLES_USER.handle}/collections` });
expect(saved.status).toBe(200);
expect(saved.body.printables.account).toBe(PRINTABLES_USER.handle);
const list = await request(app).get("/api/provider-collections").query({ provider: "printables" }).set(auth());
expect(list.status).toBe(200);
expect(list.body).toHaveLength(2);
expect(list.body[0]).toMatchObject({
id: printablesCollections[0].id,
name: "Practical finds",
model_count: 23,
url: `https://www.printables.com/@${PRINTABLES_USER.handle}/collections/${printablesCollections[0].id}`,
thumbnails: [`https://media.printables.com/media/${printablesCollections[0].id}.jpg`],
synced: null,
});
});
it("lists the user's own MakerWorld collections through their cookie, private ones included", async () => {
await setUserMakerworldCookie(userId, "token=test-bearer");
const list = await request(app).get("/api/provider-collections").query({ provider: "makerworld" }).set(auth());
expect(list.status).toBe(200);
expect(list.body.map((c: { name: string; private: boolean }) => [c.name, c.private])).toEqual([
["To print", false],
["Secret", true],
]);
});
it("looks up a pasted collection link as the same card", async () => {
const id = makerworldCollections[0].id;
const res = await request(app)
.get("/api/provider-collections/lookup")
.query({ url: `https://makerworld.com/en/collections/${id}-to-print` })
.set(auth());
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ id, name: "To print", model_count: 3, owner: "Tautve", synced: null });
const notACollection = await request(app)
.get("/api/provider-collections/lookup")
.query({ url: "https://makerworld.com/en/models/123" })
.set(auth());
expect(notACollection.status).toBe(400);
});
});
describe("turning sync on from the app", () => {
it("only follows new MakerWorld models: everything there now counts as seen", async () => {
const collectionId = await newCollection("MakerWorld");
const source = makerworldCollections[0];
const res = await request(app)
.post(`/api/collection/${collectionId}/sync`)
.set(auth())
.send({ url: `https://makerworld.com/en/collections/${source.id}`, interval_hours: 6 });
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ importing: false, sync: { provider: "makerworld", interval_hours: 6 } });
const sync = await prisma.collectionSync.findUniqueOrThrow({ where: { collectionId } });
expect(sync.knownIds.toSorted()).toEqual(makerworldDesigns[source.id].toSorted());
expect(await prisma.importJob.count({ where: { collectionSyncId: sync.id } })).toBe(0);
const list = await request(app).get("/api/provider-collections").query({ provider: "makerworld" }).set(auth());
expect(list.body[0].synced).toEqual({ collection_id: collectionId, collection_name: `MakerWorld ${stamp}` });
// One provider collection syncs with one Thingport collection at most.
const other = await newCollection("Second");
const again = await request(app)
.post(`/api/collection/${other}/sync`)
.set(auth())
.send({ url: `https://makerworld.com/en/collections/${source.id}` });
expect(again.status).toBe(409);
});
it("imports what a Printables collection holds now, through the sync's first check", async () => {
const collectionId = await newCollection("Printables");
const source = printablesCollections[1];
const res = await request(app)
.post(`/api/collection/${collectionId}/sync`)
.set(auth())
.send({ url: `https://www.printables.com/@${PRINTABLES_USER.handle}/collections/${source.id}` });
expect(res.status).toBe(200);
expect(res.body.importing).toBe(true);
// The first check runs in the background; the stand-in collection is empty, so it just finishes.
await vi.waitFor(
async () => {
const sync = await prisma.collectionSync.findUniqueOrThrow({ where: { collectionId } });
expect(sync.lastCheckedAt).not.toBeNull();
},
{ timeout: 5000 },
);
});
it("refuses print profile choices for providers without them", async () => {
const collectionId = await newCollection("Profiles");
const res = await request(app)
.post(`/api/collection/${collectionId}/sync`)
.set(auth())
.send({ url: `https://www.printables.com/collections/${printablesCollections[0].id}`, profile_scope: "all" });
expect(res.status).toBe(400);
});
});
+19 -4
View File
@@ -22,13 +22,28 @@ Thingport imports that model into the matching Thingport collection on its own,
## Before you start
- Install [Thingport Grab](../../extension/README.md), the browser extension, and connect it to your Thingport. Only
Grab can turn sync on, because it has just seen which models the collection holds.
- Sync can be turned on from Thingport itself, or with [Thingport Grab](../../extension/README.md), the browser
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 access token.
## Turn sync on
## 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
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.
## 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.
2. Click the Thingport Grab button in the bottom-right corner. The panel lists the models in the collection.
@@ -86,7 +101,7 @@ linked:
## Turn sync off
Either untick the box in Thingport Grab on the collection's page and save, or use **Stop syncing** in the collection's
sync configuration. The collection and its models stay in Thingport. You can turn sync on again from Grab at any time.
sync configuration. The collection and its models stay in Thingport. You can turn sync on again at any time, in Thingport or from Grab.
## For admins
+2 -2
View File
@@ -191,8 +191,8 @@ notification saying what came in. Models already on the page when you turn sync
you leave unticked, aren't imported by the sync.
It works for collections you imported before, too: open the collection page, click the icon, and
tick the box -- the panel says everything is already imported, and **Save** turns sync on. Only the
extension can turn sync on, since it has just seen which models the collection holds.
tick the box -- the panel says everything is already imported, and **Save** turns sync on. Sync can
also be turned on in Thingport, from the collection's **Sync configuration**.
- Sync only ever adds. A model removed from the provider's collection stays in the Thingport
collection and your library, and nothing is deleted on either side.
+17 -1
View File
@@ -11,7 +11,7 @@ export type SyncIntervalHours = (typeof SYNC_INTERVAL_HOURS)[number];
/** Which print profiles a synced MakerWorld collection brings for each new model. */
export type SyncProfileScope = "url" | "designer" | "all";
/** Thingport Grab links a collection to one on a provider; new models there come in hourly. */
/** A collection linked to one on a provider, by Thingport Grab or the sync dialog's Browse tab. */
export type CollectionSync = {
provider: string;
source_url: string;
@@ -139,6 +139,22 @@ export const collectionsApi = {
return res.json();
},
/** Links this collection to a provider's, from the sync dialog's Browse tab. `importing`: the
* provider collection's current models are being imported (not on MakerWorld, which only
* follows new ones). */
enableSync: async (
id: string,
input: { url: string; profile_scope?: SyncProfileScope; interval_hours?: SyncIntervalHours },
): Promise<{ sync: CollectionSync; importing: boolean }> => {
const res = await fetch(`${apiBase()}/collection/${id}/sync`, {
method: "POST",
headers: authHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify(input),
});
if (!res.ok) throw new Error(await readErrorMessage(res, "Failed to start syncing"));
return res.json();
},
/** Starts a sync of this collection alone; its result arrives as a notification. Answers with
* the sync as it starts, its Sync now cool-down included. */
runSync: async (id: string): Promise<CollectionSync> => {
+48
View File
@@ -0,0 +1,48 @@
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 type BrowsableProvider = (typeof BROWSABLE_PROVIDERS)[number];
/** `connected`: the instance can reach the provider for this user. `account`: who they are there,
* for providers without a per-user login; MakerWorld's cookie already says, so it's always null. */
export type ProviderAccount = { connected: boolean; account: string | null };
export type ProviderAccounts = Record<BrowsableProvider, ProviderAccount>;
export type ProviderCollection = {
id: string;
name: string;
url: string;
model_count: number;
thumbnails: string[];
private: boolean;
owner: string | null;
/** The Thingport collection already synced with it; a provider collection syncs with one only. */
synced: { collection_id: string; collection_name: string } | null;
};
async function call<T>(path: string, fallback: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${apiBase()}${path}`, { ...init, headers: authHeaders(init?.headers) });
if (res.status === 401) throw new UnauthorizedError();
if (!res.ok) throw new Error(await readErrorMessage(res, fallback));
return res.json();
}
export const providerCollectionsApi = {
accounts: (): Promise<ProviderAccounts> => call("/provider-collections/accounts", "Failed to load providers"),
/** `value` is a username, handle or profile link; null forgets it. */
setAccount: (provider: BrowsableProvider, value: string | null): Promise<ProviderAccounts> =>
call(`/provider-collections/accounts/${provider}`, "Failed to save the username", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ value }),
}),
list: (provider: BrowsableProvider): Promise<ProviderCollection[]> =>
call(`/provider-collections?provider=${provider}`, "Failed to load collections"),
lookup: (url: string): Promise<ProviderCollection> =>
call(`/provider-collections/lookup?url=${encodeURIComponent(url)}`, "Failed to find that collection"),
};
+8 -2
View File
@@ -1,4 +1,4 @@
import React, { useLayoutEffect, useRef, useState } from "react";
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
@@ -9,6 +9,7 @@ import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import type { AuthUser } from "../../api/auth";
import type { ThemeSelection } from "../../constants/settingsOptions";
import type { MakerWorldSettings } from "../../utils/settings";
import { notifyConfigurationClosed, onOpenConfiguration } from "../../utils/configurationEvents";
import AddMenu, { type AddMenuHandle } from "./AddMenu";
import ConfigurationDialog, { type ConfigurationTab } from "./ConfigurationDialog";
import NotificationBell from "./NotificationBell";
@@ -55,6 +56,8 @@ export default function TopBar({
const addMenuRef = useRef<AddMenuHandle | null>(null);
const [configTab, setConfigTab] = useState<ConfigurationTab | null>(null);
useEffect(() => onOpenConfiguration(setConfigTab), []);
// Publishes the bar's real height (it can wrap) as a CSS var for sticky elements below it.
useLayoutEffect(() => {
const el = rootRef.current;
@@ -131,7 +134,10 @@ export default function TopBar({
</Stack>
<ConfigurationDialog
open={configTab}
onClose={() => setConfigTab(null)}
onClose={() => {
setConfigTab(null);
notifyConfigurationClosed();
}}
theme={theme}
onThemeChange={onThemeChange}
isAdmin={user?.role === "ADMIN"}
+44 -3
View File
@@ -1909,7 +1909,7 @@
"profilesHelp": "\"All profiles\" includes community ones. Each print profile becomes its own file on the model. Applies to models found from now on.",
"stop": "Stop syncing",
"stopTitle": "Stop syncing?",
"stopMessage": "New models won't come into \"{{name}}\" any more. The collection and its models stay, and you can turn sync on again from Thingport Grab.",
"stopMessage": "New models won't come into \"{{name}}\" any more. The collection and its models stay, and you can turn sync on again here or from Thingport Grab.",
"saveFailed": "Failed to save sync settings.",
"syncNow": "Sync now",
"syncNowTitle": "Sync now?",
@@ -1921,7 +1921,8 @@
"saved": "Sync configuration saved.",
"sections": {
"overview": "Overview",
"settings": "Settings"
"settings": "Settings",
"browse": "Browse"
},
"intro_one": "Models added to the {{provider}} collection are imported here every hour.",
"intro_other": "Models added to the {{provider}} collection are imported here every {{count}} hours.",
@@ -1930,7 +1931,47 @@
"intervalOption_other": "{{count}} Hours",
"badgeLikes": "Synced with @{{user}}'s Thingiverse likes",
"introLikes_one": "Models @{{user}} likes on Thingiverse are imported here every hour.",
"introLikes_other": "Models @{{user}} likes on Thingiverse are imported here every {{count}} hours."
"introLikes_other": "Models @{{user}} likes on Thingiverse are imported here every {{count}} hours.",
"browse": {
"providerLabel": "Provider",
"models_one": "{{count}} model",
"models_other": "{{count}} models",
"things_one": "{{count}} Thing",
"things_other": "{{count}} Things",
"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.",
"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."
},
"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."
},
"usernamePlaceholder": {
"printables": "@handle or profile link",
"thingiverse": "Username or profile link"
},
"showing": "Showing @{{user}}'s public collections.",
"change": "Change",
"empty": "No collections on {{provider}} yet.",
"pasteLabel": "Someone else's collection",
"pastedTaken": "This collection is already synced with \"{{name}}\". A provider collection syncs with one Thingport collection only.",
"confirmMakerworld": "Models added to \"{{source}}\" from now on come into \"{{name}}\". The ones in it now aren't imported, since MakerWorld quickly rate-limits bulk imports; any already in your library are added to \"{{name}}\".",
"confirmImport_one": "The {{count}} model in \"{{source}}\" is imported into \"{{name}}\" now, and models added later come in on their own.",
"confirmImport_other": "The {{count}} models in \"{{source}}\" are imported into \"{{name}}\" now, and models added later come in on their own.",
"start": "Sync with \"{{name}}\"",
"started": "\"{{name}}\" now follows its {{provider}} collection.",
"startedImporting": "\"{{name}}\" now follows its {{provider}} collection. Its models are being imported; you'll get a notification.",
"pasteHint": "Paste its link, from MakerWorld, Printables or Thingiverse.",
"yoursLabel": "Your collections on {{provider}}"
}
}
},
"tags": {
+50 -3
View File
@@ -1939,7 +1939,7 @@
"profilesHelp": "„Visus profilius“ apima ir bendruomenės profilius. Kiekvienas spausdinimo profilis tampa atskiru modelio failu. Taikoma nuo šiol rastiems modeliams.",
"stop": "Nebesinchronizuoti",
"stopTitle": "Nebesinchronizuoti?",
"stopMessage": "Nauji modeliai į „{{name}}“ nebebus įtraukiami. Kolekcija ir jos modeliai liks, o sinchronizavimą vėl galėsite įjungti per Thingport Grab.",
"stopMessage": "Nauji modeliai į „{{name}}“ nebebus įtraukiami. Kolekcija ir jos modeliai liks, o sinchronizavimą vėl galėsite įjungti čia arba per Thingport Grab.",
"saveFailed": "Nepavyko išsaugoti sinchronizavimo nustatymų.",
"syncNow": "Sinchronizuoti dabar",
"syncNowTitle": "Sinchronizuoti dabar?",
@@ -1951,7 +1951,8 @@
"saved": "Sinchronizavimo konfigūracija išsaugota.",
"sections": {
"overview": "Apžvalga",
"settings": "Nustatymai"
"settings": "Nustatymai",
"browse": "Naršyti"
},
"intro_one": "Į {{provider}} kolekciją pridėti modeliai čia importuojami kas valandą.",
"intro_few": "Į {{provider}} kolekciją pridėti modeliai čia importuojami kas {{count}} valandas.",
@@ -1966,7 +1967,53 @@
"introLikes_one": "Modeliai, kurie patinka @{{user}} Thingiverse, čia importuojami kas valandą.",
"introLikes_few": "Modeliai, kurie patinka @{{user}} Thingiverse, čia importuojami kas {{count}} valandas.",
"introLikes_many": "Modeliai, kurie patinka @{{user}} Thingiverse, čia importuojami kas {{count}} valandos.",
"introLikes_other": "Modeliai, kurie patinka @{{user}} Thingiverse, čia importuojami kas {{count}} valandų."
"introLikes_other": "Modeliai, kurie patinka @{{user}} Thingiverse, čia importuojami kas {{count}} valandų.",
"browse": {
"providerLabel": "Tiekėjas",
"models_one": "{{count}} modelis",
"models_few": "{{count}} modeliai",
"models_many": "{{count}} modelio",
"models_other": "{{count}} modelių",
"things_one": "{{count}} modelis",
"things_few": "{{count}} modeliai",
"things_many": "{{count}} modelio",
"things_other": "{{count}} modelių",
"takenBy": "Sinchronizuojama su „{{name}}“",
"loadFailed": "Nepavyko įkelti kolekcijų.",
"lookupFailed": "Nepavyko rasti šios kolekcijos.",
"notALink": "Įklijuokite MakerWorld, Printables arba Thingiverse kolekcijos nuorodą.",
"usernameFailed": "Nepavyko išsaugoti vartotojo vardo.",
"openProviders": "Atidaryti tiekėjus",
"notConnected": {
"makerworld": "Prijunkite MakerWorld per Konfigūracija > Tiekėjai, 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."
},
"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ą."
},
"usernamePlaceholder": {
"printables": "@vardas arba profilio nuoroda",
"thingiverse": "Vartotojo vardas arba profilio nuoroda"
},
"showing": "Rodomos viešos @{{user}} kolekcijos.",
"change": "Keisti",
"empty": "{{provider}} kolekcijų dar nėra.",
"pasteLabel": "Kito žmogaus kolekcija",
"pastedTaken": "Ši kolekcija jau sinchronizuojama su „{{name}}“. Tiekėjo kolekcija gali būti sinchronizuojama tik su viena Thingport kolekcija.",
"confirmMakerworld": "Nuo šiol į „{{source}}“ įtraukti modeliai pateks į „{{name}}“. Dabar joje esantys modeliai neimportuojami, nes MakerWorld greitai riboja masinius importus; jau jūsų bibliotekoje esantys bus pridėti į „{{name}}“.",
"confirmImport_one": "{{count}} modelis iš „{{source}}“ dabar importuojamas į „{{name}}“, o vėliau pridėti modeliai pateks savaime.",
"confirmImport_few": "{{count}} modeliai iš „{{source}}“ dabar importuojami į „{{name}}“, o vėliau pridėti modeliai pateks savaime.",
"confirmImport_many": "{{count}} modelio iš „{{source}}“ dabar importuojama į „{{name}}“, o vėliau pridėti modeliai pateks savaime.",
"confirmImport_other": "{{count}} modelių iš „{{source}}“ dabar importuojama į „{{name}}“, o vėliau pridėti modeliai pateks savaime.",
"start": "Sinchronizuoti su „{{name}}“",
"started": "„{{name}}“ dabar sinchronizuojama su {{provider}} kolekcija.",
"startedImporting": "„{{name}}“ dabar sinchronizuojama su {{provider}} kolekcija. Jos modeliai importuojami; gausite pranešimą.",
"pasteHint": "Įklijuokite jos nuorodą iš MakerWorld, Printables arba Thingiverse.",
"yoursLabel": "Jūsų {{provider}} kolekcijos"
}
}
},
"tags": {
@@ -162,7 +162,8 @@ export default function CollectionActionsMenu({
<ListItemText sx={{ color: "error.main" }}>{t("common:delete")}</ListItemText>
</MenuItem>
{/* Menu needs its items as direct children, not wrapped in a fragment. */}
{collection.sync && [
{/* Every collection can be synced; an unsynced one opens on Browse. */}
{[
<Divider key="sync-divider" />,
<MenuItem
key="sync"
@@ -183,9 +184,9 @@ export default function CollectionActionsMenu({
<CollectionFormModal collection={collection} onClose={() => setEditOpen(false)} onSubmit={handleEdit} />
)}
{syncOpen && collection.sync && (
{syncOpen && (
<CollectionSyncDialog
collection={{ ...collection, sync: collection.sync }}
collection={collection}
onClose={() => setSyncOpen(false)}
onUpdated={onUpdated}
onUnauthorized={onUnauthorized}
@@ -2,12 +2,14 @@ import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import Alert from "@mui/material/Alert";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Link from "@mui/material/Link";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import SyncIcon from "@mui/icons-material/Sync";
import SyncDisabledIcon from "@mui/icons-material/SyncDisabled";
import HubOutlinedIcon from "@mui/icons-material/HubOutlined";
import TravelExploreIcon from "@mui/icons-material/TravelExplore";
import TuneIcon from "@mui/icons-material/Tune";
import { UnauthorizedError } from "../../api/client";
import {
@@ -17,22 +19,29 @@ import {
SYNC_INTERVAL_HOURS,
collectionsApi,
} from "../../api/collections";
import type { BrowsableProvider } from "../../api/providerCollections";
import { useConfirm } from "../../components/ConfirmProvider";
import { useToast } from "../../components/ToastProvider";
import { importProviderInfo } from "../../constants/importProviders";
import { relativeTime } from "../../utils/relativeTime";
import SectionedDialog, { type DialogSection } from "../../components/SectionedDialog";
import SyncChain from "./SyncChain";
import SyncBrowse, { type BrowsePick } from "./SyncBrowse";
import HelpTip from "../../components/HelpTip";
import Segmented from "../../components/controls/Segmented";
import { SectionLabel } from "../../components/Layout/ConfigurationDialog/parts";
const SCOPES: SyncProfileScope[] = ["url", "designer", "all"];
type SyncSection = "overview" | "settings";
type SyncSection = "overview" | "browse" | "settings";
// What a sync turned on here starts with; Settings can change them first.
const NEW_SYNC_INTERVAL: SyncIntervalHours = 24;
const NEW_SYNC_SCOPE: SyncProfileScope = "designer";
type Props = {
collection: Collection & { sync: NonNullable<Collection["sync"]> };
/** Unsynced, it opens on Browse, to pick the provider collection to sync with. */
collection: Collection;
onClose: () => void;
onUpdated: (collection: Collection) => void;
onUnauthorized?: () => void;
@@ -69,28 +78,32 @@ function minutesSeconds(ms: number): string {
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")}`;
}
/** A synced collection's sync, in two sections like the app's Configuration: Overview (the link,
* its status, Sync now and Stop syncing) and Settings (how often it's checked and, for MakerWorld,
* which print profiles each new model brings). */
/** A collection's sync, in sections like the app's Configuration. Synced: Overview (the link, its
* status, Sync now and Stop syncing). Not yet: Browse (the provider collection to sync with). Both
* have Settings: how often it's checked and, for MakerWorld, which print profiles each new model
* brings. */
export default function CollectionSyncDialog({ collection, onClose, onUpdated, onUnauthorized }: Props) {
const { t, i18n } = useTranslation(["models", "common"]);
const confirm = useConfirm();
const showToast = useToast();
const { sync } = collection;
const provider = importProviderInfo(sync.provider)?.label ?? sync.provider;
const hasProfiles = sync.provider === "makerworld";
const [scope, setScope] = useState<SyncProfileScope>(sync.profile_scope);
const [intervalHours, setIntervalHours] = useState<SyncIntervalHours>(sync.interval_hours);
const [browseProvider, setBrowseProvider] = useState<BrowsableProvider | null>(null);
const [picked, setPicked] = useState<BrowsePick | null>(null);
const providerId = sync?.provider ?? picked?.provider ?? browseProvider;
const provider = importProviderInfo(providerId)?.label ?? providerId ?? "";
const hasProfiles = providerId === "makerworld";
const [scope, setScope] = useState<SyncProfileScope>(sync?.profile_scope ?? NEW_SYNC_SCOPE);
const [intervalHours, setIntervalHours] = useState<SyncIntervalHours>(sync?.interval_hours ?? NEW_SYNC_INTERVAL);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [section, setSection] = useState<SyncSection>("overview");
const [section, setSection] = useState<SyncSection>(sync ? "overview" : "browse");
const readyAt = sync.sync_now_at ? new Date(sync.sync_now_at).getTime() : null;
const readyAt = sync?.sync_now_at ? new Date(sync.sync_now_at).getTime() : null;
const now = useNowUntil(readyAt);
const coolingMs = readyAt ? readyAt - now : 0;
const cooling = coolingMs > 0;
// How far through the cool-down, from the last check to when Sync now is back.
const startedAt = sync.last_checked_at ? new Date(sync.last_checked_at).getTime() : null;
const startedAt = sync?.last_checked_at ? new Date(sync.last_checked_at).getTime() : null;
const coolProgress =
cooling && readyAt && startedAt && readyAt > startedAt ? ((now - startedAt) / (readyAt - startedAt)) * 100 : 0;
@@ -153,31 +166,59 @@ export default function CollectionSyncDialog({ collection, onClose, onUpdated, o
if (!saved) revert();
};
// Before sync is on, the choices wait for it.
const changeScope = (next: SyncProfileScope) => {
const previous = scope;
setScope(next);
void saveSetting({ profile_scope: next }, () => setScope(previous));
if (sync) void saveSetting({ profile_scope: next }, () => setScope(previous));
};
const changeInterval = (next: SyncIntervalHours) => {
const previous = intervalHours;
setIntervalHours(next);
void saveSetting({ interval_hours: next }, () => setIntervalHours(previous));
if (sync) void saveSetting({ interval_hours: next }, () => setIntervalHours(previous));
};
const checked = sync.last_checked_at
const startSync = async () => {
if (!picked) return;
await run(async () => {
const result = await collectionsApi.enableSync(collection.id, {
url: picked.collection.url,
interval_hours: intervalHours,
...(picked.provider === "makerworld" ? { profile_scope: scope } : {}),
});
onUpdated({ ...collection, sync: result.sync });
showToast({
message: t(
result.importing
? "models:collections.sync.browse.startedImporting"
: "models:collections.sync.browse.started",
{ name: collection.name, provider },
),
});
setSection("overview");
});
};
const checked = sync?.last_checked_at
? t("models:collections.sync.lastChecked", { when: relativeTime(sync.last_checked_at, i18n.language) })
: t("models:collections.sync.notCheckedYet");
const next = sync.next_sync_at
const next = sync?.next_sync_at
? t("models:collections.sync.nextSync", { time: clockTime(sync.next_sync_at, i18n.language) })
: null;
const sections: DialogSection<SyncSection>[] = [
{
id: "overview",
label: t("models:collections.sync.sections.overview"),
icon: <HubOutlinedIcon fontSize="small" />,
},
sync
? {
id: "overview",
label: t("models:collections.sync.sections.overview"),
icon: <HubOutlinedIcon fontSize="small" />,
}
: {
id: "browse",
label: t("models:collections.sync.sections.browse"),
icon: <TravelExploreIcon fontSize="small" />,
},
{ id: "settings", label: t("models:collections.sync.sections.settings"), icon: <TuneIcon fontSize="small" /> },
];
@@ -189,6 +230,7 @@ export default function CollectionSyncDialog({ collection, onClose, onUpdated, o
sections={sections}
section={section}
onSectionChange={setSection}
size={sync ? "default" : "large"}
>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
@@ -196,7 +238,38 @@ export default function CollectionSyncDialog({ collection, onClose, onUpdated, o
</Alert>
)}
{section === "overview" && (
{section === "browse" && !sync && (
<Stack spacing={2.5}>
<SyncBrowse
provider={browseProvider}
onProviderChange={setBrowseProvider}
picked={picked}
onPick={setPicked}
onUnauthorized={onUnauthorized}
/>
{picked && (
<Box sx={{ borderTop: 1, borderColor: "divider", pt: 2 }}>
<Typography variant="body2" sx={{ mb: 1.5 }}>
{picked.provider === "makerworld"
? t("models:collections.sync.browse.confirmMakerworld", {
source: picked.collection.name,
name: collection.name,
})
: t("models:collections.sync.browse.confirmImport", {
source: picked.collection.name,
name: collection.name,
count: picked.collection.model_count,
})}
</Typography>
<Button variant="contained" onClick={() => void startSync()} disabled={busy}>
{t("models:collections.sync.browse.start", { name: picked.collection.name })}
</Button>
</Box>
)}
</Stack>
)}
{section === "overview" && sync && (
<Stack spacing={2}>
<Box>
<Typography variant="body2">
@@ -215,7 +288,7 @@ export default function CollectionSyncDialog({ collection, onClose, onUpdated, o
</Link>
</Box>
<SyncChain
collection={collection}
collection={{ ...collection, sync }}
syncNow={{
label: t("models:collections.sync.syncNow"),
icon: <SyncIcon fontSize="small" />,
@@ -0,0 +1,274 @@
import { useTranslation } from "react-i18next";
import Box from "@mui/material/Box";
import ButtonBase from "@mui/material/ButtonBase";
import Typography from "@mui/material/Typography";
import type { Theme } from "@mui/material/styles";
import CheckIcon from "@mui/icons-material/Check";
import LockOutlinedIcon from "@mui/icons-material/LockOutlined";
import ViewInArOutlinedIcon from "@mui/icons-material/ViewInArOutlined";
import type { BrowsableProvider, ProviderCollection } from "../../api/providerCollections";
// Each provider's own accent, so a tile reads as theirs.
export const PROVIDER_ACCENTS: Record<BrowsableProvider, string> = {
makerworld: "#00AE42",
printables: "#FA6831",
thingiverse: "#5575FE",
};
type Props = {
provider: BrowsableProvider;
collection: ProviderCollection;
selected: boolean;
onSelect: () => void;
};
const sunken = (theme: Theme) => (theme.palette.mode === "dark" ? "rgba(255, 255, 255, 0.06)" : "#f4f4f4");
/** A model image, or an empty slot when the collection has fewer. */
function Thumb({ src, sx }: { src: string | undefined; sx?: object }) {
const base = { display: "block", width: "100%", bgcolor: sunken, ...sx };
return src ? (
<Box component="img" src={src} alt="" loading="lazy" sx={{ ...base, objectFit: "cover" }} />
) : (
<Box sx={base} />
);
}
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 })}
</>
);
}
/** MakerWorld: a 2x2 mosaic, "+N" over the last tile, the deck edges below. */
function MakerWorldTile({ collection }: { collection: ProviderCollection }) {
const more = collection.model_count - 4;
return (
<Box
sx={{
borderRadius: "12px",
border: 1,
borderColor: "divider",
bgcolor: "background.paper",
overflow: "hidden",
boxShadow: (theme) =>
`0 6px 0 -2px ${theme.palette.mode === "dark" ? "#3a3a3a" : "#ebebeb"}, 0 11px 0 -5px ${theme.palette.mode === "dark" ? "#2e2e2e" : "#f2f2f2"}`,
mb: "11px",
}}
>
<Box
sx={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gridTemplateRows: "1fr 1fr",
gap: "2px",
aspectRatio: "4 / 3",
}}
>
{[0, 1, 2, 3].map((i) => (
<Box key={i} sx={{ position: "relative", minHeight: 0, overflow: "hidden" }}>
<Thumb src={collection.thumbnails[i]} sx={{ width: "100%", height: "100%" }} />
{i === 3 && more > 0 && (
<Box
sx={{
position: "absolute",
inset: 0,
display: "grid",
placeItems: "center",
bgcolor: "rgba(0, 0, 0, 0.45)",
color: "#fff",
fontWeight: 700,
fontSize: "1.25rem",
}}
>
+{more}
</Box>
)}
</Box>
))}
</Box>
<Box sx={{ px: 1.5, py: 1.25 }}>
<Typography noWrap sx={{ fontWeight: 700, fontSize: "0.875rem" }}>
{collection.name}
</Typography>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 0.5,
mt: 0.25,
color: "text.secondary",
fontSize: "0.75rem",
}}
>
<ViewInArOutlinedIcon sx={{ fontSize: 14 }} />
<ModelCount count={collection.model_count} provider="makerworld" />
{collection.private && <LockOutlinedIcon sx={{ fontSize: 14, ml: "auto" }} />}
</Box>
</Box>
</Box>
);
}
/** Printables: its list row, boxed, with the three square thumbnails along the top. */
function PrintablesTile({ collection }: { collection: ProviderCollection }) {
return (
<Box
sx={{
borderRadius: "4px",
border: 1,
borderColor: "divider",
borderTop: `3px solid ${PROVIDER_ACCENTS.printables}`,
bgcolor: "background.paper",
p: 1.25,
}}
>
<Box sx={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: "4px" }}>
{[0, 1, 2].map((i) => (
<Thumb key={i} src={collection.thumbnails[i]} sx={{ aspectRatio: "1 / 1" }} />
))}
</Box>
<Typography noWrap sx={{ fontWeight: 700, fontSize: "0.875rem", mt: 1 }}>
{collection.name}
</Typography>
<Typography sx={{ fontSize: "0.8rem", color: "text.secondary" }}>
<ModelCount count={collection.model_count} provider="printables" />
</Typography>
</Box>
);
}
// Thingiverse's card: rings of lighter blue around the bottom, where the models stack up.
const RINGS = [
{ size: 200, color: "#718BFE" },
{ size: 170, color: "#9DAFFF" },
{ size: 130, color: "#BDC9FF" },
{ size: 80, color: "#EAEEFF" },
];
function ThingiverseTile({ collection }: { collection: ProviderCollection }) {
const stack = collection.thumbnails.slice(0, 3);
return (
<Box
sx={{
position: "relative",
overflow: "hidden",
borderRadius: "8px",
bgcolor: PROVIDER_ACCENTS.thingiverse,
aspectRatio: "4 / 3",
color: "#fff",
textAlign: "center",
}}
>
{RINGS.map(({ size, color }) => (
<Box
key={size}
sx={{
position: "absolute",
left: "50%",
top: "62%",
width: `${size}%`,
aspectRatio: "1.4 / 1",
transform: "translateX(-50%)",
borderRadius: "100%",
bgcolor: color,
}}
/>
))}
<Box sx={{ position: "relative", px: 1.5, pt: 1.5 }}>
<Typography noWrap sx={{ fontWeight: 600, fontSize: "1rem", lineHeight: 1.3, color: "#fff" }}>
{collection.name}
</Typography>
<Typography sx={{ fontSize: "0.8rem", color: "#fff" }}>
<ModelCount count={collection.model_count} provider="thingiverse" />
</Typography>
</Box>
{stack.map((src, i) => (
// The first model in front, the others peeking out above it.
<Box
key={src}
sx={{
position: "absolute",
left: "50%",
bottom: `${-38 + (stack.length - 1 - i) * 7}%`,
width: `${78 - (stack.length - 1 - i) * 8}%`,
transform: "translateX(-50%)",
zIndex: stack.length - i,
borderRadius: "8px",
overflow: "hidden",
border: "1px solid rgba(255, 255, 255, 0.9)",
aspectRatio: "4 / 3",
}}
>
<Thumb src={src} sx={{ width: "100%", height: "100%" }} />
</Box>
))}
</Box>
);
}
const TILES: Record<BrowsableProvider, (props: { collection: ProviderCollection }) => JSX.Element> = {
makerworld: MakerWorldTile,
printables: PrintablesTile,
thingiverse: ThingiverseTile,
};
/** A provider's collection to pick, drawn in that provider's colours and manner. One already synced
* with another Thingport collection can't be picked, and says with which. */
export default function ProviderCollectionTile({ provider, collection, selected, onSelect }: Props) {
const { t } = useTranslation("models");
const Tile = TILES[provider];
const taken = collection.synced !== null;
return (
<Box>
<ButtonBase
onClick={onSelect}
disabled={taken}
aria-pressed={selected}
sx={{
position: "relative",
display: "block",
width: "100%",
textAlign: "left",
borderRadius: "12px",
opacity: taken ? 0.5 : 1,
outline: selected ? `2px solid ${PROVIDER_ACCENTS[provider]}` : "2px solid transparent",
outlineOffset: 3,
transition: "outline-color 150ms, transform 150ms",
"&:hover": taken ? undefined : { transform: "translateY(-2px)" },
"&:focus-visible": { outline: `2px solid ${PROVIDER_ACCENTS[provider]}` },
}}
>
<Tile collection={collection} />
{selected && (
<Box
sx={{
position: "absolute",
top: -6,
right: -6,
zIndex: 5,
width: 22,
height: 22,
borderRadius: "50%",
display: "grid",
placeItems: "center",
bgcolor: PROVIDER_ACCENTS[provider],
color: "#fff",
boxShadow: 1,
}}
>
<CheckIcon sx={{ fontSize: 15 }} />
</Box>
)}
</ButtonBase>
{taken && (
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 0.5 }}>
{t("collections.sync.browse.takenBy", { name: collection.synced!.collection_name })}
</Typography>
)}
</Box>
);
}
@@ -0,0 +1,319 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import Alert from "@mui/material/Alert";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import CircularProgress from "@mui/material/CircularProgress";
import Link from "@mui/material/Link";
import Stack from "@mui/material/Stack";
import TextField from "@mui/material/TextField";
import Typography from "@mui/material/Typography";
import { UnauthorizedError } from "../../api/client";
import {
BROWSABLE_PROVIDERS,
type BrowsableProvider,
type ProviderAccounts,
type ProviderCollection,
providerCollectionsApi,
} from "../../api/providerCollections";
import { importProviderInfo } from "../../constants/importProviders";
import Segmented from "../../components/controls/Segmented";
import { SectionLabel } from "../../components/Layout/ConfigurationDialog/parts";
import { useSlicerPreference } from "../../hooks/useSlicerPreference";
import { onConfigurationClosed, openConfiguration } from "../../utils/configurationEvents";
import ProviderCollectionTile from "./ProviderCollectionTile";
export type BrowsePick = { provider: BrowsableProvider; collection: ProviderCollection };
type Props = {
provider: BrowsableProvider | null;
onProviderChange: (provider: BrowsableProvider) => void;
picked: BrowsePick | null;
onPick: (pick: BrowsePick | null) => void;
onUnauthorized?: () => void;
};
const PASTE_DEBOUNCE_MS = 400;
const PASTE_EXAMPLES: Record<BrowsableProvider, string> = {
makerworld: "https://makerworld.com/en/collections/…",
printables: "https://www.printables.com/@…/collections/…",
thingiverse: "https://www.thingiverse.com/…/collections/…",
};
/** The one the user's slicer goes with, unless it isn't connected; then the first that is. */
function defaultProvider(accounts: ProviderAccounts, slicer: string | null): BrowsableProvider {
const preferred = slicer === "bambustudio" ? "makerworld" : slicer === "prusaslicer" ? "printables" : "thingiverse";
if (accounts[preferred].connected) return preferred;
return BROWSABLE_PROVIDERS.find((provider) => accounts[provider].connected) ?? preferred;
}
function providerOfUrl(url: string): BrowsableProvider | null {
try {
const host = new URL(url).hostname.toLowerCase();
return BROWSABLE_PROVIDERS.find((provider) => host.endsWith(`${provider}.com`)) ?? null;
} catch {
return null;
}
}
/** Picking the provider collection an unsynced Thingport collection should follow: the user's own
* collections on the chosen provider, or any collection by its link. */
export default function SyncBrowse({ provider, onProviderChange, picked, onPick, onUnauthorized }: Props) {
const { t } = useTranslation(["models", "common"]);
const slicer = useSlicerPreference();
const [accounts, setAccounts] = useState<ProviderAccounts | null>(null);
const [collections, setCollections] = useState<Partial<Record<BrowsableProvider, ProviderCollection[]>>>({});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [username, setUsername] = useState("");
const [savingUsername, setSavingUsername] = useState(false);
const [editingAccount, setEditingAccount] = useState(false);
const [pasted, setPasted] = useState("");
const [pastedResult, setPastedResult] = useState<{ pick: BrowsePick } | { error: string } | "loading" | null>(null);
const fail = useCallback(
(err: unknown, fallback: string) => {
if (err instanceof UnauthorizedError) onUnauthorized?.();
else setError(err instanceof Error ? err.message : fallback);
},
[onUnauthorized],
);
const loadAccounts = useCallback(() => {
providerCollectionsApi
.accounts()
.then((next) => {
setAccounts(next);
// A provider connected in Configuration lists afresh.
setCollections({});
})
.catch((err) => fail(err, t("models:collections.sync.browse.loadFailed")));
}, [fail, t]);
useEffect(() => {
loadAccounts();
return onConfigurationClosed(loadAccounts);
}, [loadAccounts]);
// Waits for the slicer too, so the default doesn't flip once it arrives.
useEffect(() => {
if (accounts && !provider) onProviderChange(defaultProvider(accounts, slicer));
}, [accounts, provider, slicer, onProviderChange]);
const account = provider && accounts ? accounts[provider] : null;
const needsAccount = provider !== null && provider !== "makerworld" && account?.connected && !account.account;
const canList = Boolean(account?.connected && (provider === "makerworld" || account.account));
useEffect(() => {
if (!provider || !canList || collections[provider]) return;
let active = true;
setLoading(true);
setError(null);
providerCollectionsApi
.list(provider)
.then((list) => active && setCollections((current) => ({ ...current, [provider]: list })))
.catch((err) => active && fail(err, t("models:collections.sync.browse.loadFailed")))
.finally(() => active && setLoading(false));
return () => {
active = false;
};
}, [provider, canList, collections, fail, t]);
// A pasted link shows as its card once it's recognised.
useEffect(() => {
const url = pasted.trim();
if (!url) {
setPastedResult(null);
return;
}
const linkProvider = providerOfUrl(url);
if (!linkProvider) {
setPastedResult({ error: t("models:collections.sync.browse.notALink") });
return;
}
let active = true;
setPastedResult("loading");
const timer = setTimeout(() => {
providerCollectionsApi
.lookup(url)
.then((collection) => {
if (!active) return;
const pick = { provider: linkProvider, collection };
setPastedResult({ pick });
if (!collection.synced) onPick(pick);
})
.catch((err) => {
if (!active) return;
if (err instanceof UnauthorizedError) onUnauthorized?.();
else
setPastedResult({
error: err instanceof Error ? err.message : t("models:collections.sync.browse.lookupFailed"),
});
});
}, PASTE_DEBOUNCE_MS);
return () => {
active = false;
clearTimeout(timer);
};
}, [pasted, onPick, onUnauthorized, t]);
const saveUsername = async () => {
if (!provider || !username.trim()) return;
setSavingUsername(true);
setError(null);
try {
setAccounts(await providerCollectionsApi.setAccount(provider, username.trim()));
setCollections((current) => ({ ...current, [provider]: undefined }));
setEditingAccount(false);
setUsername("");
} catch (err) {
fail(err, t("models:collections.sync.browse.usernameFailed"));
} finally {
setSavingUsername(false);
}
};
const isPicked = (p: BrowsableProvider, collection: ProviderCollection) =>
picked?.provider === p && picked.collection.id === collection.id;
const tiles = (p: BrowsableProvider, list: ProviderCollection[]) => (
<Box sx={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(170px, 1fr))", gap: 2 }}>
{list.map((collection) => (
<ProviderCollectionTile
key={collection.id}
provider={p}
collection={collection}
selected={isPicked(p, collection)}
onSelect={() => onPick(isPicked(p, collection) ? null : { provider: p, collection })}
/>
))}
</Box>
);
if (!accounts || !provider) {
return error ? <Alert severity="error">{error}</Alert> : <CircularProgress size={24} />;
}
const name = importProviderInfo(provider)?.label ?? provider;
const list = collections[provider];
return (
<Stack spacing={2.5}>
{/* Wrapped, so the stack doesn't stretch the picker across the dialog. */}
<div>
<Segmented
label={t("models:collections.sync.browse.providerLabel")}
value={provider}
onChange={(next) => {
setError(null);
setEditingAccount(false);
onProviderChange(next);
}}
options={BROWSABLE_PROVIDERS.map((value) => ({ value, label: importProviderInfo(value)?.label ?? value }))}
/>
</div>
{/* First, so it's in view however long the list of the user's own is. */}
<Box>
<SectionLabel>{t("models:collections.sync.browse.pasteLabel")}</SectionLabel>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
{t("models:collections.sync.browse.pasteHint")}
</Typography>
<TextField
size="small"
fullWidth
value={pasted}
onChange={(e) => setPasted(e.target.value)}
placeholder={PASTE_EXAMPLES[provider]}
/>
{pastedResult === "loading" && <CircularProgress size={20} sx={{ mt: 1.5 }} />}
{pastedResult && pastedResult !== "loading" && "error" in pastedResult && (
<Alert severity="warning" sx={{ mt: 1.5 }}>
{pastedResult.error}
</Alert>
)}
{pastedResult && pastedResult !== "loading" && "pick" in pastedResult && (
<Box sx={{ mt: 1.5 }}>
{pastedResult.pick.collection.synced && (
<Alert severity="warning" sx={{ mb: 1.5 }}>
{t("models:collections.sync.browse.pastedTaken", {
name: pastedResult.pick.collection.synced.collection_name,
})}
</Alert>
)}
<Box sx={{ maxWidth: 220 }}>{tiles(pastedResult.pick.provider, [pastedResult.pick.collection])}</Box>
</Box>
)}
</Box>
{error && <Alert severity="error">{error}</Alert>}
{!account?.connected && (
<Alert
severity="info"
action={
<Button color="inherit" size="small" onClick={() => openConfiguration("providers")}>
{t("models:collections.sync.browse.openProviders")}
</Button>
}
>
{t(`models:collections.sync.browse.notConnected.${provider}`)}
</Alert>
)}
{account?.connected && (needsAccount || editingAccount) && (
<Box>
<SectionLabel>{t("models:collections.sync.browse.usernameLabel", { provider: name })}</SectionLabel>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
{t(`models:collections.sync.browse.usernameHint.${provider}`)}
</Typography>
<Stack direction="row" spacing={1}>
<TextField
size="small"
fullWidth
value={username}
onChange={(e) => setUsername(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void saveUsername();
}}
placeholder={t(`models:collections.sync.browse.usernamePlaceholder.${provider}`)}
disabled={savingUsername}
/>
<Button
variant="contained"
onClick={() => void saveUsername()}
disabled={savingUsername || !username.trim()}
>
{t("common:save")}
</Button>
</Stack>
</Box>
)}
{canList && !editingAccount && (
<Box>
<SectionLabel>{t("models:collections.sync.browse.yoursLabel", { provider: name })}</SectionLabel>
{provider !== "makerworld" && account?.account && (
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
{t("models:collections.sync.browse.showing", { user: account.account })}{" "}
<Link component="button" variant="body2" onClick={() => setEditingAccount(true)}>
{t("models:collections.sync.browse.change")}
</Link>
</Typography>
)}
{loading && !list ? (
<CircularProgress size={24} />
) : list && list.length ? (
tiles(provider, list)
) : (
list && (
<Typography variant="body2" color="text.secondary">
{t("models:collections.sync.browse.empty", { provider: name })}
</Typography>
)
)}
</Box>
)}
</Stack>
);
}
+26
View File
@@ -0,0 +1,26 @@
import type { ConfigurationTab } from "../components/Layout/ConfigurationDialog";
// The top bar owns the Configuration dialog; anything else asks it to open through these.
const OPEN_EVENT = "thingport:open-configuration";
const CLOSED_EVENT = "thingport:configuration-closed";
export function openConfiguration(tab: ConfigurationTab): void {
window.dispatchEvent(new CustomEvent<ConfigurationTab>(OPEN_EVENT, { detail: tab }));
}
/** Returns the unsubscribe. */
export function onOpenConfiguration(handler: (tab: ConfigurationTab) => void): () => void {
const listener = (event: Event) => handler((event as CustomEvent<ConfigurationTab>).detail);
window.addEventListener(OPEN_EVENT, listener);
return () => window.removeEventListener(OPEN_EVENT, listener);
}
export function notifyConfigurationClosed(): void {
window.dispatchEvent(new Event(CLOSED_EVENT));
}
/** For whatever depends on settings changed there, e.g. a provider just connected. */
export function onConfigurationClosed(handler: () => void): () => void {
window.addEventListener(CLOSED_EVENT, handler);
return () => window.removeEventListener(CLOSED_EVENT, handler);
}