From b66893df7dec6735298558fa64fbdc63ec9a69a3 Mon Sep 17 00:00:00 2001 From: Sushil Muthuvelkumar <123318620+sushilm20@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:30:29 -0400 Subject: [PATCH 1/6] Prints: a 3D print library per workspace, with folders and groups Each file lives once, in a folder, and belongs to smart groups worked out from its details (season and robot, subsystem, material, print status) and to any collections the team makes, without copies. The explorer shows folders and groups side by side; picks combine ("Intake + PETG + To print"). - Upload by button or drag and drop (STL, 3MF, STEP/STP, OBJ, G-code), rename, move (drag or "Move to"), delete with confirmation, folder delete that says how many files, single and ZIP downloads, search, list and grid views, sorting by name, date or size. - 3D preview for STL/3MF/OBJ with three.js (lazy), and browser-drawn thumbnails on grid tiles; STEP and G-code show an icon. - Details: who added it and when, size, notes, robot/season, the CAD part it prints (its section is the subsystem and its quantity the copies needed), material, status and copies printed, collections, history. - Bytes on the VM's disk under PRINTS_DIR at random keys; per-file limit, per-workspace quota checked inside the insert, and a free-disk reserve. - manage_prints role permission (granted wherever manage_cad is); members view and download. Every change goes to print_activity. - fileControlKitPrint() files a Control Kit print under "Season / Robot / Control Kit" for the import to call. --- migrations/versions/155-prints-library.sql | 99 +++ server.ts | 13 +- server/__tests__/prints.test.ts | 442 ++++++++++ server/prints.ts | 950 +++++++++++++++++++++ server/zipStream.ts | 126 +++ src/App.tsx | 7 +- src/components/PrintViewer.tsx | 133 +++ src/components/printModel.ts | 102 +++ src/i18n/index.ts | 1 + src/modern/__tests__/prints.test.tsx | 204 +++++ src/modern/nav.ts | 4 +- src/modern/pages/prints/PrintDetails.tsx | 229 +++++ src/modern/pages/prints/PrintsPage.tsx | 651 ++++++++++++++ src/modern/pages/prints/PrintsSidebar.tsx | 193 +++++ src/modern/pages/prints/printsUi.tsx | 153 ++++ src/modern/pages/prints/usePrints.ts | 131 +++ src/types/prints.ts | 264 ++++++ src/types/teamModules.ts | 2 +- src/utils/__tests__/publicRoutes.test.ts | 2 +- src/utils/publicRoutes.ts | 2 +- 20 files changed, 3699 insertions(+), 9 deletions(-) create mode 100644 migrations/versions/155-prints-library.sql create mode 100644 server/__tests__/prints.test.ts create mode 100644 server/prints.ts create mode 100644 server/zipStream.ts create mode 100644 src/components/PrintViewer.tsx create mode 100644 src/components/printModel.ts create mode 100644 src/modern/__tests__/prints.test.tsx create mode 100644 src/modern/pages/prints/PrintDetails.tsx create mode 100644 src/modern/pages/prints/PrintsPage.tsx create mode 100644 src/modern/pages/prints/PrintsSidebar.tsx create mode 100644 src/modern/pages/prints/printsUi.tsx create mode 100644 src/modern/pages/prints/usePrints.ts create mode 100644 src/types/prints.ts diff --git a/migrations/versions/155-prints-library.sql b/migrations/versions/155-prints-library.sql new file mode 100644 index 000000000..3820d3943 --- /dev/null +++ b/migrations/versions/155-prints-library.sql @@ -0,0 +1,99 @@ +-- 155-prints-library.sql — the Prints library (server/prints.ts, +-- src/types/prints.ts): a team's 3D print files, organised two ways at once. +-- print_folders: where a file lives. parent_id NULL is the library root. +-- Names are unique per parent, ignoring case. +-- print_files: one row per file; the bytes live on the VM's disk under +-- PRINTS_DIR at storage_key (never derived from a user-typed name), with +-- an optional PNG thumbnail at thumb_key. Smart groups are computed from +-- these details: robot_id (its season comes from the robot) or season, +-- cad_part_id (its assembly section is the subsystem and its quantity the +-- copies needed unless set here), material, status. source is 'upload' +-- or 'control-kit'; ck_part_key is the Control Kit identity it prints. +-- print_collections / print_collection_files: groups a team makes itself +-- ("Comp spares", "Worlds kit"). A file belongs to any number of them +-- without being copied. +-- print_activity: every change, never updated. +-- manage_prints: the role permission for uploading, moving, renaming, +-- deleting and editing details. Every role that can manage CAD gets +-- it (admins have "*"); members without it view and download. +-- New tables only; nothing existing changes. +BEGIN; + +CREATE TABLE IF NOT EXISTS print_folders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + team_id INTEGER NOT NULL, + parent_id INTEGER, + name TEXT NOT NULL, + created_by INTEGER, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_print_folders_name ON print_folders (team_id, COALESCE(parent_id, 0), name COLLATE NOCASE); + +CREATE TABLE IF NOT EXISTS print_files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + team_id INTEGER NOT NULL, + folder_id INTEGER, + name TEXT NOT NULL, + kind TEXT NOT NULL, + size INTEGER NOT NULL, + sha256 TEXT NOT NULL, + storage_key TEXT NOT NULL, + thumb_key TEXT, + notes TEXT NOT NULL DEFAULT '', + robot_id INTEGER, + season INTEGER, + cad_part_id INTEGER, + subsystem TEXT, + material TEXT, + status TEXT NOT NULL DEFAULT 'to_print', + copies_needed INTEGER, + copies_printed INTEGER NOT NULL DEFAULT 0, + source TEXT NOT NULL DEFAULT 'upload', + ck_part_key TEXT, + uploaded_by INTEGER, + uploaded_by_name TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_print_files_name ON print_files (team_id, COALESCE(folder_id, 0), name COLLATE NOCASE); +CREATE INDEX IF NOT EXISTS idx_print_files_team ON print_files (team_id, folder_id); + +CREATE TABLE IF NOT EXISTS print_collections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + team_id INTEGER NOT NULL, + name TEXT NOT NULL, + created_by INTEGER, + created_at TEXT NOT NULL +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_print_collections_name ON print_collections (team_id, name COLLATE NOCASE); + +CREATE TABLE IF NOT EXISTS print_collection_files ( + collection_id INTEGER NOT NULL, + file_id INTEGER NOT NULL, + team_id INTEGER NOT NULL, + added_at TEXT NOT NULL, + PRIMARY KEY (collection_id, file_id) +); +CREATE INDEX IF NOT EXISTS idx_print_collection_files_file ON print_collection_files (file_id); + +CREATE TABLE IF NOT EXISTS print_activity ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + team_id INTEGER NOT NULL, + file_id INTEGER, + folder_id INTEGER, + member_id INTEGER, + member_name TEXT, + action TEXT NOT NULL, + detail TEXT NOT NULL DEFAULT '{}', + at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_print_activity_team ON print_activity (team_id, id); +CREATE INDEX IF NOT EXISTS idx_print_activity_file ON print_activity (file_id); + +UPDATE roles SET permissions = json_insert(permissions, '$[#]', 'manage_prints') +WHERE json_valid(permissions) + AND EXISTS (SELECT 1 FROM json_each(permissions) WHERE value = 'manage_cad') + AND NOT EXISTS (SELECT 1 FROM json_each(permissions) WHERE value = 'manage_prints'); + +COMMIT; diff --git a/server.ts b/server.ts index 4a728cdd7..e5320f130 100644 --- a/server.ts +++ b/server.ts @@ -96,6 +96,7 @@ import { registerNotebookReviewRoutes } from "./server/notebookReviews.js"; import { registerChecklistRoutes } from "./server/checklists.js"; import { createControlKitLink, deleteGrantsSql } from "./server/controlKitLink.js"; import { createCadRobots, defaultRobotId, teamRobotId } from "./server/cadRobots.js"; +import { createPrints, PRINTS_WORKSPACE_DELETE } from "./server/prints.js"; import { registerStickyNoteRoutes } from "./server/stickyNotes.js"; import { isRefType, resolveRef, type RefDeps } from "./server/refs.js"; import { createRefsFilter, refsBlock, stripModelRefs, validateRefs } from "./server/brunoRefs.js"; @@ -2024,6 +2025,7 @@ const ROLE_PERMISSIONS = [ { key: "manage_checklists", label: "Manage checklists" }, { key: "manage_documentation", label: "Manage documentation" }, { key: "manage_cad", label: "Manage CAD robots and part choices" }, + { key: "manage_prints", label: "Upload and organize 3D prints" }, { key: "edit_notebook", label: "Edit notebook pages" }, { key: "organize_notebook", label: "Organize notebooks" }, { key: "delete_notebook", label: "Delete notebook items" }, @@ -2096,14 +2098,14 @@ async function ensureRolesSeeded(teamId: number | null | undefined) { if (!names.has("Member")) { const memberRole = (await dbRun( "INSERT INTO roles (team_id, name, color, permissions, position, is_system) VALUES (?,?,?,?,?,1)", - teamId, "Member", "#71717A", JSON.stringify(["view_ai", "manage_calendar", "manage_communications", "manage_tasks", "manage_outreach", "manage_checklists", "edit_notebook", "organize_notebook", "manage_cad"]), 1 + teamId, "Member", "#71717A", JSON.stringify(["view_ai", "manage_calendar", "manage_communications", "manage_tasks", "manage_outreach", "manage_checklists", "edit_notebook", "organize_notebook", "manage_cad", "manage_prints"]), 1 )) as any; memberRoleId = Number(memberRole.lastInsertRowid); } if (!names.has("Verified Member")) { await dbRun( "INSERT INTO roles (team_id, name, color, permissions, position, is_system) VALUES (?,?,?,?,?,1)", - teamId, "Verified Member", "#22C55E", JSON.stringify(["view_ai", "manage_inventory", "manage_calendar", "manage_tasks", "manage_documentation", "manage_communications", "manage_outreach", "manage_attendance", "manage_code", "manage_budget", "manage_checklists", "edit_notebook", "organize_notebook", "manage_cad"]), 2 + teamId, "Verified Member", "#22C55E", JSON.stringify(["view_ai", "manage_inventory", "manage_calendar", "manage_tasks", "manage_documentation", "manage_communications", "manage_outreach", "manage_attendance", "manage_code", "manage_budget", "manage_checklists", "edit_notebook", "organize_notebook", "manage_cad", "manage_prints"]), 2 ); } @@ -2402,6 +2404,8 @@ async function startServer() { // are registered with the CAD routes, behind the guard. // CAD by season and robot; Control Kit reads the part choices made in it. const cadRobots = createCadRobots({ dbGet, dbAll, dbRun, dbBatchResults, requireAuth: requireAuth as any, hasPerm }); + // The Prints library (server/prints.ts): 3D print files on the VM's disk, in folders and groups. + const prints = createPrints({ dbGet, dbAll, dbRun, dbBatchResults, requireAuth: requireAuth as any, hasPerm, env: process.env }); const controlKit = createControlKitLink({ dbGet, dbAll, dbRun, dbBatchResults, env: process.env, decisionsFor: cadRobots.decisionsFor, requireAuth: requireAuth as any, authEmail: (auth) => authEmail(auth as any), @@ -5419,6 +5423,9 @@ async function startServer() { { sql: "DELETE FROM cad_parts WHERE team_id = ?", args: [teamId] }, { sql: "DELETE FROM cad_imports WHERE team_id = ?", args: [teamId] }, { sql: "DELETE FROM cad_docs WHERE team_id = ?", args: [teamId] }, + // The Prints library: rows here, bytes on disk after the batch commits. + ...PRINTS_WORKSPACE_DELETE.others.map((sql) => ({ sql, args: [teamId] })), + { sql: PRINTS_WORKSPACE_DELETE.files, args: [teamId] }, { sql: "DELETE FROM checkin_sessions WHERE team_id = ?", args: [teamId] }, // References teams without a cascade. { sql: "DELETE FROM call_moderation_log WHERE team_id = ?", args: [teamId] }, @@ -5453,6 +5460,7 @@ async function startServer() { const deletedIds = (sql: string) => (results[stmts.findIndex((s) => s.sql === sql)]?.rows || []).map((row: any) => Number(row.id)); // Remove the private object copies only after the database deletion commits. for (const fileId of deletedIds(FILES_STMT)) await deleteStoredRow(fileId); + await prints.removeStored(results[stmts.findIndex((s) => s.sql === PRINTS_WORKSPACE_DELETE.files)]?.rows || []).catch((e) => console.error("Deleting print files failed:", e?.message || e)); for (const imageId of deletedIds(IMAGES_STMT)) await deleteMessageImageRow(imageId).catch((e) => console.error("Deleting a chat image failed:", e?.message || e)); for (const id of memberIds) if (id !== keepMemberId) disconnectMember(teamId, id); } @@ -16437,6 +16445,7 @@ NOTES>>>`; return auth.teamId as number; }; cadRobots.registerRoutes(app); + prints.registerRoutes(app); // Lists take ?robot= to show one robot's rows; a new row goes to the robot it names, else the team's latest main robot. const cadRobotFilter = async (teamId: number, q: unknown) => { if (q === undefined || q === "") return { sql: "", args: [] as any[] }; diff --git a/server/__tests__/prints.test.ts b/server/__tests__/prints.test.ts new file mode 100644 index 000000000..919871115 --- /dev/null +++ b/server/__tests__/prints.test.ts @@ -0,0 +1,442 @@ +// @vitest-environment node +/** + * The Prints library (migration 155, server/prints.ts): names and paths, path + * traversal, the per-file limit, the workspace quota and the disk reserve, + * role permissions, folders (move/rename edge cases, delete with files), + * groups (status, collections), downloads and ZIPs, the activity record, and + * filing a Control Kit print from inside the server. + */ +import { describe, it, expect, beforeAll, afterAll, vi } from "vitest"; +import { mkdtempSync, readdirSync, statSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { inflateRawSync } from "node:zlib"; +import { PassThrough } from "node:stream"; +import { writeFileSync } from "node:fs"; +import { startTestServer, seedTeam, seedMember, type TestServer } from "./helpers/testServer"; +import { withSession } from "./helpers/session"; +import { cleanName, splitPath, uniqueName, printKind, safeName, matchesFilters, groupLine, folderAndBelow, type PrintFile } from "../../src/types/prints"; +import { PrintStore, createPrints, controlKitPrintPath, printLimits } from "../prints"; +import { crc32, writeZip, uniqueZipNames } from "../zipStream"; + +vi.setConfig({ testTimeout: 60_000 }); + +let t: TestServer; +let dir = ""; +let team = 0, other = 0; +let admin = "", student = "", lead = "", outsider = ""; +const MB = 1024 * 1024; + +/** A tiny ASCII STL. */ +const stl = (name = "cube") => Buffer.from(`solid ${name}\nfacet normal 0 0 1\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\nendsolid ${name}\n`); + +async function uploadFile(session: string, opts: { name: string; bytes?: Buffer; folderId?: number | null; field?: string; nameField?: string; thumb?: Buffer }) { + const form = new FormData(); + if (opts.nameField !== undefined) form.append("name", opts.nameField); + if (opts.folderId != null) form.append("folderId", String(opts.folderId)); + form.append(opts.field ?? "file", new Blob([opts.bytes ?? stl()]), opts.name); + if (opts.thumb) form.append("thumbnail", new Blob([opts.thumb], { type: "image/png" }), "thumb.png"); + const headers = withSession(new Headers(), session); + const res = await fetch(`${t.base}/api/prints/files`, { method: "POST", body: form, headers }); + return { status: res.status, body: await res.json().catch(() => null) }; +} +const get = (path: string, session: string) => t.api(path, { session }); +const del = (path: string, session: string) => t.api(path, { method: "DELETE", session }); +const lib = async (session = admin) => (await get("/api/prints/library", session)).body; +const raw = async (path: string, session: string) => { + const res = await fetch(`${t.base}${path}`, { headers: withSession(new Headers(), session) }); + return { status: res.status, headers: res.headers, bytes: Buffer.from(await res.arrayBuffer()) }; +}; +const storedFiles = (): string[] => { + const out: string[] = []; + const walk = (d: string) => { for (const f of readdirSync(d)) { const p = join(d, f); statSync(p).isDirectory() ? walk(p) : out.push(p); } }; + if (existsSync(join(dir, "files"))) walk(join(dir, "files")); + return out; +}; + +/** Read a ZIP's central directory and inflate each entry, checking its CRC. */ +function unzip(buf: Buffer): Record { + const end = buf.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06])); + expect(end).toBeGreaterThan(-1); + const count = buf.readUInt16LE(end + 10); + let p = buf.readUInt32LE(end + 16); + const out: Record = {}; + for (let i = 0; i < count; i++) { + expect(buf.readUInt32LE(p)).toBe(0x02014b50); + const crc = buf.readUInt32LE(p + 16), csize = buf.readUInt32LE(p + 20), nlen = buf.readUInt16LE(p + 28), elen = buf.readUInt16LE(p + 30), clen = buf.readUInt16LE(p + 32); + const local = buf.readUInt32LE(p + 42); + const name = buf.subarray(p + 46, p + 46 + nlen).toString("utf8"); + const lnlen = buf.readUInt16LE(local + 26), lelen = buf.readUInt16LE(local + 28); + const data = inflateRawSync(buf.subarray(local + 30 + lnlen + lelen, local + 30 + lnlen + lelen + csize)); + expect(crc32(data)).toBe(crc); + out[name] = data; + p += 46 + nlen + elen + clen; + } + return out; +} + +beforeAll(async () => { + dir = mkdtempSync(join(tmpdir(), "cp-prints-store-")); + t = await startTestServer("cp-prints-", { PRINTS_DIR: dir, PRINTS_MAX_FILE_MB: "1", PRINTS_QUOTA_MB: "3", PRINTS_MIN_FREE_MB: "0" }); + team = await seedTeam(t.db, "HORS"); + other = await seedTeam(t.db, "Other"); + const adminId = await seedMember(t.db, team, "Ada", "ada@test.local", "admin"); + const studentId = await seedMember(t.db, team, "Stu", "stu@test.local"); + const leadId = await seedMember(t.db, team, "Lee", "lee@test.local"); + const outsiderId = await seedMember(t.db, other, "Otto", "otto@test.local", "admin"); + const role = Number((await t.db.execute({ sql: "INSERT INTO roles (team_id, name, color, permissions, position, is_system) VALUES (?, 'Print lead', '#ffc700', '[\"manage_prints\"]', 5, 0)", args: [team] })).lastInsertRowid); + await t.db.execute({ sql: "INSERT INTO member_roles (member_id, role_id) VALUES (?, ?)", args: [leadId, role] }); + admin = await t.session(adminId); + student = await t.session(studentId); + lead = await t.session(leadId); + outsider = await t.session(outsiderId); +}, 120_000); +afterAll(async () => { await t?.stop(); }); + +describe("names, paths and storage keys (pure)", () => { + it("refuses names that could leave their folder or break an OS", () => { + for (const bad of ["", " ", ".", "..", "../x.stl", "a/b.stl", "a\\b.stl", "C:evil.stl", "x?.stl", "bad\u0000.stl", "trailing.", "x".repeat(121)]) expect(cleanName(bad)).toBeNull(); + expect(cleanName(" Intake roller v2.stl ")).toBe("Intake roller v2.stl"); + expect(cleanName("2025–26 DECODE")).toBe("2025–26 DECODE"); + }); + it("splits a path into safe segments, and refuses traversal", () => { + expect(splitPath("Season / Robot / Control Kit")).toEqual(["Season", "Robot", "Control Kit"]); + expect(splitPath(["a", "..", "b"])).toBeNull(); + expect(splitPath("")).toBeNull(); + expect(safeName("Arm/Claw: v2")).toBe("Arm-Claw- v2"); + expect(safeName("..")).toBe("Untitled"); + expect(controlKitPrintPath(2025, "Bot/1")).toEqual(["2025–26 DECODE", "Bot-1", "Control Kit"]); + }); + it("numbers a taken name, keeping the extension", () => { + expect(uniqueName("a.stl", new Set(["a.stl"]))).toBe("a (2).stl"); + expect(uniqueName("a (2).stl", new Set(["a (2).stl", "a.stl"]))).toBe("a (3).stl"); + expect(uniqueName("A.STL", new Set(["b.stl"]))).toBe("A.STL"); + }); + it("knows the print formats by extension", () => { + expect(["x.stl", "x.3MF", "x.step", "x.stp", "x.obj", "x.gcode", "x.bgcode"].map(printKind)).toEqual(["stl", "3mf", "step", "step", "obj", "gcode", "gcode"]); + expect(printKind("x.exe")).toBeNull(); + expect(printKind("stl")).toBeNull(); + }); + it("never turns anything but a random key into a disk path", () => { + const s = new PrintStore(mkdtempSync(join(tmpdir(), "cp-prints-keys-"))); + const k = s.newKey(7); + expect(s.pathOf(k)).toContain(join("files", "7")); + for (const bad of ["../etc/passwd", "7/../../x", "7/abc", "/7/" + "a".repeat(32), "7\\" + "a".repeat(32)]) expect(() => s.pathOf(bad)).toThrow(); + }); + it("reads the limits from the environment", () => { + expect(printLimits({})).toEqual({ maxFile: 100 * MB, quota: 2048 * MB, minFree: 5120 * MB }); + expect(printLimits({ PRINTS_MAX_FILE_MB: "50", PRINTS_QUOTA_MB: "-1", PRINTS_MIN_FREE_MB: "0" })).toEqual({ maxFile: 50 * MB, quota: 2048 * MB, minFree: 0 }); + }); + it("combines group filters and counts what's left to print", () => { + const f = (o: Partial): PrintFile => ({ id: 1, folderId: null, name: "x.stl", kind: "stl", size: 1, createdAt: "", updatedAt: "", uploadedBy: null, notes: "", robotId: null, season: null, cadPart: null, subsystem: null, subsystemFrom: null, material: null, status: "to_print", copiesNeeded: null, copiesNeededFrom: null, copiesPrinted: 0, collections: [], hasThumb: false, source: "upload", ...o }); + const files = [f({ subsystem: "Intake", material: "PETG" }), f({ subsystem: "intake", material: "PLA" }), f({ subsystem: "Intake", material: "PETG", status: "printed" }), f({ subsystem: "Lift", material: "PETG", status: "failed" })]; + const hit = files.filter((x) => matchesFilters(x, { subsystem: "Intake", material: "PETG", status: "to_print" })); + expect(hit).toHaveLength(1); + expect(groupLine("Intake", files.filter((x) => matchesFilters(x, { subsystem: "INTAKE" })))).toBe("Intake · 3 files · 2 still to print"); + expect(matchesFilters(f({ name: "Roller hub.stl" }), { query: "hub roll" })).toBe(true); + expect(folderAndBelow([{ id: 1, parentId: null, name: "a", createdAt: "", updatedAt: "" }, { id: 2, parentId: 1, name: "b", createdAt: "", updatedAt: "" }, { id: 3, parentId: 2, name: "c", createdAt: "", updatedAt: "" }], 2)).toEqual(new Set([2, 3])); + }); + it("writes a ZIP any unzipper reads, with UTF-8 names and unique paths", async () => { + const d = mkdtempSync(join(tmpdir(), "cp-zip-")); + writeFileSync(join(d, "a"), "hello"); + writeFileSync(join(d, "b"), Buffer.alloc(200_000, 7)); + const out = new PassThrough(); + const chunks: Buffer[] = []; + out.on("data", (c) => chunks.push(c)); + const names = uniqueZipNames(["2025–26/a.stl", "2025–26/A.stl", "b.gcode"]); + expect(names).toEqual(["2025–26/a.stl", "2025–26/A (2).stl", "b.gcode"]); + await writeZip(out, [{ name: names[0], path: join(d, "a"), mtime: new Date() }, { name: names[2], path: join(d, "b"), mtime: new Date() }]); + const files = unzip(Buffer.concat(chunks)); + expect(files["2025–26/a.stl"].toString()).toBe("hello"); + expect(files["b.gcode"].length).toBe(200_000); + }); +}); + +describe("permissions", () => { + it("lets every member view, and only manage_prints change", async () => { + expect((await get("/api/prints/library", student)).status).toBe(200); + expect((await lib(student)).canEdit).toBe(false); + expect((await lib(lead)).canEdit).toBe(true); + expect((await uploadFile(student, { name: "nope.stl" })).status).toBe(403); + expect((await t.post("/api/prints/folders", { name: "Nope" }, student)).status).toBe(403); + expect((await t.post("/api/prints/collections", { name: "Nope" }, student)).status).toBe(403); + const up = await uploadFile(lead, { name: "lead.stl" }); + expect(up.status).toBe(200); + expect((await t.patch(`/api/prints/files/${up.body.id}`, { status: "printed" }, student)).status).toBe(403); + expect((await del(`/api/prints/files/${up.body.id}`, student)).status).toBe(403); + // A member can still download it. + const dl = await raw(`/api/prints/files/${up.body.id}/download`, student); + expect(dl.status).toBe(200); + expect(dl.bytes.equals(stl())).toBe(true); + expect(dl.headers.get("content-disposition")).toContain('filename="lead.stl"'); + await del(`/api/prints/files/${up.body.id}`, lead); + }); + it("keeps another workspace out: its ids are 404", async () => { + const up = await uploadFile(admin, { name: "ours.stl" }); + const folder = await t.post("/api/prints/folders", { name: "Ours" }, admin); + expect((await get(`/api/prints/files/${up.body.id}`, outsider)).status).toBe(404); + expect((await raw(`/api/prints/files/${up.body.id}/download`, outsider)).status).toBe(404); + expect((await t.patch(`/api/prints/files/${up.body.id}`, { name: "theirs.stl" }, outsider)).status).toBe(404); + expect((await del(`/api/prints/folders/${folder.body.id}`, outsider)).status).toBe(404); + expect((await raw(`/api/prints/zip?files=${up.body.id}`, outsider)).status).toBe(404); + expect((await uploadFile(outsider, { name: "x.stl", folderId: folder.body.id })).status).toBe(404); + expect((await t.post("/api/prints/files/bulk", { ids: [up.body.id], action: "delete" }, outsider)).status).toBe(404); + expect((await lib(outsider)).files).toEqual([]); + expect((await get(`/api/prints/files/${up.body.id}`, admin)).status).toBe(200); + await del(`/api/prints/files/${up.body.id}`, admin); + await del(`/api/prints/folders/${folder.body.id}`, admin); + }); +}); + +describe("uploads", () => { + it("stores bytes under a random key whatever the name says", async () => { + const before = storedFiles().length; + const sneaky = await uploadFile(admin, { name: "../../../escape.stl" }); + // The browser-style name is reduced to its last part by the form, or refused. + expect([200, 400]).toContain(sneaky.status); + const traversal = await uploadFile(admin, { name: "ok.stl", nameField: "../../escape.stl" }); + expect(traversal.status).toBe(400); + const after = storedFiles(); + expect(after.every((p) => p.startsWith(join(dir, "files")) && /[0-9a-f]{32}(\.thumb\.png)?$/.test(p))).toBe(true); + expect(existsSync(join(dir, "..", "escape.stl"))).toBe(false); + if (sneaky.status === 200) { expect(sneaky.body.name).not.toContain("/"); await del(`/api/prints/files/${sneaky.body.id}`, admin); } + expect(storedFiles().length).toBe(before); + }); + it("takes only print formats, and keeps UTF-8 names", async () => { + const bad = await uploadFile(admin, { name: "virus.exe" }); + expect(bad.status).toBe(400); + const empty = await uploadFile(admin, { name: "empty.stl", bytes: Buffer.alloc(0) }); + expect(empty.status).toBe(400); + const ok = await uploadFile(admin, { name: "x.3mf", nameField: "Rädchen – v2.3mf", bytes: Buffer.from("PK fake 3mf") }); + expect(ok.status).toBe(200); + expect(ok.body.name).toBe("Rädchen – v2.3mf"); + await del(`/api/prints/files/${ok.body.id}`, admin); + }); + it("refuses a file over the per-file limit", async () => { + const big = await uploadFile(admin, { name: "big.stl", bytes: Buffer.alloc(Math.round(1.2 * MB), 1) }); + expect(big.status).toBe(413); + expect(big.body.code).toBe("too_large"); + expect(readdirSync(join(dir, "tmp"))).toEqual([]); + }); + it("refuses an upload past the workspace quota, counting every file", async () => { + const ids: number[] = []; + for (let i = 0; i < 3; i++) { + const r = await uploadFile(admin, { name: `fill${i}.stl`, bytes: Buffer.alloc(Math.round(0.9 * MB), 2) }); + expect(r.status).toBe(200); + ids.push(r.body.id); + } + const over = await uploadFile(admin, { name: "over.stl", bytes: Buffer.alloc(Math.round(0.5 * MB), 3) }); + expect(over.status).toBe(413); + expect(over.body.code).toBe("quota"); + expect((await lib()).usage).toMatchObject({ quota: 3 * MB, maxFile: 1 * MB }); + const r = await t.post("/api/prints/files/bulk", { ids, action: "delete" }, admin); + expect(r.body.files).toBe(3); + expect((await uploadFile(admin, { name: "fits.stl", bytes: Buffer.alloc(Math.round(0.5 * MB), 3) })).status).toBe(200); + const fits = (await lib()).files.find((f: any) => f.name === "fits.stl"); + await del(`/api/prints/files/${fits.id}`, admin); + }); + it("numbers a second upload of the same name, and keeps a PNG thumbnail", async () => { + const png = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.alloc(40)]); + const a = await uploadFile(admin, { name: "Bracket.stl", thumb: png }); + const b = await uploadFile(admin, { name: "bracket.stl" }); + expect(a.body.name).toBe("Bracket.stl"); + expect(b.body).toMatchObject({ name: "bracket (2).stl", renamed: true }); + const th = await raw(`/api/prints/files/${a.body.id}/thumbnail`, student); + expect(th.status).toBe(200); + expect(th.headers.get("content-type")).toBe("image/png"); + expect((await raw(`/api/prints/files/${b.body.id}/thumbnail`, admin)).status).toBe(404); + await t.post("/api/prints/files/bulk", { ids: [a.body.id, b.body.id], action: "delete" }, admin); + }); +}); + +describe("folders, moves and renames", () => { + it("keeps names unique per folder, ignoring case", async () => { + const a = await t.post("/api/prints/folders", { name: "Intake" }, admin); + expect(a.status).toBe(200); + expect((await t.post("/api/prints/folders", { name: "intake" }, admin)).status).toBe(409); + expect((await t.post("/api/prints/folders", { name: "../x" }, admin)).status).toBe(400); + const inner = await t.post("/api/prints/folders", { name: "Intake", parentId: a.body.id }, admin); + expect(inner.status).toBe(200); + const deeper = await t.post("/api/prints/folders", { name: "Rollers", parentId: inner.body.id }, admin); + // A folder can't go inside itself or anything below it. + expect((await t.patch(`/api/prints/folders/${a.body.id}`, { parentId: a.body.id }, admin)).status).toBe(400); + expect((await t.patch(`/api/prints/folders/${a.body.id}`, { parentId: deeper.body.id }, admin)).status).toBe(400); + // Moving the inner "Intake" to the root collides with the outer one. + expect((await t.patch(`/api/prints/folders/${inner.body.id}`, { parentId: null }, admin)).status).toBe(409); + expect((await t.patch(`/api/prints/folders/${inner.body.id}`, { parentId: null, name: "Intake 2" }, admin)).status).toBe(200); + // Into a folder that doesn't exist: 404, nothing changes. + expect((await t.patch(`/api/prints/folders/${inner.body.id}`, { parentId: 999999 }, admin)).status).toBe(404); + const l = await lib(); + expect(l.folders.find((f: any) => f.id === inner.body.id)).toMatchObject({ parentId: null, name: "Intake 2" }); + expect(l.folders.find((f: any) => f.id === deeper.body.id)).toMatchObject({ parentId: inner.body.id }); + for (const id of [a.body.id, inner.body.id]) await del(`/api/prints/folders/${id}`, admin); + }); + it("moves a file into a folder with the same name as '(2)', and refuses a rename that collides", async () => { + const f = await t.post("/api/prints/folders", { name: "Lift" }, admin); + const inside = await uploadFile(admin, { name: "spool.stl", folderId: f.body.id }); + const outside = await uploadFile(admin, { name: "spool.stl" }); + const moved = await t.patch(`/api/prints/files/${outside.body.id}`, { folderId: f.body.id }, admin); + expect(moved.status).toBe(200); + expect(moved.body).toMatchObject({ folderId: f.body.id, name: "spool (2).stl" }); + expect((await t.patch(`/api/prints/files/${moved.body.id}`, { name: "SPOOL.stl" }, admin)).status).toBe(409); + expect((await t.patch(`/api/prints/files/${moved.body.id}`, { name: "spool.step" }, admin)).status).toBe(400); + expect((await t.patch(`/api/prints/files/${moved.body.id}`, { name: "a/b.stl" }, admin)).status).toBe(400); + expect((await t.patch(`/api/prints/files/${moved.body.id}`, { name: "spool spare.stl" }, admin)).body.name).toBe("spool spare.stl"); + // Bulk move back to the root: no collision now. + const back = await t.post("/api/prints/files/bulk", { ids: [inside.body.id, moved.body.id], action: "move", folderId: null }, admin); + expect(back.status).toBe(200); + const names = (await lib()).files.filter((x: any) => x.folderId === null).map((x: any) => x.name).sort(); + expect(names).toEqual(expect.arrayContaining(["spool spare.stl", "spool.stl"])); + await t.post("/api/prints/files/bulk", { ids: [inside.body.id, moved.body.id], action: "delete" }, admin); + await del(`/api/prints/folders/${f.body.id}`, admin); + }); + it("asks before deleting a folder that holds files, says how many, then removes their bytes", async () => { + const top = await t.post("/api/prints/folders", { name: "Old season" }, admin); + const sub = await t.post("/api/prints/folders", { name: "Arm", parentId: top.body.id }, admin); + await uploadFile(admin, { name: "a.stl", folderId: top.body.id }); + await uploadFile(admin, { name: "b.stl", folderId: sub.body.id }); + const before = storedFiles().length; + const ask = await del(`/api/prints/folders/${top.body.id}`, admin); + expect(ask.status).toBe(409); + expect(ask.body).toMatchObject({ files: 2, folders: 1 }); + // A stale confirmation (someone added a file since) asks again. + expect((await del(`/api/prints/folders/${top.body.id}?files=1`, admin)).status).toBe(409); + const ok = await del(`/api/prints/folders/${top.body.id}?files=2`, admin); + expect(ok.body).toMatchObject({ success: true, files: 2 }); + expect(storedFiles().length).toBe(before - 2); + const l = await lib(); + expect(l.folders.some((f: any) => f.id === top.body.id || f.id === sub.body.id)).toBe(false); + // An empty folder goes without asking. + const empty = await t.post("/api/prints/folders", { name: "Empty" }, admin); + expect((await del(`/api/prints/folders/${empty.body.id}`, admin)).status).toBe(200); + }); +}); + +describe("groups, details and downloads", () => { + it("files one print in several collections without copying it, and marks a selection printed", async () => { + const a = await uploadFile(admin, { name: "hub.stl" }); + const b = await uploadFile(admin, { name: "roller.stl" }); + const spares = await t.post("/api/prints/collections", { name: "Comp spares" }, admin); + const worlds = await t.post("/api/prints/collections", { name: "Worlds kit" }, admin); + expect((await t.post("/api/prints/collections", { name: "comp SPARES" }, admin)).status).toBe(409); + await t.post("/api/prints/files/bulk", { ids: [a.body.id, b.body.id], action: "collect", collectionId: spares.body.id }, admin); + await t.post("/api/prints/files/bulk", { ids: [a.body.id], action: "collect", collectionId: worlds.body.id }, admin); + await t.patch(`/api/prints/files/${a.body.id}`, { subsystem: "Intake", material: "petg", copiesNeeded: 4, notes: "Print at 40% infill" }, admin); + let l = await lib(); + const hub = l.files.find((f: any) => f.id === a.body.id); + expect(hub).toMatchObject({ subsystem: "Intake", subsystemFrom: "manual", material: "PETG", copiesNeeded: 4, copiesNeededFrom: "manual", notes: "Print at 40% infill", status: "to_print" }); + expect(hub.collections.sort()).toEqual([spares.body.id, worlds.body.id].sort()); + expect(l.collections.find((c: any) => c.id === spares.body.id).files).toBe(2); + expect(storedFiles().filter((p) => !p.endsWith(".thumb.png")).length).toBe(l.files.length); + // Marking printed counts every copy needed. + await t.post("/api/prints/files/bulk", { ids: [a.body.id, b.body.id], action: "status", status: "printed" }, admin); + l = await lib(); + expect(l.files.find((f: any) => f.id === a.body.id)).toMatchObject({ status: "printed", copiesPrinted: 4 }); + expect(l.files.find((f: any) => f.id === b.body.id)).toMatchObject({ status: "printed", copiesPrinted: 0 }); + await t.post("/api/prints/files/bulk", { ids: [b.body.id], action: "status", status: "failed" }, admin); + // Reaching the copies needed by hand marks it printed. + await t.patch(`/api/prints/files/${a.body.id}`, { status: "to_print", copiesPrinted: 0 }, admin); + const back = await t.patch(`/api/prints/files/${a.body.id}`, { copiesPrinted: 4 }, admin); + expect(back.body.status).toBe("printed"); + // Deleting a collection keeps the files. + await del(`/api/prints/collections/${worlds.body.id}`, admin); + l = await lib(); + expect(l.files.find((f: any) => f.id === a.body.id).collections).toEqual([spares.body.id]); + // Every change is recorded. + const act = (await get(`/api/prints/activity?file=${a.body.id}`, student)).body; + expect(act.map((x: any) => x.action)).toEqual(expect.arrayContaining(["upload", "collect", "edit", "status"])); + expect(act[0].by).toBe("Ada"); + await t.post("/api/prints/files/bulk", { ids: [a.body.id, b.body.id], action: "delete" }, admin); + await del(`/api/prints/collections/${spares.body.id}`, admin); + }); + it("links a CAD part: its section is the subsystem and its quantity the copies needed", async () => { + const robot = Number((await t.db.execute({ sql: "INSERT INTO cad_robots (team_id, season, name, is_main, created_at, updated_at) VALUES (?, 2025, 'Comp bot', 1, 'x', 'x')", args: [team] })).lastInsertRowid); + const part = Number((await t.db.execute({ sql: "INSERT INTO cad_parts (team_id, name, section, quantity, source, unit_cost, status, notes, created_at, updated_at, robot_id) VALUES (?, 'Roller hub', 'Intake', 6, 'printed', 0, 'to_order', '', 'x', 'x', ?)", args: [team, robot] })).lastInsertRowid); + const otherPart = Number((await t.db.execute({ sql: "INSERT INTO cad_parts (team_id, name, section, quantity, source, unit_cost, status, notes, created_at, updated_at) VALUES (?, 'Theirs', 'Lift', 1, 'printed', 0, 'to_order', '', 'x', 'x')", args: [other] })).lastInsertRowid); + const f = await uploadFile(admin, { name: "roller hub.stl" }); + expect((await t.patch(`/api/prints/files/${f.body.id}`, { cadPartId: otherPart }, admin)).status).toBe(404); + const r = await t.patch(`/api/prints/files/${f.body.id}`, { cadPartId: part }, admin); + expect(r.body).toMatchObject({ robotId: robot, season: 2025, subsystem: "Intake", subsystemFrom: "cad", copiesNeeded: 6, copiesNeededFrom: "cad", cadPart: { id: part, name: "Roller hub" } }); + const own = await t.patch(`/api/prints/files/${f.body.id}`, { subsystem: "Outtake" }, admin); + expect(own.body).toMatchObject({ subsystem: "Outtake", subsystemFrom: "manual" }); + expect((await t.patch(`/api/prints/files/${f.body.id}`, { season: 1990 }, admin)).status).toBe(400); + await del(`/api/prints/files/${f.body.id}`, admin); + }); + it("zips a folder with its subfolders, and a group's chosen files in their folders", async () => { + const top = await t.post("/api/prints/folders", { name: "2025–26 DECODE" }, admin); + const sub = await t.post("/api/prints/folders", { name: "Intake", parentId: top.body.id }, admin); + const a = await uploadFile(admin, { name: "a.stl", folderId: top.body.id, bytes: stl("a") }); + const b = await uploadFile(admin, { name: "b.gcode", folderId: sub.body.id, bytes: Buffer.from("G28\nG1 X10\n") }); + const z = await raw(`/api/prints/zip?folder=${top.body.id}`, student); + expect(z.status).toBe(200); + expect(z.headers.get("content-disposition")).toContain("filename*=UTF-8''2025%E2%80%9326%20DECODE.zip"); + const files = unzip(z.bytes); + expect(Object.keys(files).sort()).toEqual(["Intake/b.gcode", "a.stl"]); + expect(files["a.stl"].equals(stl("a"))).toBe(true); + const g = await raw(`/api/prints/zip?files=${a.body.id},${b.body.id}&name=Intake`, student); + expect(Object.keys(unzip(g.bytes)).sort()).toEqual(["2025–26 DECODE/Intake/b.gcode", "2025–26 DECODE/a.stl"]); + expect(g.headers.get("content-disposition")).toContain('filename="Intake.zip"'); + const emptyFolder = await t.post("/api/prints/folders", { name: "Nothing" }, admin); + expect((await raw(`/api/prints/zip?folder=${emptyFolder.body.id}`, admin)).status).toBe(404); + await del(`/api/prints/folders/${top.body.id}?files=2`, admin); + await del(`/api/prints/folders/${emptyFolder.body.id}`, admin); + }); +}); + +describe("filing a Control Kit print from inside the server", () => { + it("files under Season / Robot / Control Kit, links the part, and replaces its own earlier send", async () => { + // The test process builds its own Prints module on the same database and storage, as server.ts does. + const norm = (a: any[]) => a.map((v) => (v === undefined ? null : v)); + const exec = async (sql: string, args: any[]) => t.db.execute({ sql, args: norm(args) }); + const prints = createPrints({ + dbGet: async (sql, ...args) => (await exec(sql, args)).rows[0], + dbAll: async (sql, ...args) => (await exec(sql, args)).rows as any[], + dbRun: async (sql, ...args) => { const r = await exec(sql, args); return { lastInsertRowid: Number(r.lastInsertRowid), changes: r.rowsAffected }; }, + dbBatchResults: async (stmts) => (await t.db.batch(stmts.map((s) => ({ sql: s.sql, args: norm(s.args ?? []) })), "write")).map((r) => ({ lastInsertRowid: Number(r.lastInsertRowid), changes: r.rowsAffected, rows: r.rows as any[] })), + requireAuth: async () => null, hasPerm: async () => false, + env: { PRINTS_DIR: dir, PRINTS_MAX_FILE_MB: "1", PRINTS_QUOTA_MB: "3", PRINTS_MIN_FREE_MB: "0" }, + }); + const robot = Number((await t.db.execute({ sql: "INSERT INTO cad_robots (team_id, season, name, is_main, created_at, updated_at) VALUES (?, 2025, 'Practice/bot', 0, 'x', 'x')", args: [team] })).lastInsertRowid); + const part = Number((await t.db.execute({ sql: "INSERT INTO cad_parts (team_id, name, section, quantity, source, unit_cost, status, notes, created_at, updated_at, robot_id, ck_part_key) VALUES (?, 'Claw finger', 'Claw', 2, 'printed', 0, 'to_order', '', 'x', 'x', ?, 'doc1:ps:JHD:default')", args: [team, robot] })).lastInsertRowid); + const first = await prints.fileControlKitPrint({ teamId: team, robotId: robot, name: "Claw finger.step", bytes: Buffer.from("ISO-10303-21; v1"), ckPartKey: "doc1:ps:JHD:default", sentBy: "Ada" }); + expect(first).toMatchObject({ name: "Claw finger.step", replaced: false }); + let l = await lib(); + const path = []; + for (let id = l.folders.find((f: any) => f.id === first.folderId)?.id; id != null;) { const f = l.folders.find((x: any) => x.id === id); path.unshift(f.name); id = f.parentId; } + expect(path).toEqual(["2025–26 DECODE", "Practice-bot", "Control Kit"]); + expect(l.files.find((f: any) => f.id === first.fileId)).toMatchObject({ source: "control-kit", robotId: robot, cadPart: { id: part }, subsystem: "Claw", copiesNeeded: 2, uploadedBy: "Ada via Control Kit", kind: "step" }); + // Printed, then Control Kit sends the same bytes (nothing changes) and then new ones (back to To print). + await t.post("/api/prints/files/bulk", { ids: [first.fileId], action: "status", status: "printed" }, admin); + expect(await prints.fileControlKitPrint({ teamId: team, robotId: robot, name: "Claw finger.step", bytes: Buffer.from("ISO-10303-21; v1"), ckPartKey: "doc1:ps:JHD:default" })).toMatchObject({ unchanged: true }); + const again = await prints.fileControlKitPrint({ teamId: team, robotId: robot, name: "Claw finger.step", bytes: Buffer.from("ISO-10303-21; v2"), ckPartKey: "doc1:ps:JHD:default" }); + expect(again).toMatchObject({ fileId: first.fileId, replaced: true }); + l = await lib(); + expect(l.files.find((f: any) => f.id === first.fileId).status).toBe("to_print"); + expect((await raw(`/api/prints/files/${first.fileId}/download`, admin)).bytes.toString()).toBe("ISO-10303-21; v2"); + // A file a person put there under the same name is kept; Control Kit's gets "(2)". + const mine = await uploadFile(admin, { name: "Bracket.step", folderId: first.folderId, bytes: Buffer.from("mine") }); + const theirs = await prints.fileControlKitPrint({ teamId: team, robotId: robot, name: "Bracket.step", bytes: Buffer.from("ck") }); + expect(theirs.name).toBe("Bracket (2).step"); + expect((await raw(`/api/prints/files/${mine.body.id}/download`, admin)).bytes.toString()).toBe("mine"); + await expect(prints.fileControlKitPrint({ teamId: team, robotId: robot, name: "x.exe", bytes: Buffer.from("x") })).rejects.toMatchObject({ code: "unsupported" }); + await expect(prints.fileControlKitPrint({ teamId: team, robotId: robot, name: "big.step", bytes: Buffer.alloc(2 * MB) })).rejects.toMatchObject({ code: "too_large" }); + await expect(prints.fileControlKitPrint({ teamId: other, robotId: robot, name: "x.step", bytes: Buffer.from("x") })).rejects.toThrow(); + const act = (await get(`/api/prints/activity?file=${first.fileId}`, admin)).body; + expect(act.map((x: any) => x.action)).toEqual(expect.arrayContaining(["upload", "status", "replace"])); + }); +}); + +describe("disk reserve", () => { + it("refuses uploads that would leave the server short of disk", async () => { + const t2 = await startTestServer("cp-prints-full-", { PRINTS_DIR: mkdtempSync(join(tmpdir(), "cp-prints-full-store-")), PRINTS_MIN_FREE_MB: String(1024 * 1024 * 1024) }); + try { + const tm = await seedTeam(t2.db, "Full"); + const s = await t2.session(await seedMember(t2.db, tm, "A", "a@full.local", "admin")); + const form = new FormData(); + form.append("file", new Blob([stl()]), "x.stl"); + const res = await fetch(`${t2.base}/api/prints/files`, { method: "POST", body: form, headers: withSession(new Headers(), s) }); + expect(res.status).toBe(507); + expect((await res.json()).code).toBe("disk_full"); + } finally { await t2.stop(); } + }, 120_000); +}); diff --git a/server/prints.ts b/server/prints.ts new file mode 100644 index 000000000..f25118af7 --- /dev/null +++ b/server/prints.ts @@ -0,0 +1,950 @@ +// The Prints library (migration 155): a team's 3D print files, in folders +// (where a file lives) and in groups (smart groups from each file's details, +// plus collections the team makes). Shared rules are in src/types/prints.ts. +// +// Storage: bytes live on the VM's disk under PRINTS_DIR (default +// /.data/prints, outside every statically served directory), at a random +// key per file; names people type never reach a disk path. Limits, all +// configurable in the environment: +// PRINTS_MAX_FILE_MB one file (default 100) +// PRINTS_QUOTA_MB one workspace's total (default 2048) +// PRINTS_MIN_FREE_MB free disk an upload must leave (default 5120) +// The quota is checked inside the INSERT itself, so two uploads at once can't +// both slip past it. +// +// Permissions follow the workspace's roles: every member views and +// downloads; manage_prints uploads, moves, renames, deletes and edits +// details. Every route binds auth.teamId; another team's ids are 404. Every +// change is written to print_activity. +// +// Control Kit: fileControlKitPrint() files a print sent for a robot under +// "Season / Robot / Control Kit" (see the end of this file). +import type { Express } from "express"; +import multer from "multer"; +import { createHash, randomBytes } from "node:crypto"; +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import path from "node:path"; +import { seasonName, isSeason } from "../src/types/cadBom.js"; +import { DEFAULT_SUBSYSTEMS, cleanSubsystems } from "../src/types/teamModules.js"; +import { + canPreview, cleanMaterial, cleanName, cleanSubsystem, isPrintStatus, printKind, safeName, uniqueName, + type PrintFile, type PrintKind, type PrintStatus, +} from "../src/types/prints.js"; +import { writeZip, uniqueZipNames, ZIP_MAX_BYTES } from "./zipStream.js"; + +type Auth = { memberId: number; teamId: number | null }; +type Stmt = { sql: string; args: any[] }; +export interface PrintDeps { + dbGet: (sql: string, ...args: any[]) => Promise; + dbAll: (sql: string, ...args: any[]) => Promise; + dbRun: (sql: string, ...args: any[]) => Promise<{ lastInsertRowid: number; changes: number }>; + dbBatchResults: (stmts: { sql: string; args?: any[] }[]) => Promise<{ lastInsertRowid: number; changes: number; rows: any[] }[]>; + requireAuth: (req: any, res: any) => Promise; + hasPerm: (auth: Auth, perm: string) => Promise; + env: Record; +} + +export const MANAGE_PRINTS = "manage_prints"; +const MB = 1024 * 1024; +const THUMB_MAX = 512 * 1024; +const MAX_BULK = 1000; +const MAX_COLLECTIONS = 60; +const MAX_FOLDERS = 2000; +const PNG_SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +export interface PrintLimits { maxFile: number; quota: number; minFree: number } +export function printLimits(env: Record): PrintLimits { + const num = (k: string, d: number, min: number) => { const n = Number(env[k]); return Number.isFinite(n) && n >= min && env[k] !== "" && env[k] != null ? n : d; }; + return { + maxFile: Math.round(num("PRINTS_MAX_FILE_MB", 100, 0.001) * MB), + quota: Math.round(num("PRINTS_QUOTA_MB", 2048, 0.001) * MB), + minFree: Math.round(num("PRINTS_MIN_FREE_MB", 5120, 0) * MB), + }; +} + +/** Why a print couldn't be stored. `status` is the HTTP answer for routes. */ +export class PrintError extends Error { + constructor(public code: "unsupported" | "too_large" | "quota" | "disk_full" | "bad_name" | "empty", message: string, public status = 400) { super(message); } +} + +/** Files on disk under random keys "/<32 hex>" (+ ".thumb.png"). A key that doesn't look like one never becomes a path. */ +export class PrintStore { + readonly files: string; + readonly tmp: string; + constructor(readonly root: string) { + this.files = path.join(root, "files"); + this.tmp = path.join(root, "tmp"); + fs.mkdirSync(this.files, { recursive: true }); + fs.mkdirSync(this.tmp, { recursive: true }); + } + newKey(teamId: number) { return `${Math.trunc(teamId)}/${randomBytes(16).toString("hex")}`; } + pathOf(key: string): string { + if (!/^\d{1,12}\/[0-9a-f]{32}(\.thumb\.png)?$/.test(key)) throw new Error("bad storage key"); + return path.join(this.files, ...key.split("/")); + } + async adopt(tmpPath: string, key: string) { + const dest = this.pathOf(key); + await fsp.mkdir(path.dirname(dest), { recursive: true }); + await fsp.rename(tmpPath, dest); + } + async write(key: string, bytes: Uint8Array) { + const dest = this.pathOf(key); + await fsp.mkdir(path.dirname(dest), { recursive: true }); + const part = `${dest}.${randomBytes(4).toString("hex")}.part`; + await fsp.writeFile(part, bytes); + await fsp.rename(part, dest); + } + async remove(key: string | null | undefined) { + if (!key) return; + try { await fsp.unlink(this.pathOf(key)); } catch (e: any) { if (e?.code !== "ENOENT") console.error("Removing a print file failed:", e?.message || e); } + } + async exists(key: string) { try { await fsp.access(this.pathOf(key)); return true; } catch { return false; } } + /** Bytes free for this process on the store's disk. */ + async free(): Promise { + const s = await fsp.statfs(this.root); + return Number(s.bavail) * Number(s.bsize); + } + /** Temp uploads left by a crash. */ + async sweepTmp(maxAgeMs = 3600_000) { + for (const f of await fsp.readdir(this.tmp).catch(() => [] as string[])) { + const p = path.join(this.tmp, f); + const st = await fsp.stat(p).catch(() => null); + if (st && Date.now() - st.mtimeMs > maxAgeMs) await fsp.unlink(p).catch(() => {}); + } + } +} + +const sha256File = (p: string) => new Promise((resolve, reject) => { + const h = createHash("sha256"); + fs.createReadStream(p).on("data", (c) => h.update(c)).on("error", reject).on("end", () => resolve(h.digest("hex"))); +}); +const isUnique = (e: any) => /UNIQUE constraint failed/i.test(String(e?.message || e)); +const intOrNull = (v: unknown, max: number): number | null | undefined => { + if (v === null || v === "") return null; + const n = Number(v); + return Number.isInteger(n) && n >= 0 && n <= max ? n : undefined; +}; +const ids = (raw: unknown): number[] => (Array.isArray(raw) ? [...new Set(raw.map(Number).filter((n) => Number.isInteger(n) && n > 0))] : []); +const qs = (n: number) => Array(n).fill("?").join(","); +/** A Content-Disposition that keeps a UTF-8 name ("2025–26 DECODE.zip"). */ +function disposition(kind: "attachment" | "inline", name: string) { + const ascii = name.replace(/[^\x20-\x7e]/g, "_").replace(/["\\]/g, "_"); + return `${kind}; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(name)}`; +} + +export function createPrints(deps: PrintDeps) { + const { dbGet, dbAll, dbRun } = deps; + const limits = printLimits(deps.env); + const store = new PrintStore(path.resolve(deps.env.PRINTS_DIR?.trim() || path.join(process.cwd(), ".data", "prints"))); + void store.sweepTmp(); + setInterval(() => void store.sweepTmp(), 3600_000).unref(); + + const upload = multer({ + storage: multer.diskStorage({ + destination: store.tmp, + filename: (_req, _file, cb) => cb(null, randomBytes(16).toString("hex")), + }), + limits: { fileSize: limits.maxFile, files: 2, fields: 8, fieldSize: 4096 }, + fileFilter: (_req, file, cb) => { + if (file.fieldname === "file" && printKind(file.originalname)) return cb(null, true); + if (file.fieldname === "thumbnail" && file.mimetype === "image/png") return cb(null, true); + cb(new PrintError("unsupported", file.fieldname === "file" ? "Prints takes STL, 3MF, STEP/STP, OBJ and G-code files" : "Thumbnails are PNG images")); + }, + }); + + const teamOf = (auth: Auth, res: any) => { + if (!auth.teamId) { res.status(400).json({ error: "Join a team first" }); return null; } + return auth.teamId; + }; + const memberName = async (id: number) => String((await dbGet("SELECT name FROM members WHERE id = ?", id))?.name || "A teammate"); + /** Signed in, on a team, and (when `edit`) allowed to change the library. */ + async function access(req: any, res: any, edit: boolean): Promise<{ auth: Auth; teamId: number } | null> { + const auth = await deps.requireAuth(req, res); + if (!auth) return null; + const teamId = teamOf(auth, res); + if (!teamId) return null; + if (edit && !(await deps.hasPerm(auth, MANAGE_PRINTS))) { res.status(403).json({ error: "Your role can view and download prints, not change them" }); return null; } + return { auth, teamId }; + } + const now = () => new Date().toISOString(); + const logStmt = (teamId: number, by: { id: number | null; name: string }, action: string, o: { fileId?: number | null; folderId?: number | null; detail?: unknown } = {}): Stmt => ({ + sql: "INSERT INTO print_activity (team_id, file_id, folder_id, member_id, member_name, action, detail, at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + args: [teamId, o.fileId ?? null, o.folderId ?? null, by.id, by.name, action, JSON.stringify(o.detail ?? {}), now()], + }); + const usedBytes = async (teamId: number) => Number((await dbGet("SELECT COALESCE(SUM(size), 0) AS n FROM print_files WHERE team_id = ?", teamId))?.n) || 0; + async function teamFolder(teamId: number, raw: unknown): Promise { + if (raw === null || raw === undefined || raw === "" || raw === "root") return null; + const id = Number(raw); + if (!Number.isInteger(id) || id <= 0) return undefined; + return (await dbGet("SELECT id FROM print_folders WHERE id = ? AND team_id = ?", id, teamId)) ? id : undefined; + } + async function namesIn(teamId: number, folderId: number | null, exceptFile?: number) { + const rows = await dbAll("SELECT name FROM print_files WHERE team_id = ? AND COALESCE(folder_id, 0) = ? AND id <> ?", teamId, folderId ?? 0, exceptFile ?? 0); + return new Set(rows.map((r) => String(r.name).toLowerCase())); + } + /** `id` and every folder below it, in this team. */ + async function subtree(teamId: number, id: number): Promise { + const rows = await dbAll( + `WITH RECURSIVE sub(id) AS (SELECT id FROM print_folders WHERE id = ? AND team_id = ? + UNION SELECT f.id FROM print_folders f JOIN sub ON f.parent_id = sub.id WHERE f.team_id = ?) SELECT id FROM sub`, id, teamId, teamId); + return rows.map((r) => Number(r.id)); + } + async function folderTrailNames(teamId: number): Promise> { + const rows = await dbAll("SELECT id, parent_id, name FROM print_folders WHERE team_id = ?", teamId); + return new Map(rows.map((r) => [Number(r.id), { parent: r.parent_id == null ? null : Number(r.parent_id), name: String(r.name) }])); + } + /** "A/B/C" from the root to a folder (or from just below `from`). */ + function trail(map: Map, id: number | null, from: number | null = null): string[] { + const out: string[] = []; + const seen = new Set(); + let cur = id; + while (cur != null && cur !== from && !seen.has(cur)) { seen.add(cur); const f = map.get(cur); if (!f) break; out.unshift(f.name); cur = f.parent; } + return out; + } + + /** The library row as the explorer sees it. */ + function fileView(r: any, collections: Map): PrintFile { + const cadSection = r.cad_section && String(r.cad_section).toLowerCase() !== "other" ? String(r.cad_section) : null; + const cadQty = r.cad_id != null && Number.isFinite(Number(r.cad_quantity)) ? Number(r.cad_quantity) : null; + return { + id: Number(r.id), folderId: r.folder_id == null ? null : Number(r.folder_id), name: r.name, kind: r.kind as PrintKind, size: Number(r.size) || 0, + createdAt: r.created_at, updatedAt: r.updated_at, uploadedBy: r.uploaded_by_name || null, notes: r.notes || "", + robotId: r.robot_ok != null ? Number(r.robot_id) : null, + season: r.robot_ok != null ? Number(r.robot_season) : r.season != null ? Number(r.season) : null, + cadPart: r.cad_id != null ? { id: Number(r.cad_id), name: r.cad_name, section: r.cad_section ?? null, quantity: Number(r.cad_quantity) || 0, robotId: r.cad_robot_id == null ? null : Number(r.cad_robot_id) } : null, + subsystem: r.subsystem || cadSection, subsystemFrom: r.subsystem ? "manual" : cadSection ? "cad" : null, + material: r.material || null, status: isPrintStatus(r.status) ? r.status : "to_print", + copiesNeeded: r.copies_needed != null ? Number(r.copies_needed) : cadQty, + copiesNeededFrom: r.copies_needed != null ? "manual" : cadQty != null ? "cad" : null, + copiesPrinted: Number(r.copies_printed) || 0, collections: collections.get(Number(r.id)) ?? [], hasThumb: !!r.thumb_key, + source: r.source === "control-kit" ? "control-kit" : "upload", + }; + } + const FILE_SELECT = `SELECT f.*, r.id AS robot_ok, r.season AS robot_season, p.id AS cad_id, p.name AS cad_name, p.section AS cad_section, p.quantity AS cad_quantity, p.robot_id AS cad_robot_id + FROM print_files f + LEFT JOIN cad_robots r ON r.id = f.robot_id AND r.team_id = f.team_id + LEFT JOIN cad_parts p ON p.id = f.cad_part_id AND p.team_id = f.team_id`; + async function oneFile(teamId: number, id: number) { + const r = await dbGet(`${FILE_SELECT} WHERE f.id = ? AND f.team_id = ?`, id, teamId); + if (!r) return null; + const col = (await dbAll("SELECT collection_id FROM print_collection_files WHERE file_id = ? AND team_id = ?", id, teamId)).map((x) => Number(x.collection_id)); + return fileView(r, new Map([[Number(r.id), col]])); + } + + /** + * Insert a stored file's row. The quota is checked in the same statement, and a name taken meanwhile gets the next + * "(n)". Returns the row id and the name it got, or throws PrintError("quota"). + */ + async function insertFile(teamId: number, row: { folderId: number | null; name: string; kind: PrintKind; size: number; sha: string; key: string; by: { id: number | null; name: string }; source: "upload" | "control-kit"; robotId?: number | null; cadPartId?: number | null; ckPartKey?: string | null }) { + for (let attempt = 0; attempt < 5; attempt++) { + const name = uniqueName(row.name, await namesIn(teamId, row.folderId)); + const t = now(); + try { + const r = await dbRun( + `INSERT INTO print_files (team_id, folder_id, name, kind, size, sha256, storage_key, robot_id, cad_part_id, ck_part_key, source, uploaded_by, uploaded_by_name, created_at, updated_at) + SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + WHERE (SELECT COALESCE(SUM(size), 0) FROM print_files WHERE team_id = ?) + ? <= ?`, + teamId, row.folderId, name, row.kind, row.size, row.sha, row.key, row.robotId ?? null, row.cadPartId ?? null, row.ckPartKey ?? null, row.source, row.by.id, row.by.name, t, t, + teamId, row.size, limits.quota); + if (!r.changes) throw new PrintError("quota", quotaMessage(), 413); + return { id: Number(r.lastInsertRowid), name }; + } catch (e) { + if (!isUnique(e) || attempt === 4) throw e; + } + } + throw new Error("unreachable"); + } + const mbText = (n: number) => (n >= 1024 * MB ? `${+(n / (1024 * MB)).toFixed(1)} GB` : `${Math.round(n / MB)} MB`); + const quotaMessage = () => `Your team's prints are limited to ${mbText(limits.quota)}. Delete old files to make room.`; + async function checkDisk(incoming: number) { + const free = await store.free().catch(() => Infinity); + if (free - incoming < limits.minFree) throw new PrintError("disk_full", "The server is short of disk space right now. Ask the Control Point owner, or try again later.", 507); + } + async function saveThumb(teamId: number, fileId: number, key: string, bytes: Buffer, by: { id: number | null; name: string } | null) { + if (bytes.length > THUMB_MAX || !bytes.subarray(0, 8).equals(PNG_SIG)) throw new PrintError("unsupported", "Thumbnails are PNG images up to 512 KB"); + const thumbKey = `${key}.thumb.png`; + await store.write(thumbKey, bytes); + const r = await dbRun("UPDATE print_files SET thumb_key = ? WHERE id = ? AND team_id = ? AND storage_key = ?", thumbKey, fileId, teamId, key); + if (!r.changes) await store.remove(thumbKey); + void by; + } + const sendError = (res: any, e: any, what: string) => { + if (e instanceof PrintError) return res.status(e.status).json({ error: e.message, code: e.code }); + console.error(`Prints ${what} error:`, e); + res.status(500).json({ error: "Internal server error" }); + }; + + function registerRoutes(app: Express) { + // Everything the explorer needs in one answer: folders, files with their details, collections, robots, usage. + app.get("/api/prints/library", async (req, res) => { + try { + const a = await access(req, res, false); if (!a) return; + const { teamId } = a; + const [folders, files, collections, members, robots, team] = await Promise.all([ + dbAll("SELECT id, parent_id, name, created_at, updated_at FROM print_folders WHERE team_id = ? ORDER BY name COLLATE NOCASE", teamId), + dbAll(`${FILE_SELECT} WHERE f.team_id = ?`, teamId), + dbAll("SELECT c.id, c.name, (SELECT COUNT(*) FROM print_collection_files x WHERE x.collection_id = c.id) AS n FROM print_collections c WHERE c.team_id = ? ORDER BY c.name COLLATE NOCASE", teamId), + dbAll("SELECT collection_id, file_id FROM print_collection_files WHERE team_id = ?", teamId), + dbAll("SELECT id, name, season FROM cad_robots WHERE team_id = ? ORDER BY season DESC, is_main DESC, id", teamId), + dbGet("SELECT subsystems FROM teams WHERE id = ?", teamId), + ]); + const byFile = new Map(); + for (const m of members) byFile.set(Number(m.file_id), [...(byFile.get(Number(m.file_id)) ?? []), Number(m.collection_id)]); + let subsystems: string[] = [...DEFAULT_SUBSYSTEMS]; + try { if (team?.subsystems) subsystems = cleanSubsystems(JSON.parse(team.subsystems)); } catch { /* defaults */ } + res.setHeader("Cache-Control", "private, no-store"); + res.json({ + canEdit: await deps.hasPerm(a.auth, MANAGE_PRINTS), + usage: { used: files.reduce((s, f) => s + (Number(f.size) || 0), 0), quota: limits.quota, maxFile: limits.maxFile }, + folders: folders.map((f) => ({ id: Number(f.id), parentId: f.parent_id == null ? null : Number(f.parent_id), name: f.name, createdAt: f.created_at, updatedAt: f.updated_at })), + files: files.map((f) => fileView(f, byFile)), + collections: collections.map((c) => ({ id: Number(c.id), name: c.name, files: Number(c.n) || 0 })), + robots: robots.map((r) => ({ id: Number(r.id), name: r.name, season: Number(r.season), seasonName: seasonName(Number(r.season)) })), + subsystems, + }); + } catch (e) { sendError(res, e, "library"); } + }); + + // ---- Folders ---- + app.post("/api/prints/folders", async (req, res) => { + try { + const a = await access(req, res, true); if (!a) return; + const name = cleanName(req.body?.name); + if (!name) return res.status(400).json({ error: "Folder names can't be empty or contain / \\ : * ? \" < > |" }); + const parentId = await teamFolder(a.teamId, req.body?.parentId); + if (parentId === undefined) return res.status(404).json({ error: "That folder no longer exists" }); + const count = await dbGet("SELECT COUNT(*) AS n FROM print_folders WHERE team_id = ?", a.teamId); + if (Number(count?.n) >= MAX_FOLDERS) return res.status(400).json({ error: `A library holds at most ${MAX_FOLDERS} folders` }); + const by = { id: a.auth.memberId, name: await memberName(a.auth.memberId) }; + const t = now(); + try { + const r = await dbRun("INSERT INTO print_folders (team_id, parent_id, name, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)", a.teamId, parentId, name, by.id, t, t); + const id = Number(r.lastInsertRowid); + const log = logStmt(a.teamId, by, "folder_create", { folderId: id, detail: { name, parentId } }); + await dbRun(log.sql, ...log.args); + res.json({ id, name }); + } catch (e) { + if (isUnique(e)) return res.status(409).json({ error: `There's already a folder named "${name}" here` }); + throw e; + } + } catch (e) { sendError(res, e, "folder create"); } + }); + + // Rename and/or move a folder. It can't move into itself or a folder inside it. + app.patch("/api/prints/folders/:id", async (req, res) => { + try { + const a = await access(req, res, true); if (!a) return; + const folder = await dbGet("SELECT * FROM print_folders WHERE id = ? AND team_id = ?", Number(req.params.id), a.teamId); + if (!folder) return res.status(404).json({ error: "Not found" }); + const body = req.body || {}; + let name = String(folder.name); + let parentId: number | null = folder.parent_id == null ? null : Number(folder.parent_id); + if (body.name !== undefined) { + const n = cleanName(body.name); + if (!n) return res.status(400).json({ error: "Folder names can't be empty or contain / \\ : * ? \" < > |" }); + name = n; + } + if (body.parentId !== undefined) { + const p = await teamFolder(a.teamId, body.parentId); + if (p === undefined) return res.status(404).json({ error: "That folder no longer exists" }); + if (p !== null && (await subtree(a.teamId, Number(folder.id))).includes(p)) return res.status(400).json({ error: "A folder can't go inside itself" }); + parentId = p; + } + const by = { id: a.auth.memberId, name: await memberName(a.auth.memberId) }; + const moved = parentId !== (folder.parent_id == null ? null : Number(folder.parent_id)); + const renamed = name !== folder.name; + if (!moved && !renamed) return res.json({ id: Number(folder.id), name }); + const stmts: Stmt[] = [ + // The parent is checked again inside the update: a move into a folder deleted meanwhile changes nothing. + { sql: `UPDATE print_folders SET name = ?, parent_id = ?, updated_at = ? WHERE id = ? AND team_id = ? + AND (? IS NULL OR EXISTS (SELECT 1 FROM print_folders p WHERE p.id = ? AND p.team_id = ?))`, + args: [name, parentId, now(), folder.id, a.teamId, parentId, parentId, a.teamId] }, + ]; + if (renamed) stmts.push(logStmt(a.teamId, by, "folder_rename", { folderId: Number(folder.id), detail: { from: folder.name, to: name } })); + if (moved) stmts.push(logStmt(a.teamId, by, "folder_move", { folderId: Number(folder.id), detail: { from: folder.parent_id ?? null, to: parentId } })); + try { + const [r] = await deps.dbBatchResults(stmts); + if (!r.changes) return res.status(404).json({ error: "That folder no longer exists" }); + } catch (e) { + if (isUnique(e)) return res.status(409).json({ error: `There's already a folder named "${name}" there` }); + throw e; + } + res.json({ id: Number(folder.id), name }); + } catch (e) { sendError(res, e, "folder update"); } + }); + + // Delete a folder and everything in it. With files inside, the caller must say how many it confirmed (?files=N); + // a different count (someone added or removed files meanwhile) asks again. + app.delete("/api/prints/folders/:id", async (req, res) => { + try { + const a = await access(req, res, true); if (!a) return; + const tree = await subtree(a.teamId, Number(req.params.id)); + if (!tree.length) return res.status(404).json({ error: "Not found" }); + const count = await dbGet(`SELECT COUNT(*) AS n FROM print_files WHERE team_id = ? AND folder_id IN (${qs(tree.length)})`, a.teamId, ...tree); + const files = Number(count?.n) || 0; + const confirmed = req.query.files === undefined ? null : Number(req.query.files); + if (files > 0 && confirmed !== files) { + return res.status(409).json({ error: `This folder holds ${files} file${files === 1 ? "" : "s"}. Confirm to delete them too.`, files, folders: tree.length - 1 }); + } + const by = { id: a.auth.memberId, name: await memberName(a.auth.memberId) }; + const folder = await dbGet("SELECT name FROM print_folders WHERE id = ?", tree[0]); + const inTree = `IN (${qs(tree.length)})`; + const stmts: Stmt[] = [ + { sql: `DELETE FROM print_collection_files WHERE team_id = ? AND file_id IN (SELECT id FROM print_files WHERE team_id = ? AND folder_id ${inTree})`, args: [a.teamId, a.teamId, ...tree] }, + { sql: `DELETE FROM print_files WHERE team_id = ? AND folder_id ${inTree} RETURNING id, name, storage_key, thumb_key`, args: [a.teamId, ...tree] }, + { sql: `DELETE FROM print_folders WHERE team_id = ? AND id ${inTree}`, args: [a.teamId, ...tree] }, + logStmt(a.teamId, by, "folder_delete", { folderId: tree[0], detail: { name: folder?.name, files, folders: tree.length } }), + ]; + const out = await deps.dbBatchResults(stmts); + for (const r of out[1].rows) { await store.remove(r.storage_key); await store.remove(r.thumb_key); } + res.json({ success: true, files: out[1].rows.length }); + } catch (e) { sendError(res, e, "folder delete"); } + }); + + // ---- Files ---- + // Upload one file (field "file", optional PNG "thumbnail", fields "folderId" and "name"). + app.post("/api/prints/files", async (req, res) => { + const cleanup: string[] = []; + try { + const a = await access(req, res, true); if (!a) return; + // Refuse early, before the body is read, when its declared size can't fit. + const declared = Number(req.headers["content-length"]) || 0; + if (declared > limits.maxFile + THUMB_MAX + 64 * 1024) return res.status(413).json({ error: `Files can be up to ${mbText(limits.maxFile)}`, code: "too_large" }); + if (declared && (await usedBytes(a.teamId)) + declared - THUMB_MAX - 64 * 1024 > limits.quota) return res.status(413).json({ error: quotaMessage(), code: "quota" }); + await checkDisk(declared); + await new Promise((resolve, reject) => upload.fields([{ name: "file", maxCount: 1 }, { name: "thumbnail", maxCount: 1 }])(req, res, (err: any) => { + for (const list of Object.values((req as any).files || {}) as any[]) for (const f of list) cleanup.push(f.path); + if (!err) return resolve(); + if (err?.code === "LIMIT_FILE_SIZE") return reject(new PrintError("too_large", `Files can be up to ${mbText(limits.maxFile)}`, 413)); + if (err instanceof PrintError) return reject(err); + reject(new PrintError("unsupported", err?.message || "Upload failed")); + })); + const file = (req as any).files?.file?.[0]; + if (!file) return res.status(400).json({ error: "Choose a file to upload" }); + const name = cleanName(req.body?.name ?? file.originalname); + if (!name) throw new PrintError("bad_name", "File names can't contain / \\ : * ? \" < > |"); + const kind = printKind(name); + if (!kind) throw new PrintError("unsupported", "Prints takes STL, 3MF, STEP/STP, OBJ and G-code files"); + if (!file.size) throw new PrintError("empty", "That file is empty"); + const folderId = await teamFolder(a.teamId, req.body?.folderId); + if (folderId === undefined) return res.status(404).json({ error: "That folder no longer exists" }); + await checkDisk(0); + const sha = await sha256File(file.path); + const key = store.newKey(a.teamId); + await store.adopt(file.path, key); + const by = { id: a.auth.memberId, name: await memberName(a.auth.memberId) }; + let row: { id: number; name: string }; + try { row = await insertFile(a.teamId, { folderId, name, kind, size: file.size, sha, key, by, source: "upload" }); } + catch (e) { await store.remove(key); throw e; } + const thumb = (req as any).files?.thumbnail?.[0]; + if (thumb && canPreview(kind)) { + try { await saveThumb(a.teamId, row.id, key, await fsp.readFile(thumb.path), by); } catch (e) { console.warn("Print thumbnail skipped:", (e as any)?.message || e); } + } + const log = logStmt(a.teamId, by, "upload", { fileId: row.id, folderId, detail: { name: row.name, size: file.size } }); + await dbRun(log.sql, ...log.args); + res.json({ id: row.id, name: row.name, renamed: row.name !== name }); + } catch (e) { sendError(res, e, "upload"); } + finally { for (const p of cleanup) await fsp.unlink(p).catch(() => {}); } + }); + + app.get("/api/prints/files/:id", async (req, res) => { + try { + const a = await access(req, res, false); if (!a) return; + const f = await oneFile(a.teamId, Number(req.params.id)); + if (!f) return res.status(404).json({ error: "Not found" }); + res.json(f); + } catch (e) { sendError(res, e, "file"); } + }); + + // Details: name, folder, notes, robot/season, CAD part, subsystem, material, status, copies. + app.patch("/api/prints/files/:id", async (req, res) => { + try { + const a = await access(req, res, true); if (!a) return; + const row = await dbGet("SELECT * FROM print_files WHERE id = ? AND team_id = ?", Number(req.params.id), a.teamId); + if (!row) return res.status(404).json({ error: "Not found" }); + const body = req.body || {}; + const sets: string[] = []; + const args: any[] = []; + const changes: Record = {}; + const set = (col: string, v: unknown, label = col) => { if (row[col] !== v) { sets.push(`${col} = ?`); args.push(v); changes[label] = v; } }; + let folderId: number | null = row.folder_id == null ? null : Number(row.folder_id); + if (body.folderId !== undefined) { + const f = await teamFolder(a.teamId, body.folderId); + if (f === undefined) return res.status(404).json({ error: "That folder no longer exists" }); + folderId = f; + set("folder_id", f, "folderId"); + } + if (body.name !== undefined) { + const n = cleanName(body.name); + if (!n) return res.status(400).json({ error: "File names can't be empty or contain / \\ : * ? \" < > |" }); + if (printKind(n) !== row.kind) return res.status(400).json({ error: `Keep the file's .${String(row.name).split(".").pop()} ending` }); + set("name", n); + } else if (folderId !== (row.folder_id == null ? null : Number(row.folder_id))) { + // Moving into a folder with a file of the same name: this one becomes "name (2)". + set("name", uniqueName(row.name, await namesIn(a.teamId, folderId, Number(row.id)))); + } + if (body.notes !== undefined) set("notes", typeof body.notes === "string" ? body.notes.slice(0, 2000) : ""); + let robotId: number | null | undefined; + if (body.robotId !== undefined) { + if (body.robotId === null) robotId = null; + else { + const r = await dbGet("SELECT id FROM cad_robots WHERE id = ? AND team_id = ?", Number(body.robotId), a.teamId); + if (!r) return res.status(404).json({ error: "That robot no longer exists" }); + robotId = Number(r.id); + } + } + if (body.cadPartId !== undefined) { + if (body.cadPartId === null) set("cad_part_id", null, "cadPartId"); + else { + const p = await dbGet("SELECT id, robot_id FROM cad_parts WHERE id = ? AND team_id = ?", Number(body.cadPartId), a.teamId); + if (!p) return res.status(404).json({ error: "That CAD part no longer exists" }); + set("cad_part_id", Number(p.id), "cadPartId"); + // A part belongs to a robot: the file follows it unless the request names one. + if (robotId === undefined && p.robot_id != null) robotId = Number(p.robot_id); + } + } + if (robotId !== undefined) set("robot_id", robotId, "robotId"); + if (body.season !== undefined) { + if (body.season !== null && !isSeason(Number(body.season))) return res.status(400).json({ error: "Pick a season" }); + set("season", body.season === null ? null : Number(body.season)); + } + if (body.subsystem !== undefined) set("subsystem", cleanSubsystem(body.subsystem) || null); + if (body.material !== undefined) set("material", cleanMaterial(body.material) || null); + if (body.copiesNeeded !== undefined) { + const n = intOrNull(body.copiesNeeded, 999); + if (n === undefined) return res.status(400).json({ error: "Copies needed is a whole number up to 999" }); + set("copies_needed", n, "copiesNeeded"); + } + let status = row.status; + if (body.status !== undefined) { + if (!isPrintStatus(body.status)) return res.status(400).json({ error: "Unknown print status" }); + status = body.status; + } + if (body.copiesPrinted !== undefined) { + const n = intOrNull(body.copiesPrinted, 999); + if (n === undefined || n === null) return res.status(400).json({ error: "Copies printed is a whole number up to 999" }); + set("copies_printed", n, "copiesPrinted"); + // Reaching the copies needed marks it printed, unless the request says otherwise. + if (body.status === undefined && (status === "to_print" || status === "printing")) { + const need = body.copiesNeeded !== undefined ? intOrNull(body.copiesNeeded, 999) : row.copies_needed != null ? Number(row.copies_needed) + : row.cad_part_id ? Number((await dbGet("SELECT quantity FROM cad_parts WHERE id = ? AND team_id = ?", row.cad_part_id, a.teamId))?.quantity ?? NaN) : NaN; + if (need != null && Number.isFinite(need) && need > 0 && n >= need) status = "printed"; + } + } + set("status", status); + if (!sets.length) return res.json(await oneFile(a.teamId, Number(row.id))); + const by = { id: a.auth.memberId, name: await memberName(a.auth.memberId) }; + const stmts: Stmt[] = [{ sql: `UPDATE print_files SET ${sets.join(", ")}, updated_at = ? WHERE id = ? AND team_id = ?`, args: [...args, now(), row.id, a.teamId] }]; + const action = changes.folderId !== undefined ? "move" : changes.name !== undefined ? "rename" : changes.status !== undefined && Object.keys(changes).length === 1 ? "status" : "edit"; + stmts.push(logStmt(a.teamId, by, action, { fileId: Number(row.id), folderId, detail: { ...changes, ...(changes.name !== undefined ? { from: row.name } : {}) } })); + try { await deps.dbBatchResults(stmts); } + catch (e) { + if (isUnique(e)) return res.status(409).json({ error: `There's already a file named "${changes.name ?? row.name}" there` }); + throw e; + } + res.json(await oneFile(a.teamId, Number(row.id))); + } catch (e) { sendError(res, e, "file update"); } + }); + + app.delete("/api/prints/files/:id", async (req, res) => { + try { + const a = await access(req, res, true); if (!a) return; + const by = { id: a.auth.memberId, name: await memberName(a.auth.memberId) }; + const removed = await removeFiles(a.teamId, [Number(req.params.id)], by); + if (!removed) return res.status(404).json({ error: "Not found" }); + res.json({ success: true }); + } catch (e) { sendError(res, e, "file delete"); } + }); + + async function removeFiles(teamId: number, list: number[], by: { id: number | null; name: string }) { + if (!list.length) return 0; + const inList = `IN (${qs(list.length)})`; + const out = await deps.dbBatchResults([ + { sql: `DELETE FROM print_collection_files WHERE team_id = ? AND file_id ${inList}`, args: [teamId, ...list] }, + { sql: `DELETE FROM print_files WHERE team_id = ? AND id ${inList} RETURNING id, name, folder_id, storage_key, thumb_key`, args: [teamId, ...list] }, + ]); + const rows = out[1].rows; + if (rows.length) await deps.dbBatchResults(rows.map((r) => logStmt(teamId, by, "delete", { fileId: Number(r.id), folderId: r.folder_id ?? null, detail: { name: r.name } }))); + for (const r of rows) { await store.remove(r.storage_key); await store.remove(r.thumb_key); } + return rows.length; + } + + // Several files at once: move, status, delete, add to / remove from a collection. + app.post("/api/prints/files/bulk", async (req, res) => { + try { + const a = await access(req, res, true); if (!a) return; + const body = req.body || {}; + const list = ids(body.ids); + if (!list.length) return res.status(400).json({ error: "Pick some files" }); + if (list.length > MAX_BULK) return res.status(400).json({ error: `At most ${MAX_BULK} files at a time` }); + const rows = await dbAll(`SELECT id, name, folder_id, status FROM print_files WHERE team_id = ? AND id IN (${qs(list.length)})`, a.teamId, ...list); + if (!rows.length) return res.status(404).json({ error: "Those files no longer exist" }); + const mine = rows.map((r) => Number(r.id)); + const by = { id: a.auth.memberId, name: await memberName(a.auth.memberId) }; + const t = now(); + const stmts: Stmt[] = []; + switch (body.action) { + case "move": { + const folderId = await teamFolder(a.teamId, body.folderId); + if (folderId === undefined) return res.status(404).json({ error: "That folder no longer exists" }); + const taken = await namesIn(a.teamId, folderId); + const moving = rows.filter((r) => (r.folder_id == null ? null : Number(r.folder_id)) !== folderId); + for (const r of moving) taken.delete(String(r.name).toLowerCase()); + // Names already in the target stay; each moving file takes its name, or the next "(n)". + for (const r of rows.filter((x) => !moving.includes(x))) taken.add(String(r.name).toLowerCase()); + for (const r of moving) { + const name = uniqueName(r.name, taken); + taken.add(name.toLowerCase()); + stmts.push({ sql: "UPDATE print_files SET folder_id = ?, name = ?, updated_at = ? WHERE id = ? AND team_id = ?", args: [folderId, name, t, r.id, a.teamId] }); + stmts.push(logStmt(a.teamId, by, "move", { fileId: Number(r.id), folderId, detail: { from: r.folder_id ?? null, to: folderId, ...(name !== r.name ? { name, was: r.name } : {}) } })); + } + break; + } + case "status": { + if (!isPrintStatus(body.status)) return res.status(400).json({ error: "Unknown print status" }); + const status: PrintStatus = body.status; + for (const r of rows.filter((x) => x.status !== status)) { + stmts.push({ + // Marking printed counts every copy needed as printed (when the count is known). + sql: `UPDATE print_files SET status = ?, updated_at = ?, copies_printed = CASE WHEN ? = 'printed' + THEN MAX(copies_printed, COALESCE(copies_needed, (SELECT p.quantity FROM cad_parts p WHERE p.id = print_files.cad_part_id AND p.team_id = print_files.team_id), copies_printed)) + ELSE copies_printed END + WHERE id = ? AND team_id = ?`, + args: [status, t, status, r.id, a.teamId], + }); + stmts.push(logStmt(a.teamId, by, "status", { fileId: Number(r.id), folderId: r.folder_id ?? null, detail: { from: r.status, to: status } })); + } + break; + } + case "delete": { + const n = await removeFiles(a.teamId, mine, by); + return res.json({ success: true, files: n }); + } + case "collect": + case "uncollect": { + const c = await dbGet("SELECT id, name FROM print_collections WHERE id = ? AND team_id = ?", Number(body.collectionId), a.teamId); + if (!c) return res.status(404).json({ error: "That collection no longer exists" }); + const have = new Set((await dbAll(`SELECT file_id FROM print_collection_files WHERE collection_id = ? AND file_id IN (${qs(mine.length)})`, c.id, ...mine)).map((x) => Number(x.file_id))); + const change = body.action === "collect" ? mine.filter((id) => !have.has(id)) : mine.filter((id) => have.has(id)); + for (const id of change) { + stmts.push(body.action === "collect" + // Only while the file and the collection still exist. + ? { sql: `INSERT OR IGNORE INTO print_collection_files (collection_id, file_id, team_id, added_at) + SELECT ?, f.id, f.team_id, ? FROM print_files f WHERE f.id = ? AND f.team_id = ? AND EXISTS (SELECT 1 FROM print_collections c WHERE c.id = ? AND c.team_id = ?)`, + args: [c.id, t, id, a.teamId, c.id, a.teamId] } + : { sql: "DELETE FROM print_collection_files WHERE collection_id = ? AND file_id = ? AND team_id = ?", args: [c.id, id, a.teamId] }); + stmts.push(logStmt(a.teamId, by, body.action, { fileId: id, detail: { collection: c.name, collectionId: Number(c.id) } })); + } + break; + } + default: + return res.status(400).json({ error: "Unknown action" }); + } + if (stmts.length) { + try { await deps.dbBatchResults(stmts); } + catch (e) { + if (isUnique(e)) return res.status(409).json({ error: "A file with one of those names just appeared there. Try again." }); + throw e; + } + } + res.json({ success: true, files: stmts.length / 2 }); + } catch (e) { sendError(res, e, "bulk"); } + }); + + app.get("/api/prints/files/:id/download", async (req, res) => { + try { + const a = await access(req, res, false); if (!a) return; + const r = await dbGet("SELECT id, name, size, sha256, storage_key FROM print_files WHERE id = ? AND team_id = ?", Number(req.params.id), a.teamId); + if (!r) return res.status(404).json({ error: "Not found" }); + const etag = `"p${r.id}-${String(r.sha256).slice(0, 16)}"`; + res.setHeader("ETag", etag); + res.setHeader("Cache-Control", "private, no-cache"); + res.setHeader("X-Content-Type-Options", "nosniff"); + res.setHeader("Content-Security-Policy", "default-src 'none'; sandbox"); + if (req.headers["if-none-match"] === etag) return res.status(304).end(); + if (!(await store.exists(r.storage_key))) return res.status(404).json({ error: "This file is missing from storage" }); + res.setHeader("Content-Type", "application/octet-stream"); + res.setHeader("Content-Disposition", disposition("attachment", String(r.name))); + res.setHeader("Content-Length", String(r.size)); + fs.createReadStream(store.pathOf(r.storage_key)).on("error", () => res.destroy()).pipe(res); + } catch (e) { sendError(res, e, "download"); } + }); + + app.get("/api/prints/files/:id/thumbnail", async (req, res) => { + try { + const a = await access(req, res, false); if (!a) return; + const r = await dbGet("SELECT id, thumb_key FROM print_files WHERE id = ? AND team_id = ?", Number(req.params.id), a.teamId); + if (!r?.thumb_key || !(await store.exists(r.thumb_key))) return res.status(404).end(); + const etag = `"t${r.id}-${String(r.thumb_key).slice(-48, -16)}"`; + res.setHeader("ETag", etag); + res.setHeader("Cache-Control", "private, no-cache"); + res.setHeader("X-Content-Type-Options", "nosniff"); + if (req.headers["if-none-match"] === etag) return res.status(304).end(); + res.setHeader("Content-Type", "image/png"); + fs.createReadStream(store.pathOf(r.thumb_key)).on("error", () => res.destroy()).pipe(res); + } catch (e) { sendError(res, e, "thumbnail"); } + }); + + // A thumbnail drawn in the browser for a file that has none yet (field "thumbnail", PNG). + app.put("/api/prints/files/:id/thumbnail", async (req, res) => { + const cleanup: string[] = []; + try { + const a = await access(req, res, true); if (!a) return; + const r = await dbGet("SELECT id, kind, storage_key FROM print_files WHERE id = ? AND team_id = ?", Number(req.params.id), a.teamId); + if (!r) return res.status(404).json({ error: "Not found" }); + if (!canPreview(r.kind)) return res.status(400).json({ error: "Only STL, 3MF and OBJ files get thumbnails" }); + if ((Number(req.headers["content-length"]) || 0) > THUMB_MAX + 16 * 1024) return res.status(413).json({ error: "Thumbnails are up to 512 KB" }); + await new Promise((resolve, reject) => upload.single("thumbnail")(req, res, (err: any) => { + if ((req as any).file) cleanup.push((req as any).file.path); + err ? reject(err instanceof PrintError ? err : new PrintError("unsupported", err?.message || "Upload failed")) : resolve(); + })); + const f = (req as any).file; + if (!f) return res.status(400).json({ error: "No thumbnail" }); + await saveThumb(a.teamId, Number(r.id), r.storage_key, await fsp.readFile(f.path), null); + res.json({ success: true }); + } catch (e) { sendError(res, e, "thumbnail upload"); } + finally { for (const p of cleanup) await fsp.unlink(p).catch(() => {}); } + }); + + // A ZIP of a folder (?folder=|root, everything below it) or of chosen files (?files=1,2,3, kept in their + // folders). ?name= names the ZIP (a group's name). + app.get("/api/prints/zip", async (req, res) => { + try { + const a = await access(req, res, false); if (!a) return; + const map = await folderTrailNames(a.teamId); + let rows: any[] = []; + let base: number | null = null; + let zipName = "Prints"; + if (req.query.folder !== undefined) { + if (req.query.folder === "root") rows = await dbAll("SELECT id, name, folder_id, size, storage_key, updated_at FROM print_files WHERE team_id = ?", a.teamId); + else { + const tree = await subtree(a.teamId, Number(req.query.folder)); + if (!tree.length) return res.status(404).json({ error: "Not found" }); + base = tree[0]; + zipName = map.get(base)?.name ?? zipName; + rows = await dbAll(`SELECT id, name, folder_id, size, storage_key, updated_at FROM print_files WHERE team_id = ? AND folder_id IN (${qs(tree.length)})`, a.teamId, ...tree); + } + } else { + const list = String(req.query.files || "").split(",").map(Number).filter((n) => Number.isInteger(n) && n > 0).slice(0, 5000); + if (!list.length) return res.status(400).json({ error: "Pick some files" }); + rows = await dbAll(`SELECT id, name, folder_id, size, storage_key, updated_at FROM print_files WHERE team_id = ? AND id IN (${qs(list.length)})`, a.teamId, ...list); + if (typeof req.query.name === "string" && req.query.name.trim()) zipName = safeName(req.query.name, "Prints"); + } + if (!rows.length) return res.status(404).json({ error: "There are no files to download here" }); + const total = rows.reduce((s, r) => s + (Number(r.size) || 0), 0); + if (total > ZIP_MAX_BYTES) return res.status(413).json({ error: "That's too much for one download. Download smaller folders." }); + const present: any[] = []; + for (const r of rows) if (await store.exists(r.storage_key)) present.push(r); + const names = uniqueZipNames(present.map((r) => [...trail(map, r.folder_id == null ? null : Number(r.folder_id), base), r.name].join("/"))); + res.setHeader("Content-Type", "application/zip"); + res.setHeader("Content-Disposition", disposition("attachment", `${zipName}.zip`)); + res.setHeader("Cache-Control", "private, no-store"); + res.setHeader("X-Content-Type-Options", "nosniff"); + try { + await writeZip(res, present.map((r, i) => ({ name: names[i], path: store.pathOf(r.storage_key), mtime: new Date(r.updated_at || Date.now()) }))); + res.end(); + } catch (e) { + if (!res.writableEnded) res.destroy(); + if ((e as any)?.message !== "closed") console.error("Prints ZIP failed:", (e as any)?.message || e); + } + } catch (e) { if (!res.headersSent) sendError(res, e, "zip"); else res.destroy(); } + }); + + // ---- Collections ---- + app.post("/api/prints/collections", async (req, res) => { + try { + const a = await access(req, res, true); if (!a) return; + const name = typeof req.body?.name === "string" ? req.body.name.replace(/[\u0000-\u001f]/g, "").trim().slice(0, 40) : ""; + if (!name) return res.status(400).json({ error: "Name the collection" }); + const count = await dbGet("SELECT COUNT(*) AS n FROM print_collections WHERE team_id = ?", a.teamId); + if (Number(count?.n) >= MAX_COLLECTIONS) return res.status(400).json({ error: `A library holds at most ${MAX_COLLECTIONS} collections` }); + const by = { id: a.auth.memberId, name: await memberName(a.auth.memberId) }; + try { + const r = await dbRun("INSERT INTO print_collections (team_id, name, created_by, created_at) VALUES (?, ?, ?, ?)", a.teamId, name, by.id, now()); + const log = logStmt(a.teamId, by, "collection_create", { detail: { name, collectionId: Number(r.lastInsertRowid) } }); + await dbRun(log.sql, ...log.args); + res.json({ id: Number(r.lastInsertRowid), name }); + } catch (e) { + if (isUnique(e)) return res.status(409).json({ error: `There's already a collection named "${name}"` }); + throw e; + } + } catch (e) { sendError(res, e, "collection create"); } + }); + + app.patch("/api/prints/collections/:id", async (req, res) => { + try { + const a = await access(req, res, true); if (!a) return; + const c = await dbGet("SELECT id, name FROM print_collections WHERE id = ? AND team_id = ?", Number(req.params.id), a.teamId); + if (!c) return res.status(404).json({ error: "Not found" }); + const name = typeof req.body?.name === "string" ? req.body.name.replace(/[\u0000-\u001f]/g, "").trim().slice(0, 40) : ""; + if (!name) return res.status(400).json({ error: "Name the collection" }); + const by = { id: a.auth.memberId, name: await memberName(a.auth.memberId) }; + try { + await deps.dbBatchResults([ + { sql: "UPDATE print_collections SET name = ? WHERE id = ? AND team_id = ?", args: [name, c.id, a.teamId] }, + logStmt(a.teamId, by, "collection_rename", { detail: { from: c.name, to: name, collectionId: Number(c.id) } }), + ]); + } catch (e) { + if (isUnique(e)) return res.status(409).json({ error: `There's already a collection named "${name}"` }); + throw e; + } + res.json({ id: Number(c.id), name }); + } catch (e) { sendError(res, e, "collection rename"); } + }); + + // Deleting a collection only ungroups its files; every file stays where it lives. + app.delete("/api/prints/collections/:id", async (req, res) => { + try { + const a = await access(req, res, true); if (!a) return; + const c = await dbGet("SELECT id, name FROM print_collections WHERE id = ? AND team_id = ?", Number(req.params.id), a.teamId); + if (!c) return res.status(404).json({ error: "Not found" }); + const by = { id: a.auth.memberId, name: await memberName(a.auth.memberId) }; + await deps.dbBatchResults([ + { sql: "DELETE FROM print_collection_files WHERE collection_id = ? AND team_id = ?", args: [c.id, a.teamId] }, + { sql: "DELETE FROM print_collections WHERE id = ? AND team_id = ?", args: [c.id, a.teamId] }, + logStmt(a.teamId, by, "collection_delete", { detail: { name: c.name, collectionId: Number(c.id) } }), + ]); + res.json({ success: true }); + } catch (e) { sendError(res, e, "collection delete"); } + }); + + // Recent changes: the library's, or one file's (?file=). + app.get("/api/prints/activity", async (req, res) => { + try { + const a = await access(req, res, false); if (!a) return; + const file = Number(req.query.file); + const rows = Number.isInteger(file) && file > 0 + ? await dbAll("SELECT id, file_id, folder_id, member_name, action, detail, at FROM print_activity WHERE team_id = ? AND file_id = ? ORDER BY id DESC LIMIT 50", a.teamId, file) + : await dbAll("SELECT id, file_id, folder_id, member_name, action, detail, at FROM print_activity WHERE team_id = ? ORDER BY id DESC LIMIT 100", a.teamId); + res.json(rows.map((r) => { + let detail: any = {}; + try { detail = JSON.parse(r.detail || "{}"); } catch { /* none */ } + return { id: Number(r.id), fileId: r.file_id == null ? null : Number(r.file_id), folderId: r.folder_id == null ? null : Number(r.folder_id), by: r.member_name || "Someone", action: r.action, detail, at: r.at }; + })); + } catch (e) { sendError(res, e, "activity"); } + }); + } + + // ---- Filing from inside the server (Control Kit) ---- + + /** The folder at `segments` under the root, made as needed. */ + async function ensureFolders(teamId: number, segments: string[], by: { id: number | null; name: string }): Promise { + let parent: number | null = null; + for (const name of segments) { + const find = () => dbGet("SELECT id FROM print_folders WHERE team_id = ? AND COALESCE(parent_id, 0) = ? AND name = ? COLLATE NOCASE", teamId, parent ?? 0, name); + let hit = await find(); + if (!hit) { + const t = now(); + const r = await dbRun("INSERT OR IGNORE INTO print_folders (team_id, parent_id, name, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)", teamId, parent, name, by.id, t, t); + if (r.changes) { const l = logStmt(teamId, by, "folder_create", { folderId: Number(r.lastInsertRowid), detail: { name, parentId: parent } }); await dbRun(l.sql, ...l.args); } + hit = await find(); + } + parent = Number(hit.id); + } + return parent!; + } + + /** + * File a print from inside the server: `path` is the folder ("A / B / C" or segments, made as needed; unusable + * characters in each part become "-"), `name` the file name with its extension. A file of the same name in that + * folder that Control Kit sent before is replaced in place: its status, notes and collections stay, and a printed + * one goes back to "To print" when the bytes changed. A same-named file a person uploaded is kept; the new one gets + * "name (2)". Throws PrintError (unsupported, too_large, quota, disk_full, empty, bad_name). + */ + async function filePrint(input: { + teamId: number; path: string | readonly string[]; name: string; bytes: Uint8Array; + by?: { memberId?: number | null; name?: string | null } | null; source?: "upload" | "control-kit"; + robotId?: number | null; cadPartId?: number | null; ckPartKey?: string | null; + }): Promise<{ fileId: number; folderId: number; name: string; replaced: boolean; unchanged: boolean }> { + const segments = (Array.isArray(input.path) ? [...input.path] : String(input.path).split("/")).map((s) => String(s).trim()).filter(Boolean).map((s) => safeName(s)); + if (!segments.length || segments.length > 12) throw new PrintError("bad_name", "A print needs a folder path"); + const name = safeName(input.name, ""); + if (!name) throw new PrintError("bad_name", "A print needs a file name"); + const kind = printKind(name); + if (!kind) throw new PrintError("unsupported", "Prints takes STL, 3MF, STEP/STP, OBJ and G-code files"); + const bytes = Buffer.from(input.bytes.buffer, input.bytes.byteOffset, input.bytes.byteLength); + if (!bytes.length) throw new PrintError("empty", "That file is empty"); + if (bytes.length > limits.maxFile) throw new PrintError("too_large", `Files can be up to ${mbText(limits.maxFile)}`, 413); + await checkDisk(bytes.length); + const source = input.source ?? "control-kit"; + const by = { id: input.by?.memberId ?? null, name: input.by?.name || (source === "control-kit" ? "Control Kit" : "Control Point") }; + const folderId = await ensureFolders(input.teamId, segments, by); + const sha = createHash("sha256").update(bytes).digest("hex"); + const existing = await dbGet("SELECT * FROM print_files WHERE team_id = ? AND COALESCE(folder_id, 0) = ? AND name = ? COLLATE NOCASE", input.teamId, folderId, name); + if (existing && existing.source === source && source === "control-kit" && (!input.ckPartKey || !existing.ck_part_key || existing.ck_part_key === input.ckPartKey)) { + if (existing.sha256 === sha) return { fileId: Number(existing.id), folderId, name: existing.name, replaced: false, unchanged: true }; + const key = store.newKey(input.teamId); + await store.write(key, bytes); + const r = await dbRun( + `UPDATE print_files SET size = ?, sha256 = ?, storage_key = ?, thumb_key = NULL, updated_at = ?, + status = CASE WHEN status = 'printed' THEN 'to_print' ELSE status END, + robot_id = COALESCE(?, robot_id), cad_part_id = COALESCE(?, cad_part_id), ck_part_key = COALESCE(?, ck_part_key) + WHERE id = ? AND team_id = ? AND storage_key = ? + AND (SELECT COALESCE(SUM(size), 0) FROM print_files WHERE team_id = ?) - ? + ? <= ?`, + bytes.length, sha, key, now(), input.robotId ?? null, input.cadPartId ?? null, input.ckPartKey ?? null, + existing.id, input.teamId, existing.storage_key, input.teamId, Number(existing.size) || 0, bytes.length, limits.quota); + if (!r.changes) { + await store.remove(key); + const still = await dbGet("SELECT id FROM print_files WHERE id = ?", existing.id); + if (still) throw new PrintError("quota", quotaMessage(), 413); + // Deleted meanwhile: file it as new. + return filePrint(input); + } + await store.remove(existing.storage_key); + await store.remove(existing.thumb_key); + const l = logStmt(input.teamId, by, "replace", { fileId: Number(existing.id), folderId, detail: { name: existing.name, size: bytes.length, wasPrinted: existing.status === "printed" } }); + await dbRun(l.sql, ...l.args); + return { fileId: Number(existing.id), folderId, name: existing.name, replaced: true, unchanged: false }; + } + const key = store.newKey(input.teamId); + await store.write(key, bytes); + let row: { id: number; name: string }; + try { + row = await insertFile(input.teamId, { folderId, name, kind, size: bytes.length, sha, key, by, source, robotId: input.robotId, cadPartId: input.cadPartId, ckPartKey: input.ckPartKey }); + } catch (e) { await store.remove(key); throw e; } + const l = logStmt(input.teamId, by, "upload", { fileId: row.id, folderId, detail: { name: row.name, size: bytes.length, source } }); + await dbRun(l.sql, ...l.args); + return { fileId: row.id, folderId, name: row.name, replaced: false, unchanged: false }; + } + + /** + * Control Kit's entry point: file a print for a robot under " / / Control Kit", linked to + * the robot and to the robot's CAD part with that Control Kit identity (cad_parts.ck_part_key) when there is one. + */ + async function fileControlKitPrint(input: { teamId: number; robotId: number; name: string; bytes: Uint8Array; ckPartKey?: string | null; sentBy?: string | null }) { + const robot = await dbGet("SELECT id, name, season FROM cad_robots WHERE id = ? AND team_id = ?", input.robotId, input.teamId); + if (!robot) throw new PrintError("bad_name", "That robot no longer exists", 404); + const part = input.ckPartKey + ? await dbGet("SELECT id FROM cad_parts WHERE team_id = ? AND robot_id = ? AND ck_part_key = ? ORDER BY id LIMIT 1", input.teamId, robot.id, input.ckPartKey) + : null; + return filePrint({ + teamId: input.teamId, path: controlKitPrintPath(Number(robot.season), String(robot.name)), name: input.name, bytes: input.bytes, + by: { name: input.sentBy ? `${input.sentBy} via Control Kit` : "Control Kit" }, source: "control-kit", + robotId: Number(robot.id), cadPartId: part ? Number(part.id) : null, ckPartKey: input.ckPartKey ?? null, + }); + } + + /** The bytes behind deleted rows (a deleted workspace). */ + async function removeStored(rows: { storage_key?: string | null; thumb_key?: string | null }[]) { + for (const r of rows) { await store.remove(r.storage_key); await store.remove(r.thumb_key); } + } + + return { registerRoutes, filePrint, fileControlKitPrint, removeStored, limits, store }; +} + +/** Where Control Kit's prints for a robot are filed: "2025–26 DECODE / Competition robot / Control Kit". */ +export function controlKitPrintPath(season: number, robotName: string): string[] { + return [safeName(seasonName(season)), safeName(robotName, "Robot"), "Control Kit"]; +} + +/** Statements that delete a workspace's library rows; the storage_key/thumb_key rows of the RETURNING one go to removeStored. */ +export const PRINTS_WORKSPACE_DELETE = { + files: "DELETE FROM print_files WHERE team_id = ? RETURNING storage_key, thumb_key", + others: [ + "DELETE FROM print_collection_files WHERE team_id = ?", + "DELETE FROM print_collections WHERE team_id = ?", + "DELETE FROM print_folders WHERE team_id = ?", + "DELETE FROM print_activity WHERE team_id = ?", + ], +}; diff --git a/server/zipStream.ts b/server/zipStream.ts new file mode 100644 index 000000000..2e239085e --- /dev/null +++ b/server/zipStream.ts @@ -0,0 +1,126 @@ +// A streaming ZIP writer for the Prints library's folder and group downloads. +// Each entry is deflated straight from disk to the response with a data +// descriptor (sizes and CRC after the data), so a 2 GB folder never sits in +// memory. Names are UTF-8 (flag bit 11). No ZIP64: callers keep the total +// under ZIP_MAX_BYTES. +import { createReadStream } from "node:fs"; +import { createDeflateRaw } from "node:zlib"; +import type { Writable } from "node:stream"; + +/** Classic ZIP offsets are 32-bit; stay well under 4 GiB (deflate can grow incompressible data slightly). */ +export const ZIP_MAX_BYTES = 3.5 * 1024 ** 3; + +const CRC_TABLE = (() => { + const t = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + t[n] = c >>> 0; + } + return t; +})(); + +export function crc32(buf: Uint8Array, crc = 0): number { + let c = (crc ^ 0xffffffff) >>> 0; + for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +} + +function dosTime(d: Date): { time: number; date: number } { + const y = Math.max(1980, d.getFullYear()); + return { + time: (d.getHours() << 11) | (d.getMinutes() << 5) | Math.floor(d.getSeconds() / 2), + date: ((y - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate(), + }; +} + +export interface ZipEntry { + /** Path inside the archive, "/"-separated; every segment already a safe name. */ + name: string; + /** File on disk. */ + path: string; + mtime: Date; +} + +/** Write a ZIP of `entries` to `out`, honouring backpressure. Rejects if `out` closes early or a file can't be read. */ +export async function writeZip(out: Writable, entries: ZipEntry[]): Promise { + let offset = 0; + const write = (buf: Buffer) => new Promise((resolve, reject) => { + if (out.destroyed || (out as any).writableEnded) return reject(new Error("closed")); + offset += buf.length; + if (out.write(buf)) return resolve(); + const done = (err?: Error) => { out.off("drain", ok); out.off("close", closed); err ? reject(err) : resolve(); }; + const ok = () => done(); + const closed = () => done(new Error("closed")); + out.once("drain", ok); + out.once("close", closed); + }); + const central: Buffer[] = []; + for (const e of entries) { + const name = Buffer.from(e.name, "utf8"); + const { time, date } = dosTime(e.mtime); + const start = offset; + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); + local.writeUInt16LE(0x0808, 6); // data descriptor + UTF-8 names + local.writeUInt16LE(8, 8); // deflate + local.writeUInt16LE(time, 10); + local.writeUInt16LE(date, 12); + local.writeUInt16LE(name.length, 26); + await write(Buffer.concat([local, name])); + let crc = 0, usize = 0, csize = 0; + const src = createReadStream(e.path); + const deflate = createDeflateRaw({ level: 6 }); + src.on("data", (c: Buffer | string) => { const b = typeof c === "string" ? Buffer.from(c) : c; crc = crc32(b, crc); usize += b.length; }); + src.on("error", (err) => deflate.destroy(err)); + src.pipe(deflate); + try { + for await (const chunk of deflate) { csize += (chunk as Buffer).length; await write(chunk as Buffer); } + } finally { src.destroy(); } + const desc = Buffer.alloc(16); + desc.writeUInt32LE(0x08074b50, 0); + desc.writeUInt32LE(crc, 4); + desc.writeUInt32LE(csize, 8); + desc.writeUInt32LE(usize, 12); + await write(desc); + const c = Buffer.alloc(46); + c.writeUInt32LE(0x02014b50, 0); + c.writeUInt16LE(20, 4); + c.writeUInt16LE(20, 6); + c.writeUInt16LE(0x0808, 8); + c.writeUInt16LE(8, 10); + c.writeUInt16LE(time, 12); + c.writeUInt16LE(date, 14); + c.writeUInt32LE(crc, 16); + c.writeUInt32LE(csize, 20); + c.writeUInt32LE(usize, 24); + c.writeUInt16LE(name.length, 28); + c.writeUInt32LE(start, 42); + central.push(c, name); + } + const cdStart = offset; + const cd = Buffer.concat(central); + await write(cd); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); + end.writeUInt16LE(entries.length, 8); + end.writeUInt16LE(entries.length, 10); + end.writeUInt32LE(cd.length, 12); + end.writeUInt32LE(cdStart, 16); + await write(end); +} + +/** Archive paths made unique ("a.stl", "a (2).stl"), case-insensitively, as every OS will unpack them. */ +export function uniqueZipNames(names: string[]): string[] { + const taken = new Set(); + return names.map((n) => { + let out = n; + const dot = n.lastIndexOf("."); + const slash = n.lastIndexOf("/"); + const [stem, ext] = dot > slash + 1 ? [n.slice(0, dot), n.slice(dot)] : [n, ""]; + for (let i = 2; taken.has(out.toLowerCase()); i++) out = `${stem} (${i})${ext}`; + taken.add(out.toLowerCase()); + return out; + }); +} diff --git a/src/App.tsx b/src/App.tsx index dde98db5f..7ad47c8fc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useMemo, useRef, Suspense } from 'react'; import { isNavIdDisabled, parseDisabledModules } from './types/teamModules'; import { Routes, Route, Navigate, useLocation, useNavigate, useSearchParams } from 'react-router-dom'; -import { LayoutDashboard, Users, Crown, Pencil, CalendarCheck, CheckSquare, Wallet, Globe, Newspaper, Mail, X, ChevronRight, ChevronDown, Plus, AtSign, TrendingUp, MessageSquare, Lock, Calendar, UserCheck, Zap, Trash2, Bolt, Code2, Check, ShieldCheck, UserX, Copy, Sparkles, Save, Trophy, Layers, Box, FileBox, ClipboardCheck, ClipboardList, Package, ChevronUp, Phone, Video, ScanLine, Keyboard, BadgeCheck, Loader2, CheckCircle2, Clock3, ListTodo } from 'lucide-react'; +import { LayoutDashboard, Users, Crown, Pencil, CalendarCheck, CheckSquare, Wallet, Globe, Newspaper, Mail, X, ChevronRight, ChevronDown, Plus, AtSign, TrendingUp, MessageSquare, Lock, Calendar, UserCheck, Zap, Trash2, Bolt, Code2, Check, ShieldCheck, UserX, Copy, Sparkles, Save, Trophy, Layers, Box, Printer, FileBox, ClipboardCheck, ClipboardList, Package, ChevronUp, Phone, Video, ScanLine, Keyboard, BadgeCheck, Loader2, CheckCircle2, Clock3, ListTodo } from 'lucide-react'; import { ContextMenuProvider, useContextMenu } from './components/contextmenu/ContextMenuProvider'; import { motion } from 'motion/react'; // Heavy libraries stay out of the initial bundle: recharts (via TaskAnalytics), @@ -35,6 +35,7 @@ const OutreachPage = React.lazy(() => import('./modern/pages/outreach/OutreachPa const ChecklistsPage = React.lazy(() => import('./modern/pages/checklists/ChecklistsPage').then((m) => ({ default: m.ChecklistsPage }))); const ResourcesPage = React.lazy(() => import('./modern/pages/resources/ResourcesPage').then((m) => ({ default: m.ResourcesPage }))); const CadPage = React.lazy(() => import('./modern/pages/cad/CadPage').then((m) => ({ default: m.CadPage }))); +const PrintsPage = React.lazy(() => import('./modern/pages/prints/PrintsPage').then((m) => ({ default: m.PrintsPage }))); /** Lightweight placeholder while a heavy lazy chunk (charts, code editor) loads. */ function ChartLoadingFallback({ label = 'Loading…' }: { label?: string }) { @@ -309,6 +310,7 @@ const navItems = [ { id: 'inventory', path: 'inventory', labelKey: 'nav.inventory', icon: Zap, scope: 'inventory', group: 'Engineering' }, { id: 'checklists', path: 'checklists', labelKey: 'nav.checklists', icon: ClipboardList, group: 'Engineering' }, { id: 'cad', path: 'cad', labelKey: 'nav.cad', icon: Box, group: 'Engineering' }, + { id: 'prints', path: 'prints', labelKey: 'nav.prints', icon: Printer, group: 'Engineering' }, { id: 'code', path: 'code', labelKey: 'nav.code', icon: Code2, scope: 'code', group: 'Engineering' }, { id: 'outreach', path: 'outreach', labelKey: 'nav.outreach', icon: Globe, group: 'Outreach' }, { id: 'budget', path: 'budget', labelKey: 'nav.budget', icon: Wallet, scope: 'budget', group: 'Outreach' }, @@ -1952,7 +1954,7 @@ export default function App() { }; // Students get a focused personal workspace; admins get everything - const studentTabIds = ['dashboard', 'teams', 'stats', 'predict', 'attendance', 'tasks', 'calendar', 'notebook', 'budget', 'inventory', 'outreach', 'comm', 'chat', 'cad', 'cad-docs', 'cad-reviews', 'cad-snapshots', 'cad-parts', 'resources', 'checklists']; + const studentTabIds = ['dashboard', 'teams', 'stats', 'predict', 'attendance', 'tasks', 'calendar', 'notebook', 'budget', 'inventory', 'outreach', 'comm', 'chat', 'cad', 'cad-docs', 'cad-reviews', 'cad-snapshots', 'cad-parts', 'prints', 'resources', 'checklists']; // Modules the workspace switched off (Settings → Workspace → Modules). const disabledModules = useMemo( () => parseDisabledModules(teams.find((t: any) => t.id === currentUser?.team_id)?.disabled_modules), @@ -2386,6 +2388,7 @@ export default function App() { } /> } /> } /> + } /> } /> : } /> } /> diff --git a/src/components/PrintViewer.tsx b/src/components/PrintViewer.tsx new file mode 100644 index 000000000..e4f426bca --- /dev/null +++ b/src/components/PrintViewer.tsx @@ -0,0 +1,133 @@ +// The Prints library's 3D preview: STL, 3MF and OBJ in the browser. Loaded +// with React.lazy, so three.js only downloads when someone opens a preview. +// Drag to orbit, scroll or pinch to zoom; the arrow keys turn the model for +// keyboard users. +import { useEffect, useRef, useState } from 'react'; +import * as THREE from 'three'; +import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; +import { AlertTriangle } from 'lucide-react'; +import { apiFetch } from '../services/api'; +import { addLights, disposeObject, fitCamera, parseModel, type PreviewKind } from './printModel'; + +export default function PrintViewer({ url, kind, name, onLoaded }: { + url: string; + kind: PreviewKind; + name: string; + /** Called once with the file's bytes after it renders (the page draws a missing thumbnail from them). */ + onLoaded?: (buffer: ArrayBuffer) => void; +}) { + const mount = useRef(null); + const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading'); + const [error, setError] = useState(''); + const [info, setInfo] = useState(''); + const loaded = useRef(onLoaded); + loaded.current = onLoaded; + + useEffect(() => { + const el = mount.current; + if (!el) return; + let cancelled = false; + let raf = 0; + let renderer: THREE.WebGLRenderer | null = null; + let controls: OrbitControls | null = null; + let model: THREE.Object3D | null = null; + let ro: ResizeObserver | null = null; + let onKey: ((e: KeyboardEvent) => void) | null = null; + (async () => { + try { + const res = await apiFetch(url); + if (!res.ok) throw new Error(`Couldn't download the file (HTTP ${res.status}).`); + const buffer = await res.arrayBuffer(); + if (cancelled) return; + const parsed = parseModel(buffer, kind); + if (cancelled) { disposeObject(parsed.group); return; } + model = parsed.group; + const scene = new THREE.Scene(); + addLights(scene); + scene.add(model); + const maxDim = Math.max(parsed.size.x, parsed.size.z, 1); + const grid = new THREE.GridHelper(maxDim * 2.4, 24, 0x4a4a52, 0x2c2c33); + scene.add(grid); + const camera = new THREE.PerspectiveCamera(40, 1, 0.1, 1000); + fitCamera(camera, parsed.size); + renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + el.appendChild(renderer.domElement); + renderer.domElement.setAttribute('aria-hidden', 'true'); + controls = new OrbitControls(camera, renderer.domElement); + controls.enableDamping = true; + controls.target.set(0, parsed.size.y * 0.45, 0); + controls.update(); + const resize = () => { + const w = el.clientWidth || 1, h = el.clientHeight || 1; + camera.aspect = w / h; + camera.updateProjectionMatrix(); + renderer?.setSize(w, h); + }; + resize(); + ro = new ResizeObserver(resize); + ro.observe(el); + onKey = (e: KeyboardEvent) => { + if (!controls || !['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) return; + e.preventDefault(); + const step = Math.PI / 24; + const offset = camera.position.clone().sub(controls.target); + const sph = new THREE.Spherical().setFromVector3(offset); + if (e.key === 'ArrowLeft') sph.theta -= step; + if (e.key === 'ArrowRight') sph.theta += step; + if (e.key === 'ArrowUp') sph.phi = Math.max(0.1, sph.phi - step); + if (e.key === 'ArrowDown') sph.phi = Math.min(Math.PI - 0.1, sph.phi + step); + camera.position.copy(controls.target).add(new THREE.Vector3().setFromSpherical(sph)); + controls.update(); + }; + el.addEventListener('keydown', onKey); + const animate = () => { raf = requestAnimationFrame(animate); controls?.update(); renderer?.render(scene, camera); }; + animate(); + const s = parsed.size; + setInfo(`${parsed.triangles.toLocaleString()} triangles · about ${Math.round(s.x)} × ${Math.round(s.z)} × ${Math.round(s.y)} (file units)`); + setState('ready'); + loaded.current?.(buffer); + } catch (e: any) { + if (cancelled) return; + setError(e?.message || "Couldn't show this model."); + setState('error'); + } + })(); + return () => { + cancelled = true; + cancelAnimationFrame(raf); + ro?.disconnect(); + if (onKey) el.removeEventListener('keydown', onKey); + controls?.dispose(); + if (model) disposeObject(model); + if (renderer) { renderer.dispose(); renderer.domElement.remove(); } + }; + }, [url, kind]); + + return ( +
+
+ {state === 'loading' && ( +
+
+ Loading the model… +
+ )} + {state === 'error' && ( +
+ +

Couldn't show this model

+

{error}

+
+ )} +
+

{state === 'ready' ? `${info} · Drag to turn, scroll or pinch to zoom` : ''}

+
+ ); +} diff --git a/src/components/printModel.ts b/src/components/printModel.ts new file mode 100644 index 000000000..6f370efb4 --- /dev/null +++ b/src/components/printModel.ts @@ -0,0 +1,102 @@ +// 3D print models in the browser: parse STL, 3MF and OBJ with three.js's own +// loaders, and draw a small thumbnail for grid tiles. Only ever loaded with a +// dynamic import (the viewer is React.lazy, thumbnails import this on demand), +// so three.js stays out of the main bundle, as for CadModelViewer. +import * as THREE from 'three'; +import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js'; +import { ThreeMFLoader } from 'three/examples/jsm/loaders/3MFLoader.js'; +import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader.js'; + +export type PreviewKind = 'stl' | '3mf' | 'obj'; + +const material = () => new THREE.MeshStandardMaterial({ color: 0xc9ccd2, metalness: 0.1, roughness: 0.55, side: THREE.DoubleSide }); + +/** The model as one group, centred on the origin, resting on y = 0, with its triangle count. */ +export function parseModel(buffer: ArrayBuffer, kind: PreviewKind): { group: THREE.Group; triangles: number; size: THREE.Vector3 } { + const group = new THREE.Group(); + if (kind === 'stl') { + const geo = new STLLoader().parse(buffer); + geo.computeVertexNormals(); + group.add(new THREE.Mesh(geo, material())); + } else if (kind === '3mf') { + group.add(new ThreeMFLoader().parse(buffer)); + } else { + group.add(new OBJLoader().parse(new TextDecoder().decode(buffer))); + } + let triangles = 0; + group.traverse((o: any) => { + if (!o.isMesh) return; + // One look for every format; 3MF colours are kept. + if (kind !== '3mf') o.material = material(); + const g = o.geometry as THREE.BufferGeometry; + if (!g.attributes.normal) g.computeVertexNormals(); + triangles += g.index ? g.index.count / 3 : (g.attributes.position?.count ?? 0) / 3; + }); + // Print files are Z-up; the viewer is Y-up. + if (kind !== 'obj') group.rotation.x = -Math.PI / 2; + group.updateMatrixWorld(true); + const box = new THREE.Box3().setFromObject(group); + if (box.isEmpty()) throw new Error('This file has no printable geometry.'); + const center = box.getCenter(new THREE.Vector3()); + const outer = new THREE.Group(); + outer.add(group); + group.position.set(-center.x, -box.min.y, -center.z); + return { group: outer, triangles: Math.round(triangles), size: box.getSize(new THREE.Vector3()) }; +} + +/** A camera looking at the model from the front-right, above, fitting it in view. */ +export function fitCamera(camera: THREE.PerspectiveCamera, size: THREE.Vector3) { + const maxDim = Math.max(size.x, size.y, size.z, 1e-3); + const dist = (maxDim * 0.9) / Math.tan(THREE.MathUtils.degToRad(camera.fov / 2)); + camera.position.set(dist * 0.7, size.y * 0.5 + dist * 0.55, dist * 0.85); + camera.near = dist / 1000; + camera.far = dist * 50; + camera.lookAt(0, size.y * 0.45, 0); + camera.updateProjectionMatrix(); +} + +export function addLights(scene: THREE.Scene) { + scene.add(new THREE.HemisphereLight(0xffffff, 0x2a2a30, 1.2)); + const key = new THREE.DirectionalLight(0xffffff, 1.6); + key.position.set(3, 5, 4); + scene.add(key); + const rim = new THREE.DirectionalLight(0xffc700, 0.35); + rim.position.set(-4, 2, -3); + scene.add(rim); +} + +export function disposeObject(o: THREE.Object3D) { + o.traverse((x: any) => { + x.geometry?.dispose?.(); + (Array.isArray(x.material) ? x.material : [x.material]).forEach((m: any) => m?.dispose?.()); + }); +} + +/** A 256 × 256 transparent PNG of the model, or null when the browser can't draw it (no WebGL, bad file). */ +export async function drawThumbnail(buffer: ArrayBuffer, kind: PreviewKind, px = 256): Promise { + let renderer: THREE.WebGLRenderer | null = null; + let model: THREE.Object3D | null = null; + try { + const canvas = document.createElement('canvas'); + canvas.width = px; + canvas.height = px; + renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true, preserveDrawingBuffer: true }); + renderer.setPixelRatio(1); + renderer.setSize(px, px, false); + const scene = new THREE.Scene(); + addLights(scene); + const parsed = parseModel(buffer, kind); + model = parsed.group; + scene.add(model); + const camera = new THREE.PerspectiveCamera(35, 1, 0.1, 1000); + fitCamera(camera, parsed.size); + renderer.render(scene, camera); + return await new Promise((resolve) => canvas.toBlob((b) => resolve(b), 'image/png')); + } catch { + return null; + } finally { + if (model) disposeObject(model); + renderer?.dispose(); + renderer?.forceContextLoss(); + } +} diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 47ee68283..457f593c8 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -26,6 +26,7 @@ const resources = { designReviews: 'Design Reviews', snapshots: 'Snapshots', partsList: 'Parts List', + prints: 'Prints', code: 'Code', outreach: 'Outreach', budget: 'Budget', diff --git a/src/modern/__tests__/prints.test.tsx b/src/modern/__tests__/prints.test.tsx new file mode 100644 index 000000000..d156df5a3 --- /dev/null +++ b/src/modern/__tests__/prints.test.tsx @@ -0,0 +1,204 @@ +// The Prints explorer: folders and groups together, combined group filters +// with counts, search, selection actions (mark printed, move, download), the +// folder delete question with its file count, drag onto a collection, keyboard +// delete, and the read-only view for members without manage_prints. +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, cleanup, fireEvent, waitFor, within, configure } from '@testing-library/react'; +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; + +const api = vi.hoisted(() => ({ apiFetch: vi.fn() })); +const dlg = vi.hoisted(() => ({ confirmDialog: vi.fn(), notify: vi.fn() })); +vi.mock('../../services/api', async (orig) => ({ ...(await orig()), ...api })); +vi.mock('../../components/dialog', async (orig) => ({ ...(await orig()), ...dlg })); +vi.mock('../../components/PrintViewer', () => ({ default: ({ name }: any) =>
})); + +import { PrintsPage } from '../pages/prints/PrintsPage'; +import type { PrintFile, PrintLibrary } from '../../types/prints'; + +// The page and its lazy chunks take a few seconds to settle in jsdom on a busy machine. +configure({ asyncUtilTimeout: 8000 }); +globalThis.ResizeObserver ??= class { observe() {} unobserve() {} disconnect() {} } as any; +const res = (body: any, ok = true, status = ok ? 200 : 400) => Promise.resolve({ ok, status, json: async () => body }); + +const file = (id: number, name: string, over: Partial = {}): PrintFile => ({ + id, folderId: null, name, kind: 'stl', size: 2048 * id, createdAt: '2026-10-20T10:00:00Z', updatedAt: `2026-10-2${id % 8}T10:00:00Z`, uploadedBy: 'Ada', notes: '', + robotId: null, season: null, cadPart: null, subsystem: null, subsystemFrom: null, material: null, status: 'to_print', copiesNeeded: null, copiesNeededFrom: null, + copiesPrinted: 0, collections: [], hasThumb: false, source: 'upload', ...over, +}); +let lib: PrintLibrary; +const seed = (): PrintLibrary => ({ + canEdit: true, + usage: { used: 5 * 1048576, quota: 2048 * 1048576, maxFile: 100 * 1048576 }, + folders: [ + { id: 1, parentId: null, name: '2025–26 DECODE', createdAt: '', updatedAt: '2026-10-20T10:00:00Z' }, + { id: 2, parentId: 1, name: 'Intake', createdAt: '', updatedAt: '2026-10-20T10:00:00Z' }, + ], + files: [ + file(1, 'Roller hub.stl', { folderId: 2, subsystem: 'Intake', subsystemFrom: 'cad', material: 'PETG', copiesNeeded: 4, copiesNeededFrom: 'cad', robotId: 1, season: 2025, collections: [1] }), + file(2, 'Roller spacer.stl', { folderId: 2, subsystem: 'Intake', material: 'TPU', status: 'printed', robotId: 1, season: 2025 }), + file(3, 'Lift spool.3mf', { kind: '3mf', folderId: 1, subsystem: 'Lift', material: 'PETG', status: 'failed', robotId: 1, season: 2025 }), + file(4, 'Camera mount.step', { kind: 'step' }), + file(5, 'Plate.gcode', { kind: 'gcode', material: 'PLA', status: 'printing' }), + ], + collections: [{ id: 1, name: 'Comp spares', files: 1 }, { id: 2, name: 'Worlds kit', files: 0 }], + robots: [{ id: 1, name: 'MR. HORS', season: 2025, seasonName: '2025–26 DECODE' }], + subsystems: ['Drivetrain', 'Intake', 'Lift'], +}); +const calls = () => api.apiFetch.mock.calls.filter(([, init]) => init?.method && init.method !== 'GET').map(([url, init]) => ({ url, method: init.method, body: init.body && typeof init.body === 'string' ? JSON.parse(init.body) : init.body })); + +beforeEach(() => { + lib = seed(); + api.apiFetch.mockReset(); + api.apiFetch.mockImplementation((url: string, init?: any) => { + if (url === '/api/prints/library') return res(lib); + if (url.startsWith('/api/prints/activity')) return res([]); + if (url.startsWith('/api/cad/robots/')) return res({ parts: [] }); + return res({ success: true, files: 1 }); + }); + dlg.confirmDialog.mockReset().mockResolvedValue(true); + dlg.notify.mockReset(); + try { localStorage.clear(); } catch { /* none */ } +}); +afterEach(cleanup); + +let where = ''; +function Where() { const l = useLocation(); where = l.pathname + l.search; return null; } +const setup = (url = '/prints') => render( + + } /> + , +); +const sidebar = () => screen.getAllByRole('navigation', { name: 'Folders and groups' })[0]; +const contents = () => screen.getByRole('list', { name: /^Contents of/ }); +/** A file's or folder's own button in the list (not its options button). */ +const item = (name: string) => within(contents()).getAllByRole('button').find((b) => !b.getAttribute('aria-label') && (b.textContent ?? '').startsWith(name))!; + +describe('Prints explorer', () => { + it('opens on the top level: folders, loose files, the path and the counts', async () => { + setup(); + expect(await screen.findByRole('heading', { name: /All prints · 2 files · 1 still to print/ })).toBeTruthy(); + const list = contents(); + expect(item('2025–26 DECODE')).toBeTruthy(); + expect(item('Camera mount.step')).toBeTruthy(); + expect(within(list).queryByRole('button', { name: /Roller hub/ })).toBeNull(); + fireEvent.click(item('2025–26 DECODE')); + expect(where).toBe('/prints?folder=1'); + expect(await screen.findByRole('heading', { name: /2025–26 DECODE · 1 file · 1 still to print/ })).toBeTruthy(); + const path = screen.getByRole('navigation', { name: 'Folder path' }); + expect(within(path).getByRole('button', { name: '2025–26 DECODE' }).getAttribute('aria-current')).toBe('page'); + }); + + it('combines groups from the sidebar: Intake + PETG + To print', async () => { + setup(); + await screen.findByRole('heading', { name: /All prints/ }); + const nav = sidebar(); + fireEvent.click(within(nav).getByRole('button', { name: /^Intake:/ })); + expect(await screen.findByRole('heading', { name: /Intake · 2 files · 1 still to print/ })).toBeTruthy(); + fireEvent.click(within(nav).getByRole('button', { name: /^PETG:/ })); + fireEvent.click(within(nav).getByRole('button', { name: /^To print:/ })); + expect(await screen.findByRole('heading', { name: /Intake \+ PETG \+ To print · 1 file · 1 still to print/ })).toBeTruthy(); + expect(where).toContain('sub=Intake'); + expect(where).toContain('mat=PETG'); + expect(where).toContain('status=to_print'); + // The file shows where it lives. + expect(within(contents()).getByRole('button', { name: /Roller hub\.stl.*2025–26 DECODE \/ Intake/ })).toBeTruthy(); + // A chip removes one group. + fireEvent.click(screen.getByRole('button', { name: 'Remove PETG' })); + expect(await screen.findByRole('heading', { name: /Intake \+ To print · 1 file/ })).toBeTruthy(); + }); + + it('searches the whole library', async () => { + setup(); + await screen.findByRole('heading', { name: /All prints/ }); + fireEvent.change(screen.getByRole('searchbox', { name: 'Search the library' }), { target: { value: 'roller' } }); + expect(await screen.findByRole('heading', { name: /Search “roller” · 2 files/ })).toBeTruthy(); + }); + + it('marks a selection printed, and downloads a group as one ZIP', async () => { + const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); + setup('/prints?sub=Intake'); + await screen.findByRole('heading', { name: /Intake · 2 files/ }); + fireEvent.click(screen.getByRole('checkbox', { name: 'Select Roller hub.stl' })); + fireEvent.click(screen.getByRole('checkbox', { name: 'Select Roller spacer.stl' })); + const bar = screen.getByRole('toolbar', { name: 'Selected files' }); + expect(within(bar).getByText('2 selected')).toBeTruthy(); + fireEvent.click(within(bar).getByRole('button', { name: 'Printed' })); + await waitFor(() => expect(calls()).toContainEqual({ url: '/api/prints/files/bulk', method: 'POST', body: { ids: [1, 2], action: 'status', status: 'printed' } })); + fireEvent.click(screen.getByRole('button', { name: 'Download all' })); + const a = click.mock.instances[0] as unknown as HTMLAnchorElement; + expect(a.getAttribute('href')).toBe('/api/prints/zip?files=1,2&name=Intake'); + click.mockRestore(); + }); + + it('moves a file with the Move to picker', async () => { + setup(); + await screen.findByRole('heading', { name: /All prints/ }); + fireEvent.click(screen.getByRole('checkbox', { name: 'Select Camera mount.step' })); + fireEvent.click(within(screen.getByRole('toolbar', { name: 'Selected files' })).getByRole('button', { name: /Move to/ })); + const dialog = await screen.findByRole('dialog', { name: /Move/ }); + fireEvent.click(within(dialog).getByRole('radio', { name: /^Intake/ })); + fireEvent.click(within(dialog).getByRole('button', { name: /Move here/ })); + await waitFor(() => expect(calls()).toContainEqual({ url: '/api/prints/files/bulk', method: 'POST', body: { ids: [4], action: 'move', folderId: 2 } })); + }); + + it('asks before deleting a folder that holds files, and says how many', async () => { + setup(); + await screen.findByRole('heading', { name: /All prints/ }); + fireEvent.pointerDown(within(contents()).getByRole('button', { name: '2025–26 DECODE options' }), { button: 0, ctrlKey: false }); + fireEvent.click(await screen.findByRole('menuitem', { name: /Delete/ })); + await waitFor(() => expect(dlg.confirmDialog).toHaveBeenCalled()); + const ask = dlg.confirmDialog.mock.calls[0][0]; + expect(ask.message).toContain('3 files'); + expect(ask.message).toContain('1 folder inside it'); + expect(ask.confirmLabel).toBe('Delete folder and 3 files'); + await waitFor(() => expect(calls()).toContainEqual({ url: '/api/prints/folders/1?files=3', method: 'DELETE', body: undefined })); + }); + + it('adds dragged files to a collection dropped on in the sidebar', async () => { + setup(); + await screen.findByRole('heading', { name: /All prints/ }); + const store: Record = {}; + const dataTransfer = { types: [] as string[], setData: (k: string, v: string) => { store[k] = v; dataTransfer.types = Object.keys(store); }, getData: (k: string) => store[k] ?? '', files: [] as File[], effectAllowed: '', dropEffect: '' }; + const row = item('Plate.gcode').closest('li')!; + fireEvent.dragStart(row, { dataTransfer }); + const target = within(sidebar()).getByRole('button', { name: /^Worlds kit:/ }).closest('li')!; + fireEvent.dragOver(target, { dataTransfer }); + fireEvent.drop(target, { dataTransfer }); + await waitFor(() => expect(calls()).toContainEqual({ url: '/api/prints/files/bulk', method: 'POST', body: { ids: [5], action: 'collect', collectionId: 2 } })); + }); + + it('deletes the focused file with the Delete key, after asking', async () => { + setup(); + await screen.findByRole('heading', { name: /All prints/ }); + const name = item('Camera mount.step'); + name.focus(); + fireEvent.keyDown(name, { key: 'Delete' }); + await waitFor(() => expect(dlg.confirmDialog).toHaveBeenCalledWith(expect.objectContaining({ title: 'Delete Camera mount.step?' }))); + await waitFor(() => expect(calls()).toContainEqual({ url: '/api/prints/files/bulk', method: 'POST', body: { ids: [4], action: 'delete' } })); + }); + + it('opens details with the CAD-derived subsystem and copies', async () => { + setup('/prints?folder=2'); + await screen.findByRole('heading', { name: /Intake ·/ }); + fireEvent.click(item('Roller hub.stl')); + const sheet = await screen.findByRole('dialog', { name: 'Roller hub.stl' }); + expect(within(sheet).getByText(/The robot needs 4, from the CAD part's quantity/)).toBeTruthy(); + expect(within(sheet).getByText(/Subsystem Intake comes from the CAD part's section/)).toBeTruthy(); + expect(within(sheet).getByRole('button', { name: 'Comp spares' }).getAttribute('aria-pressed')).toBe('true'); + fireEvent.click(within(sheet).getByRole('radio', { name: 'Printing' })); + await waitFor(() => expect(calls()).toContainEqual({ url: '/api/prints/files/1', method: 'PATCH', body: { status: 'printing' } })); + }); + + it('is read-only for members without manage_prints', async () => { + lib = { ...seed(), canEdit: false }; + api.apiFetch.mockImplementation((url: string) => (url === '/api/prints/library' ? res(lib) : res([]))); + setup(); + await screen.findByRole('heading', { name: /All prints/ }); + expect(screen.queryByRole('button', { name: /Upload/ })).toBeNull(); + expect(screen.queryByRole('button', { name: 'New collection' })).toBeNull(); + fireEvent.click(item('Camera mount.step')); + const sheet = await screen.findByRole('dialog', { name: 'Camera mount.step' }); + expect(within(sheet).queryByRole('button', { name: /Delete/ })).toBeNull(); + expect(within(sheet).getByRole('button', { name: /Download/ })).toBeTruthy(); + }); +}); diff --git a/src/modern/nav.ts b/src/modern/nav.ts index fcbc256f9..33eb96bc3 100644 --- a/src/modern/nav.ts +++ b/src/modern/nav.ts @@ -34,7 +34,7 @@ export interface ModernNavSection { const SECTION_OF: Record = { teams: 'team', attendance: 'team', calendar: 'team', tasks: 'team', notebook: 'team', stats: 'compete', predict: 'compete', - cad: 'build', code: 'build', inventory: 'build', + cad: 'build', prints: 'build', code: 'build', inventory: 'build', outreach: 'ops', comm: 'ops', budget: 'ops', resources: 'ops', checklists: 'build', owner: 'admin', }; @@ -63,7 +63,7 @@ export function buildModernNav(visible: NavItemLike[]): { primary: ModernNavItem (buckets[sec] ??= []).push(toItem(t)); } // Keep a stable, intentional order inside each section. - const ORDER = ['teams', 'roles', 'attendance', 'calendar', 'tasks', 'notebook', 'stats', 'predict', 'cad', 'code', 'inventory', 'checklists', 'outreach', 'comm', 'budget', 'resources', 'owner']; + const ORDER = ['teams', 'roles', 'attendance', 'calendar', 'tasks', 'notebook', 'stats', 'predict', 'cad', 'prints', 'code', 'inventory', 'checklists', 'outreach', 'comm', 'budget', 'resources', 'owner']; const sections = SECTION_ORDER .filter((id) => buckets[id]?.length) .map((id) => ({ diff --git a/src/modern/pages/prints/PrintDetails.tsx b/src/modern/pages/prints/PrintDetails.tsx new file mode 100644 index 000000000..56ee920d1 --- /dev/null +++ b/src/modern/pages/prints/PrintDetails.tsx @@ -0,0 +1,229 @@ +// One print file's details, in a side sheet: what it is, where it lives, who +// added it, and the details its smart groups come from (robot and season, CAD +// part, subsystem, material, status, copies), plus its collections, notes and +// history. People without manage_prints see the same details read-only. +import { useEffect, useId, useMemo, useState, type ReactNode } from 'react'; +import { Box, Download, ExternalLink, FolderInput, Minus, Pencil, Plus, Trash2 } from 'lucide-react'; +import { Button, Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, Textarea } from '../../../components/ui-kit'; +import { cn } from '../../../components/cn'; +import { apiFetch } from '../../../services/api'; +import { fmtDate } from '../../../components/cad/useCad'; +import { currentSeason, seasonName } from '../../../types/cadBom'; +import { + KIND_LABELS, PRINT_MATERIALS, PRINT_STATUSES, STATUS_LABELS, canPreview, folderTrail, formatBytes, type PrintFile, type PrintLibrary, +} from '../../../types/prints'; +import { FileArt, STATUS_DOT, copiesText } from './printsUi'; +import { download, fileUrl, type Prints } from './usePrints'; + +const field = 'h-10 w-full rounded-lg border border-border bg-background px-3 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/60 disabled:opacity-60'; +const ACTION_TEXT: Record = { + upload: 'added it', replace: 'sent a new version', rename: 'renamed it', move: 'moved it', edit: 'changed its details', status: 'changed its status', + collect: 'added it to a collection', uncollect: 'took it out of a collection', delete: 'deleted it', +}; + +function Row({ label, children, htmlFor }: { label: string; children: ReactNode; htmlFor?: string }) { + return ( +
+ {htmlFor ? : {label}} + {children} +
+ ); +} + +export function PrintDetails({ file, lib, prints, onClose, onPreview, onRename, onMove, onDelete, onError }: { + file: PrintFile; lib: PrintLibrary; prints: Prints; onClose: () => void; + onPreview: () => void; onRename: () => void; onMove: () => void; onDelete: () => void; onError: (message: string) => void; +}) { + const id = useId(); + const edit = lib.canEdit; + const [notes, setNotes] = useState(file.notes); + const [parts, setParts] = useState<{ id: number; name: string; section: string; quantity: number; group: string }[] | null>(null); + const [history, setHistory] = useState<{ id: number; by: string; action: string; detail: any; at: string }[] | null>(null); + useEffect(() => { setNotes(file.notes); }, [file.id, file.notes]); + // The linked robot's parts, for the CAD part picker (made parts first). + useEffect(() => { + setParts(null); + if (file.robotId == null) return; + let live = true; + apiFetch(`/api/cad/robots/${file.robotId}`).then((r) => (r.ok ? r.json() : null)).then((d) => { + if (!live || !d) return; + const list = (d.parts as any[]).filter((p) => !p.ck_removed_at).map((p) => ({ id: Number(p.id), name: String(p.name), section: String(p.section || ''), quantity: Number(p.quantity) || 0, group: String(p.effective?.group || '') })); + list.sort((a, b) => Number(b.group === 'make') - Number(a.group === 'make') || a.name.localeCompare(b.name)); + setParts(list); + }).catch(() => {}); + return () => { live = false; }; + }, [file.robotId]); + useEffect(() => { + let live = true; + setHistory(null); + prints.activity(file.id).then((h) => { if (live) setHistory(h); }).catch(() => { if (live) setHistory([]); }); + return () => { live = false; }; + // Reload the history when the file changes (updatedAt moves on every save). + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [file.id, file.updatedAt]); + + const save = async (patch: Record) => { + try { await prints.updateFile(file.id, patch); } catch (e: any) { onError(e?.message || "Couldn't save that."); } + }; + const where = folderTrail(lib.folders, file.folderId).map((f) => f.name); + const seasons = useMemo(() => { + const now = currentSeason(); + return [...new Set([now + 1, now, now - 1, now - 2, now - 3, ...lib.robots.map((r) => r.season), ...(file.season != null ? [file.season] : [])])].sort((a, b) => b - a); + }, [lib.robots, file.season]); + const subsystemOptions = useMemo(() => [...new Set([...lib.subsystems, ...lib.files.map((f) => f.subsystem).filter(Boolean) as string[]])], [lib]); + const copies = copiesText(file); + + return ( + { if (!o) onClose(); }}> + + + {file.name} + {KIND_LABELS[file.kind]} · {formatBytes(file.size)} · {where.length ? where.join(' / ') : 'Top level'} + + +
+ + {canPreview(file.kind) && ( + + )} +
+ +
+ + {edit && } + {edit && } + {edit && } +
+ + + {edit ? ( +
+ {PRINT_STATUSES.map((s) => ( + + ))} +
+ ) :

{STATUS_LABELS[file.status]}

} +
+ + +
+ {edit ? ( + <> + + {file.copiesPrinted} + + printed of + { + const v = e.target.value.trim(); + const next = v === '' ? null : Number(v); + if (next !== (file.copiesNeededFrom === 'manual' ? file.copiesNeeded : null)) void save({ copiesNeeded: next }); + }} /> + needed + + ) : {copies ?? 'Not counted'}} +
+ {file.copiesNeededFrom === 'cad' &&

The robot needs {file.copiesNeeded}, from the CAD part's quantity.

} +
+ + + + + {file.robotId == null && ( + + + + )} + {file.robotId != null && ( + + + {file.cadPart && ( + + Open the robot's BOM + + )} + + )} +
+ + { const v = e.target.value.trim(); if (v !== (file.subsystemFrom === 'manual' ? file.subsystem ?? '' : '')) void save({ subsystem: v }); }} /> + {subsystemOptions.map((s) => + + + { const v = e.target.value.trim(); if (v !== (file.material ?? '')) void save({ material: v }); }} /> + {PRINT_MATERIALS.map((m) => + +
+ {file.subsystemFrom === 'cad' &&

Subsystem {file.subsystem} comes from the CAD part's section. Type one to set it here.

} + + + {lib.collections.length === 0 ?

No collections yet. Make one in the sidebar.

: ( +
    + {lib.collections.map((c) => { + const on = file.collections.includes(c.id); + return ( +
  • + +
  • + ); + })} +
+ )} +
+ + +