Files
WebTools/server/navigation.mjs
T

121 lines
4.4 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, '..')
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
}