mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-21 22:22:24 -05:00
feat: reset categories to the starter tree, previewing what changes
This commit is contained in:
1 parent
3c3d19a3b7
commit
f8424dcb23
13 files changed
+823
-43
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)`);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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é.",
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user