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