312 lines
8.4 KiB
TypeScript
312 lines
8.4 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;
|
|
}
|
|
}
|
|
|