mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-18 20:59:55 -05:00
Merge pull request #55 from TautvydasDerzinskas/hotfix/post-release-bugs
fix: retry file downloads blocked by Cloudflare or rate limits
This commit is contained in:
31 files changed
+425
-134
No files matched your search
@@ -310,7 +310,7 @@ Thingport ships two small companion tools, each downloadable from the in-app Dow
|
||||
|
||||
## Provider Setup
|
||||
|
||||
Printables imports work with no setup. MakerWorld and Thingiverse each need a credential from your own account first. Connect them under **Configuration → Providers** (from the user menu): right-click a provider to connect or disconnect it. Admins can turn any provider off for the whole instance under **Administration → Providers**. See **[docs/PROVIDER_SETUP.md](docs/PROVIDER_SETUP.md)** for how to create a Thingiverse Access Token and how to grab a MakerWorld session cookie.
|
||||
Printables imports work with no setup. MakerWorld and Thingiverse each need a credential from your own account first. Connect them under **Configuration → Providers** (from the user menu): right-click a provider to connect or disconnect it. Admins can turn any provider off for the whole instance under **Administration → Providers**. See **[docs/PROVIDER_SETUP.md](docs/PROVIDER_SETUP.md)** for how to create a Thingiverse App Token and how to grab a MakerWorld session cookie.
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
@@ -115,6 +115,8 @@ export const IMPORT_HTML_MAX_KB = envInt("IMPORT_HTML_MAX_KB", 4096);
|
||||
export const IMPORT_HTML_MAX_BYTES = Math.max(64, IMPORT_HTML_MAX_KB) * 1024;
|
||||
// Gap between items in a batch import, to avoid anti-abuse bursts.
|
||||
export const IMPORT_COLLECTION_DELAY_MS = envInt("IMPORT_COLLECTION_DELAY_MS", 1000);
|
||||
// Wait before retrying a file download Cloudflare challenged or rate-limited.
|
||||
export const IMPORT_DOWNLOAD_RETRY_DELAY_MS = envInt("IMPORT_DOWNLOAD_RETRY_DELAY_MS", 2000);
|
||||
// MakerWorld's CAPTCHA trips on bursts, so in collection imports this delay precedes every
|
||||
// MakerWorld request (listing pages, each resolution step, each image), not just each model.
|
||||
export const IMPORT_MAKERWORLD_CALL_DELAY_MS = envInt("IMPORT_MAKERWORLD_CALL_DELAY_MS", 5000);
|
||||
|
||||
@@ -291,7 +291,7 @@ router.post(
|
||||
if (!accessToken) {
|
||||
throw new HttpError(
|
||||
503,
|
||||
"Thingiverse import isn't configured for this instance yet -- ask an admin to add an Access Token in Admin Settings.",
|
||||
"Thingiverse import isn't configured for this instance yet -- ask an admin to add an App Token in Admin Settings.",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -329,7 +329,7 @@ router.post(
|
||||
if (!accessToken) {
|
||||
throw new HttpError(
|
||||
503,
|
||||
"Thingiverse import isn't configured for this instance yet -- ask an admin to add an Access Token in Admin Settings.",
|
||||
"Thingiverse import isn't configured for this instance yet -- ask an admin to add an App Token in Admin Settings.",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -338,7 +338,7 @@ router.post(
|
||||
if (trimmed && !(await verifyThingiverseAccessToken(trimmed))) {
|
||||
throw new HttpError(
|
||||
422,
|
||||
"Couldn't verify this Thingiverse Access Token -- it may be invalid, revoked, or Thingiverse is rate-limiting this instance right now. Double-check the token at thingiverse.com/apps/create and try again.",
|
||||
"Couldn't verify this Thingiverse App Token -- it may be invalid, revoked, or Thingiverse is rate-limiting this instance right now. Double-check the token at thingiverse.com/apps/create and try again.",
|
||||
);
|
||||
}
|
||||
await setThingiverseAccessToken(body.access_token);
|
||||
|
||||
@@ -54,14 +54,14 @@ export async function fetchSourceMeta(userId: string, print: Print, sourceUrl: s
|
||||
if (!accessToken) {
|
||||
throw new HttpError(
|
||||
503,
|
||||
"Thingiverse import isn't configured for this instance yet -- ask an admin to add an Access Token in Admin Settings.",
|
||||
"Thingiverse import isn't configured for this instance yet -- ask an admin to add an App Token in Admin Settings.",
|
||||
);
|
||||
}
|
||||
const resolved = await resolveThingiverseThing(externalId, accessToken);
|
||||
if (!resolved) {
|
||||
throw new HttpError(
|
||||
404,
|
||||
"This Thingiverse Thing could not be found, or isn't accessible with the configured Access Token.",
|
||||
"This Thingiverse Thing could not be found, or isn't accessible with the configured App Token.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -39,12 +39,17 @@ export async function testFlaresolverr(
|
||||
// Cloudflare still said no after FlareSolverr, or with none set up: nothing to do with the link.
|
||||
export const CLOUDFLARE_BLOCKED_CODE = "CLOUDFLARE_BLOCKED";
|
||||
|
||||
/** Cloudflare challenged this request. Narrower than looksLikeCloudflareBlock: a site behind
|
||||
* Cloudflare sends `server: cloudflare` on its own 403s and 404s too. */
|
||||
export function isCloudflareChallenge(headers: Headers): boolean {
|
||||
return headers.has("cf-mitigated");
|
||||
}
|
||||
|
||||
/** A Cloudflare edge block rather than an application-level 403. */
|
||||
export function looksLikeCloudflareBlock(headers: Headers): boolean {
|
||||
const server = (headers.get("server") || "").toLowerCase();
|
||||
if (server.includes("cloudflare")) return true;
|
||||
if (headers.has("cf-mitigated")) return true;
|
||||
return false;
|
||||
return isCloudflareChallenge(headers);
|
||||
}
|
||||
|
||||
function parseCookieHeader(raw: string | null | undefined): Record<string, string> {
|
||||
|
||||
@@ -392,7 +392,7 @@ async function runThingiverseThingsImportJob(
|
||||
if (!accessToken) {
|
||||
throw new HttpError(
|
||||
503,
|
||||
"Thingiverse import isn't configured for this instance yet -- ask an admin to add an Access Token in Admin Settings.",
|
||||
"Thingiverse import isn't configured for this instance yet -- ask an admin to add an App Token in Admin Settings.",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -486,7 +486,7 @@ async function runThingiverseThingsImportJob(
|
||||
`${rateLimited} blocked by Thingiverse's rate-limit protection (too many requests at once) — wait a while, then retry`,
|
||||
);
|
||||
}
|
||||
if (authFailed) bodyParts.push(`${authFailed} failed because the configured Access Token was rejected`);
|
||||
if (authFailed) bodyParts.push(`${authFailed} failed because the configured App Token was rejected`);
|
||||
if (otherFailed) bodyParts.push(`${otherFailed} failed`);
|
||||
const label = sourceLabel(collectionTitle);
|
||||
await createNotification(userId, {
|
||||
|
||||
@@ -5,6 +5,7 @@ import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import {
|
||||
IMPORT_ALLOWED_EXTS,
|
||||
IMPORT_DOWNLOAD_RETRY_DELAY_MS,
|
||||
IMPORT_HTML_MAX_BYTES,
|
||||
IMPORT_MAX_BYTES,
|
||||
IMPORT_PREVIEW_IMAGE_DELAY_MS,
|
||||
@@ -21,10 +22,11 @@ import {
|
||||
} from "../utils/fileUtils";
|
||||
import { validateRemoteUrl } from "../utils/urlUtils";
|
||||
import { readCapped } from "../utils/readCapped";
|
||||
import { maybeSleep, sleep } from "../utils/concurrency";
|
||||
import { mapWithConcurrency, maybeSleep, sleep } from "../utils/concurrency";
|
||||
import {
|
||||
CLOUDFLARE_BLOCKED_CODE,
|
||||
fetchViaFlaresolverr,
|
||||
isCloudflareChallenge,
|
||||
looksLikeCloudflareBlock,
|
||||
shouldProxyHost,
|
||||
} from "./flaresolverr";
|
||||
@@ -737,12 +739,26 @@ export const MULTI_FILE_PLATE_EXTS = new Set([...IMPORT_ALLOWED_EXTS].filter((ex
|
||||
|
||||
export type PlainDownloadResult = { input: NewPlateInput } | { rateLimited: true } | null;
|
||||
|
||||
/** Best-effort: null on failure so one bad file doesn't fail the whole import, except a 429,
|
||||
* which is reported so the caller can say why. */
|
||||
// Thingiverse's CDN challenges request bursts; parallel downloads trip it more often.
|
||||
const THINGIVERSE_DOWNLOAD_CONCURRENCY = 2;
|
||||
|
||||
/** A 429, or a 403 Cloudflare challenge (which FlareSolverr can't solve for a binary download). */
|
||||
function isDownloadBlocked(res: Response): boolean {
|
||||
return res.status === 429 || (res.status === 403 && isCloudflareChallenge(res.headers));
|
||||
}
|
||||
|
||||
/** Best-effort: null on failure so one bad file doesn't fail the whole import, except a block,
|
||||
* which is retried once and then reported so the caller can say why. */
|
||||
export async function downloadPlainFileToTemp(url: string, suggestedName: string): Promise<PlainDownloadResult> {
|
||||
try {
|
||||
const res = await rawFetch(url, { "User-Agent": IMPORT_USER_AGENT, Accept: "*/*" });
|
||||
if (res.status === 429) {
|
||||
const headers = { "User-Agent": IMPORT_USER_AGENT, Accept: "*/*" };
|
||||
let res = await rawFetch(url, headers);
|
||||
if (isDownloadBlocked(res)) {
|
||||
await res.body?.cancel().catch(() => undefined);
|
||||
await maybeSleep(IMPORT_DOWNLOAD_RETRY_DELAY_MS);
|
||||
res = await rawFetch(url, headers);
|
||||
}
|
||||
if (isDownloadBlocked(res)) {
|
||||
await res.body?.cancel().catch(() => undefined);
|
||||
return { rateLimited: true };
|
||||
}
|
||||
@@ -778,7 +794,7 @@ async function importThingiverseThing(
|
||||
if (!accessToken) {
|
||||
throw new HttpError(
|
||||
503,
|
||||
"Thingiverse import isn't configured for this instance yet -- ask an admin to add an Access Token in Admin Settings.",
|
||||
"Thingiverse import isn't configured for this instance yet -- ask an admin to add an App Token in Admin Settings.",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -794,7 +810,7 @@ async function importThingiverseThing(
|
||||
if (!resolved) {
|
||||
throw new HttpError(
|
||||
404,
|
||||
"This Thingiverse Thing could not be found, or isn't accessible with the configured Access Token.",
|
||||
"This Thingiverse Thing could not be found, or isn't accessible with the configured App Token.",
|
||||
);
|
||||
}
|
||||
const { meta, plateFiles, galleryImages } = resolved;
|
||||
@@ -818,8 +834,10 @@ async function importThingiverseThing(
|
||||
const modelFiles = plateFiles.filter((f: ThingiversePlateFile) =>
|
||||
MULTI_FILE_PLATE_EXTS.has(path.extname(f.name).toLowerCase()),
|
||||
);
|
||||
const downloadResults = await Promise.all(
|
||||
modelFiles.map((f: ThingiversePlateFile) => downloadPlainFileToTemp(f.url, f.name)),
|
||||
const downloadResults = await mapWithConcurrency(
|
||||
modelFiles,
|
||||
THINGIVERSE_DOWNLOAD_CONCURRENCY,
|
||||
(f: ThingiversePlateFile) => downloadPlainFileToTemp(f.url, f.name),
|
||||
);
|
||||
const downloaded = downloadResults
|
||||
.filter((result): result is { input: NewPlateInput } => result !== null && "input" in result)
|
||||
@@ -904,6 +922,12 @@ async function importPrintablesModel(
|
||||
.filter((result): result is { input: NewPlateInput } => result !== null && "input" in result)
|
||||
.map((result) => result.input);
|
||||
if (!downloaded.length) {
|
||||
if (downloadResults.some((result) => result !== null && "rateLimited" in result)) {
|
||||
throw new HttpError(
|
||||
429,
|
||||
"Printables blocked a file download (rate limit or Cloudflare challenge). This usually clears after a while -- wait, then retry the same import.",
|
||||
);
|
||||
}
|
||||
throw new HttpError(400, "None of this model's files could be downloaded.");
|
||||
}
|
||||
|
||||
|
||||
@@ -5,12 +5,13 @@ import type { ProviderCollectionCard } from "./providerCollectionCard";
|
||||
import {
|
||||
extractJsonFromBrowserBody,
|
||||
fetchViaFlaresolverr,
|
||||
isCloudflareChallenge,
|
||||
looksLikeCloudflareBlock,
|
||||
shouldProxyHost,
|
||||
} from "./flaresolverr";
|
||||
import { getFlaresolverrUrl } from "./settingsService";
|
||||
|
||||
// The official API needs an app Access Token (not a session cookie). It's still behind Cloudflare
|
||||
// The official API needs an App Token (not a session cookie). It's still behind Cloudflare
|
||||
// and returns a sticky 429 challenge after a few rapid requests, hence ThingiverseRateLimitError
|
||||
// and the FlareSolverr fallback.
|
||||
const THINGIVERSE_API_BASE = "https://api.thingiverse.com";
|
||||
@@ -25,7 +26,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
export class ThingiverseAuthError extends Error {
|
||||
constructor() {
|
||||
super(
|
||||
"The Thingiverse Access Token configured for this instance was rejected. Ask an admin to " +
|
||||
"The Thingiverse App Token configured for this instance was rejected. Ask an admin to " +
|
||||
"update it in Admin Settings (a new one can be generated at thingiverse.com/apps/create).",
|
||||
);
|
||||
this.name = "ThingiverseAuthError";
|
||||
@@ -135,7 +136,7 @@ async function fetchThingiverseApi(
|
||||
}
|
||||
}
|
||||
// An unsolved challenge is a 403 too; it says nothing about the token.
|
||||
if (!viaBrowser && (res.status === 429 || res.headers.get("cf-mitigated") === "challenge")) {
|
||||
if (!viaBrowser && (res.status === 429 || isCloudflareChallenge(res.headers))) {
|
||||
throw new ThingiverseRateLimitError((await getFlaresolverrUrl()) ? "failed" : "missing");
|
||||
}
|
||||
if (res.status === 429) throw new ThingiverseRateLimitError();
|
||||
|
||||
@@ -253,7 +253,7 @@ describe("looking up authors no known author matches", () => {
|
||||
await prisma.user.update({ where: { id: userId }, data: { makerworldCookie: null } });
|
||||
});
|
||||
|
||||
it("skips Thingiverse without an access token, and says so", async () => {
|
||||
it("skips Thingiverse without an App Token, and says so", async () => {
|
||||
await setThingiverseAccessToken(null);
|
||||
const print = await unattributedPrint("lookup-tv", "thingiverse", `TvPerson${stamp}`);
|
||||
mockFetch({});
|
||||
|
||||
@@ -274,7 +274,7 @@ describe("runThingiverseLikesImportJob", () => {
|
||||
await setThingiverseAccessToken(null);
|
||||
});
|
||||
|
||||
it("fails clearly when no Access Token is configured, without touching the job's other bookkeeping", async () => {
|
||||
it("fails clearly when no App Token is configured, without touching the job's other bookkeeping", async () => {
|
||||
const job = await createJob(userId, "COLLECTION", {
|
||||
sourceUrl: "https://www.thingiverse.com/someuser/likes",
|
||||
provider: "thingiverse",
|
||||
@@ -458,7 +458,7 @@ describe("runThingiverseCollectionImportJob", () => {
|
||||
expect(finished?.sourceLabel).toBe("Thingiverse Collection 40781575");
|
||||
});
|
||||
|
||||
it("fails clearly when no Access Token is configured", async () => {
|
||||
it("fails clearly when no App Token is configured", async () => {
|
||||
const job = await createJob(userId, "COLLECTION", {
|
||||
sourceUrl: "https://www.thingiverse.com/someone/collections/1/things",
|
||||
provider: "thingiverse",
|
||||
|
||||
@@ -241,7 +241,7 @@ describe("Thingiverse likes in the Browse tab", () => {
|
||||
await setThingiverseAccessToken("rejected-token");
|
||||
const list = await request(app).get("/api/provider-collections").query({ provider: "thingiverse" }).set(auth());
|
||||
expect(list.status).toBe(400);
|
||||
expect(list.body.detail).toMatch(/Access Token .* was rejected/);
|
||||
expect(list.body.detail).toMatch(/App Token .* was rejected/);
|
||||
await setThingiverseAccessToken("test-token");
|
||||
});
|
||||
|
||||
|
||||
@@ -302,6 +302,37 @@ describe("importing from Printables", () => {
|
||||
filename: "hook.3mf",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a Cloudflare-challenged file download as a block, not a generic failure", async () => {
|
||||
const modelId = String((stamp + 1) % 1_000_000_000);
|
||||
mockFetch({
|
||||
"https://api.printables.com/graphql/": (init) => {
|
||||
const body = JSON.parse(String(init?.body ?? "{}")) as { query?: string };
|
||||
if (body.query?.includes("getDownloadLink")) {
|
||||
return json({
|
||||
data: {
|
||||
getDownloadLink: {
|
||||
ok: true,
|
||||
errors: null,
|
||||
output: { files: [{ id: "78", link: "https://files.printables.com/media/prints/blocked.3mf" }] },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
return json({
|
||||
data: { print: { id: modelId, name: "Blocked Hook", stls: [{ id: "78", name: "blocked.3mf" }] } },
|
||||
});
|
||||
},
|
||||
"https://files.printables.com/media/prints/blocked.3mf": cloudflareChallenge,
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/import")
|
||||
.set(auth())
|
||||
.send({ url: `https://www.printables.com/model/${modelId}-blocked-hook` });
|
||||
expect(res.status).toBe(429);
|
||||
expect(res.body.detail).toMatch(/blocked a file download/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("importing from MakerWorld", () => {
|
||||
|
||||
@@ -175,7 +175,7 @@ describe("POST /import/thingiverse-collection/entries", () => {
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("fails with a clear 503 when no Access Token is configured", async () => {
|
||||
it("fails with a clear 503 when no App Token is configured", async () => {
|
||||
const res = await request(app)
|
||||
.post("/api/import/thingiverse-collection/entries")
|
||||
.set("Authorization", `Bearer ${token}`)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import request from "supertest";
|
||||
import { createApp } from "../src/app";
|
||||
import { importPrintFromUrl } from "../src/services/importService";
|
||||
import { downloadPlainFileToTemp, importPrintFromUrl } from "../src/services/importService";
|
||||
import { setThingiverseAccessToken } from "../src/services/settingsService";
|
||||
import { prisma } from "../src/db";
|
||||
|
||||
@@ -65,9 +65,17 @@ function pdfResponse(): Response {
|
||||
return new Response("%PDF-1.4 fake", { status: 200, headers: { "content-type": "application/pdf" } });
|
||||
}
|
||||
|
||||
function mockThingiverseFetch(overrides?: { detailStatus?: number; detailBody?: unknown }) {
|
||||
function challengeResponse(): Response {
|
||||
return new Response("<html>Just a moment...</html>", {
|
||||
status: 403,
|
||||
headers: { "content-type": "text/html", "cf-mitigated": "challenge", server: "cloudflare" },
|
||||
});
|
||||
}
|
||||
|
||||
function mockThingiverseFetch(overrides?: { detailStatus?: number; detailBody?: unknown; cdnChallenge?: boolean }) {
|
||||
return vi.fn<(input: RequestInfo | URL) => Promise<Response>>(async (input) => {
|
||||
const url = String(input);
|
||||
if (overrides?.cdnChallenge && url.startsWith("https://cdn.thingiverse.com/assets/")) return challengeResponse();
|
||||
if (url.startsWith(`https://api.thingiverse.com/things/${THING_ID}/categories`)) {
|
||||
return jsonResponse(200, THING_CATEGORIES);
|
||||
}
|
||||
@@ -105,7 +113,7 @@ describe("importPrintFromUrl -- Thingiverse", () => {
|
||||
await setThingiverseAccessToken(null);
|
||||
});
|
||||
|
||||
it("fails clearly when no Access Token is configured", async () => {
|
||||
it("fails clearly when no App Token is configured", async () => {
|
||||
await expect(importPrintFromUrl(userId, THING_URL, { url: THING_URL, tags: [] })).rejects.toThrow(
|
||||
/isn't configured/i,
|
||||
);
|
||||
@@ -115,7 +123,30 @@ describe("importPrintFromUrl -- Thingiverse", () => {
|
||||
await setThingiverseAccessToken(ACCESS_TOKEN);
|
||||
global.fetch = mockThingiverseFetch({ detailStatus: 401, detailBody: { error: "invalid" } });
|
||||
|
||||
await expect(importPrintFromUrl(userId, THING_URL, { url: THING_URL, tags: [] })).rejects.toThrow(/access token/i);
|
||||
await expect(importPrintFromUrl(userId, THING_URL, { url: THING_URL, tags: [] })).rejects.toThrow(/app token/i);
|
||||
});
|
||||
|
||||
it("reports a Cloudflare-challenged file download as a block, not a generic failure", async () => {
|
||||
await setThingiverseAccessToken(ACCESS_TOKEN);
|
||||
global.fetch = mockThingiverseFetch({ cdnChallenge: true });
|
||||
|
||||
await expect(importPrintFromUrl(userId, THING_URL, { url: THING_URL, tags: [] })).rejects.toThrow(
|
||||
/blocked a file download/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("retries a challenged file download once", async () => {
|
||||
const url = "https://cdn.thingiverse.com/assets/test/body.stl";
|
||||
const fetchMock = vi
|
||||
.fn<(input: RequestInfo | URL) => Promise<Response>>()
|
||||
.mockResolvedValueOnce(challengeResponse())
|
||||
.mockResolvedValueOnce(stlResponse("body"));
|
||||
global.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const result = await downloadPlainFileToTemp(url, "body.stl");
|
||||
|
||||
expect(result && "input" in result ? result.input.filename : null).toBe("body.stl");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("imports a Thing's model files as plates, matches category, and attaches metadata + images", async () => {
|
||||
|
||||
@@ -114,7 +114,7 @@ describe("POST /import/thingiverse-likes/entries", () => {
|
||||
await setThingiverseAccessToken(null);
|
||||
});
|
||||
|
||||
it("fails with a clear 503 when no Access Token is configured", async () => {
|
||||
it("fails with a clear 503 when no App Token is configured", async () => {
|
||||
const res = await request(app)
|
||||
.post("/api/import/thingiverse-likes/entries")
|
||||
.set("Authorization", `Bearer ${token}`)
|
||||
|
||||
@@ -7,6 +7,7 @@ export default defineConfig({
|
||||
setupFiles: ["tests/setup.ts"],
|
||||
testTimeout: 20000,
|
||||
hookTimeout: 20000,
|
||||
env: { IMPORT_DOWNLOAD_RETRY_DELAY_MS: "0" },
|
||||
// Files share one database and mutate the same global rows, so parallel files race.
|
||||
fileParallelism: false,
|
||||
},
|
||||
|
||||
@@ -6,13 +6,13 @@ Connect them under **Configuration → Providers**: open the user menu (your ava
|
||||
|
||||

|
||||
|
||||
## Thingiverse Access Token
|
||||
## Thingiverse App Token
|
||||
|
||||
Instance-wide, admin-configured (Configuration > Providers > Thingiverse, or Administration > Providers > Thingiverse Access Token) -- one token is shared by every user's Thingiverse imports. Other users see Thingiverse as connected once an admin has set it up.
|
||||
Instance-wide, admin-configured (Configuration > Providers > Thingiverse, or Administration > Providers > Thingiverse App Token) -- one token is shared by every user's Thingiverse imports. Other users see Thingiverse as connected once an admin has set it up.
|
||||
|
||||
1. Sign in to your Thingiverse account, then go to [thingiverse.com/apps/create](https://www.thingiverse.com/apps/create).
|
||||
2. Register a new app. The name/description don't matter for this; pick whichever Application Type doesn't require a redirect URL (e.g. "Desktop" or "Mobile") -- a "Web App" asks for OAuth details you don't need here.
|
||||
3. Once created, Thingiverse shows an **Access Token** for it. This is different from your Thingiverse login password -- it's a permanent credential tied to that registered app.
|
||||
3. Once created, Thingiverse shows an **App Token** for it. This is different from your Thingiverse login password -- it's a permanent credential tied to that registered app.
|
||||
4. Copy it, right-click **Thingiverse** in Thingport's **Configuration > Providers**, choose **Connect** and paste it. Thingport verifies the token against Thingiverse before storing it, so a mistyped or revoked token is rejected immediately with a clear error instead of only failing on the next import.
|
||||
|
||||
Treat this token like a password: anyone who has it can use it to act as your Thingiverse app. If it's ever exposed, revoke it from your Thingiverse account's Apps page and generate a new one.
|
||||
@@ -43,4 +43,4 @@ Admins choose which providers this Thingport imports from, under **Administratio
|
||||
default. Turn one off and nothing new comes from it for anyone: links to it, Thingport Grab and the import queue are
|
||||
refused, its synced collections pause, and background jobs skip it. It also disappears from everyone's
|
||||
**Configuration → Providers**. Models already imported from it stay as they are, and turning it back on picks up where
|
||||
it left off. The Thingiverse Access Token is set on the same page.
|
||||
it left off. The Thingiverse App Token is set on the same page.
|
||||
@@ -6,7 +6,7 @@ first account on an instance is its admin, and there's no config file to edit af
|
||||
| Page | What it's for |
|
||||
| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Overview | [System Health](../guides/system-health.md) for everything Thingport relies on, disk space used, and the update check, which says when newer Thingport images are out. |
|
||||
| Providers | Turn MakerWorld, Printables or Thingiverse off for everyone, and set the Thingiverse Access Token. See [turning a provider off](../PROVIDER_SETUP.md#turning-a-provider-off). |
|
||||
| Providers | Turn MakerWorld, Printables or Thingiverse off for everyone, and set the Thingiverse App Token. See [turning a provider off](../PROVIDER_SETUP.md#turning-a-provider-off). |
|
||||
| Storage | The folder layout on disk, reorganizing existing files, browsing the storage folder, and the [consume folder](../guides/consume-folder.md). See [plain files on your disk](storage.md). |
|
||||
| Auth | The public URL, open or invite-only registration, [single sign-on](../guides/single-sign-on.md) providers, and how long a sign-in lasts. |
|
||||
| Rendering | How card thumbnails are made, and simplifying the previews of very detailed models. See [3D previews](3d-previews.md). |
|
||||
|
||||
@@ -36,7 +36,7 @@ from Thingport Grab wait in **Administration → Import queue** until they're st
|
||||
|
||||
## Before you start
|
||||
|
||||
Printables works with no setup. MakerWorld needs your session cookie, and Thingiverse an Access Token set by an admin.
|
||||
Printables works with no setup. MakerWorld needs your session cookie, and Thingiverse an App Token set by an admin.
|
||||
See [provider setup](../PROVIDER_SETUP.md). Admins can also [turn a provider off](../PROVIDER_SETUP.md#turning-a-provider-off)
|
||||
for the whole instance, and then nothing is imported from it.
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ Thingport imports that model into the matching Thingport collection on its own,
|
||||
extension, on the provider collection's page.
|
||||
- Connect the provider under **Configuration → Providers** where it needs it (see
|
||||
[provider setup](../PROVIDER_SETUP.md)). Printables needs nothing. MakerWorld needs your session cookie to import
|
||||
models, and to read a private collection. Thingiverse needs an access token.
|
||||
models, and to read a private collection. Thingiverse needs an App Token.
|
||||
|
||||
## Turn sync on in Thingport
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ for.
|
||||
| File storage | Required | Where model files, pictures and 3D previews are kept. Uploads, imports and downloads fail if it isn't writable or runs out of space. |
|
||||
| Memory | Required | Building 3D previews of large models takes the most. With too little, previews fail or the server restarts. |
|
||||
| FlareSolverr | Recommended | Gets past Cloudflare's checks, mostly on Thingiverse and Printables. Without it, imports and syncs from those sites are blocked whenever Cloudflare challenges the server. |
|
||||
| Thingiverse | Recommended | The instance's Thingiverse Access Token, used for every Thingiverse import, synced collection and author lookup. Not checked while Thingiverse is [turned off](../PROVIDER_SETUP.md#turning-a-provider-off). |
|
||||
| Thingiverse | Recommended | The instance's Thingiverse App Token, used for every Thingiverse import, synced collection and author lookup. Not checked while Thingiverse is [turned off](../PROVIDER_SETUP.md#turning-a-provider-off). |
|
||||
| Thingport Grab | Recommended | The [browser extension](../../extension/README.md) for importing while you browse MakerWorld, Printables and Thingiverse, whole collections included, and for bringing over Printables likes. Shown as seen once any browser uses it with this instance. |
|
||||
| AI provider | Optional | The default AI model behind [AI categorization and improved descriptions](ai.md). Everything else works without it. |
|
||||
| Email (SMTP) | Optional | Sends invitations, email confirmations and password resets. Without it, invitation links are shown to copy instead, and new accounts don't confirm their address. |
|
||||
@@ -53,7 +53,7 @@ pull the AI model on Ollama.
|
||||
- **FlareSolverr not answering**: check that its container is running and that the URL under **Administration →
|
||||
Connections → FlareSolverr** is right, port included (usually `http://flaresolverr:8191`).
|
||||
- **Thingiverse token rejected**: generate a new one at [thingiverse.com/apps/create](https://www.thingiverse.com/apps/create)
|
||||
and save it under **Administration → Providers** (see [provider setup](../PROVIDER_SETUP.md#thingiverse-access-token)).
|
||||
and save it under **Administration → Providers** (see [provider setup](../PROVIDER_SETUP.md#thingiverse-app-token)).
|
||||
- **AI provider not answering or model missing**: see [troubleshooting the AI features](ai.md#troubleshooting).
|
||||
- **Email not connecting**: check the host, port, security setting and login under **Administration → Connections →
|
||||
SMTP**.
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useMemo } from "react";
|
||||
import { keyframes } from "@emotion/react";
|
||||
import Box from "@mui/material/Box";
|
||||
|
||||
// Models dropping into one portal and out of another onto the shelf: Thingporting a link into the library.
|
||||
// Pure CSS; sized from `size`, so the same picture works on the progress bar and larger.
|
||||
const MODELS = 3;
|
||||
const CYCLE_S = 2.7;
|
||||
const COLORS = ["#22c55e", "#3b82f6", "#f59e0b"];
|
||||
const PORTAL_IN = "#38bdf8";
|
||||
const PORTAL_OUT = "#a78bfa";
|
||||
const SHELF = "#94a3b8";
|
||||
|
||||
function prefersReducedMotion(): boolean {
|
||||
return typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true;
|
||||
}
|
||||
|
||||
export default function ImportPortalAnimation({ size, paused }: { size: number; paused?: boolean }) {
|
||||
const still = paused || prefersReducedMotion();
|
||||
const portalW = size * 0.62;
|
||||
const portalH = size * 0.16;
|
||||
const portalX = (size - portalW) / 2;
|
||||
const inY = size * 0.24;
|
||||
const outY = size * 0.5;
|
||||
const shelfY = size * 0.88;
|
||||
const model = size * 0.2;
|
||||
const modelX = (size - model) / 2;
|
||||
|
||||
const { teleport, pulse } = useMemo(() => {
|
||||
const at = (y: number) => `translate(${modelX}px, ${y - model / 2}px)`;
|
||||
return {
|
||||
// Down into the top portal, gone for a beat, out of the bottom one, a bounce on the shelf.
|
||||
teleport: keyframes`
|
||||
0% { transform: ${at(size * 0.02)} scale(1); opacity: 0; }
|
||||
8% { opacity: 1; }
|
||||
30% { transform: ${at(inY)} scale(0.15, 0.1); opacity: 1; }
|
||||
31%, 44% { transform: ${at(inY)} scale(0.15, 0.1); opacity: 0; }
|
||||
45% { transform: ${at(outY)} scale(0.15, 0.1); opacity: 1; }
|
||||
66% { transform: ${at(shelfY - model / 2)} scale(1); }
|
||||
72% { transform: ${at(shelfY - model / 2 + model * 0.06)} scale(1.12, 0.88); }
|
||||
78% { transform: ${at(shelfY - model / 2)} scale(1); opacity: 1; }
|
||||
100% { transform: ${at(shelfY - model / 2)} scale(1); opacity: 0; }
|
||||
`,
|
||||
pulse: keyframes`
|
||||
0%, 100% { transform: scale(1); opacity: 0.85; }
|
||||
50% { transform: scale(1.12); opacity: 1; }
|
||||
`,
|
||||
};
|
||||
}, [modelX, model, inY, outY, shelfY, size]);
|
||||
|
||||
const portal = (top: number, color: string, delayS: number) => (
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
left: portalX,
|
||||
top: top - portalH / 2,
|
||||
width: portalW,
|
||||
height: portalH,
|
||||
borderRadius: "50%",
|
||||
border: `${Math.max(1.5, size / 22)}px solid ${color}`,
|
||||
background: `radial-gradient(ellipse at center, ${color}55 0%, ${color}11 60%, transparent 75%)`,
|
||||
boxShadow: `0 0 ${size / 8}px ${color}66`,
|
||||
animation: still ? "none" : `${pulse} ${CYCLE_S / MODELS}s ease-in-out ${delayS}s infinite`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const radius = Math.max(2, size / 18);
|
||||
|
||||
return (
|
||||
<Box aria-hidden sx={{ position: "relative", width: size, height: size, overflow: "hidden" }}>
|
||||
{portal(inY, PORTAL_IN, 0)}
|
||||
{portal(outY, PORTAL_OUT, (CYCLE_S / MODELS) * 0.5)}
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
left: size * 0.14,
|
||||
top: shelfY,
|
||||
width: size * 0.72,
|
||||
height: Math.max(1.5, size / 26),
|
||||
borderRadius: 1,
|
||||
bgcolor: SHELF,
|
||||
}}
|
||||
/>
|
||||
{Array.from({ length: MODELS }, (_model, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
sx={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: model,
|
||||
height: model,
|
||||
borderRadius: `${radius}px`,
|
||||
bgcolor: COLORS[index],
|
||||
// A lit top face, so it reads as a little printed block rather than a square.
|
||||
backgroundImage: "linear-gradient(rgba(255, 255, 255, 0.45), rgba(255, 255, 255, 0.45))",
|
||||
backgroundSize: `100% ${model * 0.3}px`,
|
||||
backgroundRepeat: "no-repeat",
|
||||
boxShadow: "0 0 0 1px rgba(15, 23, 42, 0.18)",
|
||||
transform: still ? `translate(${modelX}px, ${shelfY - model}px)` : undefined,
|
||||
opacity: still && index > 0 ? 0 : undefined,
|
||||
animation: still
|
||||
? "none"
|
||||
: `${teleport} ${CYCLE_S}s ease-in-out ${(index * CYCLE_S) / MODELS}s infinite both`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import Box from "@mui/material/Box";
|
||||
@@ -9,10 +9,9 @@ import Tooltip from "@mui/material/Tooltip";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import CategorizationAnimation from "../../pages/AiPage/CategorizationAnimation";
|
||||
import DescriptionAnimation from "../../pages/AiPage/DescriptionAnimation";
|
||||
import { useRotatingPhrase } from "../../hooks/useRotatingPhrase";
|
||||
import { useAiDescriptionRun, useAiRun } from "./AiRunContext";
|
||||
|
||||
// Long enough to read one, short enough that a run of a few minutes shows several.
|
||||
const PHRASE_MS = 3500;
|
||||
const MINI_TILE = 40;
|
||||
|
||||
/** Shown while an AI feature's library run goes; the outcome arrives as a notification once it's done. */
|
||||
@@ -24,22 +23,15 @@ export default function AiRunProgressBar({ kind }: { kind: "categorization" | "d
|
||||
const strings = kind === "categorization" ? "aiRunProgress" : "aiRunProgress.descriptions";
|
||||
const Animation = kind === "categorization" ? CategorizationAnimation : DescriptionAnimation;
|
||||
const [stopping, setStopping] = useState(false);
|
||||
const [phrase, setPhrase] = useState(0);
|
||||
|
||||
// A light touch on a long wait: the title changes every few seconds while the run goes.
|
||||
useEffect(() => {
|
||||
if (!running) return;
|
||||
const timer = window.setInterval(() => setPhrase((current) => current + 1), PHRASE_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [running]);
|
||||
const phrase = useRotatingPhrase(`${strings}.phrases`, running);
|
||||
|
||||
if (!run || !running) return null;
|
||||
const phrases = t(`${strings}.phrases`, { returnObjects: true }) as string[];
|
||||
const { done, total } = run;
|
||||
const title = run.cancelled
|
||||
? t("aiRunProgress.stopping")
|
||||
: total > 0
|
||||
? t(`${strings}.label`, { phrase: phrases[phrase % phrases.length], done, total })
|
||||
? t(`${strings}.label`, { phrase, done, total })
|
||||
: t(`${strings}.starting`);
|
||||
|
||||
return (
|
||||
|
||||
@@ -83,6 +83,7 @@ export const SEARCH_PAGES: SearchPage[] = [
|
||||
"makerworld",
|
||||
"printables",
|
||||
"thingiverse",
|
||||
"app token",
|
||||
"access token",
|
||||
]),
|
||||
adminPage("adminStorage", "/admin-storage", "app:adminSettings.storageSettings.heading", [
|
||||
|
||||
@@ -9,25 +9,30 @@ import IconButton from "@mui/material/IconButton";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import ReplayIcon from "@mui/icons-material/Replay";
|
||||
import ListIcon from "@mui/icons-material/List";
|
||||
import { useRotatingPhrase } from "../../hooks/useRotatingPhrase";
|
||||
import ImportPortalAnimation from "../ImportPortalAnimation";
|
||||
import { useImportJob } from "./ImportJobContext";
|
||||
import ImportJobItemsDialog from "./ImportJobItemsDialog";
|
||||
|
||||
const MINI_TILE = 40;
|
||||
|
||||
/** Shown while a batch import runs; clicking shows the per-link queue. A link queue that finished
|
||||
* with failures stays up, in warning colors, with a retry button until it's dismissed. */
|
||||
export default function ImportProgressBar() {
|
||||
const { t } = useTranslation("app");
|
||||
const { activeJob, retryJob, dismissJob } = useImportJob();
|
||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||
const running = activeJob?.status === "RUNNING";
|
||||
const phrase = useRotatingPhrase("importProgress.phrases", running);
|
||||
|
||||
if (!activeJob) return null;
|
||||
|
||||
const { total, processed, imported, already_in_library: alreadyInLibrary, failed_count: failedCount } = activeJob;
|
||||
const progressValue = total > 0 ? Math.min(100, (processed / total) * 100) : 0;
|
||||
const running = activeJob.status === "RUNNING";
|
||||
const hasFailures = !running && (failedCount > 0 || activeJob.status === "ERROR");
|
||||
const label = running
|
||||
? total > 0
|
||||
? t("importProgress.label", { processed, total })
|
||||
? t("importProgress.label", { phrase, processed, total })
|
||||
: t("importProgress.starting")
|
||||
: hasFailures
|
||||
? t("importProgress.doneWithFailures", { imported, alreadyInLibrary, failedCount })
|
||||
@@ -51,45 +56,63 @@ export default function ImportProgressBar() {
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<Stack spacing={0.5} sx={{ maxWidth: 480, mx: "auto" }}>
|
||||
<Stack direction="row" spacing={0.75} justifyContent="center" alignItems="center">
|
||||
<ListIcon fontSize="small" sx={{ opacity: 0.7 }} />
|
||||
<Typography variant="caption" textAlign="center">
|
||||
{label}
|
||||
</Typography>
|
||||
</Stack>
|
||||
{running ? (
|
||||
<LinearProgress
|
||||
variant={total > 0 ? "determinate" : "indeterminate"}
|
||||
value={progressValue}
|
||||
sx={{ borderRadius: 1, height: 6 }}
|
||||
/>
|
||||
) : (
|
||||
<Stack direction="row" spacing={1} justifyContent="center" alignItems="center">
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<ReplayIcon fontSize="small" />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void retryJob();
|
||||
}}
|
||||
sx={hasFailures ? { color: "warning.contrastText" } : undefined}
|
||||
>
|
||||
{t("importProgress.retryFailed")}
|
||||
</Button>
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label={t("importProgress.dismiss")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
dismissJob();
|
||||
}}
|
||||
sx={hasFailures ? { color: "warning.contrastText" } : undefined}
|
||||
>
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ maxWidth: 520, mx: "auto" }}>
|
||||
<Box
|
||||
aria-hidden
|
||||
sx={{
|
||||
flexShrink: 0,
|
||||
width: MINI_TILE,
|
||||
height: MINI_TILE,
|
||||
borderRadius: "11px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
bgcolor: "#ffffff",
|
||||
boxShadow: "inset 0 0 0 1px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.04)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<ImportPortalAnimation size={MINI_TILE - 4} paused={!running} />
|
||||
</Box>
|
||||
<Stack spacing={0.5} sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Stack direction="row" spacing={0.75} alignItems="center" sx={{ minWidth: 0 }}>
|
||||
<Typography variant="caption" noWrap={running} sx={{ display: "block", minWidth: 0 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
{running ? (
|
||||
<LinearProgress
|
||||
variant={total > 0 ? "determinate" : "indeterminate"}
|
||||
value={progressValue}
|
||||
sx={{ borderRadius: 1, height: 6 }}
|
||||
/>
|
||||
) : (
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<ReplayIcon fontSize="small" />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void retryJob();
|
||||
}}
|
||||
sx={hasFailures ? { color: "warning.contrastText" } : undefined}
|
||||
>
|
||||
{t("importProgress.retryFailed")}
|
||||
</Button>
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label={t("importProgress.dismiss")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
dismissJob();
|
||||
}}
|
||||
sx={hasFailures ? { color: "warning.contrastText" } : undefined}
|
||||
>
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
||||
@@ -3,6 +3,7 @@ import Box from "@mui/material/Box";
|
||||
import LinearProgress from "@mui/material/LinearProgress";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useRotatingPhrase } from "../../hooks/useRotatingPhrase";
|
||||
import FileMoveAnimation from "../FileMoveAnimation";
|
||||
import { useStorageMove } from "./StorageMoveContext";
|
||||
|
||||
@@ -13,6 +14,7 @@ const MINI_TILE = 40;
|
||||
export default function StorageMoveProgressBar() {
|
||||
const { t } = useTranslation("app");
|
||||
const { move, running } = useStorageMove();
|
||||
const phrase = useRotatingPhrase("storageMoveProgress.phrases", running);
|
||||
if (!move || !running) return null;
|
||||
const { done, total } = move;
|
||||
|
||||
@@ -38,7 +40,7 @@ export default function StorageMoveProgressBar() {
|
||||
<Stack spacing={0.5} sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography variant="caption" noWrap sx={{ display: "block" }}>
|
||||
{total > 0
|
||||
? t("storageMoveProgress.label", { done, total, moved: move.moved })
|
||||
? t("storageMoveProgress.label", { phrase, done, total, moved: move.moved })
|
||||
: t("storageMoveProgress.starting")}
|
||||
</Typography>
|
||||
<LinearProgress
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
// Long enough to read one, short enough that a run of a few minutes shows several.
|
||||
const PHRASE_MS = 3500;
|
||||
|
||||
/** One of the `app` namespace's `key` phrases, moving to the next every few seconds while `running`. */
|
||||
export function useRotatingPhrase(key: string, running: boolean): string {
|
||||
const { t } = useTranslation("app");
|
||||
const [index, setIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!running) return;
|
||||
const timer = window.setInterval(() => setIndex((current) => current + 1), PHRASE_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [running]);
|
||||
|
||||
const phrases = t(key, { returnObjects: true }) as string[];
|
||||
return Array.isArray(phrases) && phrases.length ? phrases[index % phrases.length] : "";
|
||||
}
|
||||
@@ -380,7 +380,19 @@
|
||||
},
|
||||
"importProgress": {
|
||||
"starting": "Starting import…",
|
||||
"label": "Importing… {{processed}} of {{total}}",
|
||||
"label": "{{phrase}} {{processed}} of {{total}}",
|
||||
"phrases": [
|
||||
"Thingporting models…",
|
||||
"Opening a portal to the model…",
|
||||
"Beaming files into your library…",
|
||||
"Unpacking the 3MF…",
|
||||
"Counting the plates…",
|
||||
"Checking for a Benchy…",
|
||||
"Rescuing models from the internet…",
|
||||
"Teleporting filament-free…",
|
||||
"Grabbing the STLs before they wander off…",
|
||||
"Dusting off the thumbnails…"
|
||||
],
|
||||
"tooltip": "{{imported}} imported, {{alreadyInLibrary}} already in your library, {{failedCount}} failed. Click to see the queue.",
|
||||
"done": "Imported {{imported}}, {{alreadyInLibrary}} already in your library. Click to see the queue.",
|
||||
"doneWithFailures": "Imported {{imported}}, {{alreadyInLibrary}} already in your library, {{failedCount}} failed. Click to see which and why.",
|
||||
@@ -517,11 +529,11 @@
|
||||
"descriptions": {
|
||||
"makerworld": "Bambu Lab's model site: imports with print profiles, and collections to sync.",
|
||||
"printables": "Prusa's model site: imports, and public and private collections to sync.",
|
||||
"thingiverse": "Imports, collections and liked models, through this Thingport's Access Token."
|
||||
"thingiverse": "Imports, collections and liked models, through this Thingport's App Token."
|
||||
},
|
||||
"tokenSet": "Access Token set",
|
||||
"tokenMissing": "No Access Token: Thingiverse imports won't work until one is set",
|
||||
"tokenSettings": "Thingiverse Access Token",
|
||||
"tokenSet": "App Token set",
|
||||
"tokenMissing": "No App Token: Thingiverse imports won't work until one is set",
|
||||
"tokenSettings": "Thingiverse App Token",
|
||||
"turnOff": "Turn {{name}} off",
|
||||
"turnOn": "Turn {{name}} on",
|
||||
"disableTitle": "Turn {{name}} off?",
|
||||
@@ -624,11 +636,11 @@
|
||||
},
|
||||
"thingiverse": {
|
||||
"name": "Thingiverse",
|
||||
"description": "The instance's Thingiverse Access Token, used to import models, sync collections and liked models, and link authors from Thingiverse.",
|
||||
"description": "The instance's Thingiverse App Token, used to import models, sync collections and liked models, and link authors from Thingiverse.",
|
||||
"problems": {
|
||||
"disabled": "Turned off under Providers, so nothing is checked. Turn Thingiverse back on there to use it.",
|
||||
"not_configured": "No Access Token set. Thingiverse imports and syncs won't work until one is added in Settings (create one at thingiverse.com/apps/create).",
|
||||
"token_rejected": "Thingiverse rejected the Access Token. Generate a new one at thingiverse.com/apps/create and save it in Settings.",
|
||||
"not_configured": "No App Token set. Thingiverse imports and syncs won't work until one is added in Settings (create one at thingiverse.com/apps/create).",
|
||||
"token_rejected": "Thingiverse rejected the App Token. Generate a new one at thingiverse.com/apps/create and save it in Settings.",
|
||||
"blocked": "Thingiverse answered with a Cloudflare challenge, which says nothing about the token. Set up FlareSolverr, or wait: it usually clears after a while.",
|
||||
"unreachable": "Thingport couldn't reach Thingiverse. Check the server's internet access, or try again later.",
|
||||
"timeout": "Thingiverse didn't answer in time. Try again later."
|
||||
@@ -824,8 +836,8 @@
|
||||
"linkAuthorsDone": "Linked {{linked}} model(s) to their author.",
|
||||
"linkAuthorsNotFound": "The author couldn't be found for {{count}} creator(s) — the model may have been removed from its site, or the site couldn't be reached.",
|
||||
"linkAuthorsProblems": {
|
||||
"thingiverse_no_token": "Thingiverse models were skipped: add a Thingiverse access token in Administration > Settings.",
|
||||
"thingiverse_token_rejected": "Thingiverse models were skipped: Thingiverse rejected the access token in Administration > Settings.",
|
||||
"thingiverse_no_token": "Thingiverse models were skipped: add a Thingiverse App Token in Administration > Settings.",
|
||||
"thingiverse_token_rejected": "Thingiverse models were skipped: Thingiverse rejected the App Token in Administration > Settings.",
|
||||
"thingiverse_rate_limited": "Thingiverse models were skipped: Thingiverse started blocking requests with a rate limit. It usually clears after a while — try again later.",
|
||||
"makerworld_captcha": "MakerWorld models were skipped: MakerWorld asked for a CAPTCHA, and blocks further requests for about 2 hours. Try again later.",
|
||||
"makerworld_login_rejected": "MakerWorld models were skipped: MakerWorld rejected the saved MakerWorld login. Update it in Settings > Imports."
|
||||
@@ -842,18 +854,18 @@
|
||||
"refetchFailedCount": "{{count}} couldn't be read from their source and were left as they were."
|
||||
},
|
||||
"thingiverse": {
|
||||
"heading": "Thingiverse Access Token",
|
||||
"helpText": "Create an app at thingiverse.com/apps/create (any type, e.g. \"Desktop\") and paste the Access Token it shows you.",
|
||||
"statusConfigured": "An Access Token is currently configured.",
|
||||
"statusNotConfigured": "No Access Token is configured yet -- Thingiverse imports will fail until one is set.",
|
||||
"tokenLabel": "Access Token",
|
||||
"heading": "Thingiverse App Token",
|
||||
"helpText": "Create an app at thingiverse.com/apps/create (any type, e.g. \"Desktop\") and paste the App Token it shows you.",
|
||||
"statusConfigured": "An App Token is currently configured.",
|
||||
"statusNotConfigured": "No App Token is configured yet -- Thingiverse imports will fail until one is set.",
|
||||
"tokenLabel": "App Token",
|
||||
"tokenPlaceholder": "Paste a new token to replace the current one",
|
||||
"tokenHelperConfigured": "For security, the current token is never shown again -- leave this blank to keep it, or paste a new one to replace it.",
|
||||
"save": "Save",
|
||||
"saving": "Saving…",
|
||||
"clear": "Remove token",
|
||||
"saved": "Saved.",
|
||||
"cleared": "Access Token removed.",
|
||||
"cleared": "App Token removed.",
|
||||
"failed": "Failed to save Thingiverse settings."
|
||||
},
|
||||
"ai": {
|
||||
@@ -1528,11 +1540,11 @@
|
||||
]
|
||||
},
|
||||
"thingiverse": {
|
||||
"placeholder": "Paste the App Access Token",
|
||||
"placeholder": "Paste the App Token",
|
||||
"steps": [
|
||||
"Sign in at thingiverse.com and open thingiverse.com/apps/create.",
|
||||
"Create an app of any type, such as \"Desktop app\".",
|
||||
"Copy the App Access Token it shows and paste it below."
|
||||
"Copy the App Token it shows and paste it below."
|
||||
],
|
||||
"shared": "This token is shared by everyone on this Thingport.",
|
||||
"adminOnly": "Only an admin of this Thingport can set this up."
|
||||
@@ -1549,7 +1561,19 @@
|
||||
"activate": "Activate"
|
||||
},
|
||||
"storageMoveProgress": {
|
||||
"label": "Moving model files · {{done}} of {{total}} models",
|
||||
"label": "{{phrase}} {{done}} of {{total}} models",
|
||||
"phrases": [
|
||||
"Moving model files…",
|
||||
"Rearranging the shelves…",
|
||||
"Relabelling the folders…",
|
||||
"Carrying STLs down the hall…",
|
||||
"Packing 3MFs into new boxes…",
|
||||
"Finding every file its new desk…",
|
||||
"Tidying the filing cabinet…",
|
||||
"Teaching files their new address…",
|
||||
"Sweeping up the old folders…",
|
||||
"Making sure nothing fell behind the drawer…"
|
||||
],
|
||||
"starting": "Getting ready to move model files…"
|
||||
},
|
||||
"grab": {
|
||||
|
||||
@@ -380,7 +380,19 @@
|
||||
},
|
||||
"importProgress": {
|
||||
"starting": "Pradedamas importas…",
|
||||
"label": "Importuojama… {{processed}} iš {{total}}",
|
||||
"label": "{{phrase}} {{processed}} iš {{total}}",
|
||||
"phrases": [
|
||||
"Thingportuojami modeliai…",
|
||||
"Atveriami vartai į modelį…",
|
||||
"Failai perkeliami į jūsų biblioteką…",
|
||||
"Išpakuojamas 3MF…",
|
||||
"Skaičiuojamos plokštės…",
|
||||
"Ieškoma Benchy…",
|
||||
"Modeliai gelbstimi iš interneto…",
|
||||
"Teleportuojama be gijos…",
|
||||
"Gaudomi STL failai, kol nepabėgo…",
|
||||
"Nuvalomos miniatiūros…"
|
||||
],
|
||||
"tooltip": "{{imported}} importuota, {{alreadyInLibrary}} jau bibliotekoje, {{failedCount}} nepavyko. Spustelėkite, kad pamatytumėte eilę.",
|
||||
"done": "Importuota {{imported}}, {{alreadyInLibrary}} jau bibliotekoje. Spustelėkite, kad pamatytumėte eilę.",
|
||||
"doneWithFailures": "Importuota {{imported}}, {{alreadyInLibrary}} jau bibliotekoje, {{failedCount}} nepavyko. Spustelėkite, kad pamatytumėte, kurios ir kodėl.",
|
||||
@@ -517,11 +529,11 @@
|
||||
"descriptions": {
|
||||
"makerworld": "Bambu Lab modelių svetainė: importai su spausdinimo profiliais ir kolekcijų sinchronizavimas.",
|
||||
"printables": "Prusa modelių svetainė: importai bei viešų ir privačių kolekcijų sinchronizavimas.",
|
||||
"thingiverse": "Importai, kolekcijos ir patikę modeliai, naudojant šio Thingport prieigos raktą."
|
||||
"thingiverse": "Importai, kolekcijos ir patikę modeliai, naudojant šio Thingport programėlės raktą."
|
||||
},
|
||||
"tokenSet": "Prieigos raktas nustatytas",
|
||||
"tokenMissing": "Nėra prieigos rakto: Thingiverse importai neveiks, kol jo nenustatysite",
|
||||
"tokenSettings": "Thingiverse prieigos raktas",
|
||||
"tokenSet": "Programėlės raktas nustatytas",
|
||||
"tokenMissing": "Nėra programėlės rakto: Thingiverse importai neveiks, kol jo nenustatysite",
|
||||
"tokenSettings": "Thingiverse programėlės raktas",
|
||||
"turnOff": "Išjungti {{name}}",
|
||||
"turnOn": "Įjungti {{name}}",
|
||||
"disableTitle": "Išjungti {{name}}?",
|
||||
@@ -625,11 +637,11 @@
|
||||
},
|
||||
"thingiverse": {
|
||||
"name": "Thingiverse",
|
||||
"description": "Sistemos Thingiverse prieigos raktas – modeliams importuoti, kolekcijoms ir patiktiems modeliams sinchronizuoti bei autoriams susieti.",
|
||||
"description": "Sistemos Thingiverse programėlės raktas – modeliams importuoti, kolekcijoms ir patiktiems modeliams sinchronizuoti bei autoriams susieti.",
|
||||
"problems": {
|
||||
"disabled": "Išjungta skiltyje „Tiekėjai“, todėl netikrinama. Įjunkite Thingiverse ten, kad galėtumėte naudoti.",
|
||||
"not_configured": "Prieigos raktas nenurodytas. Thingiverse importai ir sinchronizacijos neveiks, kol jo nepridėsite nustatymuose (sukurkite thingiverse.com/apps/create).",
|
||||
"token_rejected": "Thingiverse atmetė prieigos raktą. Sukurkite naują thingiverse.com/apps/create ir išsaugokite nustatymuose.",
|
||||
"not_configured": "Programėlės raktas nenurodytas. Thingiverse importai ir sinchronizacijos neveiks, kol jo nepridėsite nustatymuose (sukurkite thingiverse.com/apps/create).",
|
||||
"token_rejected": "Thingiverse atmetė programėlės raktą. Sukurkite naują thingiverse.com/apps/create ir išsaugokite nustatymuose.",
|
||||
"blocked": "Thingiverse atsakė Cloudflare patikra – tai nieko nesako apie raktą. Įdiekite FlareSolverr arba palaukite: dažniausiai praeina po kurio laiko.",
|
||||
"unreachable": "Thingport nepasiekė Thingiverse. Patikrinkite serverio interneto ryšį arba bandykite vėliau.",
|
||||
"timeout": "Thingiverse laiku neatsakė. Bandykite vėliau."
|
||||
@@ -832,8 +844,8 @@
|
||||
"linkAuthorsDone": "Su autoriumi susieta modelių: {{linked}}.",
|
||||
"linkAuthorsNotFound": "Nepavyko rasti autoriaus kūrėjams: {{count}} – modelis galėjo būti pašalintas iš svetainės arba svetainė nepasiekiama.",
|
||||
"linkAuthorsProblems": {
|
||||
"thingiverse_no_token": "Thingiverse modeliai praleisti: pridėkite Thingiverse prieigos raktą skiltyje Administravimas > Nustatymai.",
|
||||
"thingiverse_token_rejected": "Thingiverse modeliai praleisti: Thingiverse atmetė prieigos raktą, nurodytą skiltyje Administravimas > Nustatymai.",
|
||||
"thingiverse_no_token": "Thingiverse modeliai praleisti: pridėkite Thingiverse programėlės raktą skiltyje Administravimas > Nustatymai.",
|
||||
"thingiverse_token_rejected": "Thingiverse modeliai praleisti: Thingiverse atmetė programėlės raktą, nurodytą skiltyje Administravimas > Nustatymai.",
|
||||
"thingiverse_rate_limited": "Thingiverse modeliai praleisti: Thingiverse pradėjo riboti užklausas. Paprastai tai praeina po kurio laiko — bandykite vėliau.",
|
||||
"makerworld_captcha": "MakerWorld modeliai praleisti: MakerWorld paprašė CAPTCHA ir maždaug 2 valandoms blokuoja tolesnes užklausas. Bandykite vėliau.",
|
||||
"makerworld_login_rejected": "MakerWorld modeliai praleisti: MakerWorld atmetė išsaugotą MakerWorld prisijungimą. Atnaujinkite jį skiltyje Nustatymai > Importavimas."
|
||||
@@ -850,18 +862,18 @@
|
||||
"refetchFailedCount": "Nepavyko nuskaityti iš šaltinio: {{count}}; jie palikti nepakeisti."
|
||||
},
|
||||
"thingiverse": {
|
||||
"heading": "Thingiverse prieigos raktas",
|
||||
"helpText": "Sukurkite programėlę adresu thingiverse.com/apps/create (bet kokio tipo, pvz. „Desktop“) ir įklijuokite ten parodytą prieigos raktą. Tai skiriasi nuo jūsų Thingiverse prisijungimo -- tai nuolatinis raktas, susietas su registruota programėle.",
|
||||
"statusConfigured": "Šiuo metu sukonfigūruotas prieigos raktas.",
|
||||
"statusNotConfigured": "Prieigos raktas dar nesukonfigūruotas -- Thingiverse importas neveiks, kol jis nebus nustatytas.",
|
||||
"tokenLabel": "Prieigos raktas",
|
||||
"heading": "Thingiverse programėlės raktas (App Token)",
|
||||
"helpText": "Sukurkite programėlę adresu thingiverse.com/apps/create (bet kokio tipo, pvz. „Desktop“) ir įklijuokite ten parodytą programėlės raktą (App Token). Tai skiriasi nuo jūsų Thingiverse prisijungimo -- tai nuolatinis raktas, susietas su registruota programėle.",
|
||||
"statusConfigured": "Šiuo metu sukonfigūruotas programėlės raktas.",
|
||||
"statusNotConfigured": "Programėlės raktas dar nesukonfigūruotas -- Thingiverse importas neveiks, kol jis nebus nustatytas.",
|
||||
"tokenLabel": "Programėlės raktas",
|
||||
"tokenPlaceholder": "Įklijuokite naują raktą, kad pakeistumėte dabartinį",
|
||||
"tokenHelperConfigured": "Saugumo sumetimais dabartinis raktas daugiau nerodomas -- palikite tuščią, kad jį išsaugotumėte, arba įklijuokite naują, kad jį pakeistumėte.",
|
||||
"save": "Išsaugoti",
|
||||
"saving": "Saugoma…",
|
||||
"clear": "Pašalinti raktą",
|
||||
"saved": "Išsaugota.",
|
||||
"cleared": "Prieigos raktas pašalintas.",
|
||||
"cleared": "Programėlės raktas pašalintas.",
|
||||
"failed": "Nepavyko išsaugoti Thingiverse nustatymų."
|
||||
},
|
||||
"ai": {
|
||||
@@ -1583,11 +1595,11 @@
|
||||
]
|
||||
},
|
||||
"thingiverse": {
|
||||
"placeholder": "Įklijuokite App Access Token",
|
||||
"placeholder": "Įklijuokite App Token",
|
||||
"steps": [
|
||||
"Prisijunkite prie thingiverse.com ir atverkite thingiverse.com/apps/create.",
|
||||
"Sukurkite bet kokio tipo programą, pvz. „Desktop app“.",
|
||||
"Nukopijuokite rodomą App Access Token ir įklijuokite jį žemiau."
|
||||
"Nukopijuokite rodomą App Token ir įklijuokite jį žemiau."
|
||||
],
|
||||
"shared": "Šis raktas bendras visiems šio Thingport naudotojams.",
|
||||
"adminOnly": "Tai nustatyti gali tik šio Thingport administratorius."
|
||||
@@ -1604,7 +1616,19 @@
|
||||
"activate": "Aktyvuoti"
|
||||
},
|
||||
"storageMoveProgress": {
|
||||
"label": "Perkeliami modelių failai · {{done}} iš {{total}} modelių",
|
||||
"label": "{{phrase}} {{done}} iš {{total}} modelių",
|
||||
"phrases": [
|
||||
"Perkeliami modelių failai…",
|
||||
"Pertvarkomos lentynos…",
|
||||
"Perženklinami aplankai…",
|
||||
"STL failai nešami koridoriumi…",
|
||||
"3MF failai pakuojami į naujas dėžes…",
|
||||
"Kiekvienam failui ieškomas naujas stalas…",
|
||||
"Tvarkoma failų spinta…",
|
||||
"Failai mokomi naujo adreso…",
|
||||
"Iššluojami seni aplankai…",
|
||||
"Tikrinama, ar niekas neužkrito už stalčiaus…"
|
||||
],
|
||||
"starting": "Ruošiamasi perkelti modelių failus…"
|
||||
},
|
||||
"grab": {
|
||||
|
||||
@@ -46,7 +46,7 @@ const SECTIONS: FeatureSection[] = [
|
||||
"A provider that's off is off for everyone: links to it, Thingport Grab and the import queue are refused, and it's hidden from each person's provider settings.",
|
||||
"Its synced collections pause and background jobs skip it. Models already imported from it stay as they are.",
|
||||
"Turn it back on whenever you like, and syncs pick up where they left off.",
|
||||
"Set the Thingiverse Access Token that every user's Thingiverse imports share.",
|
||||
"Set the Thingiverse App Token that every user's Thingiverse imports share.",
|
||||
],
|
||||
link: { label: "Provider setup", href: url("docs/providers/#turning-a-provider-off") },
|
||||
image: providers,
|
||||
|
||||
Reference in new issue
Block a user