diff --git a/src/main/engine/MetaEngine.ts b/src/main/engine/MetaEngine.ts index 3ad79af..9ec4b7f 100644 --- a/src/main/engine/MetaEngine.ts +++ b/src/main/engine/MetaEngine.ts @@ -20,6 +20,40 @@ export interface ProjectMetadata { dataPath?: string; // Custom path for project data mainLanguage?: string; // Main language for AI-generated content (ISO code, e.g., 'en', 'de', 'es') defaultAuthor?: string; // Default author for new posts and media + maxPostsPerPage?: number; // Preview/server maximum posts per page (default 50) +} + +const DEFAULT_MAX_POSTS_PER_PAGE = 50; +const MIN_MAX_POSTS_PER_PAGE = 1; +const MAX_MAX_POSTS_PER_PAGE = 500; + +function sanitizeMaxPostsPerPage(value: unknown): number | undefined { + if (value === undefined || value === null || value === '') { + return undefined; + } + + const numeric = Number(value); + if (!Number.isFinite(numeric)) { + return DEFAULT_MAX_POSTS_PER_PAGE; + } + + const rounded = Math.floor(numeric); + if (rounded < MIN_MAX_POSTS_PER_PAGE) { + return DEFAULT_MAX_POSTS_PER_PAGE; + } + if (rounded > MAX_MAX_POSTS_PER_PAGE) { + return MAX_MAX_POSTS_PER_PAGE; + } + + return rounded; +} + +function normalizeProjectMetadata(metadata: ProjectMetadata): ProjectMetadata { + const maxPostsPerPage = sanitizeMaxPostsPerPage(metadata.maxPostsPerPage); + return { + ...metadata, + maxPostsPerPage, + }; } /** @@ -120,7 +154,7 @@ export class MetaEngine extends EventEmitter { * Set the project metadata (replaces existing). */ async setProjectMetadata(metadata: ProjectMetadata): Promise { - this.projectMetadata = { ...metadata }; + this.projectMetadata = normalizeProjectMetadata({ ...metadata }); await this.saveProjectMetadata(); this.emit('projectMetadataChanged', this.projectMetadata); } @@ -129,16 +163,25 @@ export class MetaEngine extends EventEmitter { * Update specific fields of project metadata. */ async updateProjectMetadata(updates: Partial): Promise { + const normalizedUpdates: Partial = { ...updates }; + if (updates.maxPostsPerPage !== undefined) { + normalizedUpdates.maxPostsPerPage = sanitizeMaxPostsPerPage(updates.maxPostsPerPage); + } + if (!this.projectMetadata) { - this.projectMetadata = { - name: updates.name || '', - description: updates.description, - }; + this.projectMetadata = normalizeProjectMetadata({ + name: normalizedUpdates.name || '', + description: normalizedUpdates.description, + dataPath: normalizedUpdates.dataPath, + mainLanguage: normalizedUpdates.mainLanguage, + defaultAuthor: normalizedUpdates.defaultAuthor, + maxPostsPerPage: normalizedUpdates.maxPostsPerPage, + }); } else { - this.projectMetadata = { + this.projectMetadata = normalizeProjectMetadata({ ...this.projectMetadata, - ...updates, - }; + ...normalizedUpdates, + }); } await this.saveProjectMetadata(); this.emit('projectMetadataChanged', this.projectMetadata); @@ -228,7 +271,7 @@ export class MetaEngine extends EventEmitter { const filePath = this.getProjectMetadataFilePath(); const content = await fs.readFile(filePath, 'utf-8'); const parsed = JSON.parse(content) as ProjectMetadata; - this.projectMetadata = parsed; + this.projectMetadata = normalizeProjectMetadata(parsed); } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { console.error('[MetaEngine] Failed to load project metadata:', error); @@ -423,6 +466,7 @@ export class MetaEngine extends EventEmitter { name: projectData.name, description: projectData.description || undefined, dataPath: projectData.dataPath || undefined, + maxPostsPerPage: DEFAULT_MAX_POSTS_PER_PAGE, }; await this.saveProjectMetadata(); } diff --git a/src/main/engine/PreviewServer.ts b/src/main/engine/PreviewServer.ts new file mode 100644 index 0000000..a767e01 --- /dev/null +++ b/src/main/engine/PreviewServer.ts @@ -0,0 +1,369 @@ +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'http'; +import { marked } from 'marked'; +import { getMetaEngine, type ProjectMetadata } from './MetaEngine'; +import { getPostEngine, type PostData, type PostFilter } from './PostEngine'; +import { getProjectEngine } from './ProjectEngine'; + +interface ActiveProjectContext { + projectId: string; + dataDir?: string; +} + +interface PostEngineContract { + getPostsFiltered: (filter: PostFilter) => Promise; + getPost: (id: string) => Promise; + setProjectContext: (projectId: string, dataDir?: string) => void; +} + +interface MetaEngineContract { + getProjectMetadata: () => Promise; + setProjectContext: (projectId: string, dataDir?: string) => void; +} + +interface PreviewServerDependencies { + postEngine: PostEngineContract; + settingsEngine: MetaEngineContract; + getActiveProjectContext: () => Promise; +} + +const DEFAULT_MAX_POSTS_PER_PAGE = 50; +const MIN_MAX_POSTS_PER_PAGE = 1; +const MAX_MAX_POSTS_PER_PAGE = 500; + +function clampMaxPostsPerPage(value: unknown): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return DEFAULT_MAX_POSTS_PER_PAGE; + } + + const normalized = Math.floor(value); + if (normalized < MIN_MAX_POSTS_PER_PAGE) return DEFAULT_MAX_POSTS_PER_PAGE; + if (normalized > MAX_MAX_POSTS_PER_PAGE) return MAX_MAX_POSTS_PER_PAGE; + return normalized; +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function parseMacroParams(paramString: string | undefined): Record { + if (!paramString) return {}; + + const params: Record = {}; + const regex = /(\w+)=(?:["']([^"']*?)["']|([^\s\]]+))/g; + let match: RegExpExecArray | null = null; + + while ((match = regex.exec(paramString)) !== null) { + params[match[1]] = match[2] !== undefined ? match[2] : match[3]; + } + + return params; +} + +function renderMacro(name: string, params: Record, postId: string): string { + if (name === 'youtube') { + const id = escapeHtml(params.id || ''); + const title = escapeHtml(params.title || 'YouTube video'); + if (!id) return ''; + return `
`; + } + + if (name === 'vimeo') { + const id = escapeHtml(params.id || ''); + const title = escapeHtml(params.title || 'Vimeo video'); + if (!id) return ''; + return `
`; + } + + if (name === 'gallery') { + const columns = escapeHtml(params.columns || '3'); + const caption = params.caption ? `
${escapeHtml(params.caption)}
` : ''; + return ``; + } + + if (name === 'photo_archive') { + const year = params.year ? ` data-year="${escapeHtml(params.year)}"` : ''; + const month = params.month ? ` data-month="${escapeHtml(params.month)}"` : ''; + return `
Photo archive preview is not interactive yet.
`; + } + + return ''; +} + +async function renderPostHtml(post: PostData): Promise { + const withMacros = post.content.replace(/\[\[(\w+)(?:\s+([^\]]+))?\]\]/g, (_match, macroName: string, rawParams: string | undefined) => { + const params = parseMacroParams(rawParams); + return renderMacro(macroName.toLowerCase(), params, post.id); + }); + + const markdownHtml = await marked.parse(withMacros, { async: true, gfm: true, breaks: false }); + return `
${markdownHtml}
`; +} + +function getPageHtml(content: string, title: string): string { + return ` + + + + + ${escapeHtml(title)} + + + + +
+ ${content} +
+ +`; +} + +export class PreviewServer { + private readonly postEngine: PostEngineContract; + private readonly settingsEngine: MetaEngineContract; + private readonly getActiveProjectContext: () => Promise; + private server: Server | null = null; + private port: number | null = null; + + constructor(dependencies?: Partial) { + this.postEngine = dependencies?.postEngine ?? getPostEngine(); + this.settingsEngine = dependencies?.settingsEngine ?? getMetaEngine(); + this.getActiveProjectContext = dependencies?.getActiveProjectContext ?? (async () => { + const projectEngine = getProjectEngine(); + const activeProject = await projectEngine.getActiveProject(); + const projectId = activeProject?.id ?? 'default'; + const dataDir = projectEngine.getDataDir(projectId, activeProject?.dataPath); + return { projectId, dataDir }; + }); + } + + async start(preferredPort = 0): Promise { + if (this.server && this.port !== null) { + return this.port; + } + + this.server = createServer(async (req, res) => { + await this.handleRequest(req, res); + }); + + await new Promise((resolve, reject) => { + if (!this.server) { + reject(new Error('Preview server was not created')); + return; + } + + this.server.once('error', reject); + this.server.listen(preferredPort, '127.0.0.1', () => { + this.server?.off('error', reject); + resolve(); + }); + }); + + const address = this.server.address(); + if (!address || typeof address === 'string') { + throw new Error('Failed to get preview server address'); + } + + this.port = address.port; + return this.port; + } + + async stop(): Promise { + if (!this.server) { + this.port = null; + return; + } + + await new Promise((resolve, reject) => { + if (!this.server) { + resolve(); + return; + } + this.server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + + this.server = null; + this.port = null; + } + + getBaseUrl(): string { + if (this.port === null) { + throw new Error('Preview server not started'); + } + return `http://127.0.0.1:${this.port}`; + } + + private async handleRequest(req: IncomingMessage, res: ServerResponse): Promise { + const remoteAddress = req.socket.remoteAddress; + const isLocal = remoteAddress === '127.0.0.1' + || remoteAddress === '::1' + || remoteAddress === '::ffff:127.0.0.1'; + + if (!isLocal) { + this.respond(res, 403, 'Forbidden'); + return; + } + + if ((req.method || 'GET').toUpperCase() !== 'GET') { + this.respond(res, 405, 'Method Not Allowed'); + return; + } + + try { + const context = await this.getActiveProjectContext(); + this.postEngine.setProjectContext(context.projectId, context.dataDir); + this.settingsEngine.setProjectContext(context.projectId, context.dataDir); + + const metadata = await this.settingsEngine.getProjectMetadata(); + const maxPostsPerPage = clampMaxPostsPerPage(metadata?.maxPostsPerPage); + + const requestUrl = new URL(req.url || '/', 'http://127.0.0.1'); + const pathname = decodeURIComponent(requestUrl.pathname.replace(/\/+$/, '') || '/'); + + const result = await this.resolveRoute(pathname, maxPostsPerPage); + if (!result) { + this.respond(res, 404, 'Not Found'); + return; + } + + this.respond(res, 200, getPageHtml(result, 'Blog Preview')); + } catch (error) { + console.error('[PreviewServer] Request failed:', error); + this.respond(res, 500, 'Internal Server Error'); + } + } + + private async resolveRoute(pathname: string, maxPostsPerPage: number): Promise { + if (pathname === '/') { + const posts = await this.loadPublishedPosts({ status: 'published' }, maxPostsPerPage); + return this.renderPostList(posts); + } + + const tagMatch = pathname.match(/^\/tag\/([^/]+)$/); + if (tagMatch) { + const tag = tagMatch[1]; + const posts = await this.loadPublishedPosts({ status: 'published', tags: [tag] }, maxPostsPerPage); + return this.renderPostList(posts); + } + + const categoryMatch = pathname.match(/^\/category\/([^/]+)$/); + if (categoryMatch) { + const category = categoryMatch[1]; + const posts = await this.loadPublishedPosts({ status: 'published', categories: [category] }, maxPostsPerPage); + return this.renderPostList(posts); + } + + const daySlugMatch = pathname.match(/^\/(\d{4})\/(\d{1,2})\/(\d{1,2})\/([^/]+)$/); + if (daySlugMatch) { + const year = Number(daySlugMatch[1]); + const month = Number(daySlugMatch[2]); + const day = Number(daySlugMatch[3]); + const slug = daySlugMatch[4]; + const posts = await this.loadPostsForDay(year, month, day, maxPostsPerPage); + const post = posts.find((candidate) => candidate.slug === slug) || null; + if (!post) return null; + return this.renderPostList([post]); + } + + const dayMatch = pathname.match(/^\/(\d{4})\/(\d{1,2})\/(\d{1,2})$/); + if (dayMatch) { + const year = Number(dayMatch[1]); + const month = Number(dayMatch[2]); + const day = Number(dayMatch[3]); + const posts = await this.loadPostsForDay(year, month, day, maxPostsPerPage); + return this.renderPostList(posts); + } + + const monthMatch = pathname.match(/^\/(\d{4})\/(\d{1,2})$/); + if (monthMatch) { + const year = Number(monthMatch[1]); + const month = Number(monthMatch[2]); + if (month < 1 || month > 12) return null; + const posts = await this.loadPublishedPosts({ status: 'published', year, month: month - 1 }, maxPostsPerPage); + return this.renderPostList(posts); + } + + const yearMatch = pathname.match(/^\/(\d{4})$/); + if (yearMatch) { + const year = Number(yearMatch[1]); + const posts = await this.loadPublishedPosts({ status: 'published', year }, maxPostsPerPage); + return this.renderPostList(posts); + } + + const pageSlugMatch = pathname.match(/^\/([^/]+)$/); + if (pageSlugMatch) { + const slug = pageSlugMatch[1]; + const pages = await this.loadPublishedPosts({ status: 'published', categories: ['page'] }, maxPostsPerPage); + const page = pages.find((candidate) => candidate.slug === slug) || null; + if (!page) return null; + return this.renderPostList([page]); + } + + return null; + } + + private async loadPostsForDay(year: number, month: number, day: number, maxPostsPerPage: number): Promise { + if (month < 1 || month > 12 || day < 1 || day > 31) { + return []; + } + + const startDate = new Date(year, month - 1, day, 0, 0, 0, 0); + const endDate = new Date(year, month - 1, day, 23, 59, 59, 999); + + const posts = await this.loadPublishedPosts({ + status: 'published', + startDate, + endDate, + }, maxPostsPerPage); + + return posts.filter((post) => { + const createdAt = post.createdAt; + return createdAt.getFullYear() === year + && createdAt.getMonth() === month - 1 + && createdAt.getDate() === day; + }); + } + + private async loadPublishedPosts(filter: PostFilter, maxPostsPerPage: number): Promise { + const posts = await this.postEngine.getPostsFiltered(filter); + const limited = posts.slice(0, maxPostsPerPage); + + const withContent = await Promise.all( + limited.map(async (post) => { + const fullPost = await this.postEngine.getPost(post.id); + return fullPost ?? post; + }) + ); + + return withContent; + } + + private async renderPostList(posts: PostData[]): Promise { + const rendered = await Promise.all(posts.map((post) => renderPostHtml(post))); + return rendered.join('\n'); + } + + private respond(res: ServerResponse, status: number, body: string): void { + res.statusCode = status; + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.setHeader('Cache-Control', 'no-store'); + res.end(body); + } +} diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index f95d8d5..034a0e5 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -699,7 +699,7 @@ export function registerIpcHandlers(): void { return engine.getProjectMetadata(); }); - safeHandle('meta:updateProjectMetadata', async (_, updates: { name?: string; description?: string; dataPath?: string; mainLanguage?: string }) => { + safeHandle('meta:updateProjectMetadata', async (_, updates: { name?: string; description?: string; dataPath?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number }) => { const engine = getMetaEngine(); await engine.updateProjectMetadata(updates); return engine.getProjectMetadata(); diff --git a/src/main/main.ts b/src/main/main.ts index 9ff0f3d..2bc0405 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, Menu, MenuItemConstructorOptions, ipcMain, protocol, net } from 'electron'; +import { app, BrowserWindow, Menu, MenuItemConstructorOptions, ipcMain, protocol, net, shell } from 'electron'; import * as path from 'path'; import * as fs from 'fs'; import { getDatabase } from './database'; @@ -6,8 +6,11 @@ import { registerIpcHandlers, registerChatHandlers, initializeChatHandlers, clea import { media } from './database/schema'; import { eq } from 'drizzle-orm'; import { getMediaEngine } from './engine/MediaEngine'; +import { PreviewServer } from './engine/PreviewServer'; let mainWindow: BrowserWindow | null = null; +let previewServer: PreviewServer | null = null; +const PREVIEW_SERVER_PORT = 4123; // Check if dev server is likely running (only in development) const isDev = process.env.NODE_ENV === 'development'; @@ -97,6 +100,15 @@ function createWindow(): void { }); } +async function openPreviewInBrowser(): Promise { + if (!previewServer) { + previewServer = new PreviewServer(); + } + + await previewServer.start(PREVIEW_SERVER_PORT); + await shell.openExternal(`${previewServer.getBaseUrl()}/`); +} + function createApplicationMenu(): Menu { const template: MenuItemConstructorOptions[] = [ { @@ -125,10 +137,20 @@ function createApplicationMenu(): Menu { }, }, { type: 'separator' }, + { + label: 'Open in Browser', + click: async () => { + try { + await openPreviewInBrowser(); + } catch (error) { + console.error('Failed to open preview in browser:', error); + } + }, + }, + { type: 'separator' }, { label: 'Open Data Folder', click: async () => { - const { shell } = require('electron'); const paths = getDatabase().getDataPaths(); shell.openPath(path.dirname(paths.database)); }, @@ -275,14 +297,12 @@ function createApplicationMenu(): Menu { { label: 'View on GitHub', click: async () => { - const { shell } = require('electron'); await shell.openExternal('https://github.com/rfc1437/bDS'); }, }, { label: 'Report Issue', click: async () => { - const { shell } = require('electron'); await shell.openExternal('https://github.com/rfc1437/bDS/issues'); }, }, @@ -444,6 +464,11 @@ app.on('before-quit', async () => { // Cleanup chat resources await cleanupChatHandlers(); + if (previewServer) { + await previewServer.stop(); + previewServer = null; + } + const db = getDatabase(); await db.close(); }); diff --git a/src/main/preload.ts b/src/main/preload.ts index ff19ec3..be5b667 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -153,7 +153,7 @@ export const electronAPI: ElectronAPI = { syncOnStartup: () => ipcRenderer.invoke('meta:syncOnStartup'), getProjectMetadata: () => ipcRenderer.invoke('meta:getProjectMetadata'), setProjectMetadata: (metadata: { name: string; description?: string }) => ipcRenderer.invoke('meta:setProjectMetadata', metadata), - updateProjectMetadata: (updates: { name?: string; description?: string; dataPath?: string; mainLanguage?: string }) => ipcRenderer.invoke('meta:updateProjectMetadata', updates), + updateProjectMetadata: (updates: { name?: string; description?: string; dataPath?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number }) => ipcRenderer.invoke('meta:updateProjectMetadata', updates), }, // Tag Management (advanced tag operations) diff --git a/src/main/shared/electronApi.ts b/src/main/shared/electronApi.ts index a9ca128..f6cb8fd 100644 --- a/src/main/shared/electronApi.ts +++ b/src/main/shared/electronApi.ts @@ -39,6 +39,8 @@ export interface ProjectMetadata { description?: string; dataPath?: string; mainLanguage?: string; + defaultAuthor?: string; + maxPostsPerPage?: number; } export interface ProjectData { @@ -517,7 +519,7 @@ export interface ElectronAPI { syncOnStartup: () => Promise<{ tags: string[]; categories: string[]; projectMetadata: ProjectMetadata | null }>; getProjectMetadata: () => Promise; setProjectMetadata: (metadata: { name: string; description?: string }) => Promise; - updateProjectMetadata: (updates: { name?: string; description?: string; dataPath?: string; mainLanguage?: string }) => Promise; + updateProjectMetadata: (updates: { name?: string; description?: string; dataPath?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number }) => Promise; }; tags: { getAll: () => Promise; diff --git a/src/renderer/components/SettingsView/SettingsView.tsx b/src/renderer/components/SettingsView/SettingsView.tsx index 435720c..03f52a6 100644 --- a/src/renderer/components/SettingsView/SettingsView.tsx +++ b/src/renderer/components/SettingsView/SettingsView.tsx @@ -110,6 +110,7 @@ export const SettingsView: React.FC = () => { const [defaultProjectPath, setDefaultProjectPath] = useState(''); const [projectMainLanguage, setProjectMainLanguage] = useState('en'); const [projectDefaultAuthor, setProjectDefaultAuthor] = useState(''); + const [projectMaxPostsPerPage, setProjectMaxPostsPerPage] = useState(50); // Post categories management const [postCategories, setPostCategories] = useState(DEFAULT_POST_CATEGORIES); @@ -154,6 +155,10 @@ export const SettingsView: React.FC = () => { } else { setProjectDefaultAuthor(''); } + const maxPostsPerPage = typeof metadata?.maxPostsPerPage === 'number' + ? metadata.maxPostsPerPage + : 50; + setProjectMaxPostsPerPage(maxPostsPerPage); }); } }, [activeProject]); @@ -253,6 +258,7 @@ export const SettingsView: React.FC = () => { dataPath: projectDataPath.trim() || undefined, mainLanguage: projectMainLanguage, defaultAuthor: projectDefaultAuthor.trim() || undefined, + maxPostsPerPage: Math.min(500, Math.max(1, Math.floor(projectMaxPostsPerPage || 50))), }); } showToast.success('Project settings saved'); @@ -274,7 +280,7 @@ export const SettingsView: React.FC = () => { }; // Keywords for each section for search filtering - const projectKeywords = ['project', 'name', 'description', 'blog', 'site', 'path', 'folder', 'location', 'data', 'language', 'author', 'default']; + const projectKeywords = ['project', 'name', 'description', 'blog', 'site', 'path', 'folder', 'location', 'data', 'language', 'author', 'default', 'preview', 'max', 'posts', 'page']; const editorKeywords = ['editor', 'mode', 'wysiwyg', 'markdown', 'preview', 'visual']; const contentKeywords = ['content', 'categories', 'post', 'article', 'picture', 'aside', 'page']; const aiKeywords = ['ai', 'assistant', 'chat', 'model', 'prompt', 'system', 'api', 'key', 'claude', 'gpt', 'opencode']; @@ -387,6 +393,28 @@ export const SettingsView: React.FC = () => { /> + + { + const parsed = Number(e.target.value); + if (!Number.isFinite(parsed)) { + setProjectMaxPostsPerPage(50); + return; + } + setProjectMaxPostsPerPage(Math.min(500, Math.max(1, Math.floor(parsed)))); + }} + /> + +