mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-21 22:22:24 -05:00
fix: count suffixed renames, roll back failed theme saves, keep reset dialog open on error
This commit is contained in:
1 parent
f8424dcb23
commit
1a44b87cdc
13 files changed
+65
-22
No files matched your search
@@ -2,7 +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";
|
||||
import { availableModelName, reorganizeManagedPrints, sanitizePathSegment } from "./printService";
|
||||
|
||||
export const CATEGORY_KINDS = ["category", "folder"] as const;
|
||||
export type CategoryKind = (typeof CATEGORY_KINDS)[number];
|
||||
@@ -188,24 +188,27 @@ async function planCategoryReset(db: Prisma.TransactionClient, userId: string):
|
||||
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.
|
||||
// A moved model is renamed as availableModelName will at reset: suffixed past names 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,
|
||||
nameNormalized: { in: names },
|
||||
OR: [{ categoryId: { in: targets } }, ...(moves.some((m) => !m.target) ? [{ categoryId: null }] : [])],
|
||||
},
|
||||
select: { categoryId: true, nameNormalized: true },
|
||||
});
|
||||
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);
|
||||
const base = sanitizePathSegment(m.name, "Model");
|
||||
let candidate = base;
|
||||
for (let suffix = 2; taken.has(`${m.target}\u0000${candidate.trim().toLowerCase()}`); suffix++) {
|
||||
candidate = `${base} (${suffix})`;
|
||||
}
|
||||
taken.add(`${m.target}\u0000${candidate.trim().toLowerCase()}`);
|
||||
if (candidate !== m.name) renamed++;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -111,15 +111,18 @@ describe("POST /api/categories/reset-defaults", () => {
|
||||
const extra = await create(`Extra ${stamp}`, child.id);
|
||||
const nested = await create(`Nested ${stamp}`, extra);
|
||||
const fromNested = await createPrint(`Clash ${stamp}`, nested);
|
||||
// Lands after the first one has taken its name.
|
||||
const suffixed = await createPrint(`Clash ${stamp} (2)`, nested);
|
||||
await prisma.print.update({ where: { id: suffixed.id }, data: { createdAt: new Date(Date.now() + 1000) } });
|
||||
|
||||
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 },
|
||||
{ path: [firstRoot.name, firstChild.name, `Extra ${stamp}`, `Nested ${stamp}`], models: 2 },
|
||||
]);
|
||||
expect(planned).toMatchObject({ models_moved: 1, models_uncategorized: 0, models_renamed: 1 });
|
||||
expect(planned).toMatchObject({ models_moved: 2, models_uncategorized: 0, models_renamed: 2 });
|
||||
|
||||
expect((await reset()).status).toBe(200);
|
||||
const after = await list();
|
||||
@@ -131,5 +134,6 @@ describe("POST /api/categories/reset-defaults", () => {
|
||||
expect(moved.categoryId).toBe(child.id);
|
||||
expect(moved.categorySource).toBe("MANUAL");
|
||||
expect(moved.name).toBe(`Clash ${stamp} (2)`);
|
||||
expect((await prisma.print.findUniqueOrThrow({ where: { id: suffixed.id } })).name).toBe(`Clash ${stamp} (2) (2)`);
|
||||
});
|
||||
});
|
||||
+22
-5
@@ -43,6 +43,7 @@ import AiPage from "./pages/AiPage";
|
||||
import { healthApi, type HealthInfo } from "./api/health";
|
||||
import { authApi, type AuthUser } from "./api/auth";
|
||||
import { settingsApi, type PreviewMode } from "./api/settings";
|
||||
import { UnauthorizedError } from "./api/client";
|
||||
import { clearToken, clearUser, readToken, readUser, storeToken, storeUser } from "./utils/auth";
|
||||
import { type AppSettings, type MakerWorldSettings, loadSettings, saveSettings } from "./utils/settings";
|
||||
import { useResolvedTheme } from "./hooks/useResolvedTheme";
|
||||
@@ -63,7 +64,7 @@ type AppShellProps = {
|
||||
previewMode: PreviewMode;
|
||||
setPreviewMode: (mode: PreviewMode) => void;
|
||||
themeSelection: ThemeSelection;
|
||||
onThemeChange: (theme: ThemeSelection) => void;
|
||||
onThemeChange: (theme: ThemeSelection) => Promise<boolean>;
|
||||
resolvedTheme: ResolvedTheme;
|
||||
muiTheme: ReturnType<typeof buildTheme>;
|
||||
onUnauthorized: () => void;
|
||||
@@ -356,10 +357,6 @@ export default function App() {
|
||||
active = false;
|
||||
};
|
||||
}, [token]);
|
||||
const handleThemeChange = React.useCallback((selected: ThemeSelection) => {
|
||||
setThemeSelection(selected);
|
||||
void settingsApi.updateTheme({ theme: selected }).catch(() => {});
|
||||
}, []);
|
||||
React.useEffect(() => {
|
||||
saveSettings(settings);
|
||||
}, [settings]);
|
||||
@@ -386,6 +383,26 @@ export default function App() {
|
||||
setShowExpiredToast(true);
|
||||
}, []);
|
||||
|
||||
// Picks can overlap; only the latest one's failure puts the theme back.
|
||||
const latestThemeSave = React.useRef(0);
|
||||
/** Resolves false when the save failed and the theme went back. */
|
||||
const handleThemeChange = React.useCallback(
|
||||
async (selected: ThemeSelection): Promise<boolean> => {
|
||||
const previous = themeSelection;
|
||||
const save = ++latestThemeSave.current;
|
||||
setThemeSelection(selected);
|
||||
try {
|
||||
await settingsApi.updateTheme({ theme: selected });
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (save === latestThemeSave.current) setThemeSelection(previous);
|
||||
if (err instanceof UnauthorizedError) handleUnauthorized();
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[themeSelection, handleUnauthorized],
|
||||
);
|
||||
|
||||
const handleLogout = () => {
|
||||
// Fire-and-forget: local logout proceeds immediately.
|
||||
void authApi.logout();
|
||||
|
||||
@@ -41,7 +41,7 @@ type AppLayoutProps = {
|
||||
makerworldCookie: string;
|
||||
onUpdateMakerWorld: (patch: Partial<MakerWorldSettings>) => void;
|
||||
user: AuthUser | null;
|
||||
onThemeChange: (theme: ThemeSelection) => void;
|
||||
onThemeChange: (theme: ThemeSelection) => Promise<boolean>;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ const MAX_SEGMENTED_LANGUAGES = 4;
|
||||
|
||||
type Props = {
|
||||
theme: ThemeSelection;
|
||||
onThemeChange: (theme: ThemeSelection) => void;
|
||||
onThemeChange: (theme: ThemeSelection) => Promise<boolean>;
|
||||
onUnauthorized?: () => void;
|
||||
};
|
||||
|
||||
@@ -69,11 +69,18 @@ export default function AppearancePanel({ theme, onThemeChange, onUnauthorized }
|
||||
themeSaves.current = request.catch(() => undefined);
|
||||
return request;
|
||||
};
|
||||
const [themeError, setThemeError] = React.useState<string | null>(null);
|
||||
const [cardPreviewError, setCardPreviewError] = React.useState<string | null>(null);
|
||||
const [hoverSlideshow, setHoverSlideshow] = useHoverSlideshowEnabled();
|
||||
const [hoverSlideshowError, setHoverSlideshowError] = React.useState<string | null>(null);
|
||||
const confirm = useConfirm();
|
||||
|
||||
const changeTheme = async (next: ThemeSelection) => {
|
||||
setThemeError(null);
|
||||
// A lapsed session also signs out, where the theme lives.
|
||||
if (!(await onThemeChange(next))) setThemeError(t("appearance.themeFailed"));
|
||||
};
|
||||
|
||||
const changeCardPreview = async (next: CardPreview) => {
|
||||
setCardPreviewError(null);
|
||||
try {
|
||||
@@ -162,7 +169,7 @@ export default function AppearancePanel({ theme, onThemeChange, onUnauthorized }
|
||||
});
|
||||
if (!confirmed) return;
|
||||
// Only what differs is saved; each keeps its own error message if its save fails.
|
||||
if (theme !== DEFAULT_THEME) onThemeChange(DEFAULT_THEME);
|
||||
if (theme !== DEFAULT_THEME) void changeTheme(DEFAULT_THEME);
|
||||
// A new accent puts the render colour back to following it.
|
||||
if (!accentAtDefault) void changeAccent(DEFAULT_ACCENT);
|
||||
if (cardPreview !== null && cardPreview !== DEFAULT_CARD_PREVIEW) void changeCardPreview(DEFAULT_CARD_PREVIEW);
|
||||
@@ -217,9 +224,14 @@ export default function AppearancePanel({ theme, onThemeChange, onUnauthorized }
|
||||
<Segmented
|
||||
label={t("userMenu.theme")}
|
||||
value={theme}
|
||||
onChange={onThemeChange}
|
||||
onChange={(next) => void changeTheme(next)}
|
||||
options={THEMES.map((value) => ({ value, label: t(`appearance.themes.${value}`) }))}
|
||||
/>
|
||||
{themeError && (
|
||||
<Typography variant="caption" color="error" sx={{ display: "block", mt: 1 }}>
|
||||
{themeError}
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<SectionLabel>{t("appearance.accent")}</SectionLabel>
|
||||
|
||||
@@ -24,7 +24,7 @@ type Props = {
|
||||
open: ConfigurationTab | null;
|
||||
onClose: () => void;
|
||||
theme: ThemeSelection;
|
||||
onThemeChange: (theme: ThemeSelection) => void;
|
||||
onThemeChange: (theme: ThemeSelection) => Promise<boolean>;
|
||||
isAdmin: boolean;
|
||||
makerworldCookie: string;
|
||||
onUpdateMakerWorld: (patch: Partial<MakerWorldSettings>) => void;
|
||||
|
||||
@@ -29,7 +29,7 @@ type Props = {
|
||||
onUnauthorized?: () => void;
|
||||
user: AuthUser | null;
|
||||
theme: ThemeSelection;
|
||||
onThemeChange: (theme: ThemeSelection) => void;
|
||||
onThemeChange: (theme: ThemeSelection) => Promise<boolean>;
|
||||
onOpenProfile: () => void;
|
||||
onLogout: () => void;
|
||||
};
|
||||
|
||||
@@ -1578,6 +1578,7 @@
|
||||
"off": "Off",
|
||||
"accent": "Accent color",
|
||||
"accentFailed": "Couldn't save the accent color.",
|
||||
"themeFailed": "Couldn't save the theme.",
|
||||
"accents": {
|
||||
"green": "Thingport green",
|
||||
"blue": "Thingiverse blue",
|
||||
|
||||
@@ -1578,6 +1578,7 @@
|
||||
"off": "Desactivado",
|
||||
"accent": "Color de acento",
|
||||
"accentFailed": "No se pudo guardar el color de acento.",
|
||||
"themeFailed": "No se pudo guardar el tema.",
|
||||
"accents": {
|
||||
"green": "Verde Thingport",
|
||||
"blue": "Azul Thingiverse",
|
||||
|
||||
@@ -1591,6 +1591,7 @@
|
||||
"off": "Désactivé",
|
||||
"accent": "Couleur d’accent",
|
||||
"accentFailed": "Impossible d’enregistrer la couleur d’accent.",
|
||||
"themeFailed": "Impossible d’enregistrer le thème.",
|
||||
"accents": {
|
||||
"green": "Vert Thingport",
|
||||
"blue": "Bleu Thingiverse",
|
||||
|
||||
@@ -1644,6 +1644,7 @@
|
||||
"off": "Išjungta",
|
||||
"accent": "Akcento spalva",
|
||||
"accentFailed": "Nepavyko išsaugoti akcento spalvos.",
|
||||
"themeFailed": "Nepavyko išsaugoti temos.",
|
||||
"accents": {
|
||||
"green": "Thingport žalia",
|
||||
"blue": "Thingiverse mėlyna",
|
||||
|
||||
@@ -25,7 +25,7 @@ const fadeIn = keyframes`
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** Resets; reports its own failure. */
|
||||
/** Resets; reports its own failure, then rejects to keep the dialog open. */
|
||||
onConfirm: () => Promise<void>;
|
||||
onUnauthorized?: () => void;
|
||||
};
|
||||
@@ -129,6 +129,8 @@ export default function CategoryResetDialog({ open, onClose, onConfirm, onUnauth
|
||||
try {
|
||||
await onConfirm();
|
||||
onClose();
|
||||
} catch {
|
||||
// onConfirm has reported it; staying open keeps the preview for a retry.
|
||||
} finally {
|
||||
setResetting(false);
|
||||
}
|
||||
|
||||
@@ -223,7 +223,7 @@ export default function ModelsPage({
|
||||
}
|
||||
};
|
||||
|
||||
// Models in removed categories move, so the list reloads too.
|
||||
// Models in removed categories move, so the list reloads too. Rethrows so the reset dialog stays open to retry.
|
||||
const resetCategories = async () => {
|
||||
try {
|
||||
const next = await categoriesApi.resetDefaults();
|
||||
@@ -232,6 +232,7 @@ export default function ModelsPage({
|
||||
refreshLibrary();
|
||||
} catch (err) {
|
||||
handleError(err, t("models:errors.resetCategoriesFailed"));
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user