116 lines
4.2 KiB
JavaScript
116 lines
4.2 KiB
JavaScript
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, '..')
|
|
|
|
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 toolId of Array.isArray(candidate.tools) ? candidate.tools : []) {
|
|
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
|
|
}
|