Files
bDS/src/main/engine/MenuEngine.ts

320 lines
8.6 KiB
TypeScript

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<string, unknown> : {};
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<string, unknown> : {};
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<MenuDocument> {
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<MenuDocument> {
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;
}