feat(catalog): generate derived scale 32 with preview and backup restore
This commit is contained in:
1 parent
08b0b5021e
commit
cd75361b9f
9 files changed
+749
-11
No files matched your search
@@ -19,6 +19,7 @@ import {
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { NitroQualityPanel } from "@/components/admin/studio/nitro-quality-panel";
|
||||
import { NitroScale32Generator } from "@/components/admin/studio/nitro-scale32-generator";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -140,6 +141,7 @@ export function NitroEditorDialog({
|
||||
const [jsonText, setJsonText] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [bundleRevision, setBundleRevision] = useState(0);
|
||||
const [activeTab, setActiveTab] = useState<string>("visual");
|
||||
const [flags, setFlags] = useState({
|
||||
canstandon: false,
|
||||
@@ -443,7 +445,19 @@ export function NitroEditorDialog({
|
||||
value="quality"
|
||||
className="min-h-0 overflow-y-auto flex-1 px-4 sm:px-6 py-4"
|
||||
>
|
||||
<NitroQualityPanel classname={classname} />
|
||||
<NitroScale32Generator
|
||||
classname={classname}
|
||||
disabled={hasChanges || saving}
|
||||
onChanged={(updated) => {
|
||||
setBundleRevision((value) => value + 1);
|
||||
setMetadata(updated as NitroMetadata);
|
||||
setOriginalMetadata(
|
||||
structuredClone(updated) as NitroMetadata,
|
||||
);
|
||||
setJsonText(JSON.stringify(updated, null, 2));
|
||||
}}
|
||||
/>
|
||||
<NitroQualityPanel key={bundleRevision} classname={classname} />
|
||||
</TabsContent>
|
||||
{/* ── Visual Tab ── */}
|
||||
<TabsContent
|
||||
|
||||
@@ -105,15 +105,23 @@ export const PUT = withAdmin(
|
||||
if (!existsSync(/*turbopackIgnore: true*/ nitroPath))
|
||||
return apiError("Nitro file not found", 404);
|
||||
|
||||
// 1. Read existing .nitro to extract PNG
|
||||
const existingBuffer = await fs.readFile(
|
||||
/*turbopackIgnore: true*/ nitroPath,
|
||||
);
|
||||
const { png } = parseNitroBundle(existingBuffer);
|
||||
|
||||
// 2. Rebuild .nitro with updated JSON + same PNG
|
||||
const newBundle = createNitroBundle(metadata, png, base);
|
||||
await fs.writeFile(nitroPath, newBundle);
|
||||
const saved = await withFurniDataLock(async () => {
|
||||
const existingBuffer = await fs.readFile(nitroPath);
|
||||
const { png, json: currentMetadata } = parseNitroBundle(existingBuffer);
|
||||
if (
|
||||
JSON.stringify(currentMetadata.catalogStudio) !==
|
||||
JSON.stringify(metadata.catalogStudio)
|
||||
)
|
||||
return false;
|
||||
const newBundle = createNitroBundle(metadata, png, base);
|
||||
await fs.writeFile(nitroPath, newBundle);
|
||||
return true;
|
||||
});
|
||||
if (!saved)
|
||||
return apiError(
|
||||
"The bundle changed. Reopen the editor before saving.",
|
||||
409,
|
||||
);
|
||||
|
||||
// 3. Extract dimensions from metadata
|
||||
const dims = metadata.logic?.model?.dimensions || { x: 1, y: 1, z: 0 };
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
read: vi.fn(),
|
||||
write: vi.fn(),
|
||||
rename: vi.fn(),
|
||||
mkdir: vi.fn(),
|
||||
rm: vi.fn(),
|
||||
generate: vi.fn(),
|
||||
}));
|
||||
vi.mock("node:fs", () => ({
|
||||
promises: {
|
||||
readFile: mocks.read,
|
||||
writeFile: mocks.write,
|
||||
rename: mocks.rename,
|
||||
mkdir: mocks.mkdir,
|
||||
rm: mocks.rm,
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
withAdmin: (_: unknown, h: unknown) => h,
|
||||
}));
|
||||
vi.mock("@/lib/services/furni-asset-dirs", () => ({
|
||||
getFurniAssetDirs: async () => ({ nitroDir: "/nitro" }),
|
||||
}));
|
||||
vi.mock("@/lib/services/furni-data", () => ({
|
||||
withFurniDataLock: (f: () => Promise<unknown>) => f(),
|
||||
}));
|
||||
vi.mock("@/lib/services/catalog-git-queue", () => ({
|
||||
withCatalogExport: (f: () => Promise<unknown>) => f(),
|
||||
}));
|
||||
vi.mock("@/lib/furni/nitro-scale32", () => ({
|
||||
generateScale32: mocks.generate,
|
||||
}));
|
||||
vi.mock("@/lib/services/swf/nitro-builder", () => ({
|
||||
parseNitroBundle: () => ({ json: { assets: {} } }),
|
||||
}));
|
||||
|
||||
import { POST } from "./route";
|
||||
|
||||
const original = Buffer.from("original"),
|
||||
generated = Buffer.from("generated"),
|
||||
hash = (b: Buffer) => createHash("sha256").update(b).digest("hex");
|
||||
const request = (body: object) =>
|
||||
new NextRequest("http://localhost/api/admin/studio/nitro-scale32", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ classname: "chair", ...body }),
|
||||
});
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.read.mockResolvedValue(original);
|
||||
mocks.generate.mockResolvedValue({
|
||||
bundle: generated,
|
||||
before: "before",
|
||||
after: "after",
|
||||
count: 1,
|
||||
});
|
||||
});
|
||||
it("preview does not write anything", async () => {
|
||||
expect((await POST(request({ action: "preview" }))).status).toBe(200);
|
||||
expect(mocks.write).not.toHaveBeenCalled();
|
||||
});
|
||||
it("rejects stale source before generation or writes", async () => {
|
||||
expect(
|
||||
(await POST(request({ action: "apply", expectedHash: hash(generated) })))
|
||||
.status,
|
||||
).toBe(409);
|
||||
expect(mocks.generate).not.toHaveBeenCalled();
|
||||
expect(mocks.write).not.toHaveBeenCalled();
|
||||
});
|
||||
it("backs up the original before replacing the bundle", async () => {
|
||||
expect(
|
||||
(await POST(request({ action: "apply", expectedHash: hash(original) })))
|
||||
.status,
|
||||
).toBe(200);
|
||||
expect(mocks.write.mock.calls[0][1]).toEqual(original);
|
||||
expect(mocks.write.mock.calls[1][1]).toEqual(generated);
|
||||
expect(mocks.rename).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("restores the exact backup after checking the current hash", async () => {
|
||||
mocks.read.mockResolvedValueOnce(generated).mockResolvedValueOnce(original);
|
||||
expect(
|
||||
(
|
||||
await POST(
|
||||
request({
|
||||
action: "restore",
|
||||
expectedHash: hash(generated),
|
||||
backupHash: hash(original),
|
||||
}),
|
||||
)
|
||||
).status,
|
||||
).toBe(200);
|
||||
expect(mocks.write.mock.calls[0][1]).toEqual(original);
|
||||
});
|
||||
it("does not replace the live file when backup writing fails", async () => {
|
||||
mocks.write.mockRejectedValueOnce(Error("disk full"));
|
||||
expect(
|
||||
(await POST(request({ action: "apply", expectedHash: hash(original) })))
|
||||
.status,
|
||||
).toBe(422);
|
||||
expect(mocks.rename).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { withAdmin } from "@/lib/api-handler";
|
||||
import { apiError, apiOk } from "@/lib/api-response";
|
||||
import { generateScale32 } from "@/lib/furni/nitro-scale32";
|
||||
import { validateClassnames } from "@/lib/furni/studio-inspection";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import { withCatalogExport } from "@/lib/services/catalog-git-queue";
|
||||
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
|
||||
import { withFurniDataLock } from "@/lib/services/furni-data";
|
||||
import { parseNitroBundle } from "@/lib/services/swf/nitro-builder";
|
||||
|
||||
const hash = (data: Buffer) => createHash("sha256").update(data).digest("hex");
|
||||
export const POST = withAdmin(
|
||||
{ permission: PERMS.ASSETS_IMPORT },
|
||||
async (request) => {
|
||||
const body = await request.json();
|
||||
if (
|
||||
!validateClassnames([body.classname]) ||
|
||||
!["preview", "apply", "restore"].includes(body.action)
|
||||
)
|
||||
return apiError("Invalid request", 400);
|
||||
if (
|
||||
body.action !== "preview" &&
|
||||
!/^[a-f0-9]{64}$/.test(body.expectedHash || "")
|
||||
)
|
||||
return apiError("Preview the current bundle first", 400);
|
||||
if (
|
||||
body.action === "restore" &&
|
||||
!/^[a-f0-9]{64}$/.test(body.backupHash || "")
|
||||
)
|
||||
return apiError("Invalid backup", 400);
|
||||
const { nitroDir } = await getFurniAssetDirs(),
|
||||
base = body.classname.split("*")[0],
|
||||
file = path.join(nitroDir, `${base}.nitro`);
|
||||
const execute = () =>
|
||||
withFurniDataLock(async () => {
|
||||
const original = await fs.readFile(file),
|
||||
originalHash = hash(original);
|
||||
if (body.action !== "preview" && originalHash !== body.expectedHash)
|
||||
return apiError(
|
||||
"The bundle changed. Reopen the editor and preview again.",
|
||||
409,
|
||||
);
|
||||
const backupDir = path.join(
|
||||
process.cwd(),
|
||||
"storage",
|
||||
"nitro-scale32-backups",
|
||||
);
|
||||
if (body.action === "restore") {
|
||||
const backup = await fs.readFile(
|
||||
path.join(backupDir, `${base}.${body.backupHash}.nitro`),
|
||||
);
|
||||
if (hash(backup) !== body.backupHash)
|
||||
return apiError("Backup integrity check failed", 422);
|
||||
const temp = `${file}.${randomUUID()}.tmp`;
|
||||
try {
|
||||
await fs.writeFile(temp, backup, { flag: "wx" });
|
||||
await fs.rename(temp, file);
|
||||
} finally {
|
||||
await fs.rm(temp, { force: true });
|
||||
}
|
||||
return apiOk({
|
||||
hash: hash(backup),
|
||||
metadata: parseNitroBundle(backup).json,
|
||||
});
|
||||
}
|
||||
const generated = await generateScale32(original);
|
||||
if (body.action === "preview")
|
||||
return apiOk({
|
||||
hash: originalHash,
|
||||
before: generated.before,
|
||||
after: generated.after,
|
||||
count: generated.count,
|
||||
});
|
||||
await fs.mkdir(backupDir, { recursive: true });
|
||||
const backupFile = path.join(
|
||||
backupDir,
|
||||
`${base}.${originalHash}.nitro`,
|
||||
);
|
||||
try {
|
||||
await fs.writeFile(backupFile, original, { flag: "wx" });
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
||||
if (hash(await fs.readFile(backupFile)) !== originalHash)
|
||||
throw Error("Backup integrity check failed");
|
||||
}
|
||||
const temp = `${file}.${randomUUID()}.tmp`;
|
||||
try {
|
||||
await fs.writeFile(temp, generated.bundle, { flag: "wx" });
|
||||
await fs.rename(temp, file);
|
||||
} finally {
|
||||
await fs.rm(temp, { force: true });
|
||||
}
|
||||
return apiOk({
|
||||
hash: hash(generated.bundle),
|
||||
backupHash: originalHash,
|
||||
metadata: parseNitroBundle(generated.bundle).json,
|
||||
});
|
||||
});
|
||||
try {
|
||||
return await (body.action === "preview"
|
||||
? execute()
|
||||
: withCatalogExport(execute));
|
||||
} catch (error) {
|
||||
return apiError(
|
||||
(error as NodeJS.ErrnoException).code === "ENOENT"
|
||||
? "Bundle or backup not found"
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: "Scale generation failed",
|
||||
422,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user