feat: initial commit

This commit is contained in:
Tautvydas Derzinskas committed 2026-09-07 09:12:37 +02:00
commit a8fef7d491
136 files changed
+23687

No files matched your search

+18
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto
+41
View File
@@ -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
+90
View File
@@ -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 }}
+38
View File
@@ -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
+49
View File
@@ -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
View File
@@ -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
+17
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
.env
storage/
*.log
+18
View File
@@ -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"]
}
+48
View File
@@ -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"]
+34
View File
@@ -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" "$@"
+4621
View File
File diff suppressed because it is too large. Load diff
+50
View File
@@ -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"
+91
View File
@@ -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
}
+65
View File
@@ -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;
}
+39
View File
@@ -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();
}
+59
View File
@@ -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"));
+29
View File
@@ -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"];
}
+9
View File
@@ -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();
+172
View File
@@ -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,
};
}
+35
View File
@@ -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;
+99
View File
@@ -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;
+10
View File
@@ -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;
+87
View File
@@ -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;
+173
View File
@@ -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;
+95
View File
@@ -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;
+340
View File
@@ -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;
+78
View File
@@ -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;
+14
View File
@@ -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);
});
});
+64
View File
@@ -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" });
}
});
}
+25
View 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;
}
+761
View File
@@ -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`;
}
+248
View File
@@ -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);
}
}
+144
View File
@@ -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}.`);
}
+255
View File
@@ -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}`;
}
+218
View File
@@ -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);
}
+173
View File
@@ -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 } });
}
+24
View File
@@ -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);
}
+353
View File
@@ -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));
}
+36
View File
@@ -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);
}
+166
View File
@@ -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 };
}
+19
View File
@@ -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 } });
+15
View File
@@ -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);
};
}
+91
View File
@@ -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";
}
+77
View File
@@ -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);
}
+13
View File
@@ -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;
}
+127
View File
@@ -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();
});
}
+166
View File
@@ -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;
}
}
+312
View File
@@ -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 });
});
});
+110
View File
@@ -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");
});
});
+18
View File
@@ -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"]
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["tests/**/*.test.ts"],
testTimeout: 20000,
hookTimeout: 20000,
},
});
+47
View File
@@ -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:
+37
View File
@@ -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
+159
View File
@@ -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.
+3
View File
@@ -0,0 +1,3 @@
node_modules
dist
npm-debug.log*
+17
View File
@@ -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"]
}
+29
View File
@@ -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"]
+13
View File
@@ -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>
+3194
View File
File diff suppressed because it is too large. Load diff
+39
View File
@@ -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"
}
}
+20
View File
@@ -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

+170
View File
@@ -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>
);
}
+36
View File
@@ -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" },
},
}}
/>
);
}
+79
View File
@@ -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>
);
}
+41
View File
@@ -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>
);
}
+137
View File
@@ -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>
);
}
+7
View File
@@ -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;
+30
View File
@@ -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);
+8
View File
@@ -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";
+41
View File
@@ -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." },
];
+11
View File
@@ -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)",
};
+38
View File
@@ -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;
+5
View File
@@ -0,0 +1,5 @@
html,
body,
#root {
height: 100%;
}
+251
View File
@@ -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."
}
}
}
+36
View File
@@ -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"
}
+150
View File
@@ -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"
}
}
+251
View File
@@ -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."
}
}
}
+36
View File
@@ -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"
}
+150
View File
@@ -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"
}
}
+13
View File
@@ -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>
);
}
@@ -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