mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-19 05:10:01 -05:00
fix: make storage template collection and creator tokens reflect real data
{collection} read a free-text Print.collection field nothing ever set, so it
always rendered "Uncollected". It now uses the model's real collections: the
collection's name, "Multiple collections" when it's in several, or
"Uncollected" when it's in none. Adding or removing a model, and renaming or
deleting a collection, moves the files when the template uses {collection}.
{creator} now resolves to the author the model page shows, falling back to
the uploader's display name for their own uploads, and an author reset moves
the files. {name} (always the same as {model}) is rewritten to {model}.
The unused Print.collection column is dropped, and the admin Storage
Structure page explains each token.
This commit is contained in:
1 parent
9dd3e693e0
commit
32710f25e7
13 files changed
+267
-44
No files matched your search
@@ -0,0 +1,4 @@
|
||||
-- Print.collection was a free-text field that nothing in the app ever set, left over from before
|
||||
-- real collections (Collection/CollectionItem) existed. The storage template's {collection} token
|
||||
-- read it, so that token always rendered "Uncollected"; it now reads real collection membership.
|
||||
ALTER TABLE "Print" DROP COLUMN "collection";
|
||||
@@ -126,7 +126,6 @@ model Print {
|
||||
title String?
|
||||
notes String?
|
||||
creator String?
|
||||
collection String?
|
||||
tags String[] @default([])
|
||||
viewCount Int @default(0)
|
||||
printCount Int @default(0)
|
||||
|
||||
@@ -111,7 +111,6 @@ export type PrintOut = {
|
||||
notes: string | null;
|
||||
creator: string | null;
|
||||
author: AuthorOut | null;
|
||||
collection: string | null;
|
||||
tags: string[];
|
||||
category_id: string | null;
|
||||
// Only populated by the single-print detail fetch (printOutById) -- see toPrintOut's `category`
|
||||
@@ -285,7 +284,6 @@ export function toPrintOut(
|
||||
notes: print.notes,
|
||||
creator: print.creator,
|
||||
author: author ? toAuthorOut(author) : null,
|
||||
collection: print.collection,
|
||||
tags: print.tags,
|
||||
category_id: print.categoryId,
|
||||
category_name: category?.name ?? null,
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "../services/collectionService";
|
||||
import { addCollectionBookmark, listBookmarkedCollectionIdSet, removeCollectionBookmark } from "../services/bookmarkService";
|
||||
import { printOutsByIds } from "../services/printLoader";
|
||||
import { relocatePrintsForToken } from "../services/printService";
|
||||
import { createLog } from "../services/auditLog";
|
||||
import { toCollectionOut, type PrintOut } from "../dto";
|
||||
|
||||
@@ -31,6 +32,13 @@ const collectionSchema = z.object({
|
||||
|
||||
const COVER_ITEM_LIMIT = 4;
|
||||
|
||||
/** The collection's models, for moving their files once its name or its existence stops being
|
||||
* what their {collection} folder was built from. */
|
||||
async function collectionPrintIds(collectionId: string): Promise<string[]> {
|
||||
const items = await prisma.collectionItem.findMany({ where: { collectionId }, select: { printId: true } });
|
||||
return items.map((item) => item.printId);
|
||||
}
|
||||
|
||||
router.get(
|
||||
"/collections",
|
||||
asyncHandler(async (req, res) => {
|
||||
@@ -116,6 +124,7 @@ router.patch(
|
||||
const collection = await prisma.collection.findFirst({ where: { id: req.params.id, userId: req.userId } });
|
||||
if (!collection) throw new HttpError(404, "Collection not found");
|
||||
await assertCollectionNameAvailable(req.userId!, body.name, collection.id);
|
||||
const renamed = body.name !== collection.name;
|
||||
const updated = await prisma.collection.update({
|
||||
where: { id: collection.id },
|
||||
data: {
|
||||
@@ -125,6 +134,7 @@ router.patch(
|
||||
tags: normalizeTags(body.tags),
|
||||
},
|
||||
});
|
||||
if (renamed) await relocatePrintsForToken("collection", await collectionPrintIds(updated.id));
|
||||
const [itemCount, bookmarked] = await Promise.all([
|
||||
prisma.collectionItem.count({ where: { collectionId: updated.id } }),
|
||||
prisma.bookmark.findFirst({ where: { userId: req.userId, type: "COLLECTION", collectionId: updated.id } }),
|
||||
@@ -147,7 +157,9 @@ router.delete(
|
||||
}
|
||||
const collection = await prisma.collection.findFirst({ where: { id: req.params.id, userId: req.userId } });
|
||||
if (!collection) throw new HttpError(404, "Collection not found");
|
||||
const printIds = await collectionPrintIds(collection.id);
|
||||
await prisma.collection.delete({ where: { id: collection.id } });
|
||||
await relocatePrintsForToken("collection", printIds);
|
||||
res.json({ ok: true });
|
||||
void createLog({
|
||||
userId: req.userId!,
|
||||
@@ -173,6 +185,7 @@ router.delete(
|
||||
const print = await prisma.print.findFirst({ where: { id: req.params.printId, userId: req.userId } });
|
||||
if (!print) throw new HttpError(404, "Print not found");
|
||||
await prisma.collectionItem.deleteMany({ where: { collectionId: collection.id, printId: print.id } });
|
||||
await relocatePrintsForToken("collection", [print.id]);
|
||||
res.json({ ok: true });
|
||||
void createLog({
|
||||
userId: req.userId!,
|
||||
|
||||
@@ -10,7 +10,7 @@ import { parseBody } from "../utils/validate";
|
||||
import { asyncHandler } from "../utils/asyncHandler";
|
||||
import { modelUpload } from "../uploadMiddleware";
|
||||
import { createPrint, deletePlateFiles, resolvePlateFilePath, type NewPlateInput } from "../services/printCreation";
|
||||
import { plateThumbPath, relocatePrint, uniqueModelName } from "../services/printService";
|
||||
import { plateThumbPath, relocatePrint, relocatePrintsForToken, uniqueModelName } from "../services/printService";
|
||||
import { previewImagePath, deleteAllPreviewImages } from "../services/previewImageService";
|
||||
import { deleteAuthorIfOrphaned, getLinkedAuthorIds } from "../services/authorService";
|
||||
import { toPrintOut } from "../dto";
|
||||
@@ -98,7 +98,6 @@ async function buildPrintWhere(req: Request): Promise<Prisma.PrintWhereInput> {
|
||||
{ title: { contains: q, mode: "insensitive" } },
|
||||
{ notes: { contains: q, mode: "insensitive" } },
|
||||
{ creator: { contains: q, mode: "insensitive" } },
|
||||
{ collection: { contains: q, mode: "insensitive" } },
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -482,7 +481,6 @@ const metaSchema = z.object({
|
||||
title: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
creator: z.string().optional(),
|
||||
collection: z.string().optional(),
|
||||
});
|
||||
router.post(
|
||||
"/print/:id/meta",
|
||||
@@ -503,7 +501,6 @@ router.post(
|
||||
}
|
||||
if (body.notes !== undefined) data.notes = body.notes;
|
||||
if (body.creator !== undefined) data.creator = body.creator.trim() || null;
|
||||
if (body.collection !== undefined) data.collection = body.collection.trim() || null;
|
||||
|
||||
const updated = await prisma.print.update({ where: { id: print.id }, data });
|
||||
const plates = await prisma.plate.findMany({ where: { printId: print.id }, orderBy: { position: "asc" } });
|
||||
@@ -554,6 +551,7 @@ router.post(
|
||||
data: { authorId: null, creator: null, sourceProvider: null, sourceExternalId: null },
|
||||
});
|
||||
if (print.authorId) await deleteAuthorIfOrphaned(print.authorId);
|
||||
await relocatePrintsForToken("creator", [print.id]);
|
||||
res.json({ print: await printOutById(req.userId!, print.id) });
|
||||
void createLog({ userId: req.userId!, action: "model_edited", targetId: print.id, details: { field: "author_reset", name: print.name } });
|
||||
}),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { prisma } from "../db";
|
||||
import { HttpError } from "../utils/fileUtils";
|
||||
import { printOutsByIds } from "./printLoader";
|
||||
import { relocatePrintsForToken } from "./printService";
|
||||
import { toSystemCollectionOut, type CollectionOut, type SystemCollectionKey } from "../dto";
|
||||
import type { Collection, Prisma } from "@prisma/client";
|
||||
|
||||
@@ -49,7 +50,8 @@ export async function findOrCreateCollectionByName(userId: string, name: string)
|
||||
}
|
||||
|
||||
/** Adds `printIds` to a collection, skipping any already present, continuing the display
|
||||
* position from wherever the collection's items currently leave off. */
|
||||
* position from wherever the collection's items currently leave off -- then moves their files if
|
||||
* the storage template has a {collection} folder. */
|
||||
export async function addPrintsToCollection(collectionId: string, printIds: string[]): Promise<void> {
|
||||
if (!printIds.length) return;
|
||||
const last = await prisma.collectionItem.findFirst({
|
||||
@@ -61,6 +63,7 @@ export async function addPrintsToCollection(collectionId: string, printIds: stri
|
||||
data: printIds.map((printId) => ({ collectionId, printId, position: nextPosition++ })),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
await relocatePrintsForToken("collection", printIds);
|
||||
}
|
||||
|
||||
// ---- Built-in "Favourites" / "Browsing History" pseudo-collections ----------------------------
|
||||
|
||||
@@ -41,7 +41,6 @@ export type PrintMetaInput = {
|
||||
tags?: string[];
|
||||
categoryId?: string | null;
|
||||
creator?: string | null;
|
||||
collection?: string | null;
|
||||
/** Id of an already-upserted Author row (see authorService.ts), e.g. "makerworld:12345". */
|
||||
authorId?: string | null;
|
||||
/** Set when this print was resolved from a known provider's model URL (see
|
||||
@@ -128,7 +127,7 @@ export async function refreshAutoPreparedMetadata(printId: string): Promise<void
|
||||
type CreatedPlate = { record: Plate; effectivePath: string | null };
|
||||
|
||||
async function createPlateAtPosition(
|
||||
print: Pick<Print, "id" | "name" | "creator" | "collection" | "tags" | "categoryId" | "userId">,
|
||||
print: Pick<Print, "id" | "name" | "creator" | "tags" | "categoryId" | "userId" | "authorId" | "sourceProvider">,
|
||||
input: NewPlateInput,
|
||||
position: number,
|
||||
): Promise<CreatedPlate> {
|
||||
@@ -191,7 +190,6 @@ export async function createPrint(
|
||||
title: meta.title ?? null,
|
||||
notes: meta.notes ?? null,
|
||||
creator: meta.creator?.trim() || null,
|
||||
collection: meta.collection?.trim() || null,
|
||||
tags: normalizeTags(meta.tags || []),
|
||||
categoryId: meta.categoryId ?? null,
|
||||
authorId: meta.authorId ?? null,
|
||||
|
||||
@@ -14,12 +14,22 @@ export const STORAGE_TEMPLATE_TOKENS = [
|
||||
"tags",
|
||||
"creator",
|
||||
"model",
|
||||
"name",
|
||||
"filename",
|
||||
"id",
|
||||
"plate",
|
||||
] as const;
|
||||
|
||||
// Older names still accepted in a saved template, and rewritten to the current one whenever the
|
||||
// template is validated -- so the admin page shows (and the next save stores) the new name.
|
||||
// {name} was always the same value as {model}.
|
||||
const LEGACY_TOKEN_ALIASES: Record<string, (typeof STORAGE_TEMPLATE_TOKENS)[number]> = {
|
||||
name: "model",
|
||||
};
|
||||
|
||||
/** The {collection} folder for a model in no collection, and for one in more than one. */
|
||||
export const NO_COLLECTION_FOLDER = "Uncollected";
|
||||
export const MULTIPLE_COLLECTIONS_FOLDER = "Multiple collections";
|
||||
|
||||
export const DEFAULT_STORAGE_TEMPLATE = "{category}/{model}/{filename}";
|
||||
|
||||
const TOKEN_RE = /\{([a-z_]+)\}/g;
|
||||
@@ -34,7 +44,11 @@ export function sanitizePathSegment(value: string | null | undefined, fallback:
|
||||
}
|
||||
|
||||
export function validateStorageTemplate(template: string | null | undefined): string {
|
||||
const normalized = (template || "").trim().replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
|
||||
const normalized = (template || "")
|
||||
.trim()
|
||||
.replace(/\\/g, "/")
|
||||
.replace(/^\/+|\/+$/g, "")
|
||||
.replace(TOKEN_RE, (match, token: string) => (token in LEGACY_TOKEN_ALIASES ? `{${LEGACY_TOKEN_ALIASES[token]}}` : match));
|
||||
if (!normalized) throw new HttpError(400, "Storage template cannot be empty");
|
||||
const tokens = [...normalized.matchAll(TOKEN_RE)].map((m) => m[1]);
|
||||
const unknown = tokens.filter((t) => !(STORAGE_TEMPLATE_TOKENS as readonly string[]).includes(t));
|
||||
@@ -84,7 +98,75 @@ function assertWithinStorage(relative: string): string {
|
||||
return relative;
|
||||
}
|
||||
|
||||
type PrintLike = Pick<Print, "id" | "name" | "creator" | "collection" | "tags" | "categoryId" | "userId">;
|
||||
type PrintLike = Pick<
|
||||
Print,
|
||||
"id" | "name" | "creator" | "tags" | "categoryId" | "userId" | "authorId" | "sourceProvider"
|
||||
>;
|
||||
|
||||
function templateUses(template: string, token: (typeof STORAGE_TEMPLATE_TOKENS)[number]): boolean {
|
||||
return template.includes(`{${token}}`);
|
||||
}
|
||||
|
||||
/** {creator}: the author the model page shows (ModelSidePanel): the imported Author's name or handle, else
|
||||
* the plain-text creator an import stored, else -- for the owner's own upload, which has no
|
||||
* source -- the owner. */
|
||||
async function creatorLabel(print: PrintLike): Promise<string | null> {
|
||||
if (print.authorId) {
|
||||
const author = await prisma.author.findUnique({ where: { id: print.authorId }, select: { name: true, handle: true } });
|
||||
if (author?.name || author?.handle) return author.name || author.handle;
|
||||
}
|
||||
if (print.creator) return print.creator;
|
||||
if (print.sourceProvider) return null;
|
||||
const owner = await prisma.user.findUnique({ where: { id: print.userId }, select: { displayName: true } });
|
||||
return owner?.displayName ?? null;
|
||||
}
|
||||
|
||||
/** A model can be in any number of collections; a folder can only hold it once. */
|
||||
async function collectionLabel(printId: string): Promise<string> {
|
||||
const items = await prisma.collectionItem.findMany({
|
||||
where: { printId },
|
||||
select: { collection: { select: { name: true } } },
|
||||
take: 2,
|
||||
});
|
||||
if (items.length === 0) return NO_COLLECTION_FOLDER;
|
||||
if (items.length > 1) return MULTIPLE_COLLECTIONS_FOLDER;
|
||||
return items[0].collection.name;
|
||||
}
|
||||
|
||||
type PrintTemplateValues = Record<string, string>;
|
||||
|
||||
/** Every token's value for one print, other than the per-plate {filename}/{plate}. Tokens that
|
||||
* need their own query are only looked up when the template uses them. */
|
||||
async function printTemplateValues(print: PrintLike, template: string): Promise<PrintTemplateValues> {
|
||||
const tagLabel = (print.tags || []).map((t) => t.trim()).filter(Boolean).join(" + ") || "Untagged";
|
||||
return {
|
||||
category: templateUses(template, "category") ? (await categorySegments(print.userId, print.categoryId)).join("/") : "",
|
||||
collection: templateUses(template, "collection") ? sanitizePathSegment(await collectionLabel(print.id), NO_COLLECTION_FOLDER) : "",
|
||||
tags: sanitizePathSegment(tagLabel, "Untagged"),
|
||||
creator: templateUses(template, "creator") ? sanitizePathSegment(await creatorLabel(print), "Unknown creator") : "",
|
||||
model: sanitizePathSegment(print.name, "Model"),
|
||||
id: print.id,
|
||||
};
|
||||
}
|
||||
|
||||
function renderPlatePath(
|
||||
print: PrintLike,
|
||||
template: string,
|
||||
printValues: PrintTemplateValues,
|
||||
plateFilename: string,
|
||||
platePosition: number,
|
||||
): string {
|
||||
const values = {
|
||||
...printValues,
|
||||
filename: sanitizePathSegment(plateFilename, "file"),
|
||||
plate: String(platePosition + 1),
|
||||
};
|
||||
const rendered = renderTemplate(template, values).replace(/\\/g, "/");
|
||||
const parts = rendered.split("/").filter(Boolean).map((part) => sanitizePathSegment(part, "item"));
|
||||
if (!parts.length) throw new HttpError(400, "Storage template produced an empty path");
|
||||
const userSegment = sanitizePathSegment(`u-${print.userId}`, "user");
|
||||
return assertWithinStorage(path.join(userSegment, ...parts));
|
||||
}
|
||||
|
||||
/** Renders the on-disk relative path for one plate of a print using the storage template.
|
||||
* Every user's files live under their own u-<userId> segment beneath the (instance-wide)
|
||||
@@ -96,24 +178,8 @@ export async function renderPlateStoragePath(
|
||||
template?: string | null,
|
||||
): Promise<string> {
|
||||
const safeTemplate = validateStorageTemplate(template ?? (await getStorageTemplate()));
|
||||
const tagLabel = (print.tags || []).map((t) => t.trim()).filter(Boolean).join(" + ") || "Untagged";
|
||||
const modelLabel = sanitizePathSegment(print.name, "Model");
|
||||
const values: Record<string, string> = {
|
||||
category: (await categorySegments(print.userId, print.categoryId)).join("/"),
|
||||
collection: sanitizePathSegment(print.collection || "Uncollected", "Uncollected"),
|
||||
tags: sanitizePathSegment(tagLabel, "Untagged"),
|
||||
creator: sanitizePathSegment(print.creator || "Unknown creator", "Unknown creator"),
|
||||
model: modelLabel,
|
||||
name: modelLabel,
|
||||
filename: sanitizePathSegment(plateFilename, "file"),
|
||||
id: print.id,
|
||||
plate: String(platePosition + 1),
|
||||
};
|
||||
const rendered = renderTemplate(safeTemplate, values).replace(/\\/g, "/");
|
||||
const parts = rendered.split("/").filter(Boolean).map((part) => sanitizePathSegment(part, "item"));
|
||||
if (!parts.length) throw new HttpError(400, "Storage template produced an empty path");
|
||||
const userSegment = sanitizePathSegment(`u-${print.userId}`, "user");
|
||||
return assertWithinStorage(path.join(userSegment, ...parts));
|
||||
const printValues = await printTemplateValues(print, safeTemplate);
|
||||
return renderPlatePath(print, safeTemplate, printValues, plateFilename, platePosition);
|
||||
}
|
||||
|
||||
export function samplePlateStoragePaths(template: string): [string, string] {
|
||||
@@ -124,7 +190,6 @@ export function samplePlateStoragePaths(template: string): [string, string] {
|
||||
tags: "Print in place + Useful",
|
||||
creator: "Example creator",
|
||||
model: "Multi-part gadget",
|
||||
name: "Multi-part gadget",
|
||||
id: "a1b2c3d4",
|
||||
};
|
||||
const first = renderTemplate(safeTemplate, { ...base, filename: "Base.3mf", plate: "1" });
|
||||
@@ -224,14 +289,17 @@ export async function pruneEmptyStorageDirs(start: string): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-renders and (if needed) moves every plate of a print after a name/category/tag/creator/
|
||||
* collection change. Mirrors MakersVault's relocate_asset, generalized to N plates. Supporting
|
||||
* and prepared PrintFiles are never touched here — they live at a fixed bundles/ path.
|
||||
* Re-renders and (if needed) moves every plate of a print after a change to anything its path
|
||||
* is built from: name, category, tags, creator, collections. Mirrors MakersVault's relocate_asset,
|
||||
* generalized to N plates. Supporting and prepared PrintFiles are never touched here — they live
|
||||
* at a fixed bundles/ path.
|
||||
*/
|
||||
export async function relocatePrint(print: PrintLike, plates: Plate[], template?: string | null): Promise<void> {
|
||||
const safeTemplate = validateStorageTemplate(template ?? (await getStorageTemplate()));
|
||||
const printValues = await printTemplateValues(print, safeTemplate);
|
||||
for (const plate of plates) {
|
||||
const oldPath = managedPlatePath(plate);
|
||||
const newRelative = await renderPlateStoragePath(print, plate.filename, plate.position, template);
|
||||
const newRelative = renderPlatePath(print, safeTemplate, printValues, plate.filename, plate.position);
|
||||
const newPath = path.join(STORAGE, newRelative);
|
||||
if (path.resolve(oldPath) === path.resolve(newPath)) {
|
||||
if (plate.storagePath !== newRelative) {
|
||||
@@ -251,6 +319,28 @@ export async function relocatePrint(print: PrintLike, plates: Plate[], template?
|
||||
}
|
||||
}
|
||||
|
||||
/** Moves the given prints' files after a change that only matters when the storage template
|
||||
* uses `token` -- a collection membership change, say, moves nothing under the default
|
||||
* {category}/{model}/{filename}. Best-effort per print, like reorganizeManagedPrints: the change
|
||||
* itself has already been saved, and one print's file collision shouldn't undo it or block the
|
||||
* others. */
|
||||
export async function relocatePrintsForToken(
|
||||
token: (typeof STORAGE_TEMPLATE_TOKENS)[number],
|
||||
printIds: string[],
|
||||
): Promise<void> {
|
||||
if (!printIds.length) return;
|
||||
const template = validateStorageTemplate(await getStorageTemplate());
|
||||
if (!templateUses(template, token)) return;
|
||||
const prints = await prisma.print.findMany({ where: { id: { in: printIds } }, include: { plates: true } });
|
||||
for (const print of prints) {
|
||||
try {
|
||||
await relocatePrint(print, print.plates, template);
|
||||
} catch (err) {
|
||||
console.error(`Couldn't move the files of print ${print.id} after a {${token}} change`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-lays-out managed print storage. Pass `userId` for a category rename/delete affecting only
|
||||
* that user's own prints; omit it for an instance-wide storage-template change (admin-only --
|
||||
* see requireAdmin on POST /settings/storage), which needs to touch every user's files. */
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import request from "supertest";
|
||||
import { createApp } from "../src/app";
|
||||
import { STORAGE } from "../src/config";
|
||||
import { prisma } from "../src/db";
|
||||
import { getStorageTemplate, setStorageTemplate, validateStorageTemplate } from "../src/services/printService";
|
||||
|
||||
const app = createApp();
|
||||
const stamp = Date.now();
|
||||
let token: string;
|
||||
// The template is instance-wide and every test file shares one database (see vitest.config.ts),
|
||||
// so it's put back afterwards.
|
||||
let previousTemplate: string;
|
||||
|
||||
const auth = () => ({ Authorization: `Bearer ${token}` });
|
||||
|
||||
function tmpFile(name: string, contents: string): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "thingport-test-"));
|
||||
const p = path.join(dir, name);
|
||||
fs.writeFileSync(p, contents);
|
||||
return p;
|
||||
}
|
||||
|
||||
async function upload(name: string): Promise<string> {
|
||||
const res = await request(app).post("/api/upload").set(auth()).attach("files", tmpFile(`${name}.stl`, `solid ${name} endsolid`));
|
||||
expect(res.status).toBe(200);
|
||||
return res.body.prints[0].id;
|
||||
}
|
||||
|
||||
/** The plate's path below the user's own u-<id> folder, after checking the file really is there. */
|
||||
async function platePath(printId: string): Promise<string> {
|
||||
const plate = await prisma.plate.findFirstOrThrow({ where: { printId } });
|
||||
expect(fs.existsSync(path.join(STORAGE, plate.storagePath))).toBe(true);
|
||||
return plate.storagePath.split(path.sep).slice(1).join("/");
|
||||
}
|
||||
|
||||
async function createCollection(name: string): Promise<string> {
|
||||
const res = await request(app).post("/api/collections").set(auth()).send({ name });
|
||||
expect(res.status).toBe(200);
|
||||
return res.body.id;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
previousTemplate = await getStorageTemplate();
|
||||
await setStorageTemplate("{collection}/{creator}/{model}/{filename}");
|
||||
const res = await request(app)
|
||||
.post("/api/register")
|
||||
.send({ displayName: `Storage Tester ${stamp}`, email: `storage-template-${stamp}@example.com`, password: "password123" });
|
||||
token = res.body.token;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await setStorageTemplate(previousTemplate);
|
||||
});
|
||||
|
||||
describe("legacy tokens", () => {
|
||||
it("rewrites {name} to {model}", () => {
|
||||
expect(validateStorageTemplate("{creator}/{name}/{filename}")).toBe("{creator}/{model}/{filename}");
|
||||
});
|
||||
});
|
||||
|
||||
describe("{collection} and {creator} folders", () => {
|
||||
it("follow a model's real collections as it's added, removed, and its collection renamed or deleted", async () => {
|
||||
const printId = await upload(`collection-moves-${stamp}`);
|
||||
const model = `collection-moves-${stamp}`;
|
||||
const author = `Storage Tester ${stamp}`;
|
||||
expect(await platePath(printId)).toBe(`Uncollected/${author}/${model}/${model}.stl`);
|
||||
|
||||
const first = await createCollection(`First ${stamp}`);
|
||||
expect((await request(app).post(`/api/collection/${first}/items/${printId}`).set(auth())).status).toBe(200);
|
||||
expect(await platePath(printId)).toBe(`First ${stamp}/${author}/${model}/${model}.stl`);
|
||||
|
||||
const second = await createCollection(`Second ${stamp}`);
|
||||
await request(app).post(`/api/collection/${second}/items/${printId}`).set(auth());
|
||||
expect(await platePath(printId)).toBe(`Multiple collections/${author}/${model}/${model}.stl`);
|
||||
|
||||
await request(app).delete(`/api/collection/${second}/items/${printId}`).set(auth());
|
||||
expect(await platePath(printId)).toBe(`First ${stamp}/${author}/${model}/${model}.stl`);
|
||||
|
||||
await request(app).patch(`/api/collection/${first}`).set(auth()).send({ name: `Renamed ${stamp}` });
|
||||
expect(await platePath(printId)).toBe(`Renamed ${stamp}/${author}/${model}/${model}.stl`);
|
||||
|
||||
await request(app).delete(`/api/collection/${first}`).set(auth());
|
||||
expect(await platePath(printId)).toBe(`Uncollected/${author}/${model}/${model}.stl`);
|
||||
});
|
||||
|
||||
it("uses an imported model's author, and the uploader's name once the author is reset", async () => {
|
||||
const printId = await upload(`author-reset-${stamp}`);
|
||||
const model = `author-reset-${stamp}`;
|
||||
const authorId = `makerworld:storage-${stamp}`;
|
||||
await prisma.author.create({ data: { id: authorId, provider: "makerworld", externalId: `storage-${stamp}`, name: "Imported Maker" } });
|
||||
await prisma.print.update({ where: { id: printId }, data: { authorId, creator: "Imported Maker", sourceProvider: "makerworld" } });
|
||||
await request(app).post(`/api/print/${printId}/tags`).set(auth()).send({ tags: ["x"] }); // any edit re-renders the path
|
||||
expect(await platePath(printId)).toBe(`Uncollected/Imported Maker/${model}/${model}.stl`);
|
||||
|
||||
expect((await request(app).post(`/api/print/${printId}/author-reset`).set(auth())).status).toBe(200);
|
||||
expect(await platePath(printId)).toBe(`Uncollected/Storage Tester ${stamp}/${model}/${model}.stl`);
|
||||
});
|
||||
});
|
||||
@@ -61,7 +61,6 @@ export type Print = {
|
||||
notes?: string | null;
|
||||
creator?: string | null;
|
||||
author?: Author | null;
|
||||
collection?: string | null;
|
||||
tags: string[];
|
||||
category_id?: string | null;
|
||||
// Only populated by GET /print/:id (the detail page) -- list endpoints don't join it.
|
||||
@@ -346,7 +345,6 @@ export const printsApi = {
|
||||
title?: string | null;
|
||||
notes?: string | null;
|
||||
creator?: string | null;
|
||||
collection?: string | null;
|
||||
}) => {
|
||||
const res = await fetch(`${apiBase()}/print/${id}/meta`, {
|
||||
method: "POST",
|
||||
|
||||
@@ -297,7 +297,17 @@
|
||||
"savedApplied": "Saved. Moved {{moved}} file(s){{skippedSuffix}}.",
|
||||
"skippedSuffix": "; skipped {{skipped}}",
|
||||
"savedNoApply": "Storage structure saved for new and updated models.",
|
||||
"failed": "Failed to save storage settings."
|
||||
"failed": "Failed to save storage settings.",
|
||||
"tokens": {
|
||||
"category": "The model's category, with parent categories as parent folders",
|
||||
"collection": "The collection the model is in. Models in no collection go to \"Uncollected\", models in several go to \"Multiple collections\"",
|
||||
"tags": "The model's tags, joined with \" + \"",
|
||||
"creator": "The author shown on the model page; a user's own uploads use their display name",
|
||||
"model": "The model's name",
|
||||
"filename": "The file's name (required, in the last folder)",
|
||||
"id": "The model's permanent ID",
|
||||
"plate": "The file's position in the model, starting at 1"
|
||||
}
|
||||
},
|
||||
"registrations": {
|
||||
"allowLabel": "Allow new registrations",
|
||||
|
||||
@@ -298,7 +298,17 @@
|
||||
"savedApplied": "Išsaugota. Perkelta {{moved}} failų{{skippedSuffix}}.",
|
||||
"skippedSuffix": "; praleista {{skipped}}",
|
||||
"savedNoApply": "Saugojimo struktūra išsaugota naujiems ir atnaujintiems modeliams.",
|
||||
"failed": "Nepavyko išsaugoti saugojimo nustatymų."
|
||||
"failed": "Nepavyko išsaugoti saugojimo nustatymų.",
|
||||
"tokens": {
|
||||
"category": "Modelio kategorija; tėvinės kategorijos tampa tėviniais aplankais",
|
||||
"collection": "Kolekcija, kuriai priklauso modelis. Modeliai be kolekcijos patenka į „Uncollected“, o esantys keliose – į „Multiple collections“",
|
||||
"tags": "Modelio žymos, sujungtos „ + “",
|
||||
"creator": "Modelio puslapyje rodomas autorius; naudotojo įkeltiems modeliams naudojamas jo rodomas vardas",
|
||||
"model": "Modelio pavadinimas",
|
||||
"filename": "Failo pavadinimas (privalomas, paskutiniame aplanke)",
|
||||
"id": "Nekintantis modelio ID",
|
||||
"plate": "Failo eilės numeris modelyje, pradedant nuo 1"
|
||||
}
|
||||
},
|
||||
"registrations": {
|
||||
"allowLabel": "Leisti naujas registracijas",
|
||||
|
||||
@@ -8,6 +8,7 @@ import TextField from "@mui/material/TextField";
|
||||
import Button from "@mui/material/Button";
|
||||
import Checkbox from "@mui/material/Checkbox";
|
||||
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import { UnauthorizedError } from "../../api/client";
|
||||
import { settingsApi } from "../../api/settings";
|
||||
import SectionHeader from "../../components/SectionHeader";
|
||||
@@ -25,7 +26,6 @@ const PLATE_PREVIEW_VALUES: Record<string, string>[] = [
|
||||
tags: "Print in place + Useful",
|
||||
creator: "Example creator",
|
||||
model: "Cable clip",
|
||||
name: "Cable clip",
|
||||
filename: "Cable clip.3mf",
|
||||
id: "a1b2c3d4",
|
||||
plate: "1",
|
||||
@@ -36,7 +36,6 @@ const PLATE_PREVIEW_VALUES: Record<string, string>[] = [
|
||||
tags: "Print in place + Useful",
|
||||
creator: "Example creator",
|
||||
model: "Cable clip",
|
||||
name: "Cable clip",
|
||||
filename: "Cable clip-2.3mf",
|
||||
id: "a1b2c3d4",
|
||||
plate: "2",
|
||||
@@ -148,8 +147,8 @@ export default function StorageSection({ onUnauthorized }: Props) {
|
||||
/>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
|
||||
{storageTokens.map(token => (
|
||||
<Tooltip key={token} title={t(`adminSettings.storage.tokens.${token}`, { defaultValue: "" })}>
|
||||
<Button
|
||||
key={token}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ fontFamily: "monospace", textTransform: "none" }}
|
||||
@@ -165,6 +164,7 @@ export default function StorageSection({ onUnauthorized }: Props) {
|
||||
>
|
||||
{`{${token}}`}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Stack>
|
||||
<Paper variant="outlined" sx={{ p: 1.5, borderStyle: "dashed" }}>
|
||||
|
||||
Reference in new issue
Block a user