import { EventEmitter } from 'events'; import * as fs from 'fs/promises'; import * as path from 'path'; import { randomUUID } from 'crypto'; import { app } from 'electron'; import { XMLBuilder, XMLParser } from 'fast-xml-parser'; export type MenuItemKind = 'page' | 'submenu' | 'category-archive' | 'home'; const HOME_MENU_ID = 'menu-home'; const DEFAULT_HOME_ITEM: MenuItemData = { id: HOME_MENU_ID, title: 'Home', kind: 'home', pageId: undefined, pageSlug: 'home', categoryName: undefined, children: [], }; export interface MenuItemData { id: string; title: string; kind: MenuItemKind; pageId?: string; pageSlug?: string; categoryName?: string; children: MenuItemData[]; } export interface MenuDocument { items: MenuItemData[]; } type OpmlOutlineNode = { '@_id'?: string; '@_text'?: string; '@_title'?: string; '@_type'?: string; '@_pageId'?: string; '@_pageSlug'?: string; '@_categoryName'?: string; outline?: OpmlOutlineNode | OpmlOutlineNode[]; }; function generateMenuItemId(): string { try { return randomUUID(); } catch { return `menu-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; } } function normalizeOutlineNodes(value: unknown): OpmlOutlineNode[] { if (!value) { return []; } if (Array.isArray(value)) { return value as OpmlOutlineNode[]; } return [value as OpmlOutlineNode]; } function normalizeNonEmptyString(value: unknown): string | undefined { if (value === undefined || value === null) { return undefined; } const trimmed = String(value).trim(); return trimmed.length > 0 ? trimmed : undefined; } function sanitizeMenuItem(input: unknown): MenuItemData { const candidate = (input && typeof input === 'object') ? input as Record : {}; const kind: MenuItemKind = candidate.kind === 'submenu' ? 'submenu' : candidate.kind === 'category-archive' ? 'category-archive' : candidate.kind === 'home' ? 'home' : 'page'; const childrenSource = Array.isArray(candidate.children) ? candidate.children : []; const title = normalizeNonEmptyString(candidate.title) || 'Untitled'; return { id: normalizeNonEmptyString(candidate.id) || generateMenuItemId(), title, kind, pageId: kind === 'page' ? normalizeNonEmptyString(candidate.pageId) : undefined, pageSlug: kind === 'page' || kind === 'home' ? normalizeNonEmptyString(candidate.pageSlug) : undefined, categoryName: kind === 'category-archive' ? normalizeNonEmptyString(candidate.categoryName) : undefined, children: childrenSource.map((child) => sanitizeMenuItem(child)), }; } function normalizeHomeItem(item: MenuItemData): MenuItemData { return { ...item, id: HOME_MENU_ID, title: 'Home', kind: 'home', pageId: undefined, pageSlug: 'home', categoryName: undefined, children: [], }; } function extractHomeItem(items: MenuItemData[]): { homeItem: MenuItemData | null; remainingItems: MenuItemData[] } { let extractedHome: MenuItemData | null = null; const isHomeCandidate = (node: MenuItemData): boolean => { if (node.id === HOME_MENU_ID) { return true; } return node.kind === 'page' && (node.pageSlug?.toLowerCase() === 'home' || node.title.trim().toLowerCase() === 'home'); }; const walk = (nodes: MenuItemData[]): MenuItemData[] => { const next: MenuItemData[] = []; for (const node of nodes) { if (isHomeCandidate(node)) { if (!extractedHome) { extractedHome = normalizeHomeItem(node); } continue; } next.push({ ...node, children: walk(node.children), }); } return next; }; const remainingItems = walk(items); return { homeItem: extractedHome, remainingItems, }; } function enforceHomeEntry(input: MenuDocument): MenuDocument { const { homeItem, remainingItems } = extractHomeItem(input.items); const ensuredHome = homeItem ? normalizeHomeItem(homeItem) : { ...DEFAULT_HOME_ITEM }; return { items: [ensuredHome, ...remainingItems], }; } function sanitizeMenuDocument(input: unknown): MenuDocument { const candidate = (input && typeof input === 'object') ? input as Record : {}; const items = Array.isArray(candidate.items) ? candidate.items : []; return { items: items.map((item) => sanitizeMenuItem(item)), }; } function parseOutlineNode(node: OpmlOutlineNode): MenuItemData { const rawType = normalizeNonEmptyString(node['@_type']); const kind: MenuItemKind = rawType === 'submenu' ? 'submenu' : rawType === 'category-archive' ? 'category-archive' : rawType === 'home' ? 'home' : 'page'; const textTitle = normalizeNonEmptyString(node['@_text']); const explicitTitle = normalizeNonEmptyString(node['@_title']); const title = kind === 'category-archive' ? explicitTitle || textTitle || 'Untitled' : textTitle || explicitTitle || 'Untitled'; return { id: normalizeNonEmptyString(node['@_id']) || generateMenuItemId(), title, kind, pageId: kind === 'page' ? normalizeNonEmptyString(node['@_pageId']) : undefined, pageSlug: kind === 'page' || kind === 'home' ? normalizeNonEmptyString(node['@_pageSlug']) : undefined, categoryName: kind === 'category-archive' ? normalizeNonEmptyString(node['@_categoryName']) : undefined, children: normalizeOutlineNodes(node.outline).map((child) => parseOutlineNode(child)), }; } function toOpmlOutlineNode(item: MenuItemData): OpmlOutlineNode { const outlineNode: OpmlOutlineNode = { '@_id': item.id, '@_text': item.title, '@_type': item.kind, }; if (item.kind === 'page' && item.pageId) { outlineNode['@_pageId'] = item.pageId; } if ((item.kind === 'page' || item.kind === 'home') && item.pageSlug) { outlineNode['@_pageSlug'] = item.pageSlug; } if (item.kind === 'category-archive' && item.categoryName) { outlineNode['@_categoryName'] = item.categoryName; } if (item.children.length > 0) { outlineNode.outline = item.children.map((child) => toOpmlOutlineNode(child)); } return outlineNode; } export class MenuEngine extends EventEmitter { private currentProjectId: string = 'default'; private dataDir: string | null = null; private getDefaultBaseDir(): string { const userDataPath = app.getPath('userData'); return path.join(userDataPath, 'projects', this.currentProjectId); } private getBaseDir(): string { return this.dataDir || this.getDefaultBaseDir(); } getMetaDir(): string { return path.join(this.getBaseDir(), 'meta'); } private getMenuFilePath(): string { return path.join(this.getMetaDir(), 'menu.opml'); } setProjectContext(projectId: string, dataDir?: string): void { this.currentProjectId = projectId; this.dataDir = dataDir || null; } async getMenu(): Promise { const menuPath = this.getMenuFilePath(); let xmlContent: string; try { xmlContent = await fs.readFile(menuPath, 'utf-8'); } catch (error) { const asErrno = error as NodeJS.ErrnoException; if (asErrno?.code === 'ENOENT') { return enforceHomeEntry({ items: [] }); } throw error; } const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '@_', allowBooleanAttributes: true, }); const parsed = parser.parse(xmlContent) as { opml?: { body?: { outline?: OpmlOutlineNode | OpmlOutlineNode[]; }; }; }; const outlineNodes = normalizeOutlineNodes(parsed?.opml?.body?.outline); const items = outlineNodes.map((node) => parseOutlineNode(node)); return enforceHomeEntry(sanitizeMenuDocument({ items })); } async saveMenu(input: MenuDocument): Promise { const sanitized = enforceHomeEntry(sanitizeMenuDocument(input)); const builder = new XMLBuilder({ ignoreAttributes: false, attributeNamePrefix: '@_', format: true, suppressEmptyNode: true, }); const opmlPayload = { '?xml': { '@_version': '1.0', '@_encoding': 'UTF-8', }, opml: { '@_version': '2.0', head: { title: 'Blog Menu', }, body: { outline: sanitized.items.map((item) => toOpmlOutlineNode(item)), }, }, }; const xml = builder.build(opmlPayload); await fs.mkdir(this.getMetaDir(), { recursive: true }); await fs.writeFile(this.getMenuFilePath(), xml, 'utf-8'); this.emit('menuUpdated', sanitized); return sanitized; } } let menuEngine: MenuEngine | null = null; export function getMenuEngine(): MenuEngine { if (!menuEngine) { menuEngine = new MenuEngine(); } return menuEngine; }