import { randomUUID } from 'node:crypto' import { mkdir, open, readFile, rename, unlink } from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' const serverDir = path.dirname(fileURLToPath(import.meta.url)) const projectRoot = path.resolve(serverDir, '..') const legacyToolIdAliases = new Map([ ['ascii-hex', 'utf8-hex'], ]) async function readJson(filePath) { return JSON.parse(await readFile(filePath, 'utf8')) } export async function loadCatalog() { return readJson(path.join(projectRoot, 'shared', 'tool-catalog.json')) } export async function loadDefaultNavigation() { return readJson(path.join(projectRoot, 'shared', 'default-navigation.json')) } export function normalizeNavigation(input, catalog, defaults) { const categoryIds = new Set(catalog.categories.map((category) => category.id)) const toolById = new Map(catalog.tools.map((tool) => [tool.id, tool])) const seenCategories = new Set() const seenTools = new Set() const categories = [] const inputCategories = Array.isArray(input?.categories) ? input.categories : [] for (const candidate of inputCategories) { if (!candidate || !categoryIds.has(candidate.id) || seenCategories.has(candidate.id)) continue seenCategories.add(candidate.id) const tools = [] for (const savedToolId of Array.isArray(candidate.tools) ? candidate.tools : []) { const migratedToolId = legacyToolIdAliases.get(savedToolId) const toolId = migratedToolId && toolById.has(migratedToolId) ? migratedToolId : savedToolId if (!toolById.has(toolId) || seenTools.has(toolId)) continue seenTools.add(toolId) tools.push(toolId) } categories.push({ id: candidate.id, tools }) } for (const defaultCategory of defaults.categories) { if (!seenCategories.has(defaultCategory.id)) { seenCategories.add(defaultCategory.id) categories.push({ id: defaultCategory.id, tools: [] }) } } for (const tool of catalog.tools) { if (seenTools.has(tool.id)) continue const preferred = categories.find((category) => category.id === tool.category) ?? categories[0] preferred?.tools.push(tool.id) seenTools.add(tool.id) } return { version: 1, categories } } export function validateNavigation(input, catalog) { if (!input || input.version !== 1 || !Array.isArray(input.categories)) { throw new Error('配置格式无效') } const validCategories = new Set(catalog.categories.map((category) => category.id)) const validTools = new Set(catalog.tools.map((tool) => tool.id)) const seenCategories = new Set() const seenTools = new Set() for (const category of input.categories) { if (!category || !validCategories.has(category.id) || seenCategories.has(category.id)) { throw new Error(`分类无效或重复:${category?.id ?? 'unknown'}`) } if (!Array.isArray(category.tools)) throw new Error(`分类 ${category.id} 缺少工具列表`) seenCategories.add(category.id) for (const toolId of category.tools) { if (!validTools.has(toolId) || seenTools.has(toolId)) throw new Error(`工具无效或重复:${toolId}`) seenTools.add(toolId) } } if (seenCategories.size !== validCategories.size || seenTools.size !== validTools.size) { throw new Error('配置必须包含全部分类和工具') } return input } export async function writeJsonAtomic(filePath, value, operations = { mkdir, open, rename, unlink }) { const serialized = `${JSON.stringify(value, null, 2)}\n` const temporaryPath = `${filePath}.${randomUUID()}.tmp` let handle try { await operations.mkdir(path.dirname(filePath), { recursive: true }) handle = await operations.open(temporaryPath, 'wx', 0o600) await handle.writeFile(serialized, 'utf8') await handle.sync() await handle.close() handle = undefined await operations.rename(temporaryPath, filePath) } catch (error) { if (handle) await handle.close().catch(() => undefined) await operations.unlink(temporaryPath).catch(() => undefined) throw error } } export async function readOrCreateNavigation(filePath, catalog, defaults) { let current try { current = await readJson(filePath) } catch (error) { if (error?.code !== 'ENOENT') throw error current = defaults } const normalized = normalizeNavigation(current, catalog, defaults) if (JSON.stringify(current) !== JSON.stringify(normalized)) await writeJsonAtomic(filePath, normalized) return normalized }