feat: first cut at menu editor

This commit is contained in:
2026-02-21 19:51:34 +01:00
parent f371dbd2b2
commit 76c3a8368e
37 changed files with 2148 additions and 4 deletions

View 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;
}

View File

@@ -93,3 +93,10 @@ export {
type BlogGenerationOptions,
type BlogGenerationResult,
} from './BlogGenerationEngine';
export {
MenuEngine,
getMenuEngine,
type MenuItemData,
type MenuDocument,
type MenuItemKind,
} from './MenuEngine';

View File

@@ -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();

View File

@@ -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

View File

@@ -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>;

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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',