feat: first cut at menu editor
This commit is contained in:
223
src/main/engine/MenuEngine.ts
Normal file
223
src/main/engine/MenuEngine.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
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';
|
||||
|
||||
export interface MenuItemData {
|
||||
id: string;
|
||||
title: string;
|
||||
kind: MenuItemKind;
|
||||
pageId?: string;
|
||||
pageSlug?: string;
|
||||
children: MenuItemData[];
|
||||
}
|
||||
|
||||
export interface MenuDocument {
|
||||
items: MenuItemData[];
|
||||
}
|
||||
|
||||
type OpmlOutlineNode = {
|
||||
'@_id'?: string;
|
||||
'@_text'?: string;
|
||||
'@_title'?: string;
|
||||
'@_type'?: string;
|
||||
'@_pageId'?: string;
|
||||
'@_pageSlug'?: 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 = candidate.kind === 'submenu' ? 'submenu' : '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' ? normalizeNonEmptyString(candidate.pageSlug) : undefined,
|
||||
children: childrenSource.map((child) => sanitizeMenuItem(child)),
|
||||
};
|
||||
}
|
||||
|
||||
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 kind: MenuItemKind = node['@_type'] === 'submenu' ? 'submenu' : 'page';
|
||||
const title = normalizeNonEmptyString(node['@_text']) || normalizeNonEmptyString(node['@_title']) || 'Untitled';
|
||||
|
||||
return {
|
||||
id: normalizeNonEmptyString(node['@_id']) || generateMenuItemId(),
|
||||
title,
|
||||
kind,
|
||||
pageId: kind === 'page' ? normalizeNonEmptyString(node['@_pageId']) : undefined,
|
||||
pageSlug: kind === 'page' ? normalizeNonEmptyString(node['@_pageSlug']) : 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.pageSlug) {
|
||||
outlineNode['@_pageSlug'] = item.pageSlug;
|
||||
}
|
||||
|
||||
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 { 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 sanitizeMenuDocument({ items });
|
||||
}
|
||||
|
||||
async saveMenu(input: MenuDocument): Promise<MenuDocument> {
|
||||
const sanitized = 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;
|
||||
}
|
||||
@@ -93,3 +93,10 @@ export {
|
||||
type BlogGenerationOptions,
|
||||
type BlogGenerationResult,
|
||||
} from './BlogGenerationEngine';
|
||||
export {
|
||||
MenuEngine,
|
||||
getMenuEngine,
|
||||
type MenuItemData,
|
||||
type MenuDocument,
|
||||
type MenuItemKind,
|
||||
} from './MenuEngine';
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getPostEngine, PostData, PostFilter, PaginationOptions } from '../engin
|
||||
import { getMediaEngine, MediaData } from '../engine/MediaEngine';
|
||||
import { getProjectEngine, ProjectData } from '../engine/ProjectEngine';
|
||||
import { getMetaEngine } from '../engine/MetaEngine';
|
||||
import { getMenuEngine, type MenuDocument } from '../engine/MenuEngine';
|
||||
import { getTagEngine } from '../engine/TagEngine';
|
||||
import { getPostMediaEngine } from '../engine/PostMediaEngine';
|
||||
import { getGitEngine } from '../engine/GitEngine';
|
||||
@@ -248,10 +249,12 @@ export function registerIpcHandlers(): void {
|
||||
const postEngine = getPostEngine();
|
||||
const mediaEngine = getMediaEngine();
|
||||
const metaEngine = getMetaEngine();
|
||||
const menuEngine = getMenuEngine();
|
||||
const tagEngine = getTagEngine();
|
||||
postEngine.setProjectContext(project.id, dataDir);
|
||||
mediaEngine.setProjectContext(project.id, dataDir, dataDir);
|
||||
metaEngine.setProjectContext(project.id, dataDir);
|
||||
menuEngine.setProjectContext(project.id, dataDir);
|
||||
tagEngine.setProjectContext(project.id, dataDir);
|
||||
const postMediaEngine = getPostMediaEngine();
|
||||
postMediaEngine.setProjectContext(project.id);
|
||||
@@ -284,10 +287,12 @@ export function registerIpcHandlers(): void {
|
||||
const postEngine = getPostEngine();
|
||||
const mediaEngine = getMediaEngine();
|
||||
const metaEngine = getMetaEngine();
|
||||
const menuEngine = getMenuEngine();
|
||||
const tagEngine = getTagEngine();
|
||||
postEngine.setProjectContext(project.id, dataDir);
|
||||
mediaEngine.setProjectContext(project.id, dataDir, dataDir);
|
||||
metaEngine.setProjectContext(project.id, dataDir);
|
||||
menuEngine.setProjectContext(project.id, dataDir);
|
||||
tagEngine.setProjectContext(project.id, dataDir);
|
||||
const postMediaEngine = getPostMediaEngine();
|
||||
postMediaEngine.setProjectContext(project.id);
|
||||
@@ -813,6 +818,34 @@ export function registerIpcHandlers(): void {
|
||||
|
||||
// ============ Meta Handlers ============
|
||||
|
||||
safeHandle('menu:get', async () => {
|
||||
const projectEngine = getProjectEngine();
|
||||
const menuEngine = getMenuEngine();
|
||||
const project = await projectEngine.getActiveProject();
|
||||
|
||||
if (!project) {
|
||||
throw new Error('No active project');
|
||||
}
|
||||
|
||||
const dataDir = projectEngine.getDataDir(project.id, project.dataPath);
|
||||
menuEngine.setProjectContext(project.id, dataDir);
|
||||
return menuEngine.getMenu();
|
||||
});
|
||||
|
||||
safeHandle('menu:save', async (_, menu: MenuDocument) => {
|
||||
const projectEngine = getProjectEngine();
|
||||
const menuEngine = getMenuEngine();
|
||||
const project = await projectEngine.getActiveProject();
|
||||
|
||||
if (!project) {
|
||||
throw new Error('No active project');
|
||||
}
|
||||
|
||||
const dataDir = projectEngine.getDataDir(project.id, project.dataPath);
|
||||
menuEngine.setProjectContext(project.id, dataDir);
|
||||
return menuEngine.saveMenu(menu);
|
||||
});
|
||||
|
||||
safeHandle('meta:getTags', async () => {
|
||||
const engine = getMetaEngine();
|
||||
return engine.getTags();
|
||||
|
||||
@@ -258,6 +258,11 @@ export const electronAPI: ElectronAPI = {
|
||||
applyValidation: (report: SiteValidationReport) => ipcRenderer.invoke('blog:applyValidation', report),
|
||||
},
|
||||
|
||||
menu: {
|
||||
get: () => ipcRenderer.invoke('menu:get'),
|
||||
save: (menu: import('./shared/electronApi').MenuDocument) => ipcRenderer.invoke('menu:save', menu),
|
||||
},
|
||||
|
||||
// AI Chat (OpenCode Zen API integration)
|
||||
chat: {
|
||||
// API Key Management
|
||||
|
||||
@@ -422,6 +422,21 @@ export interface SiteValidationApplyResult {
|
||||
removedEmptyDirCount: number;
|
||||
}
|
||||
|
||||
export type MenuItemKind = 'page' | 'submenu';
|
||||
|
||||
export interface MenuItemData {
|
||||
id: string;
|
||||
title: string;
|
||||
kind: MenuItemKind;
|
||||
pageId?: string;
|
||||
pageSlug?: string;
|
||||
children: MenuItemData[];
|
||||
}
|
||||
|
||||
export interface MenuDocument {
|
||||
items: MenuItemData[];
|
||||
}
|
||||
|
||||
export interface ElectronAPI {
|
||||
git: {
|
||||
checkAvailability: () => Promise<GitAvailability>;
|
||||
@@ -629,6 +644,10 @@ export interface ElectronAPI {
|
||||
validateSite: () => Promise<SiteValidationReport>;
|
||||
applyValidation: (report: SiteValidationReport) => Promise<SiteValidationApplyResult>;
|
||||
};
|
||||
menu: {
|
||||
get: () => Promise<MenuDocument>;
|
||||
save: (menu: MenuDocument) => Promise<MenuDocument>;
|
||||
};
|
||||
chat: {
|
||||
// API Key Management
|
||||
checkReady: () => Promise<ChatReadyStatus>;
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"menu.item.rebuildDatabase": "Datenbank aus Dateien neu aufbauen",
|
||||
"menu.item.reindexText": "Suchtext neu indizieren",
|
||||
"menu.item.metadataDiff": "Metadaten-Diff-Werkzeug",
|
||||
"menu.item.editMenu": "Blog-Menü bearbeiten",
|
||||
"menu.item.generateSitemap": "Site rendern",
|
||||
"menu.item.validateSite": "Website validieren",
|
||||
"menu.item.about": "Über Blogging Desktop Server",
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"menu.item.rebuildDatabase": "Rebuild Database from Files",
|
||||
"menu.item.reindexText": "Reindex Search Text",
|
||||
"menu.item.metadataDiff": "Metadata Diff Tool",
|
||||
"menu.item.editMenu": "Edit Blog Menu",
|
||||
"menu.item.generateSitemap": "Render Site",
|
||||
"menu.item.validateSite": "Validate Site",
|
||||
"menu.item.about": "About Blogging Desktop Server",
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"menu.item.rebuildDatabase": "Reconstruir Database from Files",
|
||||
"menu.item.reindexText": "Reindex Buscar Text",
|
||||
"menu.item.metadataDiff": "Herramienta diff de metadatos",
|
||||
"menu.item.editMenu": "Editar menú del blog",
|
||||
"menu.item.generateSitemap": "Renderizar sitio",
|
||||
"menu.item.validateSite": "Validar sitio",
|
||||
"menu.item.about": "Acerca de Blogging Desktop Server",
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"menu.item.rebuildDatabase": "Reconstruire Database from Files",
|
||||
"menu.item.reindexText": "Reindex Recherche Text",
|
||||
"menu.item.metadataDiff": "Outil de diff des métadonnées",
|
||||
"menu.item.editMenu": "Modifier le menu du blog",
|
||||
"menu.item.generateSitemap": "Rendre le site",
|
||||
"menu.item.validateSite": "Valider le site",
|
||||
"menu.item.about": "À propos de Blogging Desktop Server",
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"menu.item.rebuildDatabase": "Ricostruisci Database from Files",
|
||||
"menu.item.reindexText": "Reindex Ricerca Text",
|
||||
"menu.item.metadataDiff": "Strumento diff metadati",
|
||||
"menu.item.editMenu": "Modifica menu blog",
|
||||
"menu.item.generateSitemap": "Renderizza sito",
|
||||
"menu.item.validateSite": "Valida sito",
|
||||
"menu.item.about": "Informazioni su Blogging Desktop Server",
|
||||
|
||||
@@ -31,6 +31,7 @@ export type AppMenuAction =
|
||||
| 'rebuildDatabase'
|
||||
| 'reindexText'
|
||||
| 'metadataDiff'
|
||||
| 'editMenu'
|
||||
| 'generateSitemap'
|
||||
| 'validateSite'
|
||||
| 'openDocumentation'
|
||||
@@ -123,6 +124,7 @@ export const APP_MENU_GROUPS: AppMenuGroupDefinition[] = [
|
||||
{ label: 'menu.item.reindexText', action: 'reindexText' },
|
||||
{ label: '', action: 'blog-separator-3', separator: true },
|
||||
{ label: 'menu.item.metadataDiff', action: 'metadataDiff' },
|
||||
{ label: 'menu.item.editMenu', action: 'editMenu' },
|
||||
{ label: 'menu.item.generateSitemap', action: 'generateSitemap', accelerator: 'CmdOrCtrl+R' },
|
||||
{ label: 'menu.item.validateSite', action: 'validateSite', accelerator: 'CmdOrCtrl+Shift+L' },
|
||||
],
|
||||
@@ -156,6 +158,7 @@ export const APP_MENU_ACTION_EVENT_MAP: Partial<Record<AppMenuAction, string>> =
|
||||
rebuildDatabase: 'menu:rebuildDatabase',
|
||||
reindexText: 'menu:reindexText',
|
||||
metadataDiff: 'menu:metadataDiff',
|
||||
editMenu: 'menu:editMenu',
|
||||
generateSitemap: 'menu:generateSitemap',
|
||||
validateSite: 'menu:validateSite',
|
||||
openDocumentation: 'menu:openDocumentation',
|
||||
|
||||
Reference in New Issue
Block a user