From b619084be7ed060d02cd6ba473a196e7ba0859de Mon Sep 17 00:00:00 2001 From: Tautvydas Derzinskas Date: Mon, 12 Oct 2026 12:54:26 +0200 Subject: [PATCH] feat: browse private collections functionality --- .../migration.sql | 14 + .../migration.sql | 1 + backend/prisma/schema.prisma | 41 ++- backend/src/dto.ts | 10 +- backend/src/routes/imports.ts | 6 +- backend/src/routes/providerCollections.ts | 49 ++++ backend/src/services/collectionSyncRunner.ts | 19 +- backend/src/services/collectionSyncService.ts | 44 ++- backend/src/services/importJobRunner.ts | 10 +- backend/src/services/printablesApi.ts | 16 +- .../services/providerCollectionsService.ts | 161 ++++++++++- .../src/services/providerListingsService.ts | 119 ++++++++ backend/src/services/thingiverseApi.ts | 39 ++- backend/tests/providerCollections.test.ts | 262 +++++++++++++++++- docs/guides/synced-collections.md | 8 + extension/PRIVACY.md | 12 +- extension/scripts/manifest.ts | 4 + extension/src/background/index.ts | 4 +- extension/src/background/providerAccounts.ts | 47 +++- extension/src/content/panels/batch.ts | 86 +++++- extension/src/content/printables.ts | 189 +++++++++++++ extension/src/content/providerAccount.ts | 54 +++- extension/src/shared/messages.ts | 17 ++ extension/src/shared/storage.ts | 9 + frontend/src/api/collections.ts | 8 +- frontend/src/api/providerCollections.ts | 2 + .../src/components/CollectionSyncBadge.tsx | 2 +- .../components/CollectionSyncIndicator.tsx | 2 +- frontend/src/i18n/locales/en.json | 33 ++- frontend/src/i18n/locales/lt.json | 41 ++- .../CollectionSyncDialog.tsx | 101 ++++--- .../ProviderCollectionTile.tsx | 71 ++++- .../pages/CollectionDetailPage/SyncBrowse.tsx | 88 ++++-- 33 files changed, 1409 insertions(+), 160 deletions(-) create mode 100644 backend/prisma/migrations/20261012140000_provider_listings/migration.sql create mode 100644 backend/prisma/migrations/20261012150000_printables_push_off/migration.sql create mode 100644 backend/src/services/providerListingsService.ts create mode 100644 extension/src/content/printables.ts diff --git a/backend/prisma/migrations/20261012140000_provider_listings/migration.sql b/backend/prisma/migrations/20261012140000_provider_listings/migration.sql new file mode 100644 index 0000000..c081175 --- /dev/null +++ b/backend/prisma/migrations/20261012140000_provider_listings/migration.sql @@ -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; diff --git a/backend/prisma/migrations/20261012150000_printables_push_off/migration.sql b/backend/prisma/migrations/20261012150000_printables_push_off/migration.sql new file mode 100644 index 0000000..dd3b453 --- /dev/null +++ b/backend/prisma/migrations/20261012150000_printables_push_off/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "User" ADD COLUMN "printablesPushOff" BOOLEAN NOT NULL DEFAULT false; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 1b30d4b..1e61186 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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 diff --git a/backend/src/dto.ts b/backend/src/dto.ts index 3988251..cea754d 100644 --- a/backend/src/dto.ts +++ b/backend/src/dto.ts @@ -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); } diff --git a/backend/src/routes/imports.ts b/backend/src/routes/imports.ts index a3e9d6d..967d70c 100644 --- a/backend/src/routes/imports.ts +++ b/backend/src/routes/imports.ts @@ -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", diff --git a/backend/src/routes/providerCollections.ts b/backend/src/routes/providerCollections.ts index d1b2ab3..eb9ba00 100644 --- a/backend/src/routes/providerCollections.ts +++ b/backend/src/routes/providerCollections.ts @@ -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; diff --git a/backend/src/services/collectionSyncRunner.ts b/backend/src/services/collectionSyncRunner.ts index 91543b8..be852c1 100644 --- a/backend/src/services/collectionSyncRunner.ts +++ b/backend/src/services/collectionSyncRunner.ts @@ -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}`, diff --git a/backend/src/services/collectionSyncService.ts b/backend/src/services/collectionSyncService.ts index 322a3b4..c637fd3 100644 --- a/backend/src/services/collectionSyncService.ts +++ b/backend/src/services/collectionSyncService.ts @@ -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 = { @@ -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 { + 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; diff --git a/backend/src/services/importJobRunner.ts b/backend/src/services/importJobRunner.ts index 4f1965b..6de2a18 100644 --- a/backend/src/services/importJobRunner.ts +++ b/backend/src/services/importJobRunner.ts @@ -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 }; // 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)); diff --git a/backend/src/services/printablesApi.ts b/backend/src/services/printablesApi.ts index d5fbd16..e74a3ec 100644 --- a/backend/src/services/printablesApi.ts +++ b/backend/src/services/printablesApi.ts @@ -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, diff --git a/backend/src/services/providerCollectionsService.ts b/backend/src/services/providerCollectionsService.ts index f1ea4a0..10281b5 100644 --- a/backend/src/services/providerCollectionsService.ts +++ b/backend/src/services/providerCollectionsService.ts @@ -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 { @@ -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 { + 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(call: () => Promise): Promise { 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 { 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; +}; + +/** 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 { + 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(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)); + } + } } diff --git a/backend/src/services/providerListingsService.ts b/backend/src/services/providerListingsService.ts new file mode 100644 index 0000000..b12a2d3 --- /dev/null +++ b/backend/src/services/providerListingsService.ts @@ -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(userId: string, key: string): Promise { + 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 { + 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 { + return (await read(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 { + 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 { + 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 { + 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 { + 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(userId, COLLECTIONS_KEY), + read(userId, LIKES_KEY), + ]); + return { collections: collections?.cards ?? null, likes: likes?.card ?? null }; +} + +export async function pushedLikes(userId: string): Promise { + return read(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 { + 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(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 { + 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)]; +} diff --git a/backend/src/services/thingiverseApi.ts b/backend/src/services/thingiverseApi.ts index e515a97..1edaa3e 100644 --- a/backend/src/services/thingiverseApi.ts +++ b/backend/src/services/thingiverseApi.ts @@ -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 { - 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 | 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 { diff --git a/backend/tests/providerCollections.test.ts b/backend/tests/providerCollections.test.ts index a9643fe..907ee41 100644 --- a/backend/tests/providerCollections.test.ts +++ b/backend/tests/providerCollections.test.ts @@ -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): 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 }; 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 { @@ -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 { + 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) => + 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 }, + ); + }); +}); diff --git a/docs/guides/synced-collections.md b/docs/guides/synced-collections.md index 15595f6..889f744 100644 --- a/docs/guides/synced-collections.md +++ b/docs/guides/synced-collections.md @@ -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. diff --git a/extension/PRIVACY.md b/extension/PRIVACY.md index b8a46b8..08df65a 100644 --- a/extension/PRIVACY.md +++ b/extension/PRIVACY.md @@ -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 diff --git a/extension/scripts/manifest.ts b/extension/scripts/manifest.ts index 3da34da..1fbdd0d 100644 --- a/extension/scripts/manifest.ts +++ b/extension/scripts/manifest.ts @@ -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 { 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" }], diff --git a/extension/src/background/index.ts b/extension/src/background/index.ts index 2422dcb..40a38c0 100644 --- a/extension/src/background/index.ts +++ b/extension/src/background/index.ts @@ -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({ 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), diff --git a/extension/src/background/providerAccounts.ts b/extension/src/background/providerAccounts.ts index e16b303..6409410 100644 --- a/extension/src/background/providerAccounts.ts +++ b/extension/src/background/providerAccounts.ts @@ -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 { + 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 { + 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; +} diff --git a/extension/src/content/panels/batch.ts b/extension/src/content/panels/batch.ts index a5c0717..fa61236 100644 --- a/extension/src/content/panels/batch.ts +++ b/extension/src/content/panels/batch.ts @@ -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 { + 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 }>("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 { + let instanceError: unknown = null; + try { + const found = await api("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 { renderFunStatus(loadingModelsPhrases(ctx().classification.provider)); - let result: BatchEntriesResult; + let result: PanelEntries; let sync: SyncLookup | null; try { - [result, sync] = await Promise.all([ - api("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 { onPanelAction("import", () => void runBatchImport(result, sync)); } -async function runBatchImport(result: BatchEntriesResult, sync: SyncLookup | null): Promise { +async function runBatchImport(result: PanelEntries, sync: SyncLookup | null): Promise { const ids = panelQueryAll(".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)); diff --git a/extension/src/content/printables.ts b/extension/src/content/printables.ts new file mode 100644 index 0000000..84e57c2 --- /dev/null +++ b/extension/src/content/printables.ts @@ -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 = {}): Promise { + 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 | null { + return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : 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 { + return request("GET_PRINTABLES_TOKEN").catch(() => null); +} + +export async function readMe(token: string): Promise { + 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, +): Promise<{ items: Record[]; truncated: boolean }> { + const items: Record[] = []; + 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 { + 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> { + 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 { + 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(label: string, read: () => Promise): Promise { + 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 { + 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 = {}; + // 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 }; +} diff --git a/extension/src/content/providerAccount.ts b/extension/src/content/providerAccount.ts index 1730442..5cd16cb 100644 --- a/extension/src/content/providerAccount.ts +++ b/extension/src/content/providerAccount.ts @@ -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 { return null; } -/** Asked of Printables' API as the site itself does; from this page, so its CORS rules allow it. */ -async function readPrintablesHandle(): Promise { - 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 } = {}, +): Promise { 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 { + 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 { + // 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 }); }); } diff --git a/extension/src/shared/messages.ts b/extension/src/shared/messages.ts index dd03796..2b987a5 100644 --- a/extension/src/shared/messages.ts +++ b/extension/src/shared/messages.ts @@ -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; +}; + /** 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 }; diff --git a/extension/src/shared/storage.ts b/extension/src/shared/storage.ts index 02ee1bd..518e318 100644 --- a/extension/src/shared/storage.ts +++ b/extension/src/shared/storage.ts @@ -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 }; + lastPrintablesPushAt?: number; + lastPrintablesLikesAt?: number; + printablesPlanHandedOutAt?: number; }; export const CONFIG_CHANGE_KEYS: readonly string[] = [ diff --git a/frontend/src/api/collections.ts b/frontend/src/api/collections.ts index 2fdca74..2230025 100644 --- a/frontend/src/api/collections.ts +++ b/frontend/src/api/collections.ts @@ -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" }), diff --git a/frontend/src/api/providerCollections.ts b/frontend/src/api/providerCollections.ts index aeb7b05..2b433fd 100644 --- a/frontend/src/api/providerCollections.ts +++ b/frontend/src/api/providerCollections.ts @@ -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(path: string, fallback: string, init?: RequestInit): Promise { diff --git a/frontend/src/components/CollectionSyncBadge.tsx b/frontend/src/components/CollectionSyncBadge.tsx index 4b1913f..acb8e68 100644 --- a/frontend/src/components/CollectionSyncBadge.tsx +++ b/frontend/src/components/CollectionSyncBadge.tsx @@ -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 = { diff --git a/frontend/src/components/CollectionSyncIndicator.tsx b/frontend/src/components/CollectionSyncIndicator.tsx index a51d5e0..dd70352 100644 --- a/frontend/src/components/CollectionSyncIndicator.tsx +++ b/frontend/src/components/CollectionSyncIndicator.tsx @@ -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 ( diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index ceb3dbf..8e45486 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -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." + } } } }, diff --git a/frontend/src/i18n/locales/lt.json b/frontend/src/i18n/locales/lt.json index 39ff858..7b3dbb8 100644 --- a/frontend/src/i18n/locales/lt.json +++ b/frontend/src/i18n/locales/lt.json @@ -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." + } } } }, diff --git a/frontend/src/pages/CollectionDetailPage/CollectionSyncDialog.tsx b/frontend/src/pages/CollectionDetailPage/CollectionSyncDialog.tsx index 317bd20..1bc53ee 100644 --- a/frontend/src/pages/CollectionDetailPage/CollectionSyncDialog.tsx +++ b/frontend/src/pages/CollectionDetailPage/CollectionSyncDialog.tsx @@ -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(null); const [picked, setPicked] = useState(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 && ( - - - {picked && ( - - - {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, - })} - - + + {footerText} + {picked?.provider === "makerworld" && ( + + )} + - )} - + + )} {section === "overview" && sync && ( @@ -274,7 +311,7 @@ export default function CollectionSyncDialog({ collection, onClose, onUpdated, o {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 })} {collection.name} - + - + {collection.private && } + ); } @@ -218,9 +220,11 @@ const TILES: Record @@ -272,3 +276,64 @@ export default function ProviderCollectionTile({ provider, collection, selected, ); } + +/** A tile's shape while the provider's collections load, in that provider's manner. */ +export function ProviderCollectionTileSkeleton({ provider }: { provider: BrowsableProvider }) { + const lines = ( + <> + + + + ); + if (provider === "makerworld") { + return ( + + + {[0, 1, 2, 3].map((i) => ( + + ))} + + {lines} + + ); + } + if (provider === "printables") { + return ( + + + {[0, 1, 2].map((i) => ( + + ))} + + {lines} + + ); + } + return ( + + + + + ); +} diff --git a/frontend/src/pages/CollectionDetailPage/SyncBrowse.tsx b/frontend/src/pages/CollectionDetailPage/SyncBrowse.tsx index ef5e6ad..2c3bee4 100644 --- a/frontend/src/pages/CollectionDetailPage/SyncBrowse.tsx +++ b/frontend/src/pages/CollectionDetailPage/SyncBrowse.tsx @@ -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 = { 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(null); const [collections, setCollections] = useState>>({}); 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) => ( + + {Array.from({ length: count }, (_, i) => ( + + ))} + + ); const tiles = (p: BrowsableProvider, list: ProviderCollection[]) => ( - + {list.map((collection) => ( {error} : ; + if (error) return {error}; + // The picker, the paste field and a row of tiles, until it's known which provider to show. + return ( + + + + + {Array.from({ length: 3 }, (_, i) => ( + + ))} + + + ); } 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. */} {t("models:collections.sync.browse.pasteLabel")} - - {t("models:collections.sync.browse.pasteHint")} - setPasted(e.target.value)} placeholder={PASTE_EXAMPLES[provider]} /> - {pastedResult === "loading" && } + {pastedResult === "loading" && ( + {skeletons(providerOfUrl(pasted.trim()) ?? provider, 1)} + )} {pastedResult && pastedResult !== "loading" && "error" in pastedResult && ( {pastedResult.error} @@ -295,23 +338,28 @@ export default function SyncBrowse({ provider, onProviderChange, picked, onPick, {t("models:collections.sync.browse.yoursLabel", { provider: name })} {provider !== "makerworld" && account?.account && ( - {t("models:collections.sync.browse.showing", { user: account.account })}{" "} + {t(`models:collections.sync.browse.showing.${provider}`, { user: account.account })} + {provider === "printables" && ( + + )}{" "} setEditingAccount(true)}> {t("models:collections.sync.browse.change")} + {" · "} + void forgetAccount()}> + {t("models:collections.sync.browse.forget")} + )} - {loading && !list ? ( - - ) : list && list.length ? ( - tiles(provider, list) - ) : ( - list && ( - - {t("models:collections.sync.browse.empty", { provider: name })} - - ) - )} + {loading && !list + ? skeletons(provider, LOADING_TILES) + : list && list.length + ? tiles(provider, list) + : list && ( + + {t("models:collections.sync.browse.empty", { provider: name })} + + )} )}