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:
Tautvydas Derzinskas authored and GitHub committed 2026-10-17 21:16:04 +02:00
commit ac5547dcab
31 files changed
+425 -134

No files matched your search

+1 -1
View File
@@ -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
+2
View File
@@ -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);
+2 -2
View File
@@ -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.",
);
}
+1 -1
View File
@@ -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);
+2 -2
View File
@@ -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 {
+7 -2
View File
@@ -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> {
+2 -2
View File
@@ -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, {
+33 -9
View File
@@ -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.");
}
+4 -3
View File
@@ -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();
+1 -1
View File
@@ -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({});
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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");
});
+31
View File
@@ -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", () => {
+1 -1
View File
@@ -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}`)
+35 -4
View File
@@ -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 () => {
+1 -1
View File
@@ -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}`)
+1
View File
@@ -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,
},
+4 -4
View File
@@ -6,13 +6,13 @@ Connect them under **Configuration → Providers**: open the user menu (your ava
![Configuration, Providers section with MakerWorld, Thingiverse and Printables connected](../frontend/src/assets/screenshots/11_configuration_providers.png)
## 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.
+1 -1
View File
@@ -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). |
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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
+20
View File
@@ -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] : "";
}
+43 -19
View File
@@ -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": {
+43 -19
View File
@@ -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": {
+1 -1
View File
@@ -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,