Files
Genarrative/server-node/src/modules/editor/editorRoutes.ts
2026-04-10 15:37:02 +08:00

142 lines
3.8 KiB
TypeScript

import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { Router } from 'express';
import type { AppConfig } from '../../config.js';
import { badRequest, notFound } from '../../errors.js';
import { asyncHandler } from '../../http.js';
const EDITOR_JSON_RESOURCE_FILES = {
'item-overrides': 'src/data/itemOverrides.json',
'npc-visual-overrides': 'src/data/npcVisualOverrides.json',
'npc-layout-config': 'src/data/npcLayoutConfig.json',
'character-overrides': 'src/data/characterOverrides.json',
'monster-overrides': 'src/data/monsterOverrides.json',
'scene-overrides': 'src/data/sceneOverrides.json',
'scene-npc-overrides': 'src/data/sceneNpcOverrides.json',
'state-function-overrides': 'src/data/stateFunctionOverrides.json',
} as const;
type EditorJsonResourceId = keyof typeof EDITOR_JSON_RESOURCE_FILES;
function isEditorJsonPayload(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function resolveEditorJsonFile(
config: AppConfig,
resourceId: string,
) {
const relativePath =
EDITOR_JSON_RESOURCE_FILES[
resourceId as EditorJsonResourceId
];
if (!relativePath) {
throw notFound('未知的编辑器资源。');
}
return path.resolve(config.projectRoot, relativePath);
}
async function readEditorJsonFile(filePath: string) {
try {
const content = await readFile(filePath, 'utf8');
return JSON.parse(content) as Record<string, unknown>;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return {};
}
throw error;
}
}
async function collectPngAssetPaths(
rootDir: string,
relativeDir = 'Icons',
): Promise<string[]> {
const entries = await readdir(rootDir, { withFileTypes: true });
const collected: string[] = [];
for (const entry of entries) {
const absolutePath = path.join(rootDir, entry.name);
const relativePath = `${relativeDir}/${entry.name}`.replace(/\\/g, '/');
if (entry.isDirectory()) {
collected.push(
...(await collectPngAssetPaths(absolutePath, relativePath)),
);
continue;
}
if (entry.isFile() && entry.name.toLowerCase().endsWith('.png')) {
collected.push(relativePath);
}
}
return collected.sort((left, right) => left.localeCompare(right));
}
export function createEditorRoutes(config: AppConfig) {
const router = Router();
router.use((request, response, next) => {
if (
request.path !== '/api/editor' &&
!request.path.startsWith('/api/editor/')
) {
next();
return;
}
if (!config.editorApiEnabled) {
response.status(403).json({
error: {
message: '编辑器接口当前未启用。',
},
});
return;
}
next();
});
router.get(
'/api/editor/catalog/items',
asyncHandler(async (_request, response) => {
response.json({
assetPaths: await collectPngAssetPaths(
path.resolve(config.projectRoot, 'public/Icons'),
),
});
}),
);
router.get(
'/api/editor/json/:resourceId',
asyncHandler(async (request, response) => {
const filePath = resolveEditorJsonFile(config, request.params.resourceId);
response.json(await readEditorJsonFile(filePath));
}),
);
router.post(
'/api/editor/json/:resourceId',
asyncHandler(async (request, response) => {
if (!isEditorJsonPayload(request.body)) {
throw badRequest('编辑器保存请求必须是 JSON 对象。');
}
const filePath = resolveEditorJsonFile(config, request.params.resourceId);
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(
filePath,
JSON.stringify(request.body, null, 2) + '\n',
'utf8',
);
response.json({ ok: true });
}),
);
return router;
}