feat: reset categories to the starter tree, previewing what changes

This commit is contained in:
Tautvydas Derzinskas committed 2026-10-20 19:22:56 +02:00
1 parent 3c3d19a3b7
commit f8424dcb23
13 files changed
+823 -43

No files matched your search

+28 -1
View File
@@ -6,7 +6,14 @@ import { HttpError } from "../utils/fileUtils";
import { normalizeTags } from "../utils/tagNormalization";
import { parseBody } from "../utils/validate";
import { asyncHandler } from "../utils/asyncHandler";
import { CATEGORY_KINDS, kindUnder, setSubtreeKind, validateParentCategory } from "../services/categoryService";
import {
CATEGORY_KINDS,
kindUnder,
previewCategoryReset,
resetDefaultCategories,
setSubtreeKind,
validateParentCategory,
} from "../services/categoryService";
import { availableModelName, reorganizeManagedPrints } from "../services/printService";
import { toCategoryOut } from "../dto";
import { sendPrintsZip } from "../services/downloadZip";
@@ -34,6 +41,26 @@ router.get(
}),
);
// What the reset below would change, for confirming it; all empty when the tree is already the starter one.
router.get(
"/categories/reset-defaults",
asyncHandler(async (req, res) => {
res.json(await previewCategoryReset(req.userId!));
}),
);
router.post(
"/categories/reset-defaults",
asyncHandler(async (req, res) => {
await resetDefaultCategories(req.userId!);
const categories = await prisma.category.findMany({
where: { userId: req.userId },
orderBy: [{ position: "asc" }, { name: "asc" }],
});
res.json(categories.map(toCategoryOut));
}),
);
const reorderSchema = z.object({ category_ids: z.array(z.string()).min(1) });
router.post(
+216 -28
View File
@@ -2,6 +2,7 @@ import type { Prisma } from "@prisma/client";
import { prisma } from "../db";
import { HttpError } from "../utils/fileUtils";
import { DEFAULT_CATEGORIES, type DefaultCategoryNode } from "../seedData/defaultCategories";
import { availableModelName, reorganizeManagedPrints } from "./printService";
export const CATEGORY_KINDS = ["category", "folder"] as const;
export type CategoryKind = (typeof CATEGORY_KINDS)[number];
@@ -59,41 +60,228 @@ export async function validateParentCategory(
return parentId;
}
/** A default category's own fields, without its place in the tree. */
function defaultCategoryData(node: DefaultCategoryNode) {
return {
name: node.name,
kind: "category",
tags: node.tags ?? [],
metaTitle: node.metaTitle ?? null,
metaDescription: node.metaDescription ?? null,
makerworldCatIds: node.makerworldCatIds ?? [],
thingiverseCatIds: node.thingiverseCatIds ?? [],
printablesCatIds: node.printablesCatIds ?? [],
myminifactoryCatIds: node.myminifactoryCatIds ?? [],
snapmakerCatIds: node.snapmakerCatIds ?? [],
makeronlineCatIds: node.makeronlineCatIds ?? [],
crealitycloudCatIds: node.crealitycloudCatIds ?? [],
nexprintCatIds: node.nexprintCatIds ?? [],
thangsCatIds: node.thangsCatIds ?? [],
cults3dCatIds: node.cults3dCatIds ?? [],
};
}
/** Takes a Prisma client so it can run in the user-creation transaction. */
export async function seedDefaultCategories(tx: Prisma.TransactionClient, userId: string): Promise<void> {
async function createNode(node: DefaultCategoryNode, parentId: string | null, position: number): Promise<void> {
const category = await tx.category.create({
data: {
const category = await tx.category.create({ data: { userId, parentId, position, ...defaultCategoryData(node) } });
for (const [i, child] of (node.children ?? []).entries()) await createNode(child, category.id, i);
}
for (const [i, root] of DEFAULT_CATEGORIES.entries()) await createNode(root, null, i);
}
const pathKey = (parentKey: string, name: string) => `${parentKey}/${name.trim().toLowerCase()}`;
/** What a reset to the starter categories would do. Planned and applied from the same rows, so a preview matches. */
type ResetPlan = {
/** Path key of every default category to the existing one it keeps, if any. */
matches: Map<string, string>;
/** Defaults that would be created. */
added: string[][];
/** Kept ones whose name, details, site ids or place differ from the default. */
restored: string[][];
removed: { id: string; path: string[]; models: number }[];
/** Models in removed categories and where each would go: the nearest kept ancestor, or out of any category. */
moves: { id: string; name: string; target: string | null }[];
/** How many of the moved models would be renamed for clashing with one already in their new category. */
renamed: number;
};
async function planCategoryReset(db: Prisma.TransactionClient, userId: string): Promise<ResetPlan> {
const existing = await db.category.findMany({
where: { userId, kind: "category" },
orderBy: [{ position: "asc" }, { name: "asc" }],
});
const byId = new Map(existing.map((c) => [c.id, c]));
const childrenOf = new Map<string | null, typeof existing>();
for (const c of existing) {
const parentId = c.parentId && byId.has(c.parentId) ? c.parentId : null;
childrenOf.set(parentId, [...(childrenOf.get(parentId) ?? []), c]);
}
// With duplicate names on one path, the first in display order is the one kept.
const byPath = new Map<string, string>();
const pathOf = new Map<string, string[]>();
const index = (parentId: string | null, parentKey: string, parentPath: string[]) => {
for (const c of childrenOf.get(parentId) ?? []) {
const key = pathKey(parentKey, c.name);
if (!byPath.has(key)) byPath.set(key, c.id);
pathOf.set(c.id, [...parentPath, c.name]);
index(c.id, key, [...parentPath, c.name]);
}
};
index(null, "", []);
const matches = new Map<string, string>();
const added: string[][] = [];
const restored: string[][] = [];
// `parentId` is undefined under a category that would be new, which no existing one sits under.
const walk = (
nodes: DefaultCategoryNode[],
parentKey: string,
parentId: string | null | undefined,
parentPath: string[],
) => {
for (const [position, node] of nodes.entries()) {
const key = pathKey(parentKey, node.name);
const path = [...parentPath, node.name];
const match = byPath.get(key);
if (match) {
matches.set(key, match);
const c = byId.get(match)!;
const fields = defaultCategoryData(node);
const same =
c.parentId === parentId &&
c.position === position &&
(Object.keys(fields) as (keyof typeof fields)[]).every(
(k) => JSON.stringify(c[k]) === JSON.stringify(fields[k]),
);
if (!same) restored.push(path);
} else {
added.push(path);
}
walk(node.children ?? [], key, match, path);
}
};
walk(DEFAULT_CATEGORIES, "", null, []);
const kept = new Set(matches.values());
const removedRows = existing.filter((c) => !kept.has(c.id));
const removedIds = removedRows.map((c) => c.id);
const prints = removedIds.length
? await db.print.findMany({
where: { userId, categoryId: { in: removedIds } },
orderBy: { createdAt: "asc" },
select: { id: true, name: true, categoryId: true },
})
: [];
const keptAncestor = (id: string): string | null => {
const seen = new Set<string>();
let current = byId.get(id)?.parentId ?? null;
while (current && !kept.has(current) && !seen.has(current)) {
seen.add(current);
current = byId.get(current)?.parentId ?? null;
}
return current && kept.has(current) ? current : null;
};
const moves = prints.map((p) => ({ id: p.id, name: p.name, target: keptAncestor(p.categoryId!) }));
const modelsIn = new Map<string, number>();
for (const p of prints) modelsIn.set(p.categoryId!, (modelsIn.get(p.categoryId!) ?? 0) + 1);
// A moved model is renamed when its name is already taken where it lands, by a model there or one moved earlier.
let renamed = 0;
if (moves.length) {
const names = [...new Set(moves.map((m) => m.name.trim().toLowerCase()))];
const targets = [...new Set(moves.map((m) => m.target).filter((t): t is string => t !== null))];
const staying = await db.print.findMany({
where: {
userId,
name: node.name,
kind: "category",
tags: node.tags ?? [],
parentId,
position,
metaTitle: node.metaTitle ?? null,
metaDescription: node.metaDescription ?? null,
makerworldCatIds: node.makerworldCatIds ?? [],
thingiverseCatIds: node.thingiverseCatIds ?? [],
printablesCatIds: node.printablesCatIds ?? [],
myminifactoryCatIds: node.myminifactoryCatIds ?? [],
snapmakerCatIds: node.snapmakerCatIds ?? [],
makeronlineCatIds: node.makeronlineCatIds ?? [],
crealitycloudCatIds: node.crealitycloudCatIds ?? [],
nexprintCatIds: node.nexprintCatIds ?? [],
thangsCatIds: node.thangsCatIds ?? [],
cults3dCatIds: node.cults3dCatIds ?? [],
nameNormalized: { in: names },
OR: [{ categoryId: { in: targets } }, ...(moves.some((m) => !m.target) ? [{ categoryId: null }] : [])],
},
select: { categoryId: true, nameNormalized: true },
});
let i = 0;
for (const child of node.children ?? []) {
await createNode(child, category.id, i);
i++;
const taken = new Set(staying.map((p) => `${p.categoryId}\u0000${p.nameNormalized}`));
for (const m of moves) {
const key = `${m.target}\u0000${m.name.trim().toLowerCase()}`;
if (taken.has(key)) renamed++;
else taken.add(key);
}
}
let i = 0;
for (const root of DEFAULT_CATEGORIES) {
await createNode(root, null, i);
i++;
}
return {
matches,
added,
restored,
removed: removedRows.map((c) => ({
id: c.id,
path: pathOf.get(c.id) ?? [c.name],
models: modelsIn.get(c.id) ?? 0,
})),
moves,
renamed,
};
}
export type CategoryResetPreview = {
added: string[][];
restored: string[][];
removed: { path: string[]; models: number }[];
models_moved: number;
models_uncategorized: number;
models_renamed: number;
};
/** What resetting the category tree (not folders) to the starter one would change; all empty when it's already that. */
export async function previewCategoryReset(userId: string): Promise<CategoryResetPreview> {
const plan = await planCategoryReset(prisma, userId);
return {
added: plan.added,
restored: plan.restored,
removed: plan.removed.map(({ path, models }) => ({ path, models })),
models_moved: plan.moves.filter((m) => m.target).length,
models_uncategorized: plan.moves.filter((m) => !m.target).length,
models_renamed: plan.renamed,
};
}
/**
* Puts the user's category tree (not folders) back to the starter one. A category whose name path matches a default
* one is kept, with its models, and gets the default's details, site ids and place back; missing defaults are
* created; the rest are deleted, their models moving to the nearest kept ancestor, or out of any category.
*/
export async function resetDefaultCategories(userId: string): Promise<void> {
await prisma.$transaction(
async (tx) => {
const plan = await planCategoryReset(tx, userId);
const restore = async (nodes: DefaultCategoryNode[], parentId: string | null, parentKey: string) => {
for (const [position, node] of nodes.entries()) {
const key = pathKey(parentKey, node.name);
const data = { parentId, position, ...defaultCategoryData(node) };
const match = plan.matches.get(key);
const id = match
? (await tx.category.update({ where: { id: match }, data, select: { id: true } })).id
: (await tx.category.create({ data: { userId, ...data }, select: { id: true } })).id;
await restore(node.children ?? [], id, key);
}
};
await restore(DEFAULT_CATEGORIES, null, "");
for (const move of plan.moves) {
const name = await availableModelName(userId, move.name, move.target, move.id, tx);
const data: Prisma.PrintUncheckedUpdateInput = { categoryId: move.target };
// Out of any category, nothing chose where the model is any more.
if (!move.target) data.categorySource = null;
if (name !== move.name) Object.assign(data, { name, nameNormalized: name.trim().toLowerCase(), title: name });
await tx.print.update({ where: { id: move.id }, data });
}
if (plan.removed.length) {
await tx.category.deleteMany({ where: { userId, id: { in: plan.removed.map((r) => r.id) } } });
}
},
// A few hundred sequential writes for a full tree.
{ timeout: 60_000 },
);
// Managed storage paths include the category path.
await reorganizeManagedPrints(undefined, userId);
}
+3 -2
View File
@@ -23,7 +23,7 @@ import {
type AssetFile,
type AssetHome,
} from "./modelAssets";
import type { Category, Plate, Print } from "@prisma/client";
import type { Category, Plate, Prisma, Print } from "@prisma/client";
export const STORAGE_TEMPLATE_TOKENS = [
"category-or-folder",
@@ -364,13 +364,14 @@ export async function availableModelName(
requested: string,
categoryId: string | null,
excludeId?: string,
db: Prisma.TransactionClient = prisma,
): Promise<string> {
const base = sanitizePathSegment(requested, "Model");
let candidate = base;
let suffix = 2;
// Small categories in practice; a loop of sequential existence checks is simple and correct.
for (;;) {
const existing = await prisma.print.findFirst({
const existing = await db.print.findFirst({
where: {
userId,
categoryId: categoryId ?? null,
+135
View File
@@ -0,0 +1,135 @@
import { beforeAll, describe, expect, it } from "vitest";
import request from "supertest";
import { createApp } from "../src/app";
import { prisma } from "../src/db";
import { DEFAULT_CATEGORIES } from "../src/seedData/defaultCategories";
const app = createApp();
const stamp = Date.now();
let token: string;
let userId: string;
type CategoryOut = { id: string; name: string; parent_id: string | null; position: number; kind: string };
const auth = () => ({ Authorization: `Bearer ${token}` });
const list = async () => (await request(app).get("/api/categories").set(auth())).body as CategoryOut[];
const reset = () => request(app).post("/api/categories/reset-defaults").set(auth());
async function create(name: string, parentId: string | null = null, kind?: string): Promise<string> {
const res = await request(app).post("/api/categories").set(auth()).send({ name, parent_id: parentId, kind });
expect(res.status).toBe(200);
return res.body.id;
}
async function createPrint(name: string, categoryId: string) {
return prisma.print.create({
data: { userId, name, nameNormalized: name.toLowerCase(), categoryId, categorySource: "MANUAL" },
});
}
const [firstRoot] = DEFAULT_CATEGORIES;
const firstChild = firstRoot.children![0];
beforeAll(async () => {
const registered = (
await request(app)
.post("/api/register")
.send({ displayName: "Resetter", email: `reset-${stamp}@example.com`, password: "password123" })
).body;
token = registered.token;
userId = registered.user.id;
});
type Preview = {
added: string[][];
restored: string[][];
removed: { path: string[]; models: number }[];
models_moved: number;
models_uncategorized: number;
models_renamed: number;
};
const preview = async () => (await request(app).get("/api/categories/reset-defaults").set(auth())).body as Preview;
const atDefaults = async () => {
const p = await preview();
return !p.added.length && !p.restored.length && !p.removed.length;
};
describe("POST /api/categories/reset-defaults", () => {
it("reports a fresh tree as at its defaults, and an edited one as not, until it's reset", async () => {
expect(await atDefaults()).toBe(true);
const folder = await create(`Folder ${stamp}`, null, "folder");
expect(await atDefaults()).toBe(true);
const extra = await create(`Extra ${stamp}`, null, "category");
expect(await atDefaults()).toBe(false);
await request(app).delete(`/api/category/${extra}`).set(auth());
expect(await atDefaults()).toBe(true);
const root = (await list()).find((c) => c.name === firstRoot.name && !c.parent_id)!;
await request(app).patch(`/api/category/${root.id}/meta`).set(auth()).send({ meta_title: "Changed" });
expect(await atDefaults()).toBe(false);
expect((await reset()).status).toBe(200);
expect(await atDefaults()).toBe(true);
await request(app).delete(`/api/category/${folder}`).set(auth());
});
it("restores renamed and deleted defaults, keeps matching ones and leaves folders alone", async () => {
const before = await list();
const root = before.find((c) => c.name === firstRoot.name && !c.parent_id)!;
const child = before.find((c) => c.name === firstChild.name && c.parent_id === root.id)!;
const printInChild = await createPrint(`Kept ${stamp}`, child.id);
// Rename the root away (so its default comes back new) and delete another default.
await request(app).patch(`/api/category/${root.id}`).set(auth()).send({ name: "Renamed", tags: [] });
const second = before.find((c) => c.name === DEFAULT_CATEGORIES[1].name && !c.parent_id)!;
await request(app).delete(`/api/category/${second.id}`).set(auth());
const folder = await create(`Folder ${stamp}`, null, "folder");
const res = await reset();
expect(res.status).toBe(200);
const after = res.body as CategoryOut[];
const roots = after
.filter((c) => c.kind === "category" && !c.parent_id)
.toSorted((a, b) => a.position - b.position)
.map((c) => c.name);
expect(roots).toEqual(DEFAULT_CATEGORIES.map((c) => c.name));
expect(after.filter((c) => c.kind === "category")).toHaveLength(before.filter((c) => c.kind === "category").length);
expect(after.some((c) => c.name === "Renamed")).toBe(false);
expect(after.find((c) => c.id === folder)?.kind).toBe("folder");
// The renamed root was dropped, so its children went with it, and the model with them, out of any category.
const moved = await prisma.print.findUniqueOrThrow({ where: { id: printInChild.id } });
expect(moved.categoryId).toBeNull();
expect(moved.categorySource).toBeNull();
});
it("keeps a matching category's id and models, and moves models in added categories to the nearest kept one", async () => {
const before = await list();
const root = before.find((c) => c.name === firstRoot.name && !c.parent_id)!;
const child = before.find((c) => c.name === firstChild.name && c.parent_id === root.id)!;
const kept = await createPrint(`Clash ${stamp}`, child.id);
const extra = await create(`Extra ${stamp}`, child.id);
const nested = await create(`Nested ${stamp}`, extra);
const fromNested = await createPrint(`Clash ${stamp}`, nested);
const planned = await preview();
expect(planned.added).toEqual([]);
expect(planned.restored).toEqual([]);
expect(planned.removed).toEqual([
{ path: [firstRoot.name, firstChild.name, `Extra ${stamp}`], models: 0 },
{ path: [firstRoot.name, firstChild.name, `Extra ${stamp}`, `Nested ${stamp}`], models: 1 },
]);
expect(planned).toMatchObject({ models_moved: 1, models_uncategorized: 0, models_renamed: 1 });
expect((await reset()).status).toBe(200);
const after = await list();
expect(after.find((c) => c.id === child.id)?.parent_id).toBe(root.id);
expect(after.some((c) => c.id === extra || c.id === nested)).toBe(false);
expect((await prisma.print.findUniqueOrThrow({ where: { id: kept.id } })).categoryId).toBe(child.id);
const moved = await prisma.print.findUniqueOrThrow({ where: { id: fromNested.id } });
expect(moved.categoryId).toBe(child.id);
expect(moved.categorySource).toBe("MANUAL");
expect(moved.name).toBe(`Clash ${stamp} (2)`);
});
});
+33
View File
@@ -42,6 +42,23 @@ export type CategoryMetaInput = {
cults3dCatIds: string;
};
/** Category paths, top level first. */
export type CategoryResetPreview = {
added: string[][];
/** Kept categories whose name, details, site ids or place go back to the default's. */
restored: string[][];
removed: { path: string[]; models: number }[];
/** Models in removed categories that move to the nearest kept parent. */
models_moved: number;
/** Models in removed categories with no kept parent, left without a category. */
models_uncategorized: number;
/** Moved models renamed for clashing with one already where they land. */
models_renamed: number;
};
/** Whether a reset would change nothing. */
export const resetIsNoop = (p: CategoryResetPreview) => !p.added.length && !p.restored.length && !p.removed.length;
export const categoriesApi = {
list: async (): Promise<Category[]> => {
const res = await fetch(`${apiBase()}/categories`, { headers: authHeaders() });
@@ -126,6 +143,22 @@ export const categoriesApi = {
return res.json();
},
/** What a reset to the starter categories would change; all empty when the tree already is that. */
previewReset: async (): Promise<CategoryResetPreview> => {
const res = await fetch(`${apiBase()}/categories/reset-defaults`, { headers: authHeaders() });
if (res.status === 401) throw new UnauthorizedError();
assertOk(res, "Failed to preview the category reset");
return res.json();
},
/** Puts the category tree (not folders) back to the starter one; returns every category afterwards. */
resetDefaults: async (): Promise<Category[]> => {
const res = await fetch(`${apiBase()}/categories/reset-defaults`, { method: "POST", headers: authHeaders() });
if (res.status === 401) throw new UnauthorizedError();
assertOk(res, "Reset categories failed");
return res.json();
},
downloadZip: async (category_id: string) => {
const res = await fetch(`${apiBase()}/category/${category_id}/download`, { headers: authHeaders() });
assertOk(res, "Category download failed");
+19
View File
@@ -2957,6 +2957,7 @@
"createCategoryFailed": "Failed to create the category. Please try again.",
"renameCategoryFailed": "Failed to rename the category. Please try again.",
"deleteCategoryFailed": "Failed to delete the category. Please try again.",
"resetCategoriesFailed": "Failed to reset the categories. Please try again.",
"reorderCategoryFailed": "Failed to reorder categories. Please try again.",
"moveCategoryFailed": "Failed to move the subcategory. Please try again.",
"updateCategoryMetaFailed": "Failed to save category details. Please try again.",
@@ -2970,9 +2971,27 @@
"empty": "No categories yet",
"noSubcategories": "No subcategories yet",
"manageTooltip": "Manage categories",
"reset": {
"title": "Reset categories to defaults?",
"added": "Added",
"restored": "Restored",
"removed": "Deleted",
"modelsUncategorized_one": "{{count}} model will lose its category.",
"modelsUncategorized_other": "{{count}} models will lose their category.",
"modelsMoved_one": "{{count}} model moves to the nearest kept parent category.",
"modelsMoved_other": "{{count}} models move to the nearest kept parent category.",
"modelsRenamed_one": "{{count}} moved model is renamed to avoid a name clash.",
"modelsRenamed_other": "{{count}} moved models are renamed to avoid a name clash.",
"modelCount_one": "{{count}} model",
"modelCount_other": "{{count}} models",
"noop": "Your categories already match the defaults.",
"foldersKept": "Folders aren't touched.",
"previewFailed": "Couldn't work out what a reset would change."
},
"manager": {
"title": "Manage Categories",
"addCategory": "Add category",
"reset": "Reset to defaults",
"addSubcategory": "Add subcategory",
"noCategories": "No categories yet. Add one to get started.",
"confirmDeleteCategory": "Delete \"{{name}}\"? Any models in it move to Unassigned.",
+19
View File
@@ -2957,6 +2957,7 @@
"createCategoryFailed": "No se pudo crear la categoría. Inténtalo de nuevo.",
"renameCategoryFailed": "No se pudo renombrar la categoría. Inténtalo de nuevo.",
"deleteCategoryFailed": "No se pudo eliminar la categoría. Inténtalo de nuevo.",
"resetCategoriesFailed": "No se pudieron restablecer las categorías. Inténtalo de nuevo.",
"reorderCategoryFailed": "No se pudieron reordenar las categorías. Inténtalo de nuevo.",
"moveCategoryFailed": "No se pudo mover la subcategoría. Inténtalo de nuevo.",
"updateCategoryMetaFailed": "No se pudieron guardar los detalles de la categoría. Inténtalo de nuevo.",
@@ -2970,9 +2971,27 @@
"empty": "Aún no hay categorías",
"noSubcategories": "Aún no hay subcategorías",
"manageTooltip": "Gestionar categorías",
"reset": {
"title": "¿Restablecer las categorías predeterminadas?",
"added": "Añadidas",
"restored": "Restauradas",
"removed": "Eliminadas",
"modelsUncategorized_one": "{{count}} modelo se quedará sin categoría.",
"modelsUncategorized_other": "{{count}} modelos se quedarán sin categoría.",
"modelsMoved_one": "{{count}} modelo pasa a la categoría superior conservada más cercana.",
"modelsMoved_other": "{{count}} modelos pasan a la categoría superior conservada más cercana.",
"modelsRenamed_one": "{{count}} modelo movido se renombra para evitar un nombre repetido.",
"modelsRenamed_other": "{{count}} modelos movidos se renombran para evitar nombres repetidos.",
"modelCount_one": "{{count}} modelo",
"modelCount_other": "{{count}} modelos",
"noop": "Tus categorías ya coinciden con las predeterminadas.",
"foldersKept": "Las carpetas no cambian.",
"previewFailed": "No se pudo calcular qué cambiaría el restablecimiento."
},
"manager": {
"title": "Gestionar categorías",
"addCategory": "Añadir categoría",
"reset": "Restablecer valores predeterminados",
"addSubcategory": "Añadir subcategoría",
"noCategories": "Aún no hay categorías. Añade una para empezar.",
"confirmDeleteCategory": "¿Eliminar «{{name}}»? Los modelos que contenga pasarán a Sin asignar.",
+19
View File
@@ -2970,6 +2970,7 @@
"createCategoryFailed": "Impossible de créer la catégorie. Veuillez réessayer.",
"renameCategoryFailed": "Impossible de renommer la catégorie. Veuillez réessayer.",
"deleteCategoryFailed": "Impossible de supprimer la catégorie. Veuillez réessayer.",
"resetCategoriesFailed": "Impossible de réinitialiser les catégories. Veuillez réessayer.",
"reorderCategoryFailed": "Impossible de réorganiser les catégories. Veuillez réessayer.",
"moveCategoryFailed": "Impossible de déplacer la sous-catégorie. Veuillez réessayer.",
"updateCategoryMetaFailed": "Impossible d’enregistrer les détails de la catégorie. Veuillez réessayer.",
@@ -2983,9 +2984,27 @@
"empty": "Aucune catégorie pour le moment",
"noSubcategories": "Aucune sous-catégorie pour le moment",
"manageTooltip": "Gérer les catégories",
"reset": {
"title": "Rétablir les catégories par défaut ?",
"added": "Ajoutées",
"restored": "Rétablies",
"removed": "Supprimées",
"modelsUncategorized_one": "{{count}} modèle perdra sa catégorie.",
"modelsUncategorized_other": "{{count}} modèles perdront leur catégorie.",
"modelsMoved_one": "{{count}} modèle va dans la catégorie parente conservée la plus proche.",
"modelsMoved_other": "{{count}} modèles vont dans la catégorie parente conservée la plus proche.",
"modelsRenamed_one": "{{count}} modèle déplacé est renommé pour éviter un doublon de nom.",
"modelsRenamed_other": "{{count}} modèles déplacés sont renommés pour éviter un doublon de nom.",
"modelCount_one": "{{count}} modèle",
"modelCount_other": "{{count}} modèles",
"noop": "Vos catégories correspondent déjà aux valeurs par défaut.",
"foldersKept": "Les dossiers ne sont pas modifiés.",
"previewFailed": "Impossible de déterminer ce qu'une réinitialisation changerait."
},
"manager": {
"title": "Gérer les catégories",
"addCategory": "Ajouter une catégorie",
"reset": "Rétablir les valeurs par défaut",
"addSubcategory": "Ajouter une sous-catégorie",
"noCategories": "Aucune catégorie pour le moment. Ajoutez-en une pour commencer.",
"confirmDeleteCategory": "Supprimer « {{name}} » ? Les modèles qu’elle contient passeront dans Non attribué.",
+23
View File
@@ -3045,6 +3045,7 @@
"createCategoryFailed": "Nepavyko sukurti kategorijos. Bandykite dar kartą.",
"renameCategoryFailed": "Nepavyko pervadinti kategorijos. Bandykite dar kartą.",
"deleteCategoryFailed": "Nepavyko ištrinti kategorijos. Bandykite dar kartą.",
"resetCategoriesFailed": "Nepavyko atkurti kategorijų. Bandykite dar kartą.",
"reorderCategoryFailed": "Nepavyko pakeisti kategorijų tvarkos. Bandykite dar kartą.",
"moveCategoryFailed": "Nepavyko perkelti subkategorijos. Bandykite dar kartą.",
"updateCategoryMetaFailed": "Nepavyko išsaugoti kategorijos informacijos. Bandykite dar kartą.",
@@ -3058,9 +3059,31 @@
"empty": "Kol kas nėra kategorijų",
"noSubcategories": "Kol kas nėra subkategorijų",
"manageTooltip": "Tvarkyti kategorijas",
"reset": {
"title": "Atkurti numatytąsias kategorijas?",
"added": "Pridėta",
"restored": "Atkurta",
"removed": "Ištrinta",
"modelsUncategorized_one": "{{count}} modelis liks be kategorijos.",
"modelsUncategorized_few": "{{count}} modeliai liks be kategorijos.",
"modelsUncategorized_other": "{{count}} modelių liks be kategorijos.",
"modelsMoved_one": "{{count}} modelis bus perkeltas į artimiausią išlikusią tėvinę kategoriją.",
"modelsMoved_few": "{{count}} modeliai bus perkelti į artimiausią išlikusią tėvinę kategoriją.",
"modelsMoved_other": "{{count}} modelių bus perkelta į artimiausią išlikusią tėvinę kategoriją.",
"modelsRenamed_one": "{{count}} perkeltas modelis bus pervadintas, kad nesikartotų pavadinimas.",
"modelsRenamed_few": "{{count}} perkelti modeliai bus pervadinti, kad nesikartotų pavadinimai.",
"modelsRenamed_other": "{{count}} perkeltų modelių bus pervadinta, kad nesikartotų pavadinimai.",
"modelCount_one": "{{count}} modelis",
"modelCount_few": "{{count}} modeliai",
"modelCount_other": "{{count}} modelių",
"noop": "Jūsų kategorijos jau atitinka numatytąsias.",
"foldersKept": "Aplankai nekeičiami.",
"previewFailed": "Nepavyko nustatyti, ką pakeistų atkūrimas."
},
"manager": {
"title": "Tvarkyti kategorijas",
"addCategory": "Pridėti kategoriją",
"reset": "Atkurti numatytąsias",
"addSubcategory": "Pridėti subkategoriją",
"noCategories": "Kol kas nėra kategorijų. Pridėkite pirmąją.",
"confirmDeleteCategory": "Ištrinti „{{name}}“? Joje esantys modeliai taps nepriskirti.",
@@ -21,6 +21,7 @@ import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
import RestartAltIcon from "@mui/icons-material/RestartAlt";
import {
DndContext,
DragOverlay,
@@ -47,6 +48,7 @@ import type { Category, CategoryKind, CategoryMetaInput } from "../../api/catego
import { useConfirm } from "../../components/ConfirmProvider";
import { buildCategoryTree, flattenCategoryTree } from "../../utils/categoryTree";
import CategoryMetaDialog from "./CategoryMetaDialog";
import CategoryResetDialog from "./CategoryResetDialog";
// One nesting level. Also how far a row must be dragged sideways to change its level.
const INDENT_PX = 24;
@@ -62,6 +64,11 @@ type Props = {
onReorder: (categoryIds: string[]) => Promise<void>;
onMove: (id: string, parentId: string | null, position: number) => Promise<void>;
onUpdateMeta: (id: string, meta: CategoryMetaInput) => Promise<void>;
/** Shown only when given: puts the tree back to the starter one. */
onResetDefaults?: () => Promise<void>;
/** Turns the reset off: there's nothing for it to change. */
atDefaults?: boolean;
onUnauthorized?: () => void;
};
function hasMeta(category: Category): boolean {
@@ -483,12 +490,16 @@ export default function CategoryManager({
onReorder,
onMove,
onUpdateMeta,
onResetDefaults,
atDefaults = false,
onUnauthorized,
}: Props) {
const { t } = useTranslation(["models", "common"]);
const confirmDialog = useConfirm();
const [busyId, setBusyId] = useState<string | null>(null);
const [metaCategory, setMetaCategory] = useState<Category | null>(null);
const [addingUnderId, setAddingUnderId] = useState<string | null>(null);
const [resetOpen, setResetOpen] = useState(false);
// A drop shows right away and stays until `categories` is refetched (which happens on failure too).
const [optimistic, setOptimistic] = useState<{ base: Category[]; list: Category[] } | null>(null);
@@ -704,19 +715,42 @@ export default function CategoryManager({
<Divider sx={{ my: 1.5 }} />
<AddRow
placeholder={t(`${labels}.addCategory`)}
busy={busyId === "new-root"}
onAdd={async (name) => {
setBusyId("new-root");
try {
await onCreate(name, null);
} finally {
setBusyId(null);
}
}}
/>
<Stack direction="row" spacing={1} alignItems="center">
<Box sx={{ flex: 1, minWidth: 0 }}>
<AddRow
placeholder={t(`${labels}.addCategory`)}
busy={busyId === "new-root"}
onAdd={async (name) => {
setBusyId("new-root");
try {
await onCreate(name, null);
} finally {
setBusyId(null);
}
}}
/>
</Box>
{onResetDefaults && (
<Button
size="small"
disabled={atDefaults}
startIcon={<RestartAltIcon fontSize="small" />}
onClick={() => setResetOpen(true)}
sx={{ flexShrink: 0 }}
>
{t("models:categories.manager.reset")}
</Button>
)}
</Stack>
</Box>
{onResetDefaults && (
<CategoryResetDialog
open={resetOpen}
onClose={() => setResetOpen(false)}
onConfirm={onResetDefaults}
onUnauthorized={onUnauthorized}
/>
)}
{metaCategory && (
<CategoryMetaDialog
category={metaCategory}
@@ -0,0 +1,239 @@
import { useEffect, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import Alert from "@mui/material/Alert";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import CircularProgress from "@mui/material/CircularProgress";
import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
import DialogTitle from "@mui/material/DialogTitle";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { keyframes } from "@mui/material/styles";
import RestartAltIcon from "@mui/icons-material/RestartAlt";
import { categoriesApi, resetIsNoop, type CategoryResetPreview } from "../../api/categories";
import { UnauthorizedError } from "../../api/client";
import { ListRowSkeletons } from "../../components/ListSkeleton";
import { SectionLabel } from "../../components/Layout/ConfigurationDialog/parts";
const fadeIn = keyframes`
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: none; }
`;
type Props = {
open: boolean;
onClose: () => void;
/** Resets; reports its own failure. */
onConfirm: () => Promise<void>;
onUnauthorized?: () => void;
};
type Tone = "success" | "warning" | "error";
function Stat({ count, label, tone }: { count: number; label: string; tone?: Tone }) {
return (
<Box
sx={{
flex: 1,
minWidth: 0,
px: 1.5,
py: 1,
borderRadius: 1,
border: 1,
borderColor: "divider",
opacity: count ? 1 : 0.55,
}}
>
<Typography variant="h6" sx={{ lineHeight: 1.2, color: count && tone ? `${tone}.main` : "text.primary" }}>
{count}
</Typography>
<Typography variant="caption" color="text.secondary" noWrap component="div">
{label}
</Typography>
</Box>
);
}
/** A category path with its ancestors dimmed, so the category itself stands out. */
function PathRow({ path, trailing }: { path: string[]; trailing?: ReactNode }) {
const name = path[path.length - 1];
const parents = path.slice(0, -1);
return (
<Stack direction="row" alignItems="baseline" spacing={1} sx={{ px: 1.25, py: 0.5 }}>
<Typography variant="body2" noWrap sx={{ flex: 1, minWidth: 0 }} title={path.join(" › ")}>
{parents.length > 0 && (
<Box component="span" sx={{ color: "text.secondary" }}>
{parents.join(" › ")} ›{" "}
</Box>
)}
{name}
</Typography>
{trailing}
</Stack>
);
}
function PathList({ label, children }: { label: string; children: ReactNode }) {
return (
<Box>
<SectionLabel>{label}</SectionLabel>
<Box
sx={{
maxHeight: 168,
overflowY: "auto",
border: 1,
borderColor: "divider",
borderRadius: 1,
py: 0.5,
}}
>
{children}
</Box>
</Box>
);
}
/** Shows what a reset to the starter categories would add, restore and delete, and what happens to the models, before
* it's run. */
export default function CategoryResetDialog({ open, onClose, onConfirm, onUnauthorized }: Props) {
const { t } = useTranslation(["models", "common"]);
const [preview, setPreview] = useState<CategoryResetPreview | null>(null);
const [error, setError] = useState<string | null>(null);
const [resetting, setResetting] = useState(false);
// Fetched on every open: models may have moved since the dialog was last shown.
useEffect(() => {
if (!open) return;
let active = true;
setPreview(null);
setError(null);
categoriesApi
.previewReset()
.then((value) => {
if (active) setPreview(value);
})
.catch((err: unknown) => {
if (!active) return;
if (err instanceof UnauthorizedError) onUnauthorized?.();
else setError(t("models:categories.reset.previewFailed"));
});
return () => {
active = false;
};
}, [open, onUnauthorized, t]);
const confirm = async () => {
setResetting(true);
try {
await onConfirm();
onClose();
} finally {
setResetting(false);
}
};
const noop = preview !== null && resetIsNoop(preview);
const lostModels = preview?.models_uncategorized ?? 0;
return (
<Dialog open={open} onClose={() => !resetting && onClose()} fullWidth maxWidth="sm">
<DialogTitle>{t("models:categories.reset.title")}</DialogTitle>
<DialogContent>
{!preview && !error && <ListRowSkeletons count={4} lead={null} trailing />}
{error && <Alert severity="error">{error}</Alert>}
{preview && (
<Stack
spacing={2}
sx={{
animation: `${fadeIn} 200ms ease-out`,
"@media (prefers-reduced-motion: reduce)": { animation: "none" },
}}
>
{noop ? (
<Alert severity="success">{t("models:categories.reset.noop")}</Alert>
) : (
<>
<Stack direction="row" spacing={1}>
<Stat count={preview.added.length} label={t("models:categories.reset.added")} tone="success" />
<Stat count={preview.restored.length} label={t("models:categories.reset.restored")} tone="warning" />
<Stat count={preview.removed.length} label={t("models:categories.reset.removed")} tone="error" />
</Stack>
{(lostModels > 0 || preview.models_moved > 0 || preview.models_renamed > 0) && (
<Stack spacing={0.75}>
{lostModels > 0 && (
<Alert severity="warning" sx={{ py: 0 }}>
{t("models:categories.reset.modelsUncategorized", { count: lostModels })}
</Alert>
)}
{preview.models_moved > 0 && (
<Alert severity="info" sx={{ py: 0 }}>
{t("models:categories.reset.modelsMoved", { count: preview.models_moved })}
</Alert>
)}
{preview.models_renamed > 0 && (
<Alert severity="info" sx={{ py: 0 }}>
{t("models:categories.reset.modelsRenamed", { count: preview.models_renamed })}
</Alert>
)}
</Stack>
)}
{preview.removed.length > 0 && (
<PathList label={t("models:categories.reset.removed")}>
{preview.removed.map((r) => (
<PathRow
key={r.path.join("\u0000")}
path={r.path}
trailing={
r.models > 0 && (
<Typography variant="caption" color="text.secondary" sx={{ flexShrink: 0 }}>
{t("models:categories.reset.modelCount", { count: r.models })}
</Typography>
)
}
/>
))}
</PathList>
)}
{preview.added.length > 0 && (
<PathList label={t("models:categories.reset.added")}>
{preview.added.map((path) => (
<PathRow key={path.join("\u0000")} path={path} />
))}
</PathList>
)}
{preview.restored.length > 0 && (
<PathList label={t("models:categories.reset.restored")}>
{preview.restored.map((path) => (
<PathRow key={path.join("\u0000")} path={path} />
))}
</PathList>
)}
<Typography variant="caption" color="text.secondary">
{t("models:categories.reset.foldersKept")}
</Typography>
</>
)}
</Stack>
)}
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={resetting}>
{t("common:cancel")}
</Button>
<Button
variant="contained"
color="error"
onClick={() => void confirm()}
disabled={!preview || noop || resetting}
startIcon={resetting ? <CircularProgress size={14} color="inherit" /> : <RestartAltIcon fontSize="small" />}
>
{t("models:categories.manager.reset")}
</Button>
</DialogActions>
</Dialog>
);
}
@@ -6,6 +6,7 @@ import FolderOutlinedIcon from "@mui/icons-material/FolderOutlined";
import type { Category, CategoryKind, CategoryMetaInput } from "../../api/categories";
import type { Print } from "../../api/prints";
import { aiCategorizationApi } from "../../api/aiCategorization";
import { categoriesApi, resetIsNoop } from "../../api/categories";
import { UnauthorizedError } from "../../api/client";
import SectionedDialog from "../../components/SectionedDialog";
import CategoryManager from "./CategoryManager";
@@ -25,6 +26,7 @@ type Props = {
onReorder: (categoryIds: string[]) => Promise<void>;
onMove: (id: string, parentId: string | null, position: number) => Promise<void>;
onUpdateMeta: (id: string, meta: CategoryMetaInput) => Promise<void>;
onResetCategories: () => Promise<void>;
onPrintUpdated?: (print: Print) => void;
onLibraryChanged?: () => void;
onUnauthorized?: () => void;
@@ -36,6 +38,7 @@ export default function ModelsManagerDialog({
onClose,
categories,
onCreate,
onResetCategories,
onPrintUpdated,
onLibraryChanged,
onUnauthorized,
@@ -66,6 +69,29 @@ export default function ModelsManagerDialog({
};
}, [open, onUnauthorized]);
// Null until known; the reset button stays off meanwhile, as a reset at the defaults changes nothing.
const [categoriesAtDefaults, setCategoriesAtDefaults] = useState<boolean | null>(null);
useEffect(() => {
if (!open) return;
let active = true;
void categoriesApi
.previewReset()
.then((preview) => {
if (active) setCategoriesAtDefaults(resetIsNoop(preview));
})
.catch((err: unknown) => {
if (!active) return;
// Unknown leaves the reset available.
setCategoriesAtDefaults(false);
if (err instanceof UnauthorizedError) onUnauthorized?.();
});
return () => {
active = false;
};
// Rechecked whenever the categories change.
// oxlint-disable-next-line react/exhaustive-effect-dependencies
}, [open, categories, onUnauthorized]);
const byKind = useMemo(
() => ({
category: categories.filter((c) => c.kind === "category"),
@@ -94,6 +120,10 @@ export default function ModelsManagerDialog({
kind={kind}
categories={byKind[kind]}
onCreate={(name, parentId) => onCreate(name, parentId, kind)}
// Only categories have a starter tree; folders are the user's own.
onResetDefaults={kind === "category" ? onResetCategories : undefined}
atDefaults={categoriesAtDefaults !== false}
onUnauthorized={onUnauthorized}
{...handlers}
/>
);
+13
View File
@@ -223,6 +223,18 @@ export default function ModelsPage({
}
};
// Models in removed categories move, so the list reloads too.
const resetCategories = async () => {
try {
const next = await categoriesApi.resetDefaults();
if (categoryId && !next.some((c) => c.id === categoryId)) onSelectCategory(null);
onCategoriesChanged();
refreshLibrary();
} catch (err) {
handleError(err, t("models:errors.resetCategoriesFailed"));
}
};
// Refetch even on failure: the manager shows the drop optimistically until fresh categories arrive.
const reorderCategories = async (categoryIds: string[]) => {
try {
@@ -289,6 +301,7 @@ export default function ModelsPage({
onReorder={reorderCategories}
onMove={moveCategory}
onUpdateMeta={updateCategoryMeta}
onResetCategories={resetCategories}
onPrintUpdated={(updated) => setItems((prev) => prev.map((item) => (item.id === updated.id ? updated : item)))}
onLibraryChanged={refreshLibrary}
onUnauthorized={onUnauthorized}