feat: allow multiple meta category ids

This commit is contained in:
Tautvydas Derzinskas committed 2026-09-08 20:14:03 +02:00
1 parent ac32d03071
commit 8450885ea1
14 files changed
+300 -86

No files matched your search

@@ -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");
+16 -12
View File
@@ -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
View File
@@ -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),
};
}
+42 -7
View File
@@ -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));
+11 -8
View File
@@ -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;
+1 -1
View File
@@ -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[] {
+117
View File
@@ -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");
});
});
+6 -4
View File
@@ -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
View File
@@ -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();
},
+5 -3
View File
@@ -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"
}
}
}
+5 -3
View File
@@ -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>
+6 -1
View File
@@ -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;
}
};