feat: browse private collections functionality

This commit is contained in:
Tautvydas Derzinskas committed 2026-10-12 12:54:26 +02:00
1 parent d3ed67342a
commit b619084be7
33 files changed
+1409 -160

No files matched your search

@@ -0,0 +1,14 @@
CREATE TABLE "ProviderListing" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"key" TEXT NOT NULL,
"data" JSONB NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProviderListing_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "ProviderListing_userId_provider_key_key" ON "ProviderListing"("userId", "provider", "key");
ALTER TABLE "ProviderListing" ADD CONSTRAINT "ProviderListing_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1 @@
ALTER TABLE "User" ADD COLUMN "printablesPushOff" BOOLEAN NOT NULL DEFAULT false;
+31 -10
View File
@@ -20,23 +20,23 @@ enum CategorySource {
}
model User {
id String @id @default(cuid())
email String @unique
id String @id @default(cuid())
email String @unique
passwordHash String
displayName String
role Role @default(MEMBER)
role Role @default(MEMBER)
// Defaults true so existing accounts aren't locked out; registration sets false when SMTP is on.
emailVerified Boolean @default(true)
emailVerificationToken String? @unique
emailVerified Boolean @default(true)
emailVerificationToken String? @unique
emailVerificationExpires DateTime?
// Awaiting confirmation of an email change.
pendingEmail String?
// SHA-256 of the emailed token, so a database leak can't be used to take over accounts.
passwordResetTokenHash String? @unique
passwordResetTokenHash String? @unique
passwordResetExpires DateTime?
// A read-only token other apps use to call the API as this user. Only its SHA-256
// is stored; the token itself is shown once, when generated. One per user: generating replaces it.
apiTokenHash String? @unique
apiTokenHash String? @unique
// The token's last four characters, so the profile can show which one is in use.
apiTokenHint String?
apiTokenCreatedAt DateTime?
@@ -47,6 +47,9 @@ model User {
// syncing. Printables keeps its numeric id (the API's key) with the handle to show.
printablesUserId String?
printablesHandle String?
// Set when the user forgot their Printables account in the app: Thingport Grab's pushes of their
// private collections and likes are refused until they give one again.
printablesPushOff Boolean @default(false)
thingiverseUsername String?
slicer String?
// "light" | "dark" | "system"; null means never set.
@@ -62,7 +65,7 @@ model User {
dashboardThumbnails String?
// Where the dashboard's weather is for: { lat, lon, name, source }. Null until the user sets one.
weatherLocation Json?
authorPreviewEnabled Boolean @default(true)
authorPreviewEnabled Boolean @default(true)
// Filled from the first linked Author with this data; later links never overwrite it.
bio String?
backgroundUrl String?
@@ -70,6 +73,7 @@ model User {
prints Print[]
collections Collection[]
collectionSyncs CollectionSync[]
providerListings ProviderListing[]
importJobs ImportJob[]
notifications Notification[]
systemReceipts SystemReceipt[]
@@ -78,8 +82,8 @@ model User {
bookmarks Bookmark[]
activities Activity[]
invitationsSent Invitation[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// Lets one email register while registrations are closed. Re-inviting replaces the token.
@@ -230,6 +234,23 @@ model CollectionSync {
@@unique([userId, provider, externalId])
}
/// Lists Thingport Grab reads from a provider with the user's own login, for what the instance
/// can't read itself: Printables shows private collections and likes to their owner only. Pushed
/// on the user's visits to the site, so they're as fresh as the last one.
model ProviderListing {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
provider String
/// "collections": the user's collections, as the Browse tab shows them. "likes": their liked
/// models. Anything else: a collection's id, for its model ids.
key String
data Json
updatedAt DateTime @updatedAt
@@unique([userId, provider, key])
}
enum BookmarkType {
TAG
COLLECTION
+6 -4
View File
@@ -342,7 +342,8 @@ export type CollectionSyncOut = {
profile_scope: SyncProfileScope;
last_checked_at: string | null;
last_error: string | null;
/** Set when the sync follows a Thingiverse user's Likes rather than a collection: their username. */
/** Set when the sync follows a user's likes (on Thingiverse, or the user's own on Printables)
* rather than a collection: their username. */
likes_of: string | null;
/** How often the provider's collection is checked: 1, 6 or 24. */
interval_hours: number;
@@ -355,11 +356,12 @@ export type CollectionSyncOut = {
// A Likes sync's externalId; collection ids are numeric, so the two never collide.
export const LIKES_ID_PREFIX = "likes:";
/** The Thingiverse user whose Likes a sync follows, as written in its page address, or null for a
* collection sync. */
/** The user whose likes a sync follows (on Thingiverse or Printables), as written in its page
* address, or null for a collection sync. */
export function syncLikesUsername(sync: { externalId: string; sourceUrl: string }): string | null {
if (!sync.externalId.startsWith(LIKES_ID_PREFIX)) return null;
const fromUrl = new URL(sync.sourceUrl).pathname.split("/").find(Boolean);
// Printables writes its profile paths as "/@handle".
const fromUrl = new URL(sync.sourceUrl).pathname.split("/").find(Boolean)?.replace(/^@/, "");
return fromUrl ? decodeURIComponent(fromUrl) : sync.externalId.slice(LIKES_ID_PREFIX.length);
}
+5 -1
View File
@@ -464,7 +464,11 @@ router.post(
}),
);
const printablesCollectionImportRequestSchema = importRequestSchema.extend({ model_ids: z.array(z.string()).min(1) });
const printablesCollectionImportRequestSchema = importRequestSchema.extend({
model_ids: z.array(z.string()).min(1),
// As Thingport Grab read it: the instance can't read a private collection's name itself.
collection_title: z.string().max(300).nullable().optional(),
});
router.post(
"/import/printables-collection",
+49
View File
@@ -9,9 +9,11 @@ import {
getProviderAccounts,
listProviderCollections,
lookupProviderCollection,
savePrintablesPush,
setProviderAccount,
type BrowsableProvider,
} from "../services/providerCollectionsService";
import { assertPrintablesPushOn, wantedPrintablesListings } from "../services/providerListingsService";
const router = Router();
router.use(requireAuth);
@@ -56,4 +58,51 @@ router.get(
}),
);
// Thingport Grab, on a visit to Printables: which lists to read in full this time.
router.get(
"/provider-collections/printables/wanted",
asyncHandler(async (req, res) => {
// Refused while turned off, so Grab doesn't read anything only to have it refused.
await assertPrintablesPushOn(req.userId!);
res.json({ keys: await wantedPrintablesListings(req.userId!) });
}),
);
const MAX_PUSHED_LISTINGS = 10;
const id = z.string().regex(/^\d+$/).max(20);
const thumbnails = z.array(z.string().url().max(1000)).max(12);
const ids = z.array(id).max(5000);
const pushSchema = z.object({
user: z.object({ id, handle: z.string().min(1).max(100) }),
collections: z
.array(
z.object({
id,
name: z.string().max(300),
model_count: z.number().int().min(0),
thumbnails,
private: z.boolean(),
}),
)
.max(1000)
.optional(),
likes: z.object({ model_count: z.number().int().min(0), thumbnails, ids: ids.optional() }).optional(),
// Grab sends a few per push (the stalest wanted ones); more is a malformed request.
listings: z
.record(id, ids)
.refine((listings) => Object.keys(listings).length <= MAX_PUSHED_LISTINGS, {
message: `At most ${MAX_PUSHED_LISTINGS} collections per push`,
})
.optional(),
});
// Thingport Grab, on a visit to Printables: what it read there with the user's own login.
router.put(
"/provider-collections/printables/push",
asyncHandler(async (req, res) => {
await savePrintablesPush(req.userId!, parseBody(pushSchema, req.body));
res.json({ ok: true });
}),
);
export default router;
+17 -2
View File
@@ -17,6 +17,12 @@ import { COLLECTION_SYNC_ITEM_DELAY_MS, IMPORT_COLLECTION_DELAY_MS, IMPORT_MAKER
import { sleep } from "../utils/concurrency";
import { addPrintsToCollection } from "./collectionService";
import { findLibraryPrints, modelUrlFor, providerName, sourceDescription } from "./collectionSyncService";
import {
PRINTABLES_FORGOTTEN,
printablesPushIsOff,
pushedPrintablesModels,
WAITING_FOR_GRAB,
} from "./providerListingsService";
import { getActiveJob } from "./importJobService";
import { runLinksImportJob, SYNC_JOB_INTERRUPTED } from "./importJobRunner";
import { startLinksJob } from "./importQueueService";
@@ -56,7 +62,16 @@ export async function fetchListing(
return entries.map((e) => e.designId);
}
if (sync.provider === "printables") {
if (!(await checkPrintablesCollection(id))) return null;
// Private collections and likes come from Thingport Grab, as of the user's last visit.
const pushed = await pushedPrintablesModels(sync.userId, id);
if (pushed === "waiting") throw new Error(WAITING_FOR_GRAB);
if (pushed) return pushed;
if (!(await checkPrintablesCollection(id))) {
// Private, most likely: with the user's Printables account forgotten, Grab no longer says which
// ones are. Paused rather than unsynced, until they give it again.
if (await printablesPushIsOff(sync.userId)) throw new Error(PRINTABLES_FORGOTTEN);
return null;
}
const { entries } = await fetchPrintablesCollectionEntries(id);
return entries.map((e) => e.modelId);
}
@@ -263,7 +278,7 @@ async function notifyFiled(sync: SyncWithCollection, printIds: string[]): Promis
type: "sync",
title: `${n} model${n === 1 ? "" : "s"} added to "${sync.collection.name}"`,
body: syncLikesUsername(sync)
? `${n === 1 ? "It was" : "They were"} liked on Thingiverse and already in your library.`
? `${n === 1 ? "It was" : "They were"} liked on ${providerName(sync.provider)} and already in your library.`
: `${n === 1 ? "It was" : "They were"} added to the ${providerName(sync.provider)} collection and already in your library.`,
externalUrl: sync.sourceUrl,
internalPath: `/models/collections/${sync.collectionId}`,
+36 -8
View File
@@ -7,9 +7,10 @@ import { prisma } from "../db";
import { HttpError } from "../utils/fileUtils";
import { addPrintsToCollection, findOrCreateCollectionByName, isSystemCollectionId } from "./collectionService";
import { parseMakerworldCollectionUrl } from "./makerworldCollections";
import { parsePrintablesCollectionUrl } from "./printablesApi";
import { parsePrintablesCollectionUrl, parsePrintablesLikesUrl } from "./printablesApi";
import { parseThingiverseCollectionUrl, parseThingiverseLikesUrl } from "./thingiverseApi";
import { createLog } from "./auditLog";
import { pushedLikes } from "./providerListingsService";
import { createNotification } from "./notificationService";
import type { SyncIntervalHours } from "./collectionSyncSchedule";
import {
@@ -22,7 +23,8 @@ import {
export type SyncProvider = "makerworld" | "printables" | "thingiverse";
/** `likesOf` is set for a Thingiverse user's Likes, which a sync follows like a collection. */
/** `likesOf` is set for a user's likes (on Thingiverse, or the user's own on Printables), which a
* sync follows like a collection. */
export type SyncSource = { provider: SyncProvider; externalId: string; url: string; likesOf?: string };
const PROVIDER_NAMES: Record<SyncProvider, string> = {
@@ -35,15 +37,17 @@ export function providerName(provider: string): string {
return PROVIDER_NAMES[provider as SyncProvider] ?? provider;
}
/** The collection a Thingiverse user's Likes are filed into, by import and sync alike. */
export function likesCollectionName(username: string): string {
return `Thingiverse likes (@${username})`;
/** The collection a user's likes are filed into, by import and sync alike. */
export function likesCollectionName(username: string, provider: string = "thingiverse"): string {
return `${providerName(provider)} likes (@${username})`;
}
/** What a sync follows, for notifications: "its MakerWorld collection", "@alice's Thingiverse likes". */
export function sourceDescription(sync: { provider: string; externalId: string; sourceUrl: string }): string {
const likesOf = syncLikesUsername(sync);
return likesOf ? `@${likesOf}'s Thingiverse likes` : `its ${providerName(sync.provider)} collection`;
return likesOf
? `@${likesOf}'s ${providerName(sync.provider)} likes`
: `its ${providerName(sync.provider)} collection`;
}
/** A provider collection a sync can follow by its id, or a Thingiverse user's Likes by username. */
@@ -63,6 +67,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 printablesLikes = parsePrintablesLikesUrl(url);
if (printablesLikes) {
// Only Thingport Grab can read these, as their owner (see providerListingsService.ts).
return {
provider: "printables",
externalId: `${LIKES_ID_PREFIX}${printablesLikes.handle.toLowerCase()}`,
url: `https://www.printables.com/@${encodeURIComponent(printablesLikes.handle)}/collections/liked`,
likesOf: printablesLikes.handle,
};
}
const likes = parseThingiverseLikesUrl(url);
if (likes) {
// Thingiverse usernames aren't case-sensitive, so one user's Likes sync once however they're typed.
@@ -149,8 +163,9 @@ export async function enableSync(userId: string, input: EnableSyncInput): Promis
if (!source)
throw new HttpError(
400,
"Only MakerWorld, Printables and Thingiverse collections, and Thingiverse Likes, can be synced",
"Only MakerWorld, Printables and Thingiverse 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))];
const existing = await findSyncForSource(userId, source);
@@ -185,7 +200,7 @@ export async function enableSync(userId: string, input: EnableSyncInput): Promis
created = result.created;
} else {
const title = source.likesOf
? likesCollectionName(source.likesOf)
? likesCollectionName(source.likesOf, source.provider)
: input.title?.trim() || `${providerName(source.provider)} Collection ${source.externalId}`;
let found: (CollectionSync & { collection: Collection }) | null = null;
for (const name of collectionNamesFor(title, source.provider)) {
@@ -239,6 +254,19 @@ async function notifySyncEnabled(sync: CollectionSync & { collection: Collection
});
}
/** Printables shows likes to their owner only, and Grab reads them as that user: a sync of someone
* else's would be fed the user's own likes under the other's name. The likes Grab pushed prove whose
* they are; the account saved on the user can be typed into the app, so it proves nothing. */
async function assertOwnPrintablesLikes(userId: string, handle: string): Promise<void> {
const likes = await pushedLikes(userId);
if (likes?.card.owner?.toLowerCase() !== handle.toLowerCase()) {
throw new HttpError(
400,
"Printables shows likes to their owner only, so only your own can be synced, once Thingport Grab has seen them",
);
}
}
// How many "Things (MakerWorld 2)"-style names to try before giving up.
const MAX_NAME_SUFFIX = 20;
+8 -2
View File
@@ -40,7 +40,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 PrintablesCollectionImportJobBody = ImportRequestBody & { model_ids: string[]; collectionId: string };
type PrintablesCollectionImportJobBody = ImportRequestBody & {
model_ids: string[];
collectionId: string;
collection_title?: string | null;
};
type MakerworldProfilesImportJobBody = ImportRequestBody & { scope: Exclude<MakerworldProfileScope, "url"> };
// Why a design failed, so a batch reads as one clear cause instead of "N failed". Once a CAPTCHA
@@ -514,7 +518,9 @@ export async function runPrintablesCollectionImportJob(
let resultCollectionId: string | null = null;
const collectionTitle =
(await fetchPrintablesCollectionTitle(body.collectionId)) ?? `Printables Collection ${body.collectionId}`;
body.collection_title?.trim() ||
(await fetchPrintablesCollectionTitle(body.collectionId)) ||
`Printables Collection ${body.collectionId}`;
if (successPrintIds.length) {
const collection =
(await syncedCollectionFor(userId, body.url)) ?? (await findOrCreateCollectionByName(userId, collectionTitle));
+15 -1
View File
@@ -218,6 +218,20 @@ export async function resolvePrintablesDownloadLinks(modelId: string, fileIds: s
return links;
}
/** A user's liked models, which Printables shows to that user only. */
export function parsePrintablesLikesUrl(url: string): { handle: string } | null {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return null;
}
const host = parsed.hostname.toLowerCase();
if (host !== "printables.com" && host !== "www.printables.com") return null;
const m = parsed.pathname.match(/^\/(?:[a-z]{2}\/)?@([^/]+)\/collections\/liked\/?$/i);
return m ? { handle: decodeURIComponent(m[1]) } : null;
}
/** The `@handle` segment is cosmetic; only the id is used. */
export function parsePrintablesCollectionUrl(url: string): { collectionId: string } | null {
let parsed: URL;
@@ -382,7 +396,7 @@ function collectionCard(value: unknown): ProviderCollectionCard | null {
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/@${encodeURIComponent(owner)}/collections/${id}`
: `https://www.printables.com/collections/${id}`,
modelCount: typeof value.printsCount === "number" ? value.printsCount : 0,
thumbnails,
@@ -3,7 +3,7 @@
import { prisma } from "../db";
import { HttpError } from "../utils/fileUtils";
import type { CollectionSyncOut, SyncProfileScope } from "../dto";
import { LIKES_ID_PREFIX, type CollectionSyncOut, type SyncProfileScope } from "../dto";
import { isSystemCollectionId } from "./collectionService";
import { fetchListing, syncCollection } from "./collectionSyncRunner";
import type { SyncIntervalHours } from "./collectionSyncSchedule";
@@ -13,9 +13,21 @@ import { fetchMakerworldCollectionCard, listMakerworldOwnCollections } from "./m
import { getUserMakerworldCookie } from "./makerworldCookieService";
import { fetchPrintablesCollectionCard, listPrintablesUserCollections, resolvePrintablesUser } from "./printablesApi";
import type { ProviderCollectionCard } from "./providerCollectionCard";
import {
COLLECTIONS_KEY,
LIKES_KEY,
pushedLikes,
pushedPrintablesCards,
assertPrintablesPushOn,
deletePrintablesListings,
deleteStalePrintablesListings,
pushedPrintablesModels,
writePrintablesListing,
} from "./providerListingsService";
import { getThingiverseAccessToken } from "./settingsService";
import {
fetchThingiverseCollectionCard,
fetchThingiverseLikesSummary,
listThingiverseUserCollections,
resolveThingiverseUser,
} from "./thingiverseApi";
@@ -38,6 +50,8 @@ export type ProviderCollectionOut = {
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;
/** The user's liked models, rather than a collection. */
likes: boolean;
};
export async function getProviderAccounts(userId: string): Promise<ProviderAccountsOut> {
@@ -71,10 +85,13 @@ export async function setProviderAccount(
if (provider === "printables") {
const user = value ? await resolvePrintablesUser(value) : null;
if (value && !user) throw new HttpError(404, `Printables has no user "${value}"`);
// Forgetting the account also turns off what Grab sends from Printables, and drops what it sent:
// otherwise its next push would quietly link the account again.
await prisma.user.update({
where: { id: userId },
data: { printablesUserId: user?.id ?? null, printablesHandle: user?.handle ?? null },
data: { printablesUserId: user?.id ?? null, printablesHandle: user?.handle ?? null, printablesPushOff: !user },
});
if (!user) await deletePrintablesListings(userId);
} 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}"`);
@@ -85,6 +102,21 @@ export async function setProviderAccount(
return getProviderAccounts(userId);
}
/** A Thingiverse user's likes, which anyone can see, as one more collection to sync with. */
async function thingiverseLikesCard(username: string, token: string): Promise<ProviderCollectionCard | null> {
const likes = await fetchThingiverseLikesSummary(username, token);
if (!likes) return null;
return {
id: `${LIKES_ID_PREFIX}${likes.username.toLowerCase()}`,
name: "Liked models",
url: `https://www.thingiverse.com/${encodeURIComponent(likes.username)}/likes`,
modelCount: likes.count,
thumbnails: likes.thumbnails,
private: false,
owner: likes.username,
};
}
/** 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 {
@@ -120,6 +152,7 @@ async function withSyncs(
private: card.private,
owner: card.owner,
synced: synced ? { collection_id: synced.id, collection_name: synced.name } : null,
likes: card.id.startsWith(LIKES_ID_PREFIX),
};
});
}
@@ -138,12 +171,25 @@ export async function listProviderCollections(
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);
// Printables shows anyone the public ones; Thingport Grab adds the private ones and the likes.
const pushed = await pushedPrintablesCards(userId);
if (!user?.printablesUserId && !pushed.collections) {
throw new HttpError(409, "Tell Thingport your Printables username first");
}
const listed = user?.printablesUserId ? await listPrintablesUserCollections(user.printablesUserId) : [];
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 {
const token = await requireThingiverseToken();
if (!user?.thingiverseUsername) throw new HttpError(409, "Tell Thingport your Thingiverse username first");
cards = await listThingiverseUserCollections(user.thingiverseUsername, token);
const username = user.thingiverseUsername;
// Thingiverse shows anyone's likes, so they sync like a collection; a failed count costs only them.
const [likes, collections] = await Promise.all([
thingiverseLikesCard(username, token).catch(() => null),
listThingiverseUserCollections(username, token),
]);
cards = [...(likes ? [likes] : []), ...collections];
}
return withSyncs(userId, provider, cards);
}
@@ -151,15 +197,28 @@ 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 || source.likesOf) {
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 or Thingiverse collection link");
let card: ProviderCollectionCard | null;
if (source.provider === "makerworld") {
if (source.likesOf && source.provider === "thingiverse") {
card = await thingiverseLikesCard(source.likesOf, await requireThingiverseToken());
} else if (source.likesOf) {
// Printables shows likes to their owner only, so only the user's own, as Grab read them.
const likes = await pushedLikes(userId);
if (likes?.card.id !== source.externalId) {
throw new HttpError(
404,
"Printables shows likes to their owner only. Open Printables signed in, with Thingport Grab installed, and your own appear here.",
);
}
card = likes.card;
} else 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);
card =
(await fetchPrintablesCollectionCard(source.externalId)) ??
(await pushedPrintablesCards(userId)).collections?.find((pushed) => pushed.id === source.externalId) ??
null;
} else {
card = await fetchThingiverseCollectionCard(source.externalId, await requireThingiverseToken());
}
@@ -167,7 +226,9 @@ export async function lookupProviderCollection(userId: string, url: string): Pro
return (await withSyncs(userId, source.provider, [card]))[0];
}
export type EnableFromAppResult = { sync: CollectionSyncOut; importing: boolean };
/** `waiting`: a private Printables collection or likes whose models Thingport Grab hasn't read yet;
* they come in on the user's next visit to Printables. */
export type EnableFromAppResult = { sync: CollectionSyncOut; importing: boolean; waiting: 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
@@ -194,6 +255,8 @@ export async function enableSyncFromApp(
knownIds = listing;
}
const waiting =
source.provider === "printables" && (await pushedPrintablesModels(userId, source.externalId)) === "waiting";
const link = await enableSync(userId, { url: input.url, collectionId, knownIds });
let sync = link;
if (input.intervalHours || input.profileScope) {
@@ -213,5 +276,79 @@ export async function enableSyncFromApp(
// 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 };
return { sync, importing, waiting };
}
export type PrintablesPush = {
user: { id: string; handle: string };
/** The user's collections, private ones included. */
collections?: { id: string; name: string; model_count: number; thumbnails: string[]; private: boolean }[];
/** Their likes, when Grab read them this time: on every visit while a likes sync wants them (see
* wantedPrintablesListings), else once a day for the Browse tab's count. Either way it's every
* page of them: Printables has no likes count of its own. */
likes?: { model_count: number; thumbnails: string[]; ids?: string[] };
/** Model ids of the private collections asked for. */
listings?: Record<string, string[]>;
};
/** What Thingport Grab read on a visit to Printables. Synced collections whose models changed are
* synced straight away: a visit is when their new models can be seen. */
export async function savePrintablesPush(userId: string, push: PrintablesPush): Promise<void> {
await assertPrintablesPushOn(userId);
const { id: printablesUserId, handle } = push.user;
// The signed-in account wins over a username typed into the app, once Printables confirms it: the
// handle is what proves a likes sync is the user's own (see assertOwnPrintablesLikes).
const stored = await prisma.user.findUnique({
where: { id: userId },
select: { printablesUserId: true, printablesHandle: true },
});
if (stored?.printablesUserId !== printablesUserId || stored.printablesHandle !== handle) {
const confirmed = await resolvePrintablesUser(printablesUserId);
if (confirmed?.handle !== handle) throw new HttpError(400, "That isn't a Printables user");
await prisma.user.update({ where: { id: userId }, data: { printablesUserId, printablesHandle: handle } });
}
if (push.collections) {
const cards: ProviderCollectionCard[] = push.collections.map((c) => ({
id: c.id,
name: c.name,
url: `https://www.printables.com/@${encodeURIComponent(handle)}/collections/${c.id}`,
modelCount: c.model_count,
thumbnails: c.thumbnails,
private: c.private,
owner: handle,
}));
await writePrintablesListing(userId, COLLECTIONS_KEY, { cards });
// A collection deleted or made public since needs no model list of its own any more.
await deleteStalePrintablesListings(
userId,
cards.filter((card) => card.private).map((card) => card.id),
);
}
const changed = new Map<string, string[]>(Object.entries(push.listings ?? {}));
for (const [collectionId, ids] of changed) await writePrintablesListing(userId, collectionId, { ids });
if (push.likes) {
const ids = push.likes.ids ?? (await pushedLikes(userId))?.ids ?? null;
const card: ProviderCollectionCard = {
id: `${LIKES_ID_PREFIX}${handle.toLowerCase()}`,
name: "Liked models",
url: `https://www.printables.com/@${encodeURIComponent(handle)}/collections/liked`,
modelCount: push.likes.model_count,
thumbnails: push.likes.thumbnails,
private: true,
owner: handle,
};
await writePrintablesListing(userId, LIKES_KEY, { card, ids });
if (push.likes.ids) changed.set(card.id, push.likes.ids);
}
const syncs = await prisma.collectionSync.findMany({
where: { userId, provider: "printables", externalId: { in: [...changed.keys()] } },
});
for (const sync of syncs) {
const known = new Set(sync.knownIds);
if (changed.get(sync.externalId)!.some((id) => !known.has(id))) {
void syncCollection(sync.id).catch((err) => console.error(`[sync] ${sync.id} after a Printables visit:`, err));
}
}
}
@@ -0,0 +1,119 @@
// What Thingport Grab read from Printables with the user's own login: private collections and
// likes, which Printables shows to their owner only. The instance can't read them itself, so
// syncing them works from these lists, as fresh as the user's last visit to Printables.
import type { Prisma } from "@prisma/client";
import { prisma } from "../db";
import { HttpError } from "../utils/fileUtils";
import { LIKES_ID_PREFIX } from "../dto";
import type { ProviderCollectionCard } from "./providerCollectionCard";
const PROVIDER = "printables";
export const COLLECTIONS_KEY = "collections";
export const LIKES_KEY = "likes";
/** Why a private Printables sync can't be checked yet. Shown as the sync's last error. */
export const WAITING_FOR_GRAB =
"Waiting for Thingport Grab: open Printables in a browser where you're signed in and Grab is installed";
type CollectionsData = { cards: ProviderCollectionCard[] };
type LikesData = { card: ProviderCollectionCard; ids: string[] | null };
type ModelsData = { ids: string[] };
async function read<T>(userId: string, key: string): Promise<T | null> {
const row = await prisma.providerListing.findUnique({
where: { userId_provider_key: { userId, provider: PROVIDER, key } },
});
return row ? (row.data as unknown as T) : null;
}
export async function writePrintablesListing(userId: string, key: string, data: object): Promise<void> {
const json = data as Prisma.InputJsonValue;
await prisma.providerListing.upsert({
where: { userId_provider_key: { userId, provider: PROVIDER, key } },
create: { userId, provider: PROVIDER, key, data: json },
update: { data: json },
});
}
async function pushedCollections(userId: string): Promise<ProviderCollectionCard[] | null> {
return (await read<CollectionsData>(userId, COLLECTIONS_KEY))?.cards ?? null;
}
/** Why a Printables sync is paused once the user forgot their Printables account. */
export const PRINTABLES_FORGOTTEN =
"Couldn't read this Printables collection: it may be private, and your Printables account is forgotten in Thingport. Give your username in Browse to read it again";
export async function printablesPushIsOff(userId: string): Promise<boolean> {
const user = await prisma.user.findUnique({ where: { id: userId }, select: { printablesPushOff: true } });
return Boolean(user?.printablesPushOff);
}
/** Refuses Grab's Printables pushes for a user who forgot their Printables account in the app. */
export async function assertPrintablesPushOn(userId: string): Promise<void> {
if (await printablesPushIsOff(userId)) {
throw new HttpError(409, "Printables is turned off for Thingport Grab: give your username in Browse to turn it on");
}
}
export async function deletePrintablesListings(userId: string): Promise<void> {
await prisma.providerListing.deleteMany({ where: { userId, provider: PROVIDER } });
}
/** Drops the model lists of collections that aren't among the user's private ones any more. */
export async function deleteStalePrintablesListings(userId: string, privateIds: string[]): Promise<void> {
await prisma.providerListing.deleteMany({
where: { userId, provider: PROVIDER, key: { notIn: [COLLECTIONS_KEY, LIKES_KEY, ...privateIds] } },
});
}
/** The user's collections, private ones included, and their likes, as of their last visit. */
export async function pushedPrintablesCards(
userId: string,
): Promise<{ collections: ProviderCollectionCard[] | null; likes: ProviderCollectionCard | null }> {
const [collections, likes] = await Promise.all([
read<CollectionsData>(userId, COLLECTIONS_KEY),
read<LikesData>(userId, LIKES_KEY),
]);
return { collections: collections?.cards ?? null, likes: likes?.card ?? null };
}
export async function pushedLikes(userId: string): Promise<LikesData | null> {
return read<LikesData>(userId, LIKES_KEY);
}
/** For the sync runner: the pushed model ids; "waiting" for a Printables source only Grab can read
* that it hasn't yet; null for one the instance reads itself. A collection's pushed ids count only
* while the user's last push still lists it as private: Grab stops reading one made public, so
* its ids would go stale, and Printables answers for it anyone, the instance included. */
export async function pushedPrintablesModels(userId: string, externalId: string): Promise<string[] | "waiting" | null> {
if (externalId.startsWith(LIKES_ID_PREFIX)) return (await pushedLikes(userId))?.ids ?? "waiting";
const isPrivate = (await pushedCollections(userId))?.some((card) => card.id === externalId && card.private);
if (!isPrivate) return null;
return (await read<ModelsData>(userId, externalId))?.ids ?? "waiting";
}
// Grab pages each collection a second apart, so a visit reads only this many; the rest wait for
// the next one.
const COLLECTIONS_PER_VISIT = 3;
/** The lists Grab should read in full on its next visit: those of synced likes and synced private
* collections, the latter stalest first. Reading every list on every visit would run into
* Printables' throttling. */
export async function wantedPrintablesListings(userId: string): Promise<string[]> {
const [syncs, collections] = await Promise.all([
prisma.collectionSync.findMany({ where: { userId, provider: PROVIDER }, select: { externalId: true } }),
pushedCollections(userId),
]);
const privateIds = new Set((collections ?? []).filter((card) => card.private).map((card) => card.id));
const likes = syncs.some(({ externalId }) => externalId.startsWith(LIKES_ID_PREFIX));
const ids = syncs.flatMap(({ externalId }) => (privateIds.has(externalId) ? [externalId] : []));
// Stalest first, never read before least recently read: Grab reads only a few per visit.
const lastRead = await prisma.providerListing.findMany({
where: { userId, provider: PROVIDER, key: { in: ids } },
select: { key: true, updatedAt: true },
});
const readAt = new Map(lastRead.map((row) => [row.key, row.updatedAt.getTime()]));
ids.sort((a, b) => (readAt.get(a) ?? 0) - (readAt.get(b) ?? 0));
return [...(likes ? [LIKES_KEY] : []), ...ids.slice(0, COLLECTIONS_PER_VISIT)];
}
+31 -8
View File
@@ -334,10 +334,7 @@ export async function checkThingiverseCollection(
/** For a Likes sync: null when Thingiverse answers 404 for the user. Throws on any other failure,
* so an outage is never taken for a deleted account. */
export async function checkThingiverseUser(username: string, accessToken: string): Promise<boolean> {
const { ok, status } = await fetchThingiverseApi(`/users/${encodeURIComponent(username)}`, accessToken);
if (status === 404) return false;
if (!ok) throw new Error(`Couldn't read the Thingiverse user (HTTP ${status})`);
return true;
return (await fetchThingiverseUser(username, accessToken)) !== null;
}
/** The username in the URL is cosmetic; only the collection id is needed. */
@@ -354,6 +351,33 @@ export function parseThingiverseCollectionUrl(url: string): { collectionId: stri
return m ? { collectionId: m[1] } : null;
}
/** A user's profile as the API gives it; null when there's no such user. */
export async function fetchThingiverseUser(
username: string,
accessToken: string,
): Promise<Record<string, unknown> | null> {
const { ok, status, data } = await fetchThingiverseApi(`/users/${encodeURIComponent(username)}`, accessToken);
if (status === 404) return null;
if (!ok || !isRecord(data)) throw new Error(`Couldn't read the Thingiverse user (HTTP ${status})`);
return data;
}
const LIKES_SAMPLE = 3;
/** How many models a user likes, and a few of them, for showing their likes as a collection. Null
* when there's no such user. */
export async function fetchThingiverseLikesSummary(
username: string,
accessToken: string,
): Promise<{ username: string; count: number; thumbnails: string[] } | null> {
const user = await fetchThingiverseUser(username, accessToken);
if (!user) return null;
const name = typeof user.name === "string" && user.name ? user.name : username;
const count = Number(user.like_count) || 0;
const { entries } = count ? await fetchThingiverseUserLikes(name, accessToken, LIKES_SAMPLE) : { entries: [] };
return { username: name, count, thumbnails: entries.flatMap((entry) => (entry.cover ? [entry.cover] : [])) };
}
/** 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();
@@ -367,10 +391,9 @@ export async function resolveThingiverseUser(input: string, accessToken: string)
}
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 };
const user = await fetchThingiverseUser(value, accessToken);
if (!user) return null;
return { username: typeof user.name === "string" && user.name.trim() ? user.name.trim() : value };
}
function thingiverseCollectionCard(value: unknown): ProviderCollectionCard | null {
+261 -1
View File
@@ -4,11 +4,19 @@ 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";
process.env.COLLECTION_SYNC_ITEM_DELAY_MS = "0";
});
import { createApp } from "../src/app";
import { prisma } from "../src/db";
import { setUserMakerworldCookie } from "../src/services/makerworldCookieService";
import {
PRINTABLES_FORGOTTEN,
pushedPrintablesModels,
WAITING_FOR_GRAB,
} from "../src/services/providerListingsService";
import { syncCollection } from "../src/services/collectionSyncRunner";
import { getThingiverseAccessToken, setThingiverseAccessToken } from "../src/services/settingsService";
const app = createApp();
const stamp = Date.now();
@@ -21,6 +29,10 @@ const originalFetch = global.fetch;
let nextId = (stamp % 1_000_000_000) * 10;
const newId = () => String(nextId++);
const THINGIVERSE_USER = `Liker${stamp}`;
let previousThingiverseToken: string | null = null;
// A real Printables user, but not this Thingport user's: only typed into the app.
const OTHER_PRINTABLES_USER = { id: "777", handle: `Other_${stamp}`, publicUsername: "Other" };
const PRINTABLES_USER = { id: "3560603", handle: `Tester_${stamp}`, publicUsername: "Tester" };
const printablesCollections = [
{ id: newId(), name: "Practical finds", printsCount: 23 },
@@ -49,6 +61,7 @@ function printablesCard(c: { id: string; name: string; printsCount: number }) {
function printablesGraphql(query: string, variables: Record<string, unknown>): Response {
if (query.includes("user(id:")) {
if (variables.id === `@${OTHER_PRINTABLES_USER.handle}`) return json({ data: { user: OTHER_PRINTABLES_USER } });
const ok = variables.id === `@${PRINTABLES_USER.handle}` || variables.id === PRINTABLES_USER.id;
return json({ data: { user: ok ? PRINTABLES_USER : null } });
}
@@ -80,6 +93,22 @@ beforeAll(async () => {
const body = JSON.parse(String(init?.body ?? "{}")) as { query: string; variables: Record<string, unknown> };
return printablesGraphql(body.query, body.variables);
}
// Thingiverse's API as it answers for a user with two likes and one collection.
const thingiverse = url.match(/^https:\/\/api\.thingiverse\.com(\/[^?]*)/);
if (thingiverse) {
const path = thingiverse[1];
if (path === `/users/${THINGIVERSE_USER}`) return json({ name: THINGIVERSE_USER, like_count: 2 });
if (path === `/users/${THINGIVERSE_USER}/likes`) {
return json([
{ id: 11, name: "Hook", thumbnail: "https://cdn.thingiverse.com/hook.jpg" },
{ id: 12, name: "Clip", thumbnail: "https://cdn.thingiverse.com/clip.jpg" },
]);
}
if (path === `/users/${THINGIVERSE_USER}/collections`) {
return json([{ id: 77, name: "Bits", count: 3, creator: { name: THINGIVERSE_USER } }]);
}
return json({ error: "not found" }, 404);
}
if (url.startsWith("https://api.bambulab.com/v1/design-service/my/favorites/listlite")) {
return json({ total: makerworldCollections.length, hits: makerworldCollections.map(makerworldCard) });
}
@@ -99,8 +128,9 @@ beforeAll(async () => {
}) as unknown as typeof fetch;
});
afterAll(() => {
afterAll(async () => {
global.fetch = originalFetch;
await setThingiverseAccessToken(previousThingiverseToken);
});
async function newCollection(name: string): Promise<string> {
@@ -176,6 +206,31 @@ describe("browsing a provider's collections to sync with", () => {
});
});
describe("Thingiverse likes in the Browse tab", () => {
it("lists a user's likes first, counted from their profile, with a few of their models", async () => {
previousThingiverseToken = await getThingiverseAccessToken();
await setThingiverseAccessToken("test-token");
const saved = await request(app)
.put("/api/provider-collections/accounts/thingiverse")
.set(auth())
.send({ value: `https://www.thingiverse.com/${THINGIVERSE_USER}/designs` });
expect(saved.body.thingiverse.account).toBe(THINGIVERSE_USER);
const list = await request(app).get("/api/provider-collections").query({ provider: "thingiverse" }).set(auth());
expect(list.status).toBe(200);
expect(list.body.map((c: { name: string; likes: boolean }) => [c.name, c.likes])).toEqual([
["Liked models", true],
["Bits", false],
]);
expect(list.body[0]).toMatchObject({
id: `likes:${THINGIVERSE_USER.toLowerCase()}`,
url: `https://www.thingiverse.com/${THINGIVERSE_USER}/likes`,
model_count: 2,
thumbnails: ["https://cdn.thingiverse.com/hook.jpg", "https://cdn.thingiverse.com/clip.jpg"],
});
});
});
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");
@@ -232,3 +287,208 @@ describe("turning sync on from the app", () => {
expect(res.status).toBe(400);
});
});
async function queuedUrls(collectionId: string): Promise<string[]> {
const sync = await prisma.collectionSync.findUniqueOrThrow({ where: { collectionId } });
const items = await prisma.importJobItem.findMany({ where: { job: { collectionSyncId: sync.id } } });
return items.map((item) => item.url).toSorted();
}
describe("private Printables collections and likes, through Thingport Grab", () => {
const privateId = newId();
const likedIds = [newId(), newId()];
const push = (body: Record<string, unknown>) =>
request(app)
.put("/api/provider-collections/printables/push")
.set(auth())
.send({ user: { id: PRINTABLES_USER.id, handle: PRINTABLES_USER.handle }, ...body });
const likesUrl = `https://www.printables.com/@${PRINTABLES_USER.handle}/collections/liked`;
it("lists the private collections and likes Grab read, after the public ones", async () => {
const res = await push({
collections: [
{ id: privateId, name: "Secret stash", model_count: 2, thumbnails: [], private: true },
{ id: printablesCollections[0].id, name: "Practical finds", model_count: 23, thumbnails: [], private: false },
],
likes: { model_count: 2, thumbnails: [] },
});
expect(res.status).toBe(200);
const list = await request(app).get("/api/provider-collections").query({ provider: "printables" }).set(auth());
expect(list.body.map((c: { name: string; private: boolean }) => [c.name, c.private])).toEqual([
["Liked models", true],
["Practical finds", false],
["Cool toys", false],
["Secret stash", true],
]);
expect(list.body[0]).toMatchObject({
id: `likes:${PRINTABLES_USER.handle.toLowerCase()}`,
url: likesUrl,
likes: true,
});
expect(list.body[1].likes).toBe(false);
});
it("finds the user's own likes by link, and nobody else's", async () => {
const own = await request(app).get("/api/provider-collections/lookup").query({ url: likesUrl }).set(auth());
expect(own.status).toBe(200);
expect(own.body.name).toBe("Liked models");
const other = await request(app)
.get("/api/provider-collections/lookup")
.query({ url: "https://www.printables.com/@someone_1/collections/liked" })
.set(auth());
expect(other.status).toBe(404);
});
it("waits for Grab to read a private collection, then imports it on the next visit", async () => {
const collectionId = await newCollection("Private");
const res = await request(app)
.post(`/api/collection/${collectionId}/sync`)
.set(auth())
.send({ url: `https://www.printables.com/@${PRINTABLES_USER.handle}/collections/${privateId}` });
expect(res.body).toMatchObject({ importing: true, waiting: true });
await vi.waitFor(
async () =>
expect((await prisma.collectionSync.findUniqueOrThrow({ where: { collectionId } })).lastError).toBe(
WAITING_FOR_GRAB,
),
{ timeout: 5000 },
);
const wanted = await request(app).get("/api/provider-collections/printables/wanted").set(auth());
expect(wanted.body.keys).toContain(privateId);
const models = [newId(), newId()];
await push({ listings: { [privateId]: models } });
await vi.waitFor(
async () =>
expect(await queuedUrls(collectionId)).toEqual(
models.map((m) => `https://www.printables.com/model/${m}`).toSorted(),
),
{ timeout: 5000 },
);
});
it("reads a collection made public live again, not from what Grab read while it was private", async () => {
const id = newId();
await push({
collections: [{ id, name: "Was private", model_count: 1, thumbnails: [], private: true }],
listings: { [id]: [newId()] },
});
expect(Array.isArray(await pushedPrintablesModels(userId, id))).toBe(true);
await push({ collections: [{ id, name: "Was private", model_count: 1, thumbnails: [], private: false }] });
expect(await pushedPrintablesModels(userId, id)).toBeNull();
});
it("forgets the models Grab read of a collection that's no longer private", async () => {
const id = newId();
await push({
collections: [{ id, name: "Soon public", model_count: 1, thumbnails: [], private: true }],
listings: { [id]: [newId()] },
});
await push({ collections: [{ id, name: "Soon public", model_count: 1, thumbnails: [], private: false }] });
expect(await prisma.providerListing.count({ where: { userId, key: id } })).toBe(0);
});
it("refuses a push for a Printables account Printables doesn't know", async () => {
const res = await request(app)
.put("/api/provider-collections/printables/push")
.set(auth())
.send({ user: { id: PRINTABLES_USER.id, handle: "someone_else" }, collections: [] });
expect(res.status).toBe(400);
});
it("asks Grab for only a few private collections per visit", async () => {
const ids = [newId(), newId(), newId(), newId()];
await push({
collections: ids.map((id) => ({ id, name: `Private ${id}`, model_count: 1, thumbnails: [], private: true })),
});
for (const id of ids) {
const collectionId = await newCollection(`Capped ${id}`);
await request(app)
.post(`/api/collection/${collectionId}/sync`)
.set(auth())
.send({ url: `https://www.printables.com/@${PRINTABLES_USER.handle}/collections/${id}` });
}
const wanted = await request(app).get("/api/provider-collections/printables/wanted").set(auth());
expect(wanted.body.keys.filter((key: string) => ids.includes(key))).toHaveLength(3);
});
it("takes only a few private collections' models per push", async () => {
const listings = Object.fromEntries(Array.from({ length: 11 }, () => [newId(), [newId()]]));
expect((await push({ listings })).status).toBe(400);
});
it("refuses to sync someone else's Printables likes", async () => {
const collectionId = await newCollection("Their likes");
const res = await request(app)
.post(`/api/collection/${collectionId}/sync`)
.set(auth())
.send({ url: "https://www.printables.com/@someone_1/collections/liked" });
expect(res.status).toBe(400);
const viaGrab = await request(app)
.post("/api/collection-sync")
.set(auth())
.send({ url: "https://www.printables.com/@someone_1/collections/liked", known_ids: [] });
expect(viaGrab.status).toBe(400);
expect(await prisma.collectionSync.count({ where: { userId, externalId: "likes:someone_1" } })).toBe(0);
});
it("doesn't take a typed-in Printables username as proof whose likes they are", async () => {
await request(app)
.put("/api/provider-collections/accounts/printables")
.set(auth())
.send({ value: `@${OTHER_PRINTABLES_USER.handle}` });
const collectionId = await newCollection("Typed likes");
const res = await request(app)
.post(`/api/collection/${collectionId}/sync`)
.set(auth())
.send({ url: `https://www.printables.com/@${OTHER_PRINTABLES_USER.handle}/collections/liked` });
expect(res.status).toBe(400);
// Back to the account Grab pushed, for the tests after this one.
await push({});
});
it("turns Grab's Printables pushes off when the user forgets their account, until they give one again", async () => {
const forget = await request(app)
.put("/api/provider-collections/accounts/printables")
.set(auth())
.send({ value: null });
expect(forget.body.printables.account).toBeNull();
expect(await prisma.providerListing.count({ where: { userId, provider: "printables" } })).toBe(0);
expect((await request(app).get("/api/provider-collections/printables/wanted").set(auth())).status).toBe(409);
expect((await push({ collections: [] })).status).toBe(409);
// The private collection synced earlier can't be told from a deleted one now: it pauses.
const synced = await prisma.collectionSync.findFirstOrThrow({ where: { userId, externalId: privateId } });
expect(await syncCollection(synced.id)).toBe("error");
expect((await prisma.collectionSync.findUniqueOrThrow({ where: { id: synced.id } })).lastError).toBe(
PRINTABLES_FORGOTTEN,
);
await request(app)
.put("/api/provider-collections/accounts/printables")
.set(auth())
.send({ value: `@${PRINTABLES_USER.handle}` });
expect((await push({ likes: { model_count: 0, thumbnails: [] } })).status).toBe(200);
});
it("syncs the user's likes from the ids Grab read", async () => {
const collectionId = await newCollection("Likes");
const res = await request(app).post(`/api/collection/${collectionId}/sync`).set(auth()).send({ url: likesUrl });
expect(res.body).toMatchObject({ importing: true, waiting: true });
const wanted = await request(app).get("/api/provider-collections/printables/wanted").set(auth());
expect(wanted.body.keys).toContain("likes");
await push({ likes: { model_count: 2, thumbnails: [], ids: likedIds } });
await vi.waitFor(
async () =>
expect(await queuedUrls(collectionId)).toEqual(
likedIds.map((m) => `https://www.printables.com/model/${m}`).toSorted(),
),
{ timeout: 5000 },
);
});
});
+8
View File
@@ -43,6 +43,14 @@ they're imported now, one at a time. MakerWorld rate-limits bulk imports quickly
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
Printables shows these to you alone, so Thingport can't read them on its own. With Thingport Grab installed in a browser
where you're signed in to Printables, Grab reads them while you're on Printables, at most once an hour, and sends them to
your Thingport. They then show up in **Browse** too, your likes as **Liked models**, and sync like any other collection,
except that new models come in after your next visit to Printables rather than on a schedule. Sync one before Grab has
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.
+9 -3
View File
@@ -1,6 +1,6 @@
# Thingport Grab privacy policy
_Effective 6 October 2026._
_Effective 12 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
@@ -26,7 +26,7 @@ the list of recent imports, which is synced between your browsers as described b
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
only sent again when they change.
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
ends.
@@ -55,6 +55,10 @@ the **provider site you're on**.
- If you're logged into Printables or Thingiverse 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:
your Printables collections (names, model counts and a few model pictures, private collections
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:**
@@ -69,7 +73,9 @@ the **provider site you're on**.
- On a MakerWorld collection import, the extension moves your tab from one model page to the next,
just as if you were clicking through them.
- On Printables, once per open tab, the extension asks Printables' own API which account you're
logged in as, the same way the page does, using your existing Printables session.
logged in as, the same way the page does, using your existing Printables session. At most once an
hour it also asks it, the same way, for your collections, your liked models and the models in
the private collections you sync.
**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
+4
View File
@@ -9,6 +9,7 @@ export function isTarget(value: string): value is Target {
}
const PROVIDER_MATCHES = ["*://*.thingiverse.com/*", "*://*.makerworld.com/*", "*://*.printables.com/*"];
const COOKIE_HOSTS = ["*://*.makerworld.com/*", "*://*.printables.com/*"];
function iconSet(variant: "color" | "dark"): Record<string, string> {
return Object.fromEntries(
@@ -29,6 +30,9 @@ export function buildManifest(target: Target, pkg: { version: string; descriptio
},
background: target === "firefox" ? { scripts: ["background.js"] } : { service_worker: "background.js" },
permissions: ["storage", "cookies", "downloads"],
// 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.
optional_host_permissions: ["*://*/*"],
content_scripts: [{ matches: PROVIDER_MATCHES, js: ["content.js"], run_at: "document_idle" }],
+3 -1
View File
@@ -15,7 +15,7 @@ import {
resumeJob,
startJob,
} from "./makerworldJob";
import { getPrintablesToken, reportProviderAccount } from "./providerAccounts";
import { getPrintablesToken, printablesPushDue, pushPrintables, reportProviderAccount } from "./providerAccounts";
import { getRecentImports } from "./recentImports";
import { openSetup } from "./setup";
import { setTabIconState } from "./tabIcon";
@@ -33,6 +33,8 @@ listen<BackgroundMessages>({
CHECK_CONNECTION: () => checkConnection(),
GET_PRINTABLES_TOKEN: () => getPrintablesToken(),
REPORT_PROVIDER_ACCOUNT: ({ provider, value }) => reportProviderAccount(provider, value),
PRINTABLES_PUSH_DUE: ({ now }) => printablesPushDue({ now }),
PUSH_PRINTABLES: (push) => pushPrintables(push),
OPEN_SETUP: (_payload, sender) => openSetup(sender.tab?.id),
SET_TAB_ICON_STATE: ({ active }, sender) => setTabIconState(senderTabId(sender), active),
API_CALL: ({ method, path, body }) => apiCall(method, path, body),
+46 -1
View File
@@ -2,7 +2,8 @@
// Browse tab lists their collections without asking. Sent only when it changes; the signed-in
// account replaces a username typed into the app.
import type { AccountProvider } from "../shared/messages";
import type { AccountProvider, PrintablesPush, PrintablesPushPlan } from "../shared/messages";
import { STORAGE_KEYS } from "../shared/storage";
import { apiCall, requireConfig } from "./api";
/** HttpOnly, so the page can't read it but chrome.cookies can. Null (never throws) when signed out. */
@@ -29,3 +30,47 @@ export async function reportProviderAccount(provider: AccountProvider, value: st
} catch {}
return null;
}
const PRINTABLES_PUSH_INTERVAL_MS = 60 * 60 * 1000;
// Likes have no count of their own: reading one means reading every page. Unsynced, they're read
// this seldom, only to keep their tile in the web app's Browse tab current.
const UNSYNCED_LIKES_INTERVAL_MS = 24 * 60 * 60 * 1000;
// What the instance asks for when the user's Printables likes are synced (see wantedPrintablesListings).
const LIKES_KEY = "likes";
// A plan handed to one tab holds off the others until its push arrives, or this long passes (that
// tab may have been closed mid-read). Two tabs reading at once would double the requests. Kept in
// storage: the background can be stopped and started again between the plan and its push.
const PUSH_PLAN_HOLD_MS = 10 * 60 * 1000;
/** What to read now, when a push is due; null when it isn't, or the instance predates pushes. */
export async function printablesPushDue({ now = false } = {}): Promise<PrintablesPushPlan | null> {
const config = await requireConfig().catch(() => null);
if (!config) return null;
const handedOut = config.printablesPlanHandedOutAt;
if (!now && handedOut && Date.now() - handedOut < PUSH_PLAN_HOLD_MS) return null;
const last = config.lastPrintablesPushAt;
if (!now && last && Date.now() - last < PRINTABLES_PUSH_INTERVAL_MS) return null;
try {
const { keys } = await apiCall<{ keys: string[] }>("GET", "/provider-collections/printables/wanted");
const likesRead = config.lastPrintablesLikesAt;
const likes = keys.includes(LIKES_KEY) || !likesRead || Date.now() - likesRead >= UNSYNCED_LIKES_INTERVAL_MS;
await chrome.storage.local.set({ [STORAGE_KEYS.printablesPlanHandedOutAt]: Date.now() });
return { wanted: keys, likes };
} catch {
return null;
}
}
export async function pushPrintables(push: PrintablesPush): Promise<null> {
await chrome.storage.local.remove(STORAGE_KEYS.printablesPlanHandedOutAt);
try {
await apiCall("PUT", "/provider-collections/printables/push", push);
const now = Date.now();
await chrome.storage.local.set({
[STORAGE_KEYS.lastPrintablesPushAt]: now,
...(push.likes ? { [STORAGE_KEYS.lastPrintablesLikesAt]: now } : {}),
});
} catch {}
return null;
}
+79 -7
View File
@@ -8,6 +8,8 @@ import { isPanelMounted, onPanelAction, panelQueryAll, renderPanel } from "../sh
import { errorHtml, statusHtml, successHtml } from "./results";
import { loadingModelsPhrases, renderFunStatus } from "./funStatus";
import { applySyncToggle, loadSyncState, readSyncToggle, syncChangeNote, syncToggleHtml } from "./syncToggle";
import { printablesToken, readCollectionForPanel } from "../printables";
import { pushPrintablesNow } from "../providerAccount";
type BatchKey = "thingiverse:likes" | "thingiverse:collection" | "printables:collection";
@@ -36,15 +38,72 @@ function endpoints() {
return config;
}
function isPrintablesCollection(): boolean {
const { provider, type } = ctx().classification;
return provider === "printables" && type === "collection";
}
function printablesCollectionId(): string | null {
return ctx().url.match(/\/collections\/(\d+)/)?.[1] ?? null;
}
/** A Printables collection as its signed-in owner sees it: the instance can only read public ones,
* and would find a private one empty. Null without a login, for the instance's own read; a failed
* read with one says so, rather than falling back to that read and looking empty. */
async function signedInPrintablesEntries(): Promise<BatchEntriesResult | null> {
const collectionId = printablesCollectionId();
const token = collectionId ? await printablesToken() : null;
if (!collectionId || !token) return null;
try {
const { title, entries, truncated } = await readCollectionForPanel(token, collectionId);
const { print_ids: inLibrary } = await api<{ print_ids: Record<string, string> }>("POST", "/import/status/batch", {
provider: "printables",
external_ids: entries.map((entry) => entry.id),
});
return {
title,
truncated,
entries: entries.map((entry) => ({
design_id: entry.id,
title: entry.title,
already_imported: entry.id in inLibrary,
})),
};
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
throw new Error(`Couldn't read this collection from Printables as you: ${reason}. Reload the page to try again.`, {
cause: err,
});
}
}
/** `readAsUser`: read with the user's Printables login, as only a private collection needs. */
type PanelEntries = BatchEntriesResult & { readAsUser?: boolean };
/** The instance's own read first; for a Printables collection it finds nothing in (a private one),
* the user's own. */
async function loadEntries(): Promise<PanelEntries> {
let instanceError: unknown = null;
try {
const found = await api<BatchEntriesResult>("POST", endpoints().entries, { url: ctx().url });
if (found.entries.length || !isPrintablesCollection()) return found;
} catch (err) {
if (!isPrintablesCollection()) throw err;
instanceError = err;
}
// Null without a login; a read with one, even an empty one, is the answer.
const signedIn = await signedInPrintablesEntries();
if (signedIn) return { ...signedIn, readAsUser: true };
if (instanceError) throw instanceError;
return { entries: [] };
}
export async function loadBatchEntries(): Promise<void> {
renderFunStatus(loadingModelsPhrases(ctx().classification.provider));
let result: BatchEntriesResult;
let result: PanelEntries;
let sync: SyncLookup | null;
try {
[result, sync] = await Promise.all([
api<BatchEntriesResult>("POST", endpoints().entries, { url: ctx().url }),
loadSyncState(),
]);
[result, sync] = await Promise.all([loadEntries(), loadSyncState()]);
} catch (err) {
renderPanel(errorHtml(err));
return;
@@ -72,7 +131,7 @@ export async function loadBatchEntries(): Promise<void> {
onPanelAction("import", () => void runBatchImport(result, sync));
}
async function runBatchImport(result: BatchEntriesResult, sync: SyncLookup | null): Promise<void> {
async function runBatchImport(result: PanelEntries, sync: SyncLookup | null): Promise<void> {
const ids = panelQueryAll<HTMLInputElement>(".tg-entry__checkbox:checked").map((el) => el.value);
const wantSync = readSyncToggle();
if (!ids.length && !sync) return;
@@ -86,6 +145,14 @@ async function runBatchImport(result: BatchEntriesResult, sync: SyncLookup | nul
knownIds: result.entries.map((entry) => entry.design_id),
});
const note = syncChangeNote(change, link);
// A private one: the instance can't read it, so it gets its models now rather than waiting.
const collectionId = result.readAsUser ? printablesCollectionId() : null;
if (change === "enabled" && collectionId) {
void pushPrintablesNow(
collectionId,
result.entries.map((entry) => entry.design_id),
);
}
if (!ids.length) {
const target = link ?? sync?.sync;
const collectionLink = target
@@ -94,7 +161,12 @@ async function runBatchImport(result: BatchEntriesResult, sync: SyncLookup | nul
renderPanel(successHtml(collectionLink, note ?? "Nothing new to import.", note ? "Saved" : "Nothing to import"));
return;
}
const { job_id } = await api<{ job_id: string }>("POST", start, { url, [idField]: ids });
const { job_id } = await api<{ job_id: string }>("POST", start, {
url,
[idField]: ids,
// A private Printables collection's name, which the instance can't read itself.
...(result.readAsUser ? { collection_title: result.title ?? null } : {}),
});
await pollJobWithProgress(job_id, instanceUrl, note);
} catch (err) {
renderPanel(errorHtml(err));
+189
View File
@@ -0,0 +1,189 @@
// Printables' API, asked as the signed-in user from a printables.com page, the way the site itself
// does: its CORS rules allow this page's origin, and the token is the user's own session. Reads
// what Printables shows its owner only: private collections and likes (see background/
// providerAccounts.ts for where it goes).
import type { PrintablesPush, PrintablesPushPlan } from "../shared/messages";
import { request } from "../shared/messages";
import { sleep } from "./runtime";
const GRAPHQL_URL = "https://api.printables.com/graphql/";
const MEDIA_BASE = "https://media.printables.com/";
const PAGE_SIZE = 30;
// Safety caps against a cursor that never ends, and a pause between pages for Printables' throttling.
const MAX_PAGES = 20;
const PAGE_DELAY_MS = 1000;
const CARD_THUMBNAILS = 3;
type Me = { id: string; handle: string };
async function graphql(token: string, query: string, variables: Record<string, unknown> = {}): Promise<unknown> {
const res = await fetch(GRAPHQL_URL, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ query, variables }),
});
if (!res.ok) throw new Error(`Printables answered HTTP ${res.status}`);
const body = (await res.json()) as { data?: unknown; errors?: unknown };
if (!body.data) throw new Error("Printables answered without data");
return body.data;
}
function record(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
}
function mediaUrl(image: unknown): string | null {
const filePath = record(image)?.filePath;
return typeof filePath === "string" && filePath ? `${MEDIA_BASE}${filePath}` : null;
}
/** The session token. HttpOnly, so the background reads it; null while signed out. */
export async function printablesToken(): Promise<string | null> {
return request("GET_PRINTABLES_TOKEN").catch(() => null);
}
export async function readMe(token: string): Promise<Me | null> {
const me = record(record(await graphql(token, "query { me { id handle } }"))?.me);
return me && me.id != null && typeof me.handle === "string" ? { id: String(me.id), handle: me.handle } : null;
}
/** Every page of a cursor-paged list, as the site's own "load more" walks it. The last page ends
* with an empty cursor. */
async function allPages(
token: string,
query: string,
field: string,
variables: Record<string, unknown>,
): Promise<{ items: Record<string, unknown>[]; truncated: boolean }> {
const items: Record<string, unknown>[] = [];
let cursor: string | null = null;
for (let page = 0; page < MAX_PAGES; page++) {
if (page) await sleep(PAGE_DELAY_MS);
const node = record(record(await graphql(token, query, { ...variables, limit: PAGE_SIZE, cursor }))?.[field]);
const pageItems = (Array.isArray(node?.items) ? (node.items as unknown[]) : []).flatMap((item) => {
const found = record(item);
return found ? [found] : [];
});
items.push(...pageItems);
const next = node?.cursor;
if (!pageItems.length || typeof next !== "string" || !next) return { items, truncated: false };
cursor = next;
}
// Read further, a visit would run into Printables' throttling.
return { items, truncated: true };
}
/** A list cut short at the cap leaves its later models out of syncs, so say so where it can be seen. */
function warnTruncated(label: string): void {
console.warn(`[Thingport Grab] Only the first ${MAX_PAGES * PAGE_SIZE} of your Printables ${label} were read.`);
}
const COLLECTIONS_QUERY = `query ($userId: ID!) {
userCollections(userId: $userId) { id name private printsCount thumbnails11 { image { filePath } } }
}`;
const LIKES_QUERY = `query ($likedUserId: ID!, $limit: Int, $cursor: String) {
moreLikedPrints2(likedUserId: $likedUserId, limit: $limit, cursor: $cursor, includeDeleted: false, printType: all, query: "") {
items { id }
cursor
}
}`;
// Liked items are a print interface with no image field, so a few pictures are looked up per model.
const MODEL_IMAGE_QUERY = `query ($id: ID!) { print(id: $id) { image { filePath } } }`;
const COLLECTION_MODELS_QUERY = `query ($collectionId: ID!, $limit: Int, $cursor: String) {
moreCollectionModels(collectionId: $collectionId, limit: $limit, cursor: $cursor, ordering: added_to_collection) {
items { model: print { id name } }
cursor
}
}`;
const COLLECTION_TITLE_QUERY = `query ($id: ID!) { collection(id: $id) { name } }`;
/** A collection's name and models as its signed-in owner sees them, private ones included, for the
* import panel: the instance itself only sees public collections. */
export async function readCollectionForPanel(
token: string,
collectionId: string,
): Promise<{ title: string | null; entries: { id: string; title: string }[]; truncated: boolean }> {
const name = record(record(await graphql(token, COLLECTION_TITLE_QUERY, { id: collectionId }))?.collection)?.name;
const { items, truncated } = await allPages(token, COLLECTION_MODELS_QUERY, "moreCollectionModels", {
collectionId,
});
const entries = items.flatMap((item) => {
const model = record(item.model);
if (!model || model.id == null) return [];
return [{ id: String(model.id), title: typeof model.name === "string" ? model.name : `Model ${model.id}` }];
});
return { title: typeof name === "string" && name ? name : null, entries, truncated };
}
async function readCollections(token: string, me: Me): Promise<PrintablesPush["collections"]> {
const listed = record(await graphql(token, COLLECTIONS_QUERY, { userId: me.id }))?.userCollections;
if (!Array.isArray(listed)) throw new Error("no collections in the answer");
return listed.flatMap((value) => {
const c = record(value);
if (!c || c.id == null) return [];
const thumbnails = (Array.isArray(c.thumbnails11) ? c.thumbnails11 : [])
.flatMap((t) => mediaUrl(record(t)?.image) ?? [])
.slice(0, CARD_THUMBNAILS);
return [
{
id: String(c.id),
name: typeof c.name === "string" ? c.name : `Collection ${c.id}`,
model_count: typeof c.printsCount === "number" ? c.printsCount : 0,
thumbnails,
private: c.private === true,
},
];
});
}
async function readLikes(token: string, me: Me): Promise<NonNullable<PrintablesPush["likes"]>> {
const { items: liked, truncated } = await allPages(token, LIKES_QUERY, "moreLikedPrints2", { likedUserId: me.id });
if (truncated) warnTruncated("likes");
const ids = liked.flatMap((item) => (item.id != null ? [String(item.id)] : []));
const thumbnails: string[] = [];
for (const id of ids.slice(0, CARD_THUMBNAILS)) {
const url = await graphql(token, MODEL_IMAGE_QUERY, { id })
.then((data) => mediaUrl(record(record(data)?.print)?.image))
.catch(() => null);
if (url) thumbnails.push(url);
}
return { ids, thumbnails, model_count: ids.length };
}
async function readCollectionModels(token: string, collectionId: string): Promise<string[]> {
const { items, truncated } = await allPages(token, COLLECTION_MODELS_QUERY, "moreCollectionModels", { collectionId });
if (truncated) warnTruncated(`collection ${collectionId}'s models`);
return items.flatMap((item) => {
const id = record(item.model)?.id;
return id != null ? [String(id)] : [];
});
}
/** Each part on its own, so one Printables refuses doesn't keep the others from Thingport. A part
* left out keeps what the instance last had. Logged to the page's console, the only place a
* content script can say what went wrong. */
async function part<T>(label: string, read: () => Promise<T>): Promise<T | undefined> {
try {
return await read();
} catch (err) {
console.warn(`[Thingport Grab] Couldn't read your Printables ${label}:`, err);
return undefined;
}
}
export async function readPrintables(token: string, me: Me, plan: PrintablesPushPlan): Promise<PrintablesPush> {
const { wanted } = plan;
const collections = await part("collections", () => readCollections(token, me));
const likes = plan.likes ? await part("likes", () => readLikes(token, me)) : undefined;
const listings: Record<string, string[]> = {};
// The instance asks for a few per visit, the stalest first (see wantedPrintablesListings).
for (const collectionId of wanted.filter((key) => /^\d+$/.test(key))) {
const ids = await part(`collection ${collectionId}`, () => readCollectionModels(token, collectionId));
if (ids) listings[collectionId] = ids;
}
return { user: me, collections, likes, listings };
}
+39 -15
View File
@@ -3,11 +3,14 @@
// plenty of other people's profiles too.
import { request, send } from "../shared/messages";
import { printablesToken, readMe, readPrintables } from "./printables";
import { sleep } from "./runtime";
// The header is drawn after load; give up quietly if it never shows (e.g. signed out).
const HEADER_WAIT_MS = 15_000;
const HEADER_POLL_MS = 500;
const TOKEN_WAIT_MS = 30_000;
const TOKEN_POLL_MS = 2_000;
let captured = false;
@@ -22,21 +25,38 @@ async function readThingiverseUsername(): Promise<string | null> {
return null;
}
/** Asked of Printables' API as the site itself does; from this page, so its CORS rules allow it. */
async function readPrintablesHandle(): Promise<string | null> {
const token = await request("GET_PRINTABLES_TOKEN").catch(() => null);
if (!token) return null;
/** Reads and sends what only the user sees on Printables, when it's time (or `now`). */
async function pushPrintables(
token: string,
me: { id: string; handle: string },
{ now = false, listings = {} }: { now?: boolean; listings?: Record<string, string[]> } = {},
): Promise<void> {
try {
const res = await fetch("https://api.printables.com/graphql/", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ query: "query { me { handle } }" }),
});
const body = (await res.json()) as { data?: { me?: { handle?: unknown } | null } };
const handle = body.data?.me?.handle;
return typeof handle === "string" && handle ? handle : null;
} catch {
return null;
const plan = await request("PRINTABLES_PUSH_DUE", { now });
if (!plan) return;
const push = await readPrintables(token, me, plan);
await request("PUSH_PRINTABLES", { ...push, listings: { ...push.listings, ...listings } });
} catch {}
}
/** Right away, with the models of a collection whose sync was just turned on on its page: the
* instance can't have asked for them yet, and would otherwise wait for the next visit to read it. */
export async function pushPrintablesNow(collectionId: string, modelIds: string[]): Promise<void> {
const token = await printablesToken();
const me = token ? await readMe(token).catch(() => null) : null;
if (token && me) await pushPrintables(token, me, { now: true, listings: { [collectionId]: modelIds } });
}
/** At most hourly, what only the signed-in user sees on Printables: private collections and likes,
* for syncing them (see printables.ts). The push names the account too: reported on its own, it
* would link an account the user forgot in the app again. */
async function syncPrintables(): Promise<void> {
// The token lasts minutes; the site renews an expired one only after it loads, so keep asking.
for (let waited = 0; waited < TOKEN_WAIT_MS; waited += TOKEN_POLL_MS) {
const token = await printablesToken();
const me = token ? await readMe(token).catch(() => null) : null;
if (token && me) return pushPrintables(token, me);
await sleep(TOKEN_POLL_MS);
}
}
@@ -49,7 +69,11 @@ export function captureProviderAccount(hostname: string): void {
: null;
if (!provider) return;
captured = true;
void (provider === "printables" ? readPrintablesHandle() : readThingiverseUsername()).then((value) => {
if (provider === "printables") {
void syncPrintables();
return;
}
void readThingiverseUsername().then((value) => {
if (value) void send("REPORT_PROVIDER_ACCOUNT", { provider, value });
});
}
+17
View File
@@ -20,6 +20,19 @@ export type Connection =
* needs none: the instance has its session cookie. */
export type AccountProvider = "printables" | "thingiverse";
/** `wanted`: the private collections whose models the instance asked for. `likes`: whether to read
* every page of the user's likes this time. */
export type PrintablesPushPlan = { wanted: string[]; likes: boolean };
/** What Grab read on Printables with the user's own login: their collections, private ones
* included, their likes, and the models of the private collections the instance asked for. */
export type PrintablesPush = {
user: { id: string; handle: string };
collections?: { id: string; name: string; model_count: number; thumbnails: string[]; private: boolean }[];
likes?: { model_count: number; thumbnails: string[]; ids: string[] };
listings: Record<string, string[]>;
};
/** The signed-in account's recent imports, as the popup shows them. */
export type RecentImport = {
printId: string;
@@ -84,6 +97,10 @@ 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 };
/** 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 };
PUSH_PRINTABLES: { payload: PrintablesPush; result: null };
OPEN_SETUP: { payload: void; result: "popup" | "tab" };
SET_TAB_ICON_STATE: { payload: { active: boolean }; result: null };
API_CALL: { payload: ApiCallPayload; result: unknown };
+9
View File
@@ -15,6 +15,12 @@ export const STORAGE_KEYS = {
lastSyncedMakerworldCookie: "lastSyncedMakerworldCookie",
// 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.
lastPrintablesPushAt: "lastPrintablesPushAt",
// When every page of the user's Printables likes was last read (see printablesPushDue).
lastPrintablesLikesAt: "lastPrintablesLikesAt",
// When a tab was last told to read Printables, so others hold off meanwhile.
printablesPlanHandedOutAt: "printablesPlanHandedOutAt",
} as const;
export type StoredConfig = {
@@ -29,6 +35,9 @@ export type StoredConfig = {
lastSyncedMakerworldCookie?: string;
/** `account` is the Thingport instance and email they were sent to. */
lastSentProviderAccounts?: { account: string; values: Record<string, string> };
lastPrintablesPushAt?: number;
lastPrintablesLikesAt?: number;
printablesPlanHandedOutAt?: number;
};
export const CONFIG_CHANGE_KEYS: readonly string[] = [
+5 -3
View File
@@ -18,7 +18,8 @@ export type CollectionSync = {
profile_scope: SyncProfileScope;
last_checked_at: string | null;
last_error: string | null;
/** Set when it follows a Thingiverse user's Likes rather than a collection: their username. */
/** Set when it follows a user's likes (Thingiverse, or the user's own on Printables) rather than a
* collection: their username. */
likes_of: string | null;
/** How often the provider's collection is checked. */
interval_hours: SyncIntervalHours;
@@ -141,11 +142,12 @@ export const collectionsApi = {
/** 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). */
* follows new ones). `waiting`: a private Printables collection or likes, whose models come in
* on the user's next visit to Printables with Thingport Grab. */
enableSync: async (
id: string,
input: { url: string; profile_scope?: SyncProfileScope; interval_hours?: SyncIntervalHours },
): Promise<{ sync: CollectionSync; importing: boolean }> => {
): Promise<{ sync: CollectionSync; importing: boolean; waiting: boolean }> => {
const res = await fetch(`${apiBase()}/collection/${id}/sync`, {
method: "POST",
headers: authHeaders({ "Content-Type": "application/json" }),
+2
View File
@@ -20,6 +20,8 @@ export type ProviderCollection = {
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;
/** The user's liked models, rather than a collection. */
likes: boolean;
};
async function call<T>(path: string, fallback: string, init?: RequestInit): Promise<T> {
@@ -21,7 +21,7 @@ export default function CollectionSyncBadge({ sync, size = 30, onClick }: Props)
const { t } = useTranslation("models");
const provider = importProviderInfo(sync.provider)?.label ?? sync.provider;
const label = sync.likes_of
? t("collections.sync.badgeLikes", { user: sync.likes_of })
? t("collections.sync.badgeLikes", { user: sync.likes_of, provider })
: t("collections.sync.badge", { provider });
const bubble = Math.round(size * 0.5);
const frame = {
@@ -17,7 +17,7 @@ export default function CollectionSyncIndicator({ sync, onClick }: { sync: Colle
const { t } = useTranslation("models");
const provider = importProviderInfo(sync.provider)?.label ?? sync.provider;
const label = sync.likes_of
? t("collections.sync.badgeLikes", { user: sync.likes_of })
? t("collections.sync.badgeLikes", { user: sync.likes_of, provider })
: t("collections.sync.badge", { provider });
return (
<Tooltip title={label}>
+23 -10
View File
@@ -1929,9 +1929,9 @@
"intervalLabel": "Check for new models, every",
"intervalOption_one": "Hour",
"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.",
"badgeLikes": "Synced with @{{user}}'s {{provider}} likes",
"introLikes_one": "Models @{{user}} likes on {{provider}} are imported here every hour.",
"introLikes_other": "Models @{{user}} likes on {{provider}} are imported here every {{count}} hours.",
"browse": {
"providerLabel": "Provider",
"models_one": "{{count}} model",
@@ -1958,19 +1958,32 @@
"printables": "@handle or profile link",
"thingiverse": "Username or profile link"
},
"showing": "Showing @{{user}}'s public collections.",
"showing": {
"printables": "Showing @{{user}}'s collections.",
"thingiverse": "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}}\"",
"confirmMakerworld": "Will import only models added from now on.",
"confirmImport_one": "Will import its {{count}} model now, and new ones as they're added.",
"confirmImport_other": "Will import its {{count}} models now, and new ones as they're added.",
"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}}"
"yoursLabel": "Your collections on {{provider}}",
"likes": "Liked models",
"startedWaiting": "\"{{name}}\" now syncs with \"{{source}}\". Its models come in the next time you open {{provider}} with Thingport Grab.",
"pickPrompt": "Pick a collection above, or paste a link.",
"startNothing": "Sync",
"printablesPrivateHelp": "Private collections and likes are added once Thingport Grab sees you signed in on Printables, since Printables shows them to you alone.",
"makerworldHelp": "MakerWorld quickly rate-limits bulk imports, so the models in it now aren't imported. Any already in your library are added to \"{{name}}\".",
"forget": "Forget",
"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."
}
}
}
},
+27 -14
View File
@@ -1963,11 +1963,11 @@
"intervalOption_few": "{{count}} valandas",
"intervalOption_many": "{{count}} valandos",
"intervalOption_other": "{{count}} valandų",
"badgeLikes": "Sinchronizuojama su @{{user}} pamėgtais Thingiverse modeliais",
"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ų.",
"badgeLikes": "Sinchronizuojama su @{{user}} pamėgtais {{provider}} modeliais",
"introLikes_one": "Modeliai, kurie patinka @{{user}} {{provider}}, čia importuojami kas valandą.",
"introLikes_few": "Modeliai, kurie patinka @{{user}} {{provider}}, čia importuojami kas {{count}} valandas.",
"introLikes_many": "Modeliai, kurie patinka @{{user}} {{provider}}, čia importuojami kas {{count}} valandos.",
"introLikes_other": "Modeliai, kurie patinka @{{user}} {{provider}}, čia importuojami kas {{count}} valandų.",
"browse": {
"providerLabel": "Tiekėjas",
"models_one": "{{count}} modelis",
@@ -1998,21 +1998,34 @@
"printables": "@vardas arba profilio nuoroda",
"thingiverse": "Vartotojo vardas arba profilio nuoroda"
},
"showing": "Rodomos viešos @{{user}} kolekcijos.",
"showing": {
"printables": "Rodomos @{{user}} kolekcijos.",
"thingiverse": "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}}“",
"confirmMakerworld": "Bus importuojami tik nuo šiol pridėti modeliai.",
"confirmImport_one": "Bus importuotas jos {{count}} modelis dabar ir nauji, kai bus pridėti.",
"confirmImport_few": "Bus importuoti jos {{count}} modeliai dabar ir nauji, kai bus pridėti.",
"confirmImport_many": "Bus importuota jos {{count}} modelio dabar ir nauji, kai bus pridėti.",
"confirmImport_other": "Bus importuota jos {{count}} modelių dabar ir nauji, kai bus pridėti.",
"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"
"yoursLabel": "Jūsų {{provider}} kolekcijos",
"likes": "Pamėgti modeliai",
"startedWaiting": "„{{name}}“ dabar sinchronizuojama su „{{source}}“. Jos modeliai pateks, kai kitą kartą atidarysite {{provider}} su Thingport Grab.",
"pickPrompt": "Pasirinkite kolekciją aukščiau arba įklijuokite nuorodą.",
"startNothing": "Sinchronizuoti",
"printablesPrivateHelp": "Privačios kolekcijos ir pamėgti modeliai pridedami, kai Thingport Grab pamato jus prisijungusį Printables svetainėje, nes Printables jas rodo tik jums.",
"makerworldHelp": "MakerWorld greitai riboja masinius importus, todėl dabar joje esantys modeliai neimportuojami. Jau jūsų bibliotekoje esantys bus pridėti į „{{name}}“.",
"forget": "Pamiršti",
"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."
}
}
}
},
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import Alert from "@mui/material/Alert";
import Box from "@mui/material/Box";
@@ -89,6 +89,11 @@ export default function CollectionSyncDialog({ collection, onClose, onUpdated, o
const { sync } = collection;
const [browseProvider, setBrowseProvider] = useState<BrowsableProvider | null>(null);
const [picked, setPicked] = useState<BrowsePick | null>(null);
// A pick from another provider would leave its explanation under this one's list.
const changeBrowseProvider = useCallback((next: BrowsableProvider) => {
setBrowseProvider(next);
setPicked(null);
}, []);
const providerId = sync?.provider ?? picked?.provider ?? browseProvider;
const provider = importProviderInfo(providerId)?.label ?? providerId ?? "";
const hasProfiles = providerId === "makerworld";
@@ -190,16 +195,29 @@ export default function CollectionSyncDialog({ collection, onClose, onUpdated, o
onUpdated({ ...collection, sync: result.sync });
showToast({
message: t(
result.importing
? "models:collections.sync.browse.startedImporting"
: "models:collections.sync.browse.started",
{ name: collection.name, provider },
result.waiting
? "models:collections.sync.browse.startedWaiting"
: result.importing
? "models:collections.sync.browse.startedImporting"
: "models:collections.sync.browse.started",
{
name: collection.name,
provider,
// The instance names a likes card in English; the tile shows it translated.
source: picked.collection.likes ? t("models:collections.sync.browse.likes") : picked.collection.name,
},
),
});
setSection("overview");
});
};
const footerText = !picked
? t("models:collections.sync.browse.pickPrompt")
: picked.provider === "makerworld"
? t("models:collections.sync.browse.confirmMakerworld")
: t("models:collections.sync.browse.confirmImport", { count: picked.collection.model_count });
const checked = sync?.last_checked_at
? t("models:collections.sync.lastChecked", { when: relativeTime(sync.last_checked_at, i18n.language) })
: t("models:collections.sync.notCheckedYet");
@@ -239,34 +257,53 @@ export default function CollectionSyncDialog({ collection, onClose, onUpdated, o
)}
{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 })}
// Reaches through the content area's 20px bottom padding, so the footer sits on the
// dialog's bottom edge however short the list is, just where it sticks on a long one.
<Box sx={{ display: "flex", flexDirection: "column", minHeight: "calc(100% + 20px)", mb: "-20px" }}>
<Box sx={{ mb: 2.5 }}>
<SyncBrowse
provider={browseProvider}
onProviderChange={changeBrowseProvider}
picked={picked}
onPick={setPicked}
onUnauthorized={onUnauthorized}
/>
</Box>
{/* Always in view, however far down the list the pick was made. Its bottom offset
cancels the content area's bottom padding. */}
<Box
sx={{
position: "sticky",
bottom: -20,
mt: "auto",
mx: { xs: -2, md: -2.5 },
px: { xs: 2, md: 2.5 },
py: 1,
borderTop: 1,
borderColor: "divider",
bgcolor: "background.paper",
zIndex: 1,
}}
>
{/* One short line beside the button, to leave the list as much room as possible. */}
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
<Button
variant="contained"
onClick={() => void startSync()}
disabled={busy || !picked}
sx={{ flexShrink: 0 }}
>
{t("models:collections.sync.browse.startNothing")}
</Button>
<Typography variant="body2" color={picked ? "text.primary" : "text.secondary"} sx={{ minWidth: 0 }}>
{footerText}
{picked?.provider === "makerworld" && (
<HelpTip text={t("models:collections.sync.browse.makerworldHelp", { name: collection.name })} />
)}
</Typography>
</Box>
)}
</Stack>
</Box>
</Box>
)}
{section === "overview" && sync && (
@@ -274,7 +311,7 @@ export default function CollectionSyncDialog({ collection, onClose, onUpdated, o
<Box>
<Typography variant="body2">
{sync.likes_of
? t("models:collections.sync.introLikes", { user: sync.likes_of, count: sync.interval_hours })
? t("models:collections.sync.introLikes", { user: sync.likes_of, provider, count: sync.interval_hours })
: t("models:collections.sync.intro", { provider, count: sync.interval_hours })}
</Typography>
<Link
@@ -1,6 +1,7 @@
import { useTranslation } from "react-i18next";
import Box from "@mui/material/Box";
import ButtonBase from "@mui/material/ButtonBase";
import Skeleton from "@mui/material/Skeleton";
import Typography from "@mui/material/Typography";
import type { Theme } from "@mui/material/styles";
import CheckIcon from "@mui/icons-material/Check";
@@ -134,9 +135,10 @@ function PrintablesTile({ collection }: { collection: ProviderCollection }) {
<Typography noWrap sx={{ fontWeight: 700, fontSize: "0.875rem", mt: 1 }}>
{collection.name}
</Typography>
<Typography sx={{ fontSize: "0.8rem", color: "text.secondary" }}>
<Box sx={{ display: "flex", alignItems: "center", fontSize: "0.8rem", color: "text.secondary" }}>
<ModelCount count={collection.model_count} provider="printables" />
</Typography>
{collection.private && <LockOutlinedIcon sx={{ fontSize: 14, ml: "auto" }} />}
</Box>
</Box>
);
}
@@ -218,9 +220,11 @@ const TILES: Record<BrowsableProvider, (props: { collection: ProviderCollection
/** 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) {
export default function ProviderCollectionTile({ provider, collection: given, selected, onSelect }: Props) {
const { t } = useTranslation("models");
const Tile = TILES[provider];
// A user's likes come as a collection the instance names in English.
const collection = given.likes ? { ...given, name: t("collections.sync.browse.likes") } : given;
const taken = collection.synced !== null;
return (
<Box>
@@ -272,3 +276,64 @@ export default function ProviderCollectionTile({ provider, collection, selected,
</Box>
);
}
/** A tile's shape while the provider's collections load, in that provider's manner. */
export function ProviderCollectionTileSkeleton({ provider }: { provider: BrowsableProvider }) {
const lines = (
<>
<Skeleton variant="text" sx={{ fontSize: "0.875rem", width: "70%" }} />
<Skeleton variant="text" sx={{ fontSize: "0.75rem", width: "40%" }} />
</>
);
if (provider === "makerworld") {
return (
<Box aria-hidden sx={{ borderRadius: "12px", border: 1, borderColor: "divider", overflow: "hidden", mb: "11px" }}>
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "2px", aspectRatio: "4 / 3" }}>
{[0, 1, 2, 3].map((i) => (
<Skeleton key={i} variant="rectangular" sx={{ width: "100%", height: "100%" }} />
))}
</Box>
<Box sx={{ px: 1.5, py: 1.25 }}>{lines}</Box>
</Box>
);
}
if (provider === "printables") {
return (
<Box
aria-hidden
sx={{
borderRadius: "4px",
border: 1,
borderColor: "divider",
borderTop: `3px solid ${PROVIDER_ACCENTS.printables}`,
p: 1.25,
}}
>
<Box sx={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: "4px", mb: 1 }}>
{[0, 1, 2].map((i) => (
<Skeleton key={i} variant="rectangular" sx={{ width: "100%", height: "auto", aspectRatio: "1 / 1" }} />
))}
</Box>
{lines}
</Box>
);
}
return (
<Box
aria-hidden
sx={{
borderRadius: "8px",
aspectRatio: "4 / 3",
bgcolor: PROVIDER_ACCENTS.thingiverse,
opacity: 0.35,
display: "flex",
flexDirection: "column",
alignItems: "center",
pt: 1.5,
}}
>
<Skeleton variant="text" sx={{ fontSize: "1rem", width: "60%", bgcolor: "rgba(255, 255, 255, 0.5)" }} />
<Skeleton variant="text" sx={{ fontSize: "0.8rem", width: "30%", bgcolor: "rgba(255, 255, 255, 0.5)" }} />
</Box>
);
}
@@ -3,8 +3,8 @@ 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 Skeleton from "@mui/material/Skeleton";
import Stack from "@mui/material/Stack";
import TextField from "@mui/material/TextField";
import Typography from "@mui/material/Typography";
@@ -17,11 +17,13 @@ import {
providerCollectionsApi,
} from "../../api/providerCollections";
import { importProviderInfo } from "../../constants/importProviders";
import HelpTip from "../../components/HelpTip";
import Segmented from "../../components/controls/Segmented";
import { SectionLabel } from "../../components/Layout/ConfigurationDialog/parts";
import { useSlicerPreference } from "../../hooks/useSlicerPreference";
import { useConfirm } from "../../components/ConfirmProvider";
import { onConfigurationClosed, openConfiguration } from "../../utils/configurationEvents";
import ProviderCollectionTile from "./ProviderCollectionTile";
import ProviderCollectionTile, { ProviderCollectionTileSkeleton } from "./ProviderCollectionTile";
export type BrowsePick = { provider: BrowsableProvider; collection: ProviderCollection };
@@ -34,6 +36,8 @@ type Props = {
};
const PASTE_DEBOUNCE_MS = 400;
// Two rows' worth at the dialog's width.
const LOADING_TILES = 6;
const PASTE_EXAMPLES: Record<BrowsableProvider, string> = {
makerworld: "https://makerworld.com/en/collections/…",
@@ -62,6 +66,7 @@ function providerOfUrl(url: string): BrowsableProvider | null {
export default function SyncBrowse({ provider, onProviderChange, picked, onPick, onUnauthorized }: Props) {
const { t } = useTranslation(["models", "common"]);
const slicer = useSlicerPreference();
const confirm = useConfirm();
const [accounts, setAccounts] = useState<ProviderAccounts | null>(null);
const [collections, setCollections] = useState<Partial<Record<BrowsableProvider, ProviderCollection[]>>>({});
const [loading, setLoading] = useState(false);
@@ -174,11 +179,38 @@ export default function SyncBrowse({ provider, onProviderChange, picked, onPick,
}
};
/** For Printables, also stops Thingport Grab sending what only the user sees there. */
const forgetAccount = async () => {
if (!provider) return;
const ok = await confirm({
title: t("models:collections.sync.browse.forgetTitle", { provider: importProviderInfo(provider)?.label }),
message: t(`models:collections.sync.browse.forgetMessage.${provider}`),
confirmLabel: t("models:collections.sync.browse.forget"),
destructive: true,
});
if (!ok) return;
setError(null);
try {
setAccounts(await providerCollectionsApi.setAccount(provider, null));
setCollections((current) => ({ ...current, [provider]: undefined }));
} catch (err) {
fail(err, t("models:collections.sync.browse.usernameFailed"));
}
};
const isPicked = (p: BrowsableProvider, collection: ProviderCollection) =>
picked?.provider === p && picked.collection.id === collection.id;
const grid = { display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(170px, 1fr))", gap: 2 };
const skeletons = (p: BrowsableProvider, count: number) => (
<Box sx={grid}>
{Array.from({ length: count }, (_, i) => (
<ProviderCollectionTileSkeleton key={i} provider={p} />
))}
</Box>
);
const tiles = (p: BrowsableProvider, list: ProviderCollection[]) => (
<Box sx={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(170px, 1fr))", gap: 2 }}>
<Box sx={grid}>
{list.map((collection) => (
<ProviderCollectionTile
key={collection.id}
@@ -192,7 +224,19 @@ export default function SyncBrowse({ provider, onProviderChange, picked, onPick,
);
if (!accounts || !provider) {
return error ? <Alert severity="error">{error}</Alert> : <CircularProgress size={24} />;
if (error) return <Alert severity="error">{error}</Alert>;
// The picker, the paste field and a row of tiles, until it's known which provider to show.
return (
<Stack spacing={2.5} aria-hidden>
<Skeleton variant="rounded" width={290} height={38} sx={{ borderRadius: "10px" }} />
<Skeleton variant="rounded" height={40} />
<Box sx={grid}>
{Array.from({ length: 3 }, (_, i) => (
<Skeleton key={i} variant="rounded" sx={{ height: "auto", aspectRatio: "1 / 1", borderRadius: "12px" }} />
))}
</Box>
</Stack>
);
}
const name = importProviderInfo(provider)?.label ?? provider;
const list = collections[provider];
@@ -216,9 +260,6 @@ export default function SyncBrowse({ provider, onProviderChange, picked, onPick,
{/* 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
@@ -226,7 +267,9 @@ export default function SyncBrowse({ provider, onProviderChange, picked, onPick,
onChange={(e) => setPasted(e.target.value)}
placeholder={PASTE_EXAMPLES[provider]}
/>
{pastedResult === "loading" && <CircularProgress size={20} sx={{ mt: 1.5 }} />}
{pastedResult === "loading" && (
<Box sx={{ mt: 1.5, maxWidth: 220 }}>{skeletons(providerOfUrl(pasted.trim()) ?? provider, 1)}</Box>
)}
{pastedResult && pastedResult !== "loading" && "error" in pastedResult && (
<Alert severity="warning" sx={{ mt: 1.5 }}>
{pastedResult.error}
@@ -295,23 +338,28 @@ export default function SyncBrowse({ provider, onProviderChange, picked, onPick,
<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 })}{" "}
{t(`models:collections.sync.browse.showing.${provider}`, { user: account.account })}
{provider === "printables" && (
<HelpTip text={t("models:collections.sync.browse.printablesPrivateHelp")} />
)}{" "}
<Link component="button" variant="body2" onClick={() => setEditingAccount(true)}>
{t("models:collections.sync.browse.change")}
</Link>
{" · "}
<Link component="button" variant="body2" onClick={() => void forgetAccount()}>
{t("models:collections.sync.browse.forget")}
</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>
)
)}
{loading && !list
? skeletons(provider, LOADING_TILES)
: list && list.length
? tiles(provider, list)
: list && (
<Typography variant="body2" color="text.secondary">
{t("models:collections.sync.browse.empty", { provider: name })}
</Typography>
)}
</Box>
)}
</Stack>