No files matched your search
@@ -1,6 +1,8 @@
|
||||
name: Backend image
|
||||
|
||||
# Runs when backend/ or the build workflow changes. Use "Manual image publish" to force a build.
|
||||
# Pull requests into develop only run the checks; the image build (~6 min, arm64 under QEMU) waits for the
|
||||
# develop -> main pull request, which still builds it before anything is released.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
@@ -25,6 +27,8 @@ jobs:
|
||||
# The full backend suite against real Postgres, including end-to-end provider imports. The image
|
||||
# is only built and published if this passes.
|
||||
test:
|
||||
# A main -> develop back-merge only brings release commits main already tested.
|
||||
if: github.head_ref != 'main'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -57,6 +61,9 @@ jobs:
|
||||
cache-dependency-path: backend/package-lock.json
|
||||
- run: npm ci --no-audit --no-fund
|
||||
- run: npx prisma generate
|
||||
- run: npm run lint
|
||||
# tsc: the typecheck the image build would otherwise be the only one to run.
|
||||
- run: npm run build
|
||||
- run: npx prisma migrate deploy
|
||||
- run: npm test
|
||||
|
||||
@@ -75,7 +82,9 @@ jobs:
|
||||
build:
|
||||
needs: [test, release]
|
||||
# Also when the release was skipped, as on pull requests; never after a failed one.
|
||||
if: ${{ !cancelled() && needs.test.result == 'success' && needs.release.result != 'failure' }}
|
||||
if: >-
|
||||
${{ !cancelled() && needs.test.result == 'success' && needs.release.result != 'failure'
|
||||
&& !(github.event_name == 'pull_request' && github.base_ref == 'develop') }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
@@ -21,6 +21,8 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
build-windows:
|
||||
# A main -> develop back-merge only brings release commits main already tested.
|
||||
if: github.head_ref != 'main'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -44,6 +46,8 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
build-linux:
|
||||
# A main -> develop back-merge only brings release commits main already tested.
|
||||
if: github.head_ref != 'main'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -67,6 +71,8 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
build-macos:
|
||||
# A main -> develop back-merge only brings release commits main already tested.
|
||||
if: github.head_ref != 'main'
|
||||
# macOS URL-scheme handling needs cgo and Cocoa, so it can't be cross-compiled.
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
|
||||
@@ -23,6 +23,8 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
build-chrome:
|
||||
# A main -> develop back-merge only brings release commits main already tested.
|
||||
if: github.head_ref != 'main'
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
@@ -53,6 +55,8 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
build-firefox:
|
||||
# A main -> develop back-merge only brings release commits main already tested.
|
||||
if: github.head_ref != 'main'
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
|
||||
@@ -11,6 +11,8 @@ permissions:
|
||||
|
||||
jobs:
|
||||
check:
|
||||
# A main -> develop back-merge only brings release commits main already tested.
|
||||
if: github.head_ref != 'main'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
name: Frontend image
|
||||
|
||||
# Runs when frontend/ or the build workflow changes. Use "Manual image publish" to force a build.
|
||||
# Pull requests into develop only run the checks; the image build waits for the develop -> main pull request,
|
||||
# which still builds it before anything is released.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
@@ -22,8 +24,30 @@ concurrency:
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
# Typecheck, lint, tests and a production build, natively. The image build skips the Dockerfile's test
|
||||
# stage, so this is the only place the frontend tests run.
|
||||
verify:
|
||||
# A main -> develop back-merge only brings release commits main already tested.
|
||||
if: github.head_ref != 'main'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
working-directory: frontend
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- run: npm ci --no-audit --no-fund
|
||||
- run: npm run verify
|
||||
|
||||
# A merge into main may give the frontend a new version (release-project.yml); the image is built from it.
|
||||
release:
|
||||
needs: verify
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -34,9 +58,11 @@ jobs:
|
||||
secrets: inherit
|
||||
|
||||
build:
|
||||
needs: release
|
||||
needs: [verify, release]
|
||||
# Also when the release was skipped, as on pull requests; never after a failed one.
|
||||
if: ${{ !cancelled() && needs.release.result != 'failure' }}
|
||||
if: >-
|
||||
${{ !cancelled() && needs.verify.result == 'success' && needs.release.result != 'failure'
|
||||
&& !(github.event_name == 'pull_request' && github.base_ref == 'develop') }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
@@ -13,6 +13,11 @@ on:
|
||||
- frontend
|
||||
- both
|
||||
default: both
|
||||
version:
|
||||
description: "Optional: an existing release (e.g. 1.0.1) to build from its git tag and tag X.Y.Z, X.Y and X"
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
|
||||
jobs:
|
||||
backend:
|
||||
@@ -22,6 +27,8 @@ jobs:
|
||||
packages: write
|
||||
uses: ./.github/workflows/build-image.yml
|
||||
with:
|
||||
ref: ${{ inputs.version && format('thingport-backend-v{0}', inputs.version) || '' }}
|
||||
version: ${{ inputs.version }}
|
||||
project_dir: backend
|
||||
image_name: ghcr.io/${{ github.repository_owner }}/thingport-backend
|
||||
context: ./backend
|
||||
@@ -37,6 +44,8 @@ jobs:
|
||||
packages: write
|
||||
uses: ./.github/workflows/build-image.yml
|
||||
with:
|
||||
ref: ${{ inputs.version && format('thingport-frontend-v{0}', inputs.version) || '' }}
|
||||
version: ${{ inputs.version }}
|
||||
project_dir: frontend
|
||||
image_name: ghcr.io/${{ github.repository_owner }}/thingport-frontend
|
||||
context: ./frontend
|
||||
|
||||
@@ -74,7 +74,9 @@ jobs:
|
||||
break
|
||||
fi
|
||||
echo "main moved during the release; trying again."
|
||||
elif [ "$attempt" = 3 ]; then
|
||||
# Only a push that lost the race to the other project is worth retrying. Anything else may have
|
||||
# failed after tagging, and a retry would then find nothing to release and pass without a version.
|
||||
elif [ "$attempt" = 3 ] || [ "$(git ls-remote origin refs/heads/main | cut -f1)" = "$started" ]; then
|
||||
exit 1
|
||||
fi
|
||||
sleep $((attempt * 10))
|
||||
|
||||
@@ -52,6 +52,8 @@ env:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
# A main -> develop back-merge only brings release commits main already tested.
|
||||
if: github.head_ref != 'main'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
[
|
||||
"@semantic-release/exec",
|
||||
{
|
||||
"publishCmd": "cat > \"${RUNNER_TEMP:-/tmp}/release-notes.md\" <<'SEMANTIC_RELEASE_NOTES'\n${nextRelease.notes}\nSEMANTIC_RELEASE_NOTES\nif [ -n \"$GITHUB_OUTPUT\" ]; then echo \"version=${nextRelease.version}\" >> \"$GITHUB_OUTPUT\"; echo \"tag=${nextRelease.gitTag}\" >> \"$GITHUB_OUTPUT\"; fi"
|
||||
"publishCmd": "cat > \"$RUNNER_TEMP/release-notes.md\" <<'SEMANTIC_RELEASE_NOTES'\n${nextRelease.notes}\nSEMANTIC_RELEASE_NOTES\nif [ -n \"$GITHUB_OUTPUT\" ]; then echo \"version=${nextRelease.version}\" >> \"$GITHUB_OUTPUT\"; echo \"tag=${nextRelease.gitTag}\" >> \"$GITHUB_OUTPUT\"; fi"
|
||||
}
|
||||
],
|
||||
[
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"cors": "^2.8.6",
|
||||
"dotenv": "^18.0.8",
|
||||
"express": "^4.22.3",
|
||||
"impit": "0.14.6",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"jszip": "^3.10.2",
|
||||
"meshoptimizer": "^1.3.0",
|
||||
@@ -3148,6 +3149,153 @@
|
||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/impit": {
|
||||
"version": "0.14.6",
|
||||
"resolved": "https://registry.npmjs.org/impit/-/impit-0.14.6.tgz",
|
||||
"integrity": "sha512-1i5DxW0eRIUCtX9x+ZCSzD+FlRiEdrDOXazg0DYNMKUgnVwRWEwRhbA23Lpo2WOC674wZpwTQUubBi5z7m04Ng==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"impit-darwin-arm64": "0.14.6",
|
||||
"impit-darwin-x64": "0.14.6",
|
||||
"impit-linux-arm64-gnu": "0.14.6",
|
||||
"impit-linux-arm64-musl": "0.14.6",
|
||||
"impit-linux-x64-gnu": "0.14.6",
|
||||
"impit-linux-x64-musl": "0.14.6",
|
||||
"impit-win32-arm64-msvc": "0.14.6",
|
||||
"impit-win32-x64-msvc": "0.14.6"
|
||||
}
|
||||
},
|
||||
"node_modules/impit-darwin-arm64": {
|
||||
"version": "0.14.6",
|
||||
"resolved": "https://registry.npmjs.org/impit-darwin-arm64/-/impit-darwin-arm64-0.14.6.tgz",
|
||||
"integrity": "sha512-QhEENTXA727SlgIIIfPg4Ij2FEgwX/3rKCBpR84JxZZfQVVNO/KB2cVvt9aupokY1NZIo6oBFYJXF5PKyRVKbQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/impit-darwin-x64": {
|
||||
"version": "0.14.6",
|
||||
"resolved": "https://registry.npmjs.org/impit-darwin-x64/-/impit-darwin-x64-0.14.6.tgz",
|
||||
"integrity": "sha512-BYcBdG7+smXHrhwLGqtWWw1jTsQSWwJO7ZyfkVPMUGRcbPFJAXXKRUjdFWcp2AdMWyI5JufNF3IZzAJ6B++8mw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/impit-linux-arm64-gnu": {
|
||||
"version": "0.14.6",
|
||||
"resolved": "https://registry.npmjs.org/impit-linux-arm64-gnu/-/impit-linux-arm64-gnu-0.14.6.tgz",
|
||||
"integrity": "sha512-yoUxNCBDsQ72H7uTtaLp7hUQJxr1/OtIZ7KnaerFeJwPpuK2KCeAIXa/9ajoa0GZ6BErm9ZwkggOENhAXu0xxw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/impit-linux-arm64-musl": {
|
||||
"version": "0.14.6",
|
||||
"resolved": "https://registry.npmjs.org/impit-linux-arm64-musl/-/impit-linux-arm64-musl-0.14.6.tgz",
|
||||
"integrity": "sha512-2UL2ya3uRCdcZwkTL4Oe0RVyhvsONJHhbtD+0VZZkDKyCxyyjtZ6Rc1QQNKh2q/xKa3vNnzj/9qcu0mj/zCk8w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/impit-linux-x64-gnu": {
|
||||
"version": "0.14.6",
|
||||
"resolved": "https://registry.npmjs.org/impit-linux-x64-gnu/-/impit-linux-x64-gnu-0.14.6.tgz",
|
||||
"integrity": "sha512-6TJxs/iUQbxQuznb0gz5MQ15WIT4/IxHdwNG9II8kEKUEzVMLCun8IXiT5AGdYzwlmPYSIuxDjtY2ZALhC1FlA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/impit-linux-x64-musl": {
|
||||
"version": "0.14.6",
|
||||
"resolved": "https://registry.npmjs.org/impit-linux-x64-musl/-/impit-linux-x64-musl-0.14.6.tgz",
|
||||
"integrity": "sha512-AoneCmxaKKCx2wJZGLtFB6LrkTZUEViZ9XEsocpyMsm5IvV3mhj64AGIZI7zHlW7AFXq4MW0DPZkHiL/KxTB1A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/impit-win32-arm64-msvc": {
|
||||
"version": "0.14.6",
|
||||
"resolved": "https://registry.npmjs.org/impit-win32-arm64-msvc/-/impit-win32-arm64-msvc-0.14.6.tgz",
|
||||
"integrity": "sha512-DRsyV3vNPeUTaRjtFiRqHb8FVFmLDR3NB47cvb+AKKuo+KUbRJdJR1zbDQMD2SOKhYRHf1PTxzamRIkl3yWqWQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/impit-win32-x64-msvc": {
|
||||
"version": "0.14.6",
|
||||
"resolved": "https://registry.npmjs.org/impit-win32-x64-msvc/-/impit-win32-x64-msvc-0.14.6.tgz",
|
||||
"integrity": "sha512-0j7OHcgX8ui9ObSelx5v7OEEIfe00EgHcljq9RFR7oFzkD2ABS/6+fSyrXgu2LcC277vJ9oSfKjEacgGoCHOpQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"cors": "^2.8.6",
|
||||
"dotenv": "^18.0.8",
|
||||
"express": "^4.22.3",
|
||||
"impit": "0.14.6",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"jszip": "^3.10.2",
|
||||
"meshoptimizer": "^1.3.0",
|
||||
|
||||
@@ -127,7 +127,6 @@ export const COLLECTION_SYNC_ENABLED = (process.env.COLLECTION_SYNC_ENABLED ?? "
|
||||
export const COLLECTION_SYNC_ITEM_DELAY_MS = envInt("COLLECTION_SYNC_ITEM_DELAY_MS", 60_000);
|
||||
// Gap between a single import's preview-image fetches (up to ~20 per model).
|
||||
export const IMPORT_PREVIEW_IMAGE_DELAY_MS = envInt("IMPORT_PREVIEW_IMAGE_DELAY_MS", 250);
|
||||
export const IMPORT_USER_AGENT = "Thingport/1.0";
|
||||
export const IMPORT_BROWSER_USER_AGENT =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36";
|
||||
|
||||
|
||||
@@ -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 App Token in Admin Settings.",
|
||||
"Thingiverse import isn't configured for this instance yet -- ask an admin to add an App Token in Administration > Providers.",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 App Token in Admin Settings.",
|
||||
"Thingiverse import isn't configured for this instance yet -- ask an admin to add an App Token in Administration > Providers.",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import sharp from "sharp";
|
||||
import { DESCRIPTION_IMAGES, IMPORT_TIMEOUT_SECONDS, IMPORT_USER_AGENT } from "../config";
|
||||
import { DESCRIPTION_IMAGES, IMPORT_BROWSER_USER_AGENT, IMPORT_TIMEOUT_SECONDS } from "../config";
|
||||
import { prisma } from "../db";
|
||||
import { mapWithConcurrency } from "../utils/concurrency";
|
||||
import { validateRemoteUrl } from "../utils/urlUtils";
|
||||
import { browserFetch, needsBrowserFetch } from "../utils/browserFetch";
|
||||
import { readCapped } from "../utils/readCapped";
|
||||
|
||||
const IMAGE_MAX_BYTES = 16 * 1024 * 1024;
|
||||
@@ -47,11 +48,10 @@ async function fetchImage(url: string, deadline: number): Promise<Buffer | null>
|
||||
let current = url;
|
||||
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
||||
await validateRemoteUrl(current);
|
||||
const res = await fetch(current, {
|
||||
headers: { "User-Agent": IMPORT_USER_AGENT, Accept: "image/*" },
|
||||
redirect: "manual",
|
||||
signal: controller.signal,
|
||||
});
|
||||
const headers = { "User-Agent": IMPORT_BROWSER_USER_AGENT, Accept: "image/*" };
|
||||
const res = needsBrowserFetch(current)
|
||||
? await browserFetch(current, { headers, redirect: "manual", signal: controller.signal })
|
||||
: await fetch(current, { headers, redirect: "manual", signal: controller.signal });
|
||||
const location = res.headers.get("location");
|
||||
if (res.status >= 300 && res.status < 400 && location) {
|
||||
await res.body?.cancel().catch(() => undefined);
|
||||
|
||||
@@ -54,7 +54,7 @@ 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 App Token in Admin Settings.",
|
||||
"Thingiverse import isn't configured for this instance yet -- ask an admin to add an App Token in Administration > Providers.",
|
||||
);
|
||||
}
|
||||
const resolved = await resolveThingiverseThing(externalId, accessToken);
|
||||
|
||||
@@ -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 App Token in Admin Settings.",
|
||||
"Thingiverse import isn't configured for this instance yet -- ask an admin to add an App Token in Administration > Providers.",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
IMPORT_EXT_PRIORITY,
|
||||
IMPORT_HTML_MAX_BYTES,
|
||||
IMPORT_TIMEOUT_SECONDS,
|
||||
IMPORT_USER_AGENT,
|
||||
} from "../config";
|
||||
import { isJsonContentType } from "../utils/fileUtils";
|
||||
import { htmlToMarkdown } from "./descriptionMarkdown";
|
||||
@@ -171,7 +170,10 @@ async function fetchJsonFromUrl(
|
||||
referer?: string | null,
|
||||
headers?: Record<string, string>,
|
||||
): Promise<unknown | null> {
|
||||
const requestHeaders: Record<string, string> = { "User-Agent": IMPORT_USER_AGENT, Accept: "application/json" };
|
||||
const requestHeaders: Record<string, string> = {
|
||||
"User-Agent": IMPORT_BROWSER_USER_AGENT,
|
||||
Accept: "application/json",
|
||||
};
|
||||
if (referer) requestHeaders.Referer = referer;
|
||||
if (headers) Object.assign(requestHeaders, headers);
|
||||
const result = await fetchCappedBuffer(url, requestHeaders);
|
||||
|
||||
@@ -5,12 +5,12 @@ import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import {
|
||||
IMPORT_ALLOWED_EXTS,
|
||||
IMPORT_BROWSER_USER_AGENT,
|
||||
IMPORT_DOWNLOAD_RETRY_DELAY_MS,
|
||||
IMPORT_HTML_MAX_BYTES,
|
||||
IMPORT_MAX_BYTES,
|
||||
IMPORT_PREVIEW_IMAGE_DELAY_MS,
|
||||
IMPORT_TIMEOUT_SECONDS,
|
||||
IMPORT_USER_AGENT,
|
||||
} from "../config";
|
||||
import {
|
||||
HttpError,
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from "../utils/fileUtils";
|
||||
import { validateRemoteUrl } from "../utils/urlUtils";
|
||||
import { readCapped } from "../utils/readCapped";
|
||||
import { browserFetch, needsBrowserFetch } from "../utils/browserFetch";
|
||||
import { mapWithConcurrency, maybeSleep, sleep } from "../utils/concurrency";
|
||||
import {
|
||||
CLOUDFLARE_BLOCKED_CODE,
|
||||
@@ -118,6 +119,7 @@ function parseCharset(contentType: string | null): string {
|
||||
}
|
||||
|
||||
async function rawFetch(url: string, headers: Record<string, string>): Promise<Response> {
|
||||
if (needsBrowserFetch(url)) return browserFetch(url, { headers, timeoutMs: IMPORT_TIMEOUT_SECONDS * 1000 });
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), IMPORT_TIMEOUT_SECONDS * 1000);
|
||||
try {
|
||||
@@ -186,7 +188,7 @@ async function fetchMakerworldPageDesign(
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetchWithGuard(url, {
|
||||
"User-Agent": IMPORT_USER_AGENT,
|
||||
"User-Agent": IMPORT_BROWSER_USER_AGENT,
|
||||
Accept: "*/*",
|
||||
...makerworldHtmlHeaders(url, cookie),
|
||||
});
|
||||
@@ -223,7 +225,7 @@ export async function fetchMakerworldPageAuthor(
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetchWithGuard(url, {
|
||||
"User-Agent": IMPORT_USER_AGENT,
|
||||
"User-Agent": IMPORT_BROWSER_USER_AGENT,
|
||||
Accept: "*/*",
|
||||
...makerworldHtmlHeaders(url, cookie),
|
||||
});
|
||||
@@ -331,7 +333,7 @@ export async function openImportResponse(
|
||||
}
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = { "User-Agent": IMPORT_USER_AGENT, Accept: "*/*" };
|
||||
const headers: Record<string, string> = { "User-Agent": IMPORT_BROWSER_USER_AGENT, Accept: "*/*" };
|
||||
let makerworldCookie: string | null = null;
|
||||
if (host.endsWith("makerworld.com")) {
|
||||
makerworldCookie = resolveMakerworldCookie(body);
|
||||
@@ -491,7 +493,7 @@ const PREVIEW_IMAGE_MAX_COUNT = 20;
|
||||
|
||||
async function fetchImageBytes(url: string): Promise<Buffer | null> {
|
||||
try {
|
||||
const res = await rawFetch(url, { "User-Agent": IMPORT_USER_AGENT, Accept: "image/*" });
|
||||
const res = await rawFetch(url, { "User-Agent": IMPORT_BROWSER_USER_AGENT, Accept: "image/*" });
|
||||
if (!res.ok) {
|
||||
await res.body?.cancel().catch(() => undefined);
|
||||
return null;
|
||||
@@ -751,7 +753,7 @@ function isDownloadBlocked(res: Response): boolean {
|
||||
* 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 headers = { "User-Agent": IMPORT_USER_AGENT, Accept: "*/*" };
|
||||
const headers = { "User-Agent": IMPORT_BROWSER_USER_AGENT, Accept: "*/*" };
|
||||
let res = await rawFetch(url, headers);
|
||||
if (isDownloadBlocked(res)) {
|
||||
await res.body?.cancel().catch(() => undefined);
|
||||
@@ -794,7 +796,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 App Token in Admin Settings.",
|
||||
"Thingiverse import isn't configured for this instance yet -- ask an admin to add an App Token in Administration > Providers.",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { IMPORT_BROWSER_USER_AGENT, IMPORT_TIMEOUT_SECONDS } from "../config";
|
||||
import { IMPORT_TIMEOUT_SECONDS } from "../config";
|
||||
import { browserFetch } from "../utils/browserFetch";
|
||||
import type { ImportedAuthorInfo, ImportedPageMetadata } from "./importResolvers";
|
||||
import { cleanThingiverseMarkdown } from "./descriptionMarkdown";
|
||||
import type { ProviderCollectionCard } from "./providerCollectionCard";
|
||||
@@ -27,16 +28,16 @@ export class ThingiverseAuthError extends Error {
|
||||
constructor() {
|
||||
super(
|
||||
"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).",
|
||||
"update it in Administration > Providers (a new one can be generated at thingiverse.com/apps/create).",
|
||||
);
|
||||
this.name = "ThingiverseAuthError";
|
||||
}
|
||||
}
|
||||
|
||||
const FLARESOLVERR_HINTS = {
|
||||
missing: " Setting up FlareSolverr in Admin Settings > Connections gets past it.",
|
||||
missing: " Setting up FlareSolverr in Administration > Connections gets past it.",
|
||||
failed:
|
||||
" FlareSolverr couldn't get past it either: it may be down or unreachable. Check it in Admin Settings > Connections.",
|
||||
" FlareSolverr couldn't get past it either: it may be down or unreachable. Check it in Administration > Connections.",
|
||||
};
|
||||
|
||||
/** Cloudflare's 429 challenge, distinct from a 404 or rejected token: the fix is to wait, or a working FlareSolverr. */
|
||||
@@ -80,16 +81,7 @@ export function parseThingiverseLikesUrl(url: string): { username: string } | nu
|
||||
}
|
||||
|
||||
async function rawApiFetch(url: string): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), API_TIMEOUT_MS);
|
||||
try {
|
||||
return await fetch(url, {
|
||||
headers: { "User-Agent": IMPORT_BROWSER_USER_AGENT, Accept: "application/json" },
|
||||
signal: controller.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
return browserFetch(url, { headers: { Accept: "application/json" }, timeoutMs: API_TIMEOUT_MS });
|
||||
}
|
||||
|
||||
/** Once a challenge is seen, calls go straight through FlareSolverr for a while. JSON API calls
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { IMPORT_USER_AGENT } from "../config";
|
||||
|
||||
// The dashboard's weather, through the server like every other outside service: one place to
|
||||
// cache, rate-limit and identify ourselves. Open-Meteo gives the forecast and place search,
|
||||
// OpenStreetMap's Nominatim names a detected location.
|
||||
@@ -23,7 +21,7 @@ const SEARCH_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
const CACHE_LIMIT = 500;
|
||||
// Nominatim's usage policy: at most one request a second, with an identifying User-Agent.
|
||||
const NOMINATIM_INTERVAL_MS = 1100;
|
||||
const USER_AGENT = `${IMPORT_USER_AGENT} (self-hosted; +https://github.com/TautvydasDerzinskas/Thingport)`;
|
||||
const USER_AGENT = "Thingport/1.0 (self-hosted; +https://github.com/TautvydasDerzinskas/Thingport)";
|
||||
|
||||
let fetchImpl: typeof fetch = (...args) => fetch(...args);
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Impit } from "impit";
|
||||
|
||||
// Thingiverse's Cloudflare challenges clients by their TLS handshake, whatever the headers or
|
||||
// cookies, and Node's fetch is on its list. impit sends Chrome's handshake and headers.
|
||||
const following = new Impit({ browser: "chrome", timeout: null });
|
||||
const manual = new Impit({ browser: "chrome", timeout: null, followRedirects: false });
|
||||
|
||||
const NULL_BODY_STATUSES = new Set([204, 205, 304]);
|
||||
|
||||
/** Hosts that challenge Node's own fetch, so every request to them goes through browserFetch. */
|
||||
export function needsBrowserFetch(url: string): boolean {
|
||||
try {
|
||||
const host = new URL(url).hostname.toLowerCase();
|
||||
return host === "thingiverse.com" || host.endsWith(".thingiverse.com");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** fetch with Chrome's TLS fingerprint. `timeoutMs` covers waiting for the response, not reading
|
||||
* the body. The caller's User-Agent is dropped: one that doesn't match the handshake is itself a
|
||||
* bot signal. */
|
||||
export async function browserFetch(
|
||||
url: string,
|
||||
{
|
||||
headers = {},
|
||||
timeoutMs,
|
||||
signal,
|
||||
redirect = "follow",
|
||||
}: { headers?: Record<string, string>; timeoutMs?: number; signal?: AbortSignal; redirect?: "follow" | "manual" },
|
||||
): Promise<Response> {
|
||||
const sent = Object.fromEntries(Object.entries(headers).filter(([name]) => name.toLowerCase() !== "user-agent"));
|
||||
const controller = new AbortController();
|
||||
const onAbort = () => controller.abort();
|
||||
signal?.addEventListener("abort", onAbort);
|
||||
const timeout = timeoutMs ? setTimeout(onAbort, timeoutMs) : undefined;
|
||||
try {
|
||||
const client = redirect === "follow" ? following : manual;
|
||||
const res = await client.fetch(url, { headers: sent, signal: controller.signal });
|
||||
// A standard Response, so callers and helpers typed for fetch's take it as is.
|
||||
const body = NULL_BODY_STATUSES.has(res.status) ? null : res.body;
|
||||
const out = new Response(body, { status: res.status, statusText: res.statusText, headers: res.headers });
|
||||
Object.defineProperty(out, "url", { value: res.url || url });
|
||||
return out;
|
||||
} finally {
|
||||
// The caller's signal stays wired: like fetch's, it also cuts off reading the body.
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Server } from "node:http";
|
||||
import { Test } from "supertest";
|
||||
import { vi } from "vitest";
|
||||
|
||||
// supertest's request(app) listens on port 0 on every interface, then connects to 127.0.0.1 on the
|
||||
// port it got. On macOS a wildcard bind succeeds even when another program already holds that port
|
||||
@@ -45,3 +46,16 @@ proto.end = function (fn) {
|
||||
});
|
||||
return this;
|
||||
};
|
||||
|
||||
// Tests mock global.fetch; send browserFetch through it too, read at call time so each test's mock applies.
|
||||
vi.mock("../src/utils/browserFetch", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../src/utils/browserFetch")>()),
|
||||
browserFetch: (
|
||||
url: string,
|
||||
{
|
||||
headers,
|
||||
signal,
|
||||
redirect,
|
||||
}: { headers?: Record<string, string>; signal?: AbortSignal; redirect?: RequestRedirect },
|
||||
) => fetch(url, { headers, signal, redirect }),
|
||||
}));
|
||||
@@ -39,9 +39,9 @@ Open **Administration → Storage → Consume Folder**. If the volume is mounted
|
||||
- **Import into the library of** chooses whose library the models go to. It starts as the first admin account. On a
|
||||
shared server, switch it to the person whose library you're importing, then switch it back.
|
||||
- **Import mode** works like the choice you get when [uploading a folder](upload-folders.md):
|
||||
- **Each model file is a separate model** (the default): every STL, 3MF, STEP or OBJ becomes a model, and images
|
||||
- **Each file is a model** (the default): every STL, 3MF, STEP or OBJ becomes a model, and images
|
||||
become models too.
|
||||
- **Each folder is one model**: a folder of model files becomes one model named after the folder, with its images as
|
||||
- **Each folder is a model**: a folder of model files becomes one model named after the folder, with its images as
|
||||
photos and its other files attached.
|
||||
|
||||
Click **Save consume settings**. You only need to do this again when you want to change something.
|
||||
|
||||
@@ -13,7 +13,7 @@ all of them recreate your folders inside Thingport, so the structure you built s
|
||||
| [Consume folder](consume-folder.md) | Large libraries, NAS shares, or anything you add on a schedule | Copy files into a folder on the server |
|
||||
|
||||
The browser uploads ask you how to import things as you go. The consume folder asks nothing: you choose its rules once
|
||||
in the admin settings, and it imports whatever lands in it, without a browser tab open.
|
||||
under **Administration → Storage → Consume Folder**, and it imports whatever lands in it, without a browser tab open.
|
||||
|
||||
## The one decision: what counts as a model?
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 111 KiB |
@@ -9,14 +9,12 @@ each other by container name -- the frontend's nginx config, for example, proxie
|
||||
straight to `http://backend:8000`. Unraid's Community Applications store only installs one
|
||||
container per template with no built-in way to wire multiple templates together on a shared
|
||||
network, so Thingport is **not searchable in the CA app store**. Instead, install it as a
|
||||
Docker Compose stack, which Unraid supports natively (7.2+) or via a Community Applications
|
||||
plugin.
|
||||
Docker Compose stack. Unraid has no Compose support of its own, so this needs a plugin.
|
||||
|
||||
## 1. Get a Compose stack manager
|
||||
## 1. Install the Compose Manager plugin
|
||||
|
||||
- **Unraid 7.2+**: the Docker tab has a built-in **Compose** section -- skip to step 2.
|
||||
- **Older Unraid**: install **Compose Manager Plus** (or **Compose Manager**) from
|
||||
Community Applications (Apps tab -> search "Compose Manager").
|
||||
Apps tab -> search **Compose Manager** -> install **Compose Manager** (or **Compose Manager
|
||||
Plus**). It adds a **Compose** section to the Docker tab.
|
||||
|
||||
## 2. Create the stack
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Thingport for Unraid
|
||||
#
|
||||
# Install with the "Compose Manager" (or "Compose Manager Plus") plugin from Community
|
||||
# Applications, or with Unraid's built-in Docker > Compose tab on Unraid 7.2+:
|
||||
# Unraid has no Compose support of its own: install the "Compose Manager" (or "Compose
|
||||
# Manager Plus") plugin from Community Applications first, then:
|
||||
# 1. Docker tab -> Compose -> Add New Stack -> name it "thingport"
|
||||
# 2. Paste this file as the stack's docker-compose.yml
|
||||
# 3. Copy .env.example (next to this file) to the stack's .env and fill in the
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
[
|
||||
"@semantic-release/exec",
|
||||
{
|
||||
"publishCmd": "cat > \"${RUNNER_TEMP:-/tmp}/release-notes.md\" <<'SEMANTIC_RELEASE_NOTES'\n${nextRelease.notes}\nSEMANTIC_RELEASE_NOTES\nif [ -n \"$GITHUB_OUTPUT\" ]; then echo \"version=${nextRelease.version}\" >> \"$GITHUB_OUTPUT\"; echo \"tag=${nextRelease.gitTag}\" >> \"$GITHUB_OUTPUT\"; fi"
|
||||
"publishCmd": "cat > \"$RUNNER_TEMP/release-notes.md\" <<'SEMANTIC_RELEASE_NOTES'\n${nextRelease.notes}\nSEMANTIC_RELEASE_NOTES\nif [ -n \"$GITHUB_OUTPUT\" ]; then echo \"version=${nextRelease.version}\" >> \"$GITHUB_OUTPUT\"; echo \"tag=${nextRelease.gitTag}\" >> \"$GITHUB_OUTPUT\"; fi"
|
||||
}
|
||||
],
|
||||
[
|
||||
|
||||
|
Before Width: | Height: | Size: 255 KiB After Width: | Height: | Size: 253 KiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 393 KiB After Width: | Height: | Size: 366 KiB |
|
Before Width: | Height: | Size: 2.7 MiB After Width: | Height: | Size: 2.7 MiB |
|
Before Width: | Height: | Size: 405 KiB After Width: | Height: | Size: 2.9 MiB |
|
Before Width: | Height: | Size: 705 KiB After Width: | Height: | Size: 706 KiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 944 KiB |
|
Before Width: | Height: | Size: 231 KiB After Width: | Height: | Size: 216 KiB |
|
Before Width: | Height: | Size: 579 KiB After Width: | Height: | Size: 532 KiB |
|
Before Width: | Height: | Size: 958 KiB After Width: | Height: | Size: 911 KiB |
|
Before Width: | Height: | Size: 292 KiB After Width: | Height: | Size: 268 KiB |
|
Before Width: | Height: | Size: 165 KiB After Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 250 KiB After Width: | Height: | Size: 246 KiB |
|
Before Width: | Height: | Size: 199 KiB After Width: | Height: | Size: 200 KiB |
|
Before Width: | Height: | Size: 159 KiB After Width: | Height: | Size: 159 KiB |
|
Before Width: | Height: | Size: 174 KiB After Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 367 KiB After Width: | Height: | Size: 334 KiB |
|
Before Width: | Height: | Size: 253 KiB After Width: | Height: | Size: 254 KiB |
|
Before Width: | Height: | Size: 170 KiB After Width: | Height: | Size: 170 KiB |
|
Before Width: | Height: | Size: 335 KiB After Width: | Height: | Size: 287 KiB |
|
Before Width: | Height: | Size: 343 KiB After Width: | Height: | Size: 352 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 218 KiB After Width: | Height: | Size: 221 KiB |
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 336 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 172 KiB |
|
Before Width: | Height: | Size: 230 KiB After Width: | Height: | Size: 231 KiB |
|
Before Width: | Height: | Size: 259 KiB After Width: | Height: | Size: 258 KiB |
|
Before Width: | Height: | Size: 180 KiB After Width: | Height: | Size: 253 KiB |
|
Before Width: | Height: | Size: 537 KiB After Width: | Height: | Size: 556 KiB |
@@ -836,11 +836,11 @@
|
||||
"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 App Token in Administration > Settings.",
|
||||
"thingiverse_token_rejected": "Thingiverse models were skipped: Thingiverse rejected the App Token in Administration > Settings.",
|
||||
"thingiverse_no_token": "Thingiverse models were skipped: add a Thingiverse App Token in Administration > Providers.",
|
||||
"thingiverse_token_rejected": "Thingiverse models were skipped: Thingiverse rejected the App Token in Administration > Providers.",
|
||||
"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."
|
||||
"makerworld_login_rejected": "MakerWorld models were skipped: MakerWorld rejected the saved MakerWorld login. Update it in Configuration > Providers."
|
||||
},
|
||||
"refetchTitle": "Refetch model descriptions",
|
||||
"refetchDesc": "Replaces the description of every model a user imported from MakerWorld, Printables or Thingiverse with the current one from that site, with its formatting and images. Use it to bring models imported before formatted descriptions were supported up to date. Changes the user made to those descriptions are lost. Models whose source is gone keep their description. It runs in the background — MakerWorld requests are spaced out to avoid its CAPTCHA, so a large library can take a while.",
|
||||
|
||||
@@ -844,11 +844,11 @@
|
||||
"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 programėlės raktą skiltyje Administravimas > Nustatymai.",
|
||||
"thingiverse_token_rejected": "Thingiverse modeliai praleisti: Thingiverse atmetė programėlės raktą, nurodytą skiltyje Administravimas > Nustatymai.",
|
||||
"thingiverse_no_token": "Thingiverse modeliai praleisti: pridėkite Thingiverse programėlės raktą skiltyje Administravimas > Tiekėjai.",
|
||||
"thingiverse_token_rejected": "Thingiverse modeliai praleisti: Thingiverse atmetė programėlės raktą, nurodytą skiltyje Administravimas > Tiekėjai.",
|
||||
"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."
|
||||
"makerworld_login_rejected": "MakerWorld modeliai praleisti: MakerWorld atmetė išsaugotą MakerWorld prisijungimą. Atnaujinkite jį skiltyje Konfigūracija > Šaltiniai."
|
||||
},
|
||||
"refetchTitle": "Iš naujo gauti modelių aprašymus",
|
||||
"refetchDesc": "Pakeičia kiekvieno naudotojo iš MakerWorld, Printables ar Thingiverse importuoto modelio aprašymą dabartiniu aprašymu iš tos svetainės, su formatavimu ir paveikslėliais. Naudokite tai modeliams, importuotiems prieš atsirandant formatuotų aprašymų palaikymui, atnaujinti. Naudotojo atlikti tų aprašymų pakeitimai bus prarasti. Modeliai, kurių šaltinio nebėra, išlaiko savo aprašymą. Vykdoma fone — MakerWorld užklausos atliekamos su pertraukomis, kad būtų išvengta CAPTCHA, todėl didelė biblioteka gali užtrukti.",
|
||||
@@ -2569,7 +2569,7 @@
|
||||
"usernameFailed": "Nepavyko išsaugoti vartotojo vardo.",
|
||||
"openProviders": "Atidaryti tiekėjus",
|
||||
"notConnected": {
|
||||
"makerworld": "Prijunkite MakerWorld per Konfigūracija > Tiekėjai, kad čia matytumėte savo kolekcijas. Pagal slapuką Thingport žino, kas jūs ten esate.",
|
||||
"makerworld": "Prijunkite MakerWorld per Konfigūracija > Šaltiniai, kad čia matytumėte savo kolekcijas. Pagal slapuką Thingport žino, kas jūs ten esate.",
|
||||
"printables": "Printables šiuo metu nepasiekiamas.",
|
||||
"thingiverse": "Thingiverse šiame Thingport dar neprijungtas. Administratorius gali jį prijungti per Konfigūracija > Tiekėjai."
|
||||
},
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
//
|
||||
// The account's theme and Categories/Folders choice are switched for the shots and restored afterwards.
|
||||
//
|
||||
// The guide_* shots replace cut-outs in docs/guides/images; guide_consume_settings needs the consume folder mounted.
|
||||
//
|
||||
// The administration, AI and single sign-on shots (13 onwards) need an admin account, at least one AI model with
|
||||
// both AI features on and some suggestions waiting, and an OIDC provider that's switched on and connected to the
|
||||
// account. Email addresses on the page are blurred.
|
||||
@@ -46,6 +48,7 @@ const collectionName = args.collection || process.env.SCREENSHOT_COLLECTION;
|
||||
const outDir = path.resolve(
|
||||
args.out || process.env.SCREENSHOT_OUT || path.join(here, "../frontend/src/assets/screenshots"),
|
||||
);
|
||||
const guideImagesDir = path.join(here, "../docs/guides/images");
|
||||
const only = (args.only || process.env.SCREENSHOT_ONLY || "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
@@ -349,6 +352,56 @@ const SHOTS = [
|
||||
await blurEmails(page);
|
||||
},
|
||||
},
|
||||
// The guides' cut-outs, saved next to the guides rather than with the README shots.
|
||||
{
|
||||
name: "guide_consume_settings",
|
||||
run: async (page) => {
|
||||
// Shows "Watching /app/consume" only on an instance with the consume folder mounted.
|
||||
await openAdmin(page, "/admin-storage");
|
||||
await blurEmails(page);
|
||||
const save = page.getByRole("button", { name: "Save consume settings" });
|
||||
await save.scrollIntoViewIfNeeded();
|
||||
const clip = await page.evaluate(() => {
|
||||
const heading = [...document.querySelectorAll("h1, h2, h3, h4, h5, h6")].find(
|
||||
(el) => el.textContent?.trim() === "Consume Folder",
|
||||
);
|
||||
const button = [...document.querySelectorAll("button")].find(
|
||||
(el) => el.textContent?.trim() === "Save consume settings",
|
||||
);
|
||||
const card = button?.closest(".MuiPaper-root") ?? button?.parentElement;
|
||||
if (!heading || !card) return null;
|
||||
const top = heading.getBoundingClientRect();
|
||||
const box = card.getBoundingClientRect();
|
||||
return { x: box.left - 16, y: top.top - 16, width: box.width + 32, height: box.bottom - top.top + 32 };
|
||||
});
|
||||
if (!clip) throw new Error("Consume Folder section not found");
|
||||
// Scrolled down, the floating back-to-top button lands in the cut-out.
|
||||
await page.addStyleTag({ content: '[aria-label="Back to top"] { visibility: hidden !important; }' });
|
||||
await settle(page);
|
||||
await page.screenshot({ path: path.join(guideImagesDir, "consume-settings.png"), clip });
|
||||
return { saved: true };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "guide_add_menu",
|
||||
run: async (page) => {
|
||||
await page.goto("/");
|
||||
await settle(page, 0);
|
||||
const add = page.getByRole("button", { name: "Add", exact: true });
|
||||
// Measured first: the open menu hides the rest of the page from role queries.
|
||||
const button = await add.boundingBox();
|
||||
await add.click();
|
||||
const menu = page.getByRole("menu");
|
||||
await menu.waitFor();
|
||||
await page.waitForTimeout(400);
|
||||
const list = await menu.boundingBox();
|
||||
const left = Math.min(button.x, list.x) - 16;
|
||||
const right = Math.max(button.x + button.width, list.x + list.width) + 16;
|
||||
const clip = { x: left, y: button.y - 16, width: right - left, height: list.y + list.height - button.y + 32 };
|
||||
await page.screenshot({ path: path.join(guideImagesDir, "add-menu.png"), clip });
|
||||
return { saved: true };
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function wanted(shot) {
|
||||
|
||||