mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-21 06:09:56 -05:00
feat: add consume folder support
This commit is contained in:
1 parent
93136629e4
commit
e22f57903f
18 files changed
+982
-19
No files matched your search
@@ -42,6 +42,7 @@ Instead of having your collection scattered across different websites and your f
|
||||
|
||||
- 🌐 **Import from MakerWorld, Printables and Thingiverse** — paste a link, or use the Thingport Grab browser extension. Whole collections, likes and MakerWorld print profiles come across with their title, description, tags, photos and author. Big imports run in the background and notify you when they finish, and a model you already have is never imported twice.
|
||||
- 📤 **Upload your own files** — STL, 3MF, STEP, OBJ and LightBurn files, or a ZIP to pick files from, with its folders recreated as categories.
|
||||
- 📥 **Consume folder** — mount a folder and anything dropped into it (files, folder trees, ZIPs) is imported and moved into the library, with a notification once it's done.
|
||||
- 🧊 **3D previews** — STL, 3MF, OBJ and STEP in the browser, with a thumbnail for every plate of a multi-plate 3MF. Very complex models can be simplified for the preview only.
|
||||
- 🗂️ **Organize and search** — nested categories, collections, tags, favourites and browsing history, with full-text search across names, tags, notes and authors. Pin the collections and tags you use most to the sidebar.
|
||||
- 📊 **Dashboard** — library stats, recently added models, your most viewed and most used models, and top authors.
|
||||
@@ -169,6 +170,9 @@ services:
|
||||
condition: service_started
|
||||
volumes:
|
||||
- thingport_storage:/app/storage
|
||||
# Optional consume folder: files and folders dropped in it are imported automatically
|
||||
# (Administration -> Settings -> Consume Folder). Uncomment and point it at a host folder.
|
||||
# - /path/to/consume:/app/consume
|
||||
networks:
|
||||
- app-net
|
||||
|
||||
@@ -258,6 +262,24 @@ This builds the images locally rather than pulling from GHCR.
|
||||
|
||||
</details>
|
||||
|
||||
### Consume folder
|
||||
|
||||
To have Thingport import whatever you drop into a folder, uncomment the consume volume in `docker-compose.yml`
|
||||
and point it at a folder on the host, then run `docker compose up -d` again:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- thingport_storage:/app/storage
|
||||
- /path/to/consume:/app/consume
|
||||
```
|
||||
|
||||
Files and folder structures dropped there are picked up within a few seconds of the copy finishing, turned into
|
||||
models, and moved into Thingport's storage, so the folder empties itself. Folders become folders in Thingport and
|
||||
ZIPs are unpacked as if they were folders. In **Administration → Settings → Consume Folder**, choose whose library the
|
||||
models go to and whether each model file becomes its own model (the default) or each folder of model files becomes one
|
||||
model. Anything that can't be imported is moved to a `Not imported` folder inside the consume folder. Files are moved
|
||||
out as the backend's user, so `PUID`/`PGID` need write access to the folder.
|
||||
|
||||
## Contributing
|
||||
|
||||
Bug reports and pull requests are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for the branch,
|
||||
|
||||
@@ -28,6 +28,11 @@ else
|
||||
chown "$PUID":"$PGID" /app/storage /app/data 2> /dev/null || true
|
||||
fi
|
||||
|
||||
# The optional consume folder is only there when mounted; consumed files are moved out of it.
|
||||
if [ -d /app/consume ]; then
|
||||
chown "$PUID":"$PGID" /app/consume 2> /dev/null || true
|
||||
fi
|
||||
|
||||
# Managed migrations: apply any pending Prisma migrations before the app starts.
|
||||
npx prisma migrate deploy
|
||||
|
||||
|
||||
@@ -32,6 +32,8 @@ function defaultPreviewMemoryMb(): number {
|
||||
}
|
||||
|
||||
export const STORAGE = path.resolve(process.env.FILE_STORAGE || "./storage");
|
||||
// Watched only while it exists, i.e. when the Docker setup mounts a host folder there.
|
||||
export const CONSUME_DIR = path.resolve(process.env.CONSUME_DIR || "/app/consume");
|
||||
export const THUMBS = path.join(STORAGE, "thumbs");
|
||||
export const BUNDLES = path.join(STORAGE, "bundles");
|
||||
export const PREVIEWS = path.join(STORAGE, "previews");
|
||||
@@ -54,6 +56,25 @@ export const AUTH_ALGO = "HS256" as const;
|
||||
export const IMPORT_ALLOWED_EXTS = new Set([".stl", ".3mf", ".step", ".stp", ".obj", ".lbrn", ".lbrn2", ".zip"]);
|
||||
// Mirrors frontend's MODEL_EXTS. Other files in a multi-file upload become SUPPORTING files.
|
||||
export const RENDERABLE_MODEL_EXTS = new Set([".stl", ".3mf", ".step", ".stp", ".obj"]);
|
||||
// Mirrors frontend's UPLOAD_EXTS: what becomes a model on its own.
|
||||
export const UPLOADABLE_EXTS = new Set([
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".webp",
|
||||
".bmp",
|
||||
".gif",
|
||||
".svg",
|
||||
".stl",
|
||||
".step",
|
||||
".stp",
|
||||
".3mf",
|
||||
".obj",
|
||||
".f3d",
|
||||
".lbrn",
|
||||
".lbrn2",
|
||||
".zip",
|
||||
]);
|
||||
export const IMPORT_EXT_PRIORITY = [".3mf", ".stl", ".step", ".stp", ".lbrn2", ".lbrn", ".zip"];
|
||||
export const IMPORT_BLOCKED_EXTS = new Set([
|
||||
".jpg",
|
||||
|
||||
@@ -35,6 +35,15 @@ import { type MakerworldCookieCheck, verifyMakerworldCookie } from "../services/
|
||||
import { verifyThingiverseAccessToken } from "../services/thingiverseApi";
|
||||
import { SLICER_IDS, getUserSlicer, setUserSlicer } from "../services/slicerPreferenceService";
|
||||
import { CATEGORIES_VIEWS, getCategoriesView, setCategoriesView } from "../services/categoriesViewService";
|
||||
import {
|
||||
CONSUME_MODES,
|
||||
NOT_IMPORTED_DIR,
|
||||
consumeAvailable,
|
||||
getConsumeMode,
|
||||
getConsumeUserId,
|
||||
setConsumeSettings,
|
||||
} from "../services/consumeService";
|
||||
import { CONSUME_DIR } from "../config";
|
||||
import { THEME_SELECTIONS, getUserTheme, setUserTheme } from "../services/themePreferenceService";
|
||||
import { getUserAuthorPreviewEnabled, setUserAuthorPreviewEnabled } from "../services/authorPreviewPreferenceService";
|
||||
import { checkForUpdates } from "../services/versionService";
|
||||
@@ -372,6 +381,35 @@ router.patch(
|
||||
}),
|
||||
);
|
||||
|
||||
async function consumeSettingsOut() {
|
||||
return {
|
||||
available: consumeAvailable(),
|
||||
path: CONSUME_DIR,
|
||||
not_imported_dir: NOT_IMPORTED_DIR,
|
||||
mode: await getConsumeMode(),
|
||||
user_id: await getConsumeUserId(),
|
||||
};
|
||||
}
|
||||
|
||||
router.get(
|
||||
"/settings/consume",
|
||||
requireAdmin,
|
||||
asyncHandler(async (_req, res) => {
|
||||
res.json(await consumeSettingsOut());
|
||||
}),
|
||||
);
|
||||
|
||||
const consumeSchema = z.object({ mode: z.enum(CONSUME_MODES).optional(), user_id: z.string().optional() });
|
||||
router.patch(
|
||||
"/settings/consume",
|
||||
requireAdmin,
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = parseBody(consumeSchema, req.body);
|
||||
await setConsumeSettings({ mode: body.mode, userId: body.user_id });
|
||||
res.json(await consumeSettingsOut());
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/settings/categories-view",
|
||||
asyncHandler(async (req, res) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createApp } from "./app";
|
||||
import { API_PORT } from "./config";
|
||||
import { prisma } from "./db"; // also ensures storage directories exist before we start serving
|
||||
import { startConsumeWatcher } from "./services/consumeService";
|
||||
|
||||
const app = createApp();
|
||||
|
||||
@@ -14,4 +15,5 @@ prisma.importJob
|
||||
|
||||
app.listen(API_PORT, () => {
|
||||
console.log(`Thingport API listening on port ${API_PORT}`);
|
||||
startConsumeWatcher();
|
||||
});
|
||||
@@ -0,0 +1,395 @@
|
||||
import fs from "node:fs/promises";
|
||||
import fsSync from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import { prisma } from "../db";
|
||||
import { CONSUME_DIR, IMPORT_MAX_BYTES, RENDERABLE_MODEL_EXTS, UPLOADABLE_EXTS } from "../config";
|
||||
import { HttpError, guessMimeFromPath, sanitizeFilename } from "../utils/fileUtils";
|
||||
import { listZipEntries, readZipEntry } from "../utils/zipReader";
|
||||
import { normalizeZipEntryPath, resolveZipCategoryId } from "./zipService";
|
||||
import { createPrint, type NewPlateInput } from "./printCreation";
|
||||
import { saveFileFromTemp } from "./printFileService";
|
||||
import { addPreviewImage } from "./previewImageService";
|
||||
import { plateThumbExists, saveThumbFromBytes } from "./printService";
|
||||
import { createNotification } from "./notificationService";
|
||||
|
||||
export const CONSUME_MODES = ["separate", "folder"] as const;
|
||||
export type ConsumeMode = (typeof CONSUME_MODES)[number];
|
||||
|
||||
const MODE_KEY = "consume_mode";
|
||||
const USER_KEY = "consume_user_id";
|
||||
|
||||
/** Inside the consume folder; never scanned, so what can't be imported isn't retried forever. */
|
||||
export const NOT_IMPORTED_DIR = "Not imported";
|
||||
|
||||
const POLL_MS = 2000;
|
||||
const PREVIEW_IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".webp", ".bmp"]);
|
||||
// As for uploads: a bigger image is attached as a file instead of being read in as a preview.
|
||||
const PREVIEW_IMAGE_MAX_BYTES = 8 * 1024 * 1024;
|
||||
// OS metadata a copied folder drags along.
|
||||
const SYSTEM_FILES = new Set(["thumbs.db", "desktop.ini"]);
|
||||
|
||||
export function consumeAvailable(dir = CONSUME_DIR): boolean {
|
||||
try {
|
||||
return fsSync.statSync(dir).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getConsumeMode(): Promise<ConsumeMode> {
|
||||
const row = await prisma.setting.findUnique({ where: { key: MODE_KEY } });
|
||||
return row?.value === "folder" ? "folder" : "separate";
|
||||
}
|
||||
|
||||
/** The chosen account, or the oldest admin when none is chosen or it no longer exists. */
|
||||
export async function getConsumeUserId(): Promise<string | null> {
|
||||
const row = await prisma.setting.findUnique({ where: { key: USER_KEY } });
|
||||
if (typeof row?.value === "string") {
|
||||
const user = await prisma.user.findUnique({ where: { id: row.value }, select: { id: true } });
|
||||
if (user) return user.id;
|
||||
}
|
||||
const admin = await prisma.user.findFirst({ where: { role: "ADMIN" }, orderBy: { createdAt: "asc" } });
|
||||
return admin?.id ?? null;
|
||||
}
|
||||
|
||||
export async function setConsumeSettings(patch: { mode?: ConsumeMode; userId?: string }): Promise<void> {
|
||||
if (patch.userId && !(await prisma.user.findUnique({ where: { id: patch.userId }, select: { id: true } }))) {
|
||||
throw new HttpError(400, "User not found");
|
||||
}
|
||||
for (const [key, value] of [
|
||||
[MODE_KEY, patch.mode],
|
||||
[USER_KEY, patch.userId],
|
||||
] as const) {
|
||||
if (value === undefined) continue;
|
||||
await prisma.setting.upsert({ where: { key }, create: { key, value }, update: { value } });
|
||||
}
|
||||
}
|
||||
|
||||
type FoundFile = { abs: string; rel: string; size: number; mtimeMs: number };
|
||||
|
||||
function isSystemName(name: string) {
|
||||
return name.startsWith(".") || SYSTEM_FILES.has(name.toLowerCase());
|
||||
}
|
||||
|
||||
async function walk(root: string, dir = root, out: FoundFile[] = []): Promise<FoundFile[]> {
|
||||
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
|
||||
if (isSystemName(entry.name)) continue;
|
||||
const abs = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (dir === root && entry.name === NOT_IMPORTED_DIR) continue;
|
||||
await walk(root, abs, out);
|
||||
} else if (entry.isFile()) {
|
||||
const stat = await fs.stat(abs);
|
||||
out.push({
|
||||
abs,
|
||||
rel: path.relative(root, abs).split(path.sep).join("/"),
|
||||
size: stat.size,
|
||||
mtimeMs: stat.mtimeMs,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function signature(files: FoundFile[]) {
|
||||
return files
|
||||
.map((f) => `${f.rel}:${f.size}:${f.mtimeMs}`)
|
||||
.toSorted()
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* One file to import. `rel` is its place in the tree, with a zip standing in for a folder. Files are
|
||||
* copied into storage and the source removed only once that worked, so a failure can still set it aside.
|
||||
*/
|
||||
type Item = { rel: string; path: string; size: number };
|
||||
|
||||
export type ConsumeSummary = { models: number; files: number; failed: string[]; printIds: string[] };
|
||||
|
||||
const extOf = (name: string) => path.extname(name).toLowerCase();
|
||||
const foldersOf = (rel: string) => rel.split("/").slice(0, -1);
|
||||
const byName = (a: Item, b: Item) =>
|
||||
path.basename(a.rel).localeCompare(path.basename(b.rel), undefined, { numeric: true, sensitivity: "base" });
|
||||
|
||||
function tempPath() {
|
||||
return path.join(os.tmpdir(), `thingport-consume-${crypto.randomBytes(8).toString("hex")}`);
|
||||
}
|
||||
|
||||
async function moveFile(from: string, to: string) {
|
||||
await fs.mkdir(path.dirname(to), { recursive: true });
|
||||
try {
|
||||
await fs.rename(from, to);
|
||||
} catch (err: any) {
|
||||
if (err?.code !== "EXDEV") throw err;
|
||||
await fs.copyFile(from, to);
|
||||
await fs.rm(from, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function freePath(target: string) {
|
||||
const { dir, name, ext } = path.parse(target);
|
||||
let candidate = target;
|
||||
for (let n = 2; fsSync.existsSync(candidate); n++) candidate = path.join(dir, `${name} (${n})${ext}`);
|
||||
return candidate;
|
||||
}
|
||||
|
||||
/**
|
||||
* A zip is unpacked as if it were a folder named after it. When everything in it already sits in
|
||||
* one top folder (Benchy.zip holding Benchy/...), that folder is used instead of nesting it twice.
|
||||
*/
|
||||
async function expandZip(file: FoundFile): Promise<Item[]> {
|
||||
const entries = (await listZipEntries(file.abs))
|
||||
.filter((e) => !e.isDirectory)
|
||||
.map((e) => normalizeZipEntryPath(e.name))
|
||||
.filter((name): name is string => Boolean(name) && !name!.split("/").some(isSystemName));
|
||||
const parent = path.posix.dirname(file.rel) === "." ? "" : path.posix.dirname(file.rel);
|
||||
const tops = new Set(entries.map((e) => e.split("/")[0]));
|
||||
const selfFoldered = tops.size === 1 && entries.every((e) => e.includes("/"));
|
||||
const prefix = selfFoldered ? parent : path.posix.join(parent, path.parse(file.rel).name);
|
||||
|
||||
const items: Item[] = [];
|
||||
try {
|
||||
for (const entry of entries) {
|
||||
// All or nothing: the zip is deleted afterwards, so a skipped entry would be lost.
|
||||
const buffer = await readZipEntry(file.abs, entry, IMPORT_MAX_BYTES);
|
||||
if (!buffer) throw new Error(`Couldn't extract ${entry} (too large or unreadable)`);
|
||||
const tmp = tempPath();
|
||||
await fs.writeFile(tmp, buffer);
|
||||
items.push({ rel: path.posix.join(prefix, entry), path: tmp, size: buffer.length });
|
||||
}
|
||||
} catch (err) {
|
||||
for (const item of items) await fs.rm(item.path, { force: true });
|
||||
throw err;
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/** Imports everything currently in `dir` for its owner, then notifies them. */
|
||||
export async function consumeFolder(dir = CONSUME_DIR): Promise<ConsumeSummary | null> {
|
||||
const files = await walk(dir);
|
||||
if (!files.length) return null;
|
||||
const userId = await getConsumeUserId();
|
||||
if (!userId) {
|
||||
console.warn("Consume folder: no admin account to import for; leaving files in place.");
|
||||
return null;
|
||||
}
|
||||
const mode = await getConsumeMode();
|
||||
const summary: ConsumeSummary = { models: 0, files: 0, failed: [], printIds: [] };
|
||||
|
||||
const notImported = async (item: Item) => {
|
||||
summary.failed.push(item.rel);
|
||||
try {
|
||||
await moveFile(item.path, await freePath(path.join(dir, NOT_IMPORTED_DIR, item.rel)));
|
||||
} catch (err) {
|
||||
console.error("Consume folder: couldn't set aside", item.rel, err);
|
||||
}
|
||||
};
|
||||
|
||||
const items: Item[] = [];
|
||||
const zips: FoundFile[] = [];
|
||||
for (const file of files) {
|
||||
if (extOf(file.abs) !== ".zip") {
|
||||
items.push({ rel: file.rel, path: file.abs, size: file.size });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
items.push(...(await expandZip(file)));
|
||||
zips.push(file);
|
||||
} catch (err) {
|
||||
console.error("Consume folder: unreadable zip", file.rel, err);
|
||||
await notImported({ rel: file.rel, path: file.abs, size: file.size });
|
||||
}
|
||||
}
|
||||
|
||||
// The same cap as every other way in.
|
||||
const importable: Item[] = [];
|
||||
for (const item of items) {
|
||||
if (item.size > IMPORT_MAX_BYTES) await notImported(item);
|
||||
else importable.push(item);
|
||||
}
|
||||
|
||||
const categoryCache = new Map<string, string>();
|
||||
const categoryFor = (folders: string[]) =>
|
||||
resolveZipCategoryId(userId, null, [...folders, "_"].join("/"), categoryCache);
|
||||
|
||||
const importEach = async (item: Item) => {
|
||||
if (!UPLOADABLE_EXTS.has(extOf(item.rel))) return notImported(item);
|
||||
try {
|
||||
const filename = sanitizeFilename(path.basename(item.rel));
|
||||
const { print } = await createPrint(
|
||||
userId,
|
||||
{ categoryId: await categoryFor(foldersOf(item.rel)) },
|
||||
path.parse(filename).name,
|
||||
[{ filename, mime: guessMimeFromPath(filename), copyFromPath: item.path }],
|
||||
);
|
||||
await fs.rm(item.path, { force: true });
|
||||
summary.models += 1;
|
||||
summary.files += 1;
|
||||
summary.printIds.push(print.id);
|
||||
} catch (err) {
|
||||
console.error("Consume folder: import failed for", item.rel, err);
|
||||
await notImported(item);
|
||||
}
|
||||
};
|
||||
|
||||
// Same rules as uploading folders with "each folder is one model": a folder holding model files is
|
||||
// one model named after it; its images are the previews, anything else is attached.
|
||||
const importFolder = async (folders: string[], group: Item[]) => {
|
||||
const sorted = group.toSorted(byName);
|
||||
const plates = sorted.filter((i) => RENDERABLE_MODEL_EXTS.has(extOf(i.rel)));
|
||||
const rest = sorted.filter((i) => !plates.includes(i));
|
||||
const name = folders[folders.length - 1];
|
||||
let printId: string;
|
||||
let firstPlateId: string | undefined;
|
||||
try {
|
||||
const inputs: NewPlateInput[] = plates.map((i) => {
|
||||
const filename = sanitizeFilename(path.basename(i.rel));
|
||||
return { filename, mime: guessMimeFromPath(filename), copyFromPath: i.path };
|
||||
});
|
||||
const { print, plates: created } = await createPrint(
|
||||
userId,
|
||||
{ title: name, categoryId: await categoryFor(folders.slice(0, -1)) },
|
||||
name,
|
||||
inputs,
|
||||
);
|
||||
printId = print.id;
|
||||
firstPlateId = created[0]?.id;
|
||||
for (const item of plates) await fs.rm(item.path, { force: true });
|
||||
} catch (err) {
|
||||
console.error("Consume folder: import failed for", folders.join("/"), err);
|
||||
for (const item of group) await notImported(item);
|
||||
return;
|
||||
}
|
||||
summary.models += 1;
|
||||
summary.files += plates.length;
|
||||
summary.printIds.push(printId);
|
||||
|
||||
let thumbSeeded = !firstPlateId || plateThumbExists(firstPlateId);
|
||||
for (const item of rest) {
|
||||
try {
|
||||
if (PREVIEW_IMAGE_EXTS.has(extOf(item.rel)) && item.size <= PREVIEW_IMAGE_MAX_BYTES) {
|
||||
const buffer = await fs.readFile(item.path);
|
||||
if (await addPreviewImage(printId, buffer)) {
|
||||
if (!thumbSeeded) thumbSeeded = await saveThumbFromBytes(firstPlateId!, buffer);
|
||||
await fs.rm(item.path, { force: true });
|
||||
summary.files += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// saveFileFromTemp moves what it's given, so hand it a copy.
|
||||
const filename = path.basename(item.rel);
|
||||
const copy = tempPath();
|
||||
await fs.copyFile(item.path, copy);
|
||||
try {
|
||||
await saveFileFromTemp(userId, printId, copy, filename, guessMimeFromPath(filename));
|
||||
} finally {
|
||||
await fs.rm(copy, { force: true });
|
||||
}
|
||||
await fs.rm(item.path, { force: true });
|
||||
summary.files += 1;
|
||||
} catch (err) {
|
||||
console.error("Consume folder: couldn't add", item.rel, err);
|
||||
await notImported(item);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (mode === "folder") {
|
||||
const groups = new Map<string, Item[]>();
|
||||
for (const item of importable) {
|
||||
const key = foldersOf(item.rel).join("/");
|
||||
groups.set(key, [...(groups.get(key) ?? []), item]);
|
||||
}
|
||||
for (const [key, group] of groups) {
|
||||
if (key && group.some((i) => RENDERABLE_MODEL_EXTS.has(extOf(i.rel)))) {
|
||||
await importFolder(key.split("/"), group);
|
||||
} else {
|
||||
for (const item of group) await importEach(item);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const item of importable) await importEach(item);
|
||||
}
|
||||
|
||||
for (const zip of zips) await fs.rm(zip.abs, { force: true });
|
||||
await removeEmptyFolders(dir, files);
|
||||
|
||||
if (summary.models || summary.failed.length) {
|
||||
await createNotification(userId, consumeNotification(summary)).catch((err) =>
|
||||
console.error("Consume folder: notification failed", err),
|
||||
);
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
function plural(count: number, word: string) {
|
||||
return `${count} ${word}${count === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
function consumeNotification({ models, files, failed, printIds }: ConsumeSummary) {
|
||||
const bodyParts = [`${plural(files, "file")} moved into the library.`];
|
||||
if (failed.length) {
|
||||
bodyParts.push(
|
||||
`${plural(failed.length, "file")} couldn't be imported and ${failed.length === 1 ? "was" : "were"} moved to "${NOT_IMPORTED_DIR}" in the consume folder.`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
title: `Imported ${plural(models, "model")} from the consume folder`,
|
||||
body: bodyParts.join(" "),
|
||||
internalPath: models === 1 ? `/models/${printIds[0]}` : "/models",
|
||||
};
|
||||
}
|
||||
|
||||
/** Only folders this batch emptied, so a folder being copied in right now isn't touched. */
|
||||
async function removeEmptyFolders(root: string, files: FoundFile[]) {
|
||||
const dirs = new Set<string>();
|
||||
for (const file of files) {
|
||||
for (let dir = path.dirname(file.abs); dir.startsWith(root + path.sep); dir = path.dirname(dir)) dirs.add(dir);
|
||||
}
|
||||
// Deepest first, so a parent is checked after its children are gone.
|
||||
for (const dir of [...dirs].toSorted((a, b) => b.length - a.length)) {
|
||||
try {
|
||||
const left = (await fs.readdir(dir)).filter((name) => !isSystemName(name));
|
||||
if (left.length) continue;
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
|
||||
/**
|
||||
* Polls rather than watching for events: those don't reliably cross Docker bind mounts or network
|
||||
* shares. A batch is taken once two polls in a row see the same files, so a copy still in progress
|
||||
* isn't picked up half-written.
|
||||
*/
|
||||
export function startConsumeWatcher(dir = CONSUME_DIR): void {
|
||||
if (timer) return;
|
||||
console.log(
|
||||
consumeAvailable(dir) ? `Watching consume folder ${dir}` : `No consume folder at ${dir}; not watching it`,
|
||||
);
|
||||
let running = false;
|
||||
let previous: string | null = null;
|
||||
// What a batch left behind (e.g. a file that couldn't even be set aside), so it isn't redone.
|
||||
let leftover: string | null = null;
|
||||
timer = setInterval(async () => {
|
||||
if (running || !consumeAvailable(dir)) return;
|
||||
running = true;
|
||||
try {
|
||||
const current = signature(await walk(dir));
|
||||
if (!current || current !== previous || current === leftover) {
|
||||
previous = current;
|
||||
return;
|
||||
}
|
||||
await consumeFolder(dir);
|
||||
leftover = previous = signature(await walk(dir));
|
||||
} catch (err) {
|
||||
console.error("Consume folder: scan failed", err);
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
}, POLL_MS);
|
||||
timer.unref();
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
availablePlateFilename,
|
||||
ensurePlateThumbnail,
|
||||
managedPlatePath,
|
||||
plateThumbPath,
|
||||
pruneEmptyStorageDirs,
|
||||
renderPlateStoragePath,
|
||||
saveThumbFromFile,
|
||||
@@ -185,29 +186,51 @@ export async function createPrint(
|
||||
},
|
||||
});
|
||||
|
||||
const plates: Plate[] = [];
|
||||
let firstEffectivePath: string | null = null;
|
||||
let firstFilename = "";
|
||||
for (let i = 0; i < plateInputs.length; i++) {
|
||||
const { record, effectivePath } = await createPlateAtPosition(print, plateInputs[i], i);
|
||||
plates.push(record);
|
||||
if (i === 0) {
|
||||
firstEffectivePath = effectivePath;
|
||||
firstFilename = record.filename;
|
||||
try {
|
||||
const plates: Plate[] = [];
|
||||
let firstEffectivePath: string | null = null;
|
||||
let firstFilename = "";
|
||||
for (let i = 0; i < plateInputs.length; i++) {
|
||||
const { record, effectivePath } = await createPlateAtPosition(print, plateInputs[i], i);
|
||||
plates.push(record);
|
||||
if (i === 0) {
|
||||
firstEffectivePath = effectivePath;
|
||||
firstFilename = record.filename;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (firstEffectivePath) {
|
||||
const sniffed = await inspectPreparedPrint(firstEffectivePath, firstFilename);
|
||||
if (sniffed) {
|
||||
await prisma.print.update({
|
||||
where: { id: print.id },
|
||||
data: { preparedMetadata: sniffed as unknown as Prisma.InputJsonValue },
|
||||
});
|
||||
if (firstEffectivePath) {
|
||||
const sniffed = await inspectPreparedPrint(firstEffectivePath, firstFilename);
|
||||
if (sniffed) {
|
||||
await prisma.print.update({
|
||||
where: { id: print.id },
|
||||
data: { preparedMetadata: sniffed as unknown as Prisma.InputJsonValue },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { print, plates };
|
||||
return { print, plates };
|
||||
} catch (err) {
|
||||
await discardPrint(print.id);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Undoes a half-created print, so a failure doesn't leave a model with only some of its plates. */
|
||||
async function discardPrint(printId: string): Promise<void> {
|
||||
try {
|
||||
const plates = await prisma.plate.findMany({ where: { printId } });
|
||||
await prisma.print.delete({ where: { id: printId } });
|
||||
for (const plate of plates) {
|
||||
await fs.rm(plateThumbPath(plate.id), { force: true });
|
||||
if (plate.sourcePath) continue;
|
||||
const managed = managedPlatePath(plate);
|
||||
await fs.rm(managed, { force: true });
|
||||
await pruneEmptyStorageDirs(path.dirname(managed));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to discard half-created print", printId, err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function addPlatesToPrint(
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import request from "supertest";
|
||||
import sharp from "sharp";
|
||||
import JSZip from "jszip";
|
||||
import { createApp } from "../src/app";
|
||||
import { IMPORT_MAX_BYTES } from "../src/config";
|
||||
import { createPrint } from "../src/services/printCreation";
|
||||
import { prisma } from "../src/db";
|
||||
import {
|
||||
NOT_IMPORTED_DIR,
|
||||
consumeFolder,
|
||||
setConsumeSettings,
|
||||
startConsumeWatcher,
|
||||
} from "../src/services/consumeService";
|
||||
|
||||
const app = createApp();
|
||||
const STL =
|
||||
"solid t\nfacet normal 0 0 1\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\nendsolid t\n";
|
||||
let userId: string;
|
||||
let token: string;
|
||||
let dir: string;
|
||||
|
||||
function put(rel: string, contents: string | Buffer) {
|
||||
const abs = path.join(dir, rel);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
fs.writeFileSync(abs, contents);
|
||||
}
|
||||
|
||||
const png = () =>
|
||||
sharp({ create: { width: 8, height: 8, channels: 3, background: { r: 200, g: 0, b: 0 } } })
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
async function printsNamed(name: string) {
|
||||
return prisma.print.findMany({
|
||||
where: { userId, name },
|
||||
include: { plates: true, previewImages: true, files: true, category: true },
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const res = await request(app)
|
||||
.post("/api/register")
|
||||
.send({ displayName: "Consumer", email: `consume-${Date.now()}@example.com`, password: "password123" });
|
||||
userId = res.body.user.id;
|
||||
token = res.body.token;
|
||||
await setConsumeSettings({ userId });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.setting.deleteMany({ where: { key: { in: ["consume_mode", "consume_user_id"] } } });
|
||||
});
|
||||
|
||||
describe("consume folder", () => {
|
||||
it("imports each file as a model by default, recreating folders, and empties the folder", async () => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), "consume-"));
|
||||
await setConsumeSettings({ mode: "separate" });
|
||||
put("Loose.stl", STL);
|
||||
put("Shelf/Gadgets/Clip.stl", STL);
|
||||
put("Shelf/notes.txt", "not a model");
|
||||
|
||||
const summary = await consumeFolder(dir);
|
||||
expect(summary).toMatchObject({ models: 2, files: 2, failed: ["Shelf/notes.txt"] });
|
||||
|
||||
const [clip] = await printsNamed("Clip");
|
||||
expect(clip.category?.name).toBe("Gadgets");
|
||||
expect(clip.category?.kind).toBe("folder");
|
||||
expect((await printsNamed("Loose"))[0].categoryId).toBeNull();
|
||||
|
||||
expect(fs.readdirSync(dir)).toEqual([NOT_IMPORTED_DIR]);
|
||||
expect(fs.existsSync(path.join(dir, NOT_IMPORTED_DIR, "Shelf/notes.txt"))).toBe(true);
|
||||
|
||||
const notes = await request(app)
|
||||
.get("/api/notifications")
|
||||
.set({ Authorization: `Bearer ${token}` });
|
||||
expect(JSON.stringify(notes.body)).toContain("Imported 2 models from the consume folder");
|
||||
});
|
||||
|
||||
it("makes a folder of model files one model, with its images as previews", async () => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), "consume-"));
|
||||
await setConsumeSettings({ mode: "folder" });
|
||||
put("Robots/Walker/leg.stl", STL);
|
||||
put("Robots/Walker/body.stl", STL);
|
||||
put("Robots/Walker/photo.png", await png());
|
||||
put("Robots/Walker/readme.pdf", "pdf");
|
||||
|
||||
const summary = await consumeFolder(dir);
|
||||
expect(summary).toMatchObject({ models: 1, files: 4, failed: [] });
|
||||
const [walker] = await printsNamed("Walker");
|
||||
expect(walker.plates.map((p) => p.filename).toSorted()).toEqual(["body.stl", "leg.stl"]);
|
||||
expect(walker.previewImages).toHaveLength(1);
|
||||
expect(walker.files.map((f) => f.filename)).toEqual(["readme.pdf"]);
|
||||
expect(walker.category?.name).toBe("Robots");
|
||||
expect(fs.readdirSync(dir)).toEqual([]);
|
||||
});
|
||||
|
||||
it("unpacks a zip as a folder named after it", async () => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), "consume-"));
|
||||
await setConsumeSettings({ mode: "folder" });
|
||||
const zip = new JSZip();
|
||||
zip.file("a.stl", STL);
|
||||
zip.file("b.stl", STL);
|
||||
put("Zipped Model.zip", await zip.generateAsync({ type: "nodebuffer" }));
|
||||
|
||||
expect(await consumeFolder(dir)).toMatchObject({ models: 1, failed: [] });
|
||||
expect((await printsNamed("Zipped Model"))[0].plates).toHaveLength(2);
|
||||
expect(fs.readdirSync(dir)).toEqual([]);
|
||||
});
|
||||
|
||||
it("picks up files dropped into a watched folder once they stop changing", async () => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), "consume-"));
|
||||
await setConsumeSettings({ mode: "separate" });
|
||||
startConsumeWatcher(dir);
|
||||
put("Dropped.stl", STL);
|
||||
for (let i = 0; i < 40 && fs.existsSync(path.join(dir, "Dropped.stl")); i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
expect(fs.existsSync(path.join(dir, "Dropped.stl"))).toBe(false);
|
||||
expect(await printsNamed("Dropped")).toHaveLength(1);
|
||||
}, 20000);
|
||||
|
||||
it("sets aside a folder whose model couldn't be created, leaving no half-made model", async () => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), "consume-"));
|
||||
await setConsumeSettings({ mode: "folder" });
|
||||
put("Broken/a.stl", STL);
|
||||
put("Broken/b.stl", STL);
|
||||
fs.chmodSync(path.join(dir, "Broken/b.stl"), 0o000);
|
||||
|
||||
const summary = await consumeFolder(dir);
|
||||
expect(summary).toMatchObject({ models: 0, failed: ["Broken/a.stl", "Broken/b.stl"] });
|
||||
expect(await printsNamed("Broken")).toHaveLength(0);
|
||||
expect(fs.readdirSync(path.join(dir, NOT_IMPORTED_DIR, "Broken")).toSorted()).toEqual(["a.stl", "b.stl"]);
|
||||
});
|
||||
|
||||
it("sets aside files over the import size limit", async () => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), "consume-"));
|
||||
await setConsumeSettings({ mode: "separate" });
|
||||
put("Huge.stl", "");
|
||||
// Sparse, so it takes no real disk space.
|
||||
fs.truncateSync(path.join(dir, "Huge.stl"), IMPORT_MAX_BYTES + 1);
|
||||
|
||||
expect(await consumeFolder(dir)).toMatchObject({ models: 0, failed: ["Huge.stl"] });
|
||||
expect(await printsNamed("Huge")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createPrint", () => {
|
||||
it("removes a half-created print when a later plate fails", async () => {
|
||||
const src = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "plates-")), "first.stl");
|
||||
fs.writeFileSync(src, STL);
|
||||
await expect(
|
||||
createPrint(userId, { title: "Half made" }, "Half made", [
|
||||
{ filename: "first.stl", copyFromPath: src },
|
||||
{ filename: "second.stl", copyFromPath: `${src}.missing` },
|
||||
]),
|
||||
).rejects.toThrow(/ENOENT/);
|
||||
expect(await printsNamed("Half made")).toHaveLength(0);
|
||||
expect(await prisma.plate.count({ where: { print: { userId, name: "Half made" } } })).toBe(0);
|
||||
expect(fs.existsSync(src)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("consume settings routes", () => {
|
||||
let adminToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const email = `consume-admin-${Date.now()}@example.com`;
|
||||
const res = await request(app).post("/api/register").send({ displayName: "Admin", email, password: "password123" });
|
||||
await prisma.user.update({ where: { id: res.body.user.id }, data: { role: "ADMIN" } });
|
||||
// Tokens carry the role from when they were issued.
|
||||
adminToken = (await request(app).post("/api/login").send({ email, password: "password123" })).body.token;
|
||||
});
|
||||
|
||||
it("is admin-only", async () => {
|
||||
const res = await request(app)
|
||||
.get("/api/settings/consume")
|
||||
.set({ Authorization: `Bearer ${token}` });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("reports and saves the mode and owner", async () => {
|
||||
const admin = { Authorization: `Bearer ${adminToken}` };
|
||||
const res = await request(app).patch("/api/settings/consume").set(admin).send({ mode: "folder", user_id: userId });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({ mode: "folder", user_id: userId, not_imported_dir: NOT_IMPORTED_DIR });
|
||||
expect(typeof res.body.available).toBe("boolean");
|
||||
expect((await request(app).get("/api/settings/consume").set(admin)).body.mode).toBe("folder");
|
||||
});
|
||||
|
||||
it("rejects an unknown mode or user", async () => {
|
||||
const admin = { Authorization: `Bearer ${adminToken}` };
|
||||
expect((await request(app).patch("/api/settings/consume").set(admin).send({ mode: "zip" })).status).toBe(400);
|
||||
expect((await request(app).patch("/api/settings/consume").set(admin).send({ user_id: "nope" })).status).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -53,6 +53,9 @@ services:
|
||||
condition: service_started
|
||||
volumes:
|
||||
- thingport_storage:/app/storage
|
||||
# Optional consume folder: files and folders dropped in it are imported automatically
|
||||
# (Administration -> Settings -> Consume Folder). Uncomment and point it at a host folder.
|
||||
# - /path/to/consume:/app/consume
|
||||
networks:
|
||||
- app-net
|
||||
|
||||
|
||||
@@ -53,6 +53,9 @@ services:
|
||||
condition: service_started
|
||||
volumes:
|
||||
- ./data/storage:/app/storage
|
||||
# Optional consume folder: files and folders dropped in it are imported automatically
|
||||
# (Administration -> Settings -> Consume Folder). Uncomment and point it at a host folder.
|
||||
# - ./data/consume:/app/consume
|
||||
networks:
|
||||
- app-net
|
||||
|
||||
|
||||
@@ -65,6 +65,9 @@ services:
|
||||
condition: service_started
|
||||
volumes:
|
||||
- /DATA/AppData/thingport/storage:/app/storage
|
||||
# Optional consume folder: files and folders dropped in it are imported automatically
|
||||
# (Administration -> Settings -> Consume Folder). Uncomment and point it at a host folder.
|
||||
# - /DATA/AppData/thingport/consume:/app/consume
|
||||
networks:
|
||||
- app-net
|
||||
|
||||
|
||||
@@ -69,6 +69,9 @@ services:
|
||||
condition: service_started
|
||||
volumes:
|
||||
- ${APPDATA_PATH}/storage:/app/storage
|
||||
# Optional consume folder: files and folders dropped in it are imported automatically
|
||||
# (Administration -> Settings -> Consume Folder). Uncomment and point it at a host folder.
|
||||
# - ${APPDATA_PATH}/consume:/app/consume
|
||||
networks:
|
||||
- app-net
|
||||
|
||||
|
||||
@@ -71,6 +71,9 @@ services:
|
||||
condition: service_started
|
||||
volumes:
|
||||
- /mnt/user/appdata/thingport/storage:/app/storage
|
||||
# Optional consume folder: files and folders dropped in it are imported automatically
|
||||
# (Administration -> Settings -> Consume Folder). Uncomment and point it at a host folder.
|
||||
# - /mnt/user/appdata/thingport/consume:/app/consume
|
||||
networks:
|
||||
- app-net
|
||||
labels:
|
||||
|
||||
@@ -16,6 +16,19 @@ export type CategoriesView = "categories" | "folders";
|
||||
|
||||
export type PreviewMode = "automatic" | "on-demand" | "disabled";
|
||||
|
||||
/** Kept in sync by hand with the backend's CONSUME_MODES. */
|
||||
export type ConsumeMode = "separate" | "folder";
|
||||
|
||||
export type ConsumeSettings = {
|
||||
/** False when nothing is mounted at `path`, so nothing is watched. */
|
||||
available: boolean;
|
||||
path: string;
|
||||
not_imported_dir: string;
|
||||
mode: ConsumeMode;
|
||||
/** Whose library consumed models go to. */
|
||||
user_id: string | null;
|
||||
};
|
||||
|
||||
export type RenderingSettings = { simplify_previews: boolean };
|
||||
|
||||
export type AuthSettings = {
|
||||
@@ -302,6 +315,23 @@ export const settingsApi = {
|
||||
return res.json();
|
||||
},
|
||||
|
||||
getConsume: async (): Promise<ConsumeSettings> => {
|
||||
const res = await fetch(`${apiBase()}/settings/consume`, { headers: authHeaders() });
|
||||
assertOk(res, "Failed to load consume folder settings");
|
||||
return res.json();
|
||||
},
|
||||
|
||||
updateConsume: async (patch: { mode?: ConsumeMode; user_id?: string }): Promise<ConsumeSettings> => {
|
||||
const res = await fetch(`${apiBase()}/settings/consume`, {
|
||||
method: "PATCH",
|
||||
headers: authHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
if (res.status === 401) throw new UnauthorizedError();
|
||||
if (!res.ok) throw new Error(await readErrorMessage(res, "Failed to update consume folder settings"));
|
||||
return res.json();
|
||||
},
|
||||
|
||||
getAuthorPreview: async (): Promise<{ enabled: boolean }> => {
|
||||
const res = await fetch(`${apiBase()}/settings/author-preview`, { headers: authHeaders() });
|
||||
assertOk(res, "Failed to load author preview setting");
|
||||
|
||||
@@ -349,6 +349,23 @@
|
||||
"plate": "The file's position in the model, starting at 1"
|
||||
}
|
||||
},
|
||||
"consume": {
|
||||
"heading": "Consume Folder",
|
||||
"subtitle": "Import anything dropped into a watched folder: files, folder structures and ZIPs.",
|
||||
"notSetUpTitle": "No consume folder is set up",
|
||||
"notSetUpBody": "Nothing is mounted at {{path}}, so no folder is being watched. To use one, add a volume to the backend service in docker-compose.yml, e.g. \"- /path/on/host:/app/consume\", and run \"docker compose up -d\" again. The settings below can be chosen in the meantime.",
|
||||
"activeBody": "Watching {{path}}. Files are imported a few seconds after they stop changing and moved into storage, so the folder empties itself. Folders become folders, and ZIPs are unpacked as if they were folders. Anything that can't be imported is moved to \"{{notImported}}\" in that folder.",
|
||||
"userLabel": "Import into the library of",
|
||||
"modeLabel": "Import mode",
|
||||
"modeSeparateTitle": "Each model file is a separate model",
|
||||
"modeSeparateDesc": "Every STL, 3MF, STEP or OBJ file becomes its own model.",
|
||||
"modeFolderTitle": "Each folder is one model",
|
||||
"modeFolderDesc": "A folder containing model files becomes a single model named after the folder. Its model files are added as plates, its images become preview images, and other files are attached.",
|
||||
"save": "Save consume settings",
|
||||
"saving": "Saving…",
|
||||
"saved": "Consume settings saved.",
|
||||
"failed": "Failed to save consume settings."
|
||||
},
|
||||
"registrations": {
|
||||
"allowLabel": "Allow new registrations",
|
||||
"openHelp": "Anyone who can reach this instance can create an account from the sign-in page.",
|
||||
|
||||
@@ -350,6 +350,23 @@
|
||||
"plate": "Failo eilės numeris modelyje, pradedant nuo 1"
|
||||
}
|
||||
},
|
||||
"consume": {
|
||||
"heading": "Importavimo aplankas",
|
||||
"subtitle": "Importuokite viską, kas įdedama į stebimą aplanką: failus, aplankų struktūras ir ZIP archyvus.",
|
||||
"notSetUpTitle": "Importavimo aplankas nenustatytas",
|
||||
"notSetUpBody": "Kelyje {{path}} niekas neprijungta, todėl joks aplankas nestebimas. Norėdami jį naudoti, docker-compose.yml faile backend paslaugai pridėkite tomą, pvz. \"- /kelias/serveryje:/app/consume\", ir dar kartą paleiskite \"docker compose up -d\". Žemiau esančius nustatymus galite pasirinkti jau dabar.",
|
||||
"activeBody": "Stebimas {{path}}. Failai importuojami praėjus kelioms sekundėms po to, kai nustoja keistis, ir perkeliami į saugyklą, todėl aplankas išsivalo pats. Aplankai tampa aplankais, o ZIP archyvai išpakuojami tarsi aplankai. Tai, ko nepavyksta importuoti, perkeliama į „{{notImported}}“ tame aplanke.",
|
||||
"userLabel": "Importuoti į biblioteką naudotojo",
|
||||
"modeLabel": "Importavimo būdas",
|
||||
"modeSeparateTitle": "Kiekvienas modelio failas – atskiras modelis",
|
||||
"modeSeparateDesc": "Kiekvienas STL, 3MF, STEP ar OBJ failas tampa atskiru modeliu.",
|
||||
"modeFolderTitle": "Kiekvienas aplankas – vienas modelis",
|
||||
"modeFolderDesc": "Aplankas su modelio failais tampa vienu modeliu, pavadintu aplanko vardu. Jo modelio failai pridedami kaip plokštės, paveikslėliai tampa peržiūros paveikslėliais, o kiti failai prisegami.",
|
||||
"save": "Išsaugoti importavimo nustatymus",
|
||||
"saving": "Saugoma…",
|
||||
"saved": "Importavimo nustatymai išsaugoti.",
|
||||
"failed": "Nepavyko išsaugoti importavimo nustatymų."
|
||||
},
|
||||
"registrations": {
|
||||
"allowLabel": "Leisti naujas registracijas",
|
||||
"openHelp": "Bet kas, pasiekiantis šį serverį, gali susikurti paskyrą prisijungimo puslapyje.",
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Box from "@mui/material/Box";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Paper from "@mui/material/Paper";
|
||||
import Alert from "@mui/material/Alert";
|
||||
import AlertTitle from "@mui/material/AlertTitle";
|
||||
import Button from "@mui/material/Button";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import Radio from "@mui/material/Radio";
|
||||
import RadioGroup from "@mui/material/RadioGroup";
|
||||
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||
import { UnauthorizedError } from "../../api/client";
|
||||
import { settingsApi, type ConsumeMode, type ConsumeSettings } from "../../api/settings";
|
||||
import { adminApi, type AdminUser } from "../../api/admin";
|
||||
import SectionHeader from "../../components/SectionHeader";
|
||||
|
||||
type Props = {
|
||||
onUnauthorized?: () => void;
|
||||
};
|
||||
|
||||
const MODES: ConsumeMode[] = ["separate", "folder"];
|
||||
const MODE_TEXT: Record<ConsumeMode, "Separate" | "Folder"> = { separate: "Separate", folder: "Folder" };
|
||||
|
||||
/** The folder itself comes from the Docker setup; only how it's imported is chosen here. */
|
||||
export default function ConsumeSection({ onUnauthorized }: Props) {
|
||||
const { t } = useTranslation("app");
|
||||
const [settings, setSettings] = React.useState<ConsumeSettings | null>(null);
|
||||
const [users, setUsers] = React.useState<AdminUser[]>([]);
|
||||
const [mode, setMode] = React.useState<ConsumeMode>("separate");
|
||||
const [userId, setUserId] = React.useState("");
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [status, setStatus] = React.useState<string | null>(null);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const apply = React.useCallback((data: ConsumeSettings) => {
|
||||
setSettings(data);
|
||||
setMode(data.mode);
|
||||
setUserId(data.user_id ?? "");
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
let active = true;
|
||||
void (async () => {
|
||||
try {
|
||||
const [data, list] = await Promise.all([settingsApi.getConsume(), adminApi.listUsers()]);
|
||||
if (!active) return;
|
||||
apply(data);
|
||||
setUsers(list);
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedError) onUnauthorized?.();
|
||||
else if (active) setError(err instanceof Error ? err.message : t("adminSettings.consume.failed"));
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [apply, onUnauthorized, t]);
|
||||
|
||||
const loading = settings === null;
|
||||
const isDirty = !loading && (mode !== settings.mode || userId !== (settings.user_id ?? ""));
|
||||
|
||||
const save = async () => {
|
||||
if (!isDirty || saving) return;
|
||||
setSaving(true);
|
||||
setStatus(null);
|
||||
setError(null);
|
||||
try {
|
||||
apply(await settingsApi.updateConsume({ mode, user_id: userId || undefined }));
|
||||
setStatus(t("adminSettings.consume.saved"));
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedError) onUnauthorized?.();
|
||||
else setError(err instanceof Error ? err.message : t("adminSettings.consume.failed"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack spacing={3}>
|
||||
<SectionHeader title={t("adminSettings.consume.heading")} subtitle={t("adminSettings.consume.subtitle")} />
|
||||
<Paper variant="outlined" sx={{ p: 2.5 }}>
|
||||
<Stack spacing={2}>
|
||||
{settings &&
|
||||
(settings.available ? (
|
||||
<Alert severity="info">
|
||||
{t("adminSettings.consume.activeBody", {
|
||||
path: settings.path,
|
||||
notImported: settings.not_imported_dir,
|
||||
})}
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert severity="warning">
|
||||
<AlertTitle>{t("adminSettings.consume.notSetUpTitle")}</AlertTitle>
|
||||
{t("adminSettings.consume.notSetUpBody", { path: settings.path })}
|
||||
</Alert>
|
||||
))}
|
||||
|
||||
<TextField
|
||||
select
|
||||
label={t("adminSettings.consume.userLabel")}
|
||||
value={users.some((u) => u.id === userId) ? userId : ""}
|
||||
onChange={(e) => setUserId(e.target.value)}
|
||||
disabled={loading || saving}
|
||||
sx={{ maxWidth: 420 }}
|
||||
>
|
||||
{users.map((user) => (
|
||||
<MenuItem key={user.id} value={user.id}>
|
||||
{user.display_name} ({user.email})
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>
|
||||
{t("adminSettings.consume.modeLabel")}
|
||||
</Typography>
|
||||
<RadioGroup value={mode} onChange={(e) => setMode(e.target.value as ConsumeMode)}>
|
||||
<Stack spacing={1.5}>
|
||||
{MODES.map((option) => {
|
||||
const selected = mode === option;
|
||||
return (
|
||||
<FormControlLabel
|
||||
key={option}
|
||||
value={option}
|
||||
control={<Radio />}
|
||||
disabled={loading || saving}
|
||||
sx={{
|
||||
alignItems: "flex-start",
|
||||
m: 0,
|
||||
borderRadius: 2,
|
||||
border: "1px solid",
|
||||
borderColor: selected ? "primary.main" : "divider",
|
||||
bgcolor: selected ? "action.selected" : "transparent",
|
||||
p: 2,
|
||||
}}
|
||||
label={
|
||||
<Box>
|
||||
<Typography variant="body2" fontWeight={600}>
|
||||
{t(`adminSettings.consume.mode${MODE_TEXT[option]}Title`)}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t(`adminSettings.consume.mode${MODE_TEXT[option]}Desc`)}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</RadioGroup>
|
||||
</Box>
|
||||
|
||||
<Stack direction="row" spacing={1.5} alignItems="center" flexWrap="wrap" useFlexGap>
|
||||
<Button variant="contained" onClick={save} disabled={!isDirty || saving}>
|
||||
{saving ? t("adminSettings.consume.saving") : t("adminSettings.consume.save")}
|
||||
</Button>
|
||||
{saving && <CircularProgress size={14} />}
|
||||
{status && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{status}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" onClose={() => setError(null)}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Divider from "@mui/material/Divider";
|
||||
import StorageSection from "./StorageSection";
|
||||
import ConsumeSection from "./ConsumeSection";
|
||||
import ThingiverseSection from "./ThingiverseSection";
|
||||
import SessionSection from "./SessionSection";
|
||||
|
||||
@@ -14,6 +15,7 @@ export default function AdminSettingsPage({ onUnauthorized }: Props) {
|
||||
<Stack spacing={4} divider={<Divider />}>
|
||||
<ThingiverseSection onUnauthorized={onUnauthorized} />
|
||||
<StorageSection onUnauthorized={onUnauthorized} />
|
||||
<ConsumeSection onUnauthorized={onUnauthorized} />
|
||||
<SessionSection onUnauthorized={onUnauthorized} />
|
||||
</Stack>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user