diff --git a/package-lock.json b/package-lock.json index 0e12bc5..1b1c2f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,12 +29,14 @@ "date-fns": "^4.1.0", "drizzle-orm": "^0.45.1", "dropbox": "^10.34.0", + "fast-xml-parser": "^5.3.7", "gray-matter": "^4.0.3", "lightbox2": "^2.11.5", "liquidjs": "^10.24.0", "marked-react": "^3.0.2", "monaco-editor": "^0.55.1", "react": "^19.2.4", + "react-arborist": "^3.4.3", "react-dom": "^19.2.4", "react-hot-toast": "^2.6.0", "sharp": "^0.34.5", @@ -381,7 +383,6 @@ "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -4683,6 +4684,24 @@ "dev": true, "license": "MIT" }, + "node_modules/@react-dnd/asap": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-4.0.1.tgz", + "integrity": "sha512-kLy0PJDDwvwwTXxqTFNAAllPHD73AycE9ypWeln/IguoGBEbvFcPDbCV03G52bEcC5E+YgupBE0VzHGdC8SIXg==", + "license": "MIT" + }, + "node_modules/@react-dnd/invariant": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-2.0.0.tgz", + "integrity": "sha512-xL4RCQBCBDJ+GRwKTFhGUW8GXa4yoDfJrPbLblc3U09ciS+9ZJXJ3Qrcs/x2IODOdIE5kQxvMmE2UKyqUictUw==", + "license": "MIT" + }, + "node_modules/@react-dnd/shallowequal": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-2.0.0.tgz", + "integrity": "sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg==", + "license": "MIT" + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.3", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", @@ -7829,6 +7848,26 @@ "node": ">=8" } }, + "node_modules/dnd-core": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-14.0.1.tgz", + "integrity": "sha512-+PVS2VPTgKFPYWo3vAFEA8WPbTf7/xo43TifH9G8S1KqnrQu0o77A3unrF5yOugy4mIz7K5wAVFHUcha7wsz6A==", + "license": "MIT", + "dependencies": { + "@react-dnd/asap": "^4.0.0", + "@react-dnd/invariant": "^2.0.0", + "redux": "^4.1.1" + } + }, + "node_modules/dnd-core/node_modules/redux": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", + "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.9.2" + } + }, "node_modules/dom-accessibility-api": { "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", @@ -8834,7 +8873,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-json-stable-stringify": { @@ -8868,6 +8906,24 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fast-xml-parser": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.7.tgz", + "integrity": "sha512-JzVLro9NQv92pOM/jTCR6mHlJh2FGwtomH8ZQjhFj/R29P2Fnj38OgPJVtcvYw6SuKClhgYuwUZf5b3rd8u2mA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^2.1.2" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fd-slicer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", @@ -9523,6 +9579,21 @@ "node": ">= 0.4" } }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/hosted-git-info": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", @@ -10756,6 +10827,12 @@ "tslib": "2" } }, + "node_modules/memoize-one": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", + "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", + "license": "MIT" + }, "node_modules/micromark": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", @@ -12187,6 +12264,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -12619,6 +12697,62 @@ "node": ">=0.10.0" } }, + "node_modules/react-arborist": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/react-arborist/-/react-arborist-3.4.3.tgz", + "integrity": "sha512-yFnq1nIQhT2uJY4TZVz2tgAiBb9lxSyvF4vC3S8POCK8xLzjGIxVv3/4dmYquQJ7AHxaZZArRGHiHKsEewKdTQ==", + "license": "MIT", + "dependencies": { + "react-dnd": "^14.0.3", + "react-dnd-html5-backend": "^14.0.3", + "react-window": "^1.8.11", + "redux": "^5.0.0", + "use-sync-external-store": "^1.2.0" + }, + "peerDependencies": { + "react": ">= 16.14", + "react-dom": ">= 16.14" + } + }, + "node_modules/react-dnd": { + "version": "14.0.5", + "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-14.0.5.tgz", + "integrity": "sha512-9i1jSgbyVw0ELlEVt/NkCUkxy1hmhJOkePoCH713u75vzHGyXhPDm28oLfc2NMSBjZRM1Y+wRjHXJT3sPrTy+A==", + "license": "MIT", + "dependencies": { + "@react-dnd/invariant": "^2.0.0", + "@react-dnd/shallowequal": "^2.0.0", + "dnd-core": "14.0.1", + "fast-deep-equal": "^3.1.3", + "hoist-non-react-statics": "^3.3.2" + }, + "peerDependencies": { + "@types/hoist-non-react-statics": ">= 3.3.1", + "@types/node": ">= 12", + "@types/react": ">= 16", + "react": ">= 16.14" + }, + "peerDependenciesMeta": { + "@types/hoist-non-react-statics": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-dnd-html5-backend": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-14.1.0.tgz", + "integrity": "sha512-6ONeqEC3XKVf4eVmMTe0oPds+c5B9Foyj8p/ZKLb7kL2qh9COYxiBHv3szd6gztqi/efkmriywLUVlPotqoJyw==", + "license": "MIT", + "dependencies": { + "dnd-core": "14.0.1" + } + }, "node_modules/react-dom": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", @@ -12666,6 +12800,23 @@ "node": ">=0.10.0" } }, + "node_modules/react-window": { + "version": "1.8.11", + "resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.11.tgz", + "integrity": "sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.0.0", + "memoize-one": ">=3.1.1 <6" + }, + "engines": { + "node": ">8.0.0" + }, + "peerDependencies": { + "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/read-binary-file-arch": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", @@ -12721,6 +12872,12 @@ "node": ">=8" } }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, "node_modules/remark": { "version": "15.0.1", "resolved": "https://registry.npmjs.org/remark/-/remark-15.0.1.tgz", @@ -13561,6 +13718,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strnum": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz", + "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/stubborn-fs": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-2.0.0.tgz", @@ -14699,6 +14868,16 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/utf8-byte-length": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", diff --git a/package.json b/package.json index 29560b0..0cb6c88 100644 --- a/package.json +++ b/package.json @@ -80,12 +80,14 @@ "date-fns": "^4.1.0", "drizzle-orm": "^0.45.1", "dropbox": "^10.34.0", + "fast-xml-parser": "^5.3.7", "gray-matter": "^4.0.3", "lightbox2": "^2.11.5", "liquidjs": "^10.24.0", "marked-react": "^3.0.2", "monaco-editor": "^0.55.1", "react": "^19.2.4", + "react-arborist": "^3.4.3", "react-dom": "^19.2.4", "react-hot-toast": "^2.6.0", "sharp": "^0.34.5", diff --git a/src/main/engine/MenuEngine.ts b/src/main/engine/MenuEngine.ts new file mode 100644 index 0000000..5b01f05 --- /dev/null +++ b/src/main/engine/MenuEngine.ts @@ -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 : {}; + 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 : {}; + 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 { + 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 { + 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; +} diff --git a/src/main/engine/index.ts b/src/main/engine/index.ts index 6c1d220..23dfd0e 100644 --- a/src/main/engine/index.ts +++ b/src/main/engine/index.ts @@ -93,3 +93,10 @@ export { type BlogGenerationOptions, type BlogGenerationResult, } from './BlogGenerationEngine'; +export { + MenuEngine, + getMenuEngine, + type MenuItemData, + type MenuDocument, + type MenuItemKind, +} from './MenuEngine'; diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 507763c..67dcbff 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -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(); diff --git a/src/main/preload.ts b/src/main/preload.ts index f0540c9..464d9e3 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -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 diff --git a/src/main/shared/electronApi.ts b/src/main/shared/electronApi.ts index 3d8911a..7aaca43 100644 --- a/src/main/shared/electronApi.ts +++ b/src/main/shared/electronApi.ts @@ -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; @@ -629,6 +644,10 @@ export interface ElectronAPI { validateSite: () => Promise; applyValidation: (report: SiteValidationReport) => Promise; }; + menu: { + get: () => Promise; + save: (menu: MenuDocument) => Promise; + }; chat: { // API Key Management checkReady: () => Promise; diff --git a/src/main/shared/i18n/locales/de.json b/src/main/shared/i18n/locales/de.json index 0dc05d7..7dc5944 100644 --- a/src/main/shared/i18n/locales/de.json +++ b/src/main/shared/i18n/locales/de.json @@ -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", diff --git a/src/main/shared/i18n/locales/en.json b/src/main/shared/i18n/locales/en.json index f3d1caf..9af019a 100644 --- a/src/main/shared/i18n/locales/en.json +++ b/src/main/shared/i18n/locales/en.json @@ -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", diff --git a/src/main/shared/i18n/locales/es.json b/src/main/shared/i18n/locales/es.json index d91e0c2..bc3f0e9 100644 --- a/src/main/shared/i18n/locales/es.json +++ b/src/main/shared/i18n/locales/es.json @@ -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", diff --git a/src/main/shared/i18n/locales/fr.json b/src/main/shared/i18n/locales/fr.json index 0e8c759..54d42bf 100644 --- a/src/main/shared/i18n/locales/fr.json +++ b/src/main/shared/i18n/locales/fr.json @@ -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", diff --git a/src/main/shared/i18n/locales/it.json b/src/main/shared/i18n/locales/it.json index ec98be6..d1b6a32 100644 --- a/src/main/shared/i18n/locales/it.json +++ b/src/main/shared/i18n/locales/it.json @@ -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", diff --git a/src/main/shared/menuCommands.ts b/src/main/shared/menuCommands.ts index e5c923e..e623d52 100644 --- a/src/main/shared/menuCommands.ts +++ b/src/main/shared/menuCommands.ts @@ -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> = rebuildDatabase: 'menu:rebuildDatabase', reindexText: 'menu:reindexText', metadataDiff: 'menu:metadataDiff', + editMenu: 'menu:editMenu', generateSitemap: 'menu:generateSitemap', validateSite: 'menu:validateSite', openDocumentation: 'menu:openDocumentation', diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index d07f5c8..890a7d8 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -276,6 +276,12 @@ const App: React.FC = () => { }) || (() => {}) ); + unsubscribers.push( + window.electronAPI?.on('menu:editMenu', () => { + openSingletonToolTab(openTab, 'menu-editor'); + }) || (() => {}) + ); + // Rebuild events - clear store on start, reload on complete unsubscribers.push( window.electronAPI?.on('posts:rebuildStarted', () => { diff --git a/src/renderer/components/Editor/Editor.tsx b/src/renderer/components/Editor/Editor.tsx index 9bc0c0f..2547747 100644 --- a/src/renderer/components/Editor/Editor.tsx +++ b/src/renderer/components/Editor/Editor.tsx @@ -14,6 +14,7 @@ import { TagsView } from '../TagsView'; import { TagInput } from '../TagInput'; import { ChatPanel } from '../ChatPanel'; import { ImportAnalysisView } from '../ImportAnalysisView'; +import { MenuEditorView } from '../MenuEditorView/MenuEditorView'; import { MetadataDiffPanel } from '../MetadataDiffPanel'; import { GitDiffView } from '../GitDiffView/GitDiffView'; import { DocumentationView } from '../DocumentationView/DocumentationView'; @@ -1784,6 +1785,7 @@ export const Editor: React.FC = () => { chat: () => (editorRoute.tabId ? : ), import: () => editorRoute.tabId ? : , + 'menu-editor': () => , 'metadata-diff': () => , 'git-diff': () => editorRoute.tabId && editorRoute.gitDiffResource diff --git a/src/renderer/components/MenuEditorView/MenuEditorView.css b/src/renderer/components/MenuEditorView/MenuEditorView.css new file mode 100644 index 0000000..3da75c3 --- /dev/null +++ b/src/renderer/components/MenuEditorView/MenuEditorView.css @@ -0,0 +1,196 @@ +.menu-editor-view { + padding: 1rem; + height: 100%; + display: flex; + flex-direction: column; + gap: 0.75rem; + position: relative; +} + +.menu-editor-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.menu-editor-header h2 { + margin: 0; +} + +.menu-editor-header p { + margin: 0.25rem 0 0; + color: var(--vscode-descriptionForeground); +} + +.menu-editor-loading, +.menu-editor-empty { + color: var(--vscode-descriptionForeground); +} + +.menu-editor-main { + display: grid; + grid-template-columns: minmax(480px, 1fr) minmax(280px, 340px); + gap: 0.75rem; + min-height: 0; + flex: 1; +} + +.menu-editor-tree-wrap, +.menu-editor-details { + border: 1px solid var(--vscode-panel-border); + border-radius: 6px; + background: var(--vscode-editor-background); + padding: 0.5rem; + min-height: 0; +} + +.menu-editor-toolbar { + display: flex; + align-items: center; + gap: 0.2rem; + margin-bottom: 0.5rem; + padding-bottom: 0.4rem; + border-bottom: 1px solid var(--vscode-panel-border); +} + +.menu-editor-tool { + width: 1.8rem; + height: 1.8rem; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid transparent; + border-radius: 4px; + background: transparent; + color: var(--vscode-foreground); + cursor: pointer; + padding: 0; +} + +.menu-editor-tool:hover:not(:disabled) { + background: var(--vscode-toolbar-hoverBackground); + border-color: var(--vscode-panel-border); +} + +.menu-editor-tool:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.menu-editor-row { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.2rem 0.4rem; + border-radius: 4px; + cursor: pointer; +} + +.menu-editor-row.is-selected { + background: var(--vscode-list-activeSelectionBackground); + color: var(--vscode-list-activeSelectionForeground); +} + +.menu-editor-row-kind { + font-size: 0.75rem; + opacity: 0.85; +} + +.menu-editor-row-title { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.menu-editor-details { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.menu-editor-details h3 { + margin: 0; +} + +.menu-editor-details label { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.menu-editor-picker-backdrop { + position: absolute; + inset: 0; + background: color-mix(in srgb, var(--vscode-editor-background) 75%, black); + display: flex; + align-items: center; + justify-content: center; +} + +.menu-editor-picker { + width: min(580px, 90%); + max-height: 75%; + border: 1px solid var(--vscode-panel-border); + background: var(--vscode-editor-background); + border-radius: 6px; + padding: 0.75rem; + display: flex; + flex-direction: column; + gap: 0.6rem; +} + +.menu-editor-picker-header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.menu-editor-picker-header h3 { + margin: 0; +} + +.menu-editor-picker-list { + display: flex; + flex-direction: column; + gap: 0.35rem; + overflow: auto; +} + +.menu-editor-picker-item { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + border: 1px solid var(--vscode-panel-border); + border-radius: 4px; + background: var(--vscode-input-background); + color: var(--vscode-input-foreground); + padding: 0.45rem 0.55rem; + text-align: left; + cursor: pointer; +} + +.menu-editor-picker-item:hover { + border-color: var(--vscode-focusBorder); + background: var(--vscode-list-hoverBackground); +} + +.menu-editor-picker-item.is-active { + border-color: var(--vscode-focusBorder); + background: var(--vscode-list-activeSelectionBackground); + color: var(--vscode-list-activeSelectionForeground); +} + +.menu-editor-picker-item.is-active small { + color: var(--vscode-list-activeSelectionForeground); + opacity: 0.8; +} + +.menu-editor-picker-item small { + color: var(--vscode-descriptionForeground); +} + +.menu-editor-picker-state { + color: var(--vscode-descriptionForeground); +} diff --git a/src/renderer/components/MenuEditorView/MenuEditorView.tsx b/src/renderer/components/MenuEditorView/MenuEditorView.tsx new file mode 100644 index 0000000..6ea0fe4 --- /dev/null +++ b/src/renderer/components/MenuEditorView/MenuEditorView.tsx @@ -0,0 +1,650 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { Tree } from 'react-arborist'; +import { useI18n } from '../../i18n'; +import { showToast } from '../Toast'; +import type { MenuDocument, MenuItemData, MenuItemKind, PostData } from '../../../main/shared/electronApi'; +import { createAutoExpandController } from './menuAutoExpand'; +import { + createMenuPageItemFromPost, + filterPagePosts, + getNextPickerIndex, + isPickerCloseKey, + isPickerFocusShortcut, +} from './menuPagePicker'; +import { applyTreeMove } from './menuTreeMove'; +import './MenuEditorView.css'; + +function createMenuItem(kind: MenuItemKind, title: string): MenuItemData { + return { + id: `menu-item-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + title, + kind, + pageId: undefined, + pageSlug: undefined, + children: [], + }; +} + +function findPathById(items: MenuItemData[], id: string, path: number[] = []): number[] | null { + for (let index = 0; index < items.length; index += 1) { + const item = items[index]; + const nextPath = [...path, index]; + if (item.id === id) { + return nextPath; + } + + const nested = findPathById(item.children, id, nextPath); + if (nested) { + return nested; + } + } + + return null; +} + +function updateItemsAtLevel( + items: MenuItemData[], + path: number[], + updater: (level: MenuItemData[]) => MenuItemData[], +): MenuItemData[] { + if (path.length === 0) { + return updater(items); + } + + const [head, ...tail] = path; + return items.map((item, index) => { + if (index !== head) { + return item; + } + + return { + ...item, + children: updateItemsAtLevel(item.children, tail, updater), + }; + }); +} + +function removeItemByPath(items: MenuItemData[], path: number[]): { next: MenuItemData[]; removed: MenuItemData | null } { + if (path.length === 0) { + return { next: items, removed: null }; + } + + if (path.length === 1) { + const [index] = path; + if (index < 0 || index >= items.length) { + return { next: items, removed: null }; + } + const removed = items[index]; + return { + next: items.filter((_, currentIndex) => currentIndex !== index), + removed, + }; + } + + const [head, ...tail] = path; + const current = items[head]; + if (!current) { + return { next: items, removed: null }; + } + + const nested = removeItemByPath(current.children, tail); + if (!nested.removed) { + return { next: items, removed: null }; + } + + const next = items.map((item, index) => (index === head ? { ...item, children: nested.next } : item)); + return { next, removed: nested.removed }; +} + +function insertItemAtPath(items: MenuItemData[], parentPath: number[], index: number, node: MenuItemData): MenuItemData[] { + if (parentPath.length === 0) { + const boundedIndex = Math.max(0, Math.min(index, items.length)); + return [...items.slice(0, boundedIndex), node, ...items.slice(boundedIndex)]; + } + + const [head, ...tail] = parentPath; + return items.map((item, currentIndex) => { + if (currentIndex !== head) { + return item; + } + + return { + ...item, + children: insertItemAtPath(item.children, tail, index, node), + }; + }); +} + +function mapItems(items: MenuItemData[], mapper: (item: MenuItemData) => MenuItemData): MenuItemData[] { + return items.map((item) => { + const mapped = mapper(item); + if (mapped.children.length === 0) { + return mapped; + } + + return { + ...mapped, + children: mapItems(mapped.children, mapper), + }; + }); +} + +export const MenuEditorView: React.FC = () => { + const { t: tr } = useI18n(); + const [items, setItems] = useState([]); + const [selectedId, setSelectedId] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [showPagePicker, setShowPagePicker] = useState(false); + const [pagePickerParentId, setPagePickerParentId] = useState(null); + const [pagePickerLoading, setPagePickerLoading] = useState(false); + const [pagePickerQuery, setPagePickerQuery] = useState(''); + const [pagePickerPosts, setPagePickerPosts] = useState([]); + const [pagePickerActiveIndex, setPagePickerActiveIndex] = useState(-1); + const pagePickerInputRef = useRef(null); + const autoExpandController = useMemo(() => createAutoExpandController(450), []); + + useEffect(() => { + const load = async () => { + setIsLoading(true); + try { + const menu = await window.electronAPI.menu.get(); + setItems(menu.items); + setSelectedId(menu.items[0]?.id ?? null); + } catch (error) { + console.error('Failed to load menu:', error); + showToast.error(tr('menuEditor.loadError')); + } finally { + setIsLoading(false); + } + }; + + void load(); + }, [tr]); + + useEffect(() => { + return () => { + autoExpandController.cancelAll(); + }; + }, [autoExpandController]); + + const selectedPath = useMemo(() => { + if (!selectedId) { + return null; + } + return findPathById(items, selectedId); + }, [items, selectedId]); + + const selectedItem = useMemo(() => { + if (!selectedPath || selectedPath.length === 0) { + return null; + } + + let currentItems = items; + let current: MenuItemData | null = null; + for (const segment of selectedPath) { + current = currentItems[segment] || null; + if (!current) { + return null; + } + currentItems = current.children; + } + + return current; + }, [items, selectedPath]); + + const filteredPagePosts = useMemo(() => { + return filterPagePosts(pagePickerPosts, pagePickerQuery); + }, [pagePickerPosts, pagePickerQuery]); + + const replaceSelected = (updater: (item: MenuItemData) => MenuItemData): void => { + if (!selectedId) { + return; + } + + setItems((previous) => mapItems(previous, (item) => (item.id === selectedId ? updater(item) : item))); + }; + + const insertItem = (previous: MenuItemData[], node: MenuItemData, parentId: string | null): MenuItemData[] => { + if (!parentId) { + return [...previous, node]; + } + + return mapItems(previous, (item) => { + if (item.id !== parentId) { + return item; + } + + return { + ...item, + children: [...item.children, node], + }; + }); + }; + + const closePagePicker = (): void => { + setShowPagePicker(false); + setPagePickerParentId(null); + setPagePickerQuery(''); + setPagePickerActiveIndex(-1); + }; + + const openPagePicker = async (parentId: string | null): Promise => { + setShowPagePicker(true); + setPagePickerParentId(parentId); + setPagePickerQuery(''); + setPagePickerActiveIndex(-1); + setPagePickerLoading(true); + try { + const posts = await window.electronAPI.posts.filter({ categories: ['page'] }); + setPagePickerPosts(posts); + } catch (error) { + console.error('Failed to load page posts:', error); + showToast.error(tr('menuEditor.pagePicker.loadError')); + setPagePickerPosts([]); + } finally { + setPagePickerLoading(false); + } + }; + + const selectPageForMenu = (post: PostData): void => { + const node = createMenuPageItemFromPost(post); + setItems((previous) => insertItem(previous, node, pagePickerParentId)); + setSelectedId(node.id); + closePagePicker(); + }; + + useEffect(() => { + if (!showPagePicker) { + return; + } + + if (filteredPagePosts.length === 0) { + setPagePickerActiveIndex(-1); + return; + } + + setPagePickerActiveIndex((previous) => { + if (previous < 0) { + return 0; + } + return Math.min(previous, filteredPagePosts.length - 1); + }); + }, [filteredPagePosts, showPagePicker]); + + useEffect(() => { + if (!showPagePicker) { + return; + } + + const onWindowKeyDown = (event: KeyboardEvent): void => { + if (isPickerFocusShortcut({ key: event.key, metaKey: event.metaKey, ctrlKey: event.ctrlKey })) { + event.preventDefault(); + pagePickerInputRef.current?.focus(); + pagePickerInputRef.current?.select(); + } + }; + + window.addEventListener('keydown', onWindowKeyDown); + return () => { + window.removeEventListener('keydown', onWindowKeyDown); + }; + }, [showPagePicker]); + + const addRootItem = (kind: MenuItemKind): void => { + if (kind === 'page') { + void openPagePicker(null); + return; + } + + const title = kind === 'page' ? tr('menuEditor.newPage') : tr('menuEditor.newSubmenu'); + const node = createMenuItem(kind, title); + setItems((previous) => [...previous, node]); + setSelectedId(node.id); + }; + + const addChildItem = (kind: MenuItemKind): void => { + if (!selectedId) { + addRootItem(kind); + return; + } + + if (kind === 'page') { + void openPagePicker(selectedId); + return; + } + + const title = kind === 'page' ? tr('menuEditor.newPage') : tr('menuEditor.newSubmenu'); + const node = createMenuItem(kind, title); + + setItems((previous) => mapItems(previous, (item) => { + if (item.id !== selectedId) { + return item; + } + + return { + ...item, + children: [...item.children, node], + }; + })); + setSelectedId(node.id); + }; + + const moveSelected = (direction: 'up' | 'down'): void => { + if (!selectedPath || selectedPath.length === 0) { + return; + } + + const parentPath = selectedPath.slice(0, -1); + const index = selectedPath[selectedPath.length - 1]; + const delta = direction === 'up' ? -1 : 1; + + setItems((previous) => updateItemsAtLevel(previous, parentPath, (level) => { + const targetIndex = index + delta; + if (targetIndex < 0 || targetIndex >= level.length) { + return level; + } + + const next = [...level]; + const [moved] = next.splice(index, 1); + next.splice(targetIndex, 0, moved); + return next; + })); + }; + + const indentSelected = (): void => { + if (!selectedPath || selectedPath.length === 0) { + return; + } + + const index = selectedPath[selectedPath.length - 1]; + if (index <= 0) { + return; + } + + const parentPath = selectedPath.slice(0, -1); + setItems((previous) => { + const removed = removeItemByPath(previous, selectedPath); + if (!removed.removed) { + return previous; + } + + const previousSiblingPath = [...parentPath, index - 1]; + return updateItemsAtLevel(removed.next, previousSiblingPath, (level) => [...level, removed.removed as MenuItemData]); + }); + }; + + const unindentSelected = (): void => { + if (!selectedPath || selectedPath.length < 2) { + return; + } + + const parentPath = selectedPath.slice(0, -1); + const parentIndex = parentPath[parentPath.length - 1]; + const grandParentPath = parentPath.slice(0, -1); + + setItems((previous) => { + const removed = removeItemByPath(previous, selectedPath); + if (!removed.removed) { + return previous; + } + + return insertItemAtPath(removed.next, grandParentPath, parentIndex + 1, removed.removed); + }); + }; + + const deleteSelected = (): void => { + if (!selectedPath || selectedPath.length === 0 || !selectedId) { + return; + } + + setItems((previous) => { + const removed = removeItemByPath(previous, selectedPath); + return removed.next; + }); + setSelectedId(null); + }; + + const save = async (): Promise => { + setIsSaving(true); + try { + const payload: MenuDocument = { items }; + const saved = await window.electronAPI.menu.save(payload); + setItems(saved.items); + showToast.success(tr('menuEditor.saved')); + } catch (error) { + console.error('Failed to save menu:', error); + showToast.error(tr('menuEditor.saveFailed')); + } finally { + setIsSaving(false); + } + }; + + return ( +
+
+
+

{tr('menuEditor.title')}

+

{tr('menuEditor.description')}

+
+
+ + {isLoading ? ( +
{tr('menuEditor.loading')}
+ ) : ( +
+
+
+ + + + + + + + + + +
+ + {items.length === 0 ? ( +
{tr('menuEditor.empty')}
+ ) : ( + + data={items} + width={720} + height={420} + rowHeight={30} + indent={20} + openByDefault + disableEdit + disableMultiSelection + onMove={({ dragIds, parentId, index }) => { + setItems((previous) => applyTreeMove(previous, { + dragIds, + parentId, + index, + })); + }} + onSelect={(nodes) => { + setSelectedId(nodes[0]?.data.id || null); + }} + > + {({ node, style, tree }) => ( +
setSelectedId(node.data.id)} + onMouseEnter={() => { + if (!tree.dragNode || !node.isInternal || node.isOpen) { + autoExpandController.cancel(node.id); + return; + } + + autoExpandController.schedule(node.id, () => { + node.open(); + }); + }} + onMouseLeave={() => { + autoExpandController.cancel(node.id); + }} + > + + {node.data.kind === 'page' ? tr('menuEditor.type.page') : tr('menuEditor.type.submenu')} + + {node.data.title} +
+ )} + + )} +
+ +
+

{tr('menuEditor.details')}

+ {!selectedItem ? ( +

{tr('menuEditor.selectItem')}

+ ) : ( + <> + + + + + {selectedItem.kind === 'page' && ( + <> + + + + + )} + + )} +
+
+ )} + + {showPagePicker && ( +
+
event.stopPropagation()}> +
+

{tr('menuEditor.pagePicker.title')}

+ +
+ + setPagePickerQuery(event.target.value)} + onKeyDown={(event) => { + if (isPickerCloseKey(event.key)) { + event.preventDefault(); + closePagePicker(); + return; + } + + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault(); + setPagePickerActiveIndex((previous) => getNextPickerIndex(previous, event.key, filteredPagePosts.length)); + return; + } + + if (event.key === 'Enter' && pagePickerActiveIndex >= 0 && pagePickerActiveIndex < filteredPagePosts.length) { + event.preventDefault(); + selectPageForMenu(filteredPagePosts[pagePickerActiveIndex]); + } + }} + placeholder={tr('menuEditor.pagePicker.searchPlaceholder')} + autoFocus + /> + + {pagePickerLoading ? ( +
{tr('menuEditor.pagePicker.loading')}
+ ) : filteredPagePosts.length === 0 ? ( +
{tr('menuEditor.pagePicker.empty')}
+ ) : ( +
+ {filteredPagePosts.map((post) => ( + + ))} +
+ )} +
+
+ )} +
+ ); +}; diff --git a/src/renderer/components/MenuEditorView/menuAutoExpand.ts b/src/renderer/components/MenuEditorView/menuAutoExpand.ts new file mode 100644 index 0000000..d535461 --- /dev/null +++ b/src/renderer/components/MenuEditorView/menuAutoExpand.ts @@ -0,0 +1,43 @@ +interface AutoExpandController { + schedule: (id: string, callback: () => void) => void; + cancel: (id: string) => void; + cancelAll: () => void; +} + +export function createAutoExpandController(delayMs: number): AutoExpandController { + const timers = new Map>(); + + const cancel = (id: string): void => { + const timer = timers.get(id); + if (!timer) { + return; + } + + clearTimeout(timer); + timers.delete(id); + }; + + const cancelAll = (): void => { + for (const timer of timers.values()) { + clearTimeout(timer); + } + timers.clear(); + }; + + const schedule = (id: string, callback: () => void): void => { + cancel(id); + + const timer = setTimeout(() => { + timers.delete(id); + callback(); + }, delayMs); + + timers.set(id, timer); + }; + + return { + schedule, + cancel, + cancelAll, + }; +} diff --git a/src/renderer/components/MenuEditorView/menuPagePicker.ts b/src/renderer/components/MenuEditorView/menuPagePicker.ts new file mode 100644 index 0000000..ff29a73 --- /dev/null +++ b/src/renderer/components/MenuEditorView/menuPagePicker.ts @@ -0,0 +1,55 @@ +import type { MenuItemData, PostData } from '../../../main/shared/electronApi'; + +function createMenuItemId(): string { + return `menu-item-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +export function filterPagePosts(posts: PostData[], query: string): PostData[] { + const normalized = query.trim().toLowerCase(); + + return posts.filter((post) => { + if (!(post.categories || []).includes('page')) { + return false; + } + + if (!normalized) { + return true; + } + + return post.title.toLowerCase().includes(normalized) || post.slug.toLowerCase().includes(normalized); + }); +} + +export function createMenuPageItemFromPost(post: PostData): MenuItemData { + return { + id: createMenuItemId(), + title: post.title, + kind: 'page', + pageId: post.id, + pageSlug: post.slug, + children: [], + }; +} + +export function getNextPickerIndex(currentIndex: number, key: 'ArrowDown' | 'ArrowUp', total: number): number { + if (total <= 0) { + return -1; + } + + if (key === 'ArrowDown') { + const next = currentIndex + 1; + return next >= total ? 0 : Math.max(0, next); + } + + const next = currentIndex < 0 ? total - 1 : currentIndex - 1; + return next < 0 ? total - 1 : next; +} + +export function isPickerCloseKey(key: string): boolean { + return key === 'Escape'; +} + +export function isPickerFocusShortcut(event: { key: string; metaKey: boolean; ctrlKey: boolean }): boolean { + const normalizedKey = event.key.toLowerCase(); + return normalizedKey === 'k' && (event.metaKey || event.ctrlKey); +} diff --git a/src/renderer/components/MenuEditorView/menuTreeMove.ts b/src/renderer/components/MenuEditorView/menuTreeMove.ts new file mode 100644 index 0000000..134e991 --- /dev/null +++ b/src/renderer/components/MenuEditorView/menuTreeMove.ts @@ -0,0 +1,115 @@ +import type { MenuItemData } from '../../../main/shared/electronApi'; + +export type MenuTreeItem = MenuItemData; + +interface TreeMoveInput { + dragIds: string[]; + parentId: string | null; + index: number; +} + +function findPathById(items: MenuTreeItem[], id: string, path: number[] = []): number[] | null { + for (let index = 0; index < items.length; index += 1) { + const item = items[index]; + const nextPath = [...path, index]; + if (item.id === id) { + return nextPath; + } + + const nested = findPathById(item.children, id, nextPath); + if (nested) { + return nested; + } + } + + return null; +} + +function removeItemByPath(items: MenuTreeItem[], path: number[]): { next: MenuTreeItem[]; removed: MenuTreeItem | null } { + if (path.length === 0) { + return { next: items, removed: null }; + } + + if (path.length === 1) { + const [index] = path; + if (index < 0 || index >= items.length) { + return { next: items, removed: null }; + } + + const removed = items[index]; + return { + next: items.filter((_, currentIndex) => currentIndex !== index), + removed, + }; + } + + const [head, ...tail] = path; + const current = items[head]; + if (!current) { + return { next: items, removed: null }; + } + + const nested = removeItemByPath(current.children, tail); + if (!nested.removed) { + return { next: items, removed: null }; + } + + const next = items.map((item, index) => (index === head ? { ...item, children: nested.next } : item)); + return { next, removed: nested.removed }; +} + +function insertItemsAtPath(items: MenuTreeItem[], parentPath: number[], index: number, nodes: MenuTreeItem[]): MenuTreeItem[] { + if (parentPath.length === 0) { + const boundedIndex = Math.max(0, Math.min(index, items.length)); + return [ + ...items.slice(0, boundedIndex), + ...nodes, + ...items.slice(boundedIndex), + ]; + } + + const [head, ...tail] = parentPath; + return items.map((item, currentIndex) => { + if (currentIndex !== head) { + return item; + } + + return { + ...item, + children: insertItemsAtPath(item.children, tail, index, nodes), + }; + }); +} + +export function applyTreeMove(items: MenuTreeItem[], move: TreeMoveInput): MenuTreeItem[] { + if (!move.dragIds.length) { + return items; + } + + let working = items; + const draggedNodes: MenuTreeItem[] = []; + + for (const dragId of move.dragIds) { + const path = findPathById(working, dragId); + if (!path) { + continue; + } + + const removed = removeItemByPath(working, path); + if (removed.removed) { + draggedNodes.push(removed.removed); + working = removed.next; + } + } + + if (!draggedNodes.length) { + return items; + } + + const parentPath = move.parentId ? findPathById(working, move.parentId) : []; + if (move.parentId && !parentPath) { + return working; + } + + return insertItemsAtPath(working, parentPath || [], move.index, draggedNodes); +} diff --git a/src/renderer/components/TabBar/TabBar.tsx b/src/renderer/components/TabBar/TabBar.tsx index f0c955b..8a128d6 100644 --- a/src/renderer/components/TabBar/TabBar.tsx +++ b/src/renderer/components/TabBar/TabBar.tsx @@ -64,6 +64,10 @@ const getTabTitle = ( return importDefTitles.get(tab.id) || tr('activity.import'); } + if (tab.type === 'menu-editor') { + return tr('menuEditor.tabTitle'); + } + if (tab.type === 'metadata-diff') { return tr('app.metadataDiff'); } @@ -129,6 +133,12 @@ const getTabIcon = (tab: Tab): React.ReactNode => { ); + case 'menu-editor': + return ( + + + + ); case 'metadata-diff': return ( diff --git a/src/renderer/i18n/locales/de.json b/src/renderer/i18n/locales/de.json index 3afeb2f..7e4f94d 100644 --- a/src/renderer/i18n/locales/de.json +++ b/src/renderer/i18n/locales/de.json @@ -41,6 +41,40 @@ "siteValidation.error.validate": "Website-Validierung fehlgeschlagen", "siteValidation.error.apply": "Anwenden der Validierung fehlgeschlagen", "siteValidation.toast.applySuccess": "Validierung angewendet: {rendered} gerendert, {deleted} gelöscht", + "menuEditor.tabTitle": "Blog-Menü", + "menuEditor.title": "Blog-Menü-Editor", + "menuEditor.description": "Verwalte die zentrale Blog-Navigationsstruktur und speichere sie in meta/menu.opml.", + "menuEditor.loading": "Menü wird geladen...", + "menuEditor.loadError": "Blog-Menü konnte nicht geladen werden", + "menuEditor.save": "Menü speichern", + "menuEditor.saving": "Speichern...", + "menuEditor.saved": "Blog-Menü gespeichert", + "menuEditor.saveFailed": "Blog-Menü konnte nicht gespeichert werden", + "menuEditor.pagePicker.title": "Seite auswählen", + "menuEditor.pagePicker.searchPlaceholder": "Seiten nach Titel oder Slug durchsuchen...", + "menuEditor.pagePicker.loading": "Seiten werden geladen...", + "menuEditor.pagePicker.empty": "Keine passenden Seiten gefunden.", + "menuEditor.pagePicker.loadError": "Seiten konnten nicht geladen werden", + "menuEditor.addPage": "Seite hinzufügen", + "menuEditor.addSubmenu": "Untermenü hinzufügen", + "menuEditor.addChildPage": "Unterseite hinzufügen", + "menuEditor.addChildSubmenu": "Unter-Untermenü hinzufügen", + "menuEditor.moveUp": "Nach oben", + "menuEditor.moveDown": "Nach unten", + "menuEditor.indent": "Einrücken", + "menuEditor.unindent": "Ausrücken", + "menuEditor.delete": "Löschen", + "menuEditor.details": "Eintragsdetails", + "menuEditor.selectItem": "Wähle einen Eintrag, um Details zu bearbeiten.", + "menuEditor.field.title": "Titel", + "menuEditor.field.type": "Typ", + "menuEditor.field.pageSlug": "Seiten-Slug", + "menuEditor.field.pageId": "Seiten-ID", + "menuEditor.type.page": "Seite", + "menuEditor.type.submenu": "Untermenü", + "menuEditor.empty": "Noch keine Menüeinträge. Füge eine Seite oder ein Untermenü hinzu.", + "menuEditor.newPage": "Neue Seite", + "menuEditor.newSubmenu": "Neues Untermenü", "settings.language.english": "Englisch", "settings.language.german": "Deutsch", "settings.language.french": "Französisch", diff --git a/src/renderer/i18n/locales/en.json b/src/renderer/i18n/locales/en.json index 728ad01..c64f142 100644 --- a/src/renderer/i18n/locales/en.json +++ b/src/renderer/i18n/locales/en.json @@ -41,6 +41,40 @@ "siteValidation.error.validate": "Site validation failed", "siteValidation.error.apply": "Applying validation failed", "siteValidation.toast.applySuccess": "Validation applied: {rendered} rendered, {deleted} deleted", + "menuEditor.tabTitle": "Blog Menu", + "menuEditor.title": "Blog Menu Editor", + "menuEditor.description": "Manage the central blog navigation outline and save it to meta/menu.opml.", + "menuEditor.loading": "Loading menu...", + "menuEditor.loadError": "Failed to load blog menu", + "menuEditor.save": "Save Menu", + "menuEditor.saving": "Saving...", + "menuEditor.saved": "Blog menu saved", + "menuEditor.saveFailed": "Failed to save blog menu", + "menuEditor.pagePicker.title": "Select Page", + "menuEditor.pagePicker.searchPlaceholder": "Search pages by title or slug...", + "menuEditor.pagePicker.loading": "Loading pages...", + "menuEditor.pagePicker.empty": "No matching pages found.", + "menuEditor.pagePicker.loadError": "Failed to load pages", + "menuEditor.addPage": "Add Page", + "menuEditor.addSubmenu": "Add Submenu", + "menuEditor.addChildPage": "Add Child Page", + "menuEditor.addChildSubmenu": "Add Child Submenu", + "menuEditor.moveUp": "Move Up", + "menuEditor.moveDown": "Move Down", + "menuEditor.indent": "Indent", + "menuEditor.unindent": "Unindent", + "menuEditor.delete": "Delete", + "menuEditor.details": "Entry Details", + "menuEditor.selectItem": "Select an entry to edit details.", + "menuEditor.field.title": "Title", + "menuEditor.field.type": "Type", + "menuEditor.field.pageSlug": "Page Slug", + "menuEditor.field.pageId": "Page ID", + "menuEditor.type.page": "Page", + "menuEditor.type.submenu": "Submenu", + "menuEditor.empty": "No menu entries yet. Add a page or submenu to start.", + "menuEditor.newPage": "New Page", + "menuEditor.newSubmenu": "New Submenu", "settings.language.english": "English", "settings.language.german": "German", "settings.language.french": "French", diff --git a/src/renderer/i18n/locales/es.json b/src/renderer/i18n/locales/es.json index c8856a4..c2edffe 100644 --- a/src/renderer/i18n/locales/es.json +++ b/src/renderer/i18n/locales/es.json @@ -41,6 +41,40 @@ "siteValidation.error.validate": "La validación del sitio falló", "siteValidation.error.apply": "La aplicación de la validación falló", "siteValidation.toast.applySuccess": "Validación aplicada: {rendered} renderizadas, {deleted} eliminadas", + "menuEditor.tabTitle": "Menú del blog", + "menuEditor.title": "Editor del menú del blog", + "menuEditor.description": "Gestiona la estructura central de navegación del blog y guárdala en meta/menu.opml.", + "menuEditor.loading": "Cargando menú...", + "menuEditor.loadError": "No se pudo cargar el menú del blog", + "menuEditor.save": "Guardar menú", + "menuEditor.saving": "Guardando...", + "menuEditor.saved": "Menú del blog guardado", + "menuEditor.saveFailed": "No se pudo guardar el menú del blog", + "menuEditor.pagePicker.title": "Seleccionar página", + "menuEditor.pagePicker.searchPlaceholder": "Buscar páginas por título o slug...", + "menuEditor.pagePicker.loading": "Cargando páginas...", + "menuEditor.pagePicker.empty": "No se encontraron páginas coincidentes.", + "menuEditor.pagePicker.loadError": "No se pudieron cargar las páginas", + "menuEditor.addPage": "Añadir página", + "menuEditor.addSubmenu": "Añadir submenú", + "menuEditor.addChildPage": "Añadir página hija", + "menuEditor.addChildSubmenu": "Añadir submenú hijo", + "menuEditor.moveUp": "Mover arriba", + "menuEditor.moveDown": "Mover abajo", + "menuEditor.indent": "Sangrar", + "menuEditor.unindent": "Quitar sangría", + "menuEditor.delete": "Eliminar", + "menuEditor.details": "Detalles de la entrada", + "menuEditor.selectItem": "Selecciona una entrada para editar sus detalles.", + "menuEditor.field.title": "Título", + "menuEditor.field.type": "Tipo", + "menuEditor.field.pageSlug": "Slug de página", + "menuEditor.field.pageId": "ID de página", + "menuEditor.type.page": "Página", + "menuEditor.type.submenu": "Submenú", + "menuEditor.empty": "Aún no hay entradas de menú. Añade una página o un submenú para empezar.", + "menuEditor.newPage": "Nueva página", + "menuEditor.newSubmenu": "Nuevo submenú", "settings.language.english": "Inglés", "settings.language.german": "Alemán", "settings.language.french": "Francés", diff --git a/src/renderer/i18n/locales/fr.json b/src/renderer/i18n/locales/fr.json index 697d5ce..dbb1d07 100644 --- a/src/renderer/i18n/locales/fr.json +++ b/src/renderer/i18n/locales/fr.json @@ -41,6 +41,40 @@ "siteValidation.error.validate": "Échec de la validation du site", "siteValidation.error.apply": "Échec de l’application de la validation", "siteValidation.toast.applySuccess": "Validation appliquée : {rendered} rendues, {deleted} supprimées", + "menuEditor.tabTitle": "Menu du blog", + "menuEditor.title": "Éditeur du menu du blog", + "menuEditor.description": "Gérez la structure centrale de navigation du blog et enregistrez-la dans meta/menu.opml.", + "menuEditor.loading": "Chargement du menu...", + "menuEditor.loadError": "Impossible de charger le menu du blog", + "menuEditor.save": "Enregistrer le menu", + "menuEditor.saving": "Enregistrement...", + "menuEditor.saved": "Menu du blog enregistré", + "menuEditor.saveFailed": "Impossible d’enregistrer le menu du blog", + "menuEditor.pagePicker.title": "Sélectionner une page", + "menuEditor.pagePicker.searchPlaceholder": "Rechercher des pages par titre ou slug...", + "menuEditor.pagePicker.loading": "Chargement des pages...", + "menuEditor.pagePicker.empty": "Aucune page correspondante trouvée.", + "menuEditor.pagePicker.loadError": "Impossible de charger les pages", + "menuEditor.addPage": "Ajouter une page", + "menuEditor.addSubmenu": "Ajouter un sous-menu", + "menuEditor.addChildPage": "Ajouter une page enfant", + "menuEditor.addChildSubmenu": "Ajouter un sous-menu enfant", + "menuEditor.moveUp": "Monter", + "menuEditor.moveDown": "Descendre", + "menuEditor.indent": "Indenter", + "menuEditor.unindent": "Désindenter", + "menuEditor.delete": "Supprimer", + "menuEditor.details": "Détails de l’entrée", + "menuEditor.selectItem": "Sélectionnez une entrée pour modifier ses détails.", + "menuEditor.field.title": "Titre", + "menuEditor.field.type": "Type", + "menuEditor.field.pageSlug": "Slug de page", + "menuEditor.field.pageId": "ID de page", + "menuEditor.type.page": "Page", + "menuEditor.type.submenu": "Sous-menu", + "menuEditor.empty": "Aucune entrée de menu. Ajoutez une page ou un sous-menu pour commencer.", + "menuEditor.newPage": "Nouvelle page", + "menuEditor.newSubmenu": "Nouveau sous-menu", "settings.language.english": "Anglais", "settings.language.german": "Allemand", "settings.language.french": "Français", diff --git a/src/renderer/i18n/locales/it.json b/src/renderer/i18n/locales/it.json index bf070f0..8a1e9a0 100644 --- a/src/renderer/i18n/locales/it.json +++ b/src/renderer/i18n/locales/it.json @@ -41,6 +41,40 @@ "siteValidation.error.validate": "Validazione del sito non riuscita", "siteValidation.error.apply": "Applicazione della validazione non riuscita", "siteValidation.toast.applySuccess": "Validazione applicata: {rendered} renderizzati, {deleted} eliminati", + "menuEditor.tabTitle": "Menu blog", + "menuEditor.title": "Editor del menu blog", + "menuEditor.description": "Gestisci la struttura centrale di navigazione del blog e salvala in meta/menu.opml.", + "menuEditor.loading": "Caricamento menu...", + "menuEditor.loadError": "Impossibile caricare il menu blog", + "menuEditor.save": "Salva menu", + "menuEditor.saving": "Salvataggio...", + "menuEditor.saved": "Menu blog salvato", + "menuEditor.saveFailed": "Impossibile salvare il menu blog", + "menuEditor.pagePicker.title": "Seleziona pagina", + "menuEditor.pagePicker.searchPlaceholder": "Cerca pagine per titolo o slug...", + "menuEditor.pagePicker.loading": "Caricamento pagine...", + "menuEditor.pagePicker.empty": "Nessuna pagina corrispondente trovata.", + "menuEditor.pagePicker.loadError": "Impossibile caricare le pagine", + "menuEditor.addPage": "Aggiungi pagina", + "menuEditor.addSubmenu": "Aggiungi sottomenu", + "menuEditor.addChildPage": "Aggiungi pagina figlia", + "menuEditor.addChildSubmenu": "Aggiungi sottomenu figlio", + "menuEditor.moveUp": "Sposta su", + "menuEditor.moveDown": "Sposta giù", + "menuEditor.indent": "Indenta", + "menuEditor.unindent": "Riduci rientro", + "menuEditor.delete": "Elimina", + "menuEditor.details": "Dettagli voce", + "menuEditor.selectItem": "Seleziona una voce per modificarne i dettagli.", + "menuEditor.field.title": "Titolo", + "menuEditor.field.type": "Tipo", + "menuEditor.field.pageSlug": "Slug pagina", + "menuEditor.field.pageId": "ID pagina", + "menuEditor.type.page": "Pagina", + "menuEditor.type.submenu": "Sottomenu", + "menuEditor.empty": "Nessuna voce menu. Aggiungi una pagina o un sottomenu per iniziare.", + "menuEditor.newPage": "Nuova pagina", + "menuEditor.newSubmenu": "Nuovo sottomenu", "settings.language.english": "Inglese", "settings.language.german": "Tedesco", "settings.language.french": "Francese", diff --git a/src/renderer/navigation/editorRouting.ts b/src/renderer/navigation/editorRouting.ts index 5812ef4..70b8a02 100644 --- a/src/renderer/navigation/editorRouting.ts +++ b/src/renderer/navigation/editorRouting.ts @@ -10,6 +10,7 @@ export type EditorRoute = | 'tags' | 'chat' | 'import' + | 'menu-editor' | 'metadata-diff' | 'git-diff' | 'documentation' @@ -23,6 +24,7 @@ export const EDITOR_TAB_ROUTE_REGISTRY: Record settings: { type: 'settings', id: 'settings', isTransient: false }, tags: { type: 'tags', id: 'tags', isTransient: false }, style: { type: 'style', id: 'style', isTransient: false }, + 'menu-editor': { type: 'menu-editor', id: 'menu-editor', isTransient: false }, documentation: { type: 'documentation', id: 'documentation', isTransient: false }, 'metadata-diff': { type: 'metadata-diff', id: 'metadata-diff', isTransient: false }, 'site-validation': { type: 'site-validation', id: 'site-validation', isTransient: false }, diff --git a/src/renderer/store/appStore.ts b/src/renderer/store/appStore.ts index f9fb28a..9644c43 100644 --- a/src/renderer/store/appStore.ts +++ b/src/renderer/store/appStore.ts @@ -13,7 +13,7 @@ import type { const STORAGE_KEY = 'bds-app-state'; // Tab types -export type TabType = 'post' | 'media' | 'settings' | 'style' | 'tags' | 'chat' | 'import' | 'metadata-diff' | 'git-diff' | 'documentation' | 'site-validation'; +export type TabType = 'post' | 'media' | 'settings' | 'style' | 'tags' | 'chat' | 'import' | 'menu-editor' | 'metadata-diff' | 'git-diff' | 'documentation' | 'site-validation'; export interface Tab { type: TabType; diff --git a/tests/engine/MenuEngine.test.ts b/tests/engine/MenuEngine.test.ts new file mode 100644 index 0000000..2109d1c --- /dev/null +++ b/tests/engine/MenuEngine.test.ts @@ -0,0 +1,110 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockFiles = new Map(); +const mockDirs = new Set(); + +const normalizePath = (value: string): string => value.replace(/\\/g, '/'); + +vi.mock('fs/promises', () => ({ + readFile: vi.fn(async (filePath: string) => { + const normalizedPath = normalizePath(filePath); + if (mockFiles.has(normalizedPath)) { + return mockFiles.get(normalizedPath); + } + + const err = new Error(`ENOENT: no such file or directory, open '${filePath}'`) as NodeJS.ErrnoException; + err.code = 'ENOENT'; + throw err; + }), + writeFile: vi.fn(async (filePath: string, content: string) => { + mockFiles.set(normalizePath(filePath), content); + }), + mkdir: vi.fn(async (dirPath: string) => { + mockDirs.add(normalizePath(dirPath)); + }), +})); + +vi.mock('electron', () => ({ + app: { + getPath: vi.fn(() => '/mock/userData'), + }, +})); + +import { MenuEngine } from '../../src/main/engine/MenuEngine'; + +describe('MenuEngine', () => { + let menuEngine: MenuEngine; + + beforeEach(() => { + vi.clearAllMocks(); + mockFiles.clear(); + mockDirs.clear(); + menuEngine = new MenuEngine(); + menuEngine.setProjectContext('project-1'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns an empty menu when no OPML file exists', async () => { + const result = await menuEngine.getMenu(); + + expect(result.items).toEqual([]); + }); + + it('parses nested OPML outlines into menu items', async () => { + const menuPath = normalizePath(`${menuEngine.getMetaDir()}/menu.opml`); + mockFiles.set( + menuPath, + `\n\n Blog Menu\n \n \n \n \n \n \n`, + ); + + const result = await menuEngine.getMenu(); + + expect(result.items).toHaveLength(2); + expect(result.items[0]).toMatchObject({ + id: 'home', + title: 'Home', + kind: 'page', + pageSlug: 'home', + }); + expect(result.items[1]).toMatchObject({ + id: 'docs', + title: 'Docs', + kind: 'submenu', + }); + expect(result.items[1].children[0]).toMatchObject({ + id: 'about', + title: 'About', + kind: 'page', + pageSlug: 'about', + }); + }); + + it('writes menu state as OPML and can read it back', async () => { + const saved = await menuEngine.saveMenu({ + items: [ + { + id: 'top', + title: 'Top', + kind: 'submenu', + children: [ + { + id: 'page-1', + title: 'First Page', + kind: 'page', + pageSlug: 'first-page', + children: [], + }, + ], + }, + ], + }); + + expect(saved.items[0].title).toBe('Top'); + + const roundTrip = await menuEngine.getMenu(); + expect(roundTrip).toEqual(saved); + }); +}); \ No newline at end of file diff --git a/tests/ipc/handlers.test.ts b/tests/ipc/handlers.test.ts index c14193a..f11a43c 100644 --- a/tests/ipc/handlers.test.ts +++ b/tests/ipc/handlers.test.ts @@ -136,6 +136,12 @@ const mockTagEngine = { searchTags: vi.fn(), }; +const mockMenuEngine = { + setProjectContext: vi.fn(), + getMenu: vi.fn(), + saveMenu: vi.fn(), +}; + const mockPostMediaEngine = { on: vi.fn(), setProjectContext: vi.fn(), @@ -245,6 +251,10 @@ vi.mock('../../src/main/engine/TagEngine', () => ({ getTagEngine: vi.fn(() => mockTagEngine), })); +vi.mock('../../src/main/engine/MenuEngine', () => ({ + getMenuEngine: vi.fn(() => mockMenuEngine), +})); + vi.mock('../../src/main/engine/PostMediaEngine', () => ({ getPostMediaEngine: vi.fn(() => mockPostMediaEngine), })); @@ -1252,6 +1262,51 @@ describe('IPC Handlers', () => { }); }); + // ============ Menu Handlers ============ + describe('Menu Handlers', () => { + describe('menu:get', () => { + it('loads menu for active project context', async () => { + const activeProject = createMockProject({ id: 'project-42', dataPath: '/custom/data' }); + const menuDocument = { + items: [ + { id: 'home', title: 'Home', kind: 'page', pageSlug: 'home', children: [] }, + ], + }; + + mockProjectEngine.getActiveProject.mockResolvedValue(activeProject); + mockProjectEngine.getDataDir.mockReturnValue('/resolved/project-data'); + mockMenuEngine.getMenu.mockResolvedValue(menuDocument); + + const result = await invokeHandler('menu:get'); + + expect(mockMenuEngine.setProjectContext).toHaveBeenCalledWith('project-42', '/resolved/project-data'); + expect(mockMenuEngine.getMenu).toHaveBeenCalled(); + expect(result).toEqual(menuDocument); + }); + }); + + describe('menu:save', () => { + it('saves menu for active project context', async () => { + const activeProject = createMockProject({ id: 'project-24', dataPath: '/custom/data' }); + const menuDocument = { + items: [ + { id: 'docs', title: 'Docs', kind: 'submenu', children: [] }, + ], + }; + + mockProjectEngine.getActiveProject.mockResolvedValue(activeProject); + mockProjectEngine.getDataDir.mockReturnValue('/resolved/project-data'); + mockMenuEngine.saveMenu.mockResolvedValue(menuDocument); + + const result = await invokeHandler('menu:save', menuDocument); + + expect(mockMenuEngine.setProjectContext).toHaveBeenCalledWith('project-24', '/resolved/project-data'); + expect(mockMenuEngine.saveMenu).toHaveBeenCalledWith(menuDocument); + expect(result).toEqual(menuDocument); + }); + }); + }); + // ============ Task Handlers ============ describe('Task Handlers', () => { describe('tasks:getAll', () => { diff --git a/tests/renderer/components/menuAutoExpand.test.ts b/tests/renderer/components/menuAutoExpand.test.ts new file mode 100644 index 0000000..b1ad3cf --- /dev/null +++ b/tests/renderer/components/menuAutoExpand.test.ts @@ -0,0 +1,63 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createAutoExpandController } from '../../../src/renderer/components/MenuEditorView/menuAutoExpand'; + +describe('createAutoExpandController', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('runs callback after configured delay', () => { + const controller = createAutoExpandController(300); + const callback = vi.fn(); + + controller.schedule('node-a', callback); + vi.advanceTimersByTime(299); + expect(callback).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('cancels scheduled callback for a node', () => { + const controller = createAutoExpandController(300); + const callback = vi.fn(); + + controller.schedule('node-a', callback); + controller.cancel('node-a'); + vi.runAllTimers(); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('replaces existing schedule for same node id', () => { + const controller = createAutoExpandController(300); + const first = vi.fn(); + const second = vi.fn(); + + controller.schedule('node-a', first); + controller.schedule('node-a', second); + vi.runAllTimers(); + + expect(first).not.toHaveBeenCalled(); + expect(second).toHaveBeenCalledTimes(1); + }); + + it('cancels all pending callbacks', () => { + const controller = createAutoExpandController(300); + const first = vi.fn(); + const second = vi.fn(); + + controller.schedule('node-a', first); + controller.schedule('node-b', second); + controller.cancelAll(); + vi.runAllTimers(); + + expect(first).not.toHaveBeenCalled(); + expect(second).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/renderer/components/menuPagePicker.test.ts b/tests/renderer/components/menuPagePicker.test.ts new file mode 100644 index 0000000..1962c5e --- /dev/null +++ b/tests/renderer/components/menuPagePicker.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; + +import type { PostData } from '../../../src/main/shared/electronApi'; +import { + createMenuPageItemFromPost, + filterPagePosts, + getNextPickerIndex, + isPickerCloseKey, + isPickerFocusShortcut, +} from '../../../src/renderer/components/MenuEditorView/menuPagePicker'; + +function createPost(overrides: Partial): PostData { + return { + id: 'post-1', + projectId: 'project-1', + title: 'Sample Page', + slug: 'sample-page', + content: '', + status: 'draft', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + tags: [], + categories: ['page'], + ...overrides, + }; +} + +describe('menuPagePicker', () => { + it('filters to page-category posts only', () => { + const posts = [ + createPost({ id: 'page-1', categories: ['page'] }), + createPost({ id: 'article-1', categories: ['article'] }), + ]; + + const result = filterPagePosts(posts, ''); + + expect(result.map((post) => post.id)).toEqual(['page-1']); + }); + + it('filters by title and slug using case-insensitive query', () => { + const posts = [ + createPost({ id: 'alpha', title: 'About Us', slug: 'about-us' }), + createPost({ id: 'beta', title: 'Imprint', slug: 'impressum' }), + ]; + + expect(filterPagePosts(posts, 'about').map((post) => post.id)).toEqual(['alpha']); + expect(filterPagePosts(posts, 'IMPRESS').map((post) => post.id)).toEqual(['beta']); + }); + + it('creates a menu page node with linked page metadata', () => { + const post = createPost({ + id: 'page-3', + title: 'Contact', + slug: 'contact', + categories: ['page'], + }); + + const item = createMenuPageItemFromPost(post); + + expect(item.kind).toBe('page'); + expect(item.title).toBe('Contact'); + expect(item.pageId).toBe('page-3'); + expect(item.pageSlug).toBe('contact'); + expect(item.children).toEqual([]); + expect(item.id.length).toBeGreaterThan(0); + }); + + it('moves active index with arrow navigation and wraps around', () => { + expect(getNextPickerIndex(-1, 'ArrowDown', 3)).toBe(0); + expect(getNextPickerIndex(0, 'ArrowDown', 3)).toBe(1); + expect(getNextPickerIndex(2, 'ArrowDown', 3)).toBe(0); + + expect(getNextPickerIndex(-1, 'ArrowUp', 3)).toBe(2); + expect(getNextPickerIndex(2, 'ArrowUp', 3)).toBe(1); + expect(getNextPickerIndex(0, 'ArrowUp', 3)).toBe(2); + }); + + it('returns -1 when there are no picker items', () => { + expect(getNextPickerIndex(-1, 'ArrowDown', 0)).toBe(-1); + expect(getNextPickerIndex(1, 'ArrowUp', 0)).toBe(-1); + }); + + it('detects escape as picker close key', () => { + expect(isPickerCloseKey('Escape')).toBe(true); + expect(isPickerCloseKey('Enter')).toBe(false); + }); + + it('detects cmd/ctrl+k as picker focus shortcut', () => { + expect(isPickerFocusShortcut({ key: 'k', metaKey: true, ctrlKey: false })).toBe(true); + expect(isPickerFocusShortcut({ key: 'K', metaKey: false, ctrlKey: true })).toBe(true); + expect(isPickerFocusShortcut({ key: 'k', metaKey: false, ctrlKey: false })).toBe(false); + expect(isPickerFocusShortcut({ key: 'p', metaKey: true, ctrlKey: false })).toBe(false); + }); +}); diff --git a/tests/renderer/components/menuTreeMove.test.ts b/tests/renderer/components/menuTreeMove.test.ts new file mode 100644 index 0000000..fb6ae87 --- /dev/null +++ b/tests/renderer/components/menuTreeMove.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; +import { applyTreeMove, type MenuTreeItem } from '../../../src/renderer/components/MenuEditorView/menuTreeMove'; + +function createTree(): MenuTreeItem[] { + return [ + { + id: 'home', + title: 'Home', + kind: 'page', + children: [], + }, + { + id: 'docs', + title: 'Docs', + kind: 'submenu', + children: [ + { + id: 'about', + title: 'About', + kind: 'page', + children: [], + }, + ], + }, + { + id: 'blog', + title: 'Blog', + kind: 'submenu', + children: [ + { + id: 'post-1', + title: 'Post 1', + kind: 'page', + children: [], + }, + { + id: 'post-2', + title: 'Post 2', + kind: 'page', + children: [], + }, + ], + }, + ]; +} + +describe('applyTreeMove', () => { + it('moves a page into a submenu', () => { + const moved = applyTreeMove(createTree(), { + dragIds: ['home'], + parentId: 'docs', + index: 1, + }); + + const docs = moved.find((item) => item.id === 'docs'); + expect(docs?.children.map((child) => child.id)).toEqual(['about', 'home']); + expect(moved.map((item) => item.id)).toEqual(['docs', 'blog']); + }); + + it('moves a whole subtree without losing children', () => { + const moved = applyTreeMove(createTree(), { + dragIds: ['blog'], + parentId: null, + index: 0, + }); + + expect(moved[0].id).toBe('blog'); + expect(moved[0].children.map((child) => child.id)).toEqual(['post-1', 'post-2']); + }); + + it('reorders siblings within same parent', () => { + const moved = applyTreeMove(createTree(), { + dragIds: ['post-2'], + parentId: 'blog', + index: 0, + }); + + const blog = moved.find((item) => item.id === 'blog'); + expect(blog?.children.map((child) => child.id)).toEqual(['post-2', 'post-1']); + }); +}); diff --git a/tests/renderer/menuCommands.test.ts b/tests/renderer/menuCommands.test.ts index e7a8aea..141a5c8 100644 --- a/tests/renderer/menuCommands.test.ts +++ b/tests/renderer/menuCommands.test.ts @@ -63,4 +63,15 @@ describe('Help menu documentation entry', () => { it('maps Edit Preferences to a renderer menu event', () => { expect(APP_MENU_ACTION_EVENT_MAP.editPreferences).toBe('menu:editPreferences'); }); + + it('includes Edit Menu action in Blog menu', () => { + const blogGroup = APP_MENU_GROUPS.find((group) => group.label === 'Blog'); + + expect(blogGroup).toBeDefined(); + expect(blogGroup?.items.some((item) => item.action === 'editMenu')).toBe(true); + }); + + it('maps Edit Menu to a renderer menu event', () => { + expect(APP_MENU_ACTION_EVENT_MAP.editMenu).toBe('menu:editMenu'); + }); }); diff --git a/tests/renderer/navigation/editorRouting.test.ts b/tests/renderer/navigation/editorRouting.test.ts index 86c85d8..1960f13 100644 --- a/tests/renderer/navigation/editorRouting.test.ts +++ b/tests/renderer/navigation/editorRouting.test.ts @@ -15,6 +15,7 @@ describe('editorRouting', () => { tags: 'tags', chat: 'chat', import: 'import', + 'menu-editor': 'menu-editor', 'metadata-diff': 'metadata-diff', 'git-diff': 'git-diff', documentation: 'documentation', diff --git a/tests/renderer/navigation/tabPolicy.test.ts b/tests/renderer/navigation/tabPolicy.test.ts index 4f19dcd..7d74889 100644 --- a/tests/renderer/navigation/tabPolicy.test.ts +++ b/tests/renderer/navigation/tabPolicy.test.ts @@ -20,6 +20,7 @@ describe('tabPolicy', () => { expect(getSingletonToolTabSpec('settings')).toEqual({ type: 'settings', id: 'settings', isTransient: false }); expect(getSingletonToolTabSpec('tags')).toEqual({ type: 'tags', id: 'tags', isTransient: false }); expect(getSingletonToolTabSpec('style')).toEqual({ type: 'style', id: 'style', isTransient: false }); + expect(getSingletonToolTabSpec('menu-editor')).toEqual({ type: 'menu-editor', id: 'menu-editor', isTransient: false }); expect(getSingletonToolTabSpec('documentation')).toEqual({ type: 'documentation', id: 'documentation', isTransient: false }); expect(getSingletonToolTabSpec('metadata-diff')).toEqual({ type: 'metadata-diff', id: 'metadata-diff', isTransient: false }); expect(getSingletonToolTabSpec('site-validation')).toEqual({ type: 'site-validation', id: 'site-validation', isTransient: false }); @@ -33,8 +34,9 @@ describe('tabPolicy', () => { let captured: { type: string; id: string; isTransient: boolean } | null = null; openSingletonToolTab(openTab, 'site-validation'); + openSingletonToolTab(openTab, 'menu-editor'); - expect(captured).toEqual({ type: 'site-validation', id: 'site-validation', isTransient: false }); + expect(captured).toEqual({ type: 'menu-editor', id: 'menu-editor', isTransient: false }); }); it('provides canonical entity tab spec for preview and pin intents', () => {