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 { 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; } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { return {}; } throw error; } } async function collectPngAssetPaths( rootDir: string, relativeDir = 'Icons', ): Promise { 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; }