mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-18 20:59:55 -05:00
feat: initial commit
This commit is contained in:
commit
a8fef7d491
136 files changed
+23687
No files matched your search
@@ -0,0 +1,18 @@
|
||||
PUID=1000
|
||||
PGID=1000
|
||||
BACKEND_IMAGE=printstash-backend:latest
|
||||
POSTGRES_USER=printstash
|
||||
POSTGRES_PASSWORD=printstash
|
||||
POSTGRES_DB=printstash
|
||||
API_PORT=8000
|
||||
PUBLIC_URL=
|
||||
CORS_ORIGINS=
|
||||
AUTH_USERNAME=admin
|
||||
AUTH_PASSWORD=super-secret
|
||||
AUTH_SECRET=replace-with-random-secret
|
||||
AUTH_TOKEN_TTL=43200
|
||||
IMPORT_MOUNT_PATH=/imports
|
||||
IMPORT_MOUNT_PATH_HOST=/path/to/imports
|
||||
IMPORT_MOUNT_EXTS=stl,3mf,step,stp,obj,svg,png,jpg,jpeg,webp,bmp,lbrn,lbrn2,zip
|
||||
IMPORT_MOUNT_INCLUDE_HIDDEN=false
|
||||
IMPORT_MOUNT_ON_STARTUP=true
|
||||
@@ -0,0 +1,2 @@
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
@@ -0,0 +1,41 @@
|
||||
name: Backend image
|
||||
|
||||
# Only runs when backend/ (or the build workflow) changes — a frontend-only commit never
|
||||
# triggers a backend publish, and vice versa (see frontend-image.yml). To build+publish either
|
||||
# image on demand regardless of what changed, use the "Manual image publish" workflow instead.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ["v*.*.*"]
|
||||
paths:
|
||||
- "backend/**"
|
||||
- ".github/workflows/backend-image.yml"
|
||||
- ".github/workflows/build-image.yml"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "backend/**"
|
||||
- ".github/workflows/backend-image.yml"
|
||||
- ".github/workflows/build-image.yml"
|
||||
|
||||
concurrency:
|
||||
group: backend-image-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
uses: ./.github/workflows/build-image.yml
|
||||
with:
|
||||
# Build context is the repo root: backend/Dockerfile builds the frontend in its own stage
|
||||
# and bundles the static output into the backend image, so it needs to see both backend/
|
||||
# and frontend/ regardless of which one changed.
|
||||
project_dir: backend
|
||||
image_name: ghcr.io/${{ github.repository_owner }}/printstash-backend
|
||||
context: .
|
||||
dockerfile: backend/Dockerfile
|
||||
cache_scope: backend
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
secrets: inherit
|
||||
@@ -0,0 +1,90 @@
|
||||
name: Build image (reusable)
|
||||
|
||||
# Shared build+push logic used by backend-image.yml, frontend-image.yml, and
|
||||
# manual-image.yml — not triggered on its own (workflow_call only).
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
project_dir:
|
||||
description: "The npm project directory to lint, e.g. backend or frontend"
|
||||
required: true
|
||||
type: string
|
||||
image_name:
|
||||
description: "Fully-qualified image name, e.g. ghcr.io/owner/printstash-backend"
|
||||
required: true
|
||||
type: string
|
||||
context:
|
||||
description: "Docker build context"
|
||||
required: true
|
||||
type: string
|
||||
dockerfile:
|
||||
description: "Path to the Dockerfile"
|
||||
required: true
|
||||
type: string
|
||||
cache_scope:
|
||||
description: "GHA cache scope, so backend/frontend caches don't collide"
|
||||
required: true
|
||||
type: string
|
||||
push:
|
||||
description: "Whether to log in and push (false for PR validation builds)"
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: ${{ inputs.project_dir }}/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ${{ inputs.project_dir }}
|
||||
run: npm ci --no-audit --no-fund
|
||||
|
||||
# - name: Lint (quality gate — blocks the image build on failure)
|
||||
# working-directory: ${{ inputs.project_dir }}
|
||||
# run: npm run lint
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
if: inputs.push
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Compute image tags
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ inputs.image_name }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=sha,prefix=sha-,format=short
|
||||
type=semver,pattern={{version}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build (and push)
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ${{ inputs.context }}
|
||||
file: ${{ inputs.dockerfile }}
|
||||
push: ${{ inputs.push }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha,scope=${{ inputs.cache_scope }}
|
||||
cache-to: type=gha,mode=max,scope=${{ inputs.cache_scope }}
|
||||
@@ -0,0 +1,38 @@
|
||||
name: Frontend image
|
||||
|
||||
# Only runs when frontend/ (or the build workflow) changes — a backend-only commit never
|
||||
# triggers a frontend publish, and vice versa (see backend-image.yml). To build+publish either
|
||||
# image on demand regardless of what changed, use the "Manual image publish" workflow instead.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ["v*.*.*"]
|
||||
paths:
|
||||
- "frontend/**"
|
||||
- ".github/workflows/frontend-image.yml"
|
||||
- ".github/workflows/build-image.yml"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "frontend/**"
|
||||
- ".github/workflows/frontend-image.yml"
|
||||
- ".github/workflows/build-image.yml"
|
||||
|
||||
concurrency:
|
||||
group: frontend-image-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
uses: ./.github/workflows/build-image.yml
|
||||
with:
|
||||
project_dir: frontend
|
||||
image_name: ghcr.io/${{ github.repository_owner }}/printstash-frontend
|
||||
context: ./frontend
|
||||
dockerfile: frontend/Dockerfile
|
||||
cache_scope: frontend
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
secrets: inherit
|
||||
@@ -0,0 +1,49 @@
|
||||
name: Manual image publish
|
||||
|
||||
# One place to build+push either (or both) images on demand, regardless of what actually
|
||||
# changed — e.g. to re-publish after a registry hiccup, or force a rebuild after a base-image
|
||||
# security update. The automatic, path-filtered publishes still live in backend-image.yml and
|
||||
# frontend-image.yml.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
target:
|
||||
description: "Which image(s) to build and publish"
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- backend
|
||||
- frontend
|
||||
- both
|
||||
default: both
|
||||
|
||||
jobs:
|
||||
backend:
|
||||
if: inputs.target == 'backend' || inputs.target == 'both'
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
uses: ./.github/workflows/build-image.yml
|
||||
with:
|
||||
project_dir: backend
|
||||
image_name: ghcr.io/${{ github.repository_owner }}/printstash-backend
|
||||
context: .
|
||||
dockerfile: backend/Dockerfile
|
||||
cache_scope: backend
|
||||
push: true
|
||||
secrets: inherit
|
||||
|
||||
frontend:
|
||||
if: inputs.target == 'frontend' || inputs.target == 'both'
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
uses: ./.github/workflows/build-image.yml
|
||||
with:
|
||||
project_dir: frontend
|
||||
image_name: ghcr.io/${{ github.repository_owner }}/printstash-frontend
|
||||
context: ./frontend
|
||||
dockerfile: frontend/Dockerfile
|
||||
cache_scope: frontend
|
||||
push: true
|
||||
secrets: inherit
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
# Local secrets and environment overrides
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.sample
|
||||
!.env.template
|
||||
|
||||
# Node and Vite
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
coverage/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Prisma
|
||||
backend/prisma/dev.db*
|
||||
|
||||
# PrintStash runtime content
|
||||
data/
|
||||
imports/
|
||||
backend/storage/
|
||||
|
||||
# IDE, operating-system, and temporary files
|
||||
.idea/
|
||||
.vscode/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.swp
|
||||
*.swo
|
||||
*.tmp
|
||||
*.temp
|
||||
*.log
|
||||
@@ -0,0 +1,17 @@
|
||||
PUID=1000
|
||||
PGID=1000
|
||||
DATABASE_URL=postgresql://printstash:printstash@db:5432/printstash?schema=public
|
||||
FILE_STORAGE=/app/storage
|
||||
API_PORT=8000
|
||||
PUBLIC_URL=
|
||||
CORS_ORIGINS=
|
||||
AUTH_USERNAME=admin
|
||||
AUTH_PASSWORD=super-secret
|
||||
AUTH_SECRET=replace-with-random-secret
|
||||
AUTH_TOKEN_TTL=43200
|
||||
IMPORT_MOUNT_PATH=/imports
|
||||
IMPORT_MOUNT_PATH_HOST=/path/to/imports
|
||||
IMPORT_MOUNT_EXTS=stl,3mf,step,stp,obj,svg,png,jpg,jpeg,webp,bmp,lbrn,lbrn2,zip
|
||||
IMPORT_MOUNT_INCLUDE_HIDDEN=false
|
||||
IMPORT_MOUNT_ON_STARTUP=true
|
||||
IMPORT_MOUNT_COPY=true
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
storage/
|
||||
*.log
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["typescript", "unicorn", "oxc", "node", "vitest"],
|
||||
"categories": {
|
||||
"correctness": "error",
|
||||
"suspicious": "error",
|
||||
"perf": "warn"
|
||||
},
|
||||
"rules": {
|
||||
"no-underscore-dangle": ["error", { "allow": ["_max", "_count", "_sum", "_avg", "_min"] }],
|
||||
"no-await-in-loop": "off"
|
||||
},
|
||||
"env": {
|
||||
"builtin": true,
|
||||
"node": true
|
||||
},
|
||||
"ignorePatterns": ["dist", "node_modules", "coverage", "prisma/migrations", "storage"]
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
# Build context is the repo root (see docker-compose.yml) so this stage can reach ../frontend.
|
||||
FROM node:20-alpine AS frontend-build
|
||||
WORKDIR /frontend
|
||||
COPY frontend/package*.json ./
|
||||
RUN npm ci --no-audit --no-fund
|
||||
COPY frontend ./
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine AS dependencies
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache openssl
|
||||
COPY backend/package*.json ./
|
||||
COPY backend/prisma ./prisma
|
||||
RUN npm ci --no-audit --no-fund && npx prisma generate
|
||||
|
||||
FROM dependencies AS test
|
||||
COPY backend .
|
||||
ENV DATABASE_URL="postgresql://printstash:printstash@localhost:5432/printstash_test?schema=public" \
|
||||
FILE_STORAGE=/tmp/printstash-storage
|
||||
CMD ["npm", "test"]
|
||||
|
||||
FROM dependencies AS build
|
||||
COPY backend .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine AS runtime
|
||||
RUN apk add --no-cache openssl su-exec
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=dependencies /app/node_modules ./node_modules
|
||||
COPY --from=build /app/dist ./dist
|
||||
COPY --from=frontend-build /frontend/dist ./public
|
||||
COPY backend/package*.json ./
|
||||
COPY backend/prisma ./prisma
|
||||
COPY backend/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
# node:20-alpine already ships a "node" user/group at uid/gid 1000; remove it first so our own
|
||||
# "appuser"/"appgroup" (which docker-entrypoint.sh re-aligns to PUID/PGID at container start)
|
||||
# can claim that uid/gid without colliding.
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh \
|
||||
&& (deluser node 2>/dev/null || true) \
|
||||
&& (delgroup node 2>/dev/null || true) \
|
||||
&& addgroup -g 1000 appgroup \
|
||||
&& adduser -D -u 1000 -G appgroup -s /sbin/nologin appuser \
|
||||
&& mkdir -p /app/storage /app/data
|
||||
|
||||
EXPOSE 8000
|
||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||
CMD ["node", "dist/server.js"]
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
PUID=${PUID:-1000}
|
||||
PGID=${PGID:-1000}
|
||||
APP_USER=appuser
|
||||
APP_GROUP=appgroup
|
||||
|
||||
# Align group with requested PGID (create or update in-place).
|
||||
if getent group "$PGID" >/dev/null 2>&1; then
|
||||
APP_GROUP="$(getent group "$PGID" | cut -d: -f1)"
|
||||
elif getent group "$APP_GROUP" >/dev/null 2>&1; then
|
||||
addgroup -g "$PGID" "$APP_GROUP" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Align user with requested PUID.
|
||||
if id "$APP_USER" >/dev/null 2>&1; then
|
||||
deluser "$APP_USER" >/dev/null 2>&1 || true
|
||||
adduser -D -u "$PUID" -G "$APP_GROUP" -s /sbin/nologin "$APP_USER"
|
||||
fi
|
||||
|
||||
# Make sure mounted data directories are writable by the runtime user.
|
||||
CHOWN_MODE=${CHOWN_MODE:-minimal}
|
||||
mkdir -p /app/storage /app/data
|
||||
if [ "$CHOWN_MODE" = "recursive" ]; then
|
||||
chown -R "$PUID":"$PGID" /app/storage /app/data 2>/dev/null || true
|
||||
else
|
||||
chown "$PUID":"$PGID" /app/storage /app/data 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Managed migrations: apply any pending Prisma migrations before the app starts.
|
||||
npx prisma migrate deploy
|
||||
|
||||
exec su-exec "$APP_USER" "$@"
|
||||
Generated
+4621
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "printstash-backend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "commonjs",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/server.ts",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/server.js",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:deploy": "prisma migrate deploy",
|
||||
"test": "vitest run",
|
||||
"lint": "oxlint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.20.0",
|
||||
"cheerio": "^1.0.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.21.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mime-types": "^2.1.35",
|
||||
"multer": "^2.0.1",
|
||||
"prisma": "^5.20.0",
|
||||
"sharp": "^0.35.4",
|
||||
"yauzl": "^3.2.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jsonwebtoken": "^9.0.7",
|
||||
"@types/mime-types": "^2.1.4",
|
||||
"@types/multer": "^2.2.0",
|
||||
"@types/node": "^20.14.0",
|
||||
"@types/supertest": "^7.2.1",
|
||||
"@types/yauzl": "^2.10.3",
|
||||
"oxlint": "^1.81.0",
|
||||
"supertest": "^7.2.2",
|
||||
"tsx": "^4.19.1",
|
||||
"typescript": "^5.6.3",
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"overrides": {
|
||||
"qs": "^6.16.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "PrintFileRole" AS ENUM ('SUPPORTING', 'PREPARED');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Folder" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"tags" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||
"parentId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Folder_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Print" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"nameNormalized" TEXT NOT NULL,
|
||||
"title" TEXT,
|
||||
"notes" TEXT,
|
||||
"creator" TEXT,
|
||||
"collection" TEXT,
|
||||
"tags" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||
"folderId" TEXT,
|
||||
"sourcePath" TEXT,
|
||||
"preparedFileId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Print_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Plate" (
|
||||
"id" TEXT NOT NULL,
|
||||
"printId" TEXT NOT NULL,
|
||||
"position" INTEGER NOT NULL,
|
||||
"filename" TEXT NOT NULL,
|
||||
"mime" TEXT NOT NULL,
|
||||
"size" INTEGER NOT NULL DEFAULT 0,
|
||||
"storagePath" TEXT NOT NULL,
|
||||
"sourcePath" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Plate_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "PrintFile" (
|
||||
"id" TEXT NOT NULL,
|
||||
"printId" TEXT NOT NULL,
|
||||
"role" "PrintFileRole" NOT NULL,
|
||||
"filename" TEXT NOT NULL,
|
||||
"mime" TEXT NOT NULL,
|
||||
"size" INTEGER NOT NULL DEFAULT 0,
|
||||
"storagePath" TEXT NOT NULL,
|
||||
"metadata" JSONB,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "PrintFile_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Setting" (
|
||||
"key" TEXT NOT NULL,
|
||||
"value" JSONB NOT NULL,
|
||||
|
||||
CONSTRAINT "Setting_pkey" PRIMARY KEY ("key")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Folder_parentId_idx" ON "Folder"("parentId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Folder_tags_idx" ON "Folder" USING GIN ("tags");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Print_preparedFileId_key" ON "Print"("preparedFileId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Print_folderId_name_idx" ON "Print"("folderId", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Print_tags_idx" ON "Print" USING GIN ("tags");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Print_folderId_nameNormalized_key" ON "Print"("folderId", "nameNormalized");
|
||||
|
||||
-- CreateIndex
|
||||
-- Postgres multi-column unique indexes never conflict when any indexed column is NULL,
|
||||
-- so the composite index above does not stop two root-level (folderId IS NULL) prints
|
||||
-- from sharing a name. This partial index enforces per-folder name uniqueness at the root too.
|
||||
CREATE UNIQUE INDEX "Print_root_nameNormalized_key" ON "Print"("nameNormalized") WHERE "folderId" IS NULL;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Plate_storagePath_key" ON "Plate"("storagePath");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Plate_printId_idx" ON "Plate"("printId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Plate_printId_position_key" ON "Plate"("printId", "position");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "PrintFile_storagePath_key" ON "PrintFile"("storagePath");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "PrintFile_printId_role_idx" ON "PrintFile"("printId", "role");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Folder" ADD CONSTRAINT "Folder_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "Folder"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Print" ADD CONSTRAINT "Print_folderId_fkey" FOREIGN KEY ("folderId") REFERENCES "Folder"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Print" ADD CONSTRAINT "Print_preparedFileId_fkey" FOREIGN KEY ("preparedFileId") REFERENCES "PrintFile"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Plate" ADD CONSTRAINT "Plate_printId_fkey" FOREIGN KEY ("printId") REFERENCES "Print"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PrintFile" ADD CONSTRAINT "PrintFile_printId_fkey" FOREIGN KEY ("printId") REFERENCES "Print"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Print" ADD COLUMN "preparedMetadata" JSONB;
|
||||
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "postgresql"
|
||||
@@ -0,0 +1,91 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model Folder {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
tags String[] @default([])
|
||||
parentId String?
|
||||
parent Folder? @relation("FolderTree", fields: [parentId], references: [id], onDelete: SetNull)
|
||||
children Folder[] @relation("FolderTree")
|
||||
prints Print[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([parentId])
|
||||
@@index([tags], type: Gin)
|
||||
}
|
||||
|
||||
model Print {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
nameNormalized String
|
||||
title String?
|
||||
notes String?
|
||||
creator String?
|
||||
collection String?
|
||||
tags String[] @default([])
|
||||
folderId String?
|
||||
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
|
||||
sourcePath String?
|
||||
plates Plate[]
|
||||
files PrintFile[] @relation("PrintFiles")
|
||||
preparedFileId String? @unique
|
||||
preparedFile PrintFile? @relation("PreparedFile", fields: [preparedFileId], references: [id], onDelete: SetNull)
|
||||
preparedMetadata Json?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([folderId, nameNormalized])
|
||||
@@index([folderId, name])
|
||||
@@index([tags], type: Gin)
|
||||
}
|
||||
|
||||
model Plate {
|
||||
id String @id @default(cuid())
|
||||
printId String
|
||||
print Print @relation(fields: [printId], references: [id], onDelete: Cascade)
|
||||
position Int
|
||||
filename String
|
||||
mime String
|
||||
size Int @default(0)
|
||||
storagePath String @unique
|
||||
sourcePath String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([printId, position])
|
||||
@@index([printId])
|
||||
}
|
||||
|
||||
enum PrintFileRole {
|
||||
SUPPORTING
|
||||
PREPARED
|
||||
}
|
||||
|
||||
model PrintFile {
|
||||
id String @id @default(cuid())
|
||||
printId String
|
||||
print Print @relation("PrintFiles", fields: [printId], references: [id], onDelete: Cascade)
|
||||
role PrintFileRole
|
||||
filename String
|
||||
mime String
|
||||
size Int @default(0)
|
||||
storagePath String @unique
|
||||
metadata Json?
|
||||
createdAt DateTime @default(now())
|
||||
preparedFor Print? @relation("PreparedFile")
|
||||
|
||||
@@index([printId, role])
|
||||
}
|
||||
|
||||
model Setting {
|
||||
key String @id
|
||||
value Json
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import express, { type Express, type NextFunction, type Request, type Response } from "express";
|
||||
import cors from "cors";
|
||||
import fs from "node:fs";
|
||||
import { resolveCorsOrigins } from "./cors";
|
||||
import { FRONTEND_DIST } from "./config";
|
||||
import { HttpError } from "./utils/fileUtils";
|
||||
|
||||
import healthRoutes from "./routes/health";
|
||||
import authRoutes from "./routes/auth";
|
||||
import printsRoutes from "./routes/prints";
|
||||
import platesRoutes from "./routes/plates";
|
||||
import foldersRoutes from "./routes/folders";
|
||||
import printFilesRoutes from "./routes/printFiles";
|
||||
import settingsRoutes from "./routes/settings";
|
||||
import importsRoutes from "./routes/imports";
|
||||
|
||||
export function createApp(): Express {
|
||||
const app = express();
|
||||
|
||||
const origins = resolveCorsOrigins();
|
||||
app.use(
|
||||
cors({
|
||||
origin: origins.includes("*") ? true : origins,
|
||||
credentials: true,
|
||||
exposedHeaders: ["X-Has-More", "X-Next-Offset"],
|
||||
}),
|
||||
);
|
||||
|
||||
app.use(express.json());
|
||||
|
||||
// Serves the frontend's built static files (index.html, assets/*) when present — i.e. in the
|
||||
// production Docker image, which bundles frontend + backend behind one origin/port so the UI
|
||||
// can call the API with plain relative paths (no /api prefix, no separate proxy). Absent in
|
||||
// local dev (`npm run dev`), where the Vite dev server is used instead and this is a no-op.
|
||||
if (fs.existsSync(FRONTEND_DIST)) {
|
||||
app.use(express.static(FRONTEND_DIST));
|
||||
}
|
||||
|
||||
app.use(healthRoutes);
|
||||
app.use(authRoutes);
|
||||
app.use(settingsRoutes);
|
||||
app.use(printsRoutes);
|
||||
app.use(platesRoutes);
|
||||
app.use(foldersRoutes);
|
||||
app.use(printFilesRoutes);
|
||||
app.use(importsRoutes);
|
||||
|
||||
// 404 fallback for unmatched routes.
|
||||
app.use((_req: Request, res: Response) => {
|
||||
res.status(404).json({ detail: "Not found" });
|
||||
});
|
||||
|
||||
// Central error handler: HttpError carries its own status, everything else is a 500.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
|
||||
if (err instanceof HttpError) {
|
||||
res.status(err.status).json({ detail: err.message });
|
||||
return;
|
||||
}
|
||||
console.error(err);
|
||||
res.status(500).json({ detail: "Internal server error" });
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { AUTH_ALGO, AUTH_ENABLED, AUTH_SECRET, AUTH_TOKEN_TTL } from "./config";
|
||||
|
||||
export function createToken(username: string): string {
|
||||
return jwt.sign({ sub: username }, AUTH_SECRET, {
|
||||
algorithm: AUTH_ALGO,
|
||||
expiresIn: AUTH_TOKEN_TTL,
|
||||
});
|
||||
}
|
||||
|
||||
export function verifyToken(token: string): { sub: string } | null {
|
||||
try {
|
||||
return jwt.verify(token, AUTH_SECRET, { algorithms: [AUTH_ALGO] }) as { sub: string };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepts either an `Authorization: Bearer <token>` header or a `?token=` query
|
||||
* param (needed for <img>/direct file links that can't set headers). No-op when
|
||||
* auth is disabled entirely (mirrors MakersVault's require_auth dependency).
|
||||
*/
|
||||
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
|
||||
if (!AUTH_ENABLED) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
const header = req.header("authorization");
|
||||
const headerToken = header?.toLowerCase().startsWith("bearer ") ? header.slice(7).trim() : undefined;
|
||||
const queryToken = typeof req.query.token === "string" ? req.query.token : undefined;
|
||||
const token = headerToken || queryToken;
|
||||
if (!token || !verifyToken(token)) {
|
||||
res.status(401).json({ detail: "Unauthorized" });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import path from "node:path";
|
||||
|
||||
function envInt(name: string, fallback: number): number {
|
||||
const raw = process.env[name];
|
||||
if (raw === undefined) return fallback;
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
function envBool(name: string, fallback: boolean): boolean {
|
||||
const raw = process.env[name];
|
||||
if (raw === undefined) return fallback;
|
||||
return ["1", "true", "yes", "y", "on"].includes(raw.trim().toLowerCase());
|
||||
}
|
||||
|
||||
export const STORAGE = path.resolve(process.env.FILE_STORAGE || "./storage");
|
||||
export const THUMBS = path.join(STORAGE, "thumbs");
|
||||
export const BUNDLES = path.join(STORAGE, "bundles");
|
||||
|
||||
export const AUTH_USERNAME = process.env.AUTH_USERNAME || "admin";
|
||||
export const AUTH_PASSWORD = process.env.AUTH_PASSWORD || "super-secret";
|
||||
export const AUTH_SECRET = process.env.AUTH_SECRET || "changeme-secret";
|
||||
export const AUTH_TOKEN_TTL = envInt("AUTH_TOKEN_TTL", 43200);
|
||||
export const AUTH_ALGO = "HS256" as const;
|
||||
export const AUTH_ENABLED = Boolean(AUTH_USERNAME && AUTH_PASSWORD);
|
||||
|
||||
export const IMPORT_ALLOWED_EXTS = new Set([".stl", ".3mf", ".step", ".stp", ".obj", ".lbrn", ".lbrn2", ".zip"]);
|
||||
export const IMPORT_EXT_PRIORITY = [".3mf", ".stl", ".step", ".stp", ".lbrn2", ".lbrn", ".zip"];
|
||||
export const IMPORT_BLOCKED_EXTS = new Set([
|
||||
".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg", ".jfif", ".tif", ".tiff",
|
||||
".css", ".js", ".mjs", ".map", ".json", ".ico", ".woff", ".woff2", ".ttf", ".eot",
|
||||
]);
|
||||
export const IMPORT_TIMEOUT_SECONDS = envInt("IMPORT_TIMEOUT_SECONDS", 30);
|
||||
export const IMPORT_MAX_MB = envInt("IMPORT_MAX_MB", 512);
|
||||
export const IMPORT_MAX_BYTES = Math.max(1, IMPORT_MAX_MB) * 1024 * 1024;
|
||||
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;
|
||||
export const IMPORT_USER_AGENT = "PrintStash/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";
|
||||
|
||||
export const MOUNT_IMPORT_PATH = (process.env.IMPORT_MOUNT_PATH || "").trim();
|
||||
export const MOUNT_IMPORT_EXTS_RAW = (process.env.IMPORT_MOUNT_EXTS || "").trim();
|
||||
export const MOUNT_IMPORT_INCLUDE_HIDDEN = envBool("IMPORT_MOUNT_INCLUDE_HIDDEN", false);
|
||||
export const MOUNT_IMPORT_ENABLED = envBool("IMPORT_MOUNT_ON_STARTUP", true);
|
||||
export const MOUNT_IMPORT_COPY = envBool("IMPORT_MOUNT_COPY", true);
|
||||
export const DEFAULT_MOUNT_IMPORT_EXTS = new Set([
|
||||
".stl", ".3mf", ".step", ".stp", ".obj", ".svg", ".png", ".jpg", ".jpeg", ".webp", ".bmp",
|
||||
".lbrn", ".lbrn2", ".zip",
|
||||
]);
|
||||
|
||||
export const API_PORT = envInt("API_PORT", 8000);
|
||||
|
||||
/**
|
||||
* Where the frontend's built static files (index.html, assets/*) live, if present. Populated by
|
||||
* the Docker image's build (see Dockerfile); absent in local `npm run dev` — express.static()
|
||||
* simply serves nothing and every request falls through to the API routes / 404 handler.
|
||||
*/
|
||||
export const FRONTEND_DIST = path.resolve(process.env.FRONTEND_DIST || path.join(__dirname, "../public"));
|
||||
@@ -0,0 +1,29 @@
|
||||
/** Port of MakersVault main.py's normalize_origin/resolve_cors_origins (lines ~112-149). */
|
||||
function normalizeOrigin(raw: string | undefined): string | null {
|
||||
const value = (raw || "").trim();
|
||||
if (!value) return null;
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
if (!parsed.protocol || !parsed.host) return null;
|
||||
return `${parsed.protocol}//${parsed.host}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveCorsOrigins(): string[] {
|
||||
const raw = process.env.CORS_ORIGINS;
|
||||
if (raw) {
|
||||
return raw
|
||||
.split(",")
|
||||
.map((o) => o.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
const candidates: string[] = [];
|
||||
for (const name of ["PUBLIC_URL", "VITE_API_URL"]) {
|
||||
const origin = normalizeOrigin(process.env[name]);
|
||||
if (origin && !candidates.includes(origin)) candidates.push(origin);
|
||||
}
|
||||
if (candidates.length) return candidates;
|
||||
return ["http://localhost:5173"];
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import fs from "node:fs";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { STORAGE, THUMBS, BUNDLES } from "./config";
|
||||
|
||||
for (const dir of [STORAGE, THUMBS, BUNDLES]) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
export const prisma = new PrismaClient();
|
||||
@@ -0,0 +1,172 @@
|
||||
import fs from "node:fs";
|
||||
import type { Folder, Plate, Print, PrintFile } from "@prisma/client";
|
||||
import { plateThumbExists, plateThumbPath } from "./services/printService";
|
||||
import { preparedFilename } from "./services/preparedPrint";
|
||||
|
||||
export type PreparedPrintOut = {
|
||||
printer?: string | null;
|
||||
material?: string | null;
|
||||
nozzle_mm?: number | null;
|
||||
layer_height_mm?: number | null;
|
||||
estimated_seconds?: number | null;
|
||||
format?: string | null;
|
||||
removable: boolean;
|
||||
};
|
||||
|
||||
export type PlateOut = {
|
||||
id: string;
|
||||
print_id: string;
|
||||
position: number;
|
||||
filename: string;
|
||||
mime: string;
|
||||
size: number;
|
||||
url: string;
|
||||
thumb_url: string | null;
|
||||
};
|
||||
|
||||
export type PrintFileOut = {
|
||||
id: string;
|
||||
filename: string;
|
||||
mime: string;
|
||||
size: number;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type PrintOut = {
|
||||
id: string;
|
||||
name: string;
|
||||
title: string | null;
|
||||
notes: string | null;
|
||||
creator: string | null;
|
||||
collection: string | null;
|
||||
tags: string[];
|
||||
folder_id: string | null;
|
||||
storage_path: string | null;
|
||||
plates: PlateOut[];
|
||||
thumb_url: string | null;
|
||||
supporting_file_count: number;
|
||||
prepared_print: PreparedPrintOut | null;
|
||||
slicer_url: string | null;
|
||||
slicer_filename: string | null;
|
||||
};
|
||||
|
||||
export type FolderOut = {
|
||||
id: string;
|
||||
name: string;
|
||||
tags: string[];
|
||||
parent_id: string | null;
|
||||
};
|
||||
|
||||
function plateThumbUrl(plateId: string): string | null {
|
||||
if (!plateThumbExists(plateId)) return null;
|
||||
const mtime = fs.statSync(plateThumbPath(plateId)).mtimeMs;
|
||||
return `/plate/${plateId}/thumb.jpg?v=${mtime}`;
|
||||
}
|
||||
|
||||
export function toPlateOut(printId: string, plate: Plate): PlateOut {
|
||||
return {
|
||||
id: plate.id,
|
||||
print_id: printId,
|
||||
position: plate.position,
|
||||
filename: plate.filename,
|
||||
mime: plate.mime,
|
||||
size: plate.size,
|
||||
url: `/print/${printId}/plate/${plate.id}/file/${encodeURIComponent(plate.filename)}`,
|
||||
thumb_url: plateThumbUrl(plate.id),
|
||||
};
|
||||
}
|
||||
|
||||
export function toPrintFileOut(printFile: PrintFile): PrintFileOut {
|
||||
return {
|
||||
id: printFile.id,
|
||||
filename: printFile.filename,
|
||||
mime: printFile.mime,
|
||||
size: printFile.size,
|
||||
url: `/print/${printFile.printId}/files/${printFile.id}`,
|
||||
};
|
||||
}
|
||||
|
||||
function storageParentDir(plates: Plate[]): string | null {
|
||||
if (!plates.length) return null;
|
||||
const parts = plates[0].storagePath.split("/");
|
||||
parts.pop();
|
||||
return parts.join("/") || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the full Print DTO. `plates` must already be sorted by position ascending.
|
||||
* `files` is every PrintFile (supporting + prepared) belonging to this print.
|
||||
* `preparedFile` is the print's explicit prepared PrintFile row, if any (preparedFileId).
|
||||
*/
|
||||
export function toPrintOut(
|
||||
print: Print,
|
||||
plates: Plate[],
|
||||
files: PrintFile[],
|
||||
preparedFile: PrintFile | null,
|
||||
): PrintOut {
|
||||
const sortedPlates = plates.toSorted((a, b) => a.position - b.position);
|
||||
const plateOuts = sortedPlates.map((p) => toPlateOut(print.id, p));
|
||||
const supportingCount = files.filter((f) => f.role === "SUPPORTING").length;
|
||||
|
||||
let prepared: PreparedPrintOut | null = null;
|
||||
let slicerUrl: string | null = null;
|
||||
let slicerFilename: string | null = null;
|
||||
|
||||
if (preparedFile) {
|
||||
const meta = (preparedFile.metadata as Record<string, unknown> | null) || {};
|
||||
prepared = {
|
||||
printer: (meta.printer as string | null) ?? null,
|
||||
material: (meta.material as string | null) ?? null,
|
||||
nozzle_mm: (meta.nozzle_mm as number | null) ?? null,
|
||||
layer_height_mm: (meta.layer_height_mm as number | null) ?? null,
|
||||
estimated_seconds: (meta.estimated_seconds as number | null) ?? null,
|
||||
format: (meta.format as string | null) ?? null,
|
||||
removable: true,
|
||||
};
|
||||
slicerUrl = `/print/${print.id}/prepared-print`;
|
||||
slicerFilename = preparedFile.filename;
|
||||
} else if (print.preparedMetadata) {
|
||||
const meta = print.preparedMetadata as Record<string, unknown>;
|
||||
prepared = {
|
||||
printer: (meta.printer as string | null) ?? null,
|
||||
material: (meta.material as string | null) ?? null,
|
||||
nozzle_mm: (meta.nozzle_mm as number | null) ?? null,
|
||||
layer_height_mm: (meta.layer_height_mm as number | null) ?? null,
|
||||
estimated_seconds: (meta.estimated_seconds as number | null) ?? null,
|
||||
format: (meta.format as string | null) ?? null,
|
||||
removable: false,
|
||||
};
|
||||
slicerUrl = plateOuts[0]?.url ?? null;
|
||||
slicerFilename = preparedFilename(print.name, { format: meta.format as string });
|
||||
} else {
|
||||
slicerUrl = plateOuts[0]?.url ?? null;
|
||||
slicerFilename = sortedPlates[0]?.filename ?? null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: print.id,
|
||||
name: print.name,
|
||||
title: print.title,
|
||||
notes: print.notes,
|
||||
creator: print.creator,
|
||||
collection: print.collection,
|
||||
tags: print.tags,
|
||||
folder_id: print.folderId,
|
||||
storage_path: storageParentDir(sortedPlates),
|
||||
plates: plateOuts,
|
||||
thumb_url: plateOuts[0]?.thumb_url ?? null,
|
||||
supporting_file_count: supportingCount,
|
||||
prepared_print: prepared,
|
||||
slicer_url: slicerUrl,
|
||||
slicer_filename: slicerFilename,
|
||||
};
|
||||
}
|
||||
|
||||
export function toFolderOut(folder: Folder): FolderOut {
|
||||
return {
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
tags: folder.tags,
|
||||
parent_id: folder.parentId,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Router } from "express";
|
||||
import { z } from "zod";
|
||||
import { AUTH_ENABLED, AUTH_PASSWORD, AUTH_TOKEN_TTL, AUTH_USERNAME } from "../config";
|
||||
import { createToken, requireAuth, verifyToken } from "../auth";
|
||||
import { HttpError } from "../utils/fileUtils";
|
||||
import { parseBody } from "../utils/validate";
|
||||
|
||||
const router = Router();
|
||||
|
||||
const loginSchema = z.object({ username: z.string(), password: z.string() });
|
||||
|
||||
router.post("/login", (req, res) => {
|
||||
if (!AUTH_ENABLED) throw new HttpError(503, "Authentication is not configured on the server");
|
||||
const body = parseBody(loginSchema, req.body);
|
||||
if (body.username !== AUTH_USERNAME || body.password !== AUTH_PASSWORD) {
|
||||
throw new HttpError(401, "Invalid username or password");
|
||||
}
|
||||
const token = createToken(body.username);
|
||||
res.json({ token, expires_in: AUTH_TOKEN_TTL });
|
||||
});
|
||||
|
||||
router.post("/refresh", requireAuth, (req, res) => {
|
||||
if (!AUTH_ENABLED) throw new HttpError(503, "Authentication is not configured on the server");
|
||||
const header = req.header("authorization");
|
||||
const headerToken = header?.toLowerCase().startsWith("bearer ") ? header.slice(7).trim() : undefined;
|
||||
const queryToken = typeof req.query.token === "string" ? req.query.token : undefined;
|
||||
const token = headerToken || queryToken;
|
||||
if (!token) throw new HttpError(401, "Unauthorized");
|
||||
const payload = verifyToken(token);
|
||||
if (!payload?.sub) throw new HttpError(401, "Invalid or expired token");
|
||||
const newToken = createToken(payload.sub);
|
||||
res.json({ token: newToken, expires_in: AUTH_TOKEN_TTL });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Router } from "express";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "../db";
|
||||
import { requireAuth } from "../auth";
|
||||
import { HttpError } from "../utils/fileUtils";
|
||||
import { parseBody } from "../utils/validate";
|
||||
import { asyncHandler } from "../utils/asyncHandler";
|
||||
import { validateParentFolder } from "../services/folderService";
|
||||
import { availableModelName, reorganizeManagedPrints } from "../services/printService";
|
||||
import { toFolderOut } from "../dto";
|
||||
import { sendPrintsZip } from "../services/downloadZip";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
const folderSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
tags: z.array(z.string()).default([]),
|
||||
parent_id: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
router.get(
|
||||
"/folders",
|
||||
asyncHandler(async (_req, res) => {
|
||||
const folders = await prisma.folder.findMany();
|
||||
res.json(folders.map(toFolderOut));
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/folders",
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = parseBody(folderSchema, req.body);
|
||||
const parentId = await validateParentFolder(body.parent_id ?? null);
|
||||
const folder = await prisma.folder.create({
|
||||
data: { name: body.name, tags: body.tags.map((t) => t.trim()).filter(Boolean), parentId },
|
||||
});
|
||||
res.json(toFolderOut(folder));
|
||||
}),
|
||||
);
|
||||
|
||||
router.patch(
|
||||
"/folder/:id",
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = parseBody(folderSchema, req.body);
|
||||
const folder = await prisma.folder.findUnique({ where: { id: req.params.id } });
|
||||
if (!folder) throw new HttpError(404, "Not found");
|
||||
const parentId = await validateParentFolder(body.parent_id ?? null, folder.id);
|
||||
const updated = await prisma.folder.update({
|
||||
where: { id: folder.id },
|
||||
data: { name: body.name, tags: body.tags.map((t) => t.trim()).filter(Boolean), parentId },
|
||||
});
|
||||
await reorganizeManagedPrints();
|
||||
res.json(toFolderOut(updated));
|
||||
}),
|
||||
);
|
||||
|
||||
router.delete(
|
||||
"/folder/:id",
|
||||
asyncHandler(async (req, res) => {
|
||||
const folder = await prisma.folder.findUnique({ where: { id: req.params.id } });
|
||||
if (!folder) throw new HttpError(404, "Not found");
|
||||
|
||||
await prisma.folder.updateMany({ where: { parentId: folder.id }, data: { parentId: null } });
|
||||
|
||||
const prints = await prisma.print.findMany({ where: { folderId: folder.id } });
|
||||
for (const print of prints) {
|
||||
const nextName = await availableModelName(print.name, null, print.id);
|
||||
const data: Prisma.PrintUpdateInput = { folder: { disconnect: true } };
|
||||
if (nextName !== print.name) {
|
||||
data.name = nextName;
|
||||
data.nameNormalized = nextName.trim().toLowerCase();
|
||||
data.title = nextName;
|
||||
}
|
||||
await prisma.print.update({ where: { id: print.id }, data });
|
||||
}
|
||||
|
||||
await prisma.folder.delete({ where: { id: folder.id } });
|
||||
await reorganizeManagedPrints();
|
||||
res.json({ ok: true });
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/folder/:id/download",
|
||||
asyncHandler(async (req, res) => {
|
||||
const folder = await prisma.folder.findUnique({ where: { id: req.params.id } });
|
||||
if (!folder) throw new HttpError(404, "Folder not found");
|
||||
const prints = await prisma.print.findMany({
|
||||
where: { folderId: folder.id },
|
||||
include: { plates: { orderBy: { position: "asc" } }, folder: true },
|
||||
});
|
||||
const downloadName = `${(folder.name || "folder").replace(/ /g, "_").slice(0, 50) || "folder"}.zip`;
|
||||
await sendPrintsZip(res, prints, downloadName);
|
||||
}),
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Router } from "express";
|
||||
import { AUTH_ENABLED } from "../config";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get("/health", (_req, res) => {
|
||||
res.json({ ok: true, auth_required: AUTH_ENABLED });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,87 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { Router } from "express";
|
||||
import { z } from "zod";
|
||||
import { requireAuth } from "../auth";
|
||||
import { HttpError } from "../utils/fileUtils";
|
||||
import { normalizeImportUrl } from "../utils/urlUtils";
|
||||
import { parseBody } from "../utils/validate";
|
||||
import { asyncHandler } from "../utils/asyncHandler";
|
||||
import { downloadImportToTemp, importPrintFromUrl, inspectImportLink } from "../services/importService";
|
||||
import { extractZipEntriesToPrints, listZipEntries } from "../services/zipService";
|
||||
import { toPrintOut } from "../dto";
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
const importRequestSchema = z.object({
|
||||
url: z.string(),
|
||||
title: z.string().nullable().optional(),
|
||||
notes: z.string().nullable().optional(),
|
||||
tags: z.array(z.string()).default([]),
|
||||
folder_id: z.string().nullable().optional(),
|
||||
filename: z.string().nullable().optional(),
|
||||
makerworld_cookie: z.string().nullable().optional(),
|
||||
thingiverse_cookie: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
router.post(
|
||||
"/import",
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = parseBody(importRequestSchema, req.body);
|
||||
const url = await normalizeImportUrl(body.url);
|
||||
const { print, plates } = await importPrintFromUrl(url, body);
|
||||
res.json(toPrintOut(print, plates, [], null));
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/import/inspect",
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = parseBody(importRequestSchema, req.body);
|
||||
const url = await normalizeImportUrl(body.url);
|
||||
const result = await inspectImportLink(url, body);
|
||||
res.json(result);
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/import/zip/entries",
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = parseBody(importRequestSchema, req.body);
|
||||
const url = await normalizeImportUrl(body.url);
|
||||
const { tempPath, filename } = await downloadImportToTemp(url, body);
|
||||
try {
|
||||
if (path.extname(filename).toLowerCase() !== ".zip") throw new HttpError(415, "Imported file is not a zip");
|
||||
const entries = await listZipEntries(tempPath);
|
||||
if (!entries.length) throw new HttpError(400, "No files found in zip");
|
||||
res.json({ filename, entries });
|
||||
} finally {
|
||||
await fs.rm(tempPath, { force: true }).catch(() => undefined);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const zipExtractRequestSchema = importRequestSchema.extend({ entries: z.array(z.string()) });
|
||||
router.post(
|
||||
"/import/zip",
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = parseBody(zipExtractRequestSchema, req.body);
|
||||
const url = await normalizeImportUrl(body.url);
|
||||
const { tempPath, filename } = await downloadImportToTemp(url, body);
|
||||
try {
|
||||
if (path.extname(filename).toLowerCase() !== ".zip") throw new HttpError(415, "Imported file is not a zip");
|
||||
const { prints, failed } = await extractZipEntriesToPrints(tempPath, body.entries, {
|
||||
title: body.title,
|
||||
notes: body.notes,
|
||||
tags: body.tags,
|
||||
folderId: body.folder_id,
|
||||
});
|
||||
res.json({ prints: prints.map((p) => toPrintOut(p, p.plates, [], null)), failed });
|
||||
} finally {
|
||||
await fs.rm(tempPath, { force: true }).catch(() => undefined);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,173 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { Router } from "express";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "../db";
|
||||
import { requireAuth } from "../auth";
|
||||
import { HttpError, sanitizeFilename, mimeFromContentType } from "../utils/fileUtils";
|
||||
import { parseBody } from "../utils/validate";
|
||||
import { asyncHandler } from "../utils/asyncHandler";
|
||||
import { modelUpload, thumbnailUpload } from "../uploadMiddleware";
|
||||
import {
|
||||
addPlatesToPrint,
|
||||
deletePlateFiles,
|
||||
refreshAutoPreparedMetadata,
|
||||
type NewPlateInput,
|
||||
} from "../services/printCreation";
|
||||
import { availablePlateFilename, plateThumbPath, relocatePrint, saveThumbFromBytes } from "../services/printService";
|
||||
import { printOutById } from "../services/printLoader";
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
// ---- POST /print/:id/plates (append) --------------------------------------------------------
|
||||
|
||||
router.post(
|
||||
"/print/:id/plates",
|
||||
modelUpload.array("files"),
|
||||
asyncHandler(async (req, res) => {
|
||||
const files = (req.files as Express.Multer.File[]) || [];
|
||||
try {
|
||||
if (!files.length) throw new HttpError(400, "No files uploaded");
|
||||
const print = await prisma.print.findUnique({ where: { id: req.params.id } });
|
||||
if (!print) throw new HttpError(404, "Print not found");
|
||||
|
||||
const plateInputs: NewPlateInput[] = files.map((f) => {
|
||||
const safeName = sanitizeFilename(f.originalname);
|
||||
return { filename: safeName, mime: mimeFromContentType(f.mimetype, safeName), tempFilePath: f.path };
|
||||
});
|
||||
await addPlatesToPrint(print.id, plateInputs);
|
||||
res.json({ print: await printOutById(print.id) });
|
||||
} finally {
|
||||
for (const f of files) {
|
||||
if (fs.existsSync(f.path)) fs.rmSync(f.path, { force: true });
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// ---- DELETE /print/:id/plates/:plateId --------------------------------------------------------
|
||||
|
||||
router.delete(
|
||||
"/print/:id/plates/:plateId",
|
||||
asyncHandler(async (req, res) => {
|
||||
const print = await prisma.print.findUnique({ where: { id: req.params.id } });
|
||||
if (!print) throw new HttpError(404, "Print not found");
|
||||
const plates = await prisma.plate.findMany({ where: { printId: print.id }, orderBy: { position: "asc" } });
|
||||
const target = plates.find((p) => p.id === req.params.plateId);
|
||||
if (!target) throw new HttpError(404, "Plate not found");
|
||||
if (plates.length <= 1) {
|
||||
throw new HttpError(409, "Cannot remove the only plate of a print; delete the print instead.");
|
||||
}
|
||||
|
||||
const remaining = plates.filter((p) => p.id !== target.id);
|
||||
// Delete the target first to free its position slot, then renumber densely via the same
|
||||
// two-phase (negative temp position) trick as reorder, to avoid transient collisions with
|
||||
// the (printId, position) unique index regardless of update ordering.
|
||||
await prisma.plate.delete({ where: { id: target.id } });
|
||||
await prisma.$transaction(
|
||||
remaining.map((p, idx) => prisma.plate.update({ where: { id: p.id }, data: { position: -(idx + 1) } })),
|
||||
);
|
||||
await prisma.$transaction(
|
||||
remaining.map((p, idx) => prisma.plate.update({ where: { id: p.id }, data: { position: idx } })),
|
||||
);
|
||||
await deletePlateFiles(target);
|
||||
await fs.promises.rm(plateThumbPath(target.id), { force: true }).catch(() => undefined);
|
||||
|
||||
if (target.position === 0) {
|
||||
await refreshAutoPreparedMetadata(print.id);
|
||||
}
|
||||
res.json({ print: await printOutById(print.id) });
|
||||
}),
|
||||
);
|
||||
|
||||
// ---- POST /print/:id/plates/reorder ------------------------------------------------------------
|
||||
|
||||
const reorderSchema = z.object({ plate_ids: z.array(z.string()).min(1) });
|
||||
router.post(
|
||||
"/print/:id/plates/reorder",
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = parseBody(reorderSchema, req.body);
|
||||
const print = await prisma.print.findUnique({ where: { id: req.params.id } });
|
||||
if (!print) throw new HttpError(404, "Print not found");
|
||||
const plates = await prisma.plate.findMany({ where: { printId: print.id } });
|
||||
const byId = new Map(plates.map((p) => [p.id, p]));
|
||||
if (body.plate_ids.length !== plates.length || body.plate_ids.some((id) => !byId.has(id))) {
|
||||
throw new HttpError(400, "plate_ids must contain exactly the print's current plate ids");
|
||||
}
|
||||
const previousFirst = plates.toSorted((a, b) => a.position - b.position)[0]?.id;
|
||||
|
||||
// Two-phase update avoids transient collisions with the (printId, position) unique index.
|
||||
await prisma.$transaction(
|
||||
body.plate_ids.map((id, idx) => prisma.plate.update({ where: { id }, data: { position: -(idx + 1) } })),
|
||||
);
|
||||
await prisma.$transaction(
|
||||
body.plate_ids.map((id, idx) => prisma.plate.update({ where: { id }, data: { position: idx } })),
|
||||
);
|
||||
|
||||
if (previousFirst !== body.plate_ids[0]) {
|
||||
await refreshAutoPreparedMetadata(print.id);
|
||||
}
|
||||
res.json({ print: await printOutById(print.id) });
|
||||
}),
|
||||
);
|
||||
|
||||
// ---- POST /print/:id/plate/:plateId/rename -----------------------------------------------------
|
||||
|
||||
const renameSchema = z.object({ filename: z.string().min(1) });
|
||||
router.post(
|
||||
"/print/:id/plate/:plateId/rename",
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = parseBody(renameSchema, req.body);
|
||||
const print = await prisma.print.findUnique({ where: { id: req.params.id } });
|
||||
if (!print) throw new HttpError(404, "Print not found");
|
||||
const plate = await prisma.plate.findUnique({ where: { id: req.params.plateId } });
|
||||
if (!plate || plate.printId !== print.id) throw new HttpError(404, "Plate not found");
|
||||
|
||||
const sanitized = sanitizeFilename(body.filename);
|
||||
const nextFilename = await availablePlateFilename(print.id, sanitized, plate.id);
|
||||
if (nextFilename !== plate.filename) {
|
||||
await prisma.plate.update({ where: { id: plate.id }, data: { filename: nextFilename } });
|
||||
await relocatePrint(print, [{ ...plate, filename: nextFilename }]);
|
||||
if (plate.position === 0) {
|
||||
await refreshAutoPreparedMetadata(print.id);
|
||||
}
|
||||
}
|
||||
res.json({ print: await printOutById(print.id) });
|
||||
}),
|
||||
);
|
||||
|
||||
// ---- Plate thumbnails --------------------------------------------------------------------------
|
||||
|
||||
router.get(
|
||||
"/plate/:plateId/thumb.jpg",
|
||||
asyncHandler(async (req, res) => {
|
||||
const plate = await prisma.plate.findUnique({ where: { id: req.params.plateId } });
|
||||
if (!plate) throw new HttpError(404, "Not found");
|
||||
const thumbPath = plateThumbPath(plate.id);
|
||||
if (!fs.existsSync(thumbPath)) throw new HttpError(404, "Not found");
|
||||
res.setHeader("Content-Type", "image/jpeg");
|
||||
res.setHeader("Cache-Control", "private, max-age=31536000, immutable");
|
||||
res.sendFile(path.resolve(thumbPath));
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/plate/:plateId/thumbnail-generated",
|
||||
thumbnailUpload.single("file"),
|
||||
asyncHandler(async (req, res) => {
|
||||
const file = req.file;
|
||||
if (!file) throw new HttpError(400, "No file uploaded");
|
||||
if (!["image/png", "image/jpeg", "image/webp"].includes((file.mimetype || "").toLowerCase())) {
|
||||
throw new HttpError(415, "Generated thumbnail must be PNG, JPEG, or WebP");
|
||||
}
|
||||
if (file.size > 8 * 1024 * 1024) throw new HttpError(413, "Generated thumbnail exceeds 8 MB");
|
||||
const plate = await prisma.plate.findUnique({ where: { id: req.params.plateId } });
|
||||
if (!plate) throw new HttpError(404, "Not found");
|
||||
const ok = await saveThumbFromBytes(plate.id, file.buffer);
|
||||
if (!ok) throw new HttpError(400, "Invalid thumbnail image");
|
||||
res.json({ print: await printOutById(plate.printId) });
|
||||
}),
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,95 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { Router } from "express";
|
||||
import { prisma } from "../db";
|
||||
import { requireAuth } from "../auth";
|
||||
import { HttpError } from "../utils/fileUtils";
|
||||
import { asyncHandler } from "../utils/asyncHandler";
|
||||
import { modelUpload } from "../uploadMiddleware";
|
||||
import {
|
||||
deletePreparedFile,
|
||||
deleteSupportingFile,
|
||||
listSupportingFiles,
|
||||
managedPrintFilePath,
|
||||
saveFileFromTemp,
|
||||
} from "../services/printFileService";
|
||||
import { toPrintFileOut } from "../dto";
|
||||
import { printOutById } from "../services/printLoader";
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
router.get(
|
||||
"/print/:id/files",
|
||||
asyncHandler(async (req, res) => {
|
||||
const print = await prisma.print.findUnique({ where: { id: req.params.id } });
|
||||
if (!print) throw new HttpError(404, "Print not found");
|
||||
const files = await listSupportingFiles(print.id);
|
||||
res.json(files.map(toPrintFileOut));
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/print/:id/files",
|
||||
modelUpload.single("file"),
|
||||
asyncHandler(async (req, res) => {
|
||||
const file = req.file;
|
||||
try {
|
||||
if (!file) throw new HttpError(400, "No file uploaded");
|
||||
const print = await prisma.print.findUnique({ where: { id: req.params.id } });
|
||||
if (!print) throw new HttpError(404, "Print not found");
|
||||
await saveFileFromTemp(print.id, file.path, file.originalname || "supporting-file", file.mimetype);
|
||||
res.json({ print: await printOutById(print.id) });
|
||||
} finally {
|
||||
if (file && fs.existsSync(file.path)) fs.rmSync(file.path, { force: true });
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/print/:id/files/:fileId",
|
||||
asyncHandler(async (req, res) => {
|
||||
const record = await prisma.printFile.findUnique({ where: { id: req.params.fileId } });
|
||||
if (!record || record.printId !== req.params.id || record.role !== "SUPPORTING") {
|
||||
throw new HttpError(404, "Not found");
|
||||
}
|
||||
const filePath = managedPrintFilePath(record);
|
||||
if (!fs.existsSync(filePath)) throw new HttpError(404, "Not found");
|
||||
res.setHeader("Content-Type", record.mime || "application/octet-stream");
|
||||
res.setHeader("Content-Disposition", `attachment; filename="${encodeURIComponent(record.filename)}"`);
|
||||
res.sendFile(path.resolve(filePath));
|
||||
}),
|
||||
);
|
||||
|
||||
router.delete(
|
||||
"/print/:id/files/:fileId",
|
||||
asyncHandler(async (req, res) => {
|
||||
const print = await deleteSupportingFile(req.params.id, req.params.fileId);
|
||||
res.json({ print: await printOutById(print.id) });
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/print/:id/prepared-print",
|
||||
asyncHandler(async (req, res) => {
|
||||
const print = await prisma.print.findUnique({ where: { id: req.params.id } });
|
||||
if (!print || !print.preparedFileId) throw new HttpError(404, "Not found");
|
||||
const record = await prisma.printFile.findUnique({ where: { id: print.preparedFileId } });
|
||||
if (!record || record.printId !== print.id || record.role !== "PREPARED") throw new HttpError(404, "Not found");
|
||||
const filePath = managedPrintFilePath(record);
|
||||
if (!fs.existsSync(filePath)) throw new HttpError(404, "Not found");
|
||||
res.setHeader("Content-Type", record.mime || "application/octet-stream");
|
||||
res.setHeader("Content-Disposition", `attachment; filename="${encodeURIComponent(record.filename)}"`);
|
||||
res.sendFile(path.resolve(filePath));
|
||||
}),
|
||||
);
|
||||
|
||||
router.delete(
|
||||
"/print/:id/prepared-print",
|
||||
asyncHandler(async (req, res) => {
|
||||
const print = await deletePreparedFile(req.params.id);
|
||||
res.json({ print: await printOutById(print.id) });
|
||||
}),
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,340 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { Router, type Request } from "express";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "../db";
|
||||
import { requireAuth } from "../auth";
|
||||
import { HttpError, sanitizeFilename, mimeFromContentType } from "../utils/fileUtils";
|
||||
import { parseBody } from "../utils/validate";
|
||||
import { asyncHandler } from "../utils/asyncHandler";
|
||||
import { modelUpload } from "../uploadMiddleware";
|
||||
import { createPrint, deletePlateFiles, resolvePlateFilePath, type NewPlateInput } from "../services/printCreation";
|
||||
import { plateThumbPath, relocatePrint, uniqueModelName } from "../services/printService";
|
||||
import { toPrintOut } from "../dto";
|
||||
import { loadFullPrint, printOutById } from "../services/printLoader";
|
||||
import { deleteAllPrintFiles } from "../services/printFileService";
|
||||
import { sendPrintsZip } from "../services/downloadZip";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
function buildPrintWhere(req: Request): Prisma.PrintWhereInput {
|
||||
const q = typeof req.query.q === "string" ? req.query.q.trim() : "";
|
||||
const tagsParam = typeof req.query.tags === "string" ? req.query.tags : "";
|
||||
const folderId = typeof req.query.folder_id === "string" ? req.query.folder_id : undefined;
|
||||
|
||||
const where: Prisma.PrintWhereInput = {};
|
||||
if (folderId) where.folderId = folderId;
|
||||
const tagList = tagsParam
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
if (tagList.length) where.tags = { hasEvery: tagList };
|
||||
if (q) {
|
||||
where.OR = [
|
||||
{ name: { contains: q, mode: "insensitive" } },
|
||||
{ title: { contains: q, mode: "insensitive" } },
|
||||
{ notes: { contains: q, mode: "insensitive" } },
|
||||
{ creator: { contains: q, mode: "insensitive" } },
|
||||
{ collection: { contains: q, mode: "insensitive" } },
|
||||
];
|
||||
}
|
||||
return where;
|
||||
}
|
||||
|
||||
// ---- POST /upload ---------------------------------------------------------------------------
|
||||
|
||||
router.post(
|
||||
"/upload",
|
||||
modelUpload.array("files"),
|
||||
asyncHandler(async (req, res) => {
|
||||
const files = (req.files as Express.Multer.File[]) || [];
|
||||
const body = req.body as Record<string, string | undefined>;
|
||||
try {
|
||||
if (!files.length) throw new HttpError(400, "No files uploaded");
|
||||
const mode = body.mode === "multiplate" ? "multiplate" : "separate";
|
||||
if (files.length > 1 && body.mode !== "separate" && body.mode !== "multiplate") {
|
||||
throw new HttpError(400, "mode is required when uploading more than one file");
|
||||
}
|
||||
const tags = (body.tags || "")
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
const meta = {
|
||||
title: body.title || null,
|
||||
notes: body.notes || null,
|
||||
tags,
|
||||
folderId: body.folder_id || null,
|
||||
};
|
||||
|
||||
const printsOut = [];
|
||||
if (files.length === 1 || mode === "separate") {
|
||||
for (const file of files) {
|
||||
const safeName = sanitizeFilename(file.originalname);
|
||||
const mime = mimeFromContentType(file.mimetype, safeName);
|
||||
const { print, plates } = await createPrint(meta, path.parse(safeName).name, [
|
||||
{ filename: safeName, mime, tempFilePath: file.path },
|
||||
]);
|
||||
printsOut.push(toPrintOut(print, plates, [], null));
|
||||
}
|
||||
} else {
|
||||
const plateInputs: NewPlateInput[] = files.map((f) => {
|
||||
const safeName = sanitizeFilename(f.originalname);
|
||||
return { filename: safeName, mime: mimeFromContentType(f.mimetype, safeName), tempFilePath: f.path };
|
||||
});
|
||||
const nameHint = path.parse(plateInputs[0].filename).name;
|
||||
const { print, plates } = await createPrint(meta, nameHint, plateInputs);
|
||||
printsOut.push(toPrintOut(print, plates, [], null));
|
||||
}
|
||||
res.json({ prints: printsOut });
|
||||
} finally {
|
||||
for (const f of files) {
|
||||
if (fs.existsSync(f.path)) fs.rmSync(f.path, { force: true });
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// ---- GET /prints ------------------------------------------------------------------------------
|
||||
|
||||
router.get(
|
||||
"/prints",
|
||||
asyncHandler(async (req, res) => {
|
||||
const limitRaw = req.query.limit;
|
||||
const offsetRaw = req.query.offset;
|
||||
let limit: number | undefined;
|
||||
let offset: number | undefined;
|
||||
if (limitRaw !== undefined) {
|
||||
limit = Number(limitRaw);
|
||||
if (!Number.isFinite(limit) || limit < 1 || limit > 1000) throw new HttpError(400, "Invalid limit");
|
||||
}
|
||||
if (offsetRaw !== undefined) {
|
||||
offset = Number(offsetRaw);
|
||||
if (!Number.isFinite(offset) || offset < 0) throw new HttpError(400, "Invalid offset");
|
||||
}
|
||||
|
||||
const where = buildPrintWhere(req);
|
||||
const prints = await prisma.print.findMany({ where, include: { plates: { orderBy: { position: "asc" } } } });
|
||||
const printIds = prints.map((p) => p.id);
|
||||
const files = printIds.length ? await prisma.printFile.findMany({ where: { printId: { in: printIds } } }) : [];
|
||||
const filesByPrint = new Map<string, typeof files>();
|
||||
for (const f of files) {
|
||||
const list = filesByPrint.get(f.printId) ?? [];
|
||||
list.push(f);
|
||||
filesByPrint.set(f.printId, list);
|
||||
}
|
||||
|
||||
const sorted = prints.toSorted((a, b) => {
|
||||
const nameCmp = a.name.localeCompare(b.name);
|
||||
if (nameCmp !== 0) return nameCmp;
|
||||
const af = a.plates[0]?.filename ?? "";
|
||||
const bf = b.plates[0]?.filename ?? "";
|
||||
const fCmp = af.localeCompare(bf);
|
||||
if (fCmp !== 0) return fCmp;
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
|
||||
let paged = sorted;
|
||||
if (limit !== undefined) {
|
||||
const start = offset ?? 0;
|
||||
paged = sorted.slice(start, start + limit);
|
||||
const hasMore = sorted.length > start + paged.length;
|
||||
res.setHeader("X-Has-More", hasMore ? "true" : "false");
|
||||
res.setHeader("X-Next-Offset", String(start + paged.length));
|
||||
}
|
||||
|
||||
const out = paged.map((p) => {
|
||||
const printFiles = filesByPrint.get(p.id) ?? [];
|
||||
const preparedFile = p.preparedFileId ? printFiles.find((f) => f.id === p.preparedFileId) ?? null : null;
|
||||
return toPrintOut(p, p.plates, printFiles, preparedFile);
|
||||
});
|
||||
res.json(out);
|
||||
}),
|
||||
);
|
||||
|
||||
// ---- GET /tags ----------------------------------------------------------------------------
|
||||
|
||||
router.get(
|
||||
"/tags",
|
||||
asyncHandler(async (req, res) => {
|
||||
const where = buildPrintWhere(req);
|
||||
const rows = await prisma.print.findMany({ where, select: { tags: true } });
|
||||
const found = new Set<string>();
|
||||
for (const row of rows) {
|
||||
for (const tag of row.tags) {
|
||||
const cleaned = tag.trim();
|
||||
if (cleaned) found.add(cleaned);
|
||||
}
|
||||
}
|
||||
res.json([...found].toSorted((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())));
|
||||
}),
|
||||
);
|
||||
|
||||
// ---- POST /download/zip --------------------------------------------------------------------
|
||||
|
||||
const downloadSchema = z.object({
|
||||
print_ids: z.array(z.string()).optional(),
|
||||
tag: z.string().optional(),
|
||||
folder_id: z.string().optional(),
|
||||
filename: z.string().optional(),
|
||||
});
|
||||
|
||||
router.post(
|
||||
"/download/zip",
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = parseBody(downloadSchema, req.body);
|
||||
if (!(body.print_ids?.length || body.tag || body.folder_id)) {
|
||||
throw new HttpError(400, "Provide print_ids, tag, or folder_id to download.");
|
||||
}
|
||||
const where: Prisma.PrintWhereInput = {};
|
||||
if (body.print_ids?.length) where.id = { in: body.print_ids };
|
||||
if (body.folder_id) where.folderId = body.folder_id;
|
||||
let prints = await prisma.print.findMany({
|
||||
where,
|
||||
include: { plates: { orderBy: { position: "asc" } }, folder: true },
|
||||
});
|
||||
if (body.tag) {
|
||||
const tag = body.tag.trim();
|
||||
prints = prints.filter((p) => p.tags.includes(tag));
|
||||
}
|
||||
|
||||
let downloadName = body.filename || "printstash.zip";
|
||||
if (body.tag) {
|
||||
const safeTag = body.tag.replace(/ /g, "_").slice(0, 50) || "tag";
|
||||
downloadName = `${safeTag}.zip`;
|
||||
}
|
||||
if (body.folder_id) {
|
||||
const folder = prints.find((p) => p.folder)?.folder;
|
||||
if (folder) {
|
||||
const safeName = folder.name.replace(/ /g, "_").slice(0, 50) || "folder";
|
||||
downloadName = `${safeName}.zip`;
|
||||
}
|
||||
}
|
||||
await sendPrintsZip(res, prints, downloadName);
|
||||
}),
|
||||
);
|
||||
|
||||
// ---- Plate file streaming + print thumbnail -----------------------------------------------
|
||||
|
||||
router.get(
|
||||
"/print/:id/plate/:plateId/file/:filename",
|
||||
asyncHandler(async (req, res) => {
|
||||
const plate = await prisma.plate.findUnique({ where: { id: req.params.plateId } });
|
||||
if (!plate || plate.printId !== req.params.id) throw new HttpError(404, "Not found");
|
||||
const filePath = resolvePlateFilePath(plate);
|
||||
if (!filePath) throw new HttpError(404, "Not found");
|
||||
res.setHeader("Content-Type", plate.mime || "application/octet-stream");
|
||||
res.setHeader("Content-Disposition", `attachment; filename="${encodeURIComponent(plate.filename)}"`);
|
||||
res.setHeader("Cache-Control", "private, max-age=86400");
|
||||
res.sendFile(path.resolve(filePath));
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/print/:id/thumb.jpg",
|
||||
asyncHandler(async (req, res) => {
|
||||
const plate0 = await prisma.plate.findFirst({ where: { printId: req.params.id }, orderBy: { position: "asc" } });
|
||||
if (!plate0) throw new HttpError(404, "Not found");
|
||||
const thumbPath = plateThumbPath(plate0.id);
|
||||
if (!fs.existsSync(thumbPath)) throw new HttpError(404, "Not found");
|
||||
res.setHeader("Content-Type", "image/jpeg");
|
||||
res.setHeader("Cache-Control", "private, max-age=31536000, immutable");
|
||||
res.sendFile(path.resolve(thumbPath));
|
||||
}),
|
||||
);
|
||||
|
||||
// ---- Print metadata mutation routes ---------------------------------------------------------
|
||||
|
||||
const tagsSchema = z.object({ tags: z.array(z.string()) });
|
||||
router.post(
|
||||
"/print/:id/tags",
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = parseBody(tagsSchema, req.body);
|
||||
const print = await prisma.print.findUnique({ where: { id: req.params.id } });
|
||||
if (!print) throw new HttpError(404, "Print not found");
|
||||
const updated = await prisma.print.update({
|
||||
where: { id: print.id },
|
||||
data: { tags: body.tags.map((t) => t.trim()).filter(Boolean) },
|
||||
});
|
||||
const plates = await prisma.plate.findMany({ where: { printId: print.id }, orderBy: { position: "asc" } });
|
||||
await relocatePrint(updated, plates);
|
||||
res.json({ print: await printOutById(print.id) });
|
||||
}),
|
||||
);
|
||||
|
||||
const metaSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
title: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
creator: z.string().optional(),
|
||||
collection: z.string().optional(),
|
||||
});
|
||||
router.post(
|
||||
"/print/:id/meta",
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = parseBody(metaSchema, req.body);
|
||||
const print = await prisma.print.findUnique({ where: { id: req.params.id } });
|
||||
if (!print) throw new HttpError(404, "Print not found");
|
||||
|
||||
const data: Prisma.PrintUpdateInput = {};
|
||||
const requestedName = body.name !== undefined ? body.name : body.title;
|
||||
if (requestedName !== undefined) {
|
||||
const nextName = await uniqueModelName(requestedName, print.folderId, print.id);
|
||||
if (nextName !== print.name) {
|
||||
data.name = nextName;
|
||||
data.nameNormalized = nextName.trim().toLowerCase();
|
||||
data.title = nextName;
|
||||
}
|
||||
}
|
||||
if (body.notes !== undefined) data.notes = body.notes;
|
||||
if (body.creator !== undefined) data.creator = body.creator.trim() || null;
|
||||
if (body.collection !== undefined) data.collection = body.collection.trim() || null;
|
||||
|
||||
const updated = await prisma.print.update({ where: { id: print.id }, data });
|
||||
const plates = await prisma.plate.findMany({ where: { printId: print.id }, orderBy: { position: "asc" } });
|
||||
await relocatePrint(updated, plates);
|
||||
res.json({ print: await printOutById(print.id) });
|
||||
}),
|
||||
);
|
||||
|
||||
const folderUpdateSchema = z.object({ folder_id: z.string().nullable().optional() });
|
||||
router.post(
|
||||
"/print/:id/folder",
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = parseBody(folderUpdateSchema, req.body);
|
||||
const print = await prisma.print.findUnique({ where: { id: req.params.id } });
|
||||
if (!print) throw new HttpError(404, "Print not found");
|
||||
const folderId = body.folder_id || null;
|
||||
const data: Prisma.PrintUpdateInput = {};
|
||||
if (folderId) {
|
||||
const folder = await prisma.folder.findUnique({ where: { id: folderId } });
|
||||
if (!folder) throw new HttpError(400, "Folder not found");
|
||||
await uniqueModelName(print.name, folder.id, print.id);
|
||||
data.folder = { connect: { id: folder.id } };
|
||||
} else {
|
||||
await uniqueModelName(print.name, null, print.id);
|
||||
data.folder = { disconnect: true };
|
||||
}
|
||||
const updated = await prisma.print.update({ where: { id: print.id }, data });
|
||||
const plates = await prisma.plate.findMany({ where: { printId: print.id }, orderBy: { position: "asc" } });
|
||||
await relocatePrint(updated, plates);
|
||||
res.json({ print: await printOutById(print.id) });
|
||||
}),
|
||||
);
|
||||
|
||||
router.delete(
|
||||
"/print/:id",
|
||||
asyncHandler(async (req, res) => {
|
||||
const full = await loadFullPrint(req.params.id);
|
||||
await deleteAllPrintFiles(req.params.id);
|
||||
await prisma.print.delete({ where: { id: req.params.id } });
|
||||
for (const plate of full.plates) {
|
||||
await deletePlateFiles(plate);
|
||||
await fs.promises.rm(plateThumbPath(plate.id), { force: true }).catch(() => undefined);
|
||||
}
|
||||
res.json({ ok: true });
|
||||
}),
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Router } from "express";
|
||||
import { z } from "zod";
|
||||
import { requireAuth } from "../auth";
|
||||
import { MOUNT_IMPORT_COPY, MOUNT_IMPORT_ENABLED, MOUNT_IMPORT_PATH } from "../config";
|
||||
import { parseBody } from "../utils/validate";
|
||||
import { asyncHandler } from "../utils/asyncHandler";
|
||||
import { getMountImportCopy, getMountImportEnabled, setMountImportCopy, setMountImportEnabled } from "../services/settingsService";
|
||||
import {
|
||||
DEFAULT_STORAGE_TEMPLATE,
|
||||
STORAGE_TEMPLATE_TOKENS,
|
||||
getStorageTemplate,
|
||||
reorganizeManagedPrints,
|
||||
samplePlateStoragePaths,
|
||||
setStorageTemplate,
|
||||
validateStorageTemplate,
|
||||
} from "../services/printService";
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
router.get(
|
||||
"/settings/mount-import",
|
||||
asyncHandler(async (_req, res) => {
|
||||
res.json({
|
||||
enabled: await getMountImportEnabled(MOUNT_IMPORT_ENABLED),
|
||||
copy_files: await getMountImportCopy(MOUNT_IMPORT_COPY),
|
||||
path: MOUNT_IMPORT_PATH || null,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const mountImportSchema = z.object({ enabled: z.boolean(), copy_files: z.boolean() });
|
||||
router.post(
|
||||
"/settings/mount-import",
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = parseBody(mountImportSchema, req.body);
|
||||
await setMountImportEnabled(body.enabled);
|
||||
await setMountImportCopy(body.copy_files);
|
||||
res.json({
|
||||
enabled: await getMountImportEnabled(MOUNT_IMPORT_ENABLED),
|
||||
copy_files: await getMountImportCopy(MOUNT_IMPORT_COPY),
|
||||
path: MOUNT_IMPORT_PATH || null,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
function storageSettingsOut(template: string, moved = 0, skipped = 0) {
|
||||
return {
|
||||
template,
|
||||
default_template: DEFAULT_STORAGE_TEMPLATE,
|
||||
allowed_tokens: [...STORAGE_TEMPLATE_TOKENS],
|
||||
plate_paths: samplePlateStoragePaths(template),
|
||||
moved,
|
||||
skipped,
|
||||
};
|
||||
}
|
||||
|
||||
router.get(
|
||||
"/settings/storage",
|
||||
asyncHandler(async (_req, res) => {
|
||||
const template = validateStorageTemplate(await getStorageTemplate());
|
||||
res.json(storageSettingsOut(template));
|
||||
}),
|
||||
);
|
||||
|
||||
const storageSettingsSchema = z.object({ template: z.string(), apply_existing: z.boolean().default(false) });
|
||||
router.post(
|
||||
"/settings/storage",
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = parseBody(storageSettingsSchema, req.body);
|
||||
const template = validateStorageTemplate(body.template);
|
||||
await setStorageTemplate(template);
|
||||
const { moved, skipped } = body.apply_existing ? await reorganizeManagedPrints(template) : { moved: 0, skipped: 0 };
|
||||
res.json(storageSettingsOut(template, moved, skipped));
|
||||
}),
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,14 @@
|
||||
import { createApp } from "./app";
|
||||
import { API_PORT } from "./config";
|
||||
import { scanMountImports } from "./services/mountImport";
|
||||
import "./db"; // ensures storage directories exist before we start serving
|
||||
|
||||
const app = createApp();
|
||||
|
||||
app.listen(API_PORT, () => {
|
||||
console.log(`PrintStash API listening on port ${API_PORT}`);
|
||||
// Fire-and-forget background scan, mirrors MakersVault's lifespan background thread.
|
||||
scanMountImports().catch((err) => {
|
||||
console.error("[mount-import] Unhandled error during scan:", err);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import type { Response } from "express";
|
||||
import { prisma } from "../db";
|
||||
import { HttpError } from "../utils/fileUtils";
|
||||
import { writeZip, type ZipEntryDescriptor } from "../utils/zipWriter";
|
||||
import { resolvePlateFilePath } from "./printCreation";
|
||||
import { managedPrintFilePath } from "./printFileService";
|
||||
import type { Folder, Plate, Print } from "@prisma/client";
|
||||
|
||||
type PrintWithPlatesAndFolder = Print & { plates: Plate[]; folder: Folder | null };
|
||||
|
||||
/**
|
||||
* Builds zip entries for a set of prints: `{folder_or_unassigned}/{print.name}/{plate.filename}`
|
||||
* for every plate, and `.../supporting/{file.filename}` for each print's supporting files.
|
||||
*/
|
||||
export async function buildZipEntries(prints: PrintWithPlatesAndFolder[]): Promise<ZipEntryDescriptor[]> {
|
||||
const printIds = prints.map((p) => p.id);
|
||||
const supportingByPrint = new Map<string, { filename: string; storagePath: string }[]>();
|
||||
if (printIds.length) {
|
||||
const supporting = await prisma.printFile.findMany({
|
||||
where: { printId: { in: printIds }, role: "SUPPORTING" },
|
||||
});
|
||||
for (const file of supporting) {
|
||||
const list = supportingByPrint.get(file.printId) ?? [];
|
||||
list.push({ filename: file.filename, storagePath: file.storagePath });
|
||||
supportingByPrint.set(file.printId, list);
|
||||
}
|
||||
}
|
||||
|
||||
const entries: ZipEntryDescriptor[] = [];
|
||||
for (const print of prints) {
|
||||
const folderPart = print.folder?.name || "unassigned";
|
||||
const sortedPlates = print.plates.toSorted((a, b) => a.position - b.position);
|
||||
for (const plate of sortedPlates) {
|
||||
const filePath = resolvePlateFilePath(plate);
|
||||
if (filePath) entries.push({ arcname: `${folderPart}/${print.name}/${plate.filename}`, filePath });
|
||||
}
|
||||
for (const file of supportingByPrint.get(print.id) ?? []) {
|
||||
const filePath = managedPrintFilePath(file);
|
||||
if (fs.existsSync(filePath)) {
|
||||
entries.push({ arcname: `${folderPart}/${print.name}/supporting/${file.filename}`, filePath });
|
||||
}
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** Builds a zip from `prints` and streams it as the HTTP response, deleting the temp file after. */
|
||||
export async function sendPrintsZip(res: Response, prints: PrintWithPlatesAndFolder[], downloadName: string): Promise<void> {
|
||||
const entries = await buildZipEntries(prints);
|
||||
if (!entries.length) throw new HttpError(404, "No files available for download");
|
||||
|
||||
const tmpPath = path.join(os.tmpdir(), `printstash-zip-${crypto.randomBytes(8).toString("hex")}.zip`);
|
||||
await writeZip(tmpPath, entries);
|
||||
res.download(tmpPath, downloadName, (err) => {
|
||||
fs.rm(tmpPath, { force: true }, () => undefined);
|
||||
if (err && !res.headersSent) {
|
||||
res.status(500).json({ detail: "Failed to send zip file" });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { prisma } from "../db";
|
||||
import { HttpError } from "../utils/fileUtils";
|
||||
|
||||
/** Ensures a parent folder exists and that assigning it does not introduce a cycle. */
|
||||
export async function validateParentFolder(
|
||||
parentId: string | null | undefined,
|
||||
folderId?: string | null,
|
||||
): Promise<string | null> {
|
||||
if (!parentId) return null;
|
||||
const parent = await prisma.folder.findUnique({ where: { id: parentId } });
|
||||
if (!parent) throw new HttpError(400, "Parent folder not found");
|
||||
if (folderId && parentId === folderId) throw new HttpError(400, "Folder cannot be its own parent");
|
||||
|
||||
const visited = new Set<string>(folderId ? [folderId] : []);
|
||||
let ancestor = parent;
|
||||
while (ancestor && ancestor.parentId) {
|
||||
if (visited.has(ancestor.parentId)) throw new HttpError(400, "Invalid parent: would create a cycle");
|
||||
if (folderId && ancestor.parentId === folderId) throw new HttpError(400, "Invalid parent: would create a cycle");
|
||||
visited.add(ancestor.parentId);
|
||||
const next = await prisma.folder.findUnique({ where: { id: ancestor.parentId } });
|
||||
if (!next) break;
|
||||
ancestor = next;
|
||||
}
|
||||
return parentId;
|
||||
}
|
||||
@@ -0,0 +1,761 @@
|
||||
import path from "node:path";
|
||||
import * as cheerio from "cheerio";
|
||||
import {
|
||||
IMPORT_ALLOWED_EXTS,
|
||||
IMPORT_BLOCKED_EXTS,
|
||||
IMPORT_BROWSER_USER_AGENT,
|
||||
IMPORT_EXT_PRIORITY,
|
||||
IMPORT_HTML_MAX_BYTES,
|
||||
IMPORT_TIMEOUT_SECONDS,
|
||||
IMPORT_USER_AGENT,
|
||||
} from "../config";
|
||||
import { HttpError, isJsonContentType } from "../utils/fileUtils";
|
||||
|
||||
export type ImportCookies = {
|
||||
makerworld_cookie?: string | null;
|
||||
thingiverse_cookie?: string | null;
|
||||
};
|
||||
|
||||
function firstNonEmptyLine(raw: string): string | null {
|
||||
const lines = raw
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
if (!lines.length) return null;
|
||||
let value = lines[0];
|
||||
for (const line of lines) {
|
||||
if (line.toLowerCase().startsWith("cookie:")) {
|
||||
value = line.slice("cookie:".length).trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
return value || null;
|
||||
}
|
||||
|
||||
export function resolveMakerworldCookie(body: ImportCookies): string | null {
|
||||
const raw = (body.makerworld_cookie || process.env.MAKERWORLD_COOKIE || "").trim();
|
||||
if (!raw) return null;
|
||||
return firstNonEmptyLine(raw);
|
||||
}
|
||||
|
||||
export function resolveThingiverseCookie(body: ImportCookies): string | null {
|
||||
const raw = (body.thingiverse_cookie || process.env.THINGIVERSE_COOKIE || "").trim();
|
||||
if (!raw) return null;
|
||||
return firstNonEmptyLine(raw);
|
||||
}
|
||||
|
||||
export function makerworldHtmlHeaders(referer?: string | null, cookie?: string | null): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
"User-Agent": IMPORT_BROWSER_USER_AGENT,
|
||||
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
"Cache-Control": "no-cache",
|
||||
Pragma: "no-cache",
|
||||
"Upgrade-Insecure-Requests": "1",
|
||||
"Sec-Fetch-Dest": "document",
|
||||
"Sec-Fetch-Mode": "navigate",
|
||||
"Sec-Fetch-Site": "none",
|
||||
"Sec-Fetch-User": "?1",
|
||||
"sec-ch-ua": '"Not A(Brand";v="99", "Google Chrome";v="121", "Chromium";v="121"',
|
||||
"sec-ch-ua-mobile": "?0",
|
||||
"sec-ch-ua-platform": '"Windows"',
|
||||
};
|
||||
if (referer) headers.Referer = referer;
|
||||
if (cookie) headers.Cookie = cookie;
|
||||
return headers;
|
||||
}
|
||||
|
||||
function makerworldApiHeaders(referer?: string | null, nonce?: string | null, cookie?: string | null): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
"User-Agent": IMPORT_BROWSER_USER_AGENT,
|
||||
Accept: "application/json",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
"X-BBL-Client-Type": "web",
|
||||
"X-BBL-Client-Version": "00.00.00.01",
|
||||
"X-BBL-App-Source": "makerworld",
|
||||
"X-BBL-Client-Name": "MakerWorld",
|
||||
};
|
||||
if (referer) headers.Referer = referer;
|
||||
if (nonce) headers["X-Nonce"] = nonce;
|
||||
if (cookie) headers.Cookie = cookie;
|
||||
return headers;
|
||||
}
|
||||
|
||||
export function isPrintablesPageHost(host: string): boolean {
|
||||
const lowered = (host || "").toLowerCase();
|
||||
return lowered === "printables.com" || lowered === "www.printables.com";
|
||||
}
|
||||
|
||||
export function printablesHtmlHeaders(referer?: string | null): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
"User-Agent": IMPORT_BROWSER_USER_AGENT,
|
||||
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
"Cache-Control": "no-cache",
|
||||
Pragma: "no-cache",
|
||||
"Upgrade-Insecure-Requests": "1",
|
||||
};
|
||||
if (referer) headers.Referer = referer;
|
||||
return headers;
|
||||
}
|
||||
|
||||
export function isThingiversePageHost(host: string): boolean {
|
||||
const lowered = (host || "").toLowerCase();
|
||||
return lowered === "thingiverse.com" || lowered === "www.thingiverse.com";
|
||||
}
|
||||
|
||||
export function thingiverseHtmlHeaders(referer?: string | null, cookie?: string | null): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
"User-Agent": IMPORT_BROWSER_USER_AGENT,
|
||||
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
"Cache-Control": "no-cache",
|
||||
Pragma: "no-cache",
|
||||
"Upgrade-Insecure-Requests": "1",
|
||||
};
|
||||
if (referer) headers.Referer = referer;
|
||||
if (cookie) headers.Cookie = cookie;
|
||||
return headers;
|
||||
}
|
||||
|
||||
function thingiverseApiHeaders(referer?: string | null, cookie?: string | null, token?: string | null): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
"User-Agent": IMPORT_BROWSER_USER_AGENT,
|
||||
Accept: "application/json",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
Origin: "https://www.thingiverse.com",
|
||||
};
|
||||
if (referer) headers.Referer = referer;
|
||||
if (cookie) headers.Cookie = cookie;
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
return headers;
|
||||
}
|
||||
|
||||
// -- Small bounded fetch helpers (used only for resolver-side HTML/JSON probes; the actual
|
||||
// model-file download streams straight to disk in importService.ts, not through here). ------
|
||||
|
||||
async function fetchCappedBuffer(
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
init?: { method?: string; body?: string },
|
||||
): Promise<{ status: number; headers: Headers; buffer: Buffer; url: string } | null> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), IMPORT_TIMEOUT_SECONDS * 1000);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: init?.method || "GET",
|
||||
headers,
|
||||
body: init?.body,
|
||||
redirect: "follow",
|
||||
signal: controller.signal,
|
||||
});
|
||||
const arrayBuf = await res.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuf);
|
||||
return { status: res.status, headers: res.headers, buffer, url: res.url || url };
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function parseJsonErrorMessage(raw: Buffer): string | null {
|
||||
try {
|
||||
const data = JSON.parse(raw.toString("utf-8"));
|
||||
if (data && typeof data === "object" && !Array.isArray(data)) {
|
||||
for (const key of ["error", "detail", "message"]) {
|
||||
const value = (data as Record<string, unknown>)[key];
|
||||
if (typeof value === "string" && value.trim()) return value.trim();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function fetchJsonFromUrl(
|
||||
url: string,
|
||||
referer?: string | null,
|
||||
headers?: Record<string, string>,
|
||||
): Promise<unknown | null> {
|
||||
const requestHeaders: Record<string, string> = { "User-Agent": IMPORT_USER_AGENT, Accept: "application/json" };
|
||||
if (referer) requestHeaders.Referer = referer;
|
||||
if (headers) Object.assign(requestHeaders, headers);
|
||||
const result = await fetchCappedBuffer(url, requestHeaders);
|
||||
if (!result) return null;
|
||||
if (result.buffer.length > IMPORT_HTML_MAX_BYTES) return null;
|
||||
const contentType = result.headers.get("content-type") || "";
|
||||
if (!isJsonContentType(contentType)) {
|
||||
const trimmed = result.buffer.toString("utf-8").trimStart();
|
||||
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(result.buffer.toString("utf-8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// -- Generic download-link sniffing (non-site-specific) --------------------------------------
|
||||
|
||||
function scoreDownloadUrl(url: string): number {
|
||||
const lower = url.toLowerCase();
|
||||
let score = 0;
|
||||
if (lower.includes("download")) score += 6;
|
||||
if (lower.includes("files")) score += 2;
|
||||
IMPORT_EXT_PRIORITY.forEach((ext, idx) => {
|
||||
if (lower.includes(ext)) score += (IMPORT_EXT_PRIORITY.length - idx) * 10;
|
||||
});
|
||||
return score;
|
||||
}
|
||||
|
||||
function isPotentialUrl(value: string): boolean {
|
||||
if (/\s/.test(value)) return false;
|
||||
if (/[<>{}"\\^`]/.test(value)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function containsAllowedExt(url: string): boolean {
|
||||
const lower = url.toLowerCase();
|
||||
for (const ext of IMPORT_ALLOWED_EXTS) {
|
||||
const re = new RegExp(`${ext.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:$|[?#&])`);
|
||||
if (re.test(lower)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isAllowedDownloadCandidate(url: string): boolean {
|
||||
const lower = url.toLowerCase();
|
||||
let ext = "";
|
||||
try {
|
||||
ext = path.extname(new URL(url).pathname).toLowerCase();
|
||||
} catch {
|
||||
ext = "";
|
||||
}
|
||||
if (ext) {
|
||||
if (IMPORT_ALLOWED_EXTS.has(ext)) return true;
|
||||
if (IMPORT_BLOCKED_EXTS.has(ext)) return false;
|
||||
if (lower.includes("download")) return true;
|
||||
return containsAllowedExt(lower);
|
||||
}
|
||||
if (containsAllowedExt(lower)) return true;
|
||||
return lower.includes("download");
|
||||
}
|
||||
|
||||
/** Collects href/src/data-* link-ish attributes from every element in the page (a faithful
|
||||
* DOM-based port of MakersVault's LinkCollector, using cheerio instead of HTMLParser). */
|
||||
function collectPageLinks(html: string): string[] {
|
||||
const links: string[] = [];
|
||||
const attrNames = ["href", "src", "data-download", "data-download-url", "data-url", "data-file", "data-href"];
|
||||
try {
|
||||
const $ = cheerio.load(html);
|
||||
$("*").each((_, el) => {
|
||||
const attribs = (el as unknown as { attribs?: Record<string, string> }).attribs;
|
||||
if (!attribs) return;
|
||||
for (const name of attrNames) {
|
||||
const value = attribs[name];
|
||||
if (value) links.push(value);
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
// ignore malformed HTML, fall through with whatever we collected
|
||||
}
|
||||
return links;
|
||||
}
|
||||
|
||||
export function findDownloadUrl(html: string, baseUrl: string): string | null {
|
||||
const links = collectPageLinks(html);
|
||||
const urlMatches = html.match(/https?:\/\/[^\s"'<>]+/gi) || [];
|
||||
links.push(...urlMatches);
|
||||
|
||||
const candidates: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const rawLink of links) {
|
||||
const link = (rawLink || "").trim();
|
||||
if (!link || link.startsWith("#")) continue;
|
||||
if (/^javascript:/i.test(link) || /^mailto:/i.test(link)) continue;
|
||||
let absUrl: string;
|
||||
try {
|
||||
absUrl = new URL(link, baseUrl).toString();
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(absUrl);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") continue;
|
||||
if (seen.has(absUrl)) continue;
|
||||
seen.add(absUrl);
|
||||
if (isAllowedDownloadCandidate(absUrl)) candidates.push(absUrl);
|
||||
}
|
||||
if (!candidates.length) return null;
|
||||
candidates.sort((a, b) => scoreDownloadUrl(b) - scoreDownloadUrl(a));
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
function normalizeCandidateUrl(raw: string, baseUrl: string): string | null {
|
||||
const value = (raw || "").trim();
|
||||
if (!value) return null;
|
||||
if (!isPotentialUrl(value)) return null;
|
||||
let candidate = value;
|
||||
if (candidate.startsWith("//")) candidate = `https:${candidate}`;
|
||||
if (candidate.startsWith("http://") || candidate.startsWith("https://")) {
|
||||
return isAllowedDownloadCandidate(candidate) ? candidate : null;
|
||||
}
|
||||
if (candidate.startsWith("/") || candidate.includes("/")) {
|
||||
try {
|
||||
const abs = new URL(candidate, baseUrl).toString();
|
||||
return isAllowedDownloadCandidate(abs) ? abs : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findDownloadUrlInJson(data: unknown, baseUrl: string): string | null {
|
||||
const candidates: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
const stack: unknown[] = [data];
|
||||
while (stack.length) {
|
||||
const current = stack.pop();
|
||||
if (current && typeof current === "object" && !Array.isArray(current)) {
|
||||
stack.push(...Object.values(current as Record<string, unknown>));
|
||||
} else if (Array.isArray(current)) {
|
||||
stack.push(...current);
|
||||
} else if (typeof current === "string") {
|
||||
if (seen.has(current)) continue;
|
||||
seen.add(current);
|
||||
const url = normalizeCandidateUrl(current, baseUrl);
|
||||
if (url) candidates.push(url);
|
||||
}
|
||||
}
|
||||
if (!candidates.length) return null;
|
||||
candidates.sort((a, b) => scoreDownloadUrl(b) - scoreDownloadUrl(a));
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
function extractDownloadUrlFromResponse(data: unknown, baseUrl: string): string | null {
|
||||
if (data && typeof data === "object" && !Array.isArray(data)) {
|
||||
const dict = data as Record<string, unknown>;
|
||||
for (const key of ["url", "downloadUrl", "download_url"]) {
|
||||
const value = dict[key];
|
||||
if (typeof value === "string" && value.trim()) return value;
|
||||
}
|
||||
const inner = dict.data;
|
||||
if (inner && typeof inner === "object" && !Array.isArray(inner)) {
|
||||
const innerDict = inner as Record<string, unknown>;
|
||||
for (const key of ["url", "downloadUrl", "download_url"]) {
|
||||
const value = innerDict[key];
|
||||
if (typeof value === "string" && value.trim()) return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return findDownloadUrlInJson(data, baseUrl);
|
||||
}
|
||||
|
||||
// -- MakerWorld --------------------------------------------------------------------------------
|
||||
|
||||
function extractNextDataJson(html: string): unknown | null {
|
||||
const match = html.match(/<script[^>]+id=["']__NEXT_DATA__["'][^>]*>([\s\S]*?)<\/script>/i);
|
||||
if (!match) return null;
|
||||
const raw = (match[1] || "").trim();
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getPath(obj: unknown, ...keys: string[]): unknown {
|
||||
let current: unknown = obj;
|
||||
for (const key of keys) {
|
||||
if (!current || typeof current !== "object") return undefined;
|
||||
current = (current as Record<string, unknown>)[key];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function makerworldDesignIdFromNextData(data: unknown): string | null {
|
||||
const designId = getPath(data, "props", "pageProps", "design", "id");
|
||||
return designId ? String(designId) : null;
|
||||
}
|
||||
|
||||
function makerworldInstanceIdFromNextData(data: unknown): string | null {
|
||||
const design = getPath(data, "props", "pageProps", "design") as Record<string, unknown> | undefined;
|
||||
if (!design) return null;
|
||||
const defaultInstance = design.defaultInstanceId;
|
||||
if (defaultInstance) return String(defaultInstance);
|
||||
const instances = design.instances;
|
||||
if (Array.isArray(instances)) {
|
||||
for (const inst of instances) {
|
||||
if (inst && typeof inst === "object" && (inst as Record<string, unknown>).id) {
|
||||
return String((inst as Record<string, unknown>).id);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function makerworldNonceFromNextData(data: unknown): string | null {
|
||||
const nonce = getPath(data, "props", "pageProps", "x-nonce");
|
||||
return typeof nonce === "string" && nonce.trim() ? nonce : null;
|
||||
}
|
||||
|
||||
function makerworldModelIdFromUrl(url: string): string | null {
|
||||
try {
|
||||
const match = new URL(url).pathname.match(/\/models\/(\d+)/);
|
||||
return match ? match[1] : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMakerworldInstanceDownloadUrl(
|
||||
instanceId: string,
|
||||
pageUrl: string,
|
||||
cookie: string | null,
|
||||
): Promise<string | null> {
|
||||
const apiUrl = `https://makerworld.com/api/v1/design-service/instance/${instanceId}/f3mf?type=download&fileType=3mfstl`;
|
||||
const data = await fetchJsonFromUrl(apiUrl, pageUrl, makerworldApiHeaders(pageUrl, null, cookie));
|
||||
return data ? extractDownloadUrlFromResponse(data, apiUrl) : null;
|
||||
}
|
||||
|
||||
async function fetchMakerworldModelDownloadUrl(
|
||||
modelId: string,
|
||||
pageUrl: string,
|
||||
nonce: string | null,
|
||||
cookie: string | null,
|
||||
): Promise<string | null> {
|
||||
const apiUrl = `https://makerworld.com/api/v1/models/${modelId}/download`;
|
||||
const data = await fetchJsonFromUrl(apiUrl, pageUrl, makerworldApiHeaders(pageUrl, nonce, cookie));
|
||||
return data ? extractDownloadUrlFromResponse(data, apiUrl) : null;
|
||||
}
|
||||
|
||||
export async function resolveMakerworldDownloadUrl(
|
||||
html: string,
|
||||
pageUrl: string,
|
||||
makerworldCookie: string | null,
|
||||
): Promise<string | null> {
|
||||
const nextData = extractNextDataJson(html);
|
||||
let designId: string | null = null;
|
||||
let nonce: string | null = null;
|
||||
let instanceId: string | null = null;
|
||||
if (nextData) {
|
||||
const url = findDownloadUrlInJson(nextData, pageUrl);
|
||||
if (url) return url;
|
||||
designId = makerworldDesignIdFromNextData(nextData);
|
||||
nonce = makerworldNonceFromNextData(nextData);
|
||||
instanceId = makerworldInstanceIdFromNextData(nextData);
|
||||
}
|
||||
|
||||
if (instanceId) {
|
||||
const url = await fetchMakerworldInstanceDownloadUrl(instanceId, pageUrl, makerworldCookie);
|
||||
if (url) return url;
|
||||
}
|
||||
|
||||
const modelId = designId || makerworldModelIdFromUrl(pageUrl);
|
||||
if (!modelId) return null;
|
||||
const url = await fetchMakerworldModelDownloadUrl(modelId, pageUrl, nonce, makerworldCookie);
|
||||
if (url) return url;
|
||||
|
||||
const apiCandidates = [
|
||||
`https://makerworld.com/api/v1/models/${modelId}`,
|
||||
`https://makerworld.com/api/v1/models/${modelId}/files`,
|
||||
`https://makerworld.com/api/v1/model/${modelId}`,
|
||||
`https://makerworld.com/api/v1/model/${modelId}/files`,
|
||||
];
|
||||
for (const apiUrl of apiCandidates) {
|
||||
const data = await fetchJsonFromUrl(apiUrl, pageUrl);
|
||||
if (!data) continue;
|
||||
const found = findDownloadUrlInJson(data, apiUrl);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// -- Printables --------------------------------------------------------------------------------
|
||||
|
||||
function printablesModelIdFromUrl(url: string): string | null {
|
||||
try {
|
||||
const match = new URL(url).pathname.match(/\/model\/(\d+)/);
|
||||
return match ? match[1] : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPrintablesGraphql(query: string, variables: Record<string, unknown>, referer: string | null): Promise<unknown | null> {
|
||||
const headers: Record<string, string> = {
|
||||
"User-Agent": IMPORT_BROWSER_USER_AGENT,
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
Origin: "https://www.printables.com",
|
||||
};
|
||||
if (referer) headers.Referer = referer;
|
||||
const result = await fetchCappedBuffer("https://api.printables.com/graphql/", headers, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ query, variables: variables || {} }),
|
||||
});
|
||||
if (!result) return null;
|
||||
if (result.status >= 400) {
|
||||
const message = parseJsonErrorMessage(result.buffer);
|
||||
if ([401, 403, 429].includes(result.status)) {
|
||||
throw new HttpError(result.status, message || "Printables request failed");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (result.buffer.length > IMPORT_HTML_MAX_BYTES) return null;
|
||||
try {
|
||||
return JSON.parse(result.buffer.toString("utf-8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parsePrintablesErrorMessages(errors: unknown): string | null {
|
||||
if (!Array.isArray(errors)) return null;
|
||||
const messages: string[] = [];
|
||||
for (const err of errors) {
|
||||
if (!err || typeof err !== "object") continue;
|
||||
const values = (err as Record<string, unknown>).messages;
|
||||
if (Array.isArray(values)) {
|
||||
for (const v of values) if (typeof v === "string" && v.trim()) messages.push(v.trim());
|
||||
} else if (typeof values === "string" && values.trim()) {
|
||||
messages.push(values.trim());
|
||||
}
|
||||
}
|
||||
return messages.length ? messages.join(", ") : null;
|
||||
}
|
||||
|
||||
function selectBestDownloadLink(links: string[]): string | null {
|
||||
const candidates = links.filter((l) => isAllowedDownloadCandidate(l));
|
||||
if (!candidates.length) return null;
|
||||
candidates.sort((a, b) => scoreDownloadUrl(b) - scoreDownloadUrl(a));
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
function extractPrintablesIds(items: unknown): string[] {
|
||||
if (!Array.isArray(items)) return [];
|
||||
const ids: string[] = [];
|
||||
for (const item of items) {
|
||||
if (item && typeof item === "object" && (item as Record<string, unknown>).id !== undefined) {
|
||||
ids.push(String((item as Record<string, unknown>).id));
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
async function fetchPrintablesModelMeta(printId: string, pageUrl: string): Promise<Record<string, unknown> | null> {
|
||||
const query = `
|
||||
query ($id: ID!) {
|
||||
print(id: $id) {
|
||||
id
|
||||
downloadPacks { id fileType }
|
||||
stls { id name }
|
||||
}
|
||||
}
|
||||
`;
|
||||
const data = (await fetchPrintablesGraphql(query, { id: printId }, pageUrl)) as
|
||||
| { data?: { print?: Record<string, unknown> } }
|
||||
| null;
|
||||
return data?.data?.print ?? null;
|
||||
}
|
||||
|
||||
async function fetchPrintablesDownloadOutput(
|
||||
printId: string,
|
||||
variables: Record<string, unknown>,
|
||||
pageUrl: string,
|
||||
): Promise<Record<string, unknown> | null> {
|
||||
const mutation = `
|
||||
mutation ($printId: ID!, $source: DownloadSourceEnum!, $fileType: DownloadFileTypeEnum, $id: ID, $files: [DownloadFileInput!]) {
|
||||
getDownloadLink(printId: $printId, source: $source, fileType: $fileType, id: $id, files: $files) {
|
||||
ok
|
||||
errors { field messages code }
|
||||
output { link files { id link fileType } }
|
||||
}
|
||||
}
|
||||
`;
|
||||
const data = (await fetchPrintablesGraphql(mutation, variables, pageUrl)) as
|
||||
| { data?: { getDownloadLink?: Record<string, unknown> } }
|
||||
| null;
|
||||
const result = data?.data?.getDownloadLink;
|
||||
if (!result) return null;
|
||||
if (result.ok === false) {
|
||||
const message = parsePrintablesErrorMessages(result.errors);
|
||||
if (message) throw new HttpError(400, message);
|
||||
return null;
|
||||
}
|
||||
const output = result.output;
|
||||
return output && typeof output === "object" ? (output as Record<string, unknown>) : null;
|
||||
}
|
||||
|
||||
function collectPrintablesLinks(output: Record<string, unknown> | null): string[] {
|
||||
if (!output) return [];
|
||||
const links: string[] = [];
|
||||
if (typeof output.link === "string") links.push(output.link);
|
||||
const files = output.files;
|
||||
if (Array.isArray(files)) {
|
||||
for (const entry of files) {
|
||||
if (entry && typeof entry === "object" && typeof (entry as Record<string, unknown>).link === "string") {
|
||||
links.push((entry as Record<string, unknown>).link as string);
|
||||
}
|
||||
}
|
||||
}
|
||||
return links;
|
||||
}
|
||||
|
||||
export async function resolvePrintablesDownloadUrl(pageUrl: string): Promise<string | null> {
|
||||
const printId = printablesModelIdFromUrl(pageUrl);
|
||||
if (!printId) return null;
|
||||
const meta = await fetchPrintablesModelMeta(printId, pageUrl);
|
||||
if (!meta) return null;
|
||||
|
||||
const packs = meta.downloadPacks;
|
||||
let packId: string | null = null;
|
||||
if (Array.isArray(packs)) {
|
||||
for (const pack of packs) {
|
||||
if (pack && typeof pack === "object" && (pack as Record<string, unknown>).fileType === "MODEL_FILES" && (pack as Record<string, unknown>).id) {
|
||||
packId = String((pack as Record<string, unknown>).id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!packId && packs.length) {
|
||||
for (const pack of packs) {
|
||||
if (pack && typeof pack === "object" && (pack as Record<string, unknown>).id) {
|
||||
packId = String((pack as Record<string, unknown>).id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (packId) {
|
||||
const output = await fetchPrintablesDownloadOutput(
|
||||
printId,
|
||||
{ printId, source: "model_detail", fileType: "pack", id: packId },
|
||||
pageUrl,
|
||||
);
|
||||
const best = selectBestDownloadLink(collectPrintablesLinks(output));
|
||||
if (best) return best;
|
||||
}
|
||||
|
||||
const stlIds = extractPrintablesIds(meta.stls);
|
||||
if (stlIds.length) {
|
||||
const output = await fetchPrintablesDownloadOutput(
|
||||
printId,
|
||||
{ printId, source: "model_detail", files: [{ fileType: "stl", ids: stlIds }] },
|
||||
pageUrl,
|
||||
);
|
||||
const best = selectBestDownloadLink(collectPrintablesLinks(output));
|
||||
if (best) return best;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// -- Thingiverse -------------------------------------------------------------------------------
|
||||
|
||||
function thingiverseThingIdFromUrl(url: string): string | null {
|
||||
try {
|
||||
const pathname = new URL(url).pathname;
|
||||
const m1 = pathname.match(/thing:(\d+)/i);
|
||||
if (m1) return m1[1];
|
||||
const m2 = pathname.match(/\/things\/(\d+)/i);
|
||||
return m2 ? m2[1] : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchThingiverseJson(
|
||||
url: string,
|
||||
pageUrl: string,
|
||||
cookie: string | null,
|
||||
token: string | null,
|
||||
): Promise<unknown | null> {
|
||||
const result = await fetchCappedBuffer(url, thingiverseApiHeaders(pageUrl, cookie, token));
|
||||
if (!result) return null;
|
||||
if (result.status >= 400) {
|
||||
const message = parseJsonErrorMessage(result.buffer);
|
||||
if ([401, 403, 429].includes(result.status)) {
|
||||
throw new HttpError(result.status, message || "Thingiverse request failed");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (result.buffer.length > IMPORT_HTML_MAX_BYTES) return null;
|
||||
const contentType = result.headers.get("content-type") || "";
|
||||
if (!isJsonContentType(contentType)) {
|
||||
const trimmed = result.buffer.toString("utf-8").trimStart();
|
||||
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(result.buffer.toString("utf-8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchThingiverseViewToken(pageUrl: string, cookie: string | null): Promise<string | null> {
|
||||
const apiUrl = "https://www.thingiverse.com/api/v2/auth/view";
|
||||
const result = await fetchCappedBuffer(apiUrl, thingiverseApiHeaders(pageUrl, cookie));
|
||||
if (!result) return null;
|
||||
if (result.status >= 400) {
|
||||
const message = parseJsonErrorMessage(result.buffer);
|
||||
if ([401, 403, 429].includes(result.status)) {
|
||||
throw new HttpError(
|
||||
result.status,
|
||||
message ||
|
||||
"Thingiverse blocked the request. Paste the Cookie header from your browser session in Settings to import.",
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (result.buffer.length > IMPORT_HTML_MAX_BYTES) return null;
|
||||
let data: unknown;
|
||||
try {
|
||||
data = JSON.parse(result.buffer.toString("utf-8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (data && typeof data === "object") {
|
||||
const dict = data as Record<string, unknown>;
|
||||
const token = dict.access || dict.token || dict.access_token;
|
||||
if (typeof token === "string" && token.trim()) return token.trim();
|
||||
const inner = dict.data;
|
||||
if (inner && typeof inner === "object") {
|
||||
const innerDict = inner as Record<string, unknown>;
|
||||
const innerToken = innerDict.access || innerDict.token || innerDict.access_token;
|
||||
if (typeof innerToken === "string" && innerToken.trim()) return innerToken.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function resolveThingiverseDownloadUrl(pageUrl: string, thingiverseCookie: string | null): Promise<string | null> {
|
||||
const thingId = thingiverseThingIdFromUrl(pageUrl);
|
||||
if (!thingId) return null;
|
||||
const token = await fetchThingiverseViewToken(pageUrl, thingiverseCookie);
|
||||
if (!token) {
|
||||
if (!thingiverseCookie) {
|
||||
throw new HttpError(403, "Thingiverse requires a session cookie to fetch download links.");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const apiCandidates = [
|
||||
`https://www.thingiverse.com/api/v2/things/${thingId}/files`,
|
||||
`https://www.thingiverse.com/api/v2/things/${thingId}`,
|
||||
];
|
||||
for (const apiUrl of apiCandidates) {
|
||||
const data = await fetchThingiverseJson(apiUrl, pageUrl, thingiverseCookie, token);
|
||||
if (!data) continue;
|
||||
const url = findDownloadUrlInJson(data, apiUrl);
|
||||
if (url) return url;
|
||||
}
|
||||
return `https://www.thingiverse.com/thing:${thingId}/zip`;
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import fs from "node:fs/promises";
|
||||
import fsSync from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import {
|
||||
IMPORT_HTML_MAX_BYTES,
|
||||
IMPORT_MAX_BYTES,
|
||||
IMPORT_TIMEOUT_SECONDS,
|
||||
IMPORT_USER_AGENT,
|
||||
} from "../config";
|
||||
import { HttpError, buildImportFilename, isHtmlContentType, mimeFromContentType } from "../utils/fileUtils";
|
||||
import { validateRemoteUrl } from "../utils/urlUtils";
|
||||
import {
|
||||
findDownloadUrl,
|
||||
isPrintablesPageHost,
|
||||
isThingiversePageHost,
|
||||
makerworldHtmlHeaders,
|
||||
printablesHtmlHeaders,
|
||||
resolveMakerworldCookie,
|
||||
resolveMakerworldDownloadUrl,
|
||||
resolvePrintablesDownloadUrl,
|
||||
resolveThingiverseCookie,
|
||||
resolveThingiverseDownloadUrl,
|
||||
thingiverseHtmlHeaders,
|
||||
type ImportCookies,
|
||||
} from "./importResolvers";
|
||||
import { createPrint, type PrintMetaInput } from "./printCreation";
|
||||
import type { Plate, Print } from "@prisma/client";
|
||||
|
||||
export type ImportRequestBody = ImportCookies & {
|
||||
url: string;
|
||||
title?: string | null;
|
||||
notes?: string | null;
|
||||
tags?: string[];
|
||||
folder_id?: string | null;
|
||||
filename?: string | null;
|
||||
};
|
||||
|
||||
function parseCharset(contentType: string | null): string {
|
||||
if (!contentType) return "utf-8";
|
||||
const match = contentType.match(/charset=([^;]+)/i);
|
||||
return match ? match[1].trim().replace(/["']/g, "") : "utf-8";
|
||||
}
|
||||
|
||||
async function readCapped(response: Response, maxBytes: number): Promise<{ buffer: Buffer; truncated: boolean }> {
|
||||
if (!response.body) return { buffer: Buffer.alloc(0), truncated: false };
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
let truncated = false;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
const chunk = Buffer.from(value);
|
||||
total += chunk.length;
|
||||
if (total > maxBytes) {
|
||||
const allowed = chunk.length - (total - maxBytes);
|
||||
if (allowed > 0) chunks.push(chunk.subarray(0, allowed));
|
||||
truncated = true;
|
||||
await reader.cancel().catch(() => undefined);
|
||||
break;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
}
|
||||
return { buffer: Buffer.concat(chunks), truncated };
|
||||
}
|
||||
|
||||
async function fetchWithGuard(url: string, headers: Record<string, string>): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), IMPORT_TIMEOUT_SECONDS * 1000);
|
||||
try {
|
||||
const res = await fetch(url, { headers, redirect: "follow", signal: controller.signal });
|
||||
if (!res.ok) {
|
||||
throw new HttpError(res.status || 400, `Failed to fetch URL: ${res.statusText || res.status}`);
|
||||
}
|
||||
return res;
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError) throw err;
|
||||
throw new HttpError(400, "Failed to reach the provided URL");
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
export type OpenImportResult = { response: Response; finalUrl: string };
|
||||
|
||||
/**
|
||||
* Fetches `url`, following HTML "landing pages" recursively (MakerWorld/Printables/Thingiverse
|
||||
* page scraping, or generic <a href>/JSON link sniffing) until it lands on the actual model file
|
||||
* response. Mirrors MakersVault's open_import_response.
|
||||
*/
|
||||
export async function openImportResponse(
|
||||
url: string,
|
||||
body: ImportRequestBody,
|
||||
referer?: string | null,
|
||||
depth = 0,
|
||||
): Promise<OpenImportResult> {
|
||||
if (depth > 3) throw new HttpError(400, "Too many redirects while resolving download link");
|
||||
const validatedUrl = await validateRemoteUrl(url);
|
||||
|
||||
const headers: Record<string, string> = { "User-Agent": IMPORT_USER_AGENT, Accept: "*/*" };
|
||||
let host = "";
|
||||
try {
|
||||
host = (new URL(validatedUrl).hostname || "").toLowerCase();
|
||||
} catch {
|
||||
host = "";
|
||||
}
|
||||
let makerworldCookie: string | null = null;
|
||||
let thingiverseCookie: string | null = null;
|
||||
if (host.endsWith("makerworld.com")) {
|
||||
makerworldCookie = resolveMakerworldCookie(body);
|
||||
Object.assign(headers, makerworldHtmlHeaders(referer || validatedUrl, makerworldCookie));
|
||||
} else if (isThingiversePageHost(host)) {
|
||||
thingiverseCookie = resolveThingiverseCookie(body);
|
||||
Object.assign(headers, thingiverseHtmlHeaders(referer || validatedUrl, thingiverseCookie));
|
||||
} else if (isPrintablesPageHost(host)) {
|
||||
Object.assign(headers, printablesHtmlHeaders(referer || validatedUrl));
|
||||
}
|
||||
if (referer) headers.Referer = referer;
|
||||
|
||||
const res = await fetchWithGuard(validatedUrl, headers);
|
||||
const finalUrl = res.url || validatedUrl;
|
||||
await validateRemoteUrl(finalUrl);
|
||||
|
||||
const contentType = res.headers.get("content-type") || "";
|
||||
if (isHtmlContentType(contentType)) {
|
||||
const { buffer, truncated } = await readCapped(res, IMPORT_HTML_MAX_BYTES);
|
||||
const html = buffer.toString(parseCharset(contentType) as BufferEncoding);
|
||||
|
||||
let downloadUrl: string | null = null;
|
||||
let pageHost = "";
|
||||
try {
|
||||
pageHost = (new URL(finalUrl).hostname || "").toLowerCase();
|
||||
} catch {
|
||||
pageHost = "";
|
||||
}
|
||||
if (pageHost.endsWith("makerworld.com")) {
|
||||
if (!makerworldCookie) makerworldCookie = resolveMakerworldCookie(body);
|
||||
downloadUrl = await resolveMakerworldDownloadUrl(html, finalUrl, makerworldCookie);
|
||||
}
|
||||
if (!downloadUrl && isThingiversePageHost(pageHost)) {
|
||||
if (thingiverseCookie === null) thingiverseCookie = resolveThingiverseCookie(body);
|
||||
downloadUrl = await resolveThingiverseDownloadUrl(finalUrl, thingiverseCookie);
|
||||
}
|
||||
if (!downloadUrl && isPrintablesPageHost(pageHost)) {
|
||||
downloadUrl = await resolvePrintablesDownloadUrl(finalUrl);
|
||||
}
|
||||
if (!downloadUrl) {
|
||||
downloadUrl = findDownloadUrl(html, finalUrl);
|
||||
}
|
||||
if (!downloadUrl) {
|
||||
if (truncated) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
"No downloadable model file found in the scanned portion of the page. Use a direct download link or increase IMPORT_HTML_MAX_KB.",
|
||||
);
|
||||
}
|
||||
throw new HttpError(400, "No downloadable model file found. Use a direct download link.");
|
||||
}
|
||||
return openImportResponse(downloadUrl, body, finalUrl, depth + 1);
|
||||
}
|
||||
|
||||
return { response: res, finalUrl };
|
||||
}
|
||||
|
||||
async function streamToFileCapped(response: Response, destPath: string, maxBytes: number): Promise<number> {
|
||||
if (!response.body) {
|
||||
await fs.writeFile(destPath, Buffer.alloc(0));
|
||||
return 0;
|
||||
}
|
||||
const reader = response.body.getReader();
|
||||
const handle = await fs.open(destPath, "w");
|
||||
let total = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
const chunk = Buffer.from(value);
|
||||
total += chunk.length;
|
||||
if (total > maxBytes) {
|
||||
await reader.cancel().catch(() => undefined);
|
||||
throw new HttpError(413, "Imported file exceeds size limit");
|
||||
}
|
||||
await handle.write(chunk);
|
||||
}
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/** Downloads `url` to a temp file without creating a Print (used by inspect/zip-entries/zip-extract). */
|
||||
export async function downloadImportToTemp(
|
||||
url: string,
|
||||
body: ImportRequestBody,
|
||||
): Promise<{ tempPath: string; filename: string; mime: string }> {
|
||||
const { response, finalUrl } = await openImportResponse(url, body);
|
||||
const contentLength = response.headers.get("content-length");
|
||||
if (contentLength && Number(contentLength) > IMPORT_MAX_BYTES) {
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
throw new HttpError(413, "Imported file exceeds size limit");
|
||||
}
|
||||
const filename = buildImportFilename(finalUrl, response.headers, body.filename);
|
||||
const mime = mimeFromContentType(response.headers.get("content-type"), filename);
|
||||
const suffix = path.extname(filename) || "";
|
||||
const tempPath = path.join(os.tmpdir(), `printstash-import-${crypto.randomBytes(8).toString("hex")}${suffix}`);
|
||||
try {
|
||||
await streamToFileCapped(response, tempPath, IMPORT_MAX_BYTES);
|
||||
} catch (err) {
|
||||
await fs.rm(tempPath, { force: true }).catch(() => undefined);
|
||||
throw err;
|
||||
}
|
||||
return { tempPath, filename, mime };
|
||||
}
|
||||
|
||||
/** Inspects a link without downloading the file body: returns the filename/mime/is_zip it
|
||||
* would resolve to. */
|
||||
export async function inspectImportLink(
|
||||
url: string,
|
||||
body: ImportRequestBody,
|
||||
): Promise<{ filename: string; mime: string; is_zip: boolean }> {
|
||||
const { response, finalUrl } = await openImportResponse(url, body);
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
const filename = buildImportFilename(finalUrl, response.headers, body.filename);
|
||||
const mime = mimeFromContentType(response.headers.get("content-type"), filename);
|
||||
const isZip = path.extname(filename).toLowerCase() === ".zip";
|
||||
return { filename, mime, is_zip: isZip };
|
||||
}
|
||||
|
||||
/** Downloads a URL and creates a single-plate Print from it (POST /import). */
|
||||
export async function importPrintFromUrl(
|
||||
url: string,
|
||||
body: ImportRequestBody,
|
||||
): Promise<{ print: Print; plates: Plate[] }> {
|
||||
const { tempPath, filename, mime } = await downloadImportToTemp(url, body);
|
||||
const meta: PrintMetaInput = {
|
||||
title: body.title ?? null,
|
||||
notes: body.notes ?? null,
|
||||
tags: body.tags ?? [],
|
||||
folderId: body.folder_id ?? null,
|
||||
};
|
||||
try {
|
||||
return await createPrint(meta, path.parse(filename).name, [{ filename, mime, tempFilePath: tempPath }]);
|
||||
} finally {
|
||||
if (fsSync.existsSync(tempPath)) await fs.rm(tempPath, { force: true }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { prisma } from "../db";
|
||||
import {
|
||||
DEFAULT_MOUNT_IMPORT_EXTS,
|
||||
IMPORT_MAX_BYTES,
|
||||
MOUNT_IMPORT_COPY,
|
||||
MOUNT_IMPORT_ENABLED,
|
||||
MOUNT_IMPORT_EXTS_RAW,
|
||||
MOUNT_IMPORT_INCLUDE_HIDDEN,
|
||||
MOUNT_IMPORT_PATH,
|
||||
STORAGE,
|
||||
} from "../config";
|
||||
import { sanitizeFilename, guessMimeFromPath } from "../utils/fileUtils";
|
||||
import { getMountImportCopy, getMountImportEnabled } from "./settingsService";
|
||||
import { resolveZipFolderId } from "./zipService";
|
||||
import { createPrint } from "./printCreation";
|
||||
|
||||
function parseMountImportExts(raw: string): Set<string> | null {
|
||||
if (!raw) return new Set(DEFAULT_MOUNT_IMPORT_EXTS);
|
||||
const lowered = raw.trim().toLowerCase();
|
||||
if (lowered === "*") return null;
|
||||
const parts = lowered.split(/[,\s]+/).filter(Boolean);
|
||||
const exts = new Set<string>();
|
||||
for (const part of parts) {
|
||||
exts.add(part.startsWith(".") ? part : `.${part}`);
|
||||
}
|
||||
return exts.size ? exts : new Set(DEFAULT_MOUNT_IMPORT_EXTS);
|
||||
}
|
||||
|
||||
function shouldSkipMountEntry(name: string): boolean {
|
||||
return !MOUNT_IMPORT_INCLUDE_HIDDEN && name.startsWith(".");
|
||||
}
|
||||
|
||||
async function* walk(dir: string, skipDirNames: Set<string>): AsyncGenerator<string> {
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (shouldSkipMountEntry(entry.name)) continue;
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (skipDirNames.has(entry.name)) continue;
|
||||
yield* walk(full, skipDirNames);
|
||||
} else if (entry.isFile()) {
|
||||
yield full;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans a read-only host directory (IMPORT_MOUNT_PATH) for model files not yet imported and
|
||||
* creates a single-plate Print for each. Fire-and-forget: intended to run in the background
|
||||
* after the server starts listening, mirroring MakersVault's startup background thread.
|
||||
*/
|
||||
export async function scanMountImports(): Promise<void> {
|
||||
const root = MOUNT_IMPORT_PATH;
|
||||
if (!root) return;
|
||||
if (!(await getMountImportEnabled(MOUNT_IMPORT_ENABLED))) return;
|
||||
|
||||
let rootStat;
|
||||
try {
|
||||
rootStat = await fs.stat(root);
|
||||
} catch {
|
||||
console.log(`[mount-import] Skipping: mount path not found: ${root}`);
|
||||
return;
|
||||
}
|
||||
if (!rootStat.isDirectory()) {
|
||||
console.log(`[mount-import] Skipping: mount path not found: ${root}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const allowedExts = parseMountImportExts(MOUNT_IMPORT_EXTS_RAW);
|
||||
const copyFiles = await getMountImportCopy(MOUNT_IMPORT_COPY);
|
||||
const rootAbs = path.resolve(root);
|
||||
const rootPrefix = rootAbs.replace(/\/+$/, "");
|
||||
const storageAbs = path.resolve(STORAGE);
|
||||
const skipStorageDirs = rootAbs === storageAbs;
|
||||
const skipDirNames = skipStorageDirs ? new Set(["thumbs", "bundles"]) : new Set<string>();
|
||||
|
||||
let imported = 0;
|
||||
let skipped = 0;
|
||||
let failed = 0;
|
||||
|
||||
console.log(`[mount-import] Scanning ${rootAbs}...`);
|
||||
|
||||
const existingRows = await prisma.plate.findMany({
|
||||
where: { sourcePath: { startsWith: `${rootPrefix}/` } },
|
||||
select: { sourcePath: true },
|
||||
});
|
||||
const existingSources = new Set(existingRows.map((r) => r.sourcePath).filter((s): s is string => !!s));
|
||||
|
||||
const folderCache = new Map<string, string>();
|
||||
|
||||
for await (const filePath of walk(rootAbs, skipDirNames)) {
|
||||
const filename = path.basename(filePath);
|
||||
if (shouldSkipMountEntry(filename)) continue;
|
||||
const ext = path.extname(filename).toLowerCase();
|
||||
if (allowedExts !== null && !allowedExts.has(ext)) continue;
|
||||
|
||||
const relPath = path.relative(rootAbs, filePath).split(path.sep).join("/");
|
||||
const sourcePath = `${rootPrefix}/${relPath}`;
|
||||
if (existingSources.has(sourcePath)) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let size: number;
|
||||
try {
|
||||
size = (await fs.stat(filePath)).size;
|
||||
} catch {
|
||||
failed += 1;
|
||||
continue;
|
||||
}
|
||||
if (size > IMPORT_MAX_BYTES) {
|
||||
failed += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const folderId = await resolveZipFolderId(null, relPath, folderCache);
|
||||
const safeName = sanitizeFilename(filename);
|
||||
const mime = guessMimeFromPath(filePath);
|
||||
await createPrint({ folderId }, path.parse(safeName).name, [
|
||||
{
|
||||
filename: safeName,
|
||||
mime,
|
||||
size,
|
||||
sourcePath: filePath,
|
||||
copyFromPath: copyFiles ? filePath : undefined,
|
||||
},
|
||||
]);
|
||||
imported += 1;
|
||||
existingSources.add(sourcePath);
|
||||
} catch {
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[mount-import] Done. Imported ${imported}, skipped ${skipped}, failed ${failed}.`);
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { listZipEntries, readZipEntry } from "../utils/zipReader";
|
||||
|
||||
const TEXT_LIMIT = 2 * 1024 * 1024;
|
||||
const PREPARED_SUFFIXES = [".gcode.3mf", ".gcode", ".gco", ".bgcode"];
|
||||
|
||||
export type PreparedMetadata = {
|
||||
printer: string | null;
|
||||
material: string | null;
|
||||
nozzle_mm: number | null;
|
||||
layer_height_mm: number | null;
|
||||
estimated_seconds: number | null;
|
||||
format: string;
|
||||
};
|
||||
|
||||
export function isPreparedPrintFilename(filename: string): boolean {
|
||||
const lower = (filename || "").toLowerCase();
|
||||
return PREPARED_SUFFIXES.some((suffix) => lower.endsWith(suffix));
|
||||
}
|
||||
|
||||
export function preparedFormat(filename: string): string {
|
||||
const lower = (filename || "").toLowerCase();
|
||||
if (lower.endsWith(".gcode.3mf")) return "gcode_3mf";
|
||||
if (lower.endsWith(".bgcode")) return "bgcode";
|
||||
return "gcode";
|
||||
}
|
||||
|
||||
function firstFloat(value: string | undefined | null): number | null {
|
||||
const match = (value || "").match(/[-+]?\d+(?:\.\d+)?/);
|
||||
if (!match) return null;
|
||||
const parsed = Number.parseFloat(match[0]);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function durationSeconds(value: string | undefined | null): number | null {
|
||||
const raw = (value || "").trim().toLowerCase();
|
||||
if (!raw) return null;
|
||||
if (/^\d+$/.test(raw)) return Number.parseInt(raw, 10);
|
||||
let total = 0;
|
||||
let found = false;
|
||||
const re = /(\d+(?:\.\d+)?)\s*(d|days?|h|hours?|m|min|mins|minutes?|s|sec|secs|seconds?)\b/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = re.exec(raw))) {
|
||||
found = true;
|
||||
const amount = Number.parseFloat(match[1]);
|
||||
const unit = match[2];
|
||||
const multiplier = unit.startsWith("d") ? 86400 : unit.startsWith("h") ? 3600 : unit.startsWith("m") ? 60 : 1;
|
||||
total += amount * multiplier;
|
||||
}
|
||||
return found ? Math.trunc(total) : null;
|
||||
}
|
||||
|
||||
const PROFILE_ALIASES: Record<string, string> = {
|
||||
p1s: "Bambu Lab P1S",
|
||||
p1p: "Bambu Lab P1P",
|
||||
x1c: "Bambu Lab X1 Carbon",
|
||||
"x1 carbon": "Bambu Lab X1 Carbon",
|
||||
a1: "Bambu Lab A1",
|
||||
"a1 mini": "Bambu Lab A1 mini",
|
||||
};
|
||||
|
||||
function cleanProfile(value: string | undefined | null): string | null {
|
||||
let cleaned = (value || "").trim();
|
||||
cleaned = cleaned.replace(/\s*@\s*[^;]+$/, "");
|
||||
cleaned = cleaned.split(";")[0].trim();
|
||||
cleaned = cleaned.replace(/\s+\d+(?:\.\d+)?\s*(?:mm\s*)?nozzle$/i, "");
|
||||
if (!cleaned) return null;
|
||||
return PROFILE_ALIASES[cleaned.toLowerCase()] ?? cleaned;
|
||||
}
|
||||
|
||||
type MutableMetadata = {
|
||||
printer: string | null;
|
||||
material: string | null;
|
||||
nozzle_mm: number | null;
|
||||
layer_height_mm: number | null;
|
||||
estimated_seconds: number | null;
|
||||
};
|
||||
|
||||
function mergeTextMetadata(metadata: MutableMetadata, text: string): void {
|
||||
const attributeValues: Record<string, string> = {};
|
||||
for (const tagMatch of text.matchAll(/<[^>]+>/g)) {
|
||||
const tag = tagMatch[0];
|
||||
const attrs: Record<string, string> = {};
|
||||
for (const attrMatch of tag.matchAll(/([\w:-]+)\s*=\s*["']([^"']*)["']/g)) {
|
||||
attrs[attrMatch[1]] = attrMatch[2];
|
||||
}
|
||||
const key = (attrs.key || attrs.name || "").toLowerCase();
|
||||
const value = attrs.value;
|
||||
if (key && value !== undefined) attributeValues[key] = value;
|
||||
if (metadata.material === null && /filament/i.test(tag) && attrs.type) {
|
||||
metadata.material = attrs.type.split(";")[0].trim() || null;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(attributeValues)) {
|
||||
if (metadata.printer === null && ["printer_model", "printer_settings_id", "machine_name"].includes(key)) {
|
||||
metadata.printer = cleanProfile(value);
|
||||
} else if (metadata.material === null && ["filament_type", "material_type"].includes(key)) {
|
||||
metadata.material = value.split(";")[0].trim() || null;
|
||||
} else if (metadata.nozzle_mm === null && key === "nozzle_diameter") {
|
||||
metadata.nozzle_mm = firstFloat(value);
|
||||
} else if (metadata.layer_height_mm === null && key === "layer_height") {
|
||||
metadata.layer_height_mm = firstFloat(value);
|
||||
} else if (metadata.estimated_seconds === null && ["prediction", "estimated_time", "print_time"].includes(key)) {
|
||||
metadata.estimated_seconds = durationSeconds(value);
|
||||
}
|
||||
}
|
||||
|
||||
const patterns: Record<keyof MutableMetadata, RegExp[]> = {
|
||||
printer: [
|
||||
/^\s*;?\s*printer_settings_id\s*[:=]\s*([^\r\n]+)/im,
|
||||
/^\s*;?\s*printer_model\s*[:=]\s*([^\r\n]+)/im,
|
||||
/^\s*;?\s*machine_name\s*[:=]\s*([^\r\n]+)/im,
|
||||
],
|
||||
material: [/^\s*;?\s*(?:filament_type|material_type)\s*[:=]\s*([^\r\n]+)/im],
|
||||
nozzle_mm: [/^\s*;?\s*nozzle_diameter\s*[:=]\s*([^\r\n]+)/im],
|
||||
layer_height_mm: [/^\s*;?\s*layer_height\s*[:=]\s*([^\r\n]+)/im],
|
||||
estimated_seconds: [
|
||||
/^\s*;?\s*(?:total estimated time|model printing time|estimated printing time[^:=]*|time)\s*[:=]\s*([^\r\n]+)/im,
|
||||
],
|
||||
};
|
||||
for (const key of Object.keys(patterns) as (keyof MutableMetadata)[]) {
|
||||
if (metadata[key] !== null) continue;
|
||||
for (const pattern of patterns[key]) {
|
||||
const match = text.match(pattern);
|
||||
if (!match) continue;
|
||||
const value = match[1].trim();
|
||||
if (key === "printer") metadata.printer = cleanProfile(value);
|
||||
else if (key === "material") metadata.material = value.split(";")[0].trim() || null;
|
||||
else if (key === "nozzle_mm" || key === "layer_height_mm") metadata[key] = firstFloat(value);
|
||||
else metadata.estimated_seconds = durationSeconds(value);
|
||||
if (metadata[key] !== null) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function visitJson(node: unknown, prefix: string, flattened: Record<string, unknown>): void {
|
||||
if (node && typeof node === "object" && !Array.isArray(node)) {
|
||||
for (const [key, child] of Object.entries(node as Record<string, unknown>)) {
|
||||
visitJson(child, prefix ? `${prefix}.${key}` : key, flattened);
|
||||
}
|
||||
} else if (Array.isArray(node)) {
|
||||
node.slice(0, 20).forEach((child, index) => visitJson(child, `${prefix}.${index}`, flattened));
|
||||
} else {
|
||||
flattened[prefix.toLowerCase()] = node;
|
||||
}
|
||||
}
|
||||
|
||||
function mergeJsonMetadata(metadata: MutableMetadata, raw: Buffer): void {
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(raw.toString("utf-8"));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const flattened: Record<string, unknown> = {};
|
||||
visitJson(value, "", flattened);
|
||||
for (const [key, rawValue] of Object.entries(flattened)) {
|
||||
const text = String(rawValue);
|
||||
if (metadata.printer === null && (key.includes("printer_model") || key.includes("machine_name"))) {
|
||||
metadata.printer = cleanProfile(text);
|
||||
} else if (metadata.material === null && (key.includes("filament_type") || key.includes("material_type"))) {
|
||||
metadata.material = text.split(";")[0].trim() || null;
|
||||
} else if (metadata.nozzle_mm === null && key.includes("nozzle_diameter")) {
|
||||
metadata.nozzle_mm = firstFloat(text);
|
||||
} else if (metadata.layer_height_mm === null && key.includes("layer_height")) {
|
||||
metadata.layer_height_mm = firstFloat(text);
|
||||
} else if (
|
||||
metadata.estimated_seconds === null &&
|
||||
(key.includes("prediction") || key.includes("estimated_time") || key.includes("print_time"))
|
||||
) {
|
||||
metadata.estimated_seconds = durationSeconds(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function readTextSample(filePath: string): Promise<string> {
|
||||
try {
|
||||
const stat = await fs.stat(filePath);
|
||||
const half = TEXT_LIMIT / 2;
|
||||
const handle = await fs.open(filePath, "r");
|
||||
try {
|
||||
const firstBuf = Buffer.alloc(Math.min(half, stat.size));
|
||||
await handle.read(firstBuf, 0, firstBuf.length, 0);
|
||||
let lastBuf = Buffer.alloc(0);
|
||||
if (stat.size > firstBuf.length) {
|
||||
const start = Math.max(0, stat.size - half);
|
||||
lastBuf = Buffer.alloc(stat.size - start);
|
||||
await handle.read(lastBuf, 0, lastBuf.length, start);
|
||||
}
|
||||
return Buffer.concat([firstBuf, Buffer.from("\n"), lastBuf]).toString("utf-8");
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export async function inspectPreparedPrint(filePath: string, filename?: string): Promise<PreparedMetadata | null> {
|
||||
const displayName = filename || path.basename(filePath);
|
||||
const metadata: PreparedMetadata = {
|
||||
printer: null,
|
||||
material: null,
|
||||
nozzle_mm: null,
|
||||
layer_height_mm: null,
|
||||
estimated_seconds: null,
|
||||
format: preparedFormat(displayName),
|
||||
};
|
||||
|
||||
const is3mf = displayName.toLowerCase().endsWith(".3mf");
|
||||
if (!is3mf) {
|
||||
if (!isPreparedPrintFilename(displayName)) return null;
|
||||
mergeTextMetadata(metadata, await readTextSample(filePath));
|
||||
return metadata;
|
||||
}
|
||||
|
||||
try {
|
||||
const names = (await listZipEntries(filePath)).filter((e) => !e.isDirectory).map((e) => e.name);
|
||||
const preparedMembers = names.filter((name) => /\.(gcode|gco|bgcode)$/i.test(name));
|
||||
if (preparedMembers.length === 0 && !displayName.toLowerCase().endsWith(".gcode.3mf")) {
|
||||
return null;
|
||||
}
|
||||
metadata.format = "gcode_3mf";
|
||||
|
||||
for (const name of names) {
|
||||
const lower = name.toLowerCase();
|
||||
const isMetadataArea = lower.includes("metadata/") || lower.includes("slice") || lower.includes("plate");
|
||||
if (lower.endsWith(".json") && isMetadataArea) {
|
||||
const buf = await readZipEntry(filePath, name, TEXT_LIMIT);
|
||||
if (buf) mergeJsonMetadata(metadata, buf);
|
||||
} else if (/\.(config|xml|txt)$/i.test(lower) && (lower.includes("metadata/") || lower.includes("slice"))) {
|
||||
const buf = await readZipEntry(filePath, name, TEXT_LIMIT);
|
||||
if (buf) mergeTextMetadata(metadata, buf.toString("utf-8"));
|
||||
}
|
||||
}
|
||||
|
||||
if (preparedMembers[0]) {
|
||||
const buf = await readZipEntry(filePath, preparedMembers[0], TEXT_LIMIT);
|
||||
if (buf) mergeTextMetadata(metadata, buf.toString("utf-8"));
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
|
||||
export function preparedFilename(modelName: string, metadata: Pick<PreparedMetadata, "format"> | null): string {
|
||||
const fmt = metadata?.format;
|
||||
const suffix = fmt === "gcode_3mf" ? ".gcode.3mf" : fmt === "bgcode" ? ".bgcode" : ".gcode";
|
||||
// oxlint-disable-next-line no-control-regex -- stripping control chars is the point here.
|
||||
let safe = (modelName || "prepared-print").trim().replace(/[<>:"/\\|?*\x00-\x1f]/g, "_").replace(/[ .]+$/, "");
|
||||
return `${safe || "prepared-print"}${suffix}`;
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import fs from "node:fs/promises";
|
||||
import fsSync from "node:fs";
|
||||
import path from "node:path";
|
||||
import { prisma } from "../db";
|
||||
import { STORAGE } from "../config";
|
||||
import { sanitizeFilename, guessMimeFromPath, mimeFromContentType } from "../utils/fileUtils";
|
||||
import { inspectPreparedPrint } from "./preparedPrint";
|
||||
import {
|
||||
availableModelName,
|
||||
availablePlateFilename,
|
||||
ensurePlateThumbnail,
|
||||
managedPlatePath,
|
||||
pruneEmptyStorageDirs,
|
||||
renderPlateStoragePath,
|
||||
saveThumbFromFile,
|
||||
} from "./printService";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import type { Plate, Print } from "@prisma/client";
|
||||
|
||||
const IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".webp", ".bmp"]);
|
||||
|
||||
export type NewPlateInput = {
|
||||
/** Original (untrusted) filename; will be sanitized. */
|
||||
filename: string;
|
||||
mime?: string | null;
|
||||
size?: number;
|
||||
/** Exactly one of tempFilePath / copyFromPath / sourcePath should be set. */
|
||||
tempFilePath?: string; // moved (renamed) into managed storage, source is deleted
|
||||
copyFromPath?: string; // copied into managed storage, source left intact
|
||||
sourcePath?: string; // no-copy: file stays at this path, storagePath is still rendered/recorded
|
||||
};
|
||||
|
||||
export type PrintMetaInput = {
|
||||
title?: string | null;
|
||||
notes?: string | null;
|
||||
tags?: string[];
|
||||
folderId?: string | null;
|
||||
creator?: string | null;
|
||||
collection?: string | null;
|
||||
};
|
||||
|
||||
async function placeFile(input: NewPlateInput, destAbsPath: string): Promise<string | null> {
|
||||
await fs.mkdir(path.dirname(destAbsPath), { recursive: true });
|
||||
if (input.tempFilePath) {
|
||||
try {
|
||||
await fs.rename(input.tempFilePath, destAbsPath);
|
||||
} catch (err: any) {
|
||||
if (err?.code === "EXDEV") {
|
||||
await fs.copyFile(input.tempFilePath, destAbsPath);
|
||||
await fs.rm(input.tempFilePath, { force: true });
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
return destAbsPath;
|
||||
}
|
||||
if (input.copyFromPath) {
|
||||
await fs.copyFile(input.copyFromPath, destAbsPath);
|
||||
return destAbsPath;
|
||||
}
|
||||
// no-copy: nothing placed on disk, caller should use sourcePath for reads.
|
||||
return input.sourcePath ?? null;
|
||||
}
|
||||
|
||||
async function resolveSize(input: NewPlateInput, effectivePath: string | null): Promise<number> {
|
||||
if (typeof input.size === "number") return input.size;
|
||||
if (!effectivePath) return 0;
|
||||
try {
|
||||
const stat = await fs.stat(effectivePath);
|
||||
return stat.size;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function thumbnailAndSniff(plateId: string, filename: string, mime: string, effectivePath: string | null) {
|
||||
if (!effectivePath || !fsSync.existsSync(effectivePath)) return;
|
||||
const ext = path.extname(filename).toLowerCase();
|
||||
if (mime.toLowerCase().startsWith("image/") && IMAGE_EXTS.has(ext)) {
|
||||
await saveThumbFromFile(plateId, effectivePath);
|
||||
} else if (ext === ".3mf") {
|
||||
await ensurePlateThumbnail(plateId, effectivePath);
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolves the on-disk path to read a plate's bytes from (managed storage, else its sourcePath). */
|
||||
export function resolvePlateFilePath(plate: Pick<Plate, "storagePath" | "sourcePath">): string | null {
|
||||
const managed = managedPlatePath(plate);
|
||||
if (fsSync.existsSync(managed)) return managed;
|
||||
if (plate.sourcePath && fsSync.existsSync(plate.sourcePath)) return plate.sourcePath;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Re-inspects plate[0] of a print and refreshes the auto-detected Print.preparedMetadata. */
|
||||
export async function refreshAutoPreparedMetadata(printId: string): Promise<void> {
|
||||
const plate0 = await prisma.plate.findFirst({ where: { printId }, orderBy: { position: "asc" } });
|
||||
let metadata: Prisma.InputJsonValue | null = null;
|
||||
if (plate0) {
|
||||
const filePath = resolvePlateFilePath(plate0);
|
||||
if (filePath) {
|
||||
const sniffed = await inspectPreparedPrint(filePath, plate0.filename);
|
||||
if (sniffed) metadata = sniffed as unknown as Prisma.InputJsonValue;
|
||||
}
|
||||
}
|
||||
await prisma.print.update({
|
||||
where: { id: printId },
|
||||
data: { preparedMetadata: metadata === null ? Prisma.JsonNull : metadata },
|
||||
});
|
||||
}
|
||||
|
||||
type CreatedPlate = { record: Plate; effectivePath: string | null };
|
||||
|
||||
async function createPlateAtPosition(
|
||||
print: Pick<Print, "id" | "name" | "creator" | "collection" | "tags" | "folderId">,
|
||||
input: NewPlateInput,
|
||||
position: number,
|
||||
): Promise<CreatedPlate> {
|
||||
const sanitized = sanitizeFilename(input.filename);
|
||||
const desiredFilename = await availablePlateFilename(print.id, sanitized);
|
||||
const storagePath = await renderPlateStoragePath(print, desiredFilename, position);
|
||||
const destAbsPath = path.join(STORAGE, storagePath);
|
||||
const effectivePath = await placeFile(input, destAbsPath);
|
||||
const size = await resolveSize(input, effectivePath);
|
||||
const mime = input.mime || guessMimeFromPath(desiredFilename) || "application/octet-stream";
|
||||
|
||||
const record = await prisma.plate.create({
|
||||
data: {
|
||||
printId: print.id,
|
||||
position,
|
||||
filename: desiredFilename,
|
||||
mime,
|
||||
size,
|
||||
storagePath,
|
||||
sourcePath: input.sourcePath ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
await thumbnailAndSniff(record.id, desiredFilename, mime, effectivePath);
|
||||
return { record, effectivePath };
|
||||
}
|
||||
|
||||
/** Creates a new Print with one or more Plates (in the given order). Used by /upload (single + multiplate). */
|
||||
export async function createPrint(
|
||||
meta: PrintMetaInput,
|
||||
nameHint: string,
|
||||
plateInputs: NewPlateInput[],
|
||||
): Promise<{ print: Print; plates: Plate[] }> {
|
||||
if (!plateInputs.length) throw new Error("createPrint requires at least one plate");
|
||||
const baseName = (meta.title || "").trim() || nameHint;
|
||||
const finalName = await availableModelName(baseName, meta.folderId ?? null);
|
||||
|
||||
const print = await prisma.print.create({
|
||||
data: {
|
||||
name: finalName,
|
||||
nameNormalized: finalName.trim().toLowerCase(),
|
||||
title: meta.title ?? null,
|
||||
notes: meta.notes ?? null,
|
||||
creator: meta.creator?.trim() || null,
|
||||
collection: meta.collection?.trim() || null,
|
||||
tags: (meta.tags || []).map((t) => t.trim()).filter(Boolean),
|
||||
folderId: meta.folderId ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
const plates: Plate[] = [];
|
||||
let firstEffectivePath: string | null = null;
|
||||
let firstFilename = "";
|
||||
for (let i = 0; i < plateInputs.length; i++) {
|
||||
const { record, effectivePath } = await createPlateAtPosition(print, plateInputs[i], i);
|
||||
plates.push(record);
|
||||
if (i === 0) {
|
||||
firstEffectivePath = effectivePath;
|
||||
firstFilename = record.filename;
|
||||
}
|
||||
}
|
||||
|
||||
if (firstEffectivePath) {
|
||||
const sniffed = await inspectPreparedPrint(firstEffectivePath, firstFilename);
|
||||
if (sniffed) {
|
||||
await prisma.print.update({
|
||||
where: { id: print.id },
|
||||
data: { preparedMetadata: sniffed as unknown as Prisma.InputJsonValue },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { print, plates };
|
||||
}
|
||||
|
||||
/** Appends one or more plates to an existing print (POST /print/:id/plates). */
|
||||
export async function addPlatesToPrint(printId: string, plateInputs: NewPlateInput[]): Promise<Plate[]> {
|
||||
const print = await prisma.print.findUnique({ where: { id: printId } });
|
||||
if (!print) throw new Error("Print not found");
|
||||
const maxPosition = await prisma.plate.aggregate({ where: { printId }, _max: { position: true } });
|
||||
let nextPosition = (maxPosition._max.position ?? -1) + 1;
|
||||
|
||||
const created: Plate[] = [];
|
||||
for (const input of plateInputs) {
|
||||
const { record } = await createPlateAtPosition(print, input, nextPosition);
|
||||
created.push(record);
|
||||
nextPosition += 1;
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
export async function deletePlateFiles(plate: Pick<Plate, "storagePath">): Promise<void> {
|
||||
const abs = path.join(STORAGE, plate.storagePath);
|
||||
try {
|
||||
await fs.rm(abs, { force: true });
|
||||
await pruneEmptyStorageDirs(path.dirname(abs));
|
||||
} catch {
|
||||
// best-effort cleanup
|
||||
}
|
||||
}
|
||||
|
||||
export function mimeForUpload(contentType: string | null | undefined, filename: string): string {
|
||||
return mimeFromContentType(contentType, filename);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import fs from "node:fs/promises";
|
||||
import fsSync from "node:fs";
|
||||
import path from "node:path";
|
||||
import { prisma } from "../db";
|
||||
import { STORAGE, IMPORT_MAX_BYTES } from "../config";
|
||||
import { HttpError, sanitizeFilename, mimeFromContentType } from "../utils/fileUtils";
|
||||
import { isPreparedPrintFilename, inspectPreparedPrint } from "./preparedPrint";
|
||||
import type { Prisma, Print, PrintFile } from "@prisma/client";
|
||||
|
||||
export function managedPrintFilePath(printFile: Pick<PrintFile, "storagePath">): string {
|
||||
const candidate = path.resolve(STORAGE, printFile.storagePath);
|
||||
const root = path.resolve(STORAGE);
|
||||
if (candidate === root || !candidate.startsWith(root + path.sep)) {
|
||||
throw new HttpError(400, "Related file resolved outside storage");
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
export async function listSupportingFiles(printId: string): Promise<PrintFile[]> {
|
||||
return prisma.printFile.findMany({
|
||||
where: { printId, role: "SUPPORTING" },
|
||||
orderBy: [{ filename: "asc" }, { id: "asc" }],
|
||||
});
|
||||
}
|
||||
|
||||
async function pruneBundleDirs(start: string): Promise<void> {
|
||||
const root = path.resolve(STORAGE, "bundles");
|
||||
let current = start;
|
||||
while (fsSync.existsSync(current) && path.resolve(current) !== root && path.resolve(current).startsWith(root)) {
|
||||
try {
|
||||
await fs.rmdir(current);
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
current = path.dirname(current);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves an uploaded supporting/prepared file from a temp path onto a print. Mirrors
|
||||
* MakersVault's save_asset_file: role is auto-classified from the filename (or upgraded
|
||||
* to PREPARED after sniffing a .3mf that turns out to be a sliced gcode.3mf); a new
|
||||
* PREPARED file replaces (deletes) any existing one for the print.
|
||||
*/
|
||||
export async function saveFileFromTemp(
|
||||
printId: string,
|
||||
tempPath: string,
|
||||
originalFilename: string,
|
||||
contentType: string | null | undefined,
|
||||
): Promise<Print> {
|
||||
const print = await prisma.print.findUnique({ where: { id: printId } });
|
||||
if (!print) throw new HttpError(404, "Print not found");
|
||||
|
||||
const safeName = sanitizeFilename(originalFilename || "supporting-file");
|
||||
let role: "SUPPORTING" | "PREPARED" = isPreparedPrintFilename(safeName) ? "PREPARED" : "SUPPORTING";
|
||||
const mime = mimeFromContentType(contentType, safeName);
|
||||
|
||||
const record = await prisma.printFile.create({
|
||||
data: {
|
||||
printId: print.id,
|
||||
filename: safeName,
|
||||
mime,
|
||||
role,
|
||||
storagePath: "pending",
|
||||
},
|
||||
});
|
||||
const storagePath = `bundles/${print.id}/${record.id}/${safeName}`;
|
||||
const destination = path.join(STORAGE, storagePath);
|
||||
|
||||
try {
|
||||
await fs.mkdir(path.dirname(destination), { recursive: true });
|
||||
const stat = await fs.stat(tempPath);
|
||||
if (stat.size > IMPORT_MAX_BYTES) throw new HttpError(413, "Related file exceeds size limit");
|
||||
try {
|
||||
await fs.rename(tempPath, destination);
|
||||
} catch (err: any) {
|
||||
if (err?.code === "EXDEV") {
|
||||
await fs.copyFile(tempPath, destination);
|
||||
await fs.rm(tempPath, { force: true });
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
let metadata: Record<string, unknown> | null = null;
|
||||
if (role === "PREPARED" || safeName.toLowerCase().endsWith(".3mf")) {
|
||||
metadata = (await inspectPreparedPrint(destination, safeName)) as Record<string, unknown> | null;
|
||||
}
|
||||
if (metadata) role = "PREPARED";
|
||||
|
||||
let oldPreparedPath: string | null = null;
|
||||
let oldPreparedId: string | null = null;
|
||||
const updatedPrint = await prisma.$transaction(async (tx) => {
|
||||
const currentPrint = await tx.print.findUnique({ where: { id: printId } });
|
||||
if (!currentPrint) throw new HttpError(404, "Print not found");
|
||||
await tx.printFile.update({
|
||||
where: { id: record.id },
|
||||
data: {
|
||||
storagePath,
|
||||
size: stat.size,
|
||||
role,
|
||||
metadata: (metadata as Prisma.InputJsonValue | undefined) ?? undefined,
|
||||
},
|
||||
});
|
||||
if (role === "PREPARED") {
|
||||
if (currentPrint.preparedFileId && currentPrint.preparedFileId !== record.id) {
|
||||
const old = await tx.printFile.findUnique({ where: { id: currentPrint.preparedFileId } });
|
||||
if (old) {
|
||||
oldPreparedPath = managedPrintFilePath(old);
|
||||
oldPreparedId = old.id;
|
||||
}
|
||||
}
|
||||
await tx.print.update({ where: { id: printId }, data: { preparedFileId: record.id } });
|
||||
if (oldPreparedId) {
|
||||
await tx.printFile.delete({ where: { id: oldPreparedId } });
|
||||
}
|
||||
}
|
||||
return tx.print.findUniqueOrThrow({ where: { id: printId } });
|
||||
});
|
||||
|
||||
if (oldPreparedPath) {
|
||||
await fs.rm(oldPreparedPath, { force: true }).catch(() => undefined);
|
||||
await pruneBundleDirs(path.dirname(oldPreparedPath));
|
||||
}
|
||||
return updatedPrint;
|
||||
} catch (err) {
|
||||
await fs.rm(destination, { force: true }).catch(() => undefined);
|
||||
await pruneBundleDirs(path.dirname(destination));
|
||||
await prisma.printFile.delete({ where: { id: record.id } }).catch(() => undefined);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteSupportingFile(printId: string, fileId: string): Promise<Print> {
|
||||
const record = await prisma.printFile.findUnique({ where: { id: fileId } });
|
||||
if (!record || record.printId !== printId || record.role !== "SUPPORTING") {
|
||||
throw new HttpError(404, "Supporting file not found");
|
||||
}
|
||||
const filePath = managedPrintFilePath(record);
|
||||
await prisma.printFile.delete({ where: { id: fileId } });
|
||||
await fs.rm(filePath, { force: true }).catch(() => undefined);
|
||||
await pruneBundleDirs(path.dirname(filePath));
|
||||
return prisma.print.findUniqueOrThrow({ where: { id: printId } });
|
||||
}
|
||||
|
||||
export async function deleteAllPrintFiles(printId: string): Promise<void> {
|
||||
const rows = await prisma.printFile.findMany({ where: { printId } });
|
||||
const paths = rows.map((r) => managedPrintFilePath(r));
|
||||
// Print.preparedFileId FK will already be gone via cascade delete of the Print row itself,
|
||||
// but this helper runs against a still-live print (e.g. before a full print delete elsewhere).
|
||||
await prisma.printFile.deleteMany({ where: { printId } });
|
||||
for (const p of paths) {
|
||||
await fs.rm(p, { force: true }).catch(() => undefined);
|
||||
await pruneBundleDirs(path.dirname(p));
|
||||
}
|
||||
}
|
||||
|
||||
export async function deletePreparedFile(printId: string): Promise<Print> {
|
||||
const print = await prisma.print.findUnique({ where: { id: printId } });
|
||||
if (!print || !print.preparedFileId) throw new HttpError(404, "No prepared print file");
|
||||
const record = await prisma.printFile.findUnique({ where: { id: print.preparedFileId } });
|
||||
if (!record || record.printId !== printId || record.role !== "PREPARED") {
|
||||
throw new HttpError(404, "No prepared print file");
|
||||
}
|
||||
const filePath = managedPrintFilePath(record);
|
||||
await prisma.$transaction([
|
||||
prisma.print.update({ where: { id: printId }, data: { preparedFileId: null } }),
|
||||
prisma.printFile.delete({ where: { id: record.id } }),
|
||||
]);
|
||||
await fs.rm(filePath, { force: true }).catch(() => undefined);
|
||||
await pruneBundleDirs(path.dirname(filePath));
|
||||
return prisma.print.findUniqueOrThrow({ where: { id: printId } });
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { prisma } from "../db";
|
||||
import { HttpError } from "../utils/fileUtils";
|
||||
import { toPrintOut, type PrintOut } from "../dto";
|
||||
import type { Plate, Print, PrintFile } from "@prisma/client";
|
||||
|
||||
export type FullPrint = { print: Print; plates: Plate[]; files: PrintFile[]; preparedFile: PrintFile | null };
|
||||
|
||||
export async function loadFullPrint(printId: string): Promise<FullPrint> {
|
||||
const print = await prisma.print.findUnique({ where: { id: printId } });
|
||||
if (!print) throw new HttpError(404, "Print not found");
|
||||
const [plates, files] = await Promise.all([
|
||||
prisma.plate.findMany({ where: { printId }, orderBy: { position: "asc" } }),
|
||||
prisma.printFile.findMany({ where: { printId } }),
|
||||
]);
|
||||
const preparedFile = print.preparedFileId
|
||||
? await prisma.printFile.findUnique({ where: { id: print.preparedFileId } })
|
||||
: null;
|
||||
return { print, plates, files, preparedFile };
|
||||
}
|
||||
|
||||
export async function printOutById(printId: string): Promise<PrintOut> {
|
||||
const full = await loadFullPrint(printId);
|
||||
return toPrintOut(full.print, full.plates, full.files, full.preparedFile);
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
import fs from "node:fs/promises";
|
||||
import fsSync from "node:fs";
|
||||
import path from "node:path";
|
||||
import sharp from "sharp";
|
||||
import { HttpError } from "../utils/fileUtils";
|
||||
import { STORAGE, THUMBS } from "../config";
|
||||
import { prisma } from "../db";
|
||||
import { listZipEntries, readZipEntry } from "../utils/zipReader";
|
||||
import type { Folder, Plate, Print } from "@prisma/client";
|
||||
|
||||
export const STORAGE_TEMPLATE_TOKENS = [
|
||||
"folder",
|
||||
"collection",
|
||||
"tags",
|
||||
"creator",
|
||||
"model",
|
||||
"name",
|
||||
"filename",
|
||||
"id",
|
||||
"plate",
|
||||
] as const;
|
||||
|
||||
export const DEFAULT_STORAGE_TEMPLATE = "{folder}/{model}/{filename}";
|
||||
|
||||
const TOKEN_RE = /\{([a-z_]+)\}/g;
|
||||
// oxlint-disable-next-line no-control-regex -- stripping control chars is the point here.
|
||||
const INVALID_SEGMENT_RE = /[<>:"|?*\x00-\x1f]/g;
|
||||
|
||||
export function sanitizePathSegment(value: string | null | undefined, fallback: string): string {
|
||||
let cleaned = (value || "").trim().replace(INVALID_SEGMENT_RE, "_");
|
||||
cleaned = cleaned.replace(/\//g, "_").replace(/\\/g, "_").replace(/^[ .]+|[ .]+$/g, "");
|
||||
if (cleaned === "" || cleaned === "." || cleaned === "..") cleaned = fallback;
|
||||
return cleaned.slice(0, 120);
|
||||
}
|
||||
|
||||
export function validateStorageTemplate(template: string | null | undefined): string {
|
||||
const normalized = (template || "").trim().replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
|
||||
if (!normalized) throw new HttpError(400, "Storage template cannot be empty");
|
||||
const tokens = [...normalized.matchAll(TOKEN_RE)].map((m) => m[1]);
|
||||
const unknown = tokens.filter((t) => !(STORAGE_TEMPLATE_TOKENS as readonly string[]).includes(t));
|
||||
if (unknown.length) throw new HttpError(400, `Unknown storage token: {${unknown[0]}}`);
|
||||
const filenameCount = (normalized.match(/\{filename\}/g) || []).length;
|
||||
if (filenameCount !== 1) throw new HttpError(400, "Storage template must contain {filename} exactly once");
|
||||
const finalSegment = normalized.split("/").pop() || "";
|
||||
if (!finalSegment.includes("{filename}")) {
|
||||
throw new HttpError(400, "{filename} must be in the final path segment");
|
||||
}
|
||||
const stripped = normalized.replace(TOKEN_RE, "");
|
||||
if (stripped.includes("{") || stripped.includes("}")) {
|
||||
throw new HttpError(400, "Storage template contains an invalid token");
|
||||
}
|
||||
if (normalized.split("/").some((part) => part === "" || part === "." || part === "..")) {
|
||||
throw new HttpError(400, "Storage template contains an unsafe path segment");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function renderTemplate(template: string, values: Record<string, string>): string {
|
||||
return template.replace(TOKEN_RE, (_match, token: string) => {
|
||||
if (!(token in values)) throw new HttpError(400, `Unknown storage token: {${token}}`);
|
||||
return values[token];
|
||||
});
|
||||
}
|
||||
|
||||
export async function folderSegments(folderId: string | null | undefined): Promise<string[]> {
|
||||
if (!folderId) return ["Unassigned"];
|
||||
const segments: string[] = [];
|
||||
const visited = new Set<string>();
|
||||
let current: Folder | null = await prisma.folder.findUnique({ where: { id: folderId } });
|
||||
while (current && !visited.has(current.id)) {
|
||||
visited.add(current.id);
|
||||
segments.unshift(sanitizePathSegment(current.name, "Folder"));
|
||||
current = current.parentId ? await prisma.folder.findUnique({ where: { id: current.parentId } }) : null;
|
||||
}
|
||||
return segments.length ? segments : ["Unassigned"];
|
||||
}
|
||||
|
||||
function assertWithinStorage(relative: string): string {
|
||||
const candidate = path.resolve(STORAGE, relative);
|
||||
const root = path.resolve(STORAGE);
|
||||
if (candidate === root || !candidate.startsWith(root + path.sep)) {
|
||||
throw new HttpError(400, "Storage template resolved outside the storage directory");
|
||||
}
|
||||
return relative;
|
||||
}
|
||||
|
||||
type PrintLike = Pick<Print, "id" | "name" | "creator" | "collection" | "tags" | "folderId">;
|
||||
|
||||
/** Renders the on-disk relative path for one plate of a print using the storage template. */
|
||||
export async function renderPlateStoragePath(
|
||||
print: PrintLike,
|
||||
plateFilename: string,
|
||||
platePosition: number,
|
||||
template?: string | null,
|
||||
): Promise<string> {
|
||||
const safeTemplate = validateStorageTemplate(template ?? (await getStorageTemplate()));
|
||||
const tagLabel = (print.tags || []).map((t) => t.trim()).filter(Boolean).join(" + ") || "Untagged";
|
||||
const modelLabel = sanitizePathSegment(print.name, "Model");
|
||||
const values: Record<string, string> = {
|
||||
folder: (await folderSegments(print.folderId)).join("/"),
|
||||
collection: sanitizePathSegment(print.collection || "Uncollected", "Uncollected"),
|
||||
tags: sanitizePathSegment(tagLabel, "Untagged"),
|
||||
creator: sanitizePathSegment(print.creator || "Unknown creator", "Unknown creator"),
|
||||
model: modelLabel,
|
||||
name: modelLabel,
|
||||
filename: sanitizePathSegment(plateFilename, "file"),
|
||||
id: print.id,
|
||||
plate: String(platePosition + 1),
|
||||
};
|
||||
const rendered = renderTemplate(safeTemplate, values).replace(/\\/g, "/");
|
||||
const parts = rendered.split("/").filter(Boolean).map((part) => sanitizePathSegment(part, "item"));
|
||||
if (!parts.length) throw new HttpError(400, "Storage template produced an empty path");
|
||||
return assertWithinStorage(path.join(...parts));
|
||||
}
|
||||
|
||||
export function samplePlateStoragePaths(template: string): [string, string] {
|
||||
const safeTemplate = validateStorageTemplate(template);
|
||||
const base = {
|
||||
folder: "Props/Workshop",
|
||||
collection: "Tabletop",
|
||||
tags: "Print in place + Useful",
|
||||
creator: "Example creator",
|
||||
model: "Multi-part gadget",
|
||||
name: "Multi-part gadget",
|
||||
id: "a1b2c3d4",
|
||||
};
|
||||
const first = renderTemplate(safeTemplate, { ...base, filename: "Base.3mf", plate: "1" });
|
||||
const second = renderTemplate(safeTemplate, { ...base, filename: "Lid.3mf", plate: "2" });
|
||||
return [first, second];
|
||||
}
|
||||
|
||||
function normalizeName(name: string): string {
|
||||
return name.trim().toLowerCase();
|
||||
}
|
||||
|
||||
/** Throws 409 if `requested` is already taken in this folder. Used for explicit renames. */
|
||||
export async function uniqueModelName(
|
||||
requested: string,
|
||||
folderId: string | null,
|
||||
excludeId?: string,
|
||||
): Promise<string> {
|
||||
const base = sanitizePathSegment(requested, "Model");
|
||||
const existing = await prisma.print.findFirst({
|
||||
where: {
|
||||
folderId: folderId ?? null,
|
||||
nameNormalized: normalizeName(base),
|
||||
...(excludeId ? { id: { not: excludeId } } : {}),
|
||||
},
|
||||
});
|
||||
if (existing) throw new HttpError(409, `A print named "${base}" already exists in this folder`);
|
||||
return base;
|
||||
}
|
||||
|
||||
/** Auto-suffixes `requested` until it's free in this folder. Used for creation/reassignment. */
|
||||
export async function availableModelName(
|
||||
requested: string,
|
||||
folderId: string | null,
|
||||
excludeId?: string,
|
||||
): Promise<string> {
|
||||
const base = sanitizePathSegment(requested, "Model");
|
||||
let candidate = base;
|
||||
let suffix = 2;
|
||||
// Small folders in practice; a loop of sequential existence checks is simple and correct.
|
||||
for (;;) {
|
||||
const existing = await prisma.print.findFirst({
|
||||
where: {
|
||||
folderId: folderId ?? null,
|
||||
nameNormalized: normalizeName(candidate),
|
||||
...(excludeId ? { id: { not: excludeId } } : {}),
|
||||
},
|
||||
});
|
||||
if (!existing) return candidate;
|
||||
candidate = `${base} (${suffix})`;
|
||||
suffix += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/** Auto-suffixes a plate's filename until it's unique among its print's other plates. */
|
||||
export async function availablePlateFilename(
|
||||
printId: string,
|
||||
desiredFilename: string,
|
||||
excludePlateId?: string,
|
||||
): Promise<string> {
|
||||
const siblings = await prisma.plate.findMany({
|
||||
where: { printId, ...(excludePlateId ? { id: { not: excludePlateId } } : {}) },
|
||||
select: { filename: true },
|
||||
});
|
||||
const taken = new Set(siblings.map((p) => p.filename.toLowerCase()));
|
||||
if (!taken.has(desiredFilename.toLowerCase())) return desiredFilename;
|
||||
const ext = path.extname(desiredFilename);
|
||||
const stem = path.basename(desiredFilename, ext);
|
||||
let suffix = 2;
|
||||
for (;;) {
|
||||
const candidate = `${stem} (${suffix})${ext}`;
|
||||
if (!taken.has(candidate.toLowerCase())) return candidate;
|
||||
suffix += 1;
|
||||
}
|
||||
}
|
||||
|
||||
export function managedPlatePath(plate: Pick<Plate, "storagePath">): string {
|
||||
return path.join(STORAGE, plate.storagePath);
|
||||
}
|
||||
|
||||
export async function pruneEmptyStorageDirs(start: string): Promise<void> {
|
||||
const root = path.resolve(STORAGE);
|
||||
let current = start;
|
||||
while (fsSync.existsSync(current) && path.resolve(current) !== root && path.resolve(current).startsWith(root)) {
|
||||
try {
|
||||
await fs.rmdir(current);
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
current = path.dirname(current);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-renders and (if needed) moves every plate of a print after a name/folder/tag/creator/
|
||||
* collection change. Mirrors MakersVault's relocate_asset, generalized to N plates. Supporting
|
||||
* and prepared PrintFiles are never touched here — they live at a fixed bundles/ path.
|
||||
*/
|
||||
export async function relocatePrint(print: PrintLike, plates: Plate[], template?: string | null): Promise<void> {
|
||||
for (const plate of plates) {
|
||||
const oldPath = managedPlatePath(plate);
|
||||
const newRelative = await renderPlateStoragePath(print, plate.filename, plate.position, template);
|
||||
const newPath = path.join(STORAGE, newRelative);
|
||||
if (path.resolve(oldPath) === path.resolve(newPath)) {
|
||||
if (plate.storagePath !== newRelative) {
|
||||
await prisma.plate.update({ where: { id: plate.id }, data: { storagePath: newRelative } });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const collision = await prisma.plate.findFirst({ where: { storagePath: newRelative, id: { not: plate.id } } });
|
||||
if (collision) throw new HttpError(409, `Storage path already exists: ${newRelative}`);
|
||||
const oldExists = fsSync.existsSync(oldPath);
|
||||
if (oldExists) {
|
||||
await fs.mkdir(path.dirname(newPath), { recursive: true });
|
||||
await fs.rename(oldPath, newPath);
|
||||
await pruneEmptyStorageDirs(path.dirname(oldPath));
|
||||
}
|
||||
await prisma.plate.update({ where: { id: plate.id }, data: { storagePath: newRelative } });
|
||||
}
|
||||
}
|
||||
|
||||
export async function reorganizeManagedPrints(template?: string | null): Promise<{ moved: number; skipped: number }> {
|
||||
let moved = 0;
|
||||
let skipped = 0;
|
||||
const prints = await prisma.print.findMany({ include: { plates: true }, orderBy: { id: "asc" } });
|
||||
for (const print of prints) {
|
||||
try {
|
||||
const before = new Map(print.plates.map((p) => [p.id, p.storagePath]));
|
||||
await relocatePrint(print, print.plates, template);
|
||||
const after = await prisma.plate.findMany({ where: { printId: print.id } });
|
||||
for (const plate of after) {
|
||||
if (before.get(plate.id) !== plate.storagePath) moved += 1;
|
||||
}
|
||||
} catch {
|
||||
skipped += 1;
|
||||
}
|
||||
}
|
||||
return { moved, skipped };
|
||||
}
|
||||
|
||||
// -- Settings (storage template) -----------------------------------------
|
||||
|
||||
export async function getStorageTemplate(): Promise<string> {
|
||||
const row = await prisma.setting.findUnique({ where: { key: "storage_path_template" } });
|
||||
return typeof row?.value === "string" ? row.value : DEFAULT_STORAGE_TEMPLATE;
|
||||
}
|
||||
|
||||
export async function setStorageTemplate(value: string): Promise<void> {
|
||||
await prisma.setting.upsert({
|
||||
where: { key: "storage_path_template" },
|
||||
create: { key: "storage_path_template", value },
|
||||
update: { value },
|
||||
});
|
||||
}
|
||||
|
||||
// -- Thumbnails -------------------------------------------------------------
|
||||
|
||||
async function saveThumbBuffer(plateId: string, input: Buffer): Promise<boolean> {
|
||||
const dest = path.join(THUMBS, `${plateId}.jpg`);
|
||||
const tmp = `${dest}.${process.pid}.${Date.now()}.tmp`;
|
||||
try {
|
||||
await sharp(input)
|
||||
.flatten({ background: { r: 248, g: 250, b: 252 } })
|
||||
.resize(512, 512, { fit: "inside", withoutEnlargement: true })
|
||||
.jpeg({ quality: 88, mozjpeg: true })
|
||||
.toFile(tmp);
|
||||
await fs.rename(tmp, dest);
|
||||
return true;
|
||||
} catch {
|
||||
await fs.rm(tmp, { force: true });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveThumbFromFile(plateId: string, srcPath: string): Promise<boolean> {
|
||||
try {
|
||||
const buf = await fs.readFile(srcPath);
|
||||
return await saveThumbBuffer(plateId, buf);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveThumbFromBytes(plateId: string, data: Buffer): Promise<boolean> {
|
||||
return saveThumbBuffer(plateId, data);
|
||||
}
|
||||
|
||||
const THUMBNAIL_ENTRY_PRIORITY: Record<string, number> = {
|
||||
"metadata/thumbnail.png": 0,
|
||||
"3d/thumbnail.png": 1,
|
||||
"thumbnail.png": 2,
|
||||
};
|
||||
|
||||
/** Extracts an embedded thumbnail image from a .3mf archive's zip payload, if present. */
|
||||
export async function extract3mfThumbnail(plateId: string, srcPath: string): Promise<boolean> {
|
||||
if (!srcPath.toLowerCase().endsWith(".3mf")) return false;
|
||||
try {
|
||||
const entries = await listZipEntries(srcPath);
|
||||
const imageNames = entries
|
||||
.filter((e) => !e.isDirectory)
|
||||
.filter((e) => /\.(png|jpe?g|webp)$/i.test(e.name) && /thumbnail/i.test(path.basename(e.name)))
|
||||
.filter((e) => e.size <= 16 * 1024 * 1024)
|
||||
.toSorted((a, b) => {
|
||||
const pa = THUMBNAIL_ENTRY_PRIORITY[a.name.toLowerCase().replace(/^\//, "")] ?? 10;
|
||||
const pb = THUMBNAIL_ENTRY_PRIORITY[b.name.toLowerCase().replace(/^\//, "")] ?? 10;
|
||||
return pa - pb || a.name.length - b.name.length;
|
||||
});
|
||||
for (const entry of imageNames) {
|
||||
const buf = await readZipEntry(srcPath, entry.name, 16 * 1024 * 1024);
|
||||
if (buf && (await saveThumbBuffer(plateId, buf))) return true;
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function ensurePlateThumbnail(plateId: string, srcPath: string): Promise<boolean> {
|
||||
const existing = path.join(THUMBS, `${plateId}.jpg`);
|
||||
if (fsSync.existsSync(existing)) return true;
|
||||
return extract3mfThumbnail(plateId, srcPath);
|
||||
}
|
||||
|
||||
export function plateThumbPath(plateId: string): string {
|
||||
return path.join(THUMBS, `${plateId}.jpg`);
|
||||
}
|
||||
|
||||
export function plateThumbExists(plateId: string): boolean {
|
||||
return fsSync.existsSync(plateThumbPath(plateId));
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { prisma } from "../db";
|
||||
|
||||
// Storage-template settings (get/setStorageTemplate) live in printService.ts — not duplicated here.
|
||||
|
||||
const MOUNT_IMPORT_ENABLED_KEY = "mount_import_enabled";
|
||||
const MOUNT_IMPORT_COPY_KEY = "mount_import_copy";
|
||||
|
||||
async function getBoolSetting(key: string, fallback: boolean): Promise<boolean> {
|
||||
const row = await prisma.setting.findUnique({ where: { key } });
|
||||
if (row === null || row === undefined) return fallback;
|
||||
return typeof row.value === "boolean" ? row.value : fallback;
|
||||
}
|
||||
|
||||
async function setBoolSetting(key: string, value: boolean): Promise<void> {
|
||||
await prisma.setting.upsert({
|
||||
where: { key },
|
||||
create: { key, value },
|
||||
update: { value },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getMountImportEnabled(fallback: boolean): Promise<boolean> {
|
||||
return getBoolSetting(MOUNT_IMPORT_ENABLED_KEY, fallback);
|
||||
}
|
||||
|
||||
export async function setMountImportEnabled(value: boolean): Promise<void> {
|
||||
await setBoolSetting(MOUNT_IMPORT_ENABLED_KEY, value);
|
||||
}
|
||||
|
||||
export async function getMountImportCopy(fallback: boolean): Promise<boolean> {
|
||||
return getBoolSetting(MOUNT_IMPORT_COPY_KEY, fallback);
|
||||
}
|
||||
|
||||
export async function setMountImportCopy(value: boolean): Promise<void> {
|
||||
await setBoolSetting(MOUNT_IMPORT_COPY_KEY, value);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import { prisma } from "../db";
|
||||
import { HttpError, sanitizeFilename, guessMimeFromPath } from "../utils/fileUtils";
|
||||
import { IMPORT_MAX_BYTES } from "../config";
|
||||
import { listZipEntries as listRawZipEntries, readZipEntry } from "../utils/zipReader";
|
||||
import { validateParentFolder } from "./folderService";
|
||||
import { createPrint, type PrintMetaInput } from "./printCreation";
|
||||
import type { Print, Plate } from "@prisma/client";
|
||||
|
||||
export type ZipEntrySummary = { path: string; size: number };
|
||||
|
||||
/** Normalizes a raw zip entry name: backslashes -> slashes, strips leading slash, rejects
|
||||
* empty/directory/".."-containing paths. Returns null if the entry should be ignored. */
|
||||
export function normalizeZipEntryPath(name: string): string | null {
|
||||
let cleaned = (name || "").replace(/\\/g, "/").replace(/^\/+/, "");
|
||||
if (!cleaned || cleaned.endsWith("/")) return null;
|
||||
const parts = cleaned.split("/").filter((p) => p.length > 0);
|
||||
if (!parts.length || parts.some((p) => p === "..")) return null;
|
||||
const filtered = parts.filter((p) => p !== ".");
|
||||
if (!filtered.length) return null;
|
||||
return filtered.join("/");
|
||||
}
|
||||
|
||||
function sanitizeFolderName(name: string): string {
|
||||
const cleaned = (name || "").replace(/\0/g, "").trim().replace(/\//g, "_").replace(/\\/g, "_");
|
||||
return cleaned || "folder";
|
||||
}
|
||||
|
||||
/** Lists every real (non-directory) entry in a zip archive, path-normalized and sorted. */
|
||||
export async function listZipEntries(zipPath: string): Promise<ZipEntrySummary[]> {
|
||||
const raw = await listRawZipEntries(zipPath);
|
||||
const entries: ZipEntrySummary[] = [];
|
||||
for (const entry of raw) {
|
||||
if (entry.isDirectory) continue;
|
||||
const name = normalizeZipEntryPath(entry.name);
|
||||
if (!name) continue;
|
||||
entries.push({ path: name, size: entry.size });
|
||||
}
|
||||
entries.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
||||
return entries;
|
||||
}
|
||||
|
||||
type FolderCache = Map<string, string>;
|
||||
|
||||
async function getOrCreateFolder(
|
||||
parentId: string | null,
|
||||
parentKey: string,
|
||||
name: string,
|
||||
cache: FolderCache,
|
||||
): Promise<string> {
|
||||
const key = `${parentKey}/${name}`;
|
||||
const cached = cache.get(key);
|
||||
if (cached) return cached;
|
||||
const existing = await prisma.folder.findFirst({ where: { parentId, name } });
|
||||
if (existing) {
|
||||
cache.set(key, existing.id);
|
||||
return existing.id;
|
||||
}
|
||||
const folder = await prisma.folder.create({ data: { name, parentId, tags: [] } });
|
||||
cache.set(key, folder.id);
|
||||
return folder.id;
|
||||
}
|
||||
|
||||
/** Recreates the zip's directory structure as nested Folders, returning the leaf folder id
|
||||
* for one entry's path. Mirrors MakersVault's resolve_zip_folder_id. */
|
||||
export async function resolveZipFolderId(
|
||||
baseFolderId: string | null,
|
||||
entryPath: string,
|
||||
cache: FolderCache,
|
||||
): Promise<string | null> {
|
||||
const segments = entryPath.split("/").slice(0, -1).filter(Boolean);
|
||||
if (!segments.length) return baseFolderId;
|
||||
let targetId = baseFolderId;
|
||||
let parentKey = baseFolderId || "root";
|
||||
for (const segment of segments) {
|
||||
const safe = sanitizeFolderName(segment);
|
||||
if (!safe) continue;
|
||||
targetId = await getOrCreateFolder(targetId, parentKey, safe, cache);
|
||||
parentKey = `${parentKey}/${safe}`;
|
||||
}
|
||||
return targetId;
|
||||
}
|
||||
|
||||
export type ZipExtractOptions = {
|
||||
title?: string | null;
|
||||
notes?: string | null;
|
||||
tags?: string[];
|
||||
folderId?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts selected zip entries into individual single-plate Prints (one per entry). Each
|
||||
* entry lands in a Folder tree recreated from its path within the zip (nested under
|
||||
* options.folderId, if any). Returns the created prints plus the list of entry names that
|
||||
* failed to extract (missing, a directory, or over the size cap).
|
||||
*/
|
||||
export async function extractZipEntriesToPrints(
|
||||
zipPath: string,
|
||||
selections: string[],
|
||||
options: ZipExtractOptions,
|
||||
): Promise<{ prints: (Print & { plates: Plate[] })[]; failed: string[] }> {
|
||||
const normalized = selections.map((s) => normalizeZipEntryPath(s));
|
||||
const ordered: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const name of normalized) {
|
||||
if (!name || seen.has(name)) continue;
|
||||
seen.add(name);
|
||||
ordered.push(name);
|
||||
}
|
||||
if (!ordered.length) throw new HttpError(400, "No zip entries selected");
|
||||
|
||||
if (options.folderId) {
|
||||
await validateParentFolder(options.folderId);
|
||||
}
|
||||
|
||||
const raw = await listRawZipEntries(zipPath);
|
||||
const entryMap = new Map<string, { isDirectory: boolean; size: number }>();
|
||||
for (const entry of raw) {
|
||||
const name = normalizeZipEntryPath(entry.name);
|
||||
if (!name) continue;
|
||||
entryMap.set(name, { isDirectory: entry.isDirectory, size: entry.size });
|
||||
}
|
||||
|
||||
const prints: (Print & { plates: Plate[] })[] = [];
|
||||
const failed: string[] = [];
|
||||
const folderCache: FolderCache = new Map();
|
||||
|
||||
for (const entryName of ordered) {
|
||||
const info = entryMap.get(entryName);
|
||||
if (!info || info.isDirectory) {
|
||||
failed.push(entryName);
|
||||
continue;
|
||||
}
|
||||
let tempPath: string | null = null;
|
||||
try {
|
||||
const targetFolderId = await resolveZipFolderId(options.folderId ?? null, entryName, folderCache);
|
||||
const filename = sanitizeFilename(path.basename(entryName));
|
||||
const buffer = await readZipEntry(zipPath, entryName, IMPORT_MAX_BYTES);
|
||||
if (!buffer) throw new Error("Extracted file exceeds size limit or could not be read");
|
||||
|
||||
tempPath = path.join(os.tmpdir(), `printstash-zip-${crypto.randomBytes(8).toString("hex")}`);
|
||||
await fs.writeFile(tempPath, buffer);
|
||||
|
||||
const meta: PrintMetaInput = {
|
||||
title: options.title ?? null,
|
||||
notes: options.notes ?? null,
|
||||
tags: options.tags ?? [],
|
||||
folderId: targetFolderId,
|
||||
};
|
||||
const mime = guessMimeFromPath(filename);
|
||||
const { print, plates } = await createPrint(meta, path.parse(filename).name, [
|
||||
{ filename, mime, tempFilePath: tempPath },
|
||||
]);
|
||||
tempPath = null;
|
||||
prints.push({ ...print, plates });
|
||||
} catch {
|
||||
if (tempPath) await fs.rm(tempPath, { force: true }).catch(() => undefined);
|
||||
failed.push(entryName);
|
||||
}
|
||||
}
|
||||
|
||||
return { prints, failed };
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import multer from "multer";
|
||||
import { IMPORT_MAX_BYTES } from "./config";
|
||||
|
||||
const diskStorage = multer.diskStorage({
|
||||
destination: (_req, _file, cb) => cb(null, os.tmpdir()),
|
||||
filename: (_req, file, cb) => {
|
||||
const ext = path.extname(file.originalname || "");
|
||||
cb(null, `printstash-upload-${crypto.randomBytes(8).toString("hex")}${ext}`);
|
||||
},
|
||||
});
|
||||
|
||||
/** Disk-backed multer instance for model file uploads (never memoryStorage — files can be large). */
|
||||
export const modelUpload = multer({ storage: diskStorage, limits: { fileSize: IMPORT_MAX_BYTES } });
|
||||
|
||||
/** Small in-memory multer instance for the 8MB-capped client-rendered thumbnail upload. */
|
||||
export const thumbnailUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 8 * 1024 * 1024 + 1 } });
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { NextFunction, Request, RequestHandler, Response } from "express";
|
||||
|
||||
/**
|
||||
* Express 4 does not forward rejected promises from async handlers to the error middleware —
|
||||
* an unwrapped `async (req, res) => { throw new HttpError(...) }` would just hang the request.
|
||||
* Wrap every async route handler with this so thrown/rejected errors reach the HttpError
|
||||
* middleware in app.ts.
|
||||
*/
|
||||
export function asyncHandler(
|
||||
fn: (req: Request, res: Response, next: NextFunction) => Promise<unknown>,
|
||||
): RequestHandler {
|
||||
return (req, res, next) => {
|
||||
Promise.resolve(fn(req, res, next)).catch(next);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import path from "node:path";
|
||||
import mimeTypes from "mime-types";
|
||||
import { IMPORT_ALLOWED_EXTS } from "../config";
|
||||
|
||||
export function sanitizeFilename(name: string | null | undefined): string {
|
||||
let cleaned = (name || "").replace(/\0/g, "").trim();
|
||||
cleaned = cleaned.replace(/\//g, "_").replace(/\\/g, "_");
|
||||
cleaned = path.basename(cleaned);
|
||||
return cleaned || "imported-file";
|
||||
}
|
||||
|
||||
export function parseContentDisposition(cd: string | null | undefined): string | null {
|
||||
if (!cd) return null;
|
||||
const starMatch = cd.match(/filename\*=([^']*)''([^;]+)/i);
|
||||
if (starMatch) return decodeURIComponent(starMatch[2]);
|
||||
const plainMatch = cd.match(/filename="?([^";]+)"?/i);
|
||||
return plainMatch ? plainMatch[1] : null;
|
||||
}
|
||||
|
||||
export class HttpError extends Error {
|
||||
status: number;
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildImportFilename(url: string, headers: Headers, override?: string | null): string {
|
||||
let name: string;
|
||||
if (override) {
|
||||
name = sanitizeFilename(override);
|
||||
} else {
|
||||
const disposition = headers.get("content-disposition");
|
||||
name = parseContentDisposition(disposition) || "";
|
||||
if (!name) {
|
||||
try {
|
||||
name = path.basename(new URL(url).pathname);
|
||||
} catch {
|
||||
name = "";
|
||||
}
|
||||
}
|
||||
name = sanitizeFilename(name);
|
||||
}
|
||||
|
||||
const contentType = headers.get("content-type") || "";
|
||||
const parsed = path.parse(name);
|
||||
let ext = parsed.ext.toLowerCase();
|
||||
if (!ext) {
|
||||
try {
|
||||
ext = path.extname(new URL(url).pathname).toLowerCase();
|
||||
} catch {
|
||||
ext = "";
|
||||
}
|
||||
}
|
||||
if (!ext && contentType) {
|
||||
const guessed = mimeTypes.extension(contentType.split(";")[0].trim());
|
||||
ext = guessed ? `.${guessed}` : "";
|
||||
}
|
||||
if (ext && !name.toLowerCase().endsWith(ext)) {
|
||||
name = `${parsed.name || "imported-file"}${ext}`;
|
||||
}
|
||||
if (!ext) {
|
||||
throw new HttpError(415, "Unable to determine file extension");
|
||||
}
|
||||
if (!IMPORT_ALLOWED_EXTS.has(ext)) {
|
||||
throw new HttpError(415, `Unsupported file type: ${ext}`);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
export function mimeFromContentType(contentType: string | null | undefined, filename: string): string {
|
||||
const base = (contentType || "").split(";")[0].trim().toLowerCase();
|
||||
if (!base || base === "application/octet-stream" || base === "binary/octet-stream") {
|
||||
return mimeTypes.lookup(filename) || base || "application/octet-stream";
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
export function isHtmlContentType(contentType: string | null | undefined): boolean {
|
||||
const base = (contentType || "").split(";")[0].trim().toLowerCase();
|
||||
return base === "text/html" || base === "application/xhtml+xml";
|
||||
}
|
||||
|
||||
export function isJsonContentType(contentType: string | null | undefined): boolean {
|
||||
const base = (contentType || "").split(";")[0].trim().toLowerCase();
|
||||
return base === "application/json" || base === "text/json" || base === "application/ld+json";
|
||||
}
|
||||
|
||||
export function guessMimeFromPath(filePath: string): string {
|
||||
return mimeTypes.lookup(filePath) || "application/octet-stream";
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import dns from "node:dns/promises";
|
||||
import net from "node:net";
|
||||
import { HttpError } from "./fileUtils";
|
||||
|
||||
function isBlockedIp(address: string): boolean {
|
||||
const type = net.isIP(address);
|
||||
if (!type) return true;
|
||||
if (type === 4) {
|
||||
const [a, b] = address.split(".").map(Number);
|
||||
if (a === 10) return true;
|
||||
if (a === 127) return true;
|
||||
if (a === 169 && b === 254) return true;
|
||||
if (a === 172 && b >= 16 && b <= 31) return true;
|
||||
if (a === 192 && b === 168) return true;
|
||||
if (a === 0) return true;
|
||||
if (a >= 224) return true; // multicast/reserved
|
||||
return false;
|
||||
}
|
||||
const lower = address.toLowerCase();
|
||||
if (lower === "::1") return true;
|
||||
if (lower.startsWith("fe80:")) return true;
|
||||
if (lower.startsWith("fc") || lower.startsWith("fd")) return true; // unique local
|
||||
if (lower.startsWith("ff")) return true; // multicast
|
||||
if (lower === "::") return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function validateRemoteHost(host: string): Promise<void> {
|
||||
if (!host) throw new HttpError(400, "Invalid URL host");
|
||||
const lowered = host.toLowerCase();
|
||||
if (lowered === "localhost" || lowered.endsWith(".local")) {
|
||||
throw new HttpError(400, "Local addresses are not allowed");
|
||||
}
|
||||
if (net.isIP(host)) {
|
||||
if (isBlockedIp(host)) throw new HttpError(400, "Local addresses are not allowed");
|
||||
return;
|
||||
}
|
||||
let records: string[];
|
||||
try {
|
||||
const [v4, v6] = await Promise.allSettled([dns.resolve4(host), dns.resolve6(host)]);
|
||||
records = [
|
||||
...(v4.status === "fulfilled" ? v4.value : []),
|
||||
...(v6.status === "fulfilled" ? v6.value : []),
|
||||
];
|
||||
} catch {
|
||||
throw new HttpError(400, "Unable to resolve host");
|
||||
}
|
||||
if (records.length === 0) throw new HttpError(400, "Unable to resolve host");
|
||||
for (const ip of records) {
|
||||
if (isBlockedIp(ip)) throw new HttpError(400, "Local addresses are not allowed");
|
||||
}
|
||||
}
|
||||
|
||||
export async function validateRemoteUrl(url: string): Promise<string> {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
throw new HttpError(400, "Invalid URL");
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new HttpError(400, "URL must start with http:// or https://");
|
||||
}
|
||||
if (parsed.username || parsed.password) {
|
||||
throw new HttpError(400, "Credentials in URL are not allowed");
|
||||
}
|
||||
if (!parsed.hostname) throw new HttpError(400, "Invalid URL");
|
||||
await validateRemoteHost(parsed.hostname);
|
||||
return url;
|
||||
}
|
||||
|
||||
export async function normalizeImportUrl(raw: string | null | undefined): Promise<string> {
|
||||
let value = (raw || "").trim();
|
||||
if (!value) throw new HttpError(400, "URL is required");
|
||||
if (!value.includes("://")) value = `https://${value}`;
|
||||
return validateRemoteUrl(value);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { ZodType } from "zod";
|
||||
import { HttpError } from "./fileUtils";
|
||||
|
||||
// The explicit `any, any` params decouple T from the schema's Def/Input type positions —
|
||||
// without them, TS can infer T from ZodEffects/ZodDefault's Input type (which is `T | undefined`
|
||||
// for a field with `.default()`) instead of its Output type, making parsed fields look optional.
|
||||
export function parseBody<T>(schema: ZodType<T, any, any>, data: unknown): T {
|
||||
const result = schema.safeParse(data);
|
||||
if (!result.success) {
|
||||
throw new HttpError(400, result.error.issues.map((i) => i.message).join("; ") || "Invalid request body");
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import yauzl from "yauzl";
|
||||
|
||||
export type ZipEntryInfo = { name: string; size: number; isDirectory: boolean };
|
||||
|
||||
function openZip(filePath: string): Promise<yauzl.ZipFile> {
|
||||
return new Promise((resolve, reject) => {
|
||||
yauzl.open(filePath, { lazyEntries: true, autoClose: false }, (err, zipfile) => {
|
||||
if (err || !zipfile) return reject(err || new Error("Failed to open zip"));
|
||||
resolve(zipfile);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Lists every entry in a zip archive (files and directories) without extracting contents. */
|
||||
export async function listZipEntries(filePath: string): Promise<ZipEntryInfo[]> {
|
||||
const zipfile = await openZip(filePath);
|
||||
return new Promise((resolve, reject) => {
|
||||
const entries: ZipEntryInfo[] = [];
|
||||
zipfile.on("entry", (entry) => {
|
||||
const isDirectory = entry.fileName.endsWith('/');
|
||||
entries.push({ name: entry.fileName, size: entry.uncompressedSize, isDirectory });
|
||||
zipfile.readEntry();
|
||||
});
|
||||
zipfile.on("end", () => {
|
||||
zipfile.close();
|
||||
resolve(entries);
|
||||
});
|
||||
zipfile.on("error", (err) => {
|
||||
zipfile.close();
|
||||
reject(err);
|
||||
});
|
||||
zipfile.readEntry();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a single named entry into memory, capped at maxBytes (returns null if the
|
||||
* entry doesn't exist, is a directory, or exceeds the cap). Used for small metadata
|
||||
* files (thumbnails, slicer metadata) inside .3mf/.zip archives, never whole models.
|
||||
*/
|
||||
export async function readZipEntry(filePath: string, entryName: string, maxBytes: number): Promise<Buffer | null> {
|
||||
const zipfile = await openZip(filePath);
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const finish = (value: Buffer | null, err?: Error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
zipfile.close();
|
||||
if (err) reject(err);
|
||||
else resolve(value);
|
||||
};
|
||||
zipfile.on("entry", (entry) => {
|
||||
if (entry.fileName !== entryName || entry.fileName.endsWith('/')) {
|
||||
zipfile.readEntry();
|
||||
return;
|
||||
}
|
||||
if (entry.uncompressedSize > maxBytes) {
|
||||
finish(null);
|
||||
return;
|
||||
}
|
||||
zipfile.openReadStream(entry, (err, stream) => {
|
||||
if (err || !stream) {
|
||||
finish(null, err ?? undefined);
|
||||
return;
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
total += chunk.length;
|
||||
if (total > maxBytes) {
|
||||
stream.destroy();
|
||||
finish(null);
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
stream.on("end", () => finish(Buffer.concat(chunks)));
|
||||
stream.on("error", (streamErr) => finish(null, streamErr));
|
||||
});
|
||||
});
|
||||
zipfile.on("end", () => finish(null));
|
||||
zipfile.on("error", (err) => finish(null, err));
|
||||
zipfile.readEntry();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Streams every requested entry to an `onEntry` callback in one pass over the archive
|
||||
* (used to extract many selected entries, or every entry, without reopening the zip
|
||||
* per file). `onEntry` receives the entry metadata and a readable stream; it must
|
||||
* consume or destroy the stream before returning so the walk can continue.
|
||||
*/
|
||||
export async function walkZipEntries(
|
||||
filePath: string,
|
||||
shouldExtract: (entry: ZipEntryInfo) => boolean,
|
||||
onEntry: (entry: ZipEntryInfo, stream: NodeJS.ReadableStream) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const zipfile = await openZip(filePath);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
zipfile.on("entry", (entry) => {
|
||||
const isDirectory = entry.fileName.endsWith('/');
|
||||
const info: ZipEntryInfo = { name: entry.fileName, size: entry.uncompressedSize, isDirectory };
|
||||
if (isDirectory || !shouldExtract(info)) {
|
||||
zipfile.readEntry();
|
||||
return;
|
||||
}
|
||||
zipfile.openReadStream(entry, (err, stream) => {
|
||||
if (err || !stream) {
|
||||
zipfile.readEntry();
|
||||
return;
|
||||
}
|
||||
onEntry(info, stream)
|
||||
.catch(() => undefined)
|
||||
.finally(() => zipfile.readEntry());
|
||||
});
|
||||
});
|
||||
zipfile.on("end", () => {
|
||||
zipfile.close();
|
||||
resolve();
|
||||
});
|
||||
zipfile.on("error", (err) => {
|
||||
zipfile.close();
|
||||
reject(err);
|
||||
});
|
||||
zipfile.readEntry();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import fs from "node:fs";
|
||||
import fsp from "node:fs/promises";
|
||||
import zlib from "node:zlib";
|
||||
|
||||
// Minimal streaming ZIP writer (local header + streamed DEFLATE data + trailing data
|
||||
// descriptor + central directory + EOCD). No external zip-writing dependency is available in
|
||||
// this project (yauzl, the existing dependency, is read-only), so archive creation for
|
||||
// /download/zip and /folder/:id/download is implemented directly against node:zlib here.
|
||||
|
||||
const CRC_TABLE = (() => {
|
||||
const table = new Uint32Array(256);
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) {
|
||||
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
}
|
||||
table[n] = c >>> 0;
|
||||
}
|
||||
return table;
|
||||
})();
|
||||
|
||||
function crc32Update(crc: number, buf: Buffer): number {
|
||||
let c = crc ^ 0xffffffff;
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
||||
}
|
||||
return (c ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function dosDateTime(date: Date): { time: number; date: number } {
|
||||
const time = ((date.getHours() & 0x1f) << 11) | ((date.getMinutes() & 0x3f) << 5) | ((date.getSeconds() >> 1) & 0x1f);
|
||||
const dateVal = (((date.getFullYear() - 1980) & 0x7f) << 9) | (((date.getMonth() + 1) & 0xf) << 5) | (date.getDate() & 0x1f);
|
||||
return { time, date: dateVal };
|
||||
}
|
||||
|
||||
export type ZipEntryDescriptor = { arcname: string; filePath: string };
|
||||
|
||||
type CentralRecord = {
|
||||
nameBuf: Buffer;
|
||||
crc: number;
|
||||
compressedSize: number;
|
||||
uncompressedSize: number;
|
||||
localHeaderOffset: number;
|
||||
time: number;
|
||||
date: number;
|
||||
};
|
||||
|
||||
/** Streams `entries` into a new zip file at `destPath`. Skips any entry whose source file no
|
||||
* longer exists on disk (best-effort, mirrors the caller filtering unresolved files first). */
|
||||
export async function writeZip(destPath: string, entries: ZipEntryDescriptor[]): Promise<void> {
|
||||
const out = fs.createWriteStream(destPath);
|
||||
let offset = 0;
|
||||
const write = (buf: Buffer): Promise<void> =>
|
||||
new Promise((resolve, reject) => {
|
||||
out.write(buf, (err) => (err ? reject(err) : resolve()));
|
||||
offset += buf.length;
|
||||
});
|
||||
|
||||
const records: CentralRecord[] = [];
|
||||
|
||||
try {
|
||||
for (const entry of entries) {
|
||||
let stat;
|
||||
try {
|
||||
stat = await fsp.stat(entry.filePath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const nameBuf = Buffer.from(entry.arcname.replace(/\\/g, "/"), "utf-8");
|
||||
const { time, date } = dosDateTime(stat.mtime);
|
||||
const localHeaderOffset = offset;
|
||||
|
||||
const localHeader = Buffer.alloc(30);
|
||||
localHeader.writeUInt32LE(0x04034b50, 0);
|
||||
localHeader.writeUInt16LE(20, 4); // version needed
|
||||
localHeader.writeUInt16LE(0x0008, 6); // flags: data descriptor follows
|
||||
localHeader.writeUInt16LE(8, 8); // method: deflate
|
||||
localHeader.writeUInt16LE(time, 10);
|
||||
localHeader.writeUInt16LE(date, 12);
|
||||
localHeader.writeUInt32LE(0, 14); // crc (in descriptor)
|
||||
localHeader.writeUInt32LE(0, 18); // compressed size (in descriptor)
|
||||
localHeader.writeUInt32LE(0, 22); // uncompressed size (in descriptor)
|
||||
localHeader.writeUInt16LE(nameBuf.length, 26);
|
||||
localHeader.writeUInt16LE(0, 28);
|
||||
await write(localHeader);
|
||||
await write(nameBuf);
|
||||
|
||||
let crc = 0;
|
||||
let uncompressedSize = 0;
|
||||
let compressedSize = 0;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const readStream = fs.createReadStream(entry.filePath);
|
||||
const deflate = zlib.createDeflateRaw();
|
||||
readStream.on("data", (raw: string | Buffer) => {
|
||||
const chunk = Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
|
||||
crc = crc32Update(crc, chunk);
|
||||
uncompressedSize += chunk.length;
|
||||
});
|
||||
readStream.on("error", reject);
|
||||
deflate.on("data", (raw: string | Buffer) => {
|
||||
const chunk = Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
|
||||
compressedSize += chunk.length;
|
||||
offset += chunk.length;
|
||||
if (!out.write(chunk)) {
|
||||
deflate.pause();
|
||||
out.once("drain", () => deflate.resume());
|
||||
}
|
||||
});
|
||||
deflate.on("error", reject);
|
||||
deflate.on("end", resolve);
|
||||
readStream.pipe(deflate);
|
||||
});
|
||||
|
||||
const descriptor = Buffer.alloc(16);
|
||||
descriptor.writeUInt32LE(0x08074b50, 0);
|
||||
descriptor.writeUInt32LE(crc, 4);
|
||||
descriptor.writeUInt32LE(compressedSize, 8);
|
||||
descriptor.writeUInt32LE(uncompressedSize, 12);
|
||||
await write(descriptor);
|
||||
|
||||
records.push({ nameBuf, crc, compressedSize, uncompressedSize, localHeaderOffset, time, date });
|
||||
}
|
||||
|
||||
const cdStart = offset;
|
||||
for (const rec of records) {
|
||||
const central = Buffer.alloc(46);
|
||||
central.writeUInt32LE(0x02014b50, 0);
|
||||
central.writeUInt16LE(20, 4); // version made by
|
||||
central.writeUInt16LE(20, 6); // version needed
|
||||
central.writeUInt16LE(0x0008, 8); // flags
|
||||
central.writeUInt16LE(8, 10); // method
|
||||
central.writeUInt16LE(rec.time, 12);
|
||||
central.writeUInt16LE(rec.date, 14);
|
||||
central.writeUInt32LE(rec.crc, 16);
|
||||
central.writeUInt32LE(rec.compressedSize, 20);
|
||||
central.writeUInt32LE(rec.uncompressedSize, 24);
|
||||
central.writeUInt16LE(rec.nameBuf.length, 28);
|
||||
central.writeUInt16LE(0, 30); // extra length
|
||||
central.writeUInt16LE(0, 32); // comment length
|
||||
central.writeUInt16LE(0, 34); // disk number start
|
||||
central.writeUInt16LE(0, 36); // internal attrs
|
||||
central.writeUInt32LE(0, 38); // external attrs
|
||||
central.writeUInt32LE(rec.localHeaderOffset, 42);
|
||||
await write(central);
|
||||
await write(rec.nameBuf);
|
||||
}
|
||||
const cdSize = offset - cdStart;
|
||||
|
||||
const eocd = Buffer.alloc(22);
|
||||
eocd.writeUInt32LE(0x06054b50, 0);
|
||||
eocd.writeUInt16LE(0, 4);
|
||||
eocd.writeUInt16LE(0, 6);
|
||||
eocd.writeUInt16LE(records.length, 8);
|
||||
eocd.writeUInt16LE(records.length, 10);
|
||||
eocd.writeUInt32LE(cdSize, 12);
|
||||
eocd.writeUInt32LE(cdStart, 16);
|
||||
eocd.writeUInt16LE(0, 20);
|
||||
await write(eocd);
|
||||
|
||||
await new Promise<void>((resolve, reject) => out.end((err?: Error | null) => (err ? reject(err) : resolve())));
|
||||
} catch (err) {
|
||||
out.destroy();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import request from "supertest";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { createApp } from "../src/app";
|
||||
import { prisma } from "../src/db";
|
||||
import { listZipEntries } from "../src/utils/zipReader";
|
||||
|
||||
const app = createApp();
|
||||
|
||||
let token: string;
|
||||
const createdPrintIds: string[] = [];
|
||||
const createdFolderIds: string[] = [];
|
||||
|
||||
function tmpFile(name: string, contents: string): string {
|
||||
// supertest's .attach() uses the path's basename as the uploaded filename, so each file
|
||||
// needs its own directory to keep that basename exactly as given (e.g. "solo.stl").
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "printstash-test-"));
|
||||
const p = path.join(dir, name);
|
||||
fs.writeFileSync(p, contents);
|
||||
return p;
|
||||
}
|
||||
|
||||
async function trackPrint(id: string) {
|
||||
createdPrintIds.push(id);
|
||||
}
|
||||
|
||||
async function cleanupPrint(id: string) {
|
||||
await request(app).delete(`/print/${id}`).set("Authorization", `Bearer ${token}`);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const res = await request(app).post("/login").send({ username: "admin", password: "super-secret" });
|
||||
if (res.status !== 200) {
|
||||
throw new Error(`Failed to log in during test setup: ${res.status} ${JSON.stringify(res.body)}`);
|
||||
}
|
||||
token = res.body.token;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
for (const id of createdPrintIds) {
|
||||
await cleanupPrint(id).catch(() => undefined);
|
||||
}
|
||||
for (const id of createdFolderIds) {
|
||||
await request(app)
|
||||
.delete(`/folder/${id}`)
|
||||
.set("Authorization", `Bearer ${token}`)
|
||||
.catch(() => undefined);
|
||||
}
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
function auth(req: request.Test): request.Test {
|
||||
return req.set("Authorization", `Bearer ${token}`);
|
||||
}
|
||||
|
||||
describe("auth", () => {
|
||||
it("rejects requests without a token", async () => {
|
||||
const res = await request(app).get("/prints");
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("rejects a bad login", async () => {
|
||||
const res = await request(app).post("/login").send({ username: "admin", password: "wrong" });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("upload: separate vs multiplate", () => {
|
||||
it("uploading a single file creates a one-plate print", async () => {
|
||||
const f = tmpFile("solo.stl", "solid solo endsolid");
|
||||
const res = await auth(request(app).post("/upload")).attach("files", f);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.prints).toHaveLength(1);
|
||||
expect(res.body.prints[0].plates).toHaveLength(1);
|
||||
expect(res.body.prints[0].plates[0].filename).toBe("solo.stl");
|
||||
await trackPrint(res.body.prints[0].id);
|
||||
fs.rmSync(f, { force: true });
|
||||
});
|
||||
|
||||
it("mode=separate creates one print per file", async () => {
|
||||
const f1 = tmpFile("sep1.stl", "solid sep1 endsolid");
|
||||
const f2 = tmpFile("sep2.stl", "solid sep2 endsolid");
|
||||
const res = await auth(request(app).post("/upload"))
|
||||
.field("mode", "separate")
|
||||
.attach("files", f1)
|
||||
.attach("files", f2);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.prints).toHaveLength(2);
|
||||
for (const p of res.body.prints) {
|
||||
expect(p.plates).toHaveLength(1);
|
||||
await trackPrint(p.id);
|
||||
}
|
||||
fs.rmSync(f1, { force: true });
|
||||
fs.rmSync(f2, { force: true });
|
||||
});
|
||||
|
||||
it("mode=multiplate creates one print with several plates in submitted order", async () => {
|
||||
const f1 = tmpFile("multi-a.stl", "solid a endsolid");
|
||||
const f2 = tmpFile("multi-b.stl", "solid b endsolid");
|
||||
const f3 = tmpFile("multi-c.stl", "solid c endsolid");
|
||||
const res = await auth(request(app).post("/upload"))
|
||||
.field("mode", "multiplate")
|
||||
.field("title", "Multi Test Print")
|
||||
.attach("files", f1)
|
||||
.attach("files", f2)
|
||||
.attach("files", f3);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.prints).toHaveLength(1);
|
||||
const print = res.body.prints[0];
|
||||
expect(print.plates.map((p: any) => p.filename)).toEqual(["multi-a.stl", "multi-b.stl", "multi-c.stl"]);
|
||||
expect(print.plates.map((p: any) => p.position)).toEqual([0, 1, 2]);
|
||||
await trackPrint(print.id);
|
||||
fs.rmSync(f1, { force: true });
|
||||
fs.rmSync(f2, { force: true });
|
||||
fs.rmSync(f3, { force: true });
|
||||
});
|
||||
|
||||
it("requires mode when uploading more than one file without specifying it", async () => {
|
||||
const f1 = tmpFile("nomodeA.stl", "solid a endsolid");
|
||||
const f2 = tmpFile("nomodeB.stl", "solid b endsolid");
|
||||
const res = await auth(request(app).post("/upload")).attach("files", f1).attach("files", f2);
|
||||
expect(res.status).toBe(400);
|
||||
fs.rmSync(f1, { force: true });
|
||||
fs.rmSync(f2, { force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("plate add/remove/reorder/rename", () => {
|
||||
let printId: string;
|
||||
let plateIds: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const f1 = tmpFile("plate-x.stl", "solid x endsolid");
|
||||
const f2 = tmpFile("plate-y.stl", "solid y endsolid");
|
||||
const res = await auth(request(app).post("/upload"))
|
||||
.field("mode", "multiplate")
|
||||
.field("title", "Plate Ops Print")
|
||||
.attach("files", f1)
|
||||
.attach("files", f2);
|
||||
printId = res.body.prints[0].id;
|
||||
plateIds = res.body.prints[0].plates.map((p: any) => p.id);
|
||||
await trackPrint(printId);
|
||||
fs.rmSync(f1, { force: true });
|
||||
fs.rmSync(f2, { force: true });
|
||||
});
|
||||
|
||||
it("adds a plate to an existing print", async () => {
|
||||
const f = tmpFile("plate-z.stl", "solid z endsolid");
|
||||
const res = await auth(request(app).post(`/print/${printId}/plates`)).attach("files", f);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.print.plates).toHaveLength(3);
|
||||
expect(res.body.print.plates[2].filename).toBe("plate-z.stl");
|
||||
plateIds = res.body.print.plates.map((p: any) => p.id);
|
||||
fs.rmSync(f, { force: true });
|
||||
});
|
||||
|
||||
it("reorders plates", async () => {
|
||||
const reversed = plateIds.toReversed();
|
||||
const res = await auth(request(app).post(`/print/${printId}/plates/reorder`)).send({ plate_ids: reversed });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.print.plates.map((p: any) => p.id)).toEqual(reversed);
|
||||
expect(res.body.print.plates.map((p: any) => p.position)).toEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
it("renames a plate's filename", async () => {
|
||||
const target = plateIds[0];
|
||||
const res = await auth(request(app).post(`/print/${printId}/plate/${target}/rename`)).send({
|
||||
filename: "renamed.stl",
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const renamed = res.body.print.plates.find((p: any) => p.id === target);
|
||||
expect(renamed.filename).toBe("renamed.stl");
|
||||
});
|
||||
|
||||
it("removes plates down to one, then 409s on the last one", async () => {
|
||||
let res = await auth(request(app).delete(`/print/${printId}/plates/${plateIds[0]}`));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.print.plates).toHaveLength(2);
|
||||
expect(res.body.print.plates.map((p: any) => p.position)).toEqual([0, 1]);
|
||||
|
||||
res = await auth(request(app).delete(`/print/${printId}/plates/${plateIds[1]}`));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.print.plates).toHaveLength(1);
|
||||
|
||||
const lastPlateId = res.body.print.plates[0].id;
|
||||
res = await auth(request(app).delete(`/print/${printId}/plates/${lastPlateId}`));
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
});
|
||||
|
||||
describe("folder CRUD + cycle rejection", () => {
|
||||
it("creates, lists, updates, and deletes a folder", async () => {
|
||||
const create = await auth(request(app).post("/folders")).send({ name: "Test Folder A", tags: ["x"] });
|
||||
expect(create.status).toBe(200);
|
||||
const folderId = create.body.id;
|
||||
|
||||
const list = await auth(request(app).get("/folders"));
|
||||
expect(list.status).toBe(200);
|
||||
expect(list.body.some((f: any) => f.id === folderId)).toBe(true);
|
||||
|
||||
const update = await auth(request(app).patch(`/folder/${folderId}`)).send({ name: "Test Folder A Renamed", tags: [] });
|
||||
expect(update.status).toBe(200);
|
||||
expect(update.body.name).toBe("Test Folder A Renamed");
|
||||
|
||||
const del = await auth(request(app).delete(`/folder/${folderId}`));
|
||||
expect(del.status).toBe(200);
|
||||
});
|
||||
|
||||
it("rejects a parent that does not exist", async () => {
|
||||
const res = await auth(request(app).post("/folders")).send({ name: "Orphan", tags: [], parent_id: "does-not-exist" });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("rejects creating a cycle", async () => {
|
||||
const parent = await auth(request(app).post("/folders")).send({ name: "Cycle Parent", tags: [] });
|
||||
const child = await auth(request(app).post("/folders")).send({
|
||||
name: "Cycle Child",
|
||||
tags: [],
|
||||
parent_id: parent.body.id,
|
||||
});
|
||||
const attempt = await auth(request(app).patch(`/folder/${parent.body.id}`)).send({
|
||||
name: "Cycle Parent",
|
||||
tags: [],
|
||||
parent_id: child.body.id,
|
||||
});
|
||||
expect(attempt.status).toBe(400);
|
||||
|
||||
await auth(request(app).delete(`/folder/${child.body.id}`));
|
||||
await auth(request(app).delete(`/folder/${parent.body.id}`));
|
||||
});
|
||||
});
|
||||
|
||||
describe("prepared-print attach/detect/remove", () => {
|
||||
let printId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const f = tmpFile("bare.stl", "solid bare endsolid");
|
||||
const res = await auth(request(app).post("/upload")).attach("files", f);
|
||||
printId = res.body.prints[0].id;
|
||||
await trackPrint(printId);
|
||||
fs.rmSync(f, { force: true });
|
||||
});
|
||||
|
||||
it("starts with no prepared print", async () => {
|
||||
const res = await auth(request(app).get("/prints"));
|
||||
const print = res.body.find((p: any) => p.id === printId);
|
||||
expect(print.prepared_print).toBeNull();
|
||||
});
|
||||
|
||||
it("attaches an explicit prepared gcode file", async () => {
|
||||
const gcode = tmpFile(
|
||||
"sliced.gcode",
|
||||
"; printer_model = Bambu Lab X1 Carbon\n; filament_type = PETG\n; nozzle_diameter = 0.4\n; layer_height = 0.16\n; estimated printing time (normal mode) = 1h 5m\nG28\n",
|
||||
);
|
||||
const res = await auth(request(app).post(`/print/${printId}/files`)).attach("file", gcode);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.print.prepared_print).toMatchObject({
|
||||
printer: "Bambu Lab X1 Carbon",
|
||||
material: "PETG",
|
||||
nozzle_mm: 0.4,
|
||||
layer_height_mm: 0.16,
|
||||
removable: true,
|
||||
});
|
||||
expect(res.body.print.slicer_filename).toBe("sliced.gcode");
|
||||
fs.rmSync(gcode, { force: true });
|
||||
});
|
||||
|
||||
it("removes the prepared file and falls back to null (bare .stl isn't sniffable)", async () => {
|
||||
const res = await auth(request(app).delete(`/print/${printId}/prepared-print`));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.print.prepared_print).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("zip download arcname structure", () => {
|
||||
it("nests plates under {folder}/{print.name}/ and supporting files under .../supporting/", async () => {
|
||||
const folder = await auth(request(app).post("/folders")).send({ name: "Zip Folder Test", tags: [] });
|
||||
createdFolderIds.push(folder.body.id);
|
||||
|
||||
const f = tmpFile("zippy.stl", "solid zippy endsolid");
|
||||
const upload = await auth(request(app).post("/upload"))
|
||||
.field("title", "Zippy Print")
|
||||
.field("folder_id", folder.body.id)
|
||||
.attach("files", f);
|
||||
const printId = upload.body.prints[0].id;
|
||||
createdPrintIds.push(printId);
|
||||
fs.rmSync(f, { force: true });
|
||||
|
||||
const note = tmpFile("readme.txt", "hello");
|
||||
await auth(request(app).post(`/print/${printId}/files`)).attach("file", note);
|
||||
fs.rmSync(note, { force: true });
|
||||
|
||||
const zipRes = await auth(request(app).post("/download/zip"))
|
||||
.send({ print_ids: [printId] })
|
||||
.buffer(true)
|
||||
.parse((res, cb) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on("data", (c) => chunks.push(c));
|
||||
res.on("end", () => cb(null, Buffer.concat(chunks)));
|
||||
});
|
||||
expect(zipRes.status).toBe(200);
|
||||
|
||||
const tmpZip = path.join(os.tmpdir(), `test-${Date.now()}.zip`);
|
||||
fs.writeFileSync(tmpZip, zipRes.body as Buffer);
|
||||
const entries = await listZipEntries(tmpZip);
|
||||
const names = entries.filter((e) => !e.isDirectory).map((e) => e.name).toSorted();
|
||||
expect(names).toEqual(["Zip Folder Test/Zippy Print/supporting/readme.txt", "Zip Folder Test/Zippy Print/zippy.stl"]);
|
||||
fs.rmSync(tmpZip, { force: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
import { prisma } from "../src/db";
|
||||
import {
|
||||
availableModelName,
|
||||
availablePlateFilename,
|
||||
samplePlateStoragePaths,
|
||||
uniqueModelName,
|
||||
validateStorageTemplate,
|
||||
DEFAULT_STORAGE_TEMPLATE,
|
||||
} from "../src/services/printService";
|
||||
|
||||
describe("validateStorageTemplate", () => {
|
||||
it("accepts the default template", () => {
|
||||
expect(validateStorageTemplate(DEFAULT_STORAGE_TEMPLATE)).toBe(DEFAULT_STORAGE_TEMPLATE);
|
||||
});
|
||||
|
||||
it("rejects a template missing {filename}", () => {
|
||||
expect(() => validateStorageTemplate("{folder}/{model}")).toThrow(/exactly once/);
|
||||
});
|
||||
|
||||
it("rejects an unknown token", () => {
|
||||
expect(() => validateStorageTemplate("{folder}/{bogus}/{filename}")).toThrow(/Unknown storage token/);
|
||||
});
|
||||
|
||||
it("rejects {filename} outside the final segment", () => {
|
||||
expect(() => validateStorageTemplate("{filename}/{model}")).toThrow(/final path segment/);
|
||||
});
|
||||
|
||||
it("accepts the new {plate} token", () => {
|
||||
expect(validateStorageTemplate("{model}/plate-{plate}/{filename}")).toBe("{model}/plate-{plate}/{filename}");
|
||||
});
|
||||
});
|
||||
|
||||
describe("samplePlateStoragePaths", () => {
|
||||
it("renders two sibling plate paths sharing the same model directory", () => {
|
||||
const [first, second] = samplePlateStoragePaths(DEFAULT_STORAGE_TEMPLATE);
|
||||
expect(first).not.toBe(second);
|
||||
const firstDir = first.split("/").slice(0, -1).join("/");
|
||||
const secondDir = second.split("/").slice(0, -1).join("/");
|
||||
expect(firstDir).toBe(secondDir);
|
||||
});
|
||||
});
|
||||
|
||||
describe("print name + plate filename uniqueness (integration, real Postgres)", () => {
|
||||
const createdPrintIds: string[] = [];
|
||||
|
||||
afterAll(async () => {
|
||||
if (createdPrintIds.length) {
|
||||
await prisma.print.deleteMany({ where: { id: { in: createdPrintIds } } });
|
||||
}
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
it("availableModelName auto-suffixes root-level collisions", async () => {
|
||||
const name = `Test Widget ${Date.now()}`;
|
||||
const first = await prisma.print.create({
|
||||
data: { name, nameNormalized: name.toLowerCase(), tags: [] },
|
||||
});
|
||||
createdPrintIds.push(first.id);
|
||||
|
||||
const nextName = await availableModelName(name, null);
|
||||
expect(nextName).toBe(`${name} (2)`);
|
||||
});
|
||||
|
||||
it("uniqueModelName throws 409 on an explicit collision", async () => {
|
||||
const name = `Test Gadget ${Date.now()}`;
|
||||
const first = await prisma.print.create({
|
||||
data: { name, nameNormalized: name.toLowerCase(), tags: [] },
|
||||
});
|
||||
createdPrintIds.push(first.id);
|
||||
|
||||
await expect(uniqueModelName(name, null)).rejects.toMatchObject({ status: 409 });
|
||||
});
|
||||
|
||||
it("the partial unique index rejects two root-level prints with the same name", async () => {
|
||||
const name = `Test Collision ${Date.now()}`;
|
||||
const first = await prisma.print.create({
|
||||
data: { name, nameNormalized: name.toLowerCase(), tags: [] },
|
||||
});
|
||||
createdPrintIds.push(first.id);
|
||||
|
||||
await expect(
|
||||
prisma.print.create({ data: { name, nameNormalized: name.toLowerCase(), tags: [] } }),
|
||||
).rejects.toThrow(/unique constraint/i);
|
||||
});
|
||||
|
||||
it("availablePlateFilename auto-suffixes a duplicate filename within the same print", async () => {
|
||||
const name = `Test Multiplate ${Date.now()}`;
|
||||
const print = await prisma.print.create({
|
||||
data: { name, nameNormalized: name.toLowerCase(), tags: [] },
|
||||
});
|
||||
createdPrintIds.push(print.id);
|
||||
await prisma.plate.create({
|
||||
data: {
|
||||
printId: print.id,
|
||||
position: 0,
|
||||
filename: "body.stl",
|
||||
mime: "model/stl",
|
||||
size: 100,
|
||||
storagePath: `test/${print.id}/body.stl`,
|
||||
},
|
||||
});
|
||||
|
||||
const nextFilename = await availablePlateFilename(print.id, "body.stl");
|
||||
expect(nextFilename).toBe("body (2).stl");
|
||||
|
||||
const untakenFilename = await availablePlateFilename(print.id, "lid.stl");
|
||||
expect(untakenFilename).toBe("lid.stl");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"lib": ["ES2023"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": false,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["tests/**/*.test.ts"],
|
||||
testTimeout: 20000,
|
||||
hookTimeout: 20000,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- POSTGRES_USER=${POSTGRES_USER:-printstash}
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-printstash}
|
||||
- POSTGRES_DB=${POSTGRES_DB:-printstash}
|
||||
volumes:
|
||||
- printstash_db:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-printstash}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
backend:
|
||||
image: ${BACKEND_IMAGE:-printstash-backend:latest}
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- PUID=${PUID:-1000}
|
||||
- PGID=${PGID:-1000}
|
||||
- AUTH_USERNAME=${AUTH_USERNAME:-admin}
|
||||
- AUTH_PASSWORD=${AUTH_PASSWORD:-super-secret}
|
||||
- AUTH_SECRET=${AUTH_SECRET:-changeme-secret}
|
||||
- AUTH_TOKEN_TTL=${AUTH_TOKEN_TTL:-43200}
|
||||
- FILE_STORAGE=/app/storage
|
||||
- DATABASE_URL=postgresql://${POSTGRES_USER:-printstash}:${POSTGRES_PASSWORD:-printstash}@db:5432/${POSTGRES_DB:-printstash}?schema=public
|
||||
- PUBLIC_URL=${PUBLIC_URL:-}
|
||||
- CORS_ORIGINS=${CORS_ORIGINS:-}
|
||||
- IMPORT_MOUNT_PATH=${IMPORT_MOUNT_PATH:-}
|
||||
- IMPORT_MOUNT_EXTS=${IMPORT_MOUNT_EXTS:-stl,3mf,step,stp,obj,svg,png,jpg,jpeg,webp,bmp,lbrn,lbrn2,zip}
|
||||
- IMPORT_MOUNT_INCLUDE_HIDDEN=${IMPORT_MOUNT_INCLUDE_HIDDEN:-false}
|
||||
- IMPORT_MOUNT_ON_STARTUP=${IMPORT_MOUNT_ON_STARTUP:-false}
|
||||
- IMPORT_MOUNT_COPY=${IMPORT_MOUNT_COPY:-true}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- printstash_storage:/app/storage
|
||||
- ${IMPORT_MOUNT_PATH_HOST:-./imports}:/imports:ro
|
||||
ports:
|
||||
- "${API_PORT:-8000}:8000"
|
||||
|
||||
volumes:
|
||||
printstash_storage:
|
||||
printstash_db:
|
||||
@@ -0,0 +1,37 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- POSTGRES_USER=${POSTGRES_USER:-printstash}
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-printstash}
|
||||
- POSTGRES_DB=${POSTGRES_DB:-printstash}
|
||||
volumes:
|
||||
- ./data/postgres:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-printstash}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: backend/Dockerfile
|
||||
ports:
|
||||
- "${API_PORT:-8000}:8000"
|
||||
environment:
|
||||
- PUID=${PUID:-1000}
|
||||
- PGID=${PGID:-1000}
|
||||
- FILE_STORAGE=/app/storage
|
||||
- DATABASE_URL=postgresql://${POSTGRES_USER:-printstash}:${POSTGRES_PASSWORD:-printstash}@db:5432/${POSTGRES_DB:-printstash}?schema=public
|
||||
- CORS_ORIGINS=${CORS_ORIGINS:-}
|
||||
- AUTH_USERNAME=${AUTH_USERNAME:-admin}
|
||||
- AUTH_PASSWORD=${AUTH_PASSWORD:-super-secret}
|
||||
- AUTH_SECRET=${AUTH_SECRET:-changeme-secret}
|
||||
- AUTH_TOKEN_TTL=${AUTH_TOKEN_TTL:-43200}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./data/storage:/app/storage
|
||||
@@ -0,0 +1,159 @@
|
||||
# PrintStash API contract
|
||||
|
||||
Node.js + Express + Prisma (Postgres) backend, ported from MakersVault's FastAPI/SQLModel/SQLite
|
||||
backend, with one structural change: a **Print** is a set of one or more **Plates** (multi-part
|
||||
prints) instead of a single file. Everything else behaves the same as MakersVault.
|
||||
|
||||
Base path: no prefix, ever — routes are mounted directly (`/health`, `/prints`, `/login`, ...).
|
||||
In production the backend also serves the built frontend as static files from the same origin
|
||||
(see `backend/src/app.ts`'s `express.static(FRONTEND_DIST)`), so the UI calls these routes with
|
||||
plain relative paths and no reverse proxy is involved.
|
||||
|
||||
Auth: identical scheme to MakersVault — single-user JWT (HS256). `AUTH_USERNAME`/`AUTH_PASSWORD`/
|
||||
`AUTH_SECRET`/`AUTH_TOKEN_TTL` env vars. `AUTH_ENABLED = Boolean(username && password)`. Every
|
||||
route (except `/health`) runs a `requireAuth` middleware that accepts either an `Authorization:
|
||||
Bearer <token>` header or a `?token=` query param, and is a no-op when auth is disabled.
|
||||
|
||||
## Types
|
||||
|
||||
```ts
|
||||
type PreparedPrint = {
|
||||
printer?: string | null;
|
||||
material?: string | null;
|
||||
nozzle_mm?: number | null;
|
||||
layer_height_mm?: number | null;
|
||||
estimated_seconds?: number | null;
|
||||
format?: string | null; // "gcode" | "bgcode" | "gcode_3mf"
|
||||
removable: boolean;
|
||||
};
|
||||
|
||||
type Plate = {
|
||||
id: string;
|
||||
print_id: string;
|
||||
position: number; // dense, 0-based, ordered
|
||||
filename: string;
|
||||
mime: string;
|
||||
size: number;
|
||||
url: string; // /print/{printId}/plate/{plateId}/file/{filename}
|
||||
thumb_url?: string | null; // /plate/{plateId}/thumb.jpg?v=... if a thumb exists
|
||||
};
|
||||
|
||||
type PrintFile = { // supporting doc or prepared-print attachment (print-level, not per-plate)
|
||||
id: string;
|
||||
filename: string;
|
||||
mime: string;
|
||||
size: number;
|
||||
url: string; // /print/{printId}/files/{fileId}
|
||||
};
|
||||
|
||||
type Print = {
|
||||
id: string;
|
||||
name: string;
|
||||
title?: string | null;
|
||||
notes?: string | null;
|
||||
creator?: string | null;
|
||||
collection?: string | null;
|
||||
tags: string[];
|
||||
folder_id?: string | null;
|
||||
storage_path?: string | null; // parent directory shared by all plates
|
||||
plates: Plate[]; // ordered by position, length >= 1
|
||||
thumb_url?: string | null; // denormalized = plates[0].thumb_url, for card grids
|
||||
supporting_file_count: number;
|
||||
prepared_print?: PreparedPrint | null;
|
||||
slicer_url?: string | null; // prepared file if present, else plates[0].url
|
||||
slicer_filename?: string | null;
|
||||
};
|
||||
|
||||
type Folder = { id: string; name: string; tags: string[]; parent_id?: string | null };
|
||||
```
|
||||
|
||||
## Routes
|
||||
|
||||
Mechanical renames from MakersVault (identical request/response shape, `asset` → `print`):
|
||||
|
||||
- `GET /health` -> `{ ok, auth_required }`
|
||||
- `GET/POST /settings/mount-import`
|
||||
- `GET/POST /settings/storage` (sample path now returns a `plate_paths: string[]` array of 2
|
||||
example rendered paths instead of a single `sample_path` string, to show that sibling plates
|
||||
share the `{model}` directory)
|
||||
- `POST /login`, `POST /refresh`
|
||||
- `GET/POST /print/:id/files`, `GET/DELETE /print/:id/files/:fileId` (supporting files, unchanged
|
||||
semantics)
|
||||
- `GET/DELETE /print/:id/prepared-print`
|
||||
- `POST /print/:id/tags`, `POST /print/:id/meta`, `DELETE /print/:id`, `POST /print/:id/folder`
|
||||
- `GET/POST /folders`, `PATCH /folder/:id`, `DELETE /folder/:id`, `GET /folder/:id/download`
|
||||
- `POST /import`, `POST /import/inspect`, `POST /import/zip/entries`, `POST /import/zip` — URL and
|
||||
zip-extraction imports always create a single-plate print per item (the separate/multiplate
|
||||
choice below only applies to the direct upload picker/drag-drop).
|
||||
|
||||
Shape changes / new routes (plate-aware):
|
||||
|
||||
- `POST /upload` — multipart, **multiple `files` fields accepted**. Additional field
|
||||
`mode: "separate" | "multiplate"` (required only when more than one file is sent; ignored for a
|
||||
single file). Always responds `{ prints: Print[] }`.
|
||||
- `mode=separate` (or single file): each file becomes its own single-plate `Print`.
|
||||
- `mode=multiplate`: all files become `Plate`s (in submitted order) of one new `Print`.
|
||||
- `POST /print/:id/plates` — multipart, add one or more plates to an existing print (used by
|
||||
"drop a file onto an existing print card" and the print detail view's "Add plate" action).
|
||||
Appends at `position = max(position)+1`. Responds `{ print: Print }`.
|
||||
- `DELETE /print/:id/plates/:plateId` — remove a plate; 409 if it is the only remaining plate
|
||||
(delete the whole print instead). Renumbers remaining plates densely. Responds `{ print: Print }`.
|
||||
- `POST /print/:id/plates/reorder` — body `{ plate_ids: string[] }` (full ordered list), rewrites
|
||||
`position` transactionally. Responds `{ print: Print }`.
|
||||
- `POST /print/:id/plate/:plateId/rename` — body `{ filename: string }`. Responds `{ print: Print }`.
|
||||
- `GET /print/:id/plate/:plateId/file/:filename` — streams the plate's file (replaces
|
||||
`GET /file/{asset_id}/{name}`).
|
||||
- `GET /print/:id/thumb.jpg` — the print card thumbnail (= plate[0]'s thumbnail).
|
||||
- `GET /plate/:plateId/thumb.jpg` — a specific plate's thumbnail (for the plate switcher UI).
|
||||
- `POST /plate/:plateId/thumbnail-generated` — client-rendered PNG/JPEG/WebP snapshot for one
|
||||
plate (replaces `POST /asset/{id}/thumbnail-generated`), same 8MB cap.
|
||||
- `GET /prints` (was `GET /assets`) — same filters (`q`, `tags`, `folder_id`) and pagination
|
||||
(`limit`, `offset`, `X-Has-More`/`X-Next-Offset` headers); sort key becomes
|
||||
`name, plates[0].filename, id`.
|
||||
- `GET /tags` (was `GET /tags`) — same filters, implemented via `unnest(tags)` over the filtered
|
||||
`Print` set.
|
||||
- `POST /download/zip` — body `{ print_ids?: string[], tag?: string, folder_id?: string, filename?: string }`
|
||||
(was `{ asset_ids, tag, folder_id, filename }`).
|
||||
- `POST /import/zip` response — `{ prints: Print[], failed: string[] }` (was `{ assets, failed }`).
|
||||
- `POST /download/zip`, `GET /folder/:id/download` — zip arcname becomes
|
||||
`{folder_or_unassigned}/{print.name}/{plate.filename}` for every plate, and
|
||||
`{folder_or_unassigned}/{print.name}/supporting/{file.filename}` for supporting files. The extra
|
||||
`{print.name}` directory level (new vs. MakersVault) is required so sibling plates from
|
||||
different prints, or same-named plates across prints, can't collide in the zip.
|
||||
|
||||
Retired: `POST /asset/{id}/rename` (renaming "the file" doesn't map onto a print with no single
|
||||
filename) — replaced by `POST /print/:id/plate/:plateId/rename` for plate filenames, while
|
||||
`Print.name` (the folder-unique display name) renames via the existing `POST /print/:id/meta`.
|
||||
|
||||
## Storage path templating
|
||||
|
||||
Same template contract as MakersVault (`validate_storage_template`, default
|
||||
`{folder}/{model}/{filename}`, tokens `folder,collection,tags,creator,model,name,filename,id`,
|
||||
`{filename}` required exactly once in the final path segment). Rendered **once per Plate**:
|
||||
|
||||
- `{model}` / `{name}` = `Print.name` (shared by every plate of a print — this is what makes all
|
||||
plates land in the same `{folder}/{model}/` directory under the default template).
|
||||
- `{filename}` = the individual `Plate.filename`.
|
||||
- `{folder}`, `{collection}`, `{tags}`, `{creator}` = pulled from the parent `Print`.
|
||||
- `{id}` = the owning `Print.id` (not a plate id).
|
||||
- New optional token `{plate}` = 1-based `Plate.position + 1`, for templates that want an explicit
|
||||
per-plate disambiguator. Not needed by the default template.
|
||||
|
||||
Filename collisions between two plates of the *same* print (e.g. two uploads both named
|
||||
`body.stl`) are resolved by an `availablePlateFilename(printId, dirTokens, desiredFilename)`
|
||||
helper that auto-suffixes (`body.stl` -> `body-2.stl`), mirroring the existing per-folder
|
||||
`unique_model_name`/`available_model_name` dedup pattern. `PrintFile`s (supporting/prepared) are
|
||||
never template-rendered; they always live at `STORAGE/bundles/{printId}/{fileId}/{filename}`.
|
||||
|
||||
## Behavioral notes carried over unchanged
|
||||
|
||||
- Folder-scoped `Print.name` uniqueness (case-insensitive), including at the root — enforced via
|
||||
`nameNormalized` + the composite unique index plus a partial unique index for `folderId IS
|
||||
NULL` (see `schema.prisma` migration comment).
|
||||
- Prepared-print detection/metadata sniffing (`prepared_print.py` logic) stays **print-level**: a
|
||||
sliced job output covers the whole build plate across all plates, so it is not tied to a single
|
||||
`Plate`.
|
||||
- Mount-import volume scanning, zip-import wizard, MakerWorld/Printables/Thingiverse link import,
|
||||
and settings (mount-import, storage template) all behave the same; each imported/extracted item
|
||||
is a single-plate `Print` (no multiplate prompt in these flows).
|
||||
- SSRF guard on outbound import URLs, filename sanitization, size limits — unchanged.
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
npm-debug.log*
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["typescript", "unicorn", "oxc", "react", "react-perf", "jsx-a11y", "vitest"],
|
||||
"categories": {
|
||||
"correctness": "error",
|
||||
"suspicious": "error",
|
||||
"perf": "warn"
|
||||
},
|
||||
"rules": {
|
||||
"no-await-in-loop": "off"
|
||||
},
|
||||
"env": {
|
||||
"builtin": true,
|
||||
"browser": true
|
||||
},
|
||||
"ignorePatterns": ["dist", "node_modules", "coverage"]
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
FROM node:20-alpine AS dependencies
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci --no-audit --no-fund
|
||||
|
||||
FROM dependencies AS test
|
||||
COPY . .
|
||||
RUN npm run verify
|
||||
CMD ["npm", "run", "verify"]
|
||||
|
||||
FROM dependencies AS build
|
||||
COPY . .
|
||||
# VITE_API_URL is baked in at build time (Vite env vars aren't readable at container start).
|
||||
# Leave it unset to default to same-origin/localhost:8000 resolution (see src/services/api.ts),
|
||||
# or pass --build-arg VITE_API_URL=https://api.example.com for a fixed backend address.
|
||||
ARG VITE_API_URL
|
||||
ENV VITE_API_URL=$VITE_API_URL
|
||||
RUN npm run build
|
||||
|
||||
# A standalone static-file image: no nginx, no reverse proxy, no /api rewriting. This client
|
||||
# talks to the backend directly (same-origin default, VITE_API_URL, or the Settings > Network
|
||||
# override) — it doesn't assume anything about how or where the backend is deployed, since the
|
||||
# frontend is meant to be one of several possible client apps (web today, others later).
|
||||
FROM node:20-alpine AS runtime
|
||||
WORKDIR /app
|
||||
RUN npm install --global serve@14 --no-audit --no-fund
|
||||
COPY --from=build /app/dist ./dist
|
||||
EXPOSE 5173
|
||||
CMD ["serve", "-s", "dist", "-l", "5173"]
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>PrintStash</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
</head>
|
||||
<body style="margin:0">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+3194
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "printstash-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
"lint": "oxlint .",
|
||||
"verify": "npm run typecheck && npm run lint && npm run test:run && npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.13.0",
|
||||
"@emotion/styled": "^11.13.0",
|
||||
"@mui/icons-material": "^6.1.0",
|
||||
"@mui/material": "^6.1.0",
|
||||
"fflate": "0.8.3",
|
||||
"i18next": "^23.15.0",
|
||||
"i18next-browser-languagedetector": "^8.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-i18next": "^15.0.0",
|
||||
"three": "^0.160.0",
|
||||
"occt-import-js": "0.0.23"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.15",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"@types/three": "^0.160.0",
|
||||
"oxlint": "^1.81.0",
|
||||
"typescript": "5.6.3",
|
||||
"vitest": "3.2.6",
|
||||
"vite": "^6.4.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<svg width="256" height="256" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<radialGradient id="darkGlow" cx="50%" cy="50%" r="75%">
|
||||
<stop offset="0%" stop-color="#4b4b4b" />
|
||||
<stop offset="55%" stop-color="#2f2f2f" />
|
||||
<stop offset="100%" stop-color="#0f0f0f" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<rect width="256" height="256" rx="40" fill="url(#darkGlow)" />
|
||||
<g transform="translate(28 28)" fill="none" stroke="#050505" stroke-width="10" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="100" cy="100" r="92" />
|
||||
<rect x="52" y="52" width="96" height="96" rx="18" />
|
||||
<path d="M100 70 L124 84 L100 98 L76 84 Z" />
|
||||
<path d="M100 98 L124 84 V116 L100 134 Z" />
|
||||
<path d="M100 98 L100 134 L76 116 V84 Z" />
|
||||
<circle cx="148" cy="148" r="26" />
|
||||
<path d="M148 128 v20 l16 10" />
|
||||
<path d="M148 148 l-16 10" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 902 B |
@@ -0,0 +1,170 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ThemeProvider } from "@mui/material/styles";
|
||||
import CssBaseline from "@mui/material/CssBaseline";
|
||||
import Box from "@mui/material/Box";
|
||||
import AppLayout from "./common/layout/AppLayout";
|
||||
import PrintGrid from "./routes/library";
|
||||
import Login from "./routes/login";
|
||||
import Settings from "./routes/settings";
|
||||
import { apiHealth, getApiBase, refreshToken, type HealthInfo } from "./services/api";
|
||||
import { clearToken, readToken, storeToken } from "./services/auth";
|
||||
import { type AppSettings, loadSettings, saveSettings } from "./services/settings";
|
||||
import { resolveTheme } from "./utils/settingsHelpers";
|
||||
import { buildTheme } from "./theme";
|
||||
|
||||
const DEFAULT_REFRESH_SECONDS = 6 * 60 * 60; // 6 hours
|
||||
|
||||
export default function App() {
|
||||
const { t } = useTranslation(["app", "common"]);
|
||||
const [token, setToken] = React.useState<string | null>(() => readToken());
|
||||
const [nonce, setNonce] = React.useState(0);
|
||||
const [folderId, setFolderId] = React.useState<string | null>(null);
|
||||
const [folderVersion, setFolderVersion] = React.useState(0);
|
||||
const [health, setHealth] = React.useState<HealthInfo | null>(null);
|
||||
const [tokenTtl, setTokenTtl] = React.useState<number | null>(null);
|
||||
const [sessionExpired, setSessionExpired] = React.useState(false);
|
||||
const [activeView, setActiveView] = React.useState<"library" | "settings">("library");
|
||||
const [settings, setSettings] = React.useState<AppSettings>(() => loadSettings());
|
||||
const resolvedTheme = React.useMemo(
|
||||
() => resolveTheme(settings.theme.selected),
|
||||
[settings.theme.selected]
|
||||
);
|
||||
const muiTheme = React.useMemo(() => buildTheme(resolvedTheme), [resolvedTheme]);
|
||||
React.useEffect(() => { (async ()=> setHealth(await apiHealth()))(); }, [settings.network.publicUrl]);
|
||||
React.useEffect(() => {
|
||||
saveSettings(settings);
|
||||
}, [settings]);
|
||||
const apiUp = health?.ok ?? null;
|
||||
const authRequired = health?.auth_required ?? true;
|
||||
|
||||
const handleLogin = (tok: string, ttl: number) => {
|
||||
storeToken(tok);
|
||||
setToken(tok);
|
||||
setTokenTtl(ttl);
|
||||
setSessionExpired(false);
|
||||
};
|
||||
|
||||
const handleUnauthorized = React.useCallback(() => {
|
||||
if (sessionExpired) return;
|
||||
clearToken();
|
||||
setToken(null);
|
||||
setTokenTtl(null);
|
||||
setSessionExpired(true);
|
||||
alert(t("shell.sessionExpired"));
|
||||
}, [sessionExpired, t]);
|
||||
|
||||
const handleFoldersChanged = React.useCallback(() => {
|
||||
setFolderVersion(v => v + 1);
|
||||
}, []);
|
||||
|
||||
const handlePrintsChanged = React.useCallback(() => {
|
||||
setNonce(n => n + 1);
|
||||
}, []);
|
||||
|
||||
const handleSelectFolder = React.useCallback((id: string | null) => {
|
||||
setFolderId(id);
|
||||
setActiveView("library");
|
||||
}, []);
|
||||
|
||||
const handleLogout = () => {
|
||||
clearToken();
|
||||
setToken(null);
|
||||
setTokenTtl(null);
|
||||
};
|
||||
|
||||
const resetSavedProxyUrl = () => {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
network: {
|
||||
...prev.network,
|
||||
publicUrl: "",
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!token) return;
|
||||
const ttl = tokenTtl ?? DEFAULT_REFRESH_SECONDS;
|
||||
const refreshMs = Math.max(
|
||||
5 * 60 * 1000,
|
||||
Math.min(ttl * 0.8 * 1000, ttl * 1000 - 5 * 60 * 1000)
|
||||
);
|
||||
const timer = window.setTimeout(async () => {
|
||||
try {
|
||||
const res = await refreshToken();
|
||||
storeToken(res.token);
|
||||
setToken(res.token);
|
||||
setTokenTtl(res.expires_in);
|
||||
} catch {
|
||||
handleUnauthorized();
|
||||
}
|
||||
}, refreshMs);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [token, tokenTtl, handleUnauthorized]);
|
||||
|
||||
if (authRequired && !token) {
|
||||
return (
|
||||
<ThemeProvider theme={muiTheme}>
|
||||
<CssBaseline />
|
||||
<Box
|
||||
sx={{
|
||||
background: (theme) => theme.printstash.pageBackground,
|
||||
minHeight: "100vh",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
p: 2,
|
||||
}}
|
||||
>
|
||||
<Login onSuccess={handleLogin} apiUp={apiUp} theme={resolvedTheme} />
|
||||
</Box>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AppLayout
|
||||
muiTheme={muiTheme}
|
||||
resolvedTheme={resolvedTheme}
|
||||
apiUp={apiUp}
|
||||
apiBase={getApiBase()}
|
||||
publicUrl={settings.network.publicUrl}
|
||||
onResetProxyUrl={resetSavedProxyUrl}
|
||||
folderId={folderId}
|
||||
onSelectFolder={handleSelectFolder}
|
||||
folderVersion={folderVersion}
|
||||
onFoldersChanged={handleFoldersChanged}
|
||||
onPrintsChanged={handlePrintsChanged}
|
||||
onUnauthorized={handleUnauthorized}
|
||||
activeView={activeView}
|
||||
onOpenSettings={() => setActiveView("settings")}
|
||||
onBackToLibrary={() => setActiveView("library")}
|
||||
onLogout={handleLogout}
|
||||
makerworldCookie={settings.makerworld.cookie}
|
||||
thingiverseCookie={settings.thingiverse.cookie}
|
||||
>
|
||||
{activeView === "library" ? (
|
||||
<PrintGrid
|
||||
key={`${nonce + (folderId||'')}-${folderVersion}`}
|
||||
folderId={folderId}
|
||||
foldersVersion={folderVersion}
|
||||
onUnauthorized={handleUnauthorized}
|
||||
slicerSettings={settings.slicer}
|
||||
engravingSettings={settings.engraving}
|
||||
previewSettings={settings.previews}
|
||||
theme={resolvedTheme}
|
||||
/>
|
||||
) : (
|
||||
<Settings
|
||||
settings={settings}
|
||||
onChange={setSettings}
|
||||
onAssetsChanged={handlePrintsChanged}
|
||||
onFoldersChanged={handleFoldersChanged}
|
||||
onUnauthorized={handleUnauthorized}
|
||||
onSelectFolder={handleSelectFolder}
|
||||
/>
|
||||
)}
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import React from "react";
|
||||
import Chip from "@mui/material/Chip";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { colorForTag } from "../utils/tagColors";
|
||||
|
||||
type TagBadgeProps = {
|
||||
tag: string;
|
||||
onRemove?: () => void;
|
||||
};
|
||||
|
||||
export default function TagBadge({ tag, onRemove }: TagBadgeProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const colors = colorForTag(tag);
|
||||
return (
|
||||
<Chip
|
||||
label={tag}
|
||||
size="small"
|
||||
onDelete={onRemove}
|
||||
deleteIcon={
|
||||
onRemove ? <span aria-label={t("tagInput.removeTag", { tag })}>×</span> : undefined
|
||||
}
|
||||
sx={{
|
||||
backgroundColor: colors.bg,
|
||||
color: colors.text,
|
||||
border: "1px solid",
|
||||
borderColor: colors.border,
|
||||
fontWeight: 500,
|
||||
"& .MuiChip-deleteIcon": {
|
||||
color: "inherit",
|
||||
opacity: 0.7,
|
||||
"&:hover": { opacity: 1, color: "inherit" },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import React from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import InputBase from "@mui/material/InputBase";
|
||||
import TagBadge from "./TagBadge";
|
||||
|
||||
type TagInputProps = {
|
||||
value: string[];
|
||||
onChange: (tags: string[]) => void;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
export default function TagInput({ value, onChange, placeholder }: TagInputProps) {
|
||||
const [draft, setDraft] = React.useState("");
|
||||
const inputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const normalized = (t: string) => t.trim().replace(/\s+/g, " ");
|
||||
|
||||
const addTag = (raw: string) => {
|
||||
const tag = normalized(raw);
|
||||
if (!tag) return;
|
||||
if (value.some(v => v.toLowerCase() === tag.toLowerCase())) {
|
||||
setDraft("");
|
||||
return;
|
||||
}
|
||||
onChange([...value, tag]);
|
||||
setDraft("");
|
||||
};
|
||||
|
||||
const removeTag = (idx: number) => {
|
||||
const next = [...value.slice(0, idx), ...value.slice(idx + 1)];
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter" || e.key === ",") {
|
||||
e.preventDefault();
|
||||
addTag(draft);
|
||||
} else if (e.key === "Backspace" && draft === "" && value.length) {
|
||||
e.preventDefault();
|
||||
removeTag(value.length - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
if (draft.trim()) addTag(draft);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
onClick={() => inputRef.current?.focus()}
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: 1,
|
||||
px: 1.5,
|
||||
py: 1,
|
||||
borderRadius: 1,
|
||||
border: "1px solid",
|
||||
borderColor: "divider",
|
||||
bgcolor: "background.paper",
|
||||
fontSize: 14,
|
||||
cursor: "text",
|
||||
}}
|
||||
>
|
||||
{value.map((tag, idx) => (
|
||||
<TagBadge key={`${tag}-${idx}`} tag={tag} onRemove={() => removeTag(idx)} />
|
||||
))}
|
||||
<InputBase
|
||||
inputRef={inputRef}
|
||||
value={draft}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleBlur}
|
||||
placeholder={value.length === 0 ? placeholder : ""}
|
||||
sx={{ flex: 1, minWidth: 120, fontSize: 14 }}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import React from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { type ResolvedTheme } from "../constants/settingsOptions";
|
||||
|
||||
// PrintStash has no logo asset (yet) -- this text wordmark is an intentional
|
||||
// placeholder so both the login screen and the library header render
|
||||
// something branded instead of a 404'd <img>. Swap for a real logo image
|
||||
// whenever one exists; keep referencing plausible filenames
|
||||
// (e.g. /img/printstash-logo-light.svg) if that work resumes.
|
||||
type Props = {
|
||||
theme: ResolvedTheme;
|
||||
size?: "lg" | "md";
|
||||
};
|
||||
|
||||
export default function Wordmark({ size = "md" }: Props) {
|
||||
const fontSize = size === "lg" ? "2.25rem" : "1.5rem";
|
||||
return (
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<Box
|
||||
aria-hidden="true"
|
||||
sx={{
|
||||
width: "0.8em",
|
||||
height: "0.8em",
|
||||
fontSize,
|
||||
borderRadius: "0.2em",
|
||||
bgcolor: "primary.main",
|
||||
}}
|
||||
/>
|
||||
<Typography
|
||||
component="span"
|
||||
sx={{ fontSize, fontWeight: 600, letterSpacing: "-0.02em", color: "text.primary" }}
|
||||
>
|
||||
Print
|
||||
<Box component="span" sx={{ color: "primary.main" }}>
|
||||
Stash
|
||||
</Box>
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ThemeProvider, type Theme } from "@mui/material/styles";
|
||||
import CssBaseline from "@mui/material/CssBaseline";
|
||||
import Box from "@mui/material/Box";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Alert from "@mui/material/Alert";
|
||||
import Button from "@mui/material/Button";
|
||||
import Sidebar from "../../routes/library/components/Sidebar";
|
||||
import UploadBar from "../../routes/library/components/UploadBar";
|
||||
import Wordmark from "../Wordmark";
|
||||
import type { ResolvedTheme } from "../../constants/settingsOptions";
|
||||
|
||||
type AppLayoutProps = {
|
||||
muiTheme: Theme;
|
||||
resolvedTheme: ResolvedTheme;
|
||||
apiUp: boolean | null;
|
||||
publicUrl: string;
|
||||
onResetProxyUrl: () => void;
|
||||
folderId: string | null;
|
||||
onSelectFolder: (id: string | null) => void;
|
||||
folderVersion: number;
|
||||
onFoldersChanged: () => void;
|
||||
onPrintsChanged: () => void;
|
||||
onUnauthorized: () => void;
|
||||
activeView: "library" | "settings";
|
||||
onOpenSettings: () => void;
|
||||
onBackToLibrary: () => void;
|
||||
onLogout: () => void;
|
||||
makerworldCookie: string;
|
||||
thingiverseCookie: string;
|
||||
apiBase: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
/** The persistent app shell: theming, the folder sidebar, and the header row (wordmark, upload
|
||||
* bar or back-to-library button, logout). `children` is the active route's content. */
|
||||
export default function AppLayout({
|
||||
muiTheme,
|
||||
resolvedTheme,
|
||||
apiUp,
|
||||
publicUrl,
|
||||
onResetProxyUrl,
|
||||
folderId,
|
||||
onSelectFolder,
|
||||
folderVersion,
|
||||
onFoldersChanged,
|
||||
onPrintsChanged,
|
||||
onUnauthorized,
|
||||
activeView,
|
||||
onOpenSettings,
|
||||
onBackToLibrary,
|
||||
onLogout,
|
||||
makerworldCookie,
|
||||
thingiverseCookie,
|
||||
apiBase,
|
||||
children,
|
||||
}: AppLayoutProps) {
|
||||
const { t } = useTranslation(["app", "common"]);
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={muiTheme}>
|
||||
<CssBaseline />
|
||||
<Box
|
||||
sx={{
|
||||
background: (theme) => theme.printstash.pageBackground,
|
||||
minHeight: "100vh",
|
||||
display: "flex",
|
||||
}}
|
||||
>
|
||||
<Sidebar
|
||||
selectedId={folderId}
|
||||
onSelect={onSelectFolder}
|
||||
onFoldersChanged={onFoldersChanged}
|
||||
foldersVersion={folderVersion}
|
||||
onAssetsChanged={onPrintsChanged}
|
||||
onUnauthorized={onUnauthorized}
|
||||
onOpenSettings={onOpenSettings}
|
||||
activeView={activeView}
|
||||
/>
|
||||
<Box component="main" sx={{ flex: 1, p: 2, overflow: "auto" }}>
|
||||
{apiUp === false && (
|
||||
<Alert
|
||||
severity="error"
|
||||
sx={{ mb: 1.5 }}
|
||||
action={
|
||||
!!publicUrl && (
|
||||
<Button color="inherit" size="small" onClick={onResetProxyUrl}>
|
||||
{t("shell.resetProxyUrl")}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("shell.apiUnreachable", { base: apiBase })}
|
||||
</Alert>
|
||||
)}
|
||||
<Stack
|
||||
direction="row"
|
||||
flexWrap="wrap"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
gap={2}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
{activeView === "library" && <Wordmark theme={resolvedTheme} size="lg" />}
|
||||
<Stack direction="row" flexWrap="wrap" alignItems="center" gap={1.5}>
|
||||
{activeView === "library" ? (
|
||||
<UploadBar
|
||||
folderId={folderId}
|
||||
makerworldCookie={makerworldCookie}
|
||||
thingiverseCookie={thingiverseCookie}
|
||||
onUploaded={onPrintsChanged}
|
||||
onUnauthorized={onUnauthorized}
|
||||
/>
|
||||
) : (
|
||||
<Button variant="outlined" onClick={onBackToLibrary}>
|
||||
{t("shell.backToLibrary")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => {
|
||||
if (confirm(t("shell.confirmLogout"))) {
|
||||
onLogout();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("common:logOut")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* The "Open in Slicer" feature depends on the standalone slicer-bridge helper (a desktop app
|
||||
* registering the printstash-slicer:// protocol), which has been removed from this project for
|
||||
* now. Flip this back to true once a bridge (or another launch mechanism) exists again — every
|
||||
* "open in slicer" UI touchpoint is gated behind this single flag.
|
||||
*/
|
||||
export const SLICER_BRIDGE_ENABLED = false;
|
||||
@@ -0,0 +1,30 @@
|
||||
/** Extensions (lowercase, no leading dot) the interactive 3D viewer can render. */
|
||||
export const MODEL_EXTS = new Set(["stl", "3mf", "step", "stp", "obj"]);
|
||||
|
||||
/** LightBurn project file extensions. */
|
||||
export const LIGHTBURN_EXTS = new Set(["lbrn", "lbrn2"]);
|
||||
|
||||
/** Extensions eligible for the "Open in Engraving Software" action. */
|
||||
export const ENGRAVING_EXTS = new Set([
|
||||
"svg",
|
||||
"dxf",
|
||||
"ai",
|
||||
"eps",
|
||||
"pdf",
|
||||
"plt",
|
||||
"hpgl",
|
||||
"png",
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"bmp",
|
||||
"gif",
|
||||
"tif",
|
||||
"tiff",
|
||||
"lbrn",
|
||||
"lbrn2",
|
||||
"ezd",
|
||||
]);
|
||||
|
||||
/** Extensions offered by Settings' local-folder-scan import feature. */
|
||||
export const SCAN_EXTS = ["stl", "3mf", "step", "stp", "obj", "lbrn", "lbrn2"];
|
||||
export const SCAN_EXT_SET = new Set(SCAN_EXTS);
|
||||
@@ -0,0 +1,8 @@
|
||||
export const SUPPORTED_LANGUAGES = [
|
||||
{ code: "en", label: "English" },
|
||||
{ code: "lt", label: "Lietuvių" },
|
||||
] as const;
|
||||
|
||||
export type LanguageCode = (typeof SUPPORTED_LANGUAGES)[number]["code"];
|
||||
|
||||
export const LANGUAGE_STORAGE_KEY = "printstash_language";
|
||||
@@ -0,0 +1,41 @@
|
||||
export type SlicerOption = { id: string; label: string };
|
||||
|
||||
export const SLICER_OPTIONS: SlicerOption[] = [
|
||||
{ id: "orca", label: "OrcaSlicer" },
|
||||
{ id: "bambu", label: "Bambu Studio" },
|
||||
{ id: "prusa", label: "PrusaSlicer" },
|
||||
{ id: "superslicer", label: "SuperSlicer" },
|
||||
{ id: "cura", label: "UltiMaker Cura" },
|
||||
{ id: "ideamaker", label: "ideaMaker" },
|
||||
{ id: "simplify3d", label: "Simplify3D" },
|
||||
{ id: "kisslicer", label: "KISSlicer" },
|
||||
{ id: "repetier", label: "Repetier-Host" },
|
||||
{ id: "chitubox", label: "ChiTuBox" },
|
||||
{ id: "lychee", label: "Lychee Slicer" },
|
||||
{ id: "photon", label: "Anycubic Photon Workshop" },
|
||||
{ id: "creality", label: "Creality Print" },
|
||||
{ id: "other", label: "Other / Manual" },
|
||||
];
|
||||
|
||||
export type EngraverOption = { id: string; label: string };
|
||||
|
||||
export const ENGRAVER_OPTIONS: EngraverOption[] = [
|
||||
{ id: "lightburn", label: "LightBurn" },
|
||||
{ id: "ezcad", label: "EZCAD" },
|
||||
{ id: "other", label: "Other / Manual" },
|
||||
];
|
||||
|
||||
/** The user's persisted theme choice — "system" defers to the OS preference. */
|
||||
export type ThemeSelection = "system" | "light" | "dark" | "neon" | "purple" | "blue";
|
||||
/** A theme choice after "system" has been resolved to light/dark — what buildTheme() consumes. */
|
||||
export type ResolvedTheme = "light" | "dark" | "neon" | "purple" | "blue";
|
||||
export type ThemeOption = { id: ThemeSelection; label: string; description: string };
|
||||
|
||||
export const THEME_OPTIONS: ThemeOption[] = [
|
||||
{ id: "system", label: "System", description: "Match your device preference." },
|
||||
{ id: "light", label: "Light", description: "Bright backgrounds, dark text." },
|
||||
{ id: "dark", label: "Dark", description: "Dimmed panels for low light." },
|
||||
{ id: "neon", label: "Neon Green", description: "Black UI with neon green accents." },
|
||||
{ id: "purple", label: "Neon Purple", description: "Black UI with purple glow highlights." },
|
||||
{ id: "blue", label: "Neon Blue", description: "Black UI with blue glow highlights." },
|
||||
];
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { ThemeSelection } from "./settingsOptions";
|
||||
|
||||
/** Gradient swatch preview shown next to each theme choice in Settings > Theme. */
|
||||
export const THEME_SWATCHES: Record<ThemeSelection, string> = {
|
||||
system: "linear-gradient(135deg, #f8fafc 0%, #f8fafc 50%, #0b0f19 50%, #0b0f19 100%)",
|
||||
light: "linear-gradient(135deg, #ffffff, #e2e8f0)",
|
||||
dark: "linear-gradient(135deg, #0b0f19, #1f2937)",
|
||||
neon: "linear-gradient(135deg, #b6ff2b, #0a1508)",
|
||||
purple: "linear-gradient(135deg, #c77dff, #12091f)",
|
||||
blue: "linear-gradient(135deg, #74d4ff, #0a1324)",
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import i18n from "i18next";
|
||||
import LanguageDetector from "i18next-browser-languagedetector";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
|
||||
import commonEn from "./locales/en/common.json";
|
||||
import appEn from "./locales/en/app.json";
|
||||
import libraryEn from "./locales/en/library.json";
|
||||
import commonLt from "./locales/lt/common.json";
|
||||
import appLt from "./locales/lt/app.json";
|
||||
import libraryLt from "./locales/lt/library.json";
|
||||
import { LANGUAGE_STORAGE_KEY, SUPPORTED_LANGUAGES } from "./constants/languages";
|
||||
|
||||
// Namespaces are split by ownership area rather than kept in one giant file:
|
||||
// "common" - generic actions/words reused everywhere (Save, Cancel, Delete, ...)
|
||||
// "app" - shell chrome: login, sidebar, settings, upload bar, import/zip modals, tag editor
|
||||
// "library" - the print grid/card, print preview modal + plate switcher, model viewer overlays,
|
||||
// LightBurn preview
|
||||
i18n
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources: {
|
||||
en: { common: commonEn, app: appEn, library: libraryEn },
|
||||
lt: { common: commonLt, app: appLt, library: libraryLt },
|
||||
},
|
||||
ns: ["common", "app", "library"],
|
||||
defaultNS: "common",
|
||||
fallbackLng: "en",
|
||||
supportedLngs: SUPPORTED_LANGUAGES.map((l) => l.code),
|
||||
interpolation: { escapeValue: false },
|
||||
detection: {
|
||||
order: ["localStorage", "navigator"],
|
||||
lookupLocalStorage: LANGUAGE_STORAGE_KEY,
|
||||
caches: ["localStorage"],
|
||||
},
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
@@ -0,0 +1,5 @@
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
{
|
||||
"shell": {
|
||||
"apiUnreachable": "API unreachable at {{base}}. Ensure the API container is running and reachable.",
|
||||
"resetProxyUrl": "Reset saved proxy URL",
|
||||
"backToLibrary": "Back to library",
|
||||
"confirmLogout": "Are you sure you want to log out?",
|
||||
"sessionExpired": "Your session has expired. Please sign in again."
|
||||
},
|
||||
"login": {
|
||||
"subtitle": "Sign in to continue",
|
||||
"apiOffline": "API appears offline. Ensure the API service is running.",
|
||||
"loginFailed": "Login failed",
|
||||
"usernameLabel": "Username",
|
||||
"passwordLabel": "Password",
|
||||
"signIn": "Sign in",
|
||||
"signingIn": "Signing in…"
|
||||
},
|
||||
"sidebar": {
|
||||
"brand": "PrintStash",
|
||||
"libraryTitle": "Library",
|
||||
"folderCountTitle": "{{count}} folders",
|
||||
"newFolder": "New",
|
||||
"newFolderTitle": "New folder",
|
||||
"searchPlaceholder": "Search folders",
|
||||
"searchAriaLabel": "Search folders",
|
||||
"clearSearchAria": "Clear folder search",
|
||||
"allFiles": "All files",
|
||||
"root": "ROOT",
|
||||
"foldersHeading": "Folders",
|
||||
"noFoldersYet": "No folders yet",
|
||||
"createFirstFolder": "Create your first folder",
|
||||
"noMatchingFolders": "No matching folders",
|
||||
"clearSearch": "Clear search",
|
||||
"actionsFor": "Actions for {{name}}",
|
||||
"collapse": "Collapse",
|
||||
"expand": "Expand",
|
||||
"subfolder": "Subfolder",
|
||||
"renameEdit": "Rename & edit",
|
||||
"downloadZip": "Download ZIP",
|
||||
"delete": "Delete",
|
||||
"confirmDeleteFolder": "Delete folder? (Prints remain but become unassigned)",
|
||||
"untitled": "Untitled",
|
||||
"createFolderTitle": "New folder",
|
||||
"createFolderSubtitle": "Choose a name and location",
|
||||
"editFolderTitle": "Edit folder",
|
||||
"editFolderSubtitle": "Update its details",
|
||||
"nameLabel": "Name",
|
||||
"folderNamePlaceholder": "Folder name",
|
||||
"locationLabel": "Location",
|
||||
"rootOption": "(Root)",
|
||||
"create": "Create",
|
||||
"saveChanges": "Save changes",
|
||||
"addFolderTagsPlaceholder": "Add folder tags",
|
||||
"settings": "Settings",
|
||||
"errors": {
|
||||
"loadFolders": "Unable to load folders.",
|
||||
"createFolder": "Folder creation failed. Please try again.",
|
||||
"updateFolder": "Folder update failed. Please try again.",
|
||||
"deleteFolder": "Failed to delete folder.",
|
||||
"downloadFolder": "Unable to download folder.",
|
||||
"uploadFailed": "Failed to upload: {{files}}"
|
||||
},
|
||||
"multiplateImportFailedWithMessage": "Multi-plate import ({{message}})",
|
||||
"multiplateImportFailed": "Multi-plate import"
|
||||
},
|
||||
"uploadBar": {
|
||||
"upload": "Upload",
|
||||
"uploading": "Uploading…",
|
||||
"uploadFolder": "Upload folder",
|
||||
"linkPlaceholder": "Paste model link (MakerWorld, Printables, Thingiverse...)",
|
||||
"importLink": "Import link",
|
||||
"importing": "Importing…",
|
||||
"importFailed": "Import failed",
|
||||
"uploadFailed": "Failed to upload: {{files}}",
|
||||
"importFailedList": "Failed to import: {{files}}",
|
||||
"multiplateImportFailedWithMessage": "Multi-plate import ({{message}})",
|
||||
"multiplateImportFailed": "Multi-plate import"
|
||||
},
|
||||
"zipImport": {
|
||||
"title": "Import zip",
|
||||
"chooseFilesTitle": "Choose files",
|
||||
"prompt": "How would you like to handle this zip file?",
|
||||
"importAsZip": "Import as zip",
|
||||
"importAndUnzip": "Import and unzip",
|
||||
"selectedCount": "{{selected}} of {{total}} selected",
|
||||
"selectAll": "Select all",
|
||||
"clear": "Clear",
|
||||
"noFiles": "No files found in this zip.",
|
||||
"importSelected": "Import selected",
|
||||
"cancel": "Cancel",
|
||||
"close": "Close",
|
||||
"readError": "Unable to read zip contents",
|
||||
"importError": "Import failed",
|
||||
"selectAtLeastOne": "Select at least one file to import."
|
||||
},
|
||||
"importMode": {
|
||||
"title": "Import {{count}} files",
|
||||
"prompt": "How should these files be imported?",
|
||||
"separateTitle": "Import as separate prints",
|
||||
"separateDesc": "Each file becomes its own print with a single plate.",
|
||||
"multiplateTitle": "Import as one multi-plate print",
|
||||
"multiplateDesc": "All files become plates of a single new print, in the order selected.",
|
||||
"importing": "Importing…",
|
||||
"close": "Close",
|
||||
"importError": "Import failed"
|
||||
},
|
||||
"tagInput": {
|
||||
"removeTag": "Remove {{tag}}"
|
||||
},
|
||||
"settings": {
|
||||
"root": {
|
||||
"slicerTitle": "Slicer",
|
||||
"slicerHeading": "Open in Slicer",
|
||||
"slicerDesc": "Pick a slicer for 3D model downloads.",
|
||||
"engravingTitle": "Engraving",
|
||||
"engravingHeading": "Open in Engraving Software",
|
||||
"engravingDesc": "Pick a laser app for vector and image files.",
|
||||
"themeTitle": "Theme",
|
||||
"themeHeading": "Appearance",
|
||||
"themeDesc": "Switch between light, dark, and neon colors.",
|
||||
"networkTitle": "Network",
|
||||
"networkHeading": "API address",
|
||||
"networkDesc": "Point the app at a different API address, if needed.",
|
||||
"previewsTitle": "Performance",
|
||||
"previewsHeading": "Model Previews",
|
||||
"previewsDesc": "Choose automatic, on-demand, or disabled preview generation.",
|
||||
"storageTitle": "Files",
|
||||
"storageHeading": "Storage Structure",
|
||||
"storageDesc": "Organize files with folders, model names, creators, tags, and collections.",
|
||||
"importsTitle": "Imports",
|
||||
"importsHeading": "Imports & Folder Scan",
|
||||
"importsDesc": "Manage import cookies and scan local folders.",
|
||||
"languageTitle": "Language",
|
||||
"languageHeading": "Interface Language",
|
||||
"languageDesc": "Choose the language used throughout PrintStash."
|
||||
},
|
||||
"back": "Back",
|
||||
"slicer": {
|
||||
"heading": "Slicer",
|
||||
"subtitle": "Control the Open in Slicer button for 3D files.",
|
||||
"helperPrefix": "Requires the Slicer Bridge helper to be installed on each client to register the printstash-slicer:// protocol. Download the installer from",
|
||||
"helperLinkText": "PrintStash releases",
|
||||
"helperSuffix": ". On Linux, mark the download as executable if prompted.",
|
||||
"enableLabel": "Enable Open in Slicer",
|
||||
"preferredLabel": "Preferred slicer",
|
||||
"footerNote": "The Open in Slicer button launches the custom protocol with a signed download URL."
|
||||
},
|
||||
"engraving": {
|
||||
"heading": "Engraving Software",
|
||||
"subtitle": "Control the Open in Engraving Software button for laser-ready files.",
|
||||
"helperPrefix": "Requires the Bridge helper to be installed on each client to register the printstash-engrave:// protocol. Download the installer from",
|
||||
"helperLinkText": "PrintStash releases",
|
||||
"helperSuffix": ". On Linux, mark the download as executable if prompted.",
|
||||
"enableLabel": "Enable Open in Engraving Software",
|
||||
"preferredLabel": "Preferred engraving software",
|
||||
"footerNote": "The Open in Engraving Software button launches the custom protocol with a signed download URL."
|
||||
},
|
||||
"theme": {
|
||||
"heading": "Theme",
|
||||
"subtitle": "Choose how PrintStash looks and feels."
|
||||
},
|
||||
"network": {
|
||||
"heading": "Network",
|
||||
"subtitle": "PrintStash serves the UI and API from the same address by default — you only need this if the API lives somewhere else.",
|
||||
"publicUrlHeading": "API address override",
|
||||
"publicUrlDesc": "Set this only if the API isn't reachable at the page's own address — for example, if you're hosting the frontend separately from the backend.",
|
||||
"storedNote": "This setting is stored in your browser, so each device can point to a different URL if needed.",
|
||||
"urlLabel": "API address",
|
||||
"urlPlaceholder": "https://api.printstash.example",
|
||||
"apiBase": "Calling the API at: {{url}}",
|
||||
"apiBaseAuto": "Same origin as this page",
|
||||
"save": "Save",
|
||||
"clear": "Clear"
|
||||
},
|
||||
"storage": {
|
||||
"heading": "Storage Structure",
|
||||
"subtitle": "Choose how managed model files are organized on disk.",
|
||||
"templateHeading": "Path template",
|
||||
"templateDesc": "The model name is unique inside its PrintStash folder. Renaming a model also renames its file while preserving the extension.",
|
||||
"templateLabel": "Template",
|
||||
"templatePlaceholder": "{folder}/{model}/{filename}",
|
||||
"examplePathsHeading": "Example paths (two plates of one print)",
|
||||
"examplePathsEmpty": "Enter a template to preview its path",
|
||||
"reorganizeLabel": "Reorganize existing managed files now",
|
||||
"reorganizeHint": "Mounted no-copy files are skipped. Existing URLs continue to use stable print IDs.",
|
||||
"save": "Save storage structure",
|
||||
"saving": "Saving…",
|
||||
"savedApplied": "Saved. Moved {{moved}} file(s){{skippedSuffix}}.",
|
||||
"skippedSuffix": "; skipped {{skipped}}",
|
||||
"savedNoApply": "Storage structure saved for new and updated models.",
|
||||
"failed": "Failed to save storage settings."
|
||||
},
|
||||
"previews": {
|
||||
"heading": "Model Previews",
|
||||
"subtitle": "Control background preview work on this browser.",
|
||||
"automaticLabel": "Automatic",
|
||||
"automaticDesc": "Load missing previews only as cards enter view, then save them for future visits.",
|
||||
"onDemandLabel": "On demand",
|
||||
"onDemandDesc": "Show saved previews immediately and generate missing ones only when requested.",
|
||||
"disabledLabel": "Disabled",
|
||||
"disabledDesc": "Never generate card previews. Interactive viewing remains available by opening a model.",
|
||||
"footerNote": "Embedded 3MF thumbnails and previously generated server thumbnails are always used when available."
|
||||
},
|
||||
"imports": {
|
||||
"heading": "Imports",
|
||||
"subtitle": "Manage session cookies and import local folders.",
|
||||
"mountHeading": "Mount import (server)",
|
||||
"mountDesc": "Index files from the server's mounted volume. Enable the no-copy option to keep files in place and avoid duplicates.",
|
||||
"mountPath": "Mount path: {{path}}",
|
||||
"mountPathNotConfigured": "Not configured",
|
||||
"mountNote": "Changes apply on the next server restart. In no-copy mode, mounted files stay read-only and renaming is disabled.",
|
||||
"mountEnableLabel": "Enable mount import on startup",
|
||||
"mountCopyLabel": "Copy files into PrintStash storage (disable to use mounted files directly)",
|
||||
"save": "Save",
|
||||
"saving": "Saving…",
|
||||
"loadingSettings": "Loading settings…",
|
||||
"scanHeading": "Local folder scan",
|
||||
"scanDesc": "Select a folder from this device (USB included) and import supported files. The folder name becomes a top-level folder, and subfolders stay nested.",
|
||||
"scanExtensions": "Supported extensions: {{extensions}}. Selecting a folder uploads immediately.",
|
||||
"scanSkipRootLabel": "Skip the top-level folder name",
|
||||
"chooseFolder": "Choose folder",
|
||||
"scanNow": "Scan now",
|
||||
"rescanNow": "Rescan now",
|
||||
"uploadingStatus": "Uploading…",
|
||||
"clear": "Clear",
|
||||
"noFolderSelected": "No folder selected yet.",
|
||||
"selectedSummary": "Selected {{total}} file(s){{fromSuffix}}. Ready: {{ready}}. New: {{fresh}}. Skipped: {{skipped}}.",
|
||||
"fromSuffix": " from \"{{root}}\"",
|
||||
"noNewFiles": "No new files to upload.",
|
||||
"uploadedStatus": "Uploaded {{count}} file(s).",
|
||||
"uploadedWithFailures": "Uploaded {{count}} file(s). Failed: {{failed}}.",
|
||||
"uploadFailed": "Upload failed. Please try again.",
|
||||
"noSupportedFiles": "No supported files selected.",
|
||||
"noFilesFoundInFolder": "No files found in that folder.",
|
||||
"rootFolderCreateFailed": "Failed to create the top-level folder.",
|
||||
"makerworldHeading": "MakerWorld",
|
||||
"makerworldDesc": "Optional auth for importing MakerWorld models.",
|
||||
"makerworldHelp": "Paste the Cookie header from a logged-in makerworld.com request to lift download limits. This value is stored only in your browser.",
|
||||
"sessionCookieLabel": "Session cookie",
|
||||
"makerworldPlaceholder": "example: mw_session=...; mw_token=...;",
|
||||
"thingiverseHeading": "Thingiverse",
|
||||
"thingiverseDesc": "Optional auth for importing Thingiverse models.",
|
||||
"thingiverseHelp": "Paste your Thingiverse Cookie header to pass Cloudflare checks when importing. This value is stored only in your browser.",
|
||||
"thingiversePlaceholder": "example: cf_clearance=...; PHPSESSID=...;"
|
||||
},
|
||||
"language": {
|
||||
"heading": "Interface Language",
|
||||
"subtitle": "Choose the language used throughout PrintStash."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"close": "Close",
|
||||
"delete": "Delete",
|
||||
"remove": "Remove",
|
||||
"rename": "Rename",
|
||||
"edit": "Edit",
|
||||
"add": "Add",
|
||||
"upload": "Upload",
|
||||
"download": "Download",
|
||||
"import": "Import",
|
||||
"loading": "Loading…",
|
||||
"search": "Search",
|
||||
"settings": "Settings",
|
||||
"back": "Back",
|
||||
"confirm": "Confirm",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"ok": "OK",
|
||||
"name": "Name",
|
||||
"notes": "Notes",
|
||||
"tags": "Tags",
|
||||
"folder": "Folder",
|
||||
"folders": "Folders",
|
||||
"creator": "Creator",
|
||||
"collection": "Collection",
|
||||
"unassigned": "Unassigned",
|
||||
"selectAll": "Select all",
|
||||
"clearSelection": "Clear selection",
|
||||
"logOut": "Log out",
|
||||
"error": "Error",
|
||||
"success": "Success",
|
||||
"retry": "Retry",
|
||||
"language": "Language"
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
{
|
||||
"grid": {
|
||||
"searchPlaceholder": "Search title, filename, notes...",
|
||||
"search": "Search",
|
||||
"loading": "Loading...",
|
||||
"sortLabel": "Sort",
|
||||
"sortOptions": {
|
||||
"name": "Name",
|
||||
"size": "Size",
|
||||
"type": "File type",
|
||||
"folder": "Folder"
|
||||
},
|
||||
"sortAsc": "Asc",
|
||||
"sortDesc": "Desc",
|
||||
"resetTags": "Reset",
|
||||
"loadMore": "Load more",
|
||||
"loadingMore": "Loading...",
|
||||
"groupItemCount_one": "{{count}} item",
|
||||
"groupItemCount_few": "{{count}} items",
|
||||
"groupItemCount_other": "{{count}} items",
|
||||
"groupShow": "Show",
|
||||
"groupHide": "Hide",
|
||||
"groupEmpty": "No prints in this folder",
|
||||
"groupUngrouped": "Unassigned",
|
||||
"groupUntitled": "Untitled",
|
||||
"groupOther": "Other",
|
||||
"allItems": "All Items",
|
||||
"thisFolder": "this folder"
|
||||
},
|
||||
"dropOverlay": {
|
||||
"uploading": "Uploading to {{target}}...",
|
||||
"prompt": "Drop files or folders to upload to {{target}}"
|
||||
},
|
||||
"errors": {
|
||||
"loadPrintsFailed": "Failed to load prints. Please sign in again or refresh.",
|
||||
"loadMoreFailed": "Failed to load more prints.",
|
||||
"loadFoldersFailed": "Failed to load folders. Please refresh.",
|
||||
"uploadFailed": "Failed to upload: {{items}}",
|
||||
"tagUpdateFailed": "Tag update failed for: {{items}}",
|
||||
"saveDetailsFailed": "Failed to save model details.",
|
||||
"renameFailed": "Rename failed. Please try again.",
|
||||
"folderUpdateFailed": "Folder update failed. Please try again.",
|
||||
"downloadFailed": "Download failed. Please try again.",
|
||||
"bulkDownloadFailed": "Bulk download failed.",
|
||||
"downloadByTagFailed": "Download by tag failed.",
|
||||
"selectAtLeastOne": "Select at least one item to download.",
|
||||
"deleteFailed": "Delete failed for: {{items}}",
|
||||
"loadFilesFailed": "Failed to load supporting files.",
|
||||
"addFilesFailed": "Failed to add files.",
|
||||
"removeFileFailed": "Failed to remove file.",
|
||||
"clearPreparedFailed": "Failed to clear prepared print.",
|
||||
"addPlatesFailed": "Failed to add plate(s).",
|
||||
"addPlateFailed": "Failed to add plate.",
|
||||
"removePlateFailed": "Failed to remove plate.",
|
||||
"multiplateImportFailed": "Multi-plate import",
|
||||
"multiplateImportFailedWithMessage": "Multi-plate import ({{message}})"
|
||||
},
|
||||
"confirm": {
|
||||
"deleteMultiple": "Delete {{count}} selected items? This cannot be undone.",
|
||||
"deleteSingle": "Delete \"{{name}}\"? This cannot be undone.",
|
||||
"removeFile": "Remove {{filename}} from this model?",
|
||||
"clearPrepared": "Clear the attached prepared print from this model?",
|
||||
"removePlate": "Remove plate \"{{filename}}\"?"
|
||||
},
|
||||
"card": {
|
||||
"select": "Select",
|
||||
"plateCountBadge_one": "{{count}} plate",
|
||||
"plateCountBadge_few": "{{count}} plates",
|
||||
"plateCountBadge_other": "{{count}} plates",
|
||||
"addingPlates": "Adding plate(s)...",
|
||||
"dropToAddPlate": "Drop to add as plate",
|
||||
"doubleClickPreview": "Double-click to open large preview",
|
||||
"downloadPlaceholder": "Download...",
|
||||
"downloadAllPlatesZip": "Download all plates (zip)",
|
||||
"downloadFile": "Download file",
|
||||
"downloadSelected": "Download all selected",
|
||||
"downloadTag": "Download tag: {{tag}}",
|
||||
"downloading": "Downloading...",
|
||||
"preparing": "Preparing...",
|
||||
"openInSlicer": "Open in {{slicer}}",
|
||||
"openInEngraving": "Open in {{engraver}}",
|
||||
"folderLabel": "Folder",
|
||||
"folderUpdating": "Updating...",
|
||||
"titleTooltip": "{{name}} · {{filename}}",
|
||||
"morePlates": "{{filename}} + {{count}} more",
|
||||
"byCreator": "By {{creator}}",
|
||||
"collectionLabel": "Collection: {{collection}}",
|
||||
"noTags": "No tags",
|
||||
"supportingFiles": "Supporting files",
|
||||
"addFiles": "Add files",
|
||||
"addingFiles": "Adding...",
|
||||
"addFilesTooltip": "Add documentation or a prepared print",
|
||||
"loadingFiles": "Loading files...",
|
||||
"removeFile": "Remove",
|
||||
"supportingFilesEmpty": "Add assembly instructions, notes, other documentation, or a prepared print.",
|
||||
"modelDetails": "Model details",
|
||||
"expand": "Expand",
|
||||
"collapse": "Collapse",
|
||||
"creatorPlaceholder": "Designer or source",
|
||||
"collectionPlaceholder": "Collection name",
|
||||
"notesPlaceholder": "Add some details about this print",
|
||||
"notSet": "Not set",
|
||||
"addNotes": "Add notes",
|
||||
"editDetails": "Edit details",
|
||||
"editTags": "Edit tags",
|
||||
"tagInputPlaceholder": "Type and press comma/Enter",
|
||||
"deleting": "Deleting...",
|
||||
"noDetails": "No details"
|
||||
},
|
||||
"preparedPrint": {
|
||||
"preparedFor": "Prepared for {{printer}}",
|
||||
"available": "Prepared print available",
|
||||
"clear": "Clear",
|
||||
"clearing": "Clearing..."
|
||||
},
|
||||
"duration": {
|
||||
"days": "{{count}}d",
|
||||
"hours": "{{count}}h",
|
||||
"minutes": "{{count}}m"
|
||||
},
|
||||
"previewModal": {
|
||||
"plateCount_one": "{{count}} plate",
|
||||
"plateCount_few": "{{count}} plates",
|
||||
"plateCount_other": "{{count}} plates",
|
||||
"removePlateTooltip": "Remove plate",
|
||||
"addPlateTooltip": "Add plate",
|
||||
"addingPlate": "Adding...",
|
||||
"addPlate": "Add plate",
|
||||
"openPlateInSlicer": "Open plate in {{slicer}}",
|
||||
"openPlateInEngraving": "Open plate in {{engraver}}",
|
||||
"noTags": "No tags",
|
||||
"downloadPlate": "Download plate",
|
||||
"close": "Close"
|
||||
},
|
||||
"modelViewer": {
|
||||
"previewAlt": "Preview",
|
||||
"previewUnsupported": "Preview unsupported for this file type.",
|
||||
"previewFailed": "Preview failed to load.",
|
||||
"generatingPreview": "Generating preview...",
|
||||
"waitingToGenerate": "Waiting to generate preview...",
|
||||
"generatePreview": "Generate preview",
|
||||
"previewDisabled": "Preview generation disabled",
|
||||
"noPreview": "No preview",
|
||||
"previewUnavailable": "Preview unavailable"
|
||||
},
|
||||
"lightburn": {
|
||||
"generatingPreview": "Generating preview...",
|
||||
"previewUnavailable": "Preview unavailable"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
{
|
||||
"shell": {
|
||||
"apiUnreachable": "API nepasiekiamas adresu {{base}}. Įsitikinkite, kad API konteineris veikia ir pasiekiamas.",
|
||||
"resetProxyUrl": "Atstatyti išsaugotą proxy adresą",
|
||||
"backToLibrary": "Grįžti į biblioteką",
|
||||
"confirmLogout": "Ar tikrai norite atsijungti?",
|
||||
"sessionExpired": "Jūsų sesija baigėsi. Prašome prisijungti iš naujo."
|
||||
},
|
||||
"login": {
|
||||
"subtitle": "Prisijunkite, kad tęstumėte",
|
||||
"apiOffline": "Panašu, kad API neveikia. Įsitikinkite, kad API paslauga paleista.",
|
||||
"loginFailed": "Prisijungti nepavyko",
|
||||
"usernameLabel": "Naudotojo vardas",
|
||||
"passwordLabel": "Slaptažodis",
|
||||
"signIn": "Prisijungti",
|
||||
"signingIn": "Jungiamasi…"
|
||||
},
|
||||
"sidebar": {
|
||||
"brand": "PrintStash",
|
||||
"libraryTitle": "Biblioteka",
|
||||
"folderCountTitle": "{{count}} aplankų",
|
||||
"newFolder": "Naujas",
|
||||
"newFolderTitle": "Naujas aplankas",
|
||||
"searchPlaceholder": "Ieškoti aplankų",
|
||||
"searchAriaLabel": "Ieškoti aplankų",
|
||||
"clearSearchAria": "Išvalyti aplankų paiešką",
|
||||
"allFiles": "Visi failai",
|
||||
"root": "ŠAKNIS",
|
||||
"foldersHeading": "Aplankai",
|
||||
"noFoldersYet": "Kol kas nėra aplankų",
|
||||
"createFirstFolder": "Sukurti pirmą aplanką",
|
||||
"noMatchingFolders": "Atitinkančių aplankų nerasta",
|
||||
"clearSearch": "Išvalyti paiešką",
|
||||
"actionsFor": "Veiksmai su {{name}}",
|
||||
"collapse": "Suskleisti",
|
||||
"expand": "Išskleisti",
|
||||
"subfolder": "Poaplankis",
|
||||
"renameEdit": "Pervadinti ir redaguoti",
|
||||
"downloadZip": "Atsisiųsti ZIP",
|
||||
"delete": "Ištrinti",
|
||||
"confirmDeleteFolder": "Ištrinti aplanką? (Modeliai išliks, bet taps nepriskirti)",
|
||||
"untitled": "Be pavadinimo",
|
||||
"createFolderTitle": "Naujas aplankas",
|
||||
"createFolderSubtitle": "Pasirinkite pavadinimą ir vietą",
|
||||
"editFolderTitle": "Redaguoti aplanką",
|
||||
"editFolderSubtitle": "Atnaujinkite jo duomenis",
|
||||
"nameLabel": "Pavadinimas",
|
||||
"folderNamePlaceholder": "Aplanko pavadinimas",
|
||||
"locationLabel": "Vieta",
|
||||
"rootOption": "(Šaknis)",
|
||||
"create": "Sukurti",
|
||||
"saveChanges": "Išsaugoti pakeitimus",
|
||||
"addFolderTagsPlaceholder": "Pridėti aplanko žymas",
|
||||
"settings": "Nustatymai",
|
||||
"errors": {
|
||||
"loadFolders": "Nepavyko įkelti aplankų.",
|
||||
"createFolder": "Nepavyko sukurti aplanko. Bandykite dar kartą.",
|
||||
"updateFolder": "Nepavyko atnaujinti aplanko. Bandykite dar kartą.",
|
||||
"deleteFolder": "Nepavyko ištrinti aplanko.",
|
||||
"downloadFolder": "Nepavyko atsisiųsti aplanko.",
|
||||
"uploadFailed": "Nepavyko įkelti: {{files}}"
|
||||
},
|
||||
"multiplateImportFailedWithMessage": "Kelių plokščių importas ({{message}})",
|
||||
"multiplateImportFailed": "Kelių plokščių importas"
|
||||
},
|
||||
"uploadBar": {
|
||||
"upload": "Įkelti",
|
||||
"uploading": "Įkeliama…",
|
||||
"uploadFolder": "Įkelti aplanką",
|
||||
"linkPlaceholder": "Įklijuokite modelio nuorodą (MakerWorld, Printables, Thingiverse...)",
|
||||
"importLink": "Importuoti nuorodą",
|
||||
"importing": "Importuojama…",
|
||||
"importFailed": "Importuoti nepavyko",
|
||||
"uploadFailed": "Nepavyko įkelti: {{files}}",
|
||||
"importFailedList": "Nepavyko importuoti: {{files}}",
|
||||
"multiplateImportFailedWithMessage": "Kelių plokščių importas ({{message}})",
|
||||
"multiplateImportFailed": "Kelių plokščių importas"
|
||||
},
|
||||
"zipImport": {
|
||||
"title": "Importuoti zip",
|
||||
"chooseFilesTitle": "Pasirinkite failus",
|
||||
"prompt": "Kaip norėtumėte tvarkyti šį zip failą?",
|
||||
"importAsZip": "Importuoti kaip zip",
|
||||
"importAndUnzip": "Importuoti ir išpakuoti",
|
||||
"selectedCount": "Pažymėta {{selected}} iš {{total}}",
|
||||
"selectAll": "Pažymėti visus",
|
||||
"clear": "Išvalyti",
|
||||
"noFiles": "Šiame zip faile failų nerasta.",
|
||||
"importSelected": "Importuoti pažymėtus",
|
||||
"cancel": "Atšaukti",
|
||||
"close": "Uždaryti",
|
||||
"readError": "Nepavyko perskaityti zip failo turinio",
|
||||
"importError": "Importuoti nepavyko",
|
||||
"selectAtLeastOne": "Pasirinkite bent vieną failą importavimui."
|
||||
},
|
||||
"importMode": {
|
||||
"title": "Importuoti {{count}} failų",
|
||||
"prompt": "Kaip turėtų būti importuojami šie failai?",
|
||||
"separateTitle": "Importuoti kaip atskirus modelius",
|
||||
"separateDesc": "Kiekvienas failas taps atskiru modeliu su viena plokšte.",
|
||||
"multiplateTitle": "Importuoti kaip vieną kelių plokščių modelį",
|
||||
"multiplateDesc": "Visi failai taps vieno naujo modelio plokštėmis pasirinkta tvarka.",
|
||||
"importing": "Importuojama…",
|
||||
"close": "Uždaryti",
|
||||
"importError": "Importuoti nepavyko"
|
||||
},
|
||||
"tagInput": {
|
||||
"removeTag": "Pašalinti {{tag}}"
|
||||
},
|
||||
"settings": {
|
||||
"root": {
|
||||
"slicerTitle": "Pjaustyklė",
|
||||
"slicerHeading": "Atverti pjaustyklėje",
|
||||
"slicerDesc": "Pasirinkite pjaustyklę 3D modelių atsisiuntimams.",
|
||||
"engravingTitle": "Graviravimas",
|
||||
"engravingHeading": "Atverti graviravimo programoje",
|
||||
"engravingDesc": "Pasirinkite lazerio programą vektoriniams ir vaizdo failams.",
|
||||
"themeTitle": "Tema",
|
||||
"themeHeading": "Išvaizda",
|
||||
"themeDesc": "Perjunkite tarp šviesios, tamsios ir neoninių spalvų.",
|
||||
"networkTitle": "Tinklas",
|
||||
"networkHeading": "API adresas",
|
||||
"networkDesc": "Jei reikia, nurodykite kitą API adresą.",
|
||||
"previewsTitle": "Našumas",
|
||||
"previewsHeading": "Modelių peržiūros",
|
||||
"previewsDesc": "Pasirinkite automatinį, pagal poreikį arba išjungtą peržiūrų generavimą.",
|
||||
"storageTitle": "Failai",
|
||||
"storageHeading": "Saugojimo struktūra",
|
||||
"storageDesc": "Tvarkykite failus pagal aplankus, modelių pavadinimus, autorius, žymas ir kolekcijas.",
|
||||
"importsTitle": "Importas",
|
||||
"importsHeading": "Importas ir aplankų skenavimas",
|
||||
"importsDesc": "Tvarkykite importo slapukus ir skenuokite vietinius aplankus.",
|
||||
"languageTitle": "Kalba",
|
||||
"languageHeading": "Sąsajos kalba",
|
||||
"languageDesc": "Pasirinkite kalbą, naudojamą visame PrintStash."
|
||||
},
|
||||
"back": "Atgal",
|
||||
"slicer": {
|
||||
"heading": "Pjaustyklė",
|
||||
"subtitle": "Valdykite mygtuką „Atverti pjaustyklėje“ 3D failams.",
|
||||
"helperPrefix": "Reikalauja, kad kiekviename kliente būtų įdiegtas Slicer Bridge pagalbinis įrankis, registruojantis printstash-slicer:// protokolą. Atsisiųskite diegimo programą iš",
|
||||
"helperLinkText": "PrintStash leidimų",
|
||||
"helperSuffix": ". Sistemoje Linux, jei reikalaujama, pažymėkite atsisiųstą failą kaip vykdomąjį.",
|
||||
"enableLabel": "Įjungti „Atverti pjaustyklėje“",
|
||||
"preferredLabel": "Pageidaujama pjaustyklė",
|
||||
"footerNote": "Mygtukas „Atverti pjaustyklėje“ paleidžia pasirinktinį protokolą su pasirašytu atsisiuntimo adresu."
|
||||
},
|
||||
"engraving": {
|
||||
"heading": "Graviravimo programa",
|
||||
"subtitle": "Valdykite mygtuką „Atverti graviravimo programoje“ lazeriui paruoštiems failams.",
|
||||
"helperPrefix": "Reikalauja, kad kiekviename kliente būtų įdiegtas Bridge pagalbinis įrankis, registruojantis printstash-engrave:// protokolą. Atsisiųskite diegimo programą iš",
|
||||
"helperLinkText": "PrintStash leidimų",
|
||||
"helperSuffix": ". Sistemoje Linux, jei reikalaujama, pažymėkite atsisiųstą failą kaip vykdomąjį.",
|
||||
"enableLabel": "Įjungti „Atverti graviravimo programoje“",
|
||||
"preferredLabel": "Pageidaujama graviravimo programa",
|
||||
"footerNote": "Mygtukas „Atverti graviravimo programoje“ paleidžia pasirinktinį protokolą su pasirašytu atsisiuntimo adresu."
|
||||
},
|
||||
"theme": {
|
||||
"heading": "Tema",
|
||||
"subtitle": "Pasirinkite, kaip PrintStash atrodo ir jaučiasi."
|
||||
},
|
||||
"network": {
|
||||
"heading": "Tinklas",
|
||||
"subtitle": "PrintStash pagal nutylėjimą aptarnauja sąsają ir API iš to paties adreso — tai reikalinga tik jei API veikia kitur.",
|
||||
"publicUrlHeading": "API adreso perrašymas",
|
||||
"publicUrlDesc": "Nurodykite tik tuo atveju, jei API nepasiekiamas tuo pačiu adresu kaip puslapis — pavyzdžiui, jei sąsają talpinate atskirai nuo backend'o.",
|
||||
"storedNote": "Šis nustatymas saugomas jūsų naršyklėje, todėl kiekvienas įrenginys gali naudoti kitą adresą.",
|
||||
"urlLabel": "API adresas",
|
||||
"urlPlaceholder": "https://api.printstash.example",
|
||||
"apiBase": "Kreipiamasi į API adresu: {{url}}",
|
||||
"apiBaseAuto": "Tas pats adresas kaip puslapio",
|
||||
"save": "Išsaugoti",
|
||||
"clear": "Išvalyti"
|
||||
},
|
||||
"storage": {
|
||||
"heading": "Saugojimo struktūra",
|
||||
"subtitle": "Pasirinkite, kaip valdomi modelių failai tvarkomi diske.",
|
||||
"templateHeading": "Kelio šablonas",
|
||||
"templateDesc": "Modelio pavadinimas yra unikalus jo PrintStash aplanke. Modelio pervadinimas taip pat pervadina jo failą, išsaugant plėtinį.",
|
||||
"templateLabel": "Šablonas",
|
||||
"templatePlaceholder": "{folder}/{model}/{filename}",
|
||||
"examplePathsHeading": "Kelių pavyzdžiai (dvi vieno modelio plokštės)",
|
||||
"examplePathsEmpty": "Įveskite šabloną, kad pamatytumėte kelio pavyzdį",
|
||||
"reorganizeLabel": "Pertvarkyti esamus valdomus failus dabar",
|
||||
"reorganizeHint": "Prijungti be kopijavimo failai praleidžiami. Esami adresai toliau naudoja stabilius modelio ID.",
|
||||
"save": "Išsaugoti saugojimo struktūrą",
|
||||
"saving": "Saugoma…",
|
||||
"savedApplied": "Išsaugota. Perkelta {{moved}} failų{{skippedSuffix}}.",
|
||||
"skippedSuffix": "; praleista {{skipped}}",
|
||||
"savedNoApply": "Saugojimo struktūra išsaugota naujiems ir atnaujintiems modeliams.",
|
||||
"failed": "Nepavyko išsaugoti saugojimo nustatymų."
|
||||
},
|
||||
"previews": {
|
||||
"heading": "Modelių peržiūros",
|
||||
"subtitle": "Valdykite fono peržiūrų generavimą šioje naršyklėje.",
|
||||
"automaticLabel": "Automatinis",
|
||||
"automaticDesc": "Trūkstamas peržiūras įkelti tik joms atsiradus ekrane, tada išsaugoti ateičiai.",
|
||||
"onDemandLabel": "Pagal poreikį",
|
||||
"onDemandDesc": "Iš karto rodyti išsaugotas peržiūras, o trūkstamas generuoti tik paprašius.",
|
||||
"disabledLabel": "Išjungta",
|
||||
"disabledDesc": "Niekada negeneruoti kortelių peržiūrų. Interaktyvi peržiūra vis tiek pasiekiama atidarius modelį.",
|
||||
"footerNote": "Įtaisytosios 3MF miniatiūros ir anksčiau sugeneruotos serverio miniatiūros visada naudojamos, kai jos yra."
|
||||
},
|
||||
"imports": {
|
||||
"heading": "Importas",
|
||||
"subtitle": "Tvarkykite sesijos slapukus ir importuokite vietinius aplankus.",
|
||||
"mountHeading": "Prijungimo importas (serveris)",
|
||||
"mountDesc": "Indeksuokite failus iš serverio prijungto tomo. Įjunkite be kopijavimo parinktį, kad failai liktų vietoje ir būtų išvengta dublikatų.",
|
||||
"mountPath": "Prijungimo kelias: {{path}}",
|
||||
"mountPathNotConfigured": "Nesukonfigūruota",
|
||||
"mountNote": "Pakeitimai įsigalioja iš naujo paleidus serverį. Be kopijavimo režime prijungti failai lieka tik skaitymui, o pervadinimas išjungtas.",
|
||||
"mountEnableLabel": "Įjungti prijungimo importą paleidžiant",
|
||||
"mountCopyLabel": "Kopijuoti failus į PrintStash saugyklą (išjunkite, kad naudotumėte prijungtus failus tiesiogiai)",
|
||||
"save": "Išsaugoti",
|
||||
"saving": "Saugoma…",
|
||||
"loadingSettings": "Kraunami nustatymai…",
|
||||
"scanHeading": "Vietinio aplanko skenavimas",
|
||||
"scanDesc": "Pasirinkite aplanką iš šio įrenginio (įskaitant USB) ir importuokite palaikomus failus. Aplanko pavadinimas taps aukščiausio lygio aplanku, o poaplankiai išliks įdėti.",
|
||||
"scanExtensions": "Palaikomi plėtiniai: {{extensions}}. Pasirinkus aplanką, įkėlimas prasideda iš karto.",
|
||||
"scanSkipRootLabel": "Praleisti aukščiausio lygio aplanko pavadinimą",
|
||||
"chooseFolder": "Pasirinkti aplanką",
|
||||
"scanNow": "Skenuoti dabar",
|
||||
"rescanNow": "Skenuoti iš naujo",
|
||||
"uploadingStatus": "Įkeliama…",
|
||||
"clear": "Išvalyti",
|
||||
"noFolderSelected": "Aplankas dar nepasirinktas.",
|
||||
"selectedSummary": "Pasirinkta {{total}} failų{{fromSuffix}}. Paruošta: {{ready}}. Naujų: {{fresh}}. Praleista: {{skipped}}.",
|
||||
"fromSuffix": " iš „{{root}}“",
|
||||
"noNewFiles": "Nėra naujų failų įkėlimui.",
|
||||
"uploadedStatus": "Įkelta {{count}} failų.",
|
||||
"uploadedWithFailures": "Įkelta {{count}} failų. Nepavyko: {{failed}}.",
|
||||
"uploadFailed": "Įkėlimas nepavyko. Bandykite dar kartą.",
|
||||
"noSupportedFiles": "Nepasirinkta jokių palaikomų failų.",
|
||||
"noFilesFoundInFolder": "Tame aplanke failų nerasta.",
|
||||
"rootFolderCreateFailed": "Nepavyko sukurti aukščiausio lygio aplanko.",
|
||||
"makerworldHeading": "MakerWorld",
|
||||
"makerworldDesc": "Neprivaloma autentifikacija MakerWorld modelių importui.",
|
||||
"makerworldHelp": "Įklijuokite Cookie antraštę iš prisijungusio makerworld.com užklausos, kad panaikintumėte atsisiuntimo limitus. Ši reikšmė saugoma tik jūsų naršyklėje.",
|
||||
"sessionCookieLabel": "Sesijos slapukas",
|
||||
"makerworldPlaceholder": "pavyzdys: mw_session=...; mw_token=...;",
|
||||
"thingiverseHeading": "Thingiverse",
|
||||
"thingiverseDesc": "Neprivaloma autentifikacija Thingiverse modelių importui.",
|
||||
"thingiverseHelp": "Įklijuokite savo Thingiverse Cookie antraštę, kad importuojant būtų praeitas Cloudflare patikrinimas. Ši reikšmė saugoma tik jūsų naršyklėje.",
|
||||
"thingiversePlaceholder": "pavyzdys: cf_clearance=...; PHPSESSID=...;"
|
||||
},
|
||||
"language": {
|
||||
"heading": "Sąsajos kalba",
|
||||
"subtitle": "Pasirinkite kalbą, naudojamą visame PrintStash."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"save": "Išsaugoti",
|
||||
"cancel": "Atšaukti",
|
||||
"close": "Uždaryti",
|
||||
"delete": "Ištrinti",
|
||||
"remove": "Pašalinti",
|
||||
"rename": "Pervadinti",
|
||||
"edit": "Redaguoti",
|
||||
"add": "Pridėti",
|
||||
"upload": "Įkelti",
|
||||
"download": "Atsisiųsti",
|
||||
"import": "Importuoti",
|
||||
"loading": "Kraunama…",
|
||||
"search": "Ieškoti",
|
||||
"settings": "Nustatymai",
|
||||
"back": "Atgal",
|
||||
"confirm": "Patvirtinti",
|
||||
"yes": "Taip",
|
||||
"no": "Ne",
|
||||
"ok": "Gerai",
|
||||
"name": "Pavadinimas",
|
||||
"notes": "Pastabos",
|
||||
"tags": "Žymos",
|
||||
"folder": "Aplankas",
|
||||
"folders": "Aplankai",
|
||||
"creator": "Autorius",
|
||||
"collection": "Kolekcija",
|
||||
"unassigned": "Nepriskirta",
|
||||
"selectAll": "Pažymėti visus",
|
||||
"clearSelection": "Išvalyti pažymėjimą",
|
||||
"logOut": "Atsijungti",
|
||||
"error": "Klaida",
|
||||
"success": "Pavyko",
|
||||
"retry": "Bandyti dar kartą",
|
||||
"language": "Kalba"
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
{
|
||||
"grid": {
|
||||
"searchPlaceholder": "Ieškoti pagal pavadinimą, failą, pastabas...",
|
||||
"search": "Ieškoti",
|
||||
"loading": "Kraunama...",
|
||||
"sortLabel": "Rūšiuoti",
|
||||
"sortOptions": {
|
||||
"name": "Pavadinimas",
|
||||
"size": "Dydis",
|
||||
"type": "Failo tipas",
|
||||
"folder": "Aplankas"
|
||||
},
|
||||
"sortAsc": "Didėjančiai",
|
||||
"sortDesc": "Mažėjančiai",
|
||||
"resetTags": "Atstatyti",
|
||||
"loadMore": "Rodyti daugiau",
|
||||
"loadingMore": "Kraunama...",
|
||||
"groupItemCount_one": "{{count}} elementas",
|
||||
"groupItemCount_few": "{{count}} elementai",
|
||||
"groupItemCount_other": "{{count}} elementų",
|
||||
"groupShow": "Rodyti",
|
||||
"groupHide": "Slėpti",
|
||||
"groupEmpty": "Šiame aplanke nėra spaudinių",
|
||||
"groupUngrouped": "Nepriskirta",
|
||||
"groupUntitled": "Be pavadinimo",
|
||||
"groupOther": "Kita",
|
||||
"allItems": "Visi elementai",
|
||||
"thisFolder": "šį aplanką"
|
||||
},
|
||||
"dropOverlay": {
|
||||
"uploading": "Įkeliama į {{target}}...",
|
||||
"prompt": "Vilkite failus ar aplankus, kad įkeltumėte į {{target}}"
|
||||
},
|
||||
"errors": {
|
||||
"loadPrintsFailed": "Nepavyko įkelti spaudinių. Prisijunkite iš naujo arba atnaujinkite puslapį.",
|
||||
"loadMoreFailed": "Nepavyko įkelti daugiau spaudinių.",
|
||||
"loadFoldersFailed": "Nepavyko įkelti aplankų. Atnaujinkite puslapį.",
|
||||
"uploadFailed": "Nepavyko įkelti: {{items}}",
|
||||
"tagUpdateFailed": "Nepavyko atnaujinti žymų: {{items}}",
|
||||
"saveDetailsFailed": "Nepavyko išsaugoti modelio informacijos.",
|
||||
"renameFailed": "Nepavyko pervadinti. Bandykite dar kartą.",
|
||||
"folderUpdateFailed": "Nepavyko atnaujinti aplanko. Bandykite dar kartą.",
|
||||
"downloadFailed": "Nepavyko atsisiųsti. Bandykite dar kartą.",
|
||||
"bulkDownloadFailed": "Nepavyko masinio atsisiuntimo.",
|
||||
"downloadByTagFailed": "Nepavyko atsisiųsti pagal žymą.",
|
||||
"selectAtLeastOne": "Pasirinkite bent vieną elementą atsisiuntimui.",
|
||||
"deleteFailed": "Nepavyko ištrinti: {{items}}",
|
||||
"loadFilesFailed": "Nepavyko įkelti papildomų failų.",
|
||||
"addFilesFailed": "Nepavyko pridėti failų.",
|
||||
"removeFileFailed": "Nepavyko pašalinti failo.",
|
||||
"clearPreparedFailed": "Nepavyko išvalyti paruošto spaudinio.",
|
||||
"addPlatesFailed": "Nepavyko pridėti plokštės (-ių).",
|
||||
"addPlateFailed": "Nepavyko pridėti plokštės.",
|
||||
"removePlateFailed": "Nepavyko pašalinti plokštės.",
|
||||
"multiplateImportFailed": "Kelių plokščių importas",
|
||||
"multiplateImportFailedWithMessage": "Kelių plokščių importas ({{message}})"
|
||||
},
|
||||
"confirm": {
|
||||
"deleteMultiple": "Ištrinti {{count}} pasirinktus elementus? Šio veiksmo anuliuoti negalima.",
|
||||
"deleteSingle": "Ištrinti „{{name}}“? Šio veiksmo anuliuoti negalima.",
|
||||
"removeFile": "Pašalinti {{filename}} iš šio modelio?",
|
||||
"clearPrepared": "Išvalyti prie modelio pridėtą paruoštą spaudinį?",
|
||||
"removePlate": "Pašalinti plokštę „{{filename}}“?"
|
||||
},
|
||||
"card": {
|
||||
"select": "Pasirinkti",
|
||||
"plateCountBadge_one": "{{count}} plokštė",
|
||||
"plateCountBadge_few": "{{count}} plokštės",
|
||||
"plateCountBadge_other": "{{count}} plokščių",
|
||||
"addingPlates": "Pridedamos plokštės...",
|
||||
"dropToAddPlate": "Vilkite čia, kad pridėtumėte kaip plokštę",
|
||||
"doubleClickPreview": "Dukart spustelėkite, kad atidarytumėte didelį peržiūros langą",
|
||||
"downloadPlaceholder": "Atsisiųsti...",
|
||||
"downloadAllPlatesZip": "Atsisiųsti visas plokštes (zip)",
|
||||
"downloadFile": "Atsisiųsti failą",
|
||||
"downloadSelected": "Atsisiųsti visus pasirinktus",
|
||||
"downloadTag": "Atsisiųsti žymą: {{tag}}",
|
||||
"downloading": "Atsisiunčiama...",
|
||||
"preparing": "Ruošiama...",
|
||||
"openInSlicer": "Atidaryti su {{slicer}}",
|
||||
"openInEngraving": "Atidaryti su {{engraver}}",
|
||||
"folderLabel": "Aplankas",
|
||||
"folderUpdating": "Atnaujinama...",
|
||||
"titleTooltip": "{{name}} · {{filename}}",
|
||||
"morePlates": "{{filename}} + dar {{count}}",
|
||||
"byCreator": "Autorius: {{creator}}",
|
||||
"collectionLabel": "Kolekcija: {{collection}}",
|
||||
"noTags": "Nėra žymų",
|
||||
"supportingFiles": "Papildomi failai",
|
||||
"addFiles": "Pridėti failų",
|
||||
"addingFiles": "Pridedama...",
|
||||
"addFilesTooltip": "Pridėkite dokumentaciją arba paruoštą spaudinį",
|
||||
"loadingFiles": "Kraunami failai...",
|
||||
"removeFile": "Pašalinti",
|
||||
"supportingFilesEmpty": "Pridėkite surinkimo instrukcijas, pastabas, kitą dokumentaciją arba paruoštą spaudinį.",
|
||||
"modelDetails": "Modelio informacija",
|
||||
"expand": "Išskleisti",
|
||||
"collapse": "Suskleisti",
|
||||
"creatorPlaceholder": "Dizaineris arba šaltinis",
|
||||
"collectionPlaceholder": "Kolekcijos pavadinimas",
|
||||
"notesPlaceholder": "Pridėkite informacijos apie šį spaudinį",
|
||||
"notSet": "Nenurodyta",
|
||||
"addNotes": "Pridėti pastabų",
|
||||
"editDetails": "Redaguoti informaciją",
|
||||
"editTags": "Redaguoti žymas",
|
||||
"tagInputPlaceholder": "Rašykite ir spauskite kablelį arba Enter",
|
||||
"deleting": "Trinama...",
|
||||
"noDetails": "Nėra informacijos"
|
||||
},
|
||||
"preparedPrint": {
|
||||
"preparedFor": "Paruošta spausdintuvui {{printer}}",
|
||||
"available": "Yra paruoštas spaudinys",
|
||||
"clear": "Išvalyti",
|
||||
"clearing": "Valoma..."
|
||||
},
|
||||
"duration": {
|
||||
"days": "{{count}} d.",
|
||||
"hours": "{{count}} val.",
|
||||
"minutes": "{{count}} min."
|
||||
},
|
||||
"previewModal": {
|
||||
"plateCount_one": "{{count}} plokštė",
|
||||
"plateCount_few": "{{count}} plokštės",
|
||||
"plateCount_other": "{{count}} plokščių",
|
||||
"removePlateTooltip": "Pašalinti plokštę",
|
||||
"addPlateTooltip": "Pridėti plokštę",
|
||||
"addingPlate": "Pridedama...",
|
||||
"addPlate": "Pridėti plokštę",
|
||||
"openPlateInSlicer": "Atidaryti plokštę su {{slicer}}",
|
||||
"openPlateInEngraving": "Atidaryti plokštę su {{engraver}}",
|
||||
"noTags": "Nėra žymų",
|
||||
"downloadPlate": "Atsisiųsti plokštę",
|
||||
"close": "Uždaryti"
|
||||
},
|
||||
"modelViewer": {
|
||||
"previewAlt": "Peržiūra",
|
||||
"previewUnsupported": "Šio failo tipo peržiūra nepalaikoma.",
|
||||
"previewFailed": "Nepavyko įkelti peržiūros.",
|
||||
"generatingPreview": "Generuojama peržiūra...",
|
||||
"waitingToGenerate": "Laukiama peržiūros generavimo...",
|
||||
"generatePreview": "Generuoti peržiūrą",
|
||||
"previewDisabled": "Peržiūros generavimas išjungtas",
|
||||
"noPreview": "Peržiūros nėra",
|
||||
"previewUnavailable": "Peržiūra nepasiekiama"
|
||||
},
|
||||
"lightburn": {
|
||||
"generatingPreview": "Generuojama peržiūra...",
|
||||
"previewUnavailable": "Peržiūra nepasiekiama"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import "./i18n";
|
||||
import App from "./App";
|
||||
|
||||
const el = document.getElementById("root");
|
||||
if (!el) throw new Error("#root not found");
|
||||
createRoot(el).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,177 @@
|
||||
import React, { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Dialog from "@mui/material/Dialog";
|
||||
import DialogTitle from "@mui/material/DialogTitle";
|
||||
import DialogContent from "@mui/material/DialogContent";
|
||||
import Box from "@mui/material/Box";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import ButtonBase from "@mui/material/ButtonBase";
|
||||
import Alert from "@mui/material/Alert";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
|
||||
export type ImportMode = "separate" | "multiplate";
|
||||
|
||||
type ImportModePromptConfig = {
|
||||
label: string;
|
||||
count: number;
|
||||
onChoose: (mode: ImportMode) => Promise<void>;
|
||||
};
|
||||
|
||||
type ImportModePromptState = {
|
||||
config: ImportModePromptConfig;
|
||||
};
|
||||
|
||||
function errorMessage(err: unknown, fallback: string) {
|
||||
if (err instanceof Error && err.message.trim()) return err.message;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompts the user, for a flat multi-file drop or picker selection, to choose
|
||||
* between importing each file as its own print ("separate") or bundling all
|
||||
* of them as plates of one new multi-part print ("multiplate").
|
||||
*
|
||||
* Structurally similar to useZipImportPrompt() in ZipImportModal.tsx, but
|
||||
* this is a distinct decision (single vs. multi-plate print) with its own
|
||||
* simpler one-step UI -- it intentionally does not share state with the zip
|
||||
* import wizard.
|
||||
*/
|
||||
export function useImportModePrompt() {
|
||||
const { t } = useTranslation("app");
|
||||
const [state, setState] = useState<ImportModePromptState | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const resolveRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const close = () => {
|
||||
setState(null);
|
||||
setBusy(false);
|
||||
setError(null);
|
||||
if (resolveRef.current) {
|
||||
resolveRef.current();
|
||||
resolveRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const prompt = (config: ImportModePromptConfig) => {
|
||||
if (state) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
setBusy(false);
|
||||
setError(null);
|
||||
setState({ config });
|
||||
return new Promise<void>(resolve => {
|
||||
resolveRef.current = resolve;
|
||||
});
|
||||
};
|
||||
|
||||
const choose = async (mode: ImportMode) => {
|
||||
if (!state) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await state.config.onChoose(mode);
|
||||
close();
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, t("importMode.importError")));
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const modal = state ? (
|
||||
<ImportModeModal
|
||||
label={state.config.label}
|
||||
count={state.config.count}
|
||||
busy={busy}
|
||||
error={error}
|
||||
onChoose={choose}
|
||||
onClose={close}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
return { prompt, modal, isOpen: Boolean(state) };
|
||||
}
|
||||
|
||||
function ImportModeModal({
|
||||
label,
|
||||
count,
|
||||
busy,
|
||||
error,
|
||||
onChoose,
|
||||
onClose,
|
||||
}: {
|
||||
label: string;
|
||||
count: number;
|
||||
busy: boolean;
|
||||
error: string | null;
|
||||
onChoose: (mode: ImportMode) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("app");
|
||||
return (
|
||||
<Dialog open onClose={busy ? undefined : onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle sx={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 2 }}>
|
||||
<Box>
|
||||
<Typography variant="h6" component="div">{t("importMode.title", { count })}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">{label}</Typography>
|
||||
</Box>
|
||||
<IconButton size="small" onClick={onClose} disabled={busy} aria-label={t("importMode.close") ?? undefined}>
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Stack spacing={2}>
|
||||
<Typography variant="body2">{t("importMode.prompt")}</Typography>
|
||||
<Stack spacing={1.5}>
|
||||
<ButtonBase
|
||||
onClick={() => onChoose("separate")}
|
||||
disabled={busy}
|
||||
sx={{
|
||||
textAlign: "left",
|
||||
borderRadius: 2,
|
||||
border: "1px solid",
|
||||
borderColor: "divider",
|
||||
bgcolor: "background.paper",
|
||||
p: 1.5,
|
||||
display: "block",
|
||||
"&:hover": { boxShadow: 1 },
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2">{t("importMode.separateTitle")}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t("importMode.separateDesc")}
|
||||
</Typography>
|
||||
</ButtonBase>
|
||||
<ButtonBase
|
||||
onClick={() => onChoose("multiplate")}
|
||||
disabled={busy}
|
||||
sx={{
|
||||
textAlign: "left",
|
||||
borderRadius: 2,
|
||||
border: "1px solid",
|
||||
borderColor: "divider",
|
||||
bgcolor: "background.paper",
|
||||
p: 1.5,
|
||||
display: "block",
|
||||
"&:hover": { boxShadow: 1 },
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2">{t("importMode.multiplateTitle")}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t("importMode.multiplateDesc")}
|
||||
</Typography>
|
||||
</ButtonBase>
|
||||
</Stack>
|
||||
{busy && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t("importMode.importing")}
|
||||
</Typography>
|
||||
)}
|
||||
{error && <Alert severity="error">{error}</Alert>}
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import type { SxProps, Theme } from "@mui/material/styles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { extractLightBurnPreview } from "../../../services/lightburnExtract";
|
||||
|
||||
type PreviewState = "idle" | "loading" | "error";
|
||||
|
||||
type LightBurnPreviewProps = {
|
||||
url: string;
|
||||
assetId?: string;
|
||||
filename: string;
|
||||
/** Sizing/fit styles for the resolved <img>, e.g. { objectFit: "cover" } for a card thumbnail
|
||||
* vs. { objectFit: "contain", bgcolor: "action.hover" } for the full preview modal. */
|
||||
imgSx?: SxProps<Theme>;
|
||||
};
|
||||
|
||||
const previewCache = new Map<string, string>();
|
||||
|
||||
export default function LightBurnPreview({ url, assetId, filename, imgSx }: LightBurnPreviewProps) {
|
||||
const { t } = useTranslation(["library"]);
|
||||
const [preview, setPreview] = useState<string | null>(null);
|
||||
const [state, setState] = useState<PreviewState>("idle");
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const cacheKey = assetId ? `asset:${assetId}` : url;
|
||||
const cached = previewCache.get(cacheKey);
|
||||
if (cached) {
|
||||
setPreview(cached);
|
||||
setState("idle");
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}
|
||||
|
||||
setPreview(null);
|
||||
setState("loading");
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Preview fetch failed: ${res.status}`);
|
||||
}
|
||||
const bytes = new Uint8Array(await res.arrayBuffer());
|
||||
const payload = await extractLightBurnPreview(bytes);
|
||||
if (!payload) {
|
||||
throw new Error("Preview not found in LightBurn file");
|
||||
}
|
||||
const objectUrl = URL.createObjectURL(new Blob([payload.data], { type: payload.mime }));
|
||||
previewCache.set(cacheKey, objectUrl);
|
||||
if (alive) {
|
||||
setPreview(objectUrl);
|
||||
setState("idle");
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("LightBurn preview failed:", err);
|
||||
if (alive) {
|
||||
setState("error");
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [url, assetId]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
bgcolor: "action.hover",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: 1,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{preview ? (
|
||||
<Box component="img" src={preview} alt={filename} sx={imgSx} />
|
||||
) : state === "loading" ? (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t("library:lightburn.generatingPreview")}
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t("library:lightburn.previewUnavailable")}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Button from "@mui/material/Button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { uploadGeneratedThumbnail } from "../../../../../services/api";
|
||||
import { type ResolvedTheme } from "../../../../../constants/settingsOptions";
|
||||
import { generateModelSnapshot, queueSnapshotJob, snapshotCache } from "../../../../../services/modelSnapshotCache";
|
||||
|
||||
type SnapshotState = "idle" | "loading" | "error";
|
||||
type ModelSnapshotProps = {
|
||||
url: string;
|
||||
ext: string;
|
||||
plateId?: string;
|
||||
theme: ResolvedTheme;
|
||||
mode?: "automatic" | "on-demand";
|
||||
};
|
||||
|
||||
export function ModelSnapshot({ url, ext, plateId, mode = "automatic" }: ModelSnapshotProps) {
|
||||
const { t } = useTranslation(["library"]);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [snapshot, setSnapshot] = useState<string | null>(null);
|
||||
const [state, setState] = useState<SnapshotState>("idle");
|
||||
const [requested, setRequested] = useState(mode === "automatic");
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === "automatic") setRequested(true);
|
||||
}, [mode]);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let observer: IntersectionObserver | null = null;
|
||||
const cacheKey = plateId ? `plate:${plateId}` : url;
|
||||
if (snapshotCache.has(cacheKey)) {
|
||||
setSnapshot(snapshotCache.get(cacheKey)!);
|
||||
return;
|
||||
}
|
||||
const load = async () => {
|
||||
if (!containerRef.current) return;
|
||||
setState("loading");
|
||||
try {
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const width = Math.max(120, Math.floor(rect.width || 240));
|
||||
const height = Math.max(120, Math.floor(rect.height || 180));
|
||||
const image = await queueSnapshotJob(async () => {
|
||||
if (disposed) return null;
|
||||
return generateModelSnapshot(url, ext, width, height, "light");
|
||||
});
|
||||
if (disposed || !image) return;
|
||||
snapshotCache.set(cacheKey, image);
|
||||
setSnapshot(image);
|
||||
setState("idle");
|
||||
if (plateId) {
|
||||
try {
|
||||
const imageBlob = await fetch(image).then(response => response.blob());
|
||||
await uploadGeneratedThumbnail(plateId, imageBlob);
|
||||
} catch (err) {
|
||||
console.warn("Generated preview could not be persisted:", err);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Snapshot generation failed:", err);
|
||||
if (!disposed) {
|
||||
setState("error");
|
||||
}
|
||||
}
|
||||
};
|
||||
if (!requested) {
|
||||
setState("idle");
|
||||
return () => { disposed = true; };
|
||||
}
|
||||
if (mode === "automatic" && typeof IntersectionObserver !== "undefined") {
|
||||
observer = new IntersectionObserver(entries => {
|
||||
if (!entries.some(entry => entry.isIntersecting)) return;
|
||||
observer?.disconnect();
|
||||
void load();
|
||||
}, { rootMargin: "240px" });
|
||||
if (containerRef.current) observer.observe(containerRef.current);
|
||||
} else {
|
||||
void load();
|
||||
}
|
||||
return () => {
|
||||
disposed = true;
|
||||
observer?.disconnect();
|
||||
};
|
||||
}, [url, ext, plateId, mode, requested]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={containerRef}
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
bgcolor: "action.hover",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: 1,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{snapshot ? (
|
||||
<Box
|
||||
component="img"
|
||||
src={snapshot}
|
||||
alt={t("library:modelViewer.previewAlt")}
|
||||
sx={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||
/>
|
||||
) : state === "loading" ? (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t("library:modelViewer.generatingPreview")}
|
||||
</Typography>
|
||||
) : mode === "on-demand" && !requested ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={event => { event.stopPropagation(); setRequested(true); }}
|
||||
>
|
||||
{t("library:modelViewer.generatePreview")}
|
||||
</Button>
|
||||
) : (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t("library:modelViewer.waitingToGenerate")}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import * as THREE from "three";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type ResolvedTheme } from "../../../../constants/settingsOptions";
|
||||
import { applyThemeToObject, disposeObject3D, loadObjectFromAsset, paletteForTheme } from "../../../../services/modelLoaders";
|
||||
|
||||
type ModelViewerProps = { url: string; ext: string; viewKey?: string; theme: ResolvedTheme };
|
||||
|
||||
type ViewErrorKey = "unsupported" | "failed";
|
||||
|
||||
export default function ModelViewer({ url, ext, viewKey, theme }: ModelViewerProps) {
|
||||
const { t } = useTranslation(["library"]);
|
||||
const mountRef = useRef<HTMLDivElement | null>(null);
|
||||
const [viewError, setViewError] = useState<ViewErrorKey | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let activeObject: THREE.Object3D | null = null;
|
||||
const mount = mountRef.current;
|
||||
if (!mount) return;
|
||||
const palette = paletteForTheme(theme);
|
||||
setViewError(null);
|
||||
const reportError = (key: ViewErrorKey) => {
|
||||
if (!disposed) {
|
||||
setViewError(key);
|
||||
}
|
||||
};
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 1000);
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
||||
renderer.setSize(mount.clientWidth || 300, mount.clientHeight || 300);
|
||||
mount.appendChild(renderer.domElement);
|
||||
|
||||
scene.add(new THREE.HemisphereLight(0xffffff, 0x444444, 1.6));
|
||||
scene.add(new THREE.AmbientLight(0xffffff, 0.9));
|
||||
const dir1 = new THREE.DirectionalLight(0xffffff, 1.4);
|
||||
dir1.position.set(3, 6, 2);
|
||||
const dir2 = new THREE.DirectionalLight(0xffffff, 1.2);
|
||||
dir2.position.set(-4, -2, 3);
|
||||
scene.add(dir1);
|
||||
scene.add(dir2);
|
||||
camera.position.set(1, 1, 3);
|
||||
|
||||
let controls: any;
|
||||
let teardown: (() => void) | undefined;
|
||||
const storageKey = viewKey ? `ps-view-${viewKey}` : null;
|
||||
|
||||
const loadSavedView = () => {
|
||||
if (!storageKey || typeof window === "undefined") return false;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(storageKey);
|
||||
if (!raw) return false;
|
||||
const data = JSON.parse(raw);
|
||||
if (!Array.isArray(data?.position) || !Array.isArray(data?.target)) return false;
|
||||
camera.position.fromArray(data.position);
|
||||
controls?.target.fromArray(data.target);
|
||||
controls?.update();
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.warn("Failed to load saved view", err);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const saveView = () => {
|
||||
if (!storageKey || typeof window === "undefined" || !controls) return;
|
||||
try {
|
||||
const payload = {
|
||||
position: camera.position.toArray(),
|
||||
target: controls.target.toArray(),
|
||||
};
|
||||
window.localStorage.setItem(storageKey, JSON.stringify(payload));
|
||||
} catch {}
|
||||
};
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const { OrbitControls } = await import("three/examples/jsm/controls/OrbitControls.js");
|
||||
controls = new OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.05;
|
||||
controls.enablePan = true;
|
||||
controls.target.set(0, 0, 0);
|
||||
controls.addEventListener("change", saveView);
|
||||
|
||||
const centerSceneOn = (box: THREE.Box3) => {
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
const center = box.getCenter(new THREE.Vector3());
|
||||
const radius = Math.max(size.x, size.y, size.z) || 1;
|
||||
if (!loadSavedView()) {
|
||||
camera.position.copy(center).add(new THREE.Vector3(radius * 1.8, radius * 1.2, radius * 1.8));
|
||||
camera.lookAt(center);
|
||||
controls?.target.copy(center);
|
||||
controls?.update();
|
||||
}
|
||||
};
|
||||
|
||||
const e = (ext || "").toLowerCase();
|
||||
|
||||
try {
|
||||
const obj = await loadObjectFromAsset(e, url);
|
||||
if (!obj) {
|
||||
reportError("unsupported");
|
||||
return;
|
||||
}
|
||||
if (disposed) {
|
||||
disposeObject3D(obj);
|
||||
return;
|
||||
}
|
||||
applyThemeToObject(obj, palette);
|
||||
activeObject = obj;
|
||||
scene.add(obj);
|
||||
const box = new THREE.Box3().setFromObject(obj);
|
||||
if (!box.isEmpty()) {
|
||||
centerSceneOn(box);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Viewer asset load failed:", err);
|
||||
reportError("failed");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Viewer init failed:", err);
|
||||
}
|
||||
|
||||
const onResize = () => {
|
||||
if (!mount) return;
|
||||
const w = mount.clientWidth || 300;
|
||||
const h = mount.clientHeight || 300;
|
||||
renderer.setSize(w, h);
|
||||
camera.aspect = w / h;
|
||||
camera.updateProjectionMatrix();
|
||||
};
|
||||
window.addEventListener("resize", onResize);
|
||||
teardown = () => window.removeEventListener("resize", onResize);
|
||||
|
||||
const animate = () => {
|
||||
if (disposed) return;
|
||||
renderer.render(scene, camera);
|
||||
requestAnimationFrame(animate);
|
||||
};
|
||||
animate();
|
||||
})();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
try {
|
||||
teardown?.();
|
||||
mount.removeChild(renderer.domElement);
|
||||
} catch {}
|
||||
if (activeObject) {
|
||||
disposeObject3D(activeObject);
|
||||
}
|
||||
try {
|
||||
controls?.removeEventListener("change", saveView);
|
||||
controls?.dispose();
|
||||
} catch {}
|
||||
try {
|
||||
renderer.forceContextLoss?.();
|
||||
} catch {}
|
||||
renderer.dispose();
|
||||
};
|
||||
}, [url, ext, viewKey, theme]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={mountRef}
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
bgcolor: "action.hover",
|
||||
borderRadius: 1,
|
||||
overflow: "hidden",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{viewError && (
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
px: 2,
|
||||
textAlign: "center",
|
||||
bgcolor: "rgba(0, 0, 0, 0.55)",
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" fontWeight={600} color="error.light">
|
||||
{viewError === "unsupported"
|
||||
? t("library:modelViewer.previewUnsupported")
|
||||
: t("library:modelViewer.previewFailed")}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import Alert from "@mui/material/Alert";
|
||||
import Button from "@mui/material/Button";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type PreparedPrint } from "../../../services/api";
|
||||
import { formatPreparedDuration } from "../../../utils/duration";
|
||||
|
||||
// Shared by PrintCard (card-level prepared-print banner) and PrintPreviewModal (header banner).
|
||||
export default function PreparedPrintSummary({
|
||||
prepared,
|
||||
onClear,
|
||||
clearing = false,
|
||||
}: {
|
||||
prepared: PreparedPrint;
|
||||
onClear?: () => void;
|
||||
clearing?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation(["library"]);
|
||||
const details = [
|
||||
prepared.material,
|
||||
prepared.nozzle_mm ? `${prepared.nozzle_mm} mm` : null,
|
||||
formatPreparedDuration(t, prepared.estimated_seconds),
|
||||
].filter(Boolean);
|
||||
return (
|
||||
<Alert
|
||||
severity="success"
|
||||
variant="outlined"
|
||||
sx={{ py: 0.25, alignItems: "center" }}
|
||||
action={
|
||||
onClear ? (
|
||||
<Button size="small" color="inherit" onClick={onClear} disabled={clearing}>
|
||||
{clearing ? t("library:preparedPrint.clearing") : t("library:preparedPrint.clear")}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<Typography variant="caption">
|
||||
<strong>
|
||||
{prepared.printer
|
||||
? t("library:preparedPrint.preparedFor", { printer: prepared.printer })
|
||||
: t("library:preparedPrint.available")}
|
||||
</strong>
|
||||
{details.length > 0 && <> · {details.join(" · ")}</>}
|
||||
</Typography>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import Paper from "@mui/material/Paper";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import ButtonBase from "@mui/material/ButtonBase";
|
||||
import Button from "@mui/material/Button";
|
||||
import Chip from "@mui/material/Chip";
|
||||
import Divider from "@mui/material/Divider";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import UploadFileIcon from "@mui/icons-material/UploadFile";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
type Print,
|
||||
type PrintFile,
|
||||
fileUrl,
|
||||
listPrintFiles,
|
||||
uploadPrintFile,
|
||||
deletePrintFile,
|
||||
} from "../../../../../services/api";
|
||||
|
||||
// The "Supporting files" list/upload/remove panel on a PrintCard -- self-contained state (its own
|
||||
// expanded/loading/uploading flags and file list) that only PrintCard renders, so it lives under
|
||||
// PrintCard/components rather than being shared.
|
||||
export default function SupportingFilesPanel({
|
||||
item,
|
||||
onPrintChanged,
|
||||
}: {
|
||||
item: Print;
|
||||
onPrintChanged: (print: Print) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["library"]);
|
||||
const [filesExpanded, setFilesExpanded] = useState(false);
|
||||
const [supportingFiles, setSupportingFiles] = useState<PrintFile[]>([]);
|
||||
const [filesLoading, setFilesLoading] = useState(false);
|
||||
const [filesUploading, setFilesUploading] = useState(false);
|
||||
const relatedInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const loadSupportingFiles = async () => {
|
||||
setFilesLoading(true);
|
||||
try {
|
||||
setSupportingFiles(await listPrintFiles(item.id));
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert(err instanceof Error ? err.message : t("library:errors.loadFilesFailed"));
|
||||
} finally {
|
||||
setFilesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (filesExpanded) void loadSupportingFiles();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filesExpanded, item.id]);
|
||||
|
||||
const addRelatedFiles = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(event.target.files || []);
|
||||
event.target.value = "";
|
||||
if (!files.length) return;
|
||||
setFilesUploading(true);
|
||||
try {
|
||||
let updated = item;
|
||||
for (const file of files) {
|
||||
updated = await uploadPrintFile(item.id, file);
|
||||
onPrintChanged(updated);
|
||||
}
|
||||
setSupportingFiles(await listPrintFiles(item.id));
|
||||
setFilesExpanded(true);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert(err instanceof Error ? err.message : t("library:errors.addFilesFailed"));
|
||||
} finally {
|
||||
setFilesUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeRelatedFile = async (file: PrintFile) => {
|
||||
if (!window.confirm(t("library:confirm.removeFile", { filename: file.filename }))) return;
|
||||
try {
|
||||
const updated = await deletePrintFile(item.id, file.id);
|
||||
onPrintChanged(updated);
|
||||
setSupportingFiles(current => current.filter(entry => entry.id !== file.id));
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert(err instanceof Error ? err.message : t("library:errors.removeFileFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper variant="outlined" sx={{ fontSize: 14 }}>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" spacing={1} sx={{ p: 1 }}>
|
||||
<ButtonBase
|
||||
onClick={() => setFilesExpanded(value => !value)}
|
||||
sx={{ display: "flex", alignItems: "center", gap: 1, minWidth: 0 }}
|
||||
>
|
||||
<Typography variant="body2" fontWeight={600}>{t("library:card.supportingFiles")}</Typography>
|
||||
<Chip
|
||||
label={item.supporting_file_count || 0}
|
||||
size="small"
|
||||
sx={{ height: 18, fontSize: 11 }}
|
||||
/>
|
||||
</ButtonBase>
|
||||
<input
|
||||
ref={relatedInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
hidden
|
||||
accept=".pdf,.txt,.md,.doc,.docx,.rtf,.csv,.html,.zip,.gcode,.gco,.bgcode,.3mf"
|
||||
onChange={addRelatedFiles}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<UploadFileIcon fontSize="small" />}
|
||||
disabled={filesUploading}
|
||||
onClick={() => relatedInputRef.current?.click()}
|
||||
title={t("library:card.addFilesTooltip")}
|
||||
>
|
||||
{filesUploading ? t("library:card.addingFiles") : t("library:card.addFiles")}
|
||||
</Button>
|
||||
</Stack>
|
||||
{filesExpanded && (
|
||||
<>
|
||||
<Divider />
|
||||
<Box sx={{ px: 1, py: 1 }}>
|
||||
{filesLoading ? (
|
||||
<Typography variant="caption" color="text.secondary">{t("library:card.loadingFiles")}</Typography>
|
||||
) : supportingFiles.length ? (
|
||||
<Stack spacing={0.5}>
|
||||
{supportingFiles.map(file => (
|
||||
<Stack
|
||||
key={file.id}
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
spacing={1}
|
||||
sx={{ borderRadius: 1, px: 0.5, py: 0.5, "&:hover": { bgcolor: "action.hover" } }}
|
||||
>
|
||||
<Typography
|
||||
component="a"
|
||||
href={fileUrl(file.url)}
|
||||
download={file.filename}
|
||||
title={file.filename}
|
||||
variant="caption"
|
||||
noWrap
|
||||
sx={{ minWidth: 0, textDecoration: "none", "&:hover": { textDecoration: "underline" } }}
|
||||
>
|
||||
{file.filename}
|
||||
</Typography>
|
||||
<Button size="small" color="error" onClick={() => removeRelatedFile(file)} sx={{ minWidth: 0, fontSize: 11 }}>
|
||||
{t("library:card.removeFile")}
|
||||
</Button>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Typography variant="caption" color="text.secondary">{t("library:card.supportingFilesEmpty")}</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Box from "@mui/material/Box";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Paper from "@mui/material/Paper";
|
||||
import Card from "@mui/material/Card";
|
||||
import CardContent from "@mui/material/CardContent";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import Button from "@mui/material/Button";
|
||||
import Chip from "@mui/material/Chip";
|
||||
import Checkbox from "@mui/material/Checkbox";
|
||||
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import Menu from "@mui/material/Menu";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import LayersIcon from "@mui/icons-material/Layers";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import DownloadIcon from "@mui/icons-material/Download";
|
||||
import FolderIcon from "@mui/icons-material/Folder";
|
||||
import EditIcon from "@mui/icons-material/Edit";
|
||||
import { type Print, UnauthorizedError, addPlates, deletePreparedPrint } from "../../../../services/api";
|
||||
import { type PreviewSettings } from "../../../../services/settings";
|
||||
import { type ResolvedTheme } from "../../../../constants/settingsOptions";
|
||||
import { MODEL_EXTS, ENGRAVING_EXTS } from "../../../../constants/fileTypes";
|
||||
import { extOf } from "../../../../utils/fileExtensions";
|
||||
import { entriesFromDataTransfer } from "../../../../services/uploadTree";
|
||||
import TagBadge from "../../../../common/TagBadge";
|
||||
import TagInput from "../../../../common/TagInput";
|
||||
import PreparedPrintSummary from "../PreparedPrintSummary";
|
||||
import { renderPreviewContent } from "../renderPreviewContent";
|
||||
import SupportingFilesPanel from "./components/SupportingFilesPanel";
|
||||
|
||||
export default function PrintCard({
|
||||
item,
|
||||
onSaveTags,
|
||||
onSaveDetails,
|
||||
onRename,
|
||||
onPreview,
|
||||
onDownloadSingle,
|
||||
onDownloadByTag,
|
||||
onDownloadSelected,
|
||||
downloading,
|
||||
onDelete,
|
||||
deleting,
|
||||
onMoveFolder,
|
||||
folderOptions,
|
||||
moving,
|
||||
selected,
|
||||
onToggleSelected,
|
||||
hasSelection,
|
||||
bulkDownloading,
|
||||
slicerEnabled,
|
||||
slicerLabel,
|
||||
onOpenInSlicer,
|
||||
engravingEnabled,
|
||||
engraverLabel,
|
||||
onOpenInEngraving,
|
||||
theme,
|
||||
previewMode,
|
||||
onPrintChanged,
|
||||
onUnauthorized,
|
||||
}: {
|
||||
item: Print;
|
||||
onSaveTags: (id: string, tags: string[]) => void;
|
||||
onSaveDetails: (id: string, payload: { notes: string; creator: string; collection: string }) => void;
|
||||
onRename: (id: string, filename: string) => void;
|
||||
onPreview: (print: Print | null) => void;
|
||||
onDownloadSingle: (print: Print) => void;
|
||||
onDownloadByTag: (tag: string) => void;
|
||||
onDownloadSelected: () => void;
|
||||
downloading: boolean;
|
||||
onDelete: (print: Print) => void;
|
||||
deleting: boolean;
|
||||
onMoveFolder: (id: string, folder_id: string | null) => void;
|
||||
folderOptions: { id: string | null; name: string }[];
|
||||
moving: boolean;
|
||||
selected: boolean;
|
||||
onToggleSelected: () => void;
|
||||
hasSelection: boolean;
|
||||
bulkDownloading: boolean;
|
||||
slicerEnabled: boolean;
|
||||
slicerLabel: string;
|
||||
onOpenInSlicer: (print: Print) => void;
|
||||
engravingEnabled: boolean;
|
||||
engraverLabel: string;
|
||||
onOpenInEngraving: (print: Print) => void;
|
||||
theme: ResolvedTheme;
|
||||
previewMode: PreviewSettings["mode"];
|
||||
onPrintChanged: (print: Print) => void;
|
||||
onUnauthorized?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["library", "common"]);
|
||||
const [editingTags, setEditingTags] = useState(false);
|
||||
const [tagList, setTagList] = useState<string[]>(item.tags);
|
||||
const [editingNotes, setEditingNotes] = useState(false);
|
||||
const [notesValue, setNotesValue] = useState(item.notes || "");
|
||||
const [creatorValue, setCreatorValue] = useState(item.creator || "");
|
||||
const [collectionValue, setCollectionValue] = useState(item.collection || "");
|
||||
const [notesCollapsed, setNotesCollapsed] = useState(true);
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const [nameValue, setNameValue] = useState(item.name);
|
||||
const nameInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [downloadMenuAnchor, setDownloadMenuAnchor] = useState<HTMLElement | null>(null);
|
||||
const [folderMenuAnchor, setFolderMenuAnchor] = useState<HTMLElement | null>(null);
|
||||
const [preparedClearing, setPreparedClearing] = useState(false);
|
||||
const [plateDropActive, setPlateDropActive] = useState(false);
|
||||
const [addingPlates, setAddingPlates] = useState(false);
|
||||
const plateDragDepth = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editingTags) {
|
||||
setTagList(item.tags);
|
||||
}
|
||||
}, [item.tags, editingTags]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editingNotes) {
|
||||
setNotesValue(item.notes || "");
|
||||
setCreatorValue(item.creator || "");
|
||||
setCollectionValue(item.collection || "");
|
||||
}
|
||||
}, [item.notes, item.creator, item.collection, editingNotes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!renaming) {
|
||||
setNameValue(item.name);
|
||||
} else {
|
||||
setTimeout(() => nameInputRef.current?.select(), 0);
|
||||
}
|
||||
}, [item.name, renaming]);
|
||||
|
||||
const clearPreparedPrint = async () => {
|
||||
if (!window.confirm(t("library:confirm.clearPrepared"))) return;
|
||||
setPreparedClearing(true);
|
||||
try {
|
||||
onPrintChanged(await deletePreparedPrint(item.id));
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert(err instanceof Error ? err.message : t("library:errors.clearPreparedFailed"));
|
||||
} finally {
|
||||
setPreparedClearing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startEditing = () => {
|
||||
setTagList(item.tags);
|
||||
setEditingTags(true);
|
||||
};
|
||||
|
||||
const cancelEditing = () => {
|
||||
setEditingTags(false);
|
||||
setTagList(item.tags);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
await onSaveTags(item.id, tagList);
|
||||
setEditingTags(false);
|
||||
};
|
||||
|
||||
const saveNotes = async () => {
|
||||
await onSaveDetails(item.id, {
|
||||
notes: notesValue,
|
||||
creator: creatorValue,
|
||||
collection: collectionValue,
|
||||
});
|
||||
setEditingNotes(false);
|
||||
};
|
||||
|
||||
const cancelNotes = () => {
|
||||
setEditingNotes(false);
|
||||
setNotesValue(item.notes || "");
|
||||
setCreatorValue(item.creator || "");
|
||||
setCollectionValue(item.collection || "");
|
||||
};
|
||||
|
||||
const noteText = (item.notes || "").trim();
|
||||
|
||||
const commitRename = async () => {
|
||||
const next = nameValue.trim();
|
||||
if (!next) {
|
||||
setNameValue(item.name);
|
||||
setRenaming(false);
|
||||
return;
|
||||
}
|
||||
if (next === item.name) {
|
||||
setRenaming(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await onRename(item.id, next);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert(t("library:errors.renameFailed"));
|
||||
setNameValue(item.name);
|
||||
} finally {
|
||||
setRenaming(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cancelRename = () => {
|
||||
setNameValue(item.name);
|
||||
setRenaming(false);
|
||||
};
|
||||
|
||||
const handleRenameKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
commitRename();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
cancelRename();
|
||||
}
|
||||
};
|
||||
|
||||
const primaryPlate = item.plates[0];
|
||||
const primaryExt = extOf(primaryPlate?.filename || "");
|
||||
const canOpenInSlicer = slicerEnabled && (Boolean(item.prepared_print) || MODEL_EXTS.has(primaryExt));
|
||||
const canOpenInEngraving = engravingEnabled && ENGRAVING_EXTS.has(primaryExt);
|
||||
const isMultiPlate = item.plates.length > 1;
|
||||
const currentFolderName = folderOptions.find(opt => (opt.id || null) === (item.folder_id || null))?.name
|
||||
|| t("common:unassigned");
|
||||
|
||||
const isFileDragCard = (e: React.DragEvent<HTMLDivElement>) =>
|
||||
Array.from(e.dataTransfer?.types || []).includes("Files");
|
||||
|
||||
const handleCardDragEnter = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
if (!isFileDragCard(e)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
plateDragDepth.current += 1;
|
||||
setPlateDropActive(true);
|
||||
};
|
||||
|
||||
const handleCardDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
if (!isFileDragCard(e)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
};
|
||||
|
||||
const handleCardDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
if (!isFileDragCard(e)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
plateDragDepth.current -= 1;
|
||||
if (plateDragDepth.current <= 0) {
|
||||
plateDragDepth.current = 0;
|
||||
setPlateDropActive(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCardDrop = async (e: React.DragEvent<HTMLDivElement>) => {
|
||||
if (!isFileDragCard(e)) return;
|
||||
e.preventDefault();
|
||||
// Stop this from also bubbling up to the grid's "drop anywhere uploads a
|
||||
// new item" handler -- dropping onto an existing print card always means
|
||||
// "add these as plate(s) of this print".
|
||||
e.stopPropagation();
|
||||
plateDragDepth.current = 0;
|
||||
setPlateDropActive(false);
|
||||
const entries = await entriesFromDataTransfer(e.dataTransfer);
|
||||
const files = entries.map(entry => entry.file);
|
||||
if (!files.length) return;
|
||||
setAddingPlates(true);
|
||||
try {
|
||||
const { print: updated } = await addPlates(item.id, files);
|
||||
onPrintChanged(updated);
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedError) {
|
||||
onUnauthorized?.();
|
||||
return;
|
||||
}
|
||||
console.error(err);
|
||||
alert(err instanceof Error ? err.message : t("library:errors.addPlatesFailed"));
|
||||
} finally {
|
||||
setAddingPlates(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
variant="outlined"
|
||||
sx={{
|
||||
overflow: "hidden",
|
||||
borderColor: plateDropActive ? "primary.main" : "divider",
|
||||
outline: plateDropActive ? "2px solid" : "none",
|
||||
outlineColor: "primary.main",
|
||||
transition: "border-color 0.15s ease",
|
||||
}}
|
||||
onDragEnter={handleCardDragEnter}
|
||||
onDragOver={handleCardDragOver}
|
||||
onDragLeave={handleCardDragLeave}
|
||||
onDrop={handleCardDrop}
|
||||
>
|
||||
<Tooltip title={t("library:card.doubleClickPreview")}>
|
||||
<Box
|
||||
sx={{ height: 160, position: "relative", cursor: "pointer", bgcolor: "action.hover" }}
|
||||
onDoubleClick={() => onPreview(item)}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{renderPreviewContent(item, "card", theme, t, previewMode)}
|
||||
{isMultiPlate && (
|
||||
<Chip
|
||||
icon={<LayersIcon sx={{ fontSize: 14, color: "common.white !important" }} />}
|
||||
label={t("library:card.plateCountBadge", { count: item.plates.length })}
|
||||
size="small"
|
||||
sx={{
|
||||
position: "absolute",
|
||||
bottom: 6,
|
||||
right: 6,
|
||||
bgcolor: "rgba(0, 0, 0, 0.7)",
|
||||
color: "common.white",
|
||||
fontSize: 11,
|
||||
height: 22,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{(plateDropActive || addingPlates) && (
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
zIndex: 10,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
bgcolor: "rgba(0, 0, 0, 0.45)",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
<Paper elevation={3} sx={{ px: 1.5, py: 0.75 }}>
|
||||
<Typography variant="caption">
|
||||
{addingPlates ? t("library:card.addingPlates") : t("library:card.dropToAddPlate")}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
<CardContent sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
|
||||
{item.prepared_print && (
|
||||
<PreparedPrintSummary
|
||||
prepared={item.prepared_print}
|
||||
onClear={item.prepared_print.removable ? clearPreparedPrint : undefined}
|
||||
clearing={preparedClearing}
|
||||
/>
|
||||
)}
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" spacing={1}>
|
||||
<FormControlLabel
|
||||
sx={{ m: 0 }}
|
||||
control={<Checkbox size="small" checked={selected} onChange={onToggleSelected} />}
|
||||
label={<Typography variant="body2">{t("library:card.select")}</Typography>}
|
||||
/>
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
endIcon={<DownloadIcon fontSize="small" />}
|
||||
disabled={downloading || bulkDownloading}
|
||||
onClick={e => setDownloadMenuAnchor(e.currentTarget)}
|
||||
>
|
||||
{downloading
|
||||
? t("library:card.downloading")
|
||||
: bulkDownloading
|
||||
? t("library:card.preparing")
|
||||
: t("library:card.downloadPlaceholder")}
|
||||
</Button>
|
||||
<Menu anchorEl={downloadMenuAnchor} open={Boolean(downloadMenuAnchor)} onClose={() => setDownloadMenuAnchor(null)}>
|
||||
<MenuItem onClick={() => { setDownloadMenuAnchor(null); onDownloadSingle(item); }}>
|
||||
{isMultiPlate ? t("library:card.downloadAllPlatesZip") : t("library:card.downloadFile")}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
disabled={!hasSelection}
|
||||
onClick={() => { setDownloadMenuAnchor(null); onDownloadSelected(); }}
|
||||
>
|
||||
{t("library:card.downloadSelected")}
|
||||
</MenuItem>
|
||||
{item.tags.map(tg => (
|
||||
<MenuItem key={`tag-${tg}`} onClick={() => { setDownloadMenuAnchor(null); onDownloadByTag(tg); }}>
|
||||
{t("library:card.downloadTag", { tag: tg })}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</Stack>
|
||||
</Stack>
|
||||
{(canOpenInSlicer || canOpenInEngraving) && (
|
||||
<Stack direction="row" flexWrap="wrap" useFlexGap spacing={1} justifyContent="flex-end">
|
||||
{canOpenInSlicer && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="text"
|
||||
disabled={downloading || bulkDownloading}
|
||||
onClick={() => onOpenInSlicer(item)}
|
||||
>
|
||||
{t("library:card.openInSlicer", { slicer: slicerLabel })}
|
||||
</Button>
|
||||
)}
|
||||
{canOpenInEngraving && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="text"
|
||||
disabled={downloading || bulkDownloading}
|
||||
onClick={() => onOpenInEngraving(item)}
|
||||
>
|
||||
{t("library:card.openInEngraving", { engraver: engraverLabel })}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
<Stack spacing={0.5}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, textTransform: "uppercase", color: "text.secondary" }}>
|
||||
{moving ? t("library:card.folderUpdating") : t("library:card.folderLabel")}
|
||||
</Typography>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<FolderIcon fontSize="small" />}
|
||||
disabled={moving}
|
||||
onClick={e => setFolderMenuAnchor(e.currentTarget)}
|
||||
sx={{ justifyContent: "flex-start" }}
|
||||
>
|
||||
{currentFolderName}
|
||||
</Button>
|
||||
<Menu anchorEl={folderMenuAnchor} open={Boolean(folderMenuAnchor)} onClose={() => setFolderMenuAnchor(null)}>
|
||||
{folderOptions.map(opt => (
|
||||
<MenuItem
|
||||
key={opt.id || "none"}
|
||||
selected={(opt.id || null) === (item.folder_id || null)}
|
||||
onClick={() => {
|
||||
setFolderMenuAnchor(null);
|
||||
if ((opt.id || null) !== (item.folder_id || null)) onMoveFolder(item.id, opt.id);
|
||||
}}
|
||||
>
|
||||
{opt.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</Stack>
|
||||
<Box
|
||||
title={t("library:card.titleTooltip", { name: item.name, filename: primaryPlate?.filename || "" })}
|
||||
onDoubleClick={e => { e.stopPropagation(); setRenaming(true); }}
|
||||
onClick={e => renaming && e.stopPropagation()}
|
||||
>
|
||||
{renaming ? (
|
||||
<TextField
|
||||
inputRef={nameInputRef}
|
||||
value={nameValue}
|
||||
onChange={e => setNameValue(e.target.value)}
|
||||
onBlur={commitRename}
|
||||
onKeyDown={handleRenameKey}
|
||||
onClick={e => e.stopPropagation()}
|
||||
size="small"
|
||||
fullWidth
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<Typography variant="body2" fontWeight={600} noWrap>{item.name}</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.disabled" noWrap title={primaryPlate?.filename || ""}>
|
||||
{isMultiPlate
|
||||
? t("library:card.morePlates", { filename: primaryPlate?.filename || "", count: item.plates.length - 1 })
|
||||
: (primaryPlate?.filename || "")}
|
||||
</Typography>
|
||||
{(item.creator || item.collection) && (
|
||||
<Stack direction="row" flexWrap="wrap" useFlexGap spacing={1.5}>
|
||||
{item.creator && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t("library:card.byCreator", { creator: item.creator })}
|
||||
</Typography>
|
||||
)}
|
||||
{item.collection && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t("library:card.collectionLabel", { collection: item.collection })}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
<Stack direction="row" flexWrap="wrap" useFlexGap spacing={0.5}>
|
||||
{item.tags.length ? (
|
||||
item.tags.map(tg => <TagBadge key={tg} tag={tg} />)
|
||||
) : (
|
||||
<Typography variant="caption" color="text.disabled">{t("library:card.noTags")}</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
<SupportingFilesPanel item={item} onPrintChanged={onPrintChanged} />
|
||||
<Paper variant="outlined" sx={{ borderStyle: "dashed", p: 1, display: "flex", flexDirection: "column", gap: 1 }}>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
||||
<Typography variant="caption" sx={{ textTransform: "uppercase", color: "text.secondary" }}>
|
||||
{t("library:card.modelDetails")}
|
||||
</Typography>
|
||||
<Button size="small" onClick={() => setNotesCollapsed(v => !v)}>
|
||||
{notesCollapsed ? t("library:card.expand") : t("library:card.collapse")}
|
||||
</Button>
|
||||
</Stack>
|
||||
{!notesCollapsed && (
|
||||
<>
|
||||
{editingNotes ? (
|
||||
<>
|
||||
<TextField
|
||||
label={t("common:creator")}
|
||||
value={creatorValue}
|
||||
onChange={e => setCreatorValue(e.target.value)}
|
||||
placeholder={t("library:card.creatorPlaceholder")}
|
||||
size="small"
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label={t("common:collection")}
|
||||
value={collectionValue}
|
||||
onChange={e => setCollectionValue(e.target.value)}
|
||||
placeholder={t("library:card.collectionPlaceholder")}
|
||||
size="small"
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label={t("common:notes")}
|
||||
value={notesValue}
|
||||
onChange={e => setNotesValue(e.target.value)}
|
||||
placeholder={t("library:card.notesPlaceholder")}
|
||||
size="small"
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={3}
|
||||
/>
|
||||
<Stack direction="row" flexWrap="wrap" useFlexGap spacing={1}>
|
||||
<Button size="small" variant="contained" onClick={saveNotes}>{t("common:save")}</Button>
|
||||
<Button size="small" variant="outlined" onClick={cancelNotes}>{t("common:cancel")}</Button>
|
||||
</Stack>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1 }}>
|
||||
<Box>
|
||||
<Typography variant="caption" display="block" color="text.secondary" sx={{ textTransform: "uppercase" }}>
|
||||
{t("common:creator")}
|
||||
</Typography>
|
||||
<Typography variant="body2">{item.creator || t("library:card.notSet")}</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" display="block" color="text.secondary" sx={{ textTransform: "uppercase" }}>
|
||||
{t("common:collection")}
|
||||
</Typography>
|
||||
<Typography variant="body2">{item.collection || t("library:card.notSet")}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap", color: noteText ? "text.primary" : "text.disabled" }}>
|
||||
{noteText || t("library:card.addNotes")}
|
||||
</Typography>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<EditIcon fontSize="small" />}
|
||||
sx={{ alignSelf: "flex-start" }}
|
||||
onClick={() => setEditingNotes(true)}
|
||||
>
|
||||
{t("library:card.editDetails")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{notesCollapsed && (
|
||||
<Typography variant="body2" sx={{ color: noteText ? "text.primary" : "text.disabled" }}>
|
||||
{noteText
|
||||
? `${noteText.slice(0, 60)}${noteText.length > 60 ? "..." : ""}`
|
||||
: [item.creator, item.collection].filter(Boolean).join(" · ") || t("library:card.noDetails")}
|
||||
</Typography>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{editingTags ? (
|
||||
<Stack spacing={1}>
|
||||
<TagInput
|
||||
value={tagList}
|
||||
onChange={setTagList}
|
||||
placeholder={t("library:card.tagInputPlaceholder")}
|
||||
/>
|
||||
<Stack direction="row" flexWrap="wrap" useFlexGap spacing={1}>
|
||||
<Button size="small" variant="contained" onClick={save}>{t("common:save")}</Button>
|
||||
<Button size="small" variant="outlined" onClick={cancelEditing}>{t("common:cancel")}</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack direction="row" alignItems="center" flexWrap="wrap" useFlexGap spacing={1}>
|
||||
<Button size="small" variant="outlined" onClick={startEditing}>{t("library:card.editTags")}</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="error"
|
||||
startIcon={<DeleteIcon fontSize="small" />}
|
||||
onClick={() => onDelete(item)}
|
||||
disabled={deleting}
|
||||
>
|
||||
{deleting ? t("library:card.deleting") : t("common:delete")}
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
import { useRef } from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import ButtonBase from "@mui/material/ButtonBase";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import AddPhotoAlternateIcon from "@mui/icons-material/AddPhotoAlternate";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type Plate, fileUrl } from "../../../../../services/api";
|
||||
|
||||
// The plate-switcher thumbnail strip in PrintPreviewModal: a row of clickable plate thumbnails
|
||||
// (defaulting to whichever plate is passed as active -- callers are responsible for defaulting to
|
||||
// plate 0), each with an overlaid remove button, plus a trailing "add plate" tile. Owns its own
|
||||
// hidden file input so the parent modal only deals with File[] via onAddPlateFiles.
|
||||
export default function PlateSwitcher({
|
||||
plates,
|
||||
activePlateId,
|
||||
onSelectPlate,
|
||||
onRemovePlate,
|
||||
removingPlateId,
|
||||
canRemove,
|
||||
addingPlate,
|
||||
onAddPlateFiles,
|
||||
}: {
|
||||
plates: Plate[];
|
||||
activePlateId: string | null;
|
||||
onSelectPlate: (id: string) => void;
|
||||
onRemovePlate: (plate: Plate) => void;
|
||||
removingPlateId: string | null;
|
||||
canRemove: boolean;
|
||||
addingPlate: boolean;
|
||||
onAddPlateFiles: (files: File[]) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["library"]);
|
||||
const addPlateInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(event.target.files || []);
|
||||
event.target.value = "";
|
||||
if (files.length) onAddPlateFiles(files);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack direction="row" spacing={1} sx={{ overflowX: "auto", pb: 0.5 }}>
|
||||
{plates.map(plate => {
|
||||
const isActive = plate.id === activePlateId || (!activePlateId && plate.id === plates[0]?.id);
|
||||
return (
|
||||
<Box key={plate.id} sx={{ position: "relative", flexShrink: 0 }}>
|
||||
<ButtonBase
|
||||
onClick={() => onSelectPlate(plate.id)}
|
||||
title={plate.filename}
|
||||
sx={{
|
||||
height: 64,
|
||||
width: 64,
|
||||
overflow: "hidden",
|
||||
borderRadius: 1,
|
||||
border: "2px solid",
|
||||
borderColor: isActive ? "primary.main" : "divider",
|
||||
}}
|
||||
>
|
||||
{plate.thumb_url ? (
|
||||
<Box
|
||||
component="img"
|
||||
src={fileUrl(plate.thumb_url)}
|
||||
alt={plate.filename}
|
||||
sx={{ height: "100%", width: "100%", objectFit: "cover" }}
|
||||
/>
|
||||
) : (
|
||||
<Box sx={{ display: "flex", height: "100%", width: "100%", alignItems: "center", justifyContent: "center", bgcolor: "action.hover" }}>
|
||||
<Typography variant="caption" color="text.secondary">{plate.position + 1}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</ButtonBase>
|
||||
{canRemove && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onRemovePlate(plate)}
|
||||
disabled={removingPlateId === plate.id}
|
||||
title={t("library:previewModal.removePlateTooltip")}
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: -8,
|
||||
right: -8,
|
||||
width: 20,
|
||||
height: 20,
|
||||
bgcolor: "rgba(0, 0, 0, 0.7)",
|
||||
color: "common.white",
|
||||
"&:hover": { bgcolor: "rgba(0, 0, 0, 0.85)" },
|
||||
}}
|
||||
>
|
||||
<CloseIcon sx={{ fontSize: 12 }} />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
<ButtonBase
|
||||
onClick={() => addPlateInputRef.current?.click()}
|
||||
disabled={addingPlate}
|
||||
title={t("library:previewModal.addPlateTooltip")}
|
||||
sx={{
|
||||
height: 64,
|
||||
width: 64,
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 0.25,
|
||||
borderRadius: 1,
|
||||
border: "1px dashed",
|
||||
borderColor: "divider",
|
||||
}}
|
||||
>
|
||||
<AddPhotoAlternateIcon fontSize="small" />
|
||||
<Typography variant="caption" sx={{ fontSize: 9 }}>
|
||||
{addingPlate ? t("library:previewModal.addingPlate") : t("library:previewModal.addPlate")}
|
||||
</Typography>
|
||||
</ButtonBase>
|
||||
<input
|
||||
ref={addPlateInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
hidden
|
||||
accept=".stl,.3mf,.step,.stp,.obj"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Box from "@mui/material/Box";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Paper from "@mui/material/Paper";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Button from "@mui/material/Button";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import Dialog from "@mui/material/Dialog";
|
||||
import DialogTitle from "@mui/material/DialogTitle";
|
||||
import DialogContent from "@mui/material/DialogContent";
|
||||
import DialogActions from "@mui/material/DialogActions";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import DownloadIcon from "@mui/icons-material/Download";
|
||||
import {
|
||||
type Print,
|
||||
type Plate,
|
||||
UnauthorizedError,
|
||||
LastPlateError,
|
||||
fileUrl,
|
||||
addPlates,
|
||||
deletePlate,
|
||||
} from "../../../../services/api";
|
||||
import { type ResolvedTheme } from "../../../../constants/settingsOptions";
|
||||
import { MODEL_EXTS, ENGRAVING_EXTS } from "../../../../constants/fileTypes";
|
||||
import { SLICER_BRIDGE_ENABLED } from "../../../../constants/featureFlags";
|
||||
import { extOf } from "../../../../utils/fileExtensions";
|
||||
import { formatFileSize } from "../../../../utils/fileSize";
|
||||
import TagBadge from "../../../../common/TagBadge";
|
||||
import PreparedPrintSummary from "../PreparedPrintSummary";
|
||||
import { renderPreviewContent } from "../renderPreviewContent";
|
||||
import PlateSwitcher from "./components/PlateSwitcher";
|
||||
|
||||
export default function PrintPreviewModal({
|
||||
print,
|
||||
theme,
|
||||
slicerEnabled,
|
||||
slicerLabel,
|
||||
slicerSelected,
|
||||
engravingEnabled,
|
||||
engraverLabel,
|
||||
engravingSelected,
|
||||
onClose,
|
||||
onPrintChanged,
|
||||
onUnauthorized,
|
||||
}: {
|
||||
print: Print;
|
||||
theme: ResolvedTheme;
|
||||
slicerEnabled: boolean;
|
||||
slicerLabel: string;
|
||||
slicerSelected?: string;
|
||||
engravingEnabled: boolean;
|
||||
engraverLabel: string;
|
||||
engravingSelected?: string;
|
||||
onClose: () => void;
|
||||
onPrintChanged: (print: Print) => void;
|
||||
onUnauthorized?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["library", "common"]);
|
||||
const [activePlateId, setActivePlateId] = useState<string | null>(print.plates[0]?.id || null);
|
||||
const [addingPlate, setAddingPlate] = useState(false);
|
||||
const [removingPlateId, setRemovingPlateId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setActivePlateId(print.plates[0]?.id || null);
|
||||
// Only reset the active plate when a *different* print is opened -- an
|
||||
// in-place plate list change (add/remove) should not jump back to
|
||||
// plate[0] unless the active plate itself disappeared.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [print.id]);
|
||||
|
||||
const sortedPlates = useMemo(
|
||||
() => [...print.plates].sort((a, b) => a.position - b.position),
|
||||
[print.plates]
|
||||
);
|
||||
const activePlate = sortedPlates.find(p => p.id === activePlateId) || sortedPlates[0];
|
||||
|
||||
const handleAddPlateFiles = async (files: File[]) => {
|
||||
if (!files.length) return;
|
||||
setAddingPlate(true);
|
||||
try {
|
||||
const { print: updated } = await addPlates(print.id, files);
|
||||
onPrintChanged(updated);
|
||||
const newest = [...updated.plates].sort((a, b) => a.position - b.position).slice(-1)[0];
|
||||
if (newest) setActivePlateId(newest.id);
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedError) {
|
||||
onUnauthorized?.();
|
||||
return;
|
||||
}
|
||||
console.error(err);
|
||||
alert(err instanceof Error ? err.message : t("library:errors.addPlateFailed"));
|
||||
} finally {
|
||||
setAddingPlate(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemovePlate = async (plate: Plate) => {
|
||||
if (print.plates.length <= 1) return;
|
||||
if (!window.confirm(t("library:confirm.removePlate", { filename: plate.filename }))) return;
|
||||
setRemovingPlateId(plate.id);
|
||||
try {
|
||||
const { print: updated } = await deletePlate(print.id, plate.id);
|
||||
onPrintChanged(updated);
|
||||
setActivePlateId(current => {
|
||||
if (current !== plate.id) return current;
|
||||
const ordered = [...updated.plates].sort((a, b) => a.position - b.position);
|
||||
return ordered[0]?.id || null;
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedError) {
|
||||
onUnauthorized?.();
|
||||
return;
|
||||
}
|
||||
if (err instanceof LastPlateError) {
|
||||
alert(err.message);
|
||||
return;
|
||||
}
|
||||
console.error(err);
|
||||
alert(err instanceof Error ? err.message : t("library:errors.removePlateFailed"));
|
||||
} finally {
|
||||
setRemovingPlateId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const primaryExt = activePlate ? extOf(activePlate.filename) : "";
|
||||
const isFirstPlate = activePlate ? activePlate.position === 0 : false;
|
||||
const canOpenActiveInSlicer = Boolean(activePlate) && slicerEnabled &&
|
||||
((isFirstPlate && Boolean(print.prepared_print)) || MODEL_EXTS.has(primaryExt));
|
||||
const canOpenActiveInEngraving = Boolean(activePlate) && engravingEnabled && ENGRAVING_EXTS.has(primaryExt);
|
||||
|
||||
const openActiveInSlicer = () => {
|
||||
if (!SLICER_BRIDGE_ENABLED || !activePlate) return;
|
||||
const url = fileUrl(isFirstPlate ? (print.slicer_url || activePlate.url) : activePlate.url);
|
||||
const filename = isFirstPlate ? (print.slicer_filename || activePlate.filename) : activePlate.filename;
|
||||
const params = new URLSearchParams({ url, slicer: slicerSelected || "orca", filename: filename || "model" });
|
||||
window.location.href = `printstash-slicer://open?${params.toString()}`;
|
||||
};
|
||||
|
||||
const openActiveInEngraving = () => {
|
||||
if (!activePlate) return;
|
||||
const url = fileUrl(activePlate.url);
|
||||
const params = new URLSearchParams({ url, engraver: engravingSelected || "lightburn", filename: activePlate.filename || "design" });
|
||||
window.location.href = `printstash-engrave://open?${params.toString()}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open fullWidth maxWidth="lg" onClose={onClose}>
|
||||
<DialogTitle sx={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 2 }}>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography variant="h6" noWrap>{print.name}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{activePlate ? `${activePlate.filename} · ${formatFileSize(activePlate.size)}` : ""}
|
||||
{print.plates.length > 1 ? ` · ${t("library:previewModal.plateCount", { count: print.plates.length })}` : ""}
|
||||
</Typography>
|
||||
{(print.creator || print.collection) && (
|
||||
<Typography variant="caption" color="text.secondary" component="p">
|
||||
{[
|
||||
print.creator ? t("library:card.byCreator", { creator: print.creator }) : "",
|
||||
print.collection ? t("library:card.collectionLabel", { collection: print.collection }) : "",
|
||||
].filter(Boolean).join(" · ")}
|
||||
</Typography>
|
||||
)}
|
||||
{print.prepared_print && (
|
||||
<Box sx={{ mt: 1 }}><PreparedPrintSummary prepared={print.prepared_print} /></Box>
|
||||
)}
|
||||
</Box>
|
||||
<IconButton onClick={onClose} aria-label={t("common:close")} size="small">
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Stack spacing={2}>
|
||||
<PlateSwitcher
|
||||
plates={sortedPlates}
|
||||
activePlateId={activePlateId}
|
||||
onSelectPlate={setActivePlateId}
|
||||
onRemovePlate={handleRemovePlate}
|
||||
removingPlateId={removingPlateId}
|
||||
canRemove={print.plates.length > 1}
|
||||
addingPlate={addingPlate}
|
||||
onAddPlateFiles={handleAddPlateFiles}
|
||||
/>
|
||||
<Box sx={{ width: "100%", height: "65vh", minHeight: 360 }}>
|
||||
<Box sx={{ width: "100%", height: "100%", borderRadius: 2, bgcolor: "action.hover", display: "flex", alignItems: "center", justifyContent: "center", overflow: "hidden" }}>
|
||||
{renderPreviewContent(print, "modal", theme, t, "automatic", activePlate)}
|
||||
</Box>
|
||||
</Box>
|
||||
{(canOpenActiveInSlicer || canOpenActiveInEngraving) && (
|
||||
<Stack direction="row" flexWrap="wrap" useFlexGap spacing={1}>
|
||||
{canOpenActiveInSlicer && (
|
||||
<Button size="small" variant="outlined" onClick={openActiveInSlicer}>
|
||||
{t("library:previewModal.openPlateInSlicer", { slicer: slicerLabel })}
|
||||
</Button>
|
||||
)}
|
||||
{canOpenActiveInEngraving && (
|
||||
<Button size="small" variant="outlined" onClick={openActiveInEngraving}>
|
||||
{t("library:previewModal.openPlateInEngraving", { engraver: engraverLabel })}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
<Stack direction="row" flexWrap="wrap" useFlexGap spacing={0.5}>
|
||||
{print.tags.length ? (
|
||||
print.tags.map(tag => <TagBadge key={tag} tag={tag} />)
|
||||
) : (
|
||||
<Typography variant="caption" color="text.disabled">{t("library:previewModal.noTags")}</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
{print.notes && (
|
||||
<Paper variant="outlined" sx={{ borderStyle: "dashed", p: 1.5 }}>
|
||||
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>{print.notes}</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
{activePlate && (
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<DownloadIcon fontSize="small" />}
|
||||
component="a"
|
||||
href={fileUrl(activePlate.url)}
|
||||
download={activePlate.filename}
|
||||
>
|
||||
{t("library:previewModal.downloadPlate")}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outlined" onClick={onClose}>{t("library:previewModal.close")}</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import React from "react";
|
||||
import Menu from "@mui/material/Menu";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import ListItemIcon from "@mui/material/ListItemIcon";
|
||||
import Divider from "@mui/material/Divider";
|
||||
import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder";
|
||||
import EditIcon from "@mui/icons-material/Edit";
|
||||
import DownloadIcon from "@mui/icons-material/Download";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import type { Folder } from "../../../../../services/api";
|
||||
|
||||
type Props = {
|
||||
folder: Folder | null;
|
||||
anchorEl: HTMLElement | null;
|
||||
busy: boolean;
|
||||
labels: {
|
||||
subfolder: string;
|
||||
renameEdit: string;
|
||||
downloadZip: string;
|
||||
delete: string;
|
||||
};
|
||||
onClose: () => void;
|
||||
onSubfolder: (folder: Folder) => void;
|
||||
onRename: (folder: Folder) => void;
|
||||
onDownload: (folder: Folder) => void;
|
||||
onDelete: (folder: Folder) => void;
|
||||
};
|
||||
|
||||
/** The "..." row action menu (subfolder / rename / download / delete) -- a single shared
|
||||
* Menu instance anchored to whichever row's trigger was last clicked, rather than one
|
||||
* Menu per row. */
|
||||
export default function FolderActionMenu({ folder, anchorEl, busy, labels, onClose, onSubfolder, onRename, onDownload, onDelete }: Props) {
|
||||
return (
|
||||
<Menu anchorEl={anchorEl} open={Boolean(folder) && Boolean(anchorEl)} onClose={onClose}>
|
||||
{folder && [
|
||||
<MenuItem key="subfolder" disabled={busy} onClick={() => { onClose(); onSubfolder(folder); }}>
|
||||
<ListItemIcon><CreateNewFolderIcon fontSize="small" /></ListItemIcon>
|
||||
{labels.subfolder}
|
||||
</MenuItem>,
|
||||
<MenuItem key="rename" disabled={busy} onClick={() => { onClose(); onRename(folder); }}>
|
||||
<ListItemIcon><EditIcon fontSize="small" /></ListItemIcon>
|
||||
{labels.renameEdit}
|
||||
</MenuItem>,
|
||||
<MenuItem key="download" disabled={busy} onClick={() => { onClose(); onDownload(folder); }}>
|
||||
<ListItemIcon><DownloadIcon fontSize="small" /></ListItemIcon>
|
||||
{labels.downloadZip}
|
||||
</MenuItem>,
|
||||
<Divider key="divider" />,
|
||||
<MenuItem
|
||||
key="delete"
|
||||
disabled={busy}
|
||||
onClick={() => { onClose(); onDelete(folder); }}
|
||||
sx={{ color: "error.main" }}
|
||||
>
|
||||
<ListItemIcon sx={{ color: "error.main" }}><DeleteIcon fontSize="small" /></ListItemIcon>
|
||||
{labels.delete}
|
||||
</MenuItem>,
|
||||
]}
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Paper from "@mui/material/Paper";
|
||||
import Box from "@mui/material/Box";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import Button from "@mui/material/Button";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import Select from "@mui/material/Select";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import FormControl from "@mui/material/FormControl";
|
||||
import InputLabel from "@mui/material/InputLabel";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import CheckIcon from "@mui/icons-material/Check";
|
||||
import TagInput from "../../../../../common/TagInput";
|
||||
|
||||
export type FolderOption = { id: string | null; name: string };
|
||||
|
||||
type Props = {
|
||||
/** "create" omits the tags field (new folders start untagged); "edit" includes it. */
|
||||
mode: "create" | "edit";
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
name: string;
|
||||
onNameChange: (name: string) => void;
|
||||
namePlaceholder?: string;
|
||||
parentId: string | null;
|
||||
onParentChange: (id: string | null) => void;
|
||||
folderOptions: FolderOption[];
|
||||
tags?: string[];
|
||||
onTagsChange?: (tags: string[]) => void;
|
||||
tagsPlaceholder?: string;
|
||||
busy: boolean;
|
||||
submitLabel: string;
|
||||
submitDisabled?: boolean;
|
||||
onSubmit: () => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
/** The inline "New folder" / "Edit folder" panel shown below the tree -- one shared shape for
|
||||
* both modes, since they differ only in heading copy, the presence of a tags field, and the
|
||||
* submit action. */
|
||||
export default function FolderEditorPanel({
|
||||
mode,
|
||||
icon,
|
||||
title,
|
||||
subtitle,
|
||||
name,
|
||||
onNameChange,
|
||||
namePlaceholder,
|
||||
parentId,
|
||||
onParentChange,
|
||||
folderOptions,
|
||||
tags,
|
||||
onTagsChange,
|
||||
tagsPlaceholder,
|
||||
busy,
|
||||
submitLabel,
|
||||
submitDisabled,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: Props) {
|
||||
const { t } = useTranslation(["app", "common"]);
|
||||
const locationLabelId = `${mode}-folder-location-label`;
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
role="dialog"
|
||||
aria-label={title}
|
||||
sx={{ m: 1.5, p: 1.5, border: "1px solid", borderColor: "divider", borderRadius: 2 }}
|
||||
>
|
||||
<Stack direction="row" alignItems="flex-start" spacing={1} sx={{ mb: 1.5 }}>
|
||||
<Box sx={{ mt: 0.25, display: "flex" }}>{icon}</Box>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="subtitle2">{title}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{subtitle}</Typography>
|
||||
</Box>
|
||||
<IconButton size="small" onClick={onCancel} aria-label={t("common:cancel") ?? undefined}>
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
<Stack spacing={1.5}>
|
||||
<TextField
|
||||
autoFocus
|
||||
size="small"
|
||||
label={t("sidebar.nameLabel")}
|
||||
value={name}
|
||||
onChange={e => onNameChange(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === "Enter") onSubmit();
|
||||
if (e.key === "Escape") onCancel();
|
||||
}}
|
||||
placeholder={namePlaceholder}
|
||||
fullWidth
|
||||
/>
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel id={locationLabelId}>{t("sidebar.locationLabel")}</InputLabel>
|
||||
<Select
|
||||
labelId={locationLabelId}
|
||||
label={t("sidebar.locationLabel")}
|
||||
value={parentId || ""}
|
||||
onChange={e => onParentChange((e.target.value as string) || null)}
|
||||
>
|
||||
{folderOptions.map(opt => (
|
||||
<MenuItem key={opt.id ?? "root"} value={opt.id || ""}>{opt.name}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
{mode === "edit" && (
|
||||
<TagInput value={tags || []} onChange={onTagsChange || (() => {})} placeholder={tagsPlaceholder} />
|
||||
)}
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
startIcon={<CheckIcon fontSize="small" />}
|
||||
disabled={busy || submitDisabled}
|
||||
onClick={onSubmit}
|
||||
>
|
||||
{submitLabel}
|
||||
</Button>
|
||||
<Button size="small" onClick={onCancel}>
|
||||
{t("common:cancel")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import React from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import ListItemButton from "@mui/material/ListItemButton";
|
||||
import ListItemIcon from "@mui/material/ListItemIcon";
|
||||
import ListItemText from "@mui/material/ListItemText";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import List from "@mui/material/List";
|
||||
import Collapse from "@mui/material/Collapse";
|
||||
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
|
||||
import FolderIcon from "@mui/icons-material/Folder";
|
||||
import FolderOpenIcon from "@mui/icons-material/FolderOpen";
|
||||
import MoreHorizIcon from "@mui/icons-material/MoreHoriz";
|
||||
import type { Folder } from "../../../../../services/api";
|
||||
|
||||
/** Shared, tree-wide config a row needs to render itself and recurse into its children --
|
||||
* passed down explicitly rather than read from closures, so each row stays a plain
|
||||
* function of its props. */
|
||||
export type FolderTreeContext = {
|
||||
childrenMap: Record<string, Folder[]>;
|
||||
visibleFolderIds: Set<string> | null;
|
||||
expanded: Set<string>;
|
||||
dropTargetId: string | null;
|
||||
selectedId: string | null | undefined;
|
||||
query: string;
|
||||
untitledLabel: string;
|
||||
collapseLabel: string;
|
||||
expandLabel: string;
|
||||
actionsForLabel: (name: string) => string;
|
||||
folderPath: (folder: Folder) => string;
|
||||
onSelect: (id: string) => void;
|
||||
onToggleExpand: (id: string) => void;
|
||||
onDragOverTarget: (id: string) => (e: React.DragEvent<HTMLElement>) => void;
|
||||
onDropFiles: (id: string) => (e: React.DragEvent<HTMLElement>) => void;
|
||||
onOpenMenu: (folderId: string, anchorEl: HTMLElement) => void;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
folder: Folder;
|
||||
depth: number;
|
||||
ctx: FolderTreeContext;
|
||||
};
|
||||
|
||||
/** One row of the recursive folder tree, plus (via a nested Collapse + List) its own
|
||||
* children rendered as more FolderTreeRow instances. */
|
||||
export default function FolderTreeRow({ folder, depth, ctx }: Props) {
|
||||
const { visibleFolderIds, childrenMap, expanded, dropTargetId, selectedId, query, untitledLabel } = ctx;
|
||||
if (visibleFolderIds && !visibleFolderIds.has(folder.id)) return null;
|
||||
const children = (childrenMap[folder.id] || []).filter(
|
||||
child => !visibleFolderIds || visibleFolderIds.has(child.id)
|
||||
);
|
||||
const isSelected = selectedId === folder.id;
|
||||
const isOpen = !!query.trim() || expanded.has(folder.id);
|
||||
const isDropTarget = dropTargetId === folder.id;
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<ListItemButton
|
||||
selected={isSelected}
|
||||
onClick={() => ctx.onSelect(folder.id)}
|
||||
onDragOver={ctx.onDragOverTarget(folder.id)}
|
||||
onDrop={ctx.onDropFiles(folder.id)}
|
||||
title={ctx.folderPath(folder)}
|
||||
aria-current={isSelected ? "page" : undefined}
|
||||
sx={{
|
||||
pl: 1 + depth * 2,
|
||||
borderRadius: 1,
|
||||
mb: 0.25,
|
||||
...(isDropTarget && {
|
||||
outline: "2px dashed",
|
||||
outlineColor: "primary.main",
|
||||
outlineOffset: -2,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{children.length ? (
|
||||
<IconButton
|
||||
size="small"
|
||||
component="span"
|
||||
onClick={e => { e.stopPropagation(); ctx.onToggleExpand(folder.id); }}
|
||||
aria-label={isOpen ? ctx.collapseLabel : ctx.expandLabel}
|
||||
aria-expanded={isOpen}
|
||||
sx={{ mr: 0.5, p: 0.25 }}
|
||||
>
|
||||
<ChevronRightIcon
|
||||
fontSize="small"
|
||||
sx={{ transform: isOpen ? "rotate(90deg)" : "none", transition: "transform 0.15s" }}
|
||||
/>
|
||||
</IconButton>
|
||||
) : (
|
||||
<Box sx={{ width: 28, flexShrink: 0 }} aria-hidden="true" />
|
||||
)}
|
||||
<ListItemIcon sx={{ minWidth: 30 }}>
|
||||
{isOpen && children.length ? (
|
||||
<FolderOpenIcon fontSize="small" />
|
||||
) : (
|
||||
<FolderIcon fontSize="small" />
|
||||
)}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={folder.name || untitledLabel}
|
||||
primaryTypographyProps={{ noWrap: true, variant: "body2" }}
|
||||
/>
|
||||
<IconButton
|
||||
size="small"
|
||||
component="span"
|
||||
onClick={e => { e.stopPropagation(); ctx.onOpenMenu(folder.id, e.currentTarget); }}
|
||||
aria-label={ctx.actionsForLabel(folder.name || untitledLabel)}
|
||||
>
|
||||
<MoreHorizIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</ListItemButton>
|
||||
{children.length > 0 && (
|
||||
<Collapse in={isOpen} timeout="auto" unmountOnExit>
|
||||
<List component="div" disablePadding>
|
||||
{children.map(child => (
|
||||
<FolderTreeRow key={child.id} folder={child} depth={depth + 1} ctx={ctx} />
|
||||
))}
|
||||
</List>
|
||||
</Collapse>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,675 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Box from "@mui/material/Box";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Button from "@mui/material/Button";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import InputAdornment from "@mui/material/InputAdornment";
|
||||
import List from "@mui/material/List";
|
||||
import ListItemButton from "@mui/material/ListItemButton";
|
||||
import ListItemIcon from "@mui/material/ListItemIcon";
|
||||
import ListItemText from "@mui/material/ListItemText";
|
||||
import Chip from "@mui/material/Chip";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder";
|
||||
import EditIcon from "@mui/icons-material/Edit";
|
||||
import SettingsIcon from "@mui/icons-material/Settings";
|
||||
import LayersIcon from "@mui/icons-material/Layers";
|
||||
import { Folder, UnauthorizedError, createFolder, deleteFolder, downloadFolderZip, listFolders, updateFolder, uploadPrints } from "../../../../services/api";
|
||||
import { entriesFromDataTransfer, uploadEntriesToFolder } from "../../../../services/uploadTree";
|
||||
import { buildUploadEntriesFromZip, isZipFile, readZipEntries } from "../../../../services/zipUtils";
|
||||
import { useZipImportPrompt } from "../ZipImportModal";
|
||||
import { useImportModePrompt, type ImportMode } from "../ImportModeModal";
|
||||
import FolderTreeRow, { type FolderTreeContext } from "./components/FolderTreeRow";
|
||||
import FolderActionMenu from "./components/FolderActionMenu";
|
||||
import FolderEditorPanel, { type FolderOption } from "./components/FolderEditorPanel";
|
||||
|
||||
// Every dropped entry's relativePath equals its bare filename when the
|
||||
// selection has no folder structure -- that's the signal for the
|
||||
// separate-vs-multiplate prompt (a dropped folder always carries nested
|
||||
// relativePaths and skips this prompt).
|
||||
function isFlatFileSet(entries: { file: File; relativePath: string }[]) {
|
||||
return entries.length > 1 && entries.every(entry => entry.relativePath === entry.file.name);
|
||||
}
|
||||
|
||||
type Props = {
|
||||
selectedId?: string | null;
|
||||
onSelect: (id: string | null) => void;
|
||||
onFoldersChanged?: () => void;
|
||||
foldersVersion?: number;
|
||||
onUnauthorized?: () => void;
|
||||
onOpenSettings?: () => void;
|
||||
activeView?: "library" | "settings";
|
||||
onAssetsChanged?: () => void;
|
||||
};
|
||||
|
||||
const DROP_ALL_ID = "__all";
|
||||
|
||||
export default function Sidebar({
|
||||
selectedId,
|
||||
onSelect,
|
||||
onFoldersChanged,
|
||||
foldersVersion,
|
||||
onUnauthorized,
|
||||
onOpenSettings,
|
||||
activeView = "library",
|
||||
onAssetsChanged,
|
||||
}: Props) {
|
||||
const { t } = useTranslation(["app", "common"]);
|
||||
const [folders, setFolders] = useState<Folder[]>([]);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newParent, setNewParent] = useState<string | null>(null);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [editing, setEditing] = useState<string | null>(null);
|
||||
const [editName, setEditName] = useState("");
|
||||
const [editTags, setEditTags] = useState<string[]>([]);
|
||||
const [editParent, setEditParent] = useState<string | null>(null);
|
||||
const [dropTargetId, setDropTargetId] = useState<string | null>(null);
|
||||
const [dropUploading, setDropUploading] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [menuState, setMenuState] = useState<{ folderId: string; anchorEl: HTMLElement } | null>(null);
|
||||
const zipPrompt = useZipImportPrompt();
|
||||
const importModePrompt = useImportModePrompt();
|
||||
|
||||
const untitledLabel = t("sidebar.untitled");
|
||||
|
||||
const uploadFlatAsMultiplate = async (files: File[], folderId: string | null) => {
|
||||
try {
|
||||
const result = await uploadPrints(files, { folder_id: folderId || undefined, mode: "multiplate" });
|
||||
return { uploaded: result.prints.length, failed: [] as string[] };
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedError) {
|
||||
onUnauthorized?.();
|
||||
return { uploaded: 0, failed: [] as string[] };
|
||||
}
|
||||
const message = err instanceof Error ? err.message.trim() : "";
|
||||
return {
|
||||
uploaded: 0,
|
||||
failed: [
|
||||
message
|
||||
? t("sidebar.multiplateImportFailedWithMessage", { message })
|
||||
: t("sidebar.multiplateImportFailed"),
|
||||
],
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const uploadWithZipPrompt = async (entries: Awaited<ReturnType<typeof entriesFromDataTransfer>>, folderId: string | null) => {
|
||||
const normalEntries = entries.filter(entry => !isZipFile(entry.file.name));
|
||||
const zipEntries = entries.filter(entry => isZipFile(entry.file.name));
|
||||
let uploaded = 0;
|
||||
const failed: string[] = [];
|
||||
const applyResult = (result: { uploaded: number; failed: string[] }) => {
|
||||
uploaded += result.uploaded;
|
||||
failed.push(...result.failed);
|
||||
};
|
||||
if (normalEntries.length) {
|
||||
if (isFlatFileSet(normalEntries)) {
|
||||
await importModePrompt.prompt({
|
||||
label: normalEntries.map(entry => entry.file.name).join(", "),
|
||||
count: normalEntries.length,
|
||||
onChoose: async (mode: ImportMode) => {
|
||||
if (mode === "multiplate") {
|
||||
applyResult(await uploadFlatAsMultiplate(normalEntries.map(entry => entry.file), folderId));
|
||||
} else {
|
||||
applyResult(await uploadEntriesToFolder(normalEntries, folderId, onUnauthorized));
|
||||
}
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const result = await uploadEntriesToFolder(normalEntries, folderId, onUnauthorized);
|
||||
applyResult(result);
|
||||
}
|
||||
}
|
||||
for (const entry of zipEntries) {
|
||||
let zipData: Record<string, Uint8Array> | null = null;
|
||||
const baseParts = entry.relativePath.split("/").filter(Boolean);
|
||||
baseParts.pop();
|
||||
const basePath = baseParts.join("/");
|
||||
await zipPrompt.prompt({
|
||||
label: entry.file.name,
|
||||
onImportAsZip: async () => {
|
||||
const result = await uploadEntriesToFolder([entry], folderId, onUnauthorized);
|
||||
applyResult(result);
|
||||
},
|
||||
loadEntries: async () => {
|
||||
const result = await readZipEntries(entry.file);
|
||||
zipData = result.data;
|
||||
return result.entries;
|
||||
},
|
||||
onImportSelected: async (selectedPaths: string[]) => {
|
||||
if (!zipData) {
|
||||
const result = await readZipEntries(entry.file);
|
||||
zipData = result.data;
|
||||
}
|
||||
const unzipEntries = buildUploadEntriesFromZip(zipData || {}, selectedPaths, basePath);
|
||||
const result = await uploadEntriesToFolder(unzipEntries, folderId, onUnauthorized);
|
||||
applyResult(result);
|
||||
},
|
||||
});
|
||||
}
|
||||
if (uploaded) {
|
||||
onAssetsChanged?.();
|
||||
}
|
||||
if (failed.length) {
|
||||
alert(t("sidebar.errors.uploadFailed", { files: failed.join(", ") }));
|
||||
}
|
||||
};
|
||||
|
||||
const isFileDrag = (e: React.DragEvent<HTMLElement>) => {
|
||||
const types = Array.from(e.dataTransfer?.types || []);
|
||||
return types.includes("Files");
|
||||
};
|
||||
|
||||
const handleDragOverTarget = (targetId: string) => (e: React.DragEvent<HTMLElement>) => {
|
||||
if (!isFileDrag(e)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!dropUploading) {
|
||||
setDropTargetId(targetId);
|
||||
}
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
};
|
||||
|
||||
const handleDropFiles = (folderId: string | null) => async (
|
||||
e: React.DragEvent<HTMLElement>
|
||||
) => {
|
||||
if (!isFileDrag(e)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (dropUploading) return;
|
||||
setDropTargetId(null);
|
||||
setDropUploading(true);
|
||||
const entries = await entriesFromDataTransfer(e.dataTransfer);
|
||||
if (!entries.length) {
|
||||
setDropUploading(false);
|
||||
return;
|
||||
}
|
||||
await uploadWithZipPrompt(entries, folderId);
|
||||
setDropUploading(false);
|
||||
};
|
||||
|
||||
const handleSidebarDragOver = (e: React.DragEvent<HTMLElement>) => {
|
||||
if (!isFileDrag(e)) return;
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const handleSidebarDragLeave = (e: React.DragEvent<HTMLElement>) => {
|
||||
if (!isFileDrag(e)) return;
|
||||
if (e.currentTarget !== e.target) return;
|
||||
e.preventDefault();
|
||||
setDropTargetId(null);
|
||||
};
|
||||
|
||||
const handleSidebarDrop = (e: React.DragEvent<HTMLElement>) => {
|
||||
if (!isFileDrag(e)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (dropUploading) return;
|
||||
const targetFolderId =
|
||||
dropTargetId === DROP_ALL_ID ? null : dropTargetId;
|
||||
setDropTargetId(null);
|
||||
if (!targetFolderId && dropTargetId !== DROP_ALL_ID) return;
|
||||
setDropUploading(true);
|
||||
void (async () => {
|
||||
const entries = await entriesFromDataTransfer(e.dataTransfer);
|
||||
if (!entries.length) {
|
||||
setDropUploading(false);
|
||||
return;
|
||||
}
|
||||
await uploadWithZipPrompt(entries, targetFolderId);
|
||||
setDropUploading(false);
|
||||
})();
|
||||
};
|
||||
|
||||
const filenameFromDisposition = (res: Response, fallback: string) => {
|
||||
const dispo = res.headers.get("content-disposition") || "";
|
||||
const match = dispo.match(/filename="?([^";]+)"?/i);
|
||||
return (match && match[1]) || fallback;
|
||||
};
|
||||
|
||||
const saveResponseToDisk = async (res: Response, fallback: string) => {
|
||||
const filename = filenameFromDisposition(res, fallback);
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleError = (err: unknown, message: string) => {
|
||||
if (err instanceof UnauthorizedError) {
|
||||
onUnauthorized?.();
|
||||
return;
|
||||
}
|
||||
console.error(err);
|
||||
alert(message);
|
||||
};
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
setFolders(await listFolders());
|
||||
} catch (err) {
|
||||
handleError(err, t("sidebar.errors.loadFolders"));
|
||||
}
|
||||
};
|
||||
useEffect(() => { refresh(); }, [foldersVersion]);
|
||||
|
||||
const startCreate = (parentId: string | null = null) => {
|
||||
setEditing(null);
|
||||
setCreating(true);
|
||||
setNewName("");
|
||||
setNewParent(parentId ?? (selectedId || null));
|
||||
};
|
||||
const create = async () => {
|
||||
if (!newName.trim()) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await createFolder(newName.trim(), [], newParent || undefined);
|
||||
await refresh();
|
||||
if (newParent) {
|
||||
setExpanded(prev => new Set(prev).add(newParent));
|
||||
}
|
||||
onFoldersChanged?.();
|
||||
} catch (err) {
|
||||
handleError(err, t("sidebar.errors.createFolder"));
|
||||
} finally { setBusy(false); setCreating(false); }
|
||||
};
|
||||
|
||||
const startEdit = (f: Folder) => {
|
||||
setCreating(false);
|
||||
setEditing(f.id);
|
||||
setEditName(f.name);
|
||||
setEditTags(f.tags);
|
||||
setEditParent(f.parent_id || null);
|
||||
};
|
||||
const saveEdit = async () => {
|
||||
if (!editing) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await updateFolder(editing, editName.trim() || untitledLabel, editTags, editParent || undefined);
|
||||
await refresh();
|
||||
onFoldersChanged?.();
|
||||
} catch (err) {
|
||||
handleError(err, t("sidebar.errors.updateFolder"));
|
||||
} finally { setBusy(false); setEditing(null); setEditTags([]); setEditParent(null); }
|
||||
};
|
||||
|
||||
const remove = async (id: string) => {
|
||||
if (!confirm(t("sidebar.confirmDeleteFolder"))) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await deleteFolder(id);
|
||||
await refresh();
|
||||
onFoldersChanged?.();
|
||||
if (selectedId === id) onSelect(null);
|
||||
}
|
||||
catch (err) {
|
||||
handleError(err, t("sidebar.errors.deleteFolder"));
|
||||
}
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const downloadFolder = async (folder: Folder) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await downloadFolderZip(folder.id);
|
||||
const safe = (folder.name || "folder").replace(/\s+/g, "_") || "folder";
|
||||
await saveResponseToDisk(res, `${safe}.zip`);
|
||||
} catch (err) {
|
||||
handleError(err, t("sidebar.errors.downloadFolder"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const folderById = React.useMemo(() => {
|
||||
const map: Record<string, Folder> = {};
|
||||
folders.forEach(f => { map[f.id] = f; });
|
||||
return map;
|
||||
}, [folders]);
|
||||
|
||||
useEffect(() => {
|
||||
// Ensure selected folder path is expanded
|
||||
if (!selectedId) return;
|
||||
const next = new Set(expanded);
|
||||
let current = folderById[selectedId];
|
||||
const guard = new Set<string>();
|
||||
while (current?.parent_id && !guard.has(current.parent_id)) {
|
||||
next.add(current.parent_id);
|
||||
guard.add(current.parent_id);
|
||||
current = folderById[current.parent_id];
|
||||
}
|
||||
setExpanded(next);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedId, folderById]);
|
||||
|
||||
const isDescendant = React.useCallback(
|
||||
(candidateId: string, targetId: string) => {
|
||||
let current = folderById[candidateId];
|
||||
const guard = new Set<string>();
|
||||
while (current) {
|
||||
if (!current.parent_id) return false;
|
||||
if (current.parent_id === targetId) return true;
|
||||
if (guard.has(current.parent_id)) break;
|
||||
guard.add(current.parent_id);
|
||||
current = folderById[current.parent_id];
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[folderById]
|
||||
);
|
||||
|
||||
const folderPath = React.useCallback(
|
||||
(folder: Folder) => {
|
||||
const segments = [folder.name || untitledLabel];
|
||||
let current = folder;
|
||||
const guard = new Set<string>([folder.id]);
|
||||
while (current.parent_id) {
|
||||
const parent = folderById[current.parent_id];
|
||||
if (!parent || guard.has(parent.id)) break;
|
||||
segments.unshift(parent.name || untitledLabel);
|
||||
guard.add(parent.id);
|
||||
current = parent;
|
||||
}
|
||||
return segments.join(" / ");
|
||||
},
|
||||
[folderById, untitledLabel]
|
||||
);
|
||||
|
||||
const folderOptions: FolderOption[] = React.useMemo(() => {
|
||||
const opts: FolderOption[] = [{ id: null, name: t("sidebar.rootOption") }];
|
||||
const sorted = [...folders].sort((a, b) => folderPath(a).localeCompare(folderPath(b)));
|
||||
sorted.forEach(f => opts.push({ id: f.id, name: folderPath(f) }));
|
||||
return opts;
|
||||
}, [folders, folderPath, t]);
|
||||
|
||||
const childrenMap = React.useMemo(() => {
|
||||
const map: Record<string, Folder[]> = {};
|
||||
const push = (key: string, f: Folder) => {
|
||||
if (!map[key]) map[key] = [];
|
||||
map[key].push(f);
|
||||
};
|
||||
folders.forEach(f => push(f.parent_id || "__root", f));
|
||||
Object.values(map).forEach(list => list.sort((a, b) => a.name.localeCompare(b.name)));
|
||||
return map;
|
||||
}, [folders]);
|
||||
|
||||
const visibleFolderIds = React.useMemo(() => {
|
||||
const normalizedQuery = query.trim().toLocaleLowerCase();
|
||||
if (!normalizedQuery) return null;
|
||||
|
||||
const visible = new Set<string>();
|
||||
folders.forEach(folder => {
|
||||
if (!folderPath(folder).toLocaleLowerCase().includes(normalizedQuery)) return;
|
||||
let current: Folder | undefined = folder;
|
||||
const guard = new Set<string>();
|
||||
while (current && !guard.has(current.id)) {
|
||||
visible.add(current.id);
|
||||
guard.add(current.id);
|
||||
current = current.parent_id ? folderById[current.parent_id] : undefined;
|
||||
}
|
||||
});
|
||||
return visible;
|
||||
}, [folderById, folderPath, folders, query]);
|
||||
|
||||
// Default-expand root folders so they are visible
|
||||
useEffect(() => {
|
||||
const roots = childrenMap["__root"] || [];
|
||||
if (!roots.length) return;
|
||||
setExpanded(prev => {
|
||||
const next = new Set(prev);
|
||||
roots.forEach(r => next.add(r.id));
|
||||
return next;
|
||||
});
|
||||
}, [childrenMap]);
|
||||
|
||||
const toggleExpand = (id: string) => {
|
||||
setExpanded(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const activeMenuFolder = menuState ? folderById[menuState.folderId] ?? null : null;
|
||||
|
||||
const treeCtx: FolderTreeContext = {
|
||||
childrenMap,
|
||||
visibleFolderIds,
|
||||
expanded,
|
||||
dropTargetId,
|
||||
selectedId,
|
||||
query,
|
||||
untitledLabel,
|
||||
collapseLabel: t("sidebar.collapse"),
|
||||
expandLabel: t("sidebar.expand"),
|
||||
actionsForLabel: name => t("sidebar.actionsFor", { name }),
|
||||
folderPath,
|
||||
onSelect: id => onSelect(id),
|
||||
onToggleExpand: toggleExpand,
|
||||
onDragOverTarget: handleDragOverTarget,
|
||||
onDropFiles: handleDropFiles,
|
||||
onOpenMenu: (folderId, anchorEl) => setMenuState({ folderId, anchorEl }),
|
||||
};
|
||||
|
||||
const editFolderOptions = React.useMemo(
|
||||
() =>
|
||||
folderOptions.filter(
|
||||
opt => !editing || (opt.id !== editing && !(opt.id && isDescendant(opt.id, editing)))
|
||||
),
|
||||
[folderOptions, editing, isDescendant]
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="aside"
|
||||
onDragOver={handleSidebarDragOver}
|
||||
onDragLeave={handleSidebarDragLeave}
|
||||
onDrop={handleSidebarDrop}
|
||||
sx={{
|
||||
width: 280,
|
||||
flexShrink: 0,
|
||||
height: "100vh",
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
borderRight: "1px solid",
|
||||
borderColor: "divider",
|
||||
bgcolor: "background.paper",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ px: 2, pt: 2, pb: 1 }}>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ textTransform: "uppercase", letterSpacing: 0.5 }}>
|
||||
{t("sidebar.brand")}
|
||||
</Typography>
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<Typography variant="h6" noWrap>{t("sidebar.libraryTitle")}</Typography>
|
||||
<Chip size="small" label={folders.length} title={t("sidebar.folderCountTitle", { count: folders.length }) ?? undefined} />
|
||||
</Stack>
|
||||
</Box>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<AddIcon fontSize="small" />}
|
||||
onClick={() => startCreate(null)}
|
||||
title={t("sidebar.newFolderTitle") ?? undefined}
|
||||
>
|
||||
{t("sidebar.newFolder")}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<Box sx={{ px: 2, pb: 1 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
value={query}
|
||||
onChange={event => setQuery(event.target.value)}
|
||||
placeholder={t("sidebar.searchPlaceholder") ?? undefined}
|
||||
inputProps={{ "aria-label": t("sidebar.searchAriaLabel") ?? undefined }}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon fontSize="small" />
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: query ? (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setQuery("")}
|
||||
aria-label={t("sidebar.clearSearchAria") ?? undefined}
|
||||
>
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
) : undefined,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box component="nav" aria-label={t("sidebar.foldersHeading") ?? undefined} sx={{ flex: 1, overflow: "auto", px: 1 }}>
|
||||
<List disablePadding>
|
||||
<ListItemButton
|
||||
selected={!selectedId}
|
||||
onClick={() => onSelect(null)}
|
||||
onDragOver={handleDragOverTarget(DROP_ALL_ID)}
|
||||
onDrop={handleDropFiles(null)}
|
||||
sx={{
|
||||
borderRadius: 1,
|
||||
mb: 0.5,
|
||||
...(dropTargetId === DROP_ALL_ID && {
|
||||
outline: "2px dashed",
|
||||
outlineColor: "primary.main",
|
||||
outlineOffset: -2,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: 30 }}>
|
||||
<LayersIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={t("sidebar.allFiles")} primaryTypographyProps={{ variant: "body2" }} />
|
||||
<Chip size="small" variant="outlined" label={t("sidebar.root")} />
|
||||
</ListItemButton>
|
||||
</List>
|
||||
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ px: 1, py: 0.75 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ textTransform: "uppercase", letterSpacing: 0.5 }}>
|
||||
{t("sidebar.foldersHeading")}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{visibleFolderIds ? visibleFolderIds.size : folders.length}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<List disablePadding>
|
||||
{(childrenMap["__root"] || []).map(f => (
|
||||
<FolderTreeRow key={f.id} folder={f} depth={0} ctx={treeCtx} />
|
||||
))}
|
||||
</List>
|
||||
{folders.length === 0 && (
|
||||
<Stack alignItems="center" spacing={1} sx={{ py: 4, color: "text.secondary" }}>
|
||||
<CreateNewFolderIcon />
|
||||
<Typography variant="body2">{t("sidebar.noFoldersYet")}</Typography>
|
||||
<Button size="small" onClick={() => startCreate(null)}>{t("sidebar.createFirstFolder")}</Button>
|
||||
</Stack>
|
||||
)}
|
||||
{!!folders.length && visibleFolderIds?.size === 0 && (
|
||||
<Stack alignItems="center" spacing={1} sx={{ py: 4, color: "text.secondary" }}>
|
||||
<SearchIcon />
|
||||
<Typography variant="body2">{t("sidebar.noMatchingFolders")}</Typography>
|
||||
<Button size="small" onClick={() => setQuery("")}>{t("sidebar.clearSearch")}</Button>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<FolderActionMenu
|
||||
folder={activeMenuFolder}
|
||||
anchorEl={menuState?.anchorEl ?? null}
|
||||
busy={busy}
|
||||
labels={{
|
||||
subfolder: t("sidebar.subfolder"),
|
||||
renameEdit: t("sidebar.renameEdit"),
|
||||
downloadZip: t("sidebar.downloadZip"),
|
||||
delete: t("sidebar.delete"),
|
||||
}}
|
||||
onClose={() => setMenuState(null)}
|
||||
onSubfolder={folder => startCreate(folder.id)}
|
||||
onRename={startEdit}
|
||||
onDownload={downloadFolder}
|
||||
onDelete={folder => remove(folder.id)}
|
||||
/>
|
||||
|
||||
{creating && (
|
||||
<FolderEditorPanel
|
||||
mode="create"
|
||||
icon={<CreateNewFolderIcon fontSize="small" />}
|
||||
title={t("sidebar.createFolderTitle")}
|
||||
subtitle={t("sidebar.createFolderSubtitle")}
|
||||
name={newName}
|
||||
onNameChange={setNewName}
|
||||
namePlaceholder={t("sidebar.folderNamePlaceholder") ?? undefined}
|
||||
parentId={newParent}
|
||||
onParentChange={setNewParent}
|
||||
folderOptions={folderOptions}
|
||||
busy={busy}
|
||||
submitLabel={t("sidebar.create")}
|
||||
submitDisabled={!newName.trim()}
|
||||
onSubmit={create}
|
||||
onCancel={() => { setCreating(false); setNewParent(null); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<FolderEditorPanel
|
||||
mode="edit"
|
||||
icon={<EditIcon fontSize="small" />}
|
||||
title={t("sidebar.editFolderTitle")}
|
||||
subtitle={t("sidebar.editFolderSubtitle")}
|
||||
name={editName}
|
||||
onNameChange={setEditName}
|
||||
parentId={editParent}
|
||||
onParentChange={setEditParent}
|
||||
folderOptions={editFolderOptions}
|
||||
tags={editTags}
|
||||
onTagsChange={setEditTags}
|
||||
tagsPlaceholder={t("sidebar.addFolderTagsPlaceholder") ?? undefined}
|
||||
busy={busy}
|
||||
submitLabel={t("sidebar.saveChanges")}
|
||||
onSubmit={saveEdit}
|
||||
onCancel={() => { setEditing(null); setEditTags([]); setEditParent(null); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Box sx={{ borderTop: "1px solid", borderColor: "divider", p: 1 }}>
|
||||
<ListItemButton
|
||||
selected={activeView === "settings"}
|
||||
onClick={onOpenSettings}
|
||||
aria-label={t("common:settings") ?? undefined}
|
||||
sx={{ borderRadius: 1 }}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: 30 }}>
|
||||
<SettingsIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={t("common:settings")} primaryTypographyProps={{ variant: "body2" }} />
|
||||
</ListItemButton>
|
||||
</Box>
|
||||
{zipPrompt.modal}
|
||||
{importModePrompt.modal}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import UploadFileIcon from "@mui/icons-material/UploadFile";
|
||||
import DriveFolderUploadIcon from "@mui/icons-material/DriveFolderUpload";
|
||||
import LinkIcon from "@mui/icons-material/Link";
|
||||
import {
|
||||
UnauthorizedError,
|
||||
importFromLink,
|
||||
importZipFromLink,
|
||||
inspectImportLink,
|
||||
listImportZipEntries,
|
||||
uploadPrints,
|
||||
} from "../../../services/api";
|
||||
import { entriesFromFileList, uploadEntriesToFolder } from "../../../services/uploadTree";
|
||||
import { buildUploadEntriesFromZip, isZipFile, readZipEntries } from "../../../services/zipUtils";
|
||||
import { useZipImportPrompt } from "./ZipImportModal";
|
||||
import { useImportModePrompt, type ImportMode } from "./ImportModeModal";
|
||||
|
||||
// Every dropped/picked entry's relativePath equals its bare filename when the
|
||||
// selection has no folder structure. A webkitdirectory folder pick always
|
||||
// prefixes relativePath with the folder name, so this only ever fires for a
|
||||
// flat multi-file picker selection.
|
||||
function isFlatFileSet(entries: { file: File; relativePath: string }[]) {
|
||||
return entries.length > 1 && entries.every(entry => entry.relativePath === entry.file.name);
|
||||
}
|
||||
|
||||
type Props = {
|
||||
onUploaded: () => void;
|
||||
folderId?: string | null;
|
||||
makerworldCookie?: string | null;
|
||||
thingiverseCookie?: string | null;
|
||||
onUnauthorized?: () => void;
|
||||
};
|
||||
|
||||
export default function UploadBar({ onUploaded, folderId, makerworldCookie, thingiverseCookie, onUnauthorized }: Props) {
|
||||
const { t } = useTranslation("app");
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const folderInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [linkValue, setLinkValue] = useState("");
|
||||
const zipPrompt = useZipImportPrompt();
|
||||
const importModePrompt = useImportModePrompt();
|
||||
const isBusy = uploading || importing || zipPrompt.isOpen || importModePrompt.isOpen;
|
||||
|
||||
const uploadFlatAsMultiplate = async (files: File[]) => {
|
||||
try {
|
||||
const result = await uploadPrints(files, { folder_id: folderId || undefined, mode: "multiplate" });
|
||||
return { uploaded: result.prints.length, failed: [] as string[] };
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedError) {
|
||||
onUnauthorized?.();
|
||||
return { uploaded: 0, failed: [] as string[] };
|
||||
}
|
||||
const message = err instanceof Error ? err.message.trim() : "";
|
||||
return {
|
||||
uploaded: 0,
|
||||
failed: [
|
||||
message
|
||||
? t("uploadBar.multiplateImportFailedWithMessage", { message })
|
||||
: t("uploadBar.multiplateImportFailed"),
|
||||
],
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!folderInputRef.current) return;
|
||||
folderInputRef.current.setAttribute("webkitdirectory", "");
|
||||
folderInputRef.current.setAttribute("directory", "");
|
||||
}, []);
|
||||
|
||||
const uploadEntries = async (entries: ReturnType<typeof entriesFromFileList>) => {
|
||||
if (!entries.length) return;
|
||||
setUploading(true);
|
||||
const normalEntries = entries.filter(entry => !isZipFile(entry.file.name));
|
||||
const zipEntries = entries.filter(entry => isZipFile(entry.file.name));
|
||||
let uploaded = 0;
|
||||
const failed: string[] = [];
|
||||
const applyResult = (result: { uploaded: number; failed: string[] }) => {
|
||||
uploaded += result.uploaded;
|
||||
failed.push(...result.failed);
|
||||
};
|
||||
if (normalEntries.length) {
|
||||
if (isFlatFileSet(normalEntries)) {
|
||||
await importModePrompt.prompt({
|
||||
label: normalEntries.map(entry => entry.file.name).join(", "),
|
||||
count: normalEntries.length,
|
||||
onChoose: async (mode: ImportMode) => {
|
||||
if (mode === "multiplate") {
|
||||
applyResult(await uploadFlatAsMultiplate(normalEntries.map(entry => entry.file)));
|
||||
} else {
|
||||
applyResult(await uploadEntriesToFolder(normalEntries, folderId || null, onUnauthorized));
|
||||
}
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const result = await uploadEntriesToFolder(normalEntries, folderId || null, onUnauthorized);
|
||||
applyResult(result);
|
||||
}
|
||||
}
|
||||
for (const entry of zipEntries) {
|
||||
let zipData: Record<string, Uint8Array> | null = null;
|
||||
const baseParts = entry.relativePath.split("/").filter(Boolean);
|
||||
baseParts.pop();
|
||||
const basePath = baseParts.join("/");
|
||||
await zipPrompt.prompt({
|
||||
label: entry.file.name,
|
||||
onImportAsZip: async () => {
|
||||
const result = await uploadEntriesToFolder([entry], folderId || null, onUnauthorized);
|
||||
applyResult(result);
|
||||
},
|
||||
loadEntries: async () => {
|
||||
const result = await readZipEntries(entry.file);
|
||||
zipData = result.data;
|
||||
return result.entries;
|
||||
},
|
||||
onImportSelected: async (selectedPaths: string[]) => {
|
||||
if (!zipData) {
|
||||
const result = await readZipEntries(entry.file);
|
||||
zipData = result.data;
|
||||
}
|
||||
const unzipEntries = buildUploadEntriesFromZip(zipData || {}, selectedPaths, basePath);
|
||||
const result = await uploadEntriesToFolder(unzipEntries, folderId || null, onUnauthorized);
|
||||
applyResult(result);
|
||||
},
|
||||
});
|
||||
}
|
||||
if (uploaded) onUploaded();
|
||||
if (failed.length) {
|
||||
alert(t("uploadBar.uploadFailed", { files: failed.join(", ") }));
|
||||
}
|
||||
setUploading(false);
|
||||
};
|
||||
|
||||
const onPick = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const entries = entriesFromFileList(e.target.files || []);
|
||||
if (!entries.length) return;
|
||||
await uploadEntries(entries);
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
};
|
||||
|
||||
const onPickFolder = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const entries = entriesFromFileList(e.target.files || []);
|
||||
if (!entries.length) return;
|
||||
await uploadEntries(entries);
|
||||
if (folderInputRef.current) folderInputRef.current.value = "";
|
||||
};
|
||||
|
||||
const onImport = async () => {
|
||||
const url = linkValue.trim();
|
||||
if (!url) return;
|
||||
setImporting(true);
|
||||
try {
|
||||
const cookie = (makerworldCookie || "").trim();
|
||||
const thingiverse = (thingiverseCookie || "").trim();
|
||||
const payload = {
|
||||
url,
|
||||
folder_id: folderId || undefined,
|
||||
makerworld_cookie: cookie || undefined,
|
||||
thingiverse_cookie: thingiverse || undefined,
|
||||
};
|
||||
const inspect = await inspectImportLink(payload);
|
||||
if (!inspect.is_zip) {
|
||||
await importFromLink(payload);
|
||||
setLinkValue("");
|
||||
onUploaded();
|
||||
return;
|
||||
}
|
||||
setImporting(false);
|
||||
await zipPrompt.prompt({
|
||||
label: inspect.filename,
|
||||
onImportAsZip: async () => {
|
||||
try {
|
||||
await importFromLink(payload);
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedError) {
|
||||
onUnauthorized?.();
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
setLinkValue("");
|
||||
onUploaded();
|
||||
},
|
||||
loadEntries: async () => {
|
||||
try {
|
||||
const result = await listImportZipEntries(payload);
|
||||
return result.entries;
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedError) {
|
||||
onUnauthorized?.();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
onImportSelected: async (entries: string[]) => {
|
||||
try {
|
||||
const result = await importZipFromLink({ ...payload, entries });
|
||||
if (result.failed.length) {
|
||||
alert(t("uploadBar.importFailedList", { files: result.failed.join(", ") }));
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedError) {
|
||||
onUnauthorized?.();
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
setLinkValue("");
|
||||
onUploaded();
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedError) {
|
||||
onUnauthorized?.();
|
||||
return;
|
||||
}
|
||||
console.error("Import failed for", url, err);
|
||||
const message = err instanceof Error ? err.message : t("uploadBar.importFailed");
|
||||
alert(message);
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5, flexWrap: "wrap" }}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
onChange={onPick}
|
||||
multiple
|
||||
accept=".png,.jpg,.jpeg,.webp,.bmp,.gif,.svg,.stl,.step,.stp,.3mf,.lbrn,.lbrn2,.zip"
|
||||
hidden
|
||||
/>
|
||||
<input ref={folderInputRef} type="file" onChange={onPickFolder} multiple hidden />
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<UploadFileIcon />}
|
||||
disabled={isBusy}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
{uploading ? t("uploadBar.uploading") : t("uploadBar.upload")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<DriveFolderUploadIcon />}
|
||||
disabled={isBusy}
|
||||
onClick={() => folderInputRef.current?.click()}
|
||||
>
|
||||
{uploading ? t("uploadBar.uploading") : t("uploadBar.uploadFolder")}
|
||||
</Button>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<TextField
|
||||
type="url"
|
||||
size="small"
|
||||
value={linkValue}
|
||||
onChange={e => setLinkValue(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
onImport();
|
||||
}
|
||||
}}
|
||||
placeholder={t("uploadBar.linkPlaceholder") ?? undefined}
|
||||
disabled={isBusy}
|
||||
sx={{ width: 320 }}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<LinkIcon />}
|
||||
disabled={isBusy || !linkValue.trim()}
|
||||
onClick={onImport}
|
||||
>
|
||||
{importing ? t("uploadBar.importing") : t("uploadBar.importLink")}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
{zipPrompt.modal}
|
||||
{importModePrompt.modal}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import React from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Checkbox from "@mui/material/Checkbox";
|
||||
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { formatFileSize } from "../../../../../utils/fileSize";
|
||||
import type { ZipEntry } from "../../../../../services/zipUtils";
|
||||
|
||||
type Props = {
|
||||
entries: ZipEntry[];
|
||||
selected: Set<string>;
|
||||
busy: boolean;
|
||||
noFilesLabel: string;
|
||||
onToggleEntry: (path: string) => void;
|
||||
};
|
||||
|
||||
/** The scrollable checkbox list shown in the zip import wizard's "select files" step. */
|
||||
export default function ZipEntryList({ entries, selected, busy, noFilesLabel, onToggleEntry }: Props) {
|
||||
return (
|
||||
<Box sx={{ border: "1px solid", borderColor: "divider", borderRadius: 1, maxHeight: 360, overflow: "auto" }}>
|
||||
{entries.map(entry => (
|
||||
<FormControlLabel
|
||||
key={entry.path}
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
m: 0,
|
||||
px: 1.5,
|
||||
py: 1,
|
||||
borderBottom: "1px solid",
|
||||
borderColor: "divider",
|
||||
"&:last-of-type": { borderBottom: "none" },
|
||||
}}
|
||||
control={
|
||||
<Checkbox
|
||||
checked={selected.has(entry.path)}
|
||||
onChange={() => onToggleEntry(entry.path)}
|
||||
disabled={busy}
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Stack direction="row" sx={{ width: "100%", minWidth: 0 }} alignItems="center" spacing={1}>
|
||||
<Typography variant="body2" noWrap sx={{ flex: 1, minWidth: 0 }}>
|
||||
{entry.path}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{formatFileSize(entry.size)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{entries.length === 0 && (
|
||||
<Box sx={{ px: 1.5, py: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{noFilesLabel}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
Loaded 100 of 136 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user