mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-18 20:59:55 -05:00
feat: allow multiple meta category ids
This commit is contained in:
1 parent
ac32d03071
commit
8450885ea1
14 files changed
+300
-86
No files matched your search
+25
@@ -0,0 +1,25 @@
|
||||
-- A Folder's site-category matching now accepts several ids per site (e.g. a parent category
|
||||
-- plus a couple of its subcategories), not just one. Converts each single-value *CatId column
|
||||
-- into a *CatIds int array, preserving any value already set as a one-element array, then swaps
|
||||
-- the old scalar indexes for GIN indexes suited to the array-overlap ("hasSome") queries
|
||||
-- importService.ts's resolveFolderIdByCategory now runs.
|
||||
|
||||
ALTER TABLE "Folder" ADD COLUMN "makerworldCatIds" INTEGER[] NOT NULL DEFAULT '{}';
|
||||
ALTER TABLE "Folder" ADD COLUMN "thingiverseCatIds" INTEGER[] NOT NULL DEFAULT '{}';
|
||||
ALTER TABLE "Folder" ADD COLUMN "printablesCatIds" INTEGER[] NOT NULL DEFAULT '{}';
|
||||
|
||||
UPDATE "Folder" SET "makerworldCatIds" = ARRAY["makerworldCatId"] WHERE "makerworldCatId" IS NOT NULL;
|
||||
UPDATE "Folder" SET "thingiverseCatIds" = ARRAY["thingiverseCatId"] WHERE "thingiverseCatId" IS NOT NULL;
|
||||
UPDATE "Folder" SET "printablesCatIds" = ARRAY["printablesCatId"] WHERE "printablesCatId" IS NOT NULL;
|
||||
|
||||
DROP INDEX "Folder_userId_makerworldCatId_idx";
|
||||
DROP INDEX "Folder_userId_thingiverseCatId_idx";
|
||||
DROP INDEX "Folder_userId_printablesCatId_idx";
|
||||
|
||||
ALTER TABLE "Folder" DROP COLUMN "makerworldCatId";
|
||||
ALTER TABLE "Folder" DROP COLUMN "thingiverseCatId";
|
||||
ALTER TABLE "Folder" DROP COLUMN "printablesCatId";
|
||||
|
||||
CREATE INDEX "Folder_makerworldCatIds_idx" ON "Folder" USING GIN ("makerworldCatIds");
|
||||
CREATE INDEX "Folder_thingiverseCatIds_idx" ON "Folder" USING GIN ("thingiverseCatIds");
|
||||
CREATE INDEX "Folder_printablesCatIds_idx" ON "Folder" USING GIN ("printablesCatIds");
|
||||
@@ -44,21 +44,25 @@ model Folder {
|
||||
position Int @default(0)
|
||||
metaTitle String?
|
||||
metaDescription String?
|
||||
// When set, an import from that site whose own category id matches one of these auto-lands
|
||||
// in this folder (see importService.ts's resolveFolderIdByCategory), unless the caller already
|
||||
// picked a folder explicitly. Each site's category ids are independent integer namespaces.
|
||||
makerworldCatId Int?
|
||||
thingiverseCatId Int?
|
||||
printablesCatId Int?
|
||||
prints Print[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
// When set, an import from that site whose own category id matches ANY of these auto-lands
|
||||
// in this folder (see importService.ts's resolveFolderIdByCategory -- a Prisma `hasSome`
|
||||
// overlap check), unless the caller already picked a folder explicitly. Each site's category
|
||||
// ids are independent integer namespaces. A folder can list several ids (e.g. a parent
|
||||
// category and a couple of its subcategories) since one folder may reasonably want to catch
|
||||
// more than one upstream category -- entered/edited as a "800;71;1001"-style semicolon-
|
||||
// separated string (see routes/folders.ts's parseCatIdsInput), stored as a real int array.
|
||||
makerworldCatIds Int[] @default([])
|
||||
thingiverseCatIds Int[] @default([])
|
||||
printablesCatIds Int[] @default([])
|
||||
prints Print[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([userId, parentId, position])
|
||||
@@index([tags], type: Gin)
|
||||
@@index([userId, makerworldCatId])
|
||||
@@index([userId, thingiverseCatId])
|
||||
@@index([userId, printablesCatId])
|
||||
@@index([makerworldCatIds], type: Gin)
|
||||
@@index([thingiverseCatIds], type: Gin)
|
||||
@@index([printablesCatIds], type: Gin)
|
||||
}
|
||||
|
||||
model Print {
|
||||
|
||||
+13
-6
@@ -119,9 +119,12 @@ export type FolderOut = {
|
||||
position: number;
|
||||
meta_title: string | null;
|
||||
meta_description: string | null;
|
||||
makerworld_cat_id: number | null;
|
||||
thingiverse_cat_id: number | null;
|
||||
printables_cat_id: number | null;
|
||||
// Semicolon-separated, e.g. "800;71;1001" -- same format the category manager dialog reads
|
||||
// and writes (see routes/folders.ts's parseCatIdsInput), so the frontend can bind the field
|
||||
// straight to a text input with no extra parsing. Empty string when none are set.
|
||||
makerworld_cat_ids: string;
|
||||
thingiverse_cat_ids: string;
|
||||
printables_cat_ids: string;
|
||||
};
|
||||
|
||||
function plateThumbUrl(plateId: string): string | null {
|
||||
@@ -315,6 +318,10 @@ export function toSystemCollectionOut(
|
||||
};
|
||||
}
|
||||
|
||||
function formatCatIds(ids: number[]): string {
|
||||
return ids.join(";");
|
||||
}
|
||||
|
||||
export function toFolderOut(folder: Folder): FolderOut {
|
||||
return {
|
||||
id: folder.id,
|
||||
@@ -324,9 +331,9 @@ export function toFolderOut(folder: Folder): FolderOut {
|
||||
position: folder.position,
|
||||
meta_title: folder.metaTitle,
|
||||
meta_description: folder.metaDescription,
|
||||
makerworld_cat_id: folder.makerworldCatId,
|
||||
thingiverse_cat_id: folder.thingiverseCatId,
|
||||
printables_cat_id: folder.printablesCatId,
|
||||
makerworld_cat_ids: formatCatIds(folder.makerworldCatIds),
|
||||
thingiverse_cat_ids: formatCatIds(folder.thingiverseCatIds),
|
||||
printables_cat_ids: formatCatIds(folder.printablesCatIds),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -87,13 +87,48 @@ router.patch(
|
||||
}),
|
||||
);
|
||||
|
||||
const catIdField = z.number().int().positive().nullable().optional();
|
||||
const MAX_CAT_IDS = 50;
|
||||
|
||||
/** Parses the category manager's "800;71;1001"-style text field into the int array actually
|
||||
* stored on Folder.*CatIds -- a folder can list several ids per site (e.g. a parent category
|
||||
* plus a couple of its subcategories), matched by overlap at import time (see importService.ts's
|
||||
* resolveFolderIdByCategory). Blank/whitespace-only input clears the field. Stray/duplicate
|
||||
* separators are tolerated (e.g. "800;;71;" or "800;800;71") since that's an easy typo to make
|
||||
* in a free-text field and there's nothing genuinely ambiguous about it; anything that isn't a
|
||||
* positive whole number is rejected with a message naming the exact bad token, so a typo doesn't
|
||||
* silently vanish instead of erroring. */
|
||||
function parseCatIdsInput(raw: string | null | undefined): number[] {
|
||||
const trimmed = (raw ?? "").trim();
|
||||
if (!trimmed) return [];
|
||||
const tokens = trimmed.split(";").map((t) => t.trim()).filter(Boolean);
|
||||
const ids: number[] = [];
|
||||
const seen = new Set<number>();
|
||||
for (const token of tokens) {
|
||||
if (!/^\d+$/.test(token) || token.length > 15) {
|
||||
throw new HttpError(400, `Invalid category id "${token}" -- use numbers separated by ";", e.g. 800;71;1001`);
|
||||
}
|
||||
const id = Number(token);
|
||||
if (!Number.isSafeInteger(id) || id <= 0) {
|
||||
throw new HttpError(400, `Invalid category id "${token}" -- must be a positive whole number`);
|
||||
}
|
||||
if (!seen.has(id)) {
|
||||
seen.add(id);
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
if (ids.length > MAX_CAT_IDS) {
|
||||
throw new HttpError(400, `Too many category ids -- at most ${MAX_CAT_IDS} allowed`);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
const catIdsField = z.string().trim().max(1000).nullable().optional();
|
||||
const folderMetaSchema = z.object({
|
||||
meta_title: z.string().trim().max(200).nullable().optional(),
|
||||
meta_description: z.string().trim().max(2000).nullable().optional(),
|
||||
makerworld_cat_id: catIdField,
|
||||
thingiverse_cat_id: catIdField,
|
||||
printables_cat_id: catIdField,
|
||||
makerworld_cat_ids: catIdsField,
|
||||
thingiverse_cat_ids: catIdsField,
|
||||
printables_cat_ids: catIdsField,
|
||||
});
|
||||
|
||||
router.patch(
|
||||
@@ -107,9 +142,9 @@ router.patch(
|
||||
data: {
|
||||
metaTitle: body.meta_title || null,
|
||||
metaDescription: body.meta_description || null,
|
||||
makerworldCatId: body.makerworld_cat_id ?? null,
|
||||
thingiverseCatId: body.thingiverse_cat_id ?? null,
|
||||
printablesCatId: body.printables_cat_id ?? null,
|
||||
makerworldCatIds: parseCatIdsInput(body.makerworld_cat_ids),
|
||||
thingiverseCatIds: parseCatIdsInput(body.thingiverse_cat_ids),
|
||||
printablesCatIds: parseCatIdsInput(body.printables_cat_ids),
|
||||
},
|
||||
});
|
||||
res.json(toFolderOut(updated));
|
||||
|
||||
@@ -393,16 +393,19 @@ export async function attachImportedPreviewImages(
|
||||
}
|
||||
|
||||
const CATEGORY_SITE_FOLDER_FIELD = {
|
||||
makerworld: "makerworldCatId",
|
||||
thingiverse: "thingiverseCatId",
|
||||
printables: "printablesCatId",
|
||||
makerworld: "makerworldCatIds",
|
||||
thingiverse: "thingiverseCatIds",
|
||||
printables: "printablesCatIds",
|
||||
} as const;
|
||||
|
||||
/** When the caller didn't pick a folder explicitly, checks whether any of the user's folders
|
||||
* declared a `*CatId` for this source site matching one of the model's own category ids -- if
|
||||
* so, the import auto-lands there instead of staying uncategorized. Site-scoped (each site's
|
||||
* category ids are an independent namespace) and best-effort: a lookup failure just leaves the
|
||||
* print uncategorized rather than failing the import. */
|
||||
* declared a `*CatIds` list for this source site overlapping the model's own category ids -- if
|
||||
* so, the import auto-lands there instead of staying uncategorized. A folder can list several
|
||||
* ids per site (e.g. a parent category plus a couple of its subcategories -- see
|
||||
* routes/folders.ts's parseCatIdsInput), so this is a set-overlap ("hasSome") check, not an
|
||||
* equality one. Site-scoped (each site's category ids are an independent namespace) and
|
||||
* best-effort: a lookup failure just leaves the print uncategorized rather than failing the
|
||||
* import. */
|
||||
async function resolveFolderIdByCategory(
|
||||
userId: string,
|
||||
categorySite: ImportedPageMetadata["categorySite"],
|
||||
@@ -411,7 +414,7 @@ async function resolveFolderIdByCategory(
|
||||
if (!categorySite || !siteCategoryIds.length) return null;
|
||||
const field = CATEGORY_SITE_FOLDER_FIELD[categorySite];
|
||||
const folder = await prisma.folder.findFirst({
|
||||
where: { userId, [field]: { in: siteCategoryIds } },
|
||||
where: { userId, [field]: { hasSome: siteCategoryIds } },
|
||||
orderBy: { position: "asc" },
|
||||
});
|
||||
return folder?.id ?? null;
|
||||
|
||||
@@ -255,7 +255,7 @@ function extractGalleryImages(design: Record<string, unknown>): MakerworldGaller
|
||||
|
||||
/** design.categories -- a flat array of `{id, name, ...}` objects, most-specific first (e.g.
|
||||
* "Cosplay Weapons" then its parent "Props & Cosplays"). Used to auto-land the import into a
|
||||
* Folder whose makerworldCatId matches one of these (see importService.ts's
|
||||
* Folder whose makerworldCatIds overlaps one of these (see importService.ts's
|
||||
* resolveFolderIdByCategory); every id is kept, not just the first, so a folder configured for
|
||||
* either the specific or the parent category still matches. */
|
||||
function extractCategoryIds(design: Record<string, unknown>): number[] {
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import request from "supertest";
|
||||
import { createApp } from "../src/app";
|
||||
import { prisma } from "../src/db";
|
||||
|
||||
// PATCH /folder/:id/meta's makerworld_cat_ids/thingiverse_cat_ids/printables_cat_ids fields:
|
||||
// free-text "800;71;1001"-style input, parsed and validated by routes/folders.ts's
|
||||
// parseCatIdsInput into the real Folder.*CatIds int array that importService.ts's
|
||||
// resolveFolderIdByCategory matches against (see thingiverseImport.test.ts for that side of it).
|
||||
|
||||
const app = createApp();
|
||||
let token: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const email = `folder-category-ids-test-${Date.now()}@example.com`;
|
||||
const res = await request(app)
|
||||
.post("/api/register")
|
||||
.send({ displayName: "Category Ids Test", email, password: "password123" });
|
||||
if (res.status !== 200) {
|
||||
throw new Error(`Failed to register during test setup: ${res.status} ${JSON.stringify(res.body)}`);
|
||||
}
|
||||
token = res.body.token;
|
||||
});
|
||||
|
||||
function auth() {
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
async function createFolder(name: string): Promise<string> {
|
||||
const res = await request(app).post("/api/folders").set(auth()).send({ name, tags: [] });
|
||||
expect(res.status).toBe(200);
|
||||
return res.body.id;
|
||||
}
|
||||
|
||||
describe("PATCH /folder/:id/meta -- multi-value category ids", () => {
|
||||
it("parses a semicolon-separated string into multiple ids, and round-trips it back the same way", async () => {
|
||||
const folderId = await createFolder("Multi Cat Folder");
|
||||
const res = await request(app)
|
||||
.patch(`/api/folder/${folderId}/meta`)
|
||||
.set(auth())
|
||||
.send({ thingiverse_cat_ids: "800;71;1001" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.thingiverse_cat_ids).toBe("800;71;1001");
|
||||
|
||||
const stored = await prisma.folder.findUnique({ where: { id: folderId } });
|
||||
expect(stored?.thingiverseCatIds).toEqual([800, 71, 1001]);
|
||||
});
|
||||
|
||||
it("tolerates stray/duplicate separators and whitespace, deduping the result", async () => {
|
||||
const folderId = await createFolder("Messy Input Folder");
|
||||
const res = await request(app)
|
||||
.patch(`/api/folder/${folderId}/meta`)
|
||||
.set(auth())
|
||||
.send({ makerworld_cat_ids: " 800 ;;71; 800 ;1001;" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.makerworld_cat_ids).toBe("800;71;1001");
|
||||
});
|
||||
|
||||
it("clears the field on an empty or whitespace-only string", async () => {
|
||||
const folderId = await createFolder("Clearable Folder");
|
||||
await request(app).patch(`/api/folder/${folderId}/meta`).set(auth()).send({ printables_cat_ids: "42" });
|
||||
|
||||
const res = await request(app).patch(`/api/folder/${folderId}/meta`).set(auth()).send({ printables_cat_ids: " " });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.printables_cat_ids).toBe("");
|
||||
|
||||
const stored = await prisma.folder.findUnique({ where: { id: folderId } });
|
||||
expect(stored?.printablesCatIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects a non-numeric token, naming it in the error", async () => {
|
||||
const folderId = await createFolder("Bad Input Folder");
|
||||
const res = await request(app)
|
||||
.patch(`/api/folder/${folderId}/meta`)
|
||||
.set(auth())
|
||||
.send({ thingiverse_cat_ids: "800;abc;71" });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.detail).toContain("abc");
|
||||
});
|
||||
|
||||
it("rejects zero, negative, and decimal ids", async () => {
|
||||
const folderId = await createFolder("Invalid Numbers Folder");
|
||||
for (const bad of ["0", "-5", "1.5"]) {
|
||||
const res = await request(app)
|
||||
.patch(`/api/folder/${folderId}/meta`)
|
||||
.set(auth())
|
||||
.send({ thingiverse_cat_ids: bad });
|
||||
expect(res.status).toBe(400);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects an excessive number of ids", async () => {
|
||||
const folderId = await createFolder("Too Many Ids Folder");
|
||||
const manyIds = Array.from({ length: 51 }, (_, i) => i + 1).join(";");
|
||||
const res = await request(app)
|
||||
.patch(`/api/folder/${folderId}/meta`)
|
||||
.set(auth())
|
||||
.send({ thingiverse_cat_ids: manyIds });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("keeps each site's ids independent of the others", async () => {
|
||||
const folderId = await createFolder("Independent Sites Folder");
|
||||
const res = await request(app)
|
||||
.patch(`/api/folder/${folderId}/meta`)
|
||||
.set(auth())
|
||||
.send({ makerworld_cat_ids: "1;2", thingiverse_cat_ids: "3;4", printables_cat_ids: "5;6" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.makerworld_cat_ids).toBe("1;2");
|
||||
expect(res.body.thingiverse_cat_ids).toBe("3;4");
|
||||
expect(res.body.printables_cat_ids).toBe("5;6");
|
||||
});
|
||||
});
|
||||
@@ -120,11 +120,13 @@ describe("importPrintFromUrl -- Thingiverse", () => {
|
||||
|
||||
it("imports a Thing's model files as plates, matches category, and attaches metadata + images", async () => {
|
||||
await setThingiverseAccessToken(ACCESS_TOKEN);
|
||||
// A folder configured to auto-match this Thing's Thingiverse category (129, "3D Printing
|
||||
// Tests") -- proves category matching, previously believed infeasible against the internal
|
||||
// v2 API, actually works against the official API's separate categories_url.
|
||||
// A folder configured with several category ids, only one of which (129, "3D Printing
|
||||
// Tests") actually matches this Thing -- proves both that category matching works against
|
||||
// the official API's separate categories_url (previously believed infeasible against the
|
||||
// internal v2 API), and that a folder listing multiple ids matches on ANY overlap, not just
|
||||
// an exact single-id equality.
|
||||
const folder = await prisma.folder.create({
|
||||
data: { userId, name: "Thingiverse Tests", tags: [], thingiverseCatId: CATEGORY_ID },
|
||||
data: { userId, name: "Thingiverse Tests", tags: [], thingiverseCatIds: [999001, CATEGORY_ID, 999002] },
|
||||
});
|
||||
|
||||
global.fetch = mockThingiverseFetch();
|
||||
|
||||
+19
-11
@@ -1,5 +1,5 @@
|
||||
import { authHeaders } from "../utils/auth";
|
||||
import { apiBase, assertOk } from "./client";
|
||||
import { apiBase, assertOk, readErrorMessage, UnauthorizedError } from "./client";
|
||||
|
||||
export type Folder = {
|
||||
id: string;
|
||||
@@ -9,17 +9,20 @@ export type Folder = {
|
||||
position: number;
|
||||
meta_title: string | null;
|
||||
meta_description: string | null;
|
||||
makerworld_cat_id: number | null;
|
||||
thingiverse_cat_id: number | null;
|
||||
printables_cat_id: number | null;
|
||||
// Semicolon-separated, e.g. "800;71;1001" -- a folder can match more than one upstream
|
||||
// category id per site (see backend's routes/folders.ts parseCatIdsInput). Empty string when
|
||||
// none are set.
|
||||
makerworld_cat_ids: string;
|
||||
thingiverse_cat_ids: string;
|
||||
printables_cat_ids: string;
|
||||
};
|
||||
|
||||
export type FolderMetaInput = {
|
||||
metaTitle: string | null;
|
||||
metaDescription: string | null;
|
||||
makerworldCatId: number | null;
|
||||
thingiverseCatId: number | null;
|
||||
printablesCatId: number | null;
|
||||
makerworldCatIds: string;
|
||||
thingiverseCatIds: string;
|
||||
printablesCatIds: string;
|
||||
};
|
||||
|
||||
export const foldersApi = {
|
||||
@@ -62,12 +65,17 @@ export const foldersApi = {
|
||||
body: JSON.stringify({
|
||||
meta_title: meta.metaTitle,
|
||||
meta_description: meta.metaDescription,
|
||||
makerworld_cat_id: meta.makerworldCatId,
|
||||
thingiverse_cat_id: meta.thingiverseCatId,
|
||||
printables_cat_id: meta.printablesCatId,
|
||||
makerworld_cat_ids: meta.makerworldCatIds,
|
||||
thingiverse_cat_ids: meta.thingiverseCatIds,
|
||||
printables_cat_ids: meta.printablesCatIds,
|
||||
}),
|
||||
});
|
||||
assertOk(res, "Update category details failed");
|
||||
if (res.status === 401) throw new UnauthorizedError();
|
||||
if (!res.ok) {
|
||||
// A bad category-id string (e.g. a typo) gets a specific message from the backend --
|
||||
// assertOk's fixed fallback text would swallow that, leaving the user without a reason.
|
||||
throw new Error(await readErrorMessage(res, "Update category details failed"));
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
|
||||
|
||||
@@ -607,9 +607,11 @@
|
||||
"metaDialogTitle": "Details — {{name}}",
|
||||
"metaTitleLabel": "Meta title",
|
||||
"metaDescriptionLabel": "Meta description",
|
||||
"makerworldCatIdLabel": "MakerWorld category ID",
|
||||
"thingiverseCatIdLabel": "Thingiverse category ID",
|
||||
"printablesCatIdLabel": "Printables category ID"
|
||||
"makerworldCatIdLabel": "MakerWorld category IDs",
|
||||
"thingiverseCatIdLabel": "Thingiverse category IDs",
|
||||
"printablesCatIdLabel": "Printables category IDs",
|
||||
"catIdsPlaceholder": "e.g. 800;71;1001",
|
||||
"catIdsHelp": "Separate multiple IDs with \";\" -- matches if an import's category is any of these"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -608,9 +608,11 @@
|
||||
"metaDialogTitle": "Informacija — {{name}}",
|
||||
"metaTitleLabel": "Meta pavadinimas",
|
||||
"metaDescriptionLabel": "Meta aprašymas",
|
||||
"makerworldCatIdLabel": "MakerWorld kategorijos ID",
|
||||
"thingiverseCatIdLabel": "Thingiverse kategorijos ID",
|
||||
"printablesCatIdLabel": "Printables kategorijos ID"
|
||||
"makerworldCatIdLabel": "MakerWorld kategorijų ID",
|
||||
"thingiverseCatIdLabel": "Thingiverse kategorijų ID",
|
||||
"printablesCatIdLabel": "Printables kategorijų ID",
|
||||
"catIdsPlaceholder": "pvz. 800;71;1001",
|
||||
"catIdsHelp": "Kelis ID atskirkite „;“ -- atitiks, jei importo kategorija sutampa su bet kuriuo iš jų"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,9 +41,9 @@ function hasMeta(folder: Folder): boolean {
|
||||
return Boolean(
|
||||
folder.meta_title ||
|
||||
folder.meta_description ||
|
||||
folder.makerworld_cat_id ||
|
||||
folder.thingiverse_cat_id ||
|
||||
folder.printables_cat_id,
|
||||
folder.makerworld_cat_ids ||
|
||||
folder.thingiverse_cat_ids ||
|
||||
folder.printables_cat_ids,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import DialogActions from "@mui/material/DialogActions";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import Button from "@mui/material/Button";
|
||||
import Alert from "@mui/material/Alert";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import type { Folder, FolderMetaInput } from "../../api/folders";
|
||||
|
||||
@@ -16,41 +17,40 @@ type Props = {
|
||||
onSave: (meta: FolderMetaInput) => Promise<void>;
|
||||
};
|
||||
|
||||
/** Parses a category-id field: blank stays null, anything else must be a positive integer (kept
|
||||
* as free text while typing so "12" isn't clobbered mid-edit; validated only on save). */
|
||||
function parseCatId(raw: string): number | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return null;
|
||||
const value = Number(trimmed);
|
||||
return Number.isInteger(value) && value > 0 ? value : null;
|
||||
}
|
||||
|
||||
/** Meta title/description/site-category-id editor for one category, opened from its "details"
|
||||
/** Meta title/description/site-category-ids editor for one category, opened from its "details"
|
||||
* icon in the manager. Saving with all fields blank clears the category's metadata entirely.
|
||||
* The three `*CatId` fields drive auto-categorization on import (see importService.ts's
|
||||
* The three `*CatIds` fields drive auto-categorization on import (see importService.ts's
|
||||
* resolveFolderIdByCategory on the backend): when an imported model's own site category id
|
||||
* matches one set here, it lands in this category automatically. */
|
||||
* matches ANY id listed here, it lands in this category automatically -- a folder can list
|
||||
* several ids per site (e.g. a parent category plus a couple of its subcategories), entered as
|
||||
* plain numbers separated by ";" (parsed and validated server-side by routes/folders.ts's
|
||||
* parseCatIdsInput). A validation failure (a typo, say) is shown inline here and keeps the
|
||||
* dialog open with the edits intact, rather than closing and discarding them. */
|
||||
export default function CategoryMetaDialog({ folder, onClose, onSave }: Props) {
|
||||
const { t } = useTranslation(["models", "common"]);
|
||||
const untitledLabel = t("models:categories.untitled");
|
||||
const [title, setTitle] = useState(folder.meta_title ?? "");
|
||||
const [description, setDescription] = useState(folder.meta_description ?? "");
|
||||
const [makerworldCatId, setMakerworldCatId] = useState(folder.makerworld_cat_id?.toString() ?? "");
|
||||
const [thingiverseCatId, setThingiverseCatId] = useState(folder.thingiverse_cat_id?.toString() ?? "");
|
||||
const [printablesCatId, setPrintablesCatId] = useState(folder.printables_cat_id?.toString() ?? "");
|
||||
const [makerworldCatIds, setMakerworldCatIds] = useState(folder.makerworld_cat_ids);
|
||||
const [thingiverseCatIds, setThingiverseCatIds] = useState(folder.thingiverse_cat_ids);
|
||||
const [printablesCatIds, setPrintablesCatIds] = useState(folder.printables_cat_ids);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onSave({
|
||||
metaTitle: title.trim() || null,
|
||||
metaDescription: description.trim() || null,
|
||||
makerworldCatId: parseCatId(makerworldCatId),
|
||||
thingiverseCatId: parseCatId(thingiverseCatId),
|
||||
printablesCatId: parseCatId(printablesCatId),
|
||||
makerworldCatIds,
|
||||
thingiverseCatIds,
|
||||
printablesCatIds,
|
||||
});
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("models:errors.updateCategoryMetaFailed"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -85,29 +85,33 @@ export default function CategoryMetaDialog({ folder, onClose, onSave }: Props) {
|
||||
<Stack direction="row" spacing={2}>
|
||||
<TextField
|
||||
label={t("models:categories.manager.makerworldCatIdLabel")}
|
||||
type="number"
|
||||
placeholder={t("models:categories.manager.catIdsPlaceholder") ?? undefined}
|
||||
helperText={t("models:categories.manager.catIdsHelp")}
|
||||
fullWidth
|
||||
value={makerworldCatId}
|
||||
onChange={e => setMakerworldCatId(e.target.value)}
|
||||
value={makerworldCatIds}
|
||||
onChange={e => setMakerworldCatIds(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
<TextField
|
||||
label={t("models:categories.manager.thingiverseCatIdLabel")}
|
||||
type="number"
|
||||
placeholder={t("models:categories.manager.catIdsPlaceholder") ?? undefined}
|
||||
helperText={t("models:categories.manager.catIdsHelp")}
|
||||
fullWidth
|
||||
value={thingiverseCatId}
|
||||
onChange={e => setThingiverseCatId(e.target.value)}
|
||||
value={thingiverseCatIds}
|
||||
onChange={e => setThingiverseCatIds(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
<TextField
|
||||
label={t("models:categories.manager.printablesCatIdLabel")}
|
||||
type="number"
|
||||
placeholder={t("models:categories.manager.catIdsPlaceholder") ?? undefined}
|
||||
helperText={t("models:categories.manager.catIdsHelp")}
|
||||
fullWidth
|
||||
value={printablesCatId}
|
||||
onChange={e => setPrintablesCatId(e.target.value)}
|
||||
value={printablesCatIds}
|
||||
onChange={e => setPrintablesCatIds(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
</Stack>
|
||||
{error && <Alert severity="error">{error}</Alert>}
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
|
||||
@@ -159,7 +159,12 @@ export default function ModelsPage({ folderId, onSelectFolder, foldersVersion, o
|
||||
await foldersApi.updateMeta(id, meta);
|
||||
onFoldersChanged();
|
||||
} catch (err) {
|
||||
handleError(err, t("models:errors.updateCategoryMetaFailed"));
|
||||
// Unlike the other handlers here, this one rethrows anything but the auth-redirect case:
|
||||
// CategoryMetaDialog shows the specific message (e.g. a bad category-id string naming the
|
||||
// exact typo) inline and keeps the dialog open with the user's edits intact, instead of a
|
||||
// generic alert() that closes the dialog and discards what they typed either way.
|
||||
if (handleError(err)) return;
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user