From 0d505b716a4cc496db0b3e5edcbf138bf5594da7 Mon Sep 17 00:00:00 2001 From: Tautvydas Derzinskas Date: Tue, 20 Oct 2026 15:09:19 +0200 Subject: [PATCH] feat: keep a model's pictures and supporting files beside its model files Gallery and description images, plate thumbnails and renders now live in an Images folder next to the model's files (Print.assetsPath), and supporting and prepared files sit beside the plates, named through the storage template. They move with the model when the storage structure changes, and the storage browser lists them by kind. Models from before keep working: reads fall back to the old shared folders, and the first start runs the storage move to bring their files over, then clears what's left in those folders. --- .../migration.sql | 5 + backend/prisma/schema.prisma | 3 + backend/src/config.ts | 3 + backend/src/db.ts | 23 +- backend/src/dto.ts | 41 +- backend/src/routes/plates.ts | 11 +- backend/src/routes/previewImages.ts | 5 +- backend/src/routes/prints.ts | 21 +- backend/src/services/adminService.ts | 13 +- backend/src/services/aiProviderClient.ts | 8 +- backend/src/services/dashboardService.ts | 15 +- .../src/services/descriptionImageService.ts | 30 +- backend/src/services/modelAssets.ts | 154 ++++++++ backend/src/services/modelPreviewCache.ts | 47 ++- backend/src/services/previewImageService.ts | 50 ++- backend/src/services/printCreation.ts | 12 +- backend/src/services/printFileService.ts | 63 +-- backend/src/services/printService.ts | 363 +++++++++++++++--- backend/src/services/storageBrowserService.ts | 149 +++++-- backend/src/services/storageMoveService.ts | 73 +++- backend/src/services/tagMosaicService.ts | 9 +- backend/tests/descriptionImages.test.ts | 10 +- .../tests/makerworldProfilesImport.test.ts | 3 +- backend/tests/modelAssets.test.ts | 241 ++++++++++++ backend/tests/modelImageCache.test.ts | 78 ++-- backend/tests/previewImageThumb.test.ts | 23 +- backend/tests/snapmakerImport.test.ts | 3 +- docs/features/storage.md | 18 + frontend/src/api/admin.ts | 8 +- frontend/src/i18n/locales/en.json | 10 +- frontend/src/i18n/locales/es.json | 10 +- frontend/src/i18n/locales/fr.json | 10 +- frontend/src/i18n/locales/lt.json | 10 +- .../StorageBrowserDialog.tsx | 36 +- 34 files changed, 1265 insertions(+), 293 deletions(-) create mode 100644 backend/prisma/migrations/20261028120000_add_print_assets_path/migration.sql create mode 100644 backend/src/services/modelAssets.ts create mode 100644 backend/tests/modelAssets.test.ts diff --git a/backend/prisma/migrations/20261028120000_add_print_assets_path/migration.sql b/backend/prisma/migrations/20261028120000_add_print_assets_path/migration.sql new file mode 100644 index 0000000..0e34472 --- /dev/null +++ b/backend/prisma/migrations/20261028120000_add_print_assets_path/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "Print" ADD COLUMN "assetsPath" TEXT; + +-- CreateIndex +CREATE UNIQUE INDEX "Print_assetsPath_key" ON "Print"("assetsPath"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index d2ad1c4..97f64cd 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -265,6 +265,9 @@ model Print { authorId String? author Author? @relation(fields: [authorId], references: [id], onDelete: SetNull) sourcePath String? + // The folder beside its files that holds the model's pictures (see services/modelAssets.ts), relative to + // storage. Null for a model from before, whose pictures are still in the shared folders keyed by id. + assetsPath String? @unique plates Plate[] previewImages PreviewImage[] descriptionImages DescriptionImage[] diff --git a/backend/src/config.ts b/backend/src/config.ts index f4fe24c..0aef003 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -34,10 +34,13 @@ function defaultPreviewMemoryMb(): number { export const STORAGE = path.resolve(process.env.FILE_STORAGE || "./storage"); // Watched only while it exists, i.e. when the Docker setup mounts a host folder there. export const CONSUME_DIR = path.resolve(process.env.CONSUME_DIR || "/app/consume"); +// Where pictures and supporting files were kept, by id, before they moved in beside the model files +// (services/modelAssets.ts); still read for models the storage move hasn't reached. export const THUMBS = path.join(STORAGE, "thumbs"); export const BUNDLES = path.join(STORAGE, "bundles"); export const PREVIEWS = path.join(STORAGE, "previews"); export const DESCRIPTION_IMAGES = path.join(STORAGE, "description-images"); +// 3D previews, and the failure markers of renders. export const MODEL_PREVIEWS = path.join(STORAGE, "model-previews"); // One picture per tag for the Tags page, built from its models' pictures. export const TAG_MOSAICS = path.join(STORAGE, "tag-mosaics"); diff --git a/backend/src/db.ts b/backend/src/db.ts index 80f3611..4007ef7 100644 --- a/backend/src/db.ts +++ b/backend/src/db.ts @@ -1,27 +1,10 @@ import fs from "node:fs"; import path from "node:path"; import { PrismaClient } from "@prisma/client"; -import { - STORAGE, - THUMBS, - BUNDLES, - PREVIEWS, - DESCRIPTION_IMAGES, - MODEL_PREVIEWS, - NORMALIZED_3MFS, - TAG_MOSAICS, -} from "./config"; +import { STORAGE, MODEL_PREVIEWS, NORMALIZED_3MFS, TAG_MOSAICS } from "./config"; -for (const dir of [ - STORAGE, - THUMBS, - BUNDLES, - PREVIEWS, - DESCRIPTION_IMAGES, - MODEL_PREVIEWS, - NORMALIZED_3MFS, - TAG_MOSAICS, -]) { +// THUMBS, PREVIEWS, DESCRIPTION_IMAGES and BUNDLES are only read now, for models the storage move hasn't reached. +for (const dir of [STORAGE, MODEL_PREVIEWS, NORMALIZED_3MFS, TAG_MOSAICS]) { fs.mkdirSync(dir, { recursive: true }); } // The cache's name before it became NORMALIZED_3MFS; nothing reads it any more. diff --git a/backend/src/dto.ts b/backend/src/dto.ts index b3491fe..01c5330 100644 --- a/backend/src/dto.ts +++ b/backend/src/dto.ts @@ -14,6 +14,7 @@ import type { PrintFile, User, } from "@prisma/client"; +import type { AssetHome } from "./services/modelAssets"; import { plateThumbExists, plateThumbPath } from "./services/printService"; import { previewImageExists, previewImagePath } from "./services/previewImageService"; import { preparedFilename } from "./services/preparedPrint"; @@ -201,9 +202,10 @@ export type CategoryOut = { cults3d_cat_ids: string; }; -function plateThumbUrl(plateId: string): string | null { - if (!plateThumbExists(plateId)) return null; - const mtime = fs.statSync(plateThumbPath(plateId)).mtimeMs; +/** `home`, here and below, is the model's Print.assetsPath. */ +export function plateThumbUrl(plateId: string, home: AssetHome): string | null { + if (!plateThumbExists(plateId, home)) return null; + const mtime = fs.statSync(plateThumbPath(plateId, home)).mtimeMs; return `/plate/${plateId}/thumb.jpg?v=${mtime}`; } @@ -217,15 +219,15 @@ function previewGlbUrl(plate: Plate): string | null { } /** The plate's grey render for the browser to tint; unversioned until it exists, as asking for it queues it. */ -export function renderUrl(plate: { id: string; filename: string }): string | null { +export function renderUrl(plate: { id: string; filename: string }, home: AssetHome): string | null { if (!canRenderModelImage(plate.filename)) return null; - const file = modelImagePath(plate.id, "render"); + const file = modelImagePath(plate.id, home, "render"); return fs.existsSync(file) ? `/plate/${plate.id}/render.png?v=${fs.statSync(file).mtimeMs}` : `/plate/${plate.id}/render.png`; } -export function toPlateOut(printId: string, plate: Plate): PlateOut { +export function toPlateOut(printId: string, plate: Plate, home: AssetHome): PlateOut { return { id: plate.id, print_id: printId, @@ -234,10 +236,10 @@ export function toPlateOut(printId: string, plate: Plate): PlateOut { mime: plate.mime, size: plate.size, url: `/print/${printId}/plate/${plate.id}/file/${encodeURIComponent(plate.filename)}`, - thumb_url: plateThumbUrl(plate.id), + thumb_url: plateThumbUrl(plate.id, home), thumb_generated: plate.thumbGenerated, preview_glb_url: previewGlbUrl(plate), - render_url: renderUrl(plate), + render_url: renderUrl(plate, home), }; } @@ -246,22 +248,23 @@ export function toPlateOut(printId: string, plate: Plate): PlateOut { export function printCover( previewImageId: string | null, plateId: string | null, + home: AssetHome, ): { url: string; file: string } | null { - const image = previewImageId && previewImageUrl(previewImageId); - if (image) return { url: image, file: previewImagePath(previewImageId) }; - const plate = plateId && plateThumbUrl(plateId); - if (plate) return { url: plate, file: plateThumbPath(plateId) }; + const image = previewImageId && previewImageUrl(previewImageId, home); + if (image) return { url: image, file: previewImagePath(previewImageId, home) }; + const plate = plateId && plateThumbUrl(plateId, home); + if (plate) return { url: plate, file: plateThumbPath(plateId, home) }; return null; } -function previewImageUrl(id: string): string | null { - if (!previewImageExists(id)) return null; - const mtime = fs.statSync(previewImagePath(id)).mtimeMs; +function previewImageUrl(id: string, home: AssetHome): string | null { + if (!previewImageExists(id, home)) return null; + const mtime = fs.statSync(previewImagePath(id, home)).mtimeMs; return `/preview-image/${id}/file.jpg?v=${mtime}`; } -export function toPreviewImageOut(image: PreviewImage): PreviewImageOut | null { - const url = previewImageUrl(image.id); +export function toPreviewImageOut(image: PreviewImage, home: AssetHome): PreviewImageOut | null { + const url = previewImageUrl(image.id, home); if (!url) return null; return { id: image.id, position: image.position, url, generated: image.generated }; } @@ -298,12 +301,12 @@ export function toPrintOut( aiDescription?: Pick, ): PrintOut { const sortedPlates = plates.toSorted((a, b) => a.position - b.position); - const plateOuts = sortedPlates.map((p) => toPlateOut(print.id, p)); + const plateOuts = sortedPlates.map((p) => toPlateOut(print.id, p, print.assetsPath)); const supportingCount = files.filter((f) => f.role === "SUPPORTING").length; const totalSize = plates.reduce((sum, p) => sum + p.size, 0) + files.reduce((sum, f) => sum + f.size, 0); const previewImageOuts = (previewImages ?? []) .toSorted((a, b) => a.position - b.position) - .map(toPreviewImageOut) + .map((image) => toPreviewImageOut(image, print.assetsPath)) .filter((img): img is PreviewImageOut => img !== null); let prepared: PreparedPrintOut | null = null; diff --git a/backend/src/routes/plates.ts b/backend/src/routes/plates.ts index 6e71253..7627f91 100644 --- a/backend/src/routes/plates.ts +++ b/backend/src/routes/plates.ts @@ -20,6 +20,7 @@ import { plateThumbPath, plateThumbReplaceable, relocatePrint, + removePlatePictures, saveThumbFromBytes, } from "../services/printService"; import { addGeneratedPreviewImageIfNone, generateCardRender } from "../services/previewImageService"; @@ -84,7 +85,7 @@ router.delete( 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); + await removePlatePictures(target.id, print.assetsPath); if (target.position === 0) { await refreshAutoPreparedMetadata(print.id); @@ -149,9 +150,10 @@ router.get( asyncHandler(async (req, res) => { const plate = await prisma.plate.findFirst({ where: { id: req.params.plateId, print: { userId: req.userId } }, + include: { print: { select: { assetsPath: true } } }, }); if (!plate) throw new HttpError(404, "Not found"); - const thumbPath = plateThumbPath(plate.id); + const thumbPath = plateThumbPath(plate.id, plate.print.assetsPath); 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"); @@ -199,9 +201,10 @@ function modelPictureHandler(kind: ModelImageKind) { return asyncHandler(async (req, res) => { const plate = await prisma.plate.findFirst({ where: { id: req.params.plateId, print: { userId: req.userId } }, + include: { print: { select: { assetsPath: true } } }, }); if (!plate) throw new HttpError(404, "Not found"); - const imagePath = modelImagePath(plate.id, kind); + const imagePath = modelImagePath(plate.id, plate.print.assetsPath, kind); if (!fs.existsSync(imagePath)) { if (!canRenderModelImage(plate.filename)) throw new HttpError(404, "No picture for this file type", "PREVIEW_UNSUPPORTED"); @@ -211,7 +214,7 @@ function modelPictureHandler(kind: ModelImageKind) { const srcPath = resolvePlateFilePath(plate); if (!srcPath) throw new HttpError(404, "Model file is missing", "PREVIEW_FAILED"); void generateCardRender(plate.id, srcPath, plate.filename); - const state = modelImageState(plate.id, kind); + const state = modelImageState(plate.id, plate.print.assetsPath, kind); if (state === "unsupported") { throw new HttpError(404, "This file's layout can't be drawn by the server", "PREVIEW_UNSUPPORTED"); } diff --git a/backend/src/routes/previewImages.ts b/backend/src/routes/previewImages.ts index e7fcb67..3d971a0 100644 --- a/backend/src/routes/previewImages.ts +++ b/backend/src/routes/previewImages.ts @@ -1,4 +1,3 @@ -import fs from "node:fs/promises"; import { Router } from "express"; import { z } from "zod"; import { prisma } from "../db"; @@ -7,7 +6,7 @@ import { HttpError } from "../utils/fileUtils"; import { parseBody } from "../utils/validate"; import { asyncHandler } from "../utils/asyncHandler"; import { thumbnailUpload } from "../uploadMiddleware"; -import { addPreviewImage, preferImageOverRenders, previewImagePath } from "../services/previewImageService"; +import { addPreviewImage, preferImageOverRenders, removePreviewImageFile } from "../services/previewImageService"; import { printOutById } from "../services/printLoader"; const router = Router(); @@ -56,7 +55,7 @@ router.delete( await prisma.$transaction( remaining.map((img, idx) => prisma.previewImage.update({ where: { id: img.id }, data: { position: idx } })), ); - await fs.rm(previewImagePath(target.id), { force: true }).catch(() => undefined); + await removePreviewImageFile(print.id, target.id); res.json({ print: await printOutById(req.userId!, print.id) }); }), diff --git a/backend/src/routes/prints.ts b/backend/src/routes/prints.ts index db28af9..4b36ee1 100644 --- a/backend/src/routes/prints.ts +++ b/backend/src/routes/prints.ts @@ -10,7 +10,14 @@ 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, relocatePrintsForToken, uniqueModelName } from "../services/printService"; +import { + plateThumbPath, + relocatePrint, + relocatePrintsForToken, + removeAssetHome, + removePlatePictures, + uniqueModelName, +} from "../services/printService"; import { setPrintCategory } from "../services/printCategoryService"; import { previewImagePath, deleteAllPreviewImages } from "../services/previewImageService"; import { @@ -488,9 +495,10 @@ router.get( const plate0 = await prisma.plate.findFirst({ where: { printId: req.params.id, print: { userId: req.userId } }, orderBy: { position: "asc" }, + include: { print: { select: { assetsPath: true } } }, }); if (!plate0) throw new HttpError(404, "Not found"); - const thumbPath = plateThumbPath(plate0.id); + const thumbPath = plateThumbPath(plate0.id, plate0.print.assetsPath); 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"); @@ -503,9 +511,10 @@ router.get( asyncHandler(async (req, res) => { const image = await prisma.descriptionImage.findFirst({ where: { id: req.params.id, print: { userId: req.userId } }, + include: { print: { select: { assetsPath: true } } }, }); if (!image) throw new HttpError(404, "Not found"); - const filePath = descriptionImagePath(image.id); + const filePath = descriptionImagePath(image, image.print.assetsPath); if (!fs.existsSync(filePath)) throw new HttpError(404, "Not found"); res.setHeader("Content-Type", image.mime); res.setHeader("X-Content-Type-Options", "nosniff"); @@ -519,9 +528,10 @@ router.get( asyncHandler(async (req, res) => { const image = await prisma.previewImage.findFirst({ where: { id: req.params.id, print: { userId: req.userId } }, + include: { print: { select: { assetsPath: true } } }, }); if (!image) throw new HttpError(404, "Not found"); - const filePath = previewImagePath(image.id); + const filePath = previewImagePath(image.id, image.print.assetsPath); if (!fs.existsSync(filePath)) throw new HttpError(404, "Not found"); res.setHeader("Content-Type", "image/jpeg"); res.setHeader("Cache-Control", "private, max-age=31536000, immutable"); @@ -653,8 +663,9 @@ async function deletePrint(userId: string, printId: string): Promise { await prisma.print.delete({ where: { id: printId } }); for (const plate of full.plates) { await deletePlateFiles(plate); - await fs.promises.rm(plateThumbPath(plate.id), { force: true }).catch(() => undefined); + await removePlatePictures(plate.id, full.print.assetsPath); } + await removeAssetHome(full.print.assetsPath); void recordActivity(userId, "delete", printId, full.print.name); void createLog({ userId, diff --git a/backend/src/services/adminService.ts b/backend/src/services/adminService.ts index d05f53d..ca78bb8 100644 --- a/backend/src/services/adminService.ts +++ b/backend/src/services/adminService.ts @@ -17,7 +17,7 @@ import { deleteAllPrintFiles } from "./printFileService"; import { deleteAllPreviewImages } from "./previewImageService"; import { deleteAllDescriptionImages } from "./descriptionImageService"; import { deletePlateFiles } from "./printCreation"; -import { plateThumbPath } from "./printService"; +import { removeAssetHome, removePlatePictures } from "./printService"; import { loadFullPrint } from "./printLoader"; import { removeUserTagMosaics } from "./tagMosaicService"; @@ -122,8 +122,9 @@ export async function deleteAllPrintsForUser(userId: string): Promise { await prisma.print.delete({ where: { id } }); for (const plate of full.plates) { await deletePlateFiles(plate); - await fs.rm(plateThumbPath(plate.id), { force: true }).catch(() => undefined); + await removePlatePictures(plate.id, full.print.assetsPath); } + await removeAssetHome(full.print.assetsPath); } // With no models left the user has no tags, so none of their tag pictures is needed. await removeUserTagMosaics(userId); @@ -172,9 +173,15 @@ let diskUsage: { at: number; value: Promise } | null = null; function measureDisk(): Promise { if (diskUsage && Date.now() - diskUsage.at < DISK_USAGE_TTL_MS) return diskUsage.value; const sum = async (dirs: string[]) => (await Promise.all(dirs.map(folderBytes))).reduce((a, b) => a + b, 0); + // Each model's pictures folder, beside its files, and the shared folders older ones may still be in. + const pictureFolders = prisma.print + .findMany({ where: { assetsPath: { not: null } }, select: { assetsPath: true } }) + .then((prints) => prints.map((print) => path.join(STORAGE, print.assetsPath!))); const value = Promise.all([ folderBytes(STORAGE), - sum([THUMBS, PREVIEWS, DESCRIPTION_IMAGES, MODEL_PREVIEWS, TAG_MOSAICS]), + pictureFolders.then((folders) => + sum([...folders, THUMBS, PREVIEWS, DESCRIPTION_IMAGES, MODEL_PREVIEWS, TAG_MOSAICS]), + ), sum([NORMALIZED_3MFS, BUNDLES]), ]).then(([totalBytes, pictureBytes, otherGeneratedBytes]) => ({ totalBytes, pictureBytes, otherGeneratedBytes })); diskUsage = { at: Date.now(), value }; diff --git a/backend/src/services/aiProviderClient.ts b/backend/src/services/aiProviderClient.ts index b4a9dd5..6cd6c29 100644 --- a/backend/src/services/aiProviderClient.ts +++ b/backend/src/services/aiProviderClient.ts @@ -65,10 +65,14 @@ export async function previewPart( enabled: boolean, ): Promise<{ type: "image_url"; image_url: { url: string } } | null> { if (!enabled) return null; - const image = await prisma.previewImage.findFirst({ where: { printId }, orderBy: { position: "asc" } }); + const image = await prisma.previewImage.findFirst({ + where: { printId }, + orderBy: { position: "asc" }, + include: { print: { select: { assetsPath: true } } }, + }); if (!image) return null; try { - const data = await sharp(await fs.readFile(previewImagePath(image.id))) + const data = await sharp(await fs.readFile(previewImagePath(image.id, image.print.assetsPath))) .resize(512, 512, { fit: "inside" }) .jpeg() .toBuffer(); diff --git a/backend/src/services/dashboardService.ts b/backend/src/services/dashboardService.ts index dfcf7c3..9d8b592 100644 --- a/backend/src/services/dashboardService.ts +++ b/backend/src/services/dashboardService.ts @@ -1,9 +1,7 @@ -import fs from "node:fs"; import { createHash } from "node:crypto"; import { prisma } from "../db"; import { RENDERABLE_MODEL_EXTS } from "../config"; -import { renderUrl } from "../dto"; -import { plateThumbExists, plateThumbPath } from "./printService"; +import { plateThumbUrl, renderUrl } from "../dto"; const TOP_MODELS_PREVIEW = 3; const TOP_MODELS_LIST_MAX = 50; @@ -53,22 +51,18 @@ type PrintForModelSummary = { viewCount: number; printCount: number; createdAt: Date; + assetsPath: string | null; plates: { id: string; filename: string }[]; }; export function toModelSummary(print: PrintForModelSummary): DashboardModel { const first = print.plates[0]; const plateId = first?.id ?? null; - let thumbUrl: string | null = null; - if (plateId && plateThumbExists(plateId)) { - const mtime = fs.statSync(plateThumbPath(plateId)).mtimeMs; - thumbUrl = `/plate/${plateId}/thumb.jpg?v=${mtime}`; - } return { id: print.id, name: print.name, - thumbUrl, - renderUrl: first ? renderUrl(first) : null, + thumbUrl: plateId ? plateThumbUrl(plateId, print.assetsPath) : null, + renderUrl: first ? renderUrl(first, print.assetsPath) : null, viewCount: print.viewCount, printCount: print.printCount, createdAt: print.createdAt, @@ -81,6 +75,7 @@ export const MODEL_SUMMARY_SELECT = { viewCount: true, printCount: true, createdAt: true, + assetsPath: true, plates: { take: 1, orderBy: { position: "asc" as const }, select: { id: true, filename: true } }, }; diff --git a/backend/src/services/descriptionImageService.ts b/backend/src/services/descriptionImageService.ts index e1b25fe..1f78bd3 100644 --- a/backend/src/services/descriptionImageService.ts +++ b/backend/src/services/descriptionImageService.ts @@ -1,8 +1,17 @@ import fs from "node:fs/promises"; import path from "node:path"; import sharp from "sharp"; -import { DESCRIPTION_IMAGES, IMPORT_BROWSER_USER_AGENT, IMPORT_TIMEOUT_SECONDS } from "../config"; +import { IMPORT_BROWSER_USER_AGENT, IMPORT_TIMEOUT_SECONDS } from "../config"; import { prisma } from "../db"; +import { + assetPath, + assetTarget, + descriptionImageFile, + printAssetHome, + removeAsset, + type AssetHome, +} from "./modelAssets"; +import { ensureAssetHome } from "./printService"; import { mapWithConcurrency } from "../utils/concurrency"; import { validateRemoteUrl } from "../utils/urlUtils"; import { browserFetch, needsBrowserFetch } from "../utils/browserFetch"; @@ -28,8 +37,9 @@ const ALLOWED_FORMATS: Record = { const REMOTE_IMAGE_RE = /!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/g; const LOCAL_REF_RE = /\/description-image\/([a-z0-9]+)/g; -export function descriptionImagePath(id: string): string { - return path.join(DESCRIPTION_IMAGES, id); +/** `home` is the image's model's Print.assetsPath. */ +export function descriptionImagePath(image: { id: string; mime: string }, home: AssetHome): string { + return assetPath(home, descriptionImageFile(image)); } /** Relative to the API base; the frontend adds the base and auth token when rendering. */ @@ -88,7 +98,9 @@ async function saveDescriptionImage(printId: string, buffer: Buffer, sourceUrl: if (!mime) return null; const row = await prisma.descriptionImage.create({ data: { printId, sourceUrl, mime, size: buffer.length } }); try { - await fs.writeFile(descriptionImagePath(row.id), buffer); + const dest = assetTarget(await ensureAssetHome(printId), descriptionImageFile(row)); + await fs.mkdir(path.dirname(dest), { recursive: true }); + await fs.writeFile(dest, buffer); } catch { await prisma.descriptionImage.delete({ where: { id: row.id } }).catch(() => undefined); return null; @@ -136,15 +148,17 @@ export async function localizeDescriptionImages(printId: string): Promise export async function pruneDescriptionImages(printId: string): Promise { const print = await prisma.print.findUnique({ where: { id: printId }, select: { notes: true } }); const referenced = new Set([...(print?.notes ?? "").matchAll(LOCAL_REF_RE)].map((match) => match[1])); - const images = await prisma.descriptionImage.findMany({ where: { printId }, select: { id: true } }); + const images = await prisma.descriptionImage.findMany({ where: { printId }, select: { id: true, mime: true } }); const unused = images.filter((image) => !referenced.has(image.id)); if (!unused.length) return; await prisma.descriptionImage.deleteMany({ where: { id: { in: unused.map((image) => image.id) } } }); - for (const image of unused) await fs.rm(descriptionImagePath(image.id), { force: true }).catch(() => undefined); + const home = await printAssetHome(printId); + for (const image of unused) await removeAsset(home, descriptionImageFile(image)); } export async function deleteAllDescriptionImages(printId: string): Promise { - const rows = await prisma.descriptionImage.findMany({ where: { printId }, select: { id: true } }); + const rows = await prisma.descriptionImage.findMany({ where: { printId }, select: { id: true, mime: true } }); await prisma.descriptionImage.deleteMany({ where: { printId } }); - for (const row of rows) await fs.rm(descriptionImagePath(row.id), { force: true }).catch(() => undefined); + const home = await printAssetHome(printId); + for (const row of rows) await removeAsset(home, descriptionImageFile(row)); } diff --git a/backend/src/services/modelAssets.ts b/backend/src/services/modelAssets.ts new file mode 100644 index 0000000..3b673ec --- /dev/null +++ b/backend/src/services/modelAssets.ts @@ -0,0 +1,154 @@ +import fs from "node:fs/promises"; +import fsSync from "node:fs"; +import path from "node:path"; +import { DESCRIPTION_IMAGES, MODEL_PREVIEWS, PREVIEWS, STORAGE, THUMBS } from "../config"; +import { prisma } from "../db"; + +// A model's pictures -- its gallery and description images, and each plate's thumbnail and render -- live in an +// "Images" folder beside its files, at Print.assetsPath, so they move with the model. They used to share folders +// keyed by id (THUMBS, PREVIEWS, ...), and a model with no assetsPath still has them there. Reads look in both, so +// a model the storage move hasn't reached yet, or only part of, still finds every picture. + +/** Print.assetsPath. */ +export type AssetHome = string | null | undefined; +export type AssetKind = "image" | "description" | "thumb" | "render"; +/** `legacyName` when the old shared folder named it differently. */ +export type AssetFile = { kind: AssetKind; name: string; legacyName?: string }; + +export const ASSETS_FOLDER = "Images"; +// Gallery images go straight in the folder: they're the pictures someone browsing it is after. +export const ASSET_SUBFOLDERS: Record = { + image: "", + description: "Description", + thumb: "Thumbnails", + render: "Renders", +}; +const LEGACY_DIRS: Record = { + image: PREVIEWS, + description: DESCRIPTION_IMAGES, + thumb: THUMBS, + render: MODEL_PREVIEWS, +}; + +// Part of a render's name, so changing its look (modelPreviewCache's MODEL_IMAGE_KINDS) draws older ones again. +export const RENDER_FILE_TAG = "render.v2"; + +const DESCRIPTION_EXTS: Record = { + "image/jpeg": ".jpg", + "image/png": ".png", + "image/gif": ".gif", + "image/webp": ".webp", +}; + +export function galleryImageFile(id: string): AssetFile { + return { kind: "image", name: `${id}.jpg` }; +} + +/** Named with its extension now, so it opens from the folder; the shared folder had none. */ +export function descriptionImageFile(image: { id: string; mime: string }): AssetFile { + return { kind: "description", name: `${image.id}${DESCRIPTION_EXTS[image.mime] ?? ""}`, legacyName: image.id }; +} + +export function plateThumbFile(plateId: string): AssetFile { + return { kind: "thumb", name: `${plateId}.jpg` }; +} + +/** A render in the shared 3D previews folder, of this look or an older one. */ +export function isLegacyRender(name: string): boolean { + return /\.render(\.v\d+)?\.png$/.test(name); +} + +export function plateRenderFile(plateId: string, tag = RENDER_FILE_TAG): AssetFile { + return { kind: "render", name: `${plateId}.${tag}.png` }; +} + +export function assetTarget(home: string, file: AssetFile): string { + return path.join(STORAGE, home, ASSET_SUBFOLDERS[file.kind], file.name); +} + +export function legacyAssetPath(file: AssetFile): string { + return path.join(LEGACY_DIRS[file.kind], file.legacyName ?? file.name); +} + +/** Where the file is: the model's folder, else the old shared one. With neither, where it would be. */ +export function assetPath(home: AssetHome, file: AssetFile): string { + const legacy = legacyAssetPath(file); + if (!home) return legacy; + const target = assetTarget(home, file); + return fsSync.existsSync(target) || !fsSync.existsSync(legacy) ? target : legacy; +} + +export function assetExists(home: AssetHome, file: AssetFile): boolean { + return fsSync.existsSync(assetPath(home, file)); +} + +export async function printAssetHome(printId: string): Promise { + const print = await prisma.print.findUnique({ where: { id: printId }, select: { assetsPath: true } }); + return print?.assetsPath; +} + +export async function plateAssetHome(plateId: string): Promise { + const plate = await prisma.plate.findUnique({ + where: { id: plateId }, + select: { print: { select: { assetsPath: true } } }, + }); + return plate?.print.assetsPath; +} + +/** Wherever it is. */ +export async function removeAsset(home: AssetHome, file: AssetFile): Promise { + if (home) await fs.rm(assetTarget(home, file), { force: true }).catch(() => undefined); + await fs.rm(legacyAssetPath(file), { force: true }).catch(() => undefined); +} + +/** Moves one picture from the old shared folder into the model's; true when it moved. One already in the model's + * folder is newer, and the old copy just goes. */ +export async function adoptLegacyAsset(home: string, file: AssetFile): Promise { + const legacy = legacyAssetPath(file); + if (!fsSync.existsSync(legacy)) return false; + const target = assetTarget(home, file); + if (fsSync.existsSync(target)) { + await fs.rm(legacy, { force: true }); + return false; + } + await fs.mkdir(path.dirname(target), { recursive: true }); + await fs.rename(legacy, target); + return true; +} + +/** Every file under a model's pictures folder, relative to it with "/" separators. */ +export async function listAssetFiles(home: string): Promise<{ path: string; size: number }[]> { + const root = path.join(STORAGE, home); + const found: { path: string; size: number }[] = []; + const walk = async (dir: string): Promise => { + let entries: fsSync.Dirent[]; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) await walk(full); + // Half-written pictures aren't the model's yet. + else if (entry.isFile() && !entry.name.endsWith(".tmp")) { + const size = await fs.stat(full).then( + (stats) => stats.size, + () => null, + ); + if (size !== null) found.push({ path: path.relative(root, full).split(path.sep).join("/"), size }); + } + } + }; + await walk(root); + return found; +} + +/** Which kind a file in a model's pictures folder is, from the subfolder it's in. */ +export function assetKindOf(relative: string): AssetKind { + const folder = relative.includes("/") ? relative.slice(0, relative.indexOf("/")) : ""; + const kind = (Object.keys(ASSET_SUBFOLDERS) as AssetKind[]).find( + (candidate) => candidate !== "image" && ASSET_SUBFOLDERS[candidate] === folder, + ); + return kind ?? "image"; +} diff --git a/backend/src/services/modelPreviewCache.ts b/backend/src/services/modelPreviewCache.ts index 056bf62..792c2a7 100644 --- a/backend/src/services/modelPreviewCache.ts +++ b/backend/src/services/modelPreviewCache.ts @@ -7,6 +7,17 @@ import { prisma } from "../db"; import type { ModelImageOptions } from "./modelImageRender"; import type { ModelPreviewWorkerInput, ModelPreviewWorkerResult } from "./modelPreviewWorker"; import { getSimplifyPreviews } from "./settingsService"; +import { + assetExists, + assetPath, + assetTarget, + plateAssetHome, + plateRenderFile, + removeAsset, + RENDER_FILE_TAG, + type AssetHome, +} from "./modelAssets"; +import { ensureAssetHome } from "./printService"; import { workerBootstrap } from "../utils/workerBootstrap"; // Renders run in a worker thread under memory and time limits, so a pathological .3mf can't take @@ -281,14 +292,15 @@ type ModelImageKindDef = { const MODEL_IMAGE_KINDS: Record = { // Grey shading the browser tints with the render colour, so one picture serves every theme and colour. render: { - file: "render.v2", + file: RENDER_FILE_TAG, options: { size: 512, color: "#ffffff", view: "threeQuarter", ambient: 0.35, fill: 0.2 }, timeoutSeconds: 60, }, }; -export function modelImagePath(plateId: string, kind: ModelImageKind = "render"): string { - return path.join(MODEL_PREVIEWS, `${plateId}.${MODEL_IMAGE_KINDS[kind].file}.png`); +/** In the model's pictures folder (`home`, its Print.assetsPath); the failure markers stay with the GLB cache. */ +export function modelImagePath(plateId: string, home: AssetHome, kind: ModelImageKind = "render"): string { + return assetPath(home, plateRenderFile(plateId, MODEL_IMAGE_KINDS[kind].file)); } function modelImageErrorPath(plateId: string, kind: ModelImageKind): string { return path.join(MODEL_PREVIEWS, `${plateId}.${MODEL_IMAGE_KINDS[kind].file}.error`); @@ -311,9 +323,14 @@ export async function generateModelImage( ): Promise { const errorPath = modelImageErrorPath(plateId, kind); const key = `${kind}:${plateId}`; - if (!canRenderModelImage(filename)) return; - const cachedPath = modelImagePath(plateId, kind); - if (fsSync.existsSync(cachedPath)) { + if (!canRenderModelImage(filename) || imagesInFlight.has(key)) return; + // Claimed before looking up where the picture is, so a second call meanwhile doesn't draw it again. + imagesInFlight.add(key); + const file = plateRenderFile(plateId, MODEL_IMAGE_KINDS[kind].file); + const cachedPath = assetPath(await plateAssetHome(plateId).catch(() => null), file); + if (fsSync.existsSync(cachedPath) || recentlyFailed(plateId, errorPath)) { + imagesInFlight.delete(key); + if (!fsSync.existsSync(cachedPath)) return; // Still run onReady, so a step that failed after an earlier render gets another go. try { await onReady?.(cachedPath); @@ -322,11 +339,12 @@ export async function generateModelImage( } return; } - if (imagesInFlight.has(key) || recentlyFailed(plateId, errorPath)) return; - imagesInFlight.add(key); await scheduleRender("image", async () => { - const dest = cachedPath; - const tmp = `${dest}.${process.pid}.${Date.now()}.tmp`; + // Drawn beside the GLBs, and only then put in the model's folder: the model may move while it waits. + const tmp = path.join( + MODEL_PREVIEWS, + `${plateId}.${MODEL_IMAGE_KINDS[kind].file}.${process.pid}.${Date.now()}.tmp`, + ); try { const outcome = await renderPlateFile(plateId, srcPath, (from) => renderInWorker( @@ -341,7 +359,13 @@ export async function generateModelImage( ), ); if (outcome.status === "ok") { + const plate = await prisma.plate.findUnique({ where: { id: plateId }, select: { printId: true } }); + // Deleted while it was drawn. + if (!plate) return; + const dest = assetTarget(await ensureAssetHome(plate.printId), file); + await fs.mkdir(path.dirname(dest), { recursive: true }); await fs.rename(tmp, dest); + await removeAsset(null, file); await fs.rm(errorPath, { force: true }); await onReady?.(dest); } else { @@ -364,9 +388,10 @@ export async function generateModelImage( /** "unsupported" is a file the renderer read but couldn't draw, e.g. a 3MF layout it doesn't know. */ export function modelImageState( plateId: string, + home: AssetHome, kind: ModelImageKind = "render", ): "ready" | "generating" | "failed" | "unsupported" { - if (fsSync.existsSync(modelImagePath(plateId, kind))) return "ready"; + if (assetExists(home, plateRenderFile(plateId, MODEL_IMAGE_KINDS[kind].file))) return "ready"; if (imagesInFlight.has(`${kind}:${plateId}`)) return "generating"; const errorPath = modelImageErrorPath(plateId, kind); if (!recentlyFailed(plateId, errorPath)) return "generating"; diff --git a/backend/src/services/previewImageService.ts b/backend/src/services/previewImageService.ts index 6c96de2..f77c040 100644 --- a/backend/src/services/previewImageService.ts +++ b/backend/src/services/previewImageService.ts @@ -1,25 +1,36 @@ import fs from "node:fs/promises"; -import fsSync from "node:fs"; import path from "node:path"; import sharp from "sharp"; -import { PREVIEWS } from "../config"; import { prisma } from "../db"; import { generateModelImage } from "./modelPreviewCache"; -import { plateThumbExists, plateThumbReplaceable, saveThumbFromBytes } from "./printService"; +import { + assetExists, + assetPath, + assetTarget, + galleryImageFile, + plateAssetHome, + printAssetHome, + removeAsset, + type AssetHome, +} from "./modelAssets"; +import { ensureAssetHome, plateThumbExists, plateThumbReplaceable, saveThumbFromBytes } from "./printService"; import type { PreviewImage } from "@prisma/client"; -export function previewImagePath(id: string): string { - return path.join(PREVIEWS, `${id}.jpg`); +/** `home` is the image's model's Print.assetsPath. */ +export function previewImagePath(id: string, home: AssetHome): string { + return assetPath(home, galleryImageFile(id)); } -export function previewImageExists(id: string): boolean { - return fsSync.existsSync(previewImagePath(id)); +export function previewImageExists(id: string, home: AssetHome): boolean { + return assetExists(home, galleryImageFile(id)); } -async function saveBuffer(id: string, input: Buffer): Promise { - const dest = previewImagePath(id); - const tmp = `${dest}.${process.pid}.${Date.now()}.tmp`; +async function saveBuffer(printId: string, id: string, input: Buffer): Promise { + let tmp: string | null = null; try { + const dest = assetTarget(await ensureAssetHome(printId), galleryImageFile(id)); + tmp = `${dest}.${process.pid}.${Date.now()}.tmp`; + await fs.mkdir(path.dirname(dest), { recursive: true }); // Hero-sized: this is the detail page's main image. await sharp(input) .flatten({ background: { r: 248, g: 250, b: 252 } }) @@ -29,7 +40,7 @@ async function saveBuffer(id: string, input: Buffer): Promise { await fs.rename(tmp, dest); return true; } catch { - await fs.rm(tmp, { force: true }); + if (tmp) await fs.rm(tmp, { force: true }); return false; } } @@ -43,7 +54,7 @@ export async function addPreviewImage( const last = await prisma.previewImage.findFirst({ where: { printId }, orderBy: { position: "desc" } }); const position = last ? last.position + 1 : 0; const row = await prisma.previewImage.create({ data: { printId, position, generated } }); - const ok = await saveBuffer(row.id, buffer); + const ok = await saveBuffer(printId, row.id, buffer); if (!ok) { await prisma.previewImage.delete({ where: { id: row.id } }).catch(() => undefined); return null; @@ -71,7 +82,8 @@ export async function removeGeneratedPreviewImages(printId: string): Promise prisma.previewImage.update({ where: { id: img.id }, data: { position: idx } })), ); - for (const img of generated) await fs.rm(previewImagePath(img.id), { force: true }).catch(() => undefined); + const home = await printAssetHome(printId); + for (const img of generated) await removeAsset(home, galleryImageFile(img.id)); } /** Call once a real image is added: it takes over from our own render, as the cover and, when the plate's thumb is @@ -88,9 +100,13 @@ export async function preferImageOverRenders( export async function deleteAllPreviewImages(printId: string): Promise { const rows = await prisma.previewImage.findMany({ where: { printId } }); await prisma.previewImage.deleteMany({ where: { printId } }); - for (const row of rows) { - await fs.rm(previewImagePath(row.id), { force: true }).catch(() => undefined); - } + const home = await printAssetHome(printId); + for (const row of rows) await removeAsset(home, galleryImageFile(row.id)); +} + +/** Removes one gallery image's file, wherever it is. */ +export async function removePreviewImageFile(printId: string, id: string): Promise { + await removeAsset(await printAssetHome(printId), galleryImageFile(id)); } // The card render is white shading for the cards to tint, too pale on a light background as it is; as a thumb it's @@ -100,7 +116,7 @@ const RENDER_THUMB_SHADE = [0.58, 0.64, 0.72, 1]; /** Our own render stands in as the plate's thumb, and the model's cover, while it has no picture at all, so mosaics * and lists that show thumbs have one without the model being opened. */ async function adoptRenderAsThumb(plateId: string, renderPath: string): Promise { - if (plateThumbExists(plateId)) return; + if (plateThumbExists(plateId, await plateAssetHome(plateId))) return; const plate = await prisma.plate.findUnique({ where: { id: plateId }, select: { printId: true } }); if (!plate) return; const buffer = await sharp(renderPath).linear(RENDER_THUMB_SHADE, [0, 0, 0, 0]).png().toBuffer(); diff --git a/backend/src/services/printCreation.ts b/backend/src/services/printCreation.ts index 90996b8..0f0bb34 100644 --- a/backend/src/services/printCreation.ts +++ b/backend/src/services/printCreation.ts @@ -9,15 +9,18 @@ import { inspectPreparedPrint } from "./preparedPrint"; import { availableModelName, availablePlateFilename, + ensureAssetHome, ensurePlateThumbnail, managedPlatePath, - plateThumbPath, pruneEmptyStorageDirs, + removeAssetHome, + removePlatePictures, renderPlateStoragePath, saveThumbFromFile, } from "./printService"; import { generateModelPreviewGlb, canRenderModelImage } from "./modelPreviewCache"; import { generateCardRender } from "./previewImageService"; +import { printAssetHome } from "./modelAssets"; import { deleteNormalized3mf } from "./normalized3mfCache"; import { getPreviewMode } from "./settingsService"; import { Prisma } from "@prisma/client"; @@ -198,6 +201,9 @@ export async function createPrint( sourceExternalId: meta.sourceExternalId ?? null, }, }); + // Chosen now, though nothing is in it yet, so a null assetsPath only ever means a model from before (see + // storageMoveService). Failing here isn't fatal: the first picture tries again. + await ensureAssetHome(print.id).catch((err) => console.error("Couldn't choose a pictures folder", print.id, err)); try { const plates: Plate[] = []; @@ -237,14 +243,16 @@ export async function createPrint( async function discardPrint(printId: string): Promise { try { const plates = await prisma.plate.findMany({ where: { printId } }); + const home = await printAssetHome(printId); await prisma.print.delete({ where: { id: printId } }); for (const plate of plates) { - await fs.rm(plateThumbPath(plate.id), { force: true }); + await removePlatePictures(plate.id, home); if (plate.sourcePath) continue; const managed = managedPlatePath(plate); await fs.rm(managed, { force: true }); await pruneEmptyStorageDirs(path.dirname(managed)); } + await removeAssetHome(home); } catch (err) { console.error("Failed to discard half-created print", printId, err); } diff --git a/backend/src/services/printFileService.ts b/backend/src/services/printFileService.ts index 13c27de..7401b92 100644 --- a/backend/src/services/printFileService.ts +++ b/backend/src/services/printFileService.ts @@ -5,6 +5,7 @@ import { prisma } from "../db"; import { STORAGE, IMPORT_MAX_BYTES } from "../config"; import { HttpError, sanitizeFilename, mimeFromContentType } from "../utils/fileUtils"; import { isPreparedPrintFilename, inspectPreparedPrint } from "./preparedPrint"; +import { pruneEmptyStorageDirs, renderPrintFileStoragePath } from "./printService"; import type { Prisma, Print, PrintFile } from "@prisma/client"; export function managedPrintFilePath(printFile: Pick): string { @@ -23,22 +24,28 @@ export async function listSupportingFiles(printId: string): Promise }); } -async function pruneBundleDirs(start: string): Promise { - const root = path.resolve(STORAGE, "bundles"); - let current = start; - while (fsSync.existsSync(current) && path.resolve(current) !== root && path.resolve(current).startsWith(root)) { +/** Never over another file: a link fails on one that's there, where a rename would replace it. */ +async function placeUpload(tempPath: string, destination: string): Promise { + try { + await fs.link(tempPath, destination); + } catch (err: any) { + if (err?.code === "EEXIST") throw new HttpError(409, "Another file was just saved at the same path; try again"); + // Another disk, or one without hard links. try { - await fs.rmdir(current); - } catch { - break; + await fs.copyFile(tempPath, destination, fsSync.constants.COPYFILE_EXCL); + } catch (copyErr: any) { + if (copyErr?.code === "EEXIST") { + throw new HttpError(409, "Another file was just saved at the same path; try again"); + } + throw copyErr; } - current = path.dirname(current); } + await fs.rm(tempPath, { force: true }); } /** - * Saves an uploaded file onto a print. The role comes from the filename (a .3mf containing - * gcode becomes PREPARED); a new PREPARED file replaces the existing one. + * Saves an uploaded file onto a print, beside its plates where the storage structure puts them. The role comes + * from the filename (a .3mf containing gcode becomes PREPARED); a new PREPARED file replaces the existing one. */ export async function saveFileFromTemp( userId: string, @@ -63,23 +70,17 @@ export async function saveFileFromTemp( storagePath: "pending", }, }); - const storagePath = `bundles/${print.id}/${record.id}/${safeName}`; - const destination = path.join(STORAGE, storagePath); + let destination: string | null = null; 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; - } - } + // Claimed in the database first, so two uploads of one name can't both pick the same place. + const storagePath = await renderPrintFileStoragePath(print, safeName); + await prisma.printFile.update({ where: { id: record.id }, data: { storagePath } }); + destination = path.join(STORAGE, storagePath); + await fs.mkdir(path.dirname(destination), { recursive: true }); + await placeUpload(tempPath, destination); let metadata: Record | null = null; if (role === "PREPARED" || safeName.toLowerCase().endsWith(".3mf")) { @@ -95,7 +96,6 @@ export async function saveFileFromTemp( await tx.printFile.update({ where: { id: record.id }, data: { - storagePath, size: stat.size, role, metadata: (metadata as Prisma.InputJsonValue | undefined) ?? undefined, @@ -119,12 +119,15 @@ export async function saveFileFromTemp( if (oldPreparedPath) { await fs.rm(oldPreparedPath, { force: true }).catch(() => undefined); - await pruneBundleDirs(path.dirname(oldPreparedPath)); + await pruneEmptyStorageDirs(path.dirname(oldPreparedPath)); } return updatedPrint; } catch (err) { - await fs.rm(destination, { force: true }).catch(() => undefined); - await pruneBundleDirs(path.dirname(destination)); + // Only once it's ours: a 409 above means the file there is another one's. + if (destination && !(err instanceof HttpError && err.status === 409)) { + await fs.rm(destination, { force: true }).catch(() => undefined); + await pruneEmptyStorageDirs(path.dirname(destination)); + } await prisma.printFile.delete({ where: { id: record.id } }).catch(() => undefined); throw err; } @@ -140,7 +143,7 @@ export async function deleteSupportingFile(userId: string, printId: string, file const filePath = managedPrintFilePath(record); await prisma.printFile.delete({ where: { id: fileId } }); await fs.rm(filePath, { force: true }).catch(() => undefined); - await pruneBundleDirs(path.dirname(filePath)); + await pruneEmptyStorageDirs(path.dirname(filePath)); return prisma.print.findUniqueOrThrow({ where: { id: printId } }); } @@ -152,7 +155,7 @@ export async function deleteAllPrintFiles(printId: string): Promise { await prisma.printFile.deleteMany({ where: { printId } }); for (const p of paths) { await fs.rm(p, { force: true }).catch(() => undefined); - await pruneBundleDirs(path.dirname(p)); + await pruneEmptyStorageDirs(path.dirname(p)); } } @@ -169,6 +172,6 @@ export async function deletePreparedFile(userId: string, printId: string): Promi prisma.printFile.delete({ where: { id: record.id } }), ]); await fs.rm(filePath, { force: true }).catch(() => undefined); - await pruneBundleDirs(path.dirname(filePath)); + await pruneEmptyStorageDirs(path.dirname(filePath)); return prisma.print.findUniqueOrThrow({ where: { id: printId } }); } diff --git a/backend/src/services/printService.ts b/backend/src/services/printService.ts index 1bb178b..c4f2e84 100644 --- a/backend/src/services/printService.ts +++ b/backend/src/services/printService.ts @@ -3,9 +3,25 @@ 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 { STORAGE } from "../config"; import { prisma } from "../db"; import { listZipEntries, readZipEntry } from "../utils/zipReader"; +import { + adoptLegacyAsset, + ASSETS_FOLDER, + assetExists, + assetPath, + assetTarget, + descriptionImageFile, + galleryImageFile, + listAssetFiles, + plateAssetHome, + plateRenderFile, + plateThumbFile, + removeAsset, + type AssetFile, + type AssetHome, +} from "./modelAssets"; import type { Category, Plate, Print } from "@prisma/client"; export const STORAGE_TEMPLATE_TOKENS = [ @@ -172,19 +188,33 @@ async function printTemplateValues(print: PrintLike, template: string): Promise< }; } -function renderPlatePath( +/** {plate} with the separator next to it taken out, for the files that aren't plates: "{model}-{plate}-{filename}" + * gives "{model}-{filename}". */ +function withoutPlateToken(template: string): string { + return template + .split("/") + .map((segment) => + segment.replace(/([ _.-]*)\{plate\}([ _.-]*)/g, (match, before: string, after: string, offset: number) => + offset === 0 || offset + match.length === segment.length ? "" : before || after, + ), + ) + .join("/"); +} + +/** `plate` is the plate's number, or null for any other file of the model. */ +function renderModelPath( print: PrintLike, template: string, printValues: PrintTemplateValues, - plateFilename: string, - platePosition: number, + filename: string, + plate: number | null, ): string { const values = { ...printValues, - filename: sanitizePathSegment(plateFilename, "file"), - plate: String(platePosition + 1), + filename: sanitizePathSegment(filename, "file"), + plate: plate === null ? "" : String(plate + 1), }; - const rendered = renderTemplate(template, values).replace(/\\/g, "/"); + const rendered = renderTemplate(plate === null ? withoutPlateToken(template) : template, values).replace(/\\/g, "/"); const parts = rendered .split("/") .filter(Boolean) @@ -194,6 +224,12 @@ function renderPlatePath( return assertWithinStorage(path.join(userSegment, ...parts)); } +/** The model's pictures folder: where the template would put a file named "Images", so it sits beside the model's + * files -- "Props/Gadget/Images", or "Props/Gadget - Images" under a template that keeps models side by side. */ +function renderAssetsPath(print: PrintLike, template: string, printValues: PrintTemplateValues): string { + return renderModelPath(print, template, printValues, ASSETS_FOLDER, null); +} + /** Renders the on-disk relative path for one plate of a print using the storage template. * Every user's files live under their own u- segment beneath the (instance-wide) * rendered template, invisibly -- the template's own tokens/UX are unaware of it. */ @@ -205,25 +241,78 @@ export async function renderPlateStoragePath( ): Promise { const safeTemplate = validateStorageTemplate(template ?? (await getStorageTemplate())); const printValues = await printTemplateValues(print, safeTemplate); - return freePlatePath(renderPlatePath(print, safeTemplate, printValues, plateFilename, platePosition), print.id); + return freePlatePath(renderModelPath(print, safeTemplate, printValues, plateFilename, platePosition), print.id); } -async function pathTaken(relative: string, plateId?: string): Promise { - const holder = await prisma.plate.findUnique({ where: { storagePath: relative }, select: { id: true } }); - if (holder) return holder.id !== plateId; +/** What already holds a path, so it counts as free for that one: it's where it is. */ +type PathOwner = { plateId?: string; printFileId?: string; assetsOf?: string }; + +async function pathTaken(relative: string, owner: PathOwner = {}): Promise { + const plate = await prisma.plate.findUnique({ where: { storagePath: relative }, select: { id: true } }); + if (plate) return plate.id !== owner.plateId; + const file = await prisma.printFile.findUnique({ where: { storagePath: relative }, select: { id: true } }); + if (file) return file.id !== owner.printFileId; + const assets = await prisma.print.findUnique({ where: { assetsPath: relative }, select: { id: true } }); + if (assets) return assets.id !== owner.assetsOf; return fsSync.existsSync(path.join(STORAGE, relative)); } /** `relative`, unless another file has it: a template that doesn't tell two models apart (one saved before the * rules), or names that only differ in characters a path can't hold. The file then goes in a folder named * after its model's ID, beside where it would have been. */ -async function freePlatePath(relative: string, printId: string, plateId?: string): Promise { - if (!(await pathTaken(relative, plateId))) return relative; +async function freePlatePath(relative: string, printId: string, owner: PathOwner = {}): Promise { + if (!(await pathTaken(relative, owner))) return relative; const fallback = path.join(path.dirname(relative), printId, path.basename(relative)); - if (await pathTaken(fallback, plateId)) throw new HttpError(409, `Storage path already exists: ${relative}`); + if (await pathTaken(fallback, owner)) throw new HttpError(409, `Storage path already exists: ${relative}`); return fallback; } +/** A supporting or prepared file's place: beside the plates, numbered like "Notes (2).pdf" when one of the model's + * files already has its name. */ +async function freePrintFilePath(relative: string, printFileId?: string): Promise { + const ext = path.extname(relative); + const stem = relative.slice(0, relative.length - ext.length); + let candidate = relative; + for (let suffix = 2; await pathTaken(candidate, { printFileId }); suffix++) { + if (suffix > 1000) throw new HttpError(409, `Storage path already exists: ${relative}`); + candidate = `${stem} (${suffix})${ext}`; + } + return candidate; +} + +/** Where a new supporting or prepared file goes. */ +export async function renderPrintFileStoragePath(print: PrintLike, filename: string): Promise { + const template = validateStorageTemplate(await getStorageTemplate()); + const values = await printTemplateValues(print, template); + return freePrintFilePath(renderModelPath(print, template, values, filename, null)); +} + +/** The model's pictures folder, chosen now if it has none yet: a model from before gets one the first time a + * picture of it is saved, and its older pictures follow when the storage move reaches it. */ +export async function ensureAssetHome(printId: string): Promise { + const print = await prisma.print.findUnique({ where: { id: printId } }); + if (!print) throw new HttpError(404, "Print not found"); + if (print.assetsPath) return print.assetsPath; + const template = validateStorageTemplate(await getStorageTemplate()); + const values = await printTemplateValues(print, template); + const chosen = await freePlatePath(renderAssetsPath(print, template, values), print.id, { assetsOf: print.id }); + try { + await prisma.print.updateMany({ where: { id: printId, assetsPath: null }, data: { assetsPath: chosen } }); + } catch { + // Another model took it in the meantime; the next picture tries again. + throw new HttpError(409, "Couldn't choose a folder for the model's pictures; try again"); + } + // Another picture saved at the same moment may have chosen first. + const saved = await prisma.print.findUnique({ where: { id: printId }, select: { assetsPath: true } }); + return saved?.assetsPath ?? chosen; +} + +async function ensurePlateAssetHome(plateId: string): Promise { + const plate = await prisma.plate.findUnique({ where: { id: plateId }, select: { printId: true } }); + if (!plate) throw new HttpError(404, "Plate not found"); + return ensureAssetHome(plate.printId); +} + export function samplePlateStoragePaths(template: string): [string, string] { const safeTemplate = validateStorageTemplate(template); const base = { @@ -330,21 +419,117 @@ export async function pruneEmptyStorageDirs(start: string): Promise { } } +/** Moves one file or folder of a model; `save` records the new place, and the file goes back if that fails. */ +async function moveStored(oldPath: string, newPath: string, save: () => Promise): Promise { + const oldExists = fsSync.existsSync(oldPath); + if (oldExists) { + await fs.mkdir(path.dirname(newPath), { recursive: true }); + await fs.rename(oldPath, newPath); + } + try { + await save(); + } catch (err) { + // Put it back, so the stored path still finds it. + if (oldExists) { + await fs.mkdir(path.dirname(oldPath), { recursive: true }); + await fs.rename(newPath, oldPath); + await pruneEmptyStorageDirs(path.dirname(newPath)); + } + throw err; + } + if (oldExists) await pruneEmptyStorageDirs(path.dirname(oldPath)); + return oldExists; +} + +const samePath = (relative: string, other: string) => path.resolve(STORAGE, relative) === path.resolve(STORAGE, other); + +/** The model's supporting and prepared files, beside its plates. Those saved before that are under bundles/ and + * come over here too. */ +async function relocatePrintFiles( + print: PrintLike, + template: string, + printValues: PrintTemplateValues, +): Promise { + const files = await prisma.printFile.findMany({ where: { printId: print.id }, orderBy: { id: "asc" } }); + let moved = 0; + for (const file of files) { + const rendered = renderModelPath(print, template, printValues, file.filename, null); + const newRelative = samePath(file.storagePath, rendered) ? rendered : await freePrintFilePath(rendered, file.id); + if (samePath(file.storagePath, newRelative)) continue; + const didMove = await moveStored(path.join(STORAGE, file.storagePath), path.join(STORAGE, newRelative), () => + prisma.printFile.update({ where: { id: file.id }, data: { storagePath: newRelative } }), + ); + if (didMove) moved += 1; + } + return moved; +} + +/** Every picture the model has: its gallery and description images, and each plate's thumbnail and render. */ +async function modelAssetFiles(printId: string, plateIds: string[]): Promise { + const [images, descriptions] = await Promise.all([ + prisma.previewImage.findMany({ where: { printId }, select: { id: true } }), + prisma.descriptionImage.findMany({ where: { printId }, select: { id: true, mime: true } }), + ]); + return [ + ...images.map((image) => galleryImageFile(image.id)), + ...descriptions.map(descriptionImageFile), + ...plateIds.flatMap((plateId) => [plateThumbFile(plateId), plateRenderFile(plateId)]), + ]; +} + +/** The model's pictures folder, moved whole to where the template now puts it, with any pictures still in the old + * shared folders brought in. */ +async function relocateAssets( + print: PrintLike & { assetsPath: string | null }, + template: string, + printValues: PrintTemplateValues, + plateIds: string[], +): Promise { + const rendered = renderAssetsPath(print, template, printValues); + const current = print.assetsPath; + const home = + current && samePath(current, rendered) ? rendered : await freePlatePath(rendered, print.id, { assetsOf: print.id }); + let moved = 0; + if (!current) { + // Saved before anything moves, so a picture moved below is found even if the move stops part-way. + await prisma.print.update({ where: { id: print.id }, data: { assetsPath: home } }); + } else if (!samePath(current, home)) { + const from = path.join(STORAGE, current); + const count = (await listAssetFiles(current)).length; + await moveStored(from, path.join(STORAGE, home), () => + prisma.print.update({ where: { id: print.id }, data: { assetsPath: home } }), + ); + moved += count; + } else if (current !== home) { + await prisma.print.update({ where: { id: print.id }, data: { assetsPath: home } }); + } + print.assetsPath = home; + for (const file of await modelAssetFiles(print.id, plateIds)) { + if (await adoptLegacyAsset(home, file)) moved += 1; + } + return moved; +} + /** - * Moves every plate of a print after a change to anything its storage path is built from. - * Supporting and prepared files live at a fixed bundles/ path and aren't touched. + * Moves everything of a print -- plates, supporting and prepared files, pictures -- after a change to anything its + * storage path is built from. Returns how many files moved. */ -export async function relocatePrint(print: PrintLike, plates: Plate[], template?: string | null): Promise { +export async function relocatePrint( + print: PrintLike & { assetsPath?: string | null }, + plates: Plate[], + template?: string | null, +): Promise { const safeTemplate = validateStorageTemplate(template ?? (await getStorageTemplate())); const printValues = await printTemplateValues(print, safeTemplate); + let moved = 0; for (const plate of plates) { const oldPath = managedPlatePath(plate); - const rendered = renderPlatePath(print, safeTemplate, printValues, plate.filename, plate.position); + const rendered = renderModelPath(print, safeTemplate, printValues, plate.filename, plate.position); // Where it already is counts as free, so a plate moved aside by freePlatePath stays put. const newRelative = path.resolve(oldPath) === path.resolve(STORAGE, rendered) ? rendered - : await freePlatePath(rendered, print.id, plate.id); + : await freePlatePath(rendered, print.id, { plateId: plate.id }); const newPath = path.join(STORAGE, newRelative); if (path.resolve(oldPath) === path.resolve(newPath)) { if (plate.storagePath !== newRelative) { @@ -352,24 +537,27 @@ export async function relocatePrint(print: PrintLike, plates: Plate[], template? } continue; } - const oldExists = fsSync.existsSync(oldPath); - if (oldExists) { - await fs.mkdir(path.dirname(newPath), { recursive: true }); - await fs.rename(oldPath, newPath); - } - try { - await prisma.plate.update({ where: { id: plate.id }, data: { storagePath: newRelative } }); - } catch (err) { - // Put the file back, so the plate's stored path still finds it. - if (oldExists) { - await fs.mkdir(path.dirname(oldPath), { recursive: true }); - await fs.rename(newPath, oldPath); - await pruneEmptyStorageDirs(path.dirname(newPath)); - } - throw err; - } - if (oldExists) await pruneEmptyStorageDirs(path.dirname(oldPath)); + const didMove = await moveStored(oldPath, newPath, () => + prisma.plate.update({ where: { id: plate.id }, data: { storagePath: newRelative } }), + ); + if (didMove) moved += 1; } + moved += await relocatePrintFiles(print, safeTemplate, printValues); + // A caller that only had some of the plates still moves every plate's pictures. + const [current, allPlates] = await Promise.all([ + print.assetsPath === undefined + ? prisma.print.findUnique({ where: { id: print.id }, select: { assetsPath: true } }) + : { assetsPath: print.assetsPath }, + prisma.plate.findMany({ where: { printId: print.id }, select: { id: true } }), + ]); + if (!current) return moved; + moved += await relocateAssets( + { ...print, assetsPath: current.assetsPath }, + safeTemplate, + printValues, + allPlates.map((plate) => plate.id), + ); + return moved; } /** Moves the given prints' files after a change that only matters when the storage template @@ -413,13 +601,9 @@ export async function reorganizeManagedPrints( }); for (const [index, print] of prints.entries()) { 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 { + moved += await relocatePrint(print, print.plates, template); + } catch (err) { + console.error(`Couldn't move the files of print ${print.id}`, err); skipped += 1; } onProgress?.({ done: index + 1, total: prints.length, moved, skipped }); @@ -429,28 +613,61 @@ export async function reorganizeManagedPrints( /** How many files, of how many models, aren't where `template` puts them: what saving it would move. A file put * beside its place, in a folder named after its model because another file has that place (freePlatePath), counts - * as in place. */ + * as in place, as does a supporting file numbered apart from another (freePrintFilePath). Pictures are counted by + * their images, from the database; thumbnails and renders, which have no rows, move along uncounted. */ export async function storageOutOfPlace(template: string): Promise<{ files: number; models: number }> { const safeTemplate = validateStorageTemplate(template); - const prints = await prisma.print.findMany({ include: { plates: true } }); - const taken = new Set(prints.flatMap((print) => print.plates.map((plate) => plate.storagePath))); + const prints = await prisma.print.findMany({ + include: { + plates: true, + files: { select: { filename: true, storagePath: true } }, + _count: { select: { previewImages: true, descriptionImages: true } }, + }, + }); + const taken = new Set( + prints.flatMap((print) => [ + ...print.plates.map((plate) => plate.storagePath), + ...print.files.map((file) => file.storagePath), + ...(print.assetsPath ? [print.assetsPath] : []), + ]), + ); + const inPlace = (stored: string, target: string, printId: string) => { + if (stored === target) return true; + const aside = path.join(path.dirname(target), printId, path.basename(target)); + return stored === aside && taken.has(target); + }; let files = 0; let models = 0; for (const print of prints) { - if (!print.plates.length) continue; const values = await printTemplateValues(print, safeTemplate); - const misplaced = print.plates.filter((plate) => { - const target = renderPlatePath(print, safeTemplate, values, plate.filename, plate.position); - if (plate.storagePath === target) return false; - const aside = path.join(path.dirname(target), print.id, path.basename(target)); - return !(plate.storagePath === aside && taken.has(target)); + let misplaced = print.plates.filter( + (plate) => + !inPlace( + plate.storagePath, + renderModelPath(print, safeTemplate, values, plate.filename, plate.position), + print.id, + ), + ).length; + misplaced += print.files.filter((file) => { + const target = renderModelPath(print, safeTemplate, values, file.filename, null); + return file.storagePath !== target && withoutNumber(file.storagePath) !== target; }).length; + const assetsTarget = renderAssetsPath(print, safeTemplate, values); + const assetsMisplaced = !print.assetsPath || !inPlace(print.assetsPath, assetsTarget, print.id); + if (assetsMisplaced) misplaced += print._count.previewImages + print._count.descriptionImages; files += misplaced; - if (misplaced) models += 1; + // A model with only thumbnails or renders still has a folder to move. + if (misplaced || (assetsMisplaced && print.plates.length)) models += 1; } return { files, models }; } +/** "Notes (2).pdf" as "Notes.pdf". */ +function withoutNumber(file: string): string { + const ext = path.extname(file); + return file.slice(0, file.length - ext.length).replace(/ \(\d+\)$/, "") + ext; +} + // -- Settings (storage template) ----------------------------------------- export async function getStorageTemplate(): Promise { @@ -469,20 +686,26 @@ export async function setStorageTemplate(value: string): Promise { // -- Thumbnails ------------------------------------------------------------- async function saveThumbBuffer(plateId: string, input: Buffer): Promise { - const dest = path.join(THUMBS, `${plateId}.jpg`); - const tmp = `${dest}.${process.pid}.${Date.now()}.tmp`; + let tmp: string | null = null; try { + const file = plateThumbFile(plateId); + const home = await ensurePlateAssetHome(plateId); + const dest = assetTarget(home, file); + tmp = `${dest}.${process.pid}.${Date.now()}.tmp`; + await fs.mkdir(path.dirname(dest), { recursive: true }); 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); + // The one in the old shared folder is out of date now. + await removeAsset(null, file); // Any thumb but our own render; the generated-thumbnail route marks those after saving. await prisma.plate.updateMany({ where: { id: plateId, thumbGenerated: true }, data: { thumbGenerated: false } }); return true; } catch { - await fs.rm(tmp, { force: true }); + if (tmp) await fs.rm(tmp, { force: true }); return false; } } @@ -531,22 +754,36 @@ export async function extract3mfThumbnail(plateId: string, srcPath: string): Pro } export async function ensurePlateThumbnail(plateId: string, srcPath: string): Promise { - const existing = path.join(THUMBS, `${plateId}.jpg`); - if (fsSync.existsSync(existing)) return true; + if (plateThumbExists(plateId, await plateAssetHome(plateId))) return true; return extract3mfThumbnail(plateId, srcPath); } -export function plateThumbPath(plateId: string): string { - return path.join(THUMBS, `${plateId}.jpg`); +/** `home` is the plate's model's Print.assetsPath. */ +export function plateThumbPath(plateId: string, home: AssetHome): string { + return assetPath(home, plateThumbFile(plateId)); } -export function plateThumbExists(plateId: string): boolean { - return fsSync.existsSync(plateThumbPath(plateId)); +export function plateThumbExists(plateId: string, home: AssetHome): boolean { + return assetExists(home, plateThumbFile(plateId)); +} + +/** A plate's thumbnail and render, once the plate is gone. */ +export async function removePlatePictures(plateId: string, home: AssetHome): Promise { + await removeAsset(home, plateThumbFile(plateId)); + await removeAsset(home, plateRenderFile(plateId)); } /** True when the plate has no thumb or only our own 3D render, which an imported image beats. */ export async function plateThumbReplaceable(plateId: string): Promise { - if (!plateThumbExists(plateId)) return true; + if (!plateThumbExists(plateId, await plateAssetHome(plateId))) return true; const plate = await prisma.plate.findUnique({ where: { id: plateId }, select: { thumbGenerated: true } }); return plate?.thumbGenerated ?? false; } + +/** The whole pictures folder, once its model is gone. */ +export async function removeAssetHome(home: AssetHome): Promise { + if (!home) return; + const abs = path.join(STORAGE, home); + await fs.rm(abs, { recursive: true, force: true }).catch(() => undefined); + await pruneEmptyStorageDirs(path.dirname(abs)); +} diff --git a/backend/src/services/storageBrowserService.ts b/backend/src/services/storageBrowserService.ts index 7de37ae..6e52eba 100644 --- a/backend/src/services/storageBrowserService.ts +++ b/backend/src/services/storageBrowserService.ts @@ -3,17 +3,31 @@ import path from "node:path"; import { prisma } from "../db"; import { STORAGE } from "../config"; import { HttpError } from "../utils/fileUtils"; +import { mapWithConcurrency } from "../utils/concurrency"; +import { assetKindOf, listAssetFiles, type AssetKind } from "./modelAssets"; -// Administration's look at the managed model files, laid out as the storage template put them. Built from -// the plates' saved paths rather than a walk of the disk, so it's quick and only shows model files; only the -// files in the folder being looked at are checked on disk. +// Administration's look at the managed model files, laid out as the storage template put them. The plates and +// supporting files come from their saved paths rather than a walk of the disk, so it's quick and only shows the +// models' own files; only those in the folder being looked at are checked on disk. Each model's pictures folder +// is read from disk, as most of what's in it has no row. -export type StorageFolder = { name: string; path: string; label: string | null; files: number; bytes: number }; +export type StorageFolder = { + name: string; + path: string; + label: string | null; + files: number; + bytes: number; + /** A model's pictures folder. */ + pictures: boolean; +}; export type StorageFileLocation = "managed" | "source" | "missing"; +/** "plate" is a model file; "file" a supporting or prepared one; the rest are pictures. */ +export type StorageFileKind = "plate" | "file" | AssetKind; export type StorageFile = { name: string; path: string; - plate_id: string; + kind: StorageFileKind; + plate_id: string | null; print_id: string; print_name: string; owner_id: string; @@ -28,9 +42,26 @@ export type StorageListing = { files: StorageFile[]; }; +type ModelRef = { id: string; name: string; userId: string }; +type Entry = { + /** Relative to storage, with "/". */ + path: string; + size: number; + kind: StorageFileKind; + plateId: string | null; + print: ModelRef; + /** Only asked of the files in the folder being looked at. */ + location: () => StorageFileLocation; +}; + const USER_FOLDER_RE = /^u-(.+)$/; +// Pictures folders are read a few at a time, so a big library doesn't open every one at once. +const PICTURE_FOLDER_READS = 16; // Storage paths are written with the server's separator; the page always gets "/". const toSlash = (p: string) => p.split(path.sep).join("/"); +const toNative = (p: string) => p.split("/").join(path.sep); +const exists = (relative: string) => fsSync.existsSync(path.join(STORAGE, relative)); +const MODEL_SELECT = { select: { id: true, name: true, userId: true } } as const; function normalizeFolder(raw: string): string { const parts = raw.replace(/\\/g, "/").split("/").filter(Boolean); @@ -48,58 +79,112 @@ async function userLabels(folderNames: string[]): Promise> { const byName = (a: { name: string; label?: string | null }, b: { name: string; label?: string | null }) => (a.label ?? a.name).localeCompare(b.label ?? b.name, undefined, { numeric: true }); -function fileLocation(storagePath: string, sourcePath: string | null): StorageFileLocation { - if (fsSync.existsSync(path.join(STORAGE, storagePath))) return "managed"; +function plateLocation(storagePath: string, sourcePath: string | null): StorageFileLocation { + if (exists(storagePath)) return "managed"; if (sourcePath && fsSync.existsSync(sourcePath)) return "source"; return "missing"; } +/** Every file of the models below `folder`, or of the one whose pictures folder it's in. */ +async function entriesUnder(folder: string): Promise<{ entries: Entry[]; pictureFolders: Set }> { + const below = folder ? { startsWith: toNative(folder) + path.sep } : undefined; + const parts = folder ? folder.split("/") : []; + // Inside a pictures folder, the model it belongs to is found by the folder or one above it. + const enclosing = parts.map((_, index) => toNative(parts.slice(0, index + 1).join("/"))); + const [plates, files, homes] = await Promise.all([ + prisma.plate.findMany({ + where: below ? { storagePath: below } : undefined, + select: { id: true, storagePath: true, sourcePath: true, size: true, print: MODEL_SELECT }, + }), + prisma.printFile.findMany({ + where: below ? { storagePath: below } : undefined, + select: { storagePath: true, size: true, print: MODEL_SELECT }, + }), + prisma.print.findMany({ + where: below ? { OR: [{ assetsPath: below }, { assetsPath: { in: enclosing } }] } : { assetsPath: { not: null } }, + select: { id: true, name: true, userId: true, assetsPath: true }, + }), + ]); + + const entries: Entry[] = [ + ...plates.map((plate) => ({ + path: toSlash(plate.storagePath), + size: Number(plate.size), + kind: "plate" as const, + plateId: plate.id, + print: plate.print, + location: () => plateLocation(plate.storagePath, plate.sourcePath), + })), + ...files.map((file) => ({ + path: toSlash(file.storagePath), + size: Number(file.size), + kind: "file" as const, + plateId: null, + print: file.print, + location: (): StorageFileLocation => (exists(file.storagePath) ? "managed" : "missing"), + })), + ]; + const pictures = await mapWithConcurrency(homes, PICTURE_FOLDER_READS, async (print) => { + const home = toSlash(print.assetsPath!); + return (await listAssetFiles(print.assetsPath!)).map((file) => ({ + path: `${home}/${file.path}`, + size: file.size, + kind: assetKindOf(file.path), + plateId: null, + print: { id: print.id, name: print.name, userId: print.userId }, + location: (): StorageFileLocation => "managed", + })); + }); + entries.push(...pictures.flat()); + return { entries, pictureFolders: new Set(homes.map((print) => toSlash(print.assetsPath!))) }; +} + /** One folder: the folders in it, with how many files and bytes each holds below it, and its own files. */ export async function listStorageFolder(rawFolder: string): Promise { const folder = normalizeFolder(rawFolder); const prefix = folder ? `${folder}/` : ""; - const plates = await prisma.plate.findMany({ - where: folder ? { storagePath: { startsWith: folder.split("/").join(path.sep) + path.sep } } : undefined, - select: { - id: true, - storagePath: true, - sourcePath: true, - filename: true, - size: true, - print: { select: { id: true, name: true, userId: true } }, - }, - }); + const { entries, pictureFolders } = await entriesUnder(folder); const folders = new Map(); const files: StorageFile[] = []; - for (const plate of plates) { - const relative = toSlash(plate.storagePath).slice(prefix.length); + for (const entry of entries) { + if (!entry.path.startsWith(prefix)) continue; + const relative = entry.path.slice(prefix.length); const slash = relative.indexOf("/"); if (slash === -1) { files.push({ name: relative, - path: prefix + relative, - plate_id: plate.id, - print_id: plate.print.id, - print_name: plate.print.name, - owner_id: plate.print.userId, - size: Number(plate.size), - location: fileLocation(plate.storagePath, plate.sourcePath), + path: entry.path, + kind: entry.kind, + plate_id: entry.plateId, + print_id: entry.print.id, + print_name: entry.print.name, + owner_id: entry.print.userId, + size: entry.size, + location: entry.location(), }); continue; } const name = relative.slice(0, slash); - const entry = folders.get(name) ?? { name, path: prefix + name, label: null, files: 0, bytes: 0 }; - entry.files += 1; - entry.bytes += Number(plate.size); - folders.set(name, entry); + const entryPath = prefix + name; + const summary = folders.get(name) ?? { + name, + path: entryPath, + label: null, + files: 0, + bytes: 0, + pictures: pictureFolders.has(entryPath), + }; + summary.files += 1; + summary.bytes += entry.size; + folders.set(name, summary); } if (folder && !folders.size && !files.length) throw new HttpError(404, "No such folder"); const parts = folder ? folder.split("/") : []; // Only the top level is per user, as u-. const labels = await userLabels(folder ? parts.slice(0, 1) : [...folders.keys()]); - if (!folder) for (const entry of folders.values()) entry.label = labels.get(entry.name) ?? null; + if (!folder) for (const summary of folders.values()) summary.label = labels.get(summary.name) ?? null; return { path: folder, crumbs: parts.map((name, index) => ({ diff --git a/backend/src/services/storageMoveService.ts b/backend/src/services/storageMoveService.ts index b019e39..cb14c36 100644 --- a/backend/src/services/storageMoveService.ts +++ b/backend/src/services/storageMoveService.ts @@ -1,12 +1,19 @@ +import fs from "node:fs/promises"; +import type { Dirent } from "node:fs"; +import path from "node:path"; import type { Prisma } from "@prisma/client"; +import { BUNDLES, DESCRIPTION_IMAGES, MODEL_PREVIEWS, PREVIEWS, THUMBS } from "../config"; import { prisma } from "../db"; import { createLogger } from "../logger"; import { createNotification } from "./notificationService"; -import { reorganizeManagedPrints } from "./printService"; +import { isLegacyRender } from "./modelAssets"; +import { getStorageTemplate, reorganizeManagedPrints } from "./printService"; // Moving every user's model files to the storage structure an admin saved. It runs in the background, followed by // a bar at the bottom of the app; it can't be stopped, since half the files in one layout and half in another is // what it's there to end. Its template is kept as a setting while it runs, so a restart carries on with it. +// The first start after pictures and supporting files moved in beside the model files runs one too, to bring those +// saved before over from the shared folders they were in. const log = createLogger("storage-move"); const PENDING_KEY = "storage_move_pending"; @@ -24,7 +31,8 @@ export type StorageMoveRun = { skipped: number; }; -type Pending = { template: string; userId: string }; +/** No `userId` for the move a start-up runs on its own: nobody asked, so nobody's notified. */ +type Pending = { template: string; userId: string | null }; let current: StorageMoveRun | null = null; @@ -33,7 +41,7 @@ export function currentStorageMove(): StorageMoveRun | null { } /** Starts moving every model's files to `template`; null while a move is already going. */ -export function startStorageMove(userId: string, template: string): StorageMoveRun | null { +export function startStorageMove(userId: string | null, template: string): StorageMoveRun | null { if (current?.running) return null; current = { running: true, @@ -61,9 +69,12 @@ async function run(state: StorageMoveRun, pending: Pending): Promise { Object.assign(state, progress); }); log.info("files moved", { moved, skipped }); - if (moved || skipped) await notifyDone(pending.userId, moved, skipped); + // Every model's files came over, so what's left in the old shared folders belongs to none. + if (!skipped) await removeLegacyLeftovers(); + if (pending.userId && (moved || skipped)) await notifyDone(pending.userId, moved, skipped); } catch (err) { log.error("moving files failed", { error: err }); + if (!pending.userId) return; await createNotification(pending.userId, { type: "general", title: "Couldn't finish moving model files", @@ -92,11 +103,57 @@ async function notifyDone(userId: string, moved: number, skipped: number): Promi }); } -/** At startup: carries on with a move the previous process didn't finish. Files already moved stay put. */ +const LEGACY_FOLDERS = [THUMBS, PREVIEWS, DESCRIPTION_IMAGES, BUNDLES]; + +/** The old shared folders, and the renders among the 3D previews, once a move has brought every model's files over: + * whatever is still there is a model's that's gone, such as the render of a plate deleted before renders were + * removed with it. Otherwise it would start the move again on every start. */ +async function removeLegacyLeftovers(): Promise { + for (const dir of LEGACY_FOLDERS) await fs.rm(dir, { recursive: true, force: true }).catch(() => undefined); + const names = await fs.readdir(MODEL_PREVIEWS).catch(() => [] as string[]); + for (const name of names.filter(isLegacyRender)) { + await fs.rm(path.join(MODEL_PREVIEWS, name), { force: true }).catch(() => undefined); + } +} + +async function hasFiles(dir: string, accept: (name: string) => boolean = () => true): Promise { + let entries: Dirent[]; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + return false; + } + for (const entry of entries) { + if (entry.isFile() && accept(entry.name)) return true; + if (entry.isDirectory() && (await hasFiles(path.join(dir, entry.name), accept))) return true; + } + return false; +} + +/** True while pictures or supporting files are still in the old shared folders, or a model from before them has + * none of its own yet. A model the last move skipped keeps it true, so the next start tries it again. */ +async function legacyFilesLeft(): Promise { + const [unhomed, bundled] = await Promise.all([ + prisma.print.count({ where: { assetsPath: null } }), + prisma.printFile.count({ where: { storagePath: { startsWith: "bundles/" } } }), + ]); + if (unhomed > 0 || bundled > 0) return true; + for (const dir of LEGACY_FOLDERS) if (await hasFiles(dir)) return true; + return hasFiles(MODEL_PREVIEWS, isLegacyRender); +} + +/** At startup: carries on with a move the previous process didn't finish, or brings over the pictures and + * supporting files of models saved before they lived beside the model files. Files already moved stay put. */ export async function resumeStorageMove(): Promise { const row = await prisma.setting.findUnique({ where: { key: PENDING_KEY } }); const pending = row?.value as Pending | undefined; - if (!pending?.template || !pending.userId) return; - log.info("resuming an interrupted move"); - startStorageMove(pending.userId, pending.template); + if (pending?.template) { + log.info("resuming an interrupted move"); + startStorageMove(pending.userId ?? null, pending.template); + return; + } + if (await legacyFilesLeft()) { + log.info("moving pictures and supporting files in beside the model files"); + startStorageMove(null, await getStorageTemplate()); + } } diff --git a/backend/src/services/tagMosaicService.ts b/backend/src/services/tagMosaicService.ts index ff990a2..d7e11d4 100644 --- a/backend/src/services/tagMosaicService.ts +++ b/backend/src/services/tagMosaicService.ts @@ -67,9 +67,9 @@ async function printsTagged(userId: string, tag: string): Promise } /** A plate's render, only once it's on disk: tags don't queue renders, which cards and uploads do. */ -function renderCover(plateId: string | null): Picture | null { +function renderCover(plateId: string | null, home: string | null): Picture | null { if (!plateId) return null; - const file = modelImagePath(plateId, "render"); + const file = modelImagePath(plateId, home, "render"); try { return { url: `/plate/${plateId}/render.png?v=${fsSync.statSync(file).mtimeMs}`, file }; } catch { @@ -95,6 +95,7 @@ export async function tagPictures( where: { id: { in: [...new Set([...candidates.values()].flat())] } }, select: { id: true, + assetsPath: true, previewImages: { orderBy: { position: "asc" }, take: 1, select: { id: true } }, plates: { orderBy: { position: "asc" }, take: 1, select: { id: true } }, }, @@ -113,8 +114,8 @@ export async function tagPictures( !row ? null : kind === "renders" - ? renderCover(plateId) - : printCover(row.previewImages[0]?.id ?? null, plateId), + ? renderCover(plateId, row.assetsPath) + : printCover(row.previewImages[0]?.id ?? null, plateId, row.assetsPath), ); } return covers.get(id); diff --git a/backend/tests/descriptionImages.test.ts b/backend/tests/descriptionImages.test.ts index 49d9802..0d5d0e7 100644 --- a/backend/tests/descriptionImages.test.ts +++ b/backend/tests/descriptionImages.test.ts @@ -4,6 +4,7 @@ import request from "supertest"; import { createApp } from "../src/app"; import { importPrintFromUrl } from "../src/services/importService"; import { descriptionImagePath, localizeDescriptionImages } from "../src/services/descriptionImageService"; +import { printAssetHome } from "../src/services/modelAssets"; import { prisma } from "../src/db"; // Images embedded in an imported description are stored locally and served from our own API. @@ -129,13 +130,14 @@ describe("description images", () => { it("drops the stored images an edit removes from the description", async () => { const image = await prisma.descriptionImage.findFirstOrThrow({ where: { printId } }); + const home = await printAssetHome(printId); const res = await request(app) .post(`/api/print/${printId}/meta`) .set("Authorization", `Bearer ${token}`) .send({ notes: "Just text now." }); expect(res.status).toBe(200); expect(await prisma.descriptionImage.count({ where: { printId } })).toBe(0); - expect(fs.existsSync(descriptionImagePath(image.id))).toBe(false); + expect(fs.existsSync(descriptionImagePath(image, home))).toBe(false); }); it("fills an emptied description from the source, formatting and images included", async () => { @@ -151,13 +153,14 @@ describe("description images", () => { it("drops all stored images when the description is cleared", async () => { const image = await prisma.descriptionImage.findFirstOrThrow({ where: { printId } }); + const home = await printAssetHome(printId); const res = await request(app) .post(`/api/print/${printId}/meta`) .set("Authorization", `Bearer ${token}`) .send({ notes: "" }); expect(res.status).toBe(200); expect(await prisma.descriptionImage.count({ where: { printId } })).toBe(0); - expect(fs.existsSync(descriptionImagePath(image.id))).toBe(false); + expect(fs.existsSync(descriptionImagePath(image, home))).toBe(false); // Back again for the next test. global.fetch = mockFetch(``); @@ -166,9 +169,10 @@ describe("description images", () => { it("removes the stored images with the model", async () => { const image = await prisma.descriptionImage.findFirstOrThrow({ where: { printId } }); + const home = await printAssetHome(printId); const res = await request(app).delete(`/api/print/${printId}`).set("Authorization", `Bearer ${token}`); expect(res.status).toBe(200); - expect(fs.existsSync(descriptionImagePath(image.id))).toBe(false); + expect(fs.existsSync(descriptionImagePath(image, home))).toBe(false); }); describe("downloading untrusted image URLs", () => { diff --git a/backend/tests/makerworldProfilesImport.test.ts b/backend/tests/makerworldProfilesImport.test.ts index 4ed9878..9579556 100644 --- a/backend/tests/makerworldProfilesImport.test.ts +++ b/backend/tests/makerworldProfilesImport.test.ts @@ -22,6 +22,7 @@ import { STORAGE } from "../src/config"; import { prisma } from "../src/db"; import { selectMakerworldProfiles } from "../src/services/makerworldCloudApi"; import { plateThumbPath } from "../src/services/printService"; +import { plateAssetHome } from "../src/services/modelAssets"; const app = createApp(); const stamp = Date.now(); @@ -78,7 +79,7 @@ const PROFILE_COVERS: Record> = { /** The thumbnail's average colour, which survives the JPEG re-encode. */ async function thumbColour(plateId: string): Promise<[number, number, number]> { - const { channels } = await sharp(plateThumbPath(plateId)).stats(); + const { channels } = await sharp(plateThumbPath(plateId, await plateAssetHome(plateId))).stats(); return [channels[0].mean, channels[1].mean, channels[2].mean].map(Math.round) as [number, number, number]; } diff --git a/backend/tests/modelAssets.test.ts b/backend/tests/modelAssets.test.ts new file mode 100644 index 0000000..1ed29c7 --- /dev/null +++ b/backend/tests/modelAssets.test.ts @@ -0,0 +1,241 @@ +import fs from "node:fs"; +import path from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import request from "supertest"; +import sharp from "sharp"; +import { createApp } from "../src/app"; +import { BUNDLES, DESCRIPTION_IMAGES, MODEL_PREVIEWS, PREVIEWS, STORAGE, THUMBS } from "../src/config"; +import { prisma } from "../src/db"; +import { + getStorageTemplate, + reorganizeManagedPrints, + setStorageTemplate, + storageOutOfPlace, +} from "../src/services/printService"; +import { currentStorageMove, resumeStorageMove } from "../src/services/storageMoveService"; + +const app = createApp(); +const stamp = Date.now(); +let token: string; +let userId: string; +// The template is one instance-wide row, so it's put back afterwards. +let previousTemplate: string; + +const auth = () => ({ Authorization: `Bearer ${token}` }); +const stored = (...parts: string[]) => path.join(STORAGE, ...parts); +const png = (r: number) => + sharp({ create: { width: 8, height: 8, channels: 3, background: { r, g: 0, b: 0 } } }) + .png() + .toBuffer(); + +async function upload(filename: string, contents: string): Promise { + const res = await request(app).post("/api/upload").set(auth()).attach("files", Buffer.from(contents), filename); + expect(res.status).toBe(200); + return res.body.prints[0].id; +} + +async function attachFile(printId: string, filename: string, contents: string) { + const res = await request(app) + .post(`/api/print/${printId}/files`) + .set(auth()) + .attach("file", Buffer.from(contents), filename); + expect(res.status).toBe(200); +} + +async function browse(folder: string) { + const res = await request(app).get("/api/admin/storage/browse").query({ path: folder }).set(auth()); + expect(res.status).toBe(200); + return res.body as { + folders: { name: string; pictures: boolean }[]; + files: { name: string; kind: string; print_id: string }[]; + }; +} + +beforeAll(async () => { + previousTemplate = await getStorageTemplate(); + await setStorageTemplate("{category-or-folder}/{model}/{filename}"); + const res = await request(app) + .post("/api/register") + .send({ displayName: "Assets", email: `model-assets-${stamp}@example.com`, password: "password123" }); + token = res.body.token; + userId = res.body.user.id; + await prisma.user.update({ where: { id: userId }, data: { role: "ADMIN" } }); +}); + +afterAll(async () => { + await setStorageTemplate(previousTemplate); +}); + +describe("a model's pictures and supporting files", () => { + let printId: string; + const modelFolder = () => path.join(`u-${userId}`, "Unassigned", `beside-${stamp}`); + + it("are kept beside its model files, the pictures in an Images folder", async () => { + printId = await upload(`beside-${stamp}.stl`, "solid beside endsolid"); + // Chosen with the model, before it has any picture, so only a model from before has none. + expect((await prisma.print.findUniqueOrThrow({ where: { id: printId } })).assetsPath).toBe( + path.join(modelFolder(), "Images"), + ); + const added = await request(app) + .post(`/api/print/${printId}/preview-images`) + .set(auth()) + .attach("files", await png(200), "photo.png"); + expect(added.status).toBe(200); + await attachFile(printId, "notes.txt", "first"); + await attachFile(printId, "notes.txt", "second"); + + const print = await prisma.print.findUniqueOrThrow({ + where: { id: printId }, + include: { plates: true, previewImages: true, files: { orderBy: { id: "asc" } } }, + }); + expect(print.assetsPath).toBe(path.join(modelFolder(), "Images")); + expect(fs.existsSync(stored(print.assetsPath!, `${print.previewImages[0].id}.jpg`))).toBe(true); + // The first image became the plate's thumbnail. + expect(fs.existsSync(stored(print.assetsPath!, "Thumbnails", `${print.plates[0].id}.jpg`))).toBe(true); + // A second file of the same name is numbered rather than replacing the first. + expect(print.files.map((file) => file.storagePath)).toEqual([ + path.join(modelFolder(), "notes.txt"), + path.join(modelFolder(), "notes (2).txt"), + ]); + expect(fs.readFileSync(stored(modelFolder(), "notes.txt"), "utf8")).toBe("first"); + + const image = await request(app).get(`/api/preview-image/${print.previewImages[0].id}/file.jpg`).set(auth()); + expect(image.status).toBe(200); + }); + + it("show in the storage browser", async () => { + const model = await browse(modelFolder().split(path.sep).join("/")); + expect(model.folders).toEqual([expect.objectContaining({ name: "Images", pictures: true })]); + expect(model.files.map((file) => [file.name, file.kind])).toEqual([ + [`beside-${stamp}.stl`, "plate"], + ["notes (2).txt", "file"], + ["notes.txt", "file"], + ]); + + const pictures = await browse(`${modelFolder().split(path.sep).join("/")}/Images`); + expect(pictures.folders.map((folder) => folder.name)).toContain("Thumbnails"); + expect(pictures.files).toEqual([expect.objectContaining({ kind: "image", print_id: printId })]); + }); + + it("move with the model when the storage structure changes", async () => { + const template = "{category-or-folder}/{model}/{id}/{filename}"; + expect((await storageOutOfPlace(template)).files).toBeGreaterThanOrEqual(4); + await reorganizeManagedPrints(template, userId); + const moved = path.join(modelFolder(), printId); + const print = await prisma.print.findUniqueOrThrow({ where: { id: printId }, include: { previewImages: true } }); + expect(print.assetsPath).toBe(path.join(moved, "Images")); + expect(fs.existsSync(stored(moved, "Images", `${print.previewImages[0].id}.jpg`))).toBe(true); + expect(fs.readFileSync(stored(moved, "notes.txt"), "utf8")).toBe("first"); + expect(fs.existsSync(stored(modelFolder(), "Images"))).toBe(false); + + await reorganizeManagedPrints("{category-or-folder}/{model}/{filename}", userId); + expect(fs.existsSync(stored(moved))).toBe(false); + }); + + it("go with the model when it's deleted", async () => { + const res = await request(app).delete(`/api/print/${printId}`).set(auth()); + expect(res.status).toBe(200); + expect(fs.existsSync(stored(modelFolder()))).toBe(false); + }); +}); + +describe("a model saved before pictures moved in beside its files", () => { + it("keeps its pictures and supporting files where they were until the move brings them over", async () => { + const name = `legacy-${stamp}`; + const print = await prisma.print.create({ data: { userId, name, nameNormalized: name } }); + const platePath = path.join(`u-${userId}`, "Unassigned", name, "part.lbrn"); + fs.mkdirSync(stored(path.dirname(platePath)), { recursive: true }); + fs.writeFileSync(stored(platePath), "laser"); + const plate = await prisma.plate.create({ + data: { printId: print.id, position: 0, filename: "part.lbrn", mime: "text/plain", storagePath: platePath }, + }); + const image = await prisma.previewImage.create({ data: { printId: print.id, position: 0 } }); + const description = await prisma.descriptionImage.create({ + data: { printId: print.id, mime: "image/png", size: 10 }, + }); + const file = await prisma.printFile.create({ + data: { + printId: print.id, + role: "SUPPORTING", + filename: "guide.txt", + mime: "text/plain", + storagePath: "pending", + }, + }); + const bundled = path.join("bundles", print.id, file.id, "guide.txt"); + await prisma.printFile.update({ where: { id: file.id }, data: { storagePath: bundled, size: 5 } }); + + const legacy = { + image: path.join(PREVIEWS, `${image.id}.jpg`), + description: path.join(DESCRIPTION_IMAGES, description.id), + thumb: path.join(THUMBS, `${plate.id}.jpg`), + bundle: path.join(BUNDLES, print.id, file.id, "guide.txt"), + }; + for (const target of Object.values(legacy)) fs.mkdirSync(path.dirname(target), { recursive: true }); + await sharp(await png(200)) + .jpeg() + .toFile(legacy.image); + await sharp(await png(100)) + .jpeg() + .toFile(legacy.thumb); + fs.writeFileSync(legacy.description, await png(50)); + fs.writeFileSync(legacy.bundle, "guide"); + + const served = async () => + Promise.all( + [ + `/api/preview-image/${image.id}/file.jpg`, + `/api/description-image/${description.id}`, + `/api/plate/${plate.id}/thumb.jpg`, + `/api/print/${print.id}/files/${file.id}`, + ].map(async (url) => (await request(app).get(url).set(auth())).status), + ); + expect(await served()).toEqual([200, 200, 200, 200]); + + await reorganizeManagedPrints(undefined, userId); + const home = path.join(`u-${userId}`, "Unassigned", name, "Images"); + expect((await prisma.print.findUniqueOrThrow({ where: { id: print.id } })).assetsPath).toBe(home); + expect(fs.existsSync(stored(home, `${image.id}.jpg`))).toBe(true); + expect(fs.existsSync(stored(home, "Description", `${description.id}.png`))).toBe(true); + expect(fs.existsSync(stored(home, "Thumbnails", `${plate.id}.jpg`))).toBe(true); + expect((await prisma.printFile.findUniqueOrThrow({ where: { id: file.id } })).storagePath).toBe( + path.join(`u-${userId}`, "Unassigned", name, "guide.txt"), + ); + for (const target of Object.values(legacy)) expect(fs.existsSync(target)).toBe(false); + expect(await served()).toEqual([200, 200, 200, 200]); + + await request(app).delete(`/api/print/${print.id}`).set(auth()); + }); +}); + +describe("the first start after upgrading", () => { + it("brings older models' pictures over on its own", async () => { + const name = `upgrade-${stamp}`; + const print = await prisma.print.create({ data: { userId, name, nameNormalized: name } }); + const image = await prisma.previewImage.create({ data: { printId: print.id, position: 0 } }); + fs.mkdirSync(PREVIEWS, { recursive: true }); + await sharp(await png(200)) + .jpeg() + .toFile(path.join(PREVIEWS, `${image.id}.jpg`)); + + // A render of a plate deleted long ago belongs to no model; the move clears it, so it can't start another. + const orphan = path.join(MODEL_PREVIEWS, `gone-${stamp}.render.v2.png`); + fs.writeFileSync(orphan, "old"); + + await resumeStorageMove(); + expect(currentStorageMove()?.running).toBe(true); + for (let i = 0; i < 300 && currentStorageMove()?.running; i++) await new Promise((r) => setTimeout(r, 200)); + const home = (await prisma.print.findUniqueOrThrow({ where: { id: print.id } })).assetsPath; + expect(home).toBe(path.join(`u-${userId}`, "Unassigned", name, "Images")); + expect(fs.existsSync(stored(home!, `${image.id}.jpg`))).toBe(true); + expect(fs.existsSync(orphan)).toBe(false); + expect(fs.existsSync(PREVIEWS)).toBe(false); + // Nothing is left to bring over, so the next start doesn't move anything. + await resumeStorageMove(); + expect(currentStorageMove()?.running).toBe(false); + // Nobody asked for it, so nobody's told. + expect(await prisma.notification.count({ where: { userId, title: "Model files moved" } })).toBe(0); + + await request(app).delete(`/api/print/${print.id}`).set(auth()); + }, 90_000); +}); diff --git a/backend/tests/modelImageCache.test.ts b/backend/tests/modelImageCache.test.ts index 3d0e0fc..561aeb4 100644 --- a/backend/tests/modelImageCache.test.ts +++ b/backend/tests/modelImageCache.test.ts @@ -3,12 +3,15 @@ import fs from "node:fs/promises"; import fsSync from "node:fs"; import os from "node:os"; import path from "node:path"; +import { MODEL_PREVIEWS, STORAGE } from "../src/config"; +import { prisma } from "../src/db"; import { writeZip } from "../src/utils/zipWriter"; +import { plateAssetHome } from "../src/services/modelAssets"; import { generateModelImage, generateModelPreviewGlb, - modelImagePath, - modelImageState, + modelImagePath as cachedImagePath, + modelImageState as cachedImageState, modelPreviewGlbPath, } from "../src/services/modelPreviewCache"; @@ -63,23 +66,40 @@ const ONE_TRIANGLE_3MF_XML = ` `; let dir: string; +let userId: string; const plateIds: string[] = []; -const newPlateId = (label: string) => { - const id = `test-image-${label}-${Date.now()}-${plateIds.length}`; - plateIds.push(id); - return id; -}; +// A render goes in its model's pictures folder, so each plate needs a model. +async function newPlateId(label: string): Promise { + const name = `image-${label}-${Date.now()}-${plateIds.length}`; + const print = await prisma.print.create({ data: { userId, name, nameNormalized: name.toLowerCase() } }); + const plate = await prisma.plate.create({ + data: { + printId: print.id, + position: 0, + filename: `${label}.stl`, + mime: "model/stl", + storagePath: `u-${userId}/${name}/${label}.stl`, + }, + }); + plateIds.push(plate.id); + return plate.id; +} async function file(name: string, content: string): Promise { const target = path.join(dir, name); await fs.writeFile(target, content); return target; } -const cacheDir = () => path.dirname(modelImagePath("x")); +const modelImagePath = async (plateId: string) => cachedImagePath(plateId, await plateAssetHome(plateId)); +const modelImageState = async (plateId: string) => cachedImageState(plateId, await plateAssetHome(plateId)); +const cacheDir = () => MODEL_PREVIEWS; beforeAll(async () => { - // Needs no database, only the cache directory db.ts normally creates. await fs.mkdir(cacheDir(), { recursive: true }); dir = await fs.mkdtemp(path.join(os.tmpdir(), "thingport-image-cache-")); + const user = await prisma.user.create({ + data: { email: `image-cache-${Date.now()}@example.com`, displayName: "Image cache" }, + }); + userId = user.id; }); afterAll(async () => { @@ -87,37 +107,39 @@ afterAll(async () => { for (const name of names) { if (plateIds.some((id) => name.startsWith(id))) await fs.rm(path.join(cacheDir(), name), { force: true }); } + await fs.rm(path.join(STORAGE, `u-${userId}`), { recursive: true, force: true }); + await prisma.user.delete({ where: { id: userId } }); await fs.rm(dir, { recursive: true, force: true }); }); describe("model picture cache", () => { it("says generating while queued, then ready, and doesn't redraw an existing picture", async () => { - const plateId = newPlateId("box"); + const plateId = await newPlateId("box"); const src = await file("box.stl", BOX_STL); const run = generateModelImage(plateId, src, "box.stl"); - expect(modelImageState(plateId)).toBe("generating"); + expect(await modelImageState(plateId)).toBe("generating"); await run; - expect(modelImageState(plateId)).toBe("ready"); - const drawnAt = (await fs.stat(modelImagePath(plateId))).mtimeMs; + expect(await modelImageState(plateId)).toBe("ready"); + const drawnAt = (await fs.stat(await modelImagePath(plateId))).mtimeMs; await generateModelImage(plateId, src, "box.stl"); - expect((await fs.stat(modelImagePath(plateId))).mtimeMs).toBe(drawnAt); + expect((await fs.stat(await modelImagePath(plateId))).mtimeMs).toBe(drawnAt); }); it("asks for one picture only once while it's being drawn", async () => { - const plateId = newPlateId("twice"); + const plateId = await newPlateId("twice"); const src = await file("twice.stl", BOX_STL); await Promise.all([generateModelImage(plateId, src, "twice.stl"), generateModelImage(plateId, src, "twice.stl")]); - expect(modelImageState(plateId)).toBe("ready"); + expect(await modelImageState(plateId)).toBe("ready"); }); it("calls a 3MF layout it can't read unsupported, and doesn't try it again", async () => { - const plateId = newPlateId("elsewhere"); + const plateId = await newPlateId("elsewhere"); const modelPath = await file("model.model", ONE_TRIANGLE_3MF_XML); const src = path.join(dir, "elsewhere.3mf"); // Valid 3MF (the path comes from _rels/.rels), but not where the parser looks. await writeZip(src, [{ arcname: "3D/model.model", filePath: modelPath }]); await generateModelImage(plateId, src, "elsewhere.3mf"); - expect(modelImageState(plateId)).toBe("unsupported"); + expect(await modelImageState(plateId)).toBe("unsupported"); const marker = path.join(cacheDir(), `${plateId}.render.v2.error`); const markedAt = (await fs.stat(marker)).mtimeMs; await generateModelImage(plateId, src, "elsewhere.3mf"); @@ -125,32 +147,36 @@ describe("model picture cache", () => { }); it("calls a broken file failed rather than drawing nothing", async () => { - const plateId = newPlateId("broken"); + const plateId = await newPlateId("broken"); await generateModelImage(plateId, await file("broken.stl", "solid empty\nendsolid empty\n"), "broken.stl"); - expect(modelImageState(plateId)).toBe("failed"); + expect(await modelImageState(plateId)).toBe("failed"); }); it("ignores formats it can't draw", async () => { - const plateId = newPlateId("step"); + const plateId = await newPlateId("step"); await generateModelImage(plateId, await file("part.step", "ISO-10303-21;"), "part.step"); - expect(fsSync.existsSync(modelImagePath(plateId))).toBe(false); + expect(fsSync.existsSync(await modelImagePath(plateId))).toBe(false); }); it("lets a 3D preview go ahead of pictures already waiting", async () => { const pictures = await Promise.all( - [0, 1, 2].map(async (i) => ({ plateId: newPlateId(`queued${i}`), src: await file(`queued${i}.stl`, BOX_STL) })), + [0, 1, 2].map(async (i) => ({ + plateId: await newPlateId(`queued${i}`), + src: await file(`queued${i}.stl`, BOX_STL), + })), ); const modelPath = await file("3dmodel.model", ONE_TRIANGLE_3MF_XML); const fixture = path.join(dir, "one.3mf"); await writeZip(fixture, [{ arcname: "3D/3dmodel.model", filePath: modelPath }]); - const glbPlate = newPlateId("glb"); + const glbPlate = await newPlateId("glb"); // The first picture starts at once; the GLB, queued after the other two, still runs before them. const drawing = pictures.map(({ plateId, src }) => generateModelImage(plateId, src, "box.stl")); await generateModelPreviewGlb(glbPlate, fixture); expect(fsSync.existsSync(modelPreviewGlbPath(glbPlate))).toBe(true); - expect(fsSync.existsSync(modelImagePath(pictures[2].plateId))).toBe(false); + expect(fsSync.existsSync(await modelImagePath(pictures[2].plateId))).toBe(false); await Promise.all(drawing); - expect(pictures.every(({ plateId }) => modelImageState(plateId) === "ready")).toBe(true); + const states = await Promise.all(pictures.map(({ plateId }) => modelImageState(plateId))); + expect(states.every((state) => state === "ready")).toBe(true); }); }); diff --git a/backend/tests/previewImageThumb.test.ts b/backend/tests/previewImageThumb.test.ts index 7c950bb..0527c19 100644 --- a/backend/tests/previewImageThumb.test.ts +++ b/backend/tests/previewImageThumb.test.ts @@ -7,8 +7,9 @@ import sharp from "sharp"; import { createApp } from "../src/app"; import { prisma } from "../src/db"; import { attachImportedPreviewImages } from "../src/services/importService"; -import { plateThumbPath } from "../src/services/printService"; +import { plateThumbPath, saveThumbFromBytes } from "../src/services/printService"; import { previewImagePath } from "../src/services/previewImageService"; +import { plateAssetHome, printAssetHome } from "../src/services/modelAssets"; import { MOSAICS, renderTagMosaic } from "../src/services/tagMosaicService"; import { TAG_MOSAICS } from "../src/config"; @@ -80,8 +81,8 @@ describe("a 3D render saved before uploaded images land", () => { [0, false], [1, false], ]); - expect(await redOf(previewImagePath(previews[0].id))).toBeGreaterThan(180); - expect(await redOf(plateThumbPath(plate.id))).toBeGreaterThan(180); + expect(await redOf(previewImagePath(previews[0].id, await printAssetHome(print.id)))).toBeGreaterThan(180); + expect(await redOf(plateThumbPath(plate.id, await plateAssetHome(plate.id)))).toBeGreaterThan(180); }); }); @@ -126,8 +127,8 @@ describe("a 3D render made while an import runs", () => { [0, false], [1, false], ]); - expect(await redOf(previewImagePath(previews[0].id))).toBeGreaterThan(180); - expect(await redOf(plateThumbPath(plate.id))).toBeGreaterThan(180); + expect(await redOf(previewImagePath(previews[0].id, await printAssetHome(print.id)))).toBeGreaterThan(180); + expect(await redOf(plateThumbPath(plate.id, await plateAssetHome(plate.id)))).toBeGreaterThan(180); expect((await prisma.plate.findUnique({ where: { id: plate.id } }))!.thumbGenerated).toBe(false); // A render that finishes after the cover landed doesn't replace it. @@ -137,7 +138,7 @@ describe("a 3D render made while an import runs", () => { .attach("file", await png(10), "render.png"); expect(late.status).toBe(200); expect(late.body.print.preview_images).toHaveLength(2); - expect(await redOf(plateThumbPath(plate.id))).toBeGreaterThan(180); + expect(await redOf(plateThumbPath(plate.id, await plateAssetHome(plate.id)))).toBeGreaterThan(180); }); }); @@ -156,10 +157,12 @@ describe("tag tiles", () => { } // The newest has no picture at all; the other eleven have a rendered plate. for (const { plateId } of prints.slice(0, 11)) { - await fs.mkdir(path.dirname(plateThumbPath(plateId)), { recursive: true }); - await sharp(await png(200)) - .jpeg() - .toFile(plateThumbPath(plateId)); + await saveThumbFromBytes( + plateId, + await sharp(await png(200)) + .jpeg() + .toBuffer(), + ); } const tile = async () => ( diff --git a/backend/tests/snapmakerImport.test.ts b/backend/tests/snapmakerImport.test.ts index 78b2e4e..e2d8a23 100644 --- a/backend/tests/snapmakerImport.test.ts +++ b/backend/tests/snapmakerImport.test.ts @@ -6,6 +6,7 @@ import { importPrintFromUrl } from "../src/services/importService"; import { prisma } from "../src/db"; import { getSnapmakerConnection, setUserSnapmakerToken } from "../src/services/snapmakerSessionService"; import { plateThumbPath } from "../src/services/printService"; +import { plateAssetHome } from "../src/services/modelAssets"; // Snapmaker Space import end to end: details and signed downloads in the shapes its site gets, fetch mocked. @@ -36,7 +37,7 @@ const solid = (r: number, g: number, b: number) => .toBuffer(); /** The thumbnail's average colour, which survives the JPEG re-encode. */ async function thumbColour(plateId: string): Promise { - const { channels } = await sharp(plateThumbPath(plateId)).stats(); + const { channels } = await sharp(plateThumbPath(plateId, await plateAssetHome(plateId))).stats(); return channels.slice(0, 3).map((c) => Math.round(c.mean)); } diff --git a/docs/features/storage.md b/docs/features/storage.md index 711939b..5db9f56 100644 --- a/docs/features/storage.md +++ b/docs/features/storage.md @@ -19,6 +19,24 @@ Every user's files sit in a folder of their own first, so a file ends up at, for `/Props/Workshop/Cable clip/Cable clip.3mf`. The layout can also be typed as text, such as `{category-or-folder}/{model}/{filename}`. +Everything else that belongs to a model sits beside its files: supporting files and prepared prints next to the model +files, and its pictures (gallery and description images, thumbnails and renders) in an `Images` folder next to them: + +``` +/Props/Workshop/Cable clip/ + Cable clip.3mf + Assembly.pdf + Images/ + .jpg + Description/ + Thumbnails/ + Renders/ +``` + +With a layout that keeps several models in one folder, such as `{category-or-folder}/{model} - {filename}`, the +pictures folder takes the model's name too: `Cable clip - Images`. Installations from before this layout have their +pictures and supporting files moved in beside the model files in the background the first time they start. + Change the layout later and existing files are moved to match in the background, with progress shown as they go. Renaming a model renames its files too, so the folders stay readable. **Browse files** shows where each model's files are now. diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index 2c2348f..00effc9 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -115,12 +115,18 @@ export type StorageFolder = { label: string | null; files: number; bytes: number; + /** A model's pictures folder, beside its files. */ + pictures: boolean; }; +/** "plate" is a model file, "file" a supporting or prepared one; the rest are the model's pictures. */ +export type StorageFileKind = "plate" | "file" | "image" | "description" | "thumb" | "render"; + export type StorageFile = { name: string; path: string; - plate_id: string; + kind: StorageFileKind; + plate_id: string | null; print_id: string; print_name: string; owner_id: string; diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index ca921da..f0ef4d0 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -720,7 +720,7 @@ "heading": "Storage Structure", "subtitle": "Choose how managed model files are organized on disk.", "templateHeading": "Folder structure", - "templateDesc": "The folders each model's files are saved in, and how the files are named. Every user's files sit in a folder of their own first, so a file ends up at e.g. /Props/Workshop/Cable clip/Cable clip.3mf. Two models can have the same name if they're in different categories, so the path must include Model ID, or both Category or folder and Model name; otherwise their files could land in the same folder.", + "templateDesc": "The folders each model's files are saved in, and how the files are named. Every user's files sit in a folder of their own first, so a file ends up at e.g. /Props/Workshop/Cable clip/Cable clip.3mf. Two models can have the same name if they're in different categories, so the path must include Model ID, or both Category or folder and Model name; otherwise their files could land in the same folder. A model's supporting files go beside its model files, and its pictures in an Images folder next to them.", "templateLabel": "Template", "save": "Save & move files", "saving": "Saving…", @@ -1368,6 +1368,14 @@ "folderSummary_one": "{{count}} file · {{size}}", "folderSummary_other": "{{count}} files · {{size}}", "ofModel": "model \"{{name}}\"", + "kind": { + "plate": "Model file", + "file": "Supporting file", + "image": "Image", + "description": "Description image", + "thumb": "Thumbnail", + "render": "Render" + }, "location": { "source": "At its source", "missing": "Missing" diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index b497f65..1aee204 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -720,7 +720,7 @@ "heading": "Estructura de almacenamiento", "subtitle": "Elige cómo se organizan en disco los archivos de modelos gestionados.", "templateHeading": "Estructura de carpetas", - "templateDesc": "Las carpetas en las que se guardan los archivos de cada modelo y cómo se nombran. Los archivos de cada usuario se guardan primero en una carpeta propia, así que un archivo acaba en, por ejemplo, /Props/Workshop/Cable clip/Cable clip.3mf. Dos modelos pueden tener el mismo nombre si están en categorías distintas, así que la ruta debe incluir el ID del modelo, o bien la categoría o carpeta y el nombre del modelo; de lo contrario, sus archivos podrían acabar en la misma carpeta.", + "templateDesc": "Las carpetas en las que se guardan los archivos de cada modelo y cómo se nombran. Los archivos de cada usuario se guardan primero en una carpeta propia, así que un archivo acaba en, por ejemplo, /Props/Workshop/Cable clip/Cable clip.3mf. Dos modelos pueden tener el mismo nombre si están en categorías distintas, así que la ruta debe incluir el ID del modelo, o bien la categoría o carpeta y el nombre del modelo; de lo contrario, sus archivos podrían acabar en la misma carpeta. Los archivos adjuntos de un modelo se guardan junto a sus archivos, y sus imágenes en una carpeta Images a su lado.", "templateLabel": "Plantilla", "save": "Guardar y mover archivos", "saving": "Guardando…", @@ -1368,6 +1368,14 @@ "folderSummary_one": "{{count}} archivo · {{size}}", "folderSummary_other": "{{count}} archivos · {{size}}", "ofModel": "modelo «{{name}}»", + "kind": { + "plate": "Archivo del modelo", + "file": "Archivo adjunto", + "image": "Imagen", + "description": "Imagen de la descripción", + "thumb": "Miniatura", + "render": "Render" + }, "location": { "source": "En su origen", "missing": "Falta" diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index 85d18ba..c2c99d7 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -720,7 +720,7 @@ "heading": "Structure de stockage", "subtitle": "Choisissez l’organisation sur le disque des fichiers de modèles gérés.", "templateHeading": "Structure des dossiers", - "templateDesc": "Les dossiers dans lesquels sont enregistrés les fichiers de chaque modèle, et la façon dont ils sont nommés. Les fichiers de chaque utilisateur sont d’abord rangés dans un dossier qui lui est propre ; un fichier se retrouve donc par exemple dans /Props/Workshop/Cable clip/Cable clip.3mf. Deux modèles peuvent porter le même nom s’ils se trouvent dans des catégories différentes : le chemin doit donc inclure l’ID du modèle, ou bien la catégorie ou le dossier ainsi que le nom du modèle ; sans cela, leurs fichiers pourraient atterrir dans le même dossier.", + "templateDesc": "Les dossiers dans lesquels sont enregistrés les fichiers de chaque modèle, et la façon dont ils sont nommés. Les fichiers de chaque utilisateur sont d’abord rangés dans un dossier qui lui est propre ; un fichier se retrouve donc par exemple dans /Props/Workshop/Cable clip/Cable clip.3mf. Deux modèles peuvent porter le même nom s’ils se trouvent dans des catégories différentes : le chemin doit donc inclure l’ID du modèle, ou bien la catégorie ou le dossier ainsi que le nom du modèle ; sans cela, leurs fichiers pourraient atterrir dans le même dossier. Les fichiers annexes d’un modèle sont rangés à côté de ses fichiers, et ses images dans un dossier Images juste à côté.", "templateLabel": "Gabarit", "save": "Enregistrer et déplacer les fichiers", "saving": "Enregistrement…", @@ -1368,6 +1368,14 @@ "folderSummary_one": "{{count}} fichier · {{size}}", "folderSummary_other": "{{count}} fichiers · {{size}}", "ofModel": "modèle « {{name}} »", + "kind": { + "plate": "Fichier du modèle", + "file": "Fichier annexe", + "image": "Image", + "description": "Image de la description", + "thumb": "Miniature", + "render": "Rendu" + }, "location": { "source": "À sa source", "missing": "Manquant" diff --git a/frontend/src/i18n/locales/lt.json b/frontend/src/i18n/locales/lt.json index 7d8c2b0..94b382e 100644 --- a/frontend/src/i18n/locales/lt.json +++ b/frontend/src/i18n/locales/lt.json @@ -727,7 +727,7 @@ "heading": "Saugojimo struktūra", "subtitle": "Pasirinkite, kaip valdomi modelių failai tvarkomi diske.", "templateHeading": "Aplankų struktūra", - "templateDesc": "Aplankai, į kuriuos saugomi kiekvieno modelio failai, ir kaip failai pavadinami. Kiekvieno naudotojo failai pirmiausia būna jo paties aplanke, tad failas atsiduria, pvz., /Props/Workshop/Cable clip/Cable clip.3mf. Skirtingose kategorijose du modeliai gali turėti tą patį pavadinimą, todėl kelyje turi būti Modelio ID arba ir Kategorija ar aplankas, ir Modelio pavadinimas – kitaip jų failai galėtų patekti į tą patį aplanką.", + "templateDesc": "Aplankai, į kuriuos saugomi kiekvieno modelio failai, ir kaip failai pavadinami. Kiekvieno naudotojo failai pirmiausia būna jo paties aplanke, tad failas atsiduria, pvz., /Props/Workshop/Cable clip/Cable clip.3mf. Skirtingose kategorijose du modeliai gali turėti tą patį pavadinimą, todėl kelyje turi būti Modelio ID arba ir Kategorija ar aplankas, ir Modelio pavadinimas – kitaip jų failai galėtų patekti į tą patį aplanką. Papildomi modelio failai saugomi šalia jo failų, o paveikslėliai – greta esančiame aplanke Images.", "templateLabel": "Šablonas", "save": "Išsaugoti ir perkelti failus", "saving": "Saugoma…", @@ -1403,6 +1403,14 @@ "folderSummary_many": "{{count}} failo · {{size}}", "folderSummary_other": "{{count}} failų · {{size}}", "ofModel": "modelis „{{name}}“", + "kind": { + "plate": "Modelio failas", + "file": "Papildomas failas", + "image": "Paveikslėlis", + "description": "Aprašymo paveikslėlis", + "thumb": "Miniatiūra", + "render": "Atvaizdas" + }, "location": { "source": "Šaltinyje", "missing": "Trūksta" diff --git a/frontend/src/pages/AdminSettingsPage/StorageBrowserDialog.tsx b/frontend/src/pages/AdminSettingsPage/StorageBrowserDialog.tsx index 6993cbd..730783b 100644 --- a/frontend/src/pages/AdminSettingsPage/StorageBrowserDialog.tsx +++ b/frontend/src/pages/AdminSettingsPage/StorageBrowserDialog.tsx @@ -21,9 +21,12 @@ import useMediaQuery from "@mui/material/useMediaQuery"; import { useTheme } from "@mui/material/styles"; import CloseIcon from "@mui/icons-material/Close"; import FolderIcon from "@mui/icons-material/Folder"; +import ImageOutlinedIcon from "@mui/icons-material/ImageOutlined"; import InsertDriveFileOutlinedIcon from "@mui/icons-material/InsertDriveFileOutlined"; import PersonOutlineIcon from "@mui/icons-material/PersonOutline"; -import { adminApi, type StorageFile, type StorageListing } from "../../api/admin"; +import PhotoLibraryIcon from "@mui/icons-material/PhotoLibrary"; +import ViewInArOutlinedIcon from "@mui/icons-material/ViewInArOutlined"; +import { adminApi, type StorageFile, type StorageFileKind, type StorageListing } from "../../api/admin"; import { UnauthorizedError } from "../../api/client"; import { readUser } from "../../utils/auth"; import { formatFileSize } from "../../utils/fileSize"; @@ -33,7 +36,17 @@ type Props = { onUnauthorized?: () => void; }; -/** The managed model files as they're laid out on disk now: a folder per user, then the storage structure. */ +const KIND_ICONS: Record = { + plate: ViewInArOutlinedIcon, + file: InsertDriveFileOutlinedIcon, + image: ImageOutlinedIcon, + description: ImageOutlinedIcon, + thumb: ImageOutlinedIcon, + render: ImageOutlinedIcon, +}; + +/** The managed model files, and the pictures and supporting files beside them, as they're laid out on disk now: a + * folder per user, then the storage structure. */ export default function StorageBrowserDialog({ onClose, onUnauthorized }: Props) { const { t } = useTranslation(["app", "common"]); const theme = useTheme(); @@ -122,7 +135,13 @@ export default function StorageBrowserDialog({ onClose, onUnauthorized }: Props) {listing.folders.map((entry) => ( setFolder(entry.path)}> - {atTop ? : } + {atTop ? ( + + ) : entry.pictures ? ( + + ) : ( + + )} ( navigate(`/models/${file.print_id}`) : undefined} @@ -151,14 +170,19 @@ export default function StorageBrowserDialog({ onClose, onUnauthorized }: Props) function FileRow({ file, onOpen }: { file: StorageFile; onOpen?: () => void }) { const { t } = useTranslation("app"); + const Icon = KIND_ICONS[file.kind]; const content = ( <> - + {file.location !== "managed" && (