diff --git a/src/main/engine/BlogGenerationEngine.ts b/src/main/engine/BlogGenerationEngine.ts index 5c19c8a..2e8b334 100644 --- a/src/main/engine/BlogGenerationEngine.ts +++ b/src/main/engine/BlogGenerationEngine.ts @@ -33,11 +33,16 @@ export interface BlogGenerationOptions { language?: string; pageTitle?: string; picoTheme?: PicoThemeName; + categoryMetadata?: Record; categorySettings?: Record; menu?: MenuDocument; sections?: BlogGenerationSection[]; } +export interface CategoryMetadata extends CategoryRenderSettings { + title: string; +} + export type BlogGenerationSection = 'core' | 'single' | 'category' | 'tag' | 'date'; export interface BlogGenerationResult { @@ -105,6 +110,7 @@ function clampMaxPostsPerPage(value: unknown): number { } function resolveCategorySettings( + categoryMetadata: Record | undefined, value: Record | undefined, ): Record { const defaults: Record = { @@ -114,11 +120,20 @@ function resolveCategorySettings( page: { renderInLists: false, showTitle: true }, }; - if (!value) { - return defaults; + const merged = { ...defaults }; + if (categoryMetadata) { + for (const [category, metadata] of Object.entries(categoryMetadata)) { + merged[category] = { + renderInLists: metadata?.renderInLists !== false, + showTitle: metadata?.showTitle !== false, + }; + } + } + + if (!value) { + return merged; } - const merged = { ...defaults }; for (const [category, settings] of Object.entries(value)) { merged[category] = { renderInLists: settings?.renderInLists !== false, @@ -128,6 +143,15 @@ function resolveCategorySettings( return merged; } +function resolveCategoryDisplayTitle( + category: string, + categoryMetadata: Record | undefined, +): string { + const title = categoryMetadata?.[category]?.title; + const trimmed = typeof title === 'string' ? title.trim() : ''; + return trimmed.length > 0 ? trimmed : category; +} + function buildCanonicalPreviewPath(createdAt: Date, slug: string): string { const year = createdAt.getFullYear(); const month = String(createdAt.getMonth() + 1).padStart(2, '0'); @@ -332,7 +356,7 @@ export class BlogGenerationEngine { const includeTag = selectedSections.has('tag'); const includeDate = selectedSections.has('date'); - const categorySettings = resolveCategorySettings(options.categorySettings); + const categorySettings = resolveCategorySettings(options.categoryMetadata, options.categorySettings); const listExcludedCategories = Object.entries(categorySettings) .filter(([, settings]) => settings.renderInLists === false) .map(([category]) => category); @@ -658,7 +682,7 @@ export class BlogGenerationEngine { const pageContext = { page_title: pageTitle, language, - menu_items: buildTemplateMenuItems(options.menu), + menu_items: buildTemplateMenuItems(options.menu, options.categoryMetadata), pico_stylesheet_href: getPicoStylesheetHref(sanitizePicoTheme(options.picoTheme)), }; @@ -680,7 +704,7 @@ export class BlogGenerationEngine { if (includeCategory) { onProgress(50, 'Generating category pages...'); - pagesGenerated += await this.generateCategoryPages(options.projectId, publishedListPosts, allCategories, rewriteContext, maxPostsPerPage, htmlDir, pageContext, pageRenderer, categorySettings, reportUnitProgress); + pagesGenerated += await this.generateCategoryPages(options.projectId, publishedListPosts, allCategories, rewriteContext, maxPostsPerPage, htmlDir, pageContext, pageRenderer, categorySettings, options.categoryMetadata, reportUnitProgress); } if (includeTag) { @@ -723,7 +747,7 @@ export class BlogGenerationEngine { onProgress(0, 'Collecting sitemap URLs...'); const maxPostsPerPage = clampMaxPostsPerPage(options.maxPostsPerPage); - const categorySettings = resolveCategorySettings(options.categorySettings); + const categorySettings = resolveCategorySettings(options.categoryMetadata, options.categorySettings); const listExcludedCategories = Object.entries(categorySettings) .filter(([, settings]) => settings.renderInLists === false) .map(([category]) => category); @@ -1117,7 +1141,7 @@ export class BlogGenerationEngine { renderedUrlCount += generationResult.pagesGenerated; } } else { - const categorySettings = resolveCategorySettings(options.categorySettings); + const categorySettings = resolveCategorySettings(options.categoryMetadata, options.categorySettings); const listExcludedCategories = Object.entries(categorySettings) .filter(([, settings]) => settings.renderInLists === false) .map(([category]) => category); @@ -1274,7 +1298,7 @@ export class BlogGenerationEngine { const pageContext = { page_title: pageTitle, language, - menu_items: buildTemplateMenuItems(options.menu), + menu_items: buildTemplateMenuItems(options.menu, options.categoryMetadata), pico_stylesheet_href: getPicoStylesheetHref(sanitizePicoTheme(options.picoTheme)), }; const pageRenderer = new PageRenderer(this.mediaEngine, this.postMediaEngine, this.postEngine); @@ -1367,6 +1391,7 @@ export class BlogGenerationEngine { pageContext, pageRenderer, categorySettings, + options.categoryMetadata, onPageGenerated, ); } @@ -1560,6 +1585,7 @@ export class BlogGenerationEngine { pageContext: { page_title: string; language: string; menu_items?: TemplateMenuItem[]; pico_stylesheet_href?: string }, pageRenderer: PageRenderer, categorySettings: Record, + categoryMetadata: Record | undefined, onPageGenerated: (message: string) => void, ): Promise { let count = 0; @@ -1568,6 +1594,8 @@ export class BlogGenerationEngine { const categoryPosts = posts.filter((post) => (post.categories || []).includes(category)); if (categoryPosts.length === 0) continue; + const categoryDisplayTitle = resolveCategoryDisplayTitle(category, categoryMetadata); + const totalPages = Math.max(1, Math.ceil(categoryPosts.length / maxPostsPerPage)); const encodedCategory = encodeURIComponent(category); const basePathname = `/category/${encodedCategory}`; @@ -1580,7 +1608,7 @@ export class BlogGenerationEngine { const html = await pageRenderer.renderPostList(pagePosts, rewriteContext, { archiveGrouping: true, routeKind: 'non-date', - archiveContext: { kind: 'category', name: category }, + archiveContext: { kind: 'category', name: categoryDisplayTitle }, basePathname, pagination: { page, maxPostsPerPage, totalPosts: categoryPosts.length }, categorySettings, diff --git a/src/main/engine/MetaEngine.ts b/src/main/engine/MetaEngine.ts index e8a0fd1..9445f64 100644 --- a/src/main/engine/MetaEngine.ts +++ b/src/main/engine/MetaEngine.ts @@ -24,6 +24,7 @@ export interface ProjectMetadata { defaultAuthor?: string; // Default author for new posts and media maxPostsPerPage?: number; // Preview/server maximum posts per page (default 50) picoTheme?: PicoThemeName; // Selected Pico CSS theme for preview/rendering + categoryMetadata?: Record; // Per-category metadata for UI/rendering categorySettings?: Record; // Per-category list rendering preferences } @@ -32,6 +33,10 @@ export interface CategoryRenderSettings { showTitle: boolean; } +export interface CategoryMetadata extends CategoryRenderSettings { + title: string; +} + const DEFAULT_MAX_POSTS_PER_PAGE = 50; const MIN_MAX_POSTS_PER_PAGE = 1; const MAX_MAX_POSTS_PER_PAGE = 500; @@ -66,17 +71,25 @@ function sanitizePublicUrl(value: unknown): string | undefined { return trimmed.length > 0 ? trimmed : undefined; } +function sanitizeCategoryTitle(value: unknown, fallback: string): string { + const trimmed = typeof value === 'string' ? value.trim() : ''; + return trimmed.length > 0 ? trimmed : fallback; +} + +type RawCategoryMetadataInput = Record; + function normalizeProjectMetadata(metadata: ProjectMetadata): ProjectMetadata { const maxPostsPerPage = sanitizeMaxPostsPerPage(metadata.maxPostsPerPage); const publicUrl = sanitizePublicUrl(metadata.publicUrl); const picoTheme = sanitizePicoTheme(metadata.picoTheme); - const categorySettings = normalizeCategorySettings(metadata.categorySettings); + const categoryMetadata = normalizeCategoryMetadata(metadata.categoryMetadata ?? metadata.categorySettings); return { ...metadata, publicUrl, maxPostsPerPage, picoTheme, - categorySettings, + categoryMetadata, + categorySettings: undefined, }; } @@ -86,37 +99,62 @@ function normalizeProjectMetadata(metadata: ProjectMetadata): ProjectMetadata { export const DEFAULT_CATEGORIES = ['article', 'picture', 'aside', 'page']; export function getDefaultCategorySettings(): Record { + const defaults = getDefaultCategoryMetadata(); + return Object.fromEntries( + Object.entries(defaults).map(([category, value]) => [ + category, + { renderInLists: value.renderInLists, showTitle: value.showTitle }, + ]), + ); +} + +export function getDefaultCategoryMetadata(): Record { return { - article: { renderInLists: true, showTitle: true }, - picture: { renderInLists: true, showTitle: true }, - aside: { renderInLists: true, showTitle: false }, - page: { renderInLists: false, showTitle: true }, + article: { renderInLists: true, showTitle: true, title: 'article' }, + picture: { renderInLists: true, showTitle: true, title: 'picture' }, + aside: { renderInLists: true, showTitle: false, title: 'aside' }, + page: { renderInLists: false, showTitle: true, title: 'page' }, }; } -function normalizeCategorySettings(value: unknown): Record { - const defaults = getDefaultCategorySettings(); +function normalizeCategoryMetadata(value: unknown): Record { + const defaults = getDefaultCategoryMetadata(); if (!value || typeof value !== 'object') { return defaults; } - const normalized: Record = { ...defaults }; - for (const [rawCategory, rawSettings] of Object.entries(value as Record)) { + const normalized: Record = { ...defaults }; + for (const [rawCategory, rawSettings] of Object.entries(value as RawCategoryMetadataInput)) { const category = normalizeTaxonomyTerm(rawCategory); if (!category || !rawSettings || typeof rawSettings !== 'object') { continue; } - const settings = rawSettings as Record; + const settings = rawSettings as unknown as { + renderInLists?: unknown; + showTitle?: unknown; + title?: unknown; + }; normalized[category] = { renderInLists: settings.renderInLists !== false, showTitle: settings.showTitle !== false, + title: sanitizeCategoryTitle(settings.title, category), }; } return normalized; } +function normalizeCategorySettings(value: unknown): Record { + const metadata = normalizeCategoryMetadata(value); + return Object.fromEntries( + Object.entries(metadata).map(([category, data]) => [ + category, + { renderInLists: data.renderInLists, showTitle: data.showTitle }, + ]), + ); +} + /** * MetaEngine manages project metadata like available tags and categories. * @@ -171,6 +209,10 @@ export class MetaEngine extends EventEmitter { return path.join(this.getMetaDir(), 'project.json'); } + private getCategoryMetadataFilePath(): string { + return path.join(this.getMetaDir(), 'category-meta.json'); + } + setProjectContext(projectId: string, dataDir?: string): void { this.currentProjectId = projectId; this.dataDir = dataDir || null; @@ -211,7 +253,11 @@ export class MetaEngine extends EventEmitter { */ async setProjectMetadata(metadata: ProjectMetadata): Promise { this.projectMetadata = normalizeProjectMetadata({ ...metadata }); + this.projectMetadata.categoryMetadata = this.ensureCategoryMetadataForKnownCategories( + this.projectMetadata.categoryMetadata, + ); await this.saveProjectMetadata(); + await this.saveCategoryMetadata(); this.emit('projectMetadataChanged', this.projectMetadata); } @@ -227,6 +273,13 @@ export class MetaEngine extends EventEmitter { normalizedUpdates.picoTheme = sanitizePicoTheme(updates.picoTheme); } + if (updates.categoryMetadata !== undefined || updates.categorySettings !== undefined) { + normalizedUpdates.categoryMetadata = normalizeCategoryMetadata( + updates.categoryMetadata ?? updates.categorySettings, + ); + normalizedUpdates.categorySettings = undefined; + } + if (!this.projectMetadata) { this.projectMetadata = normalizeProjectMetadata({ name: normalizedUpdates.name || '', @@ -237,6 +290,7 @@ export class MetaEngine extends EventEmitter { defaultAuthor: normalizedUpdates.defaultAuthor, maxPostsPerPage: normalizedUpdates.maxPostsPerPage, picoTheme: normalizedUpdates.picoTheme, + categoryMetadata: normalizedUpdates.categoryMetadata, }); } else { this.projectMetadata = normalizeProjectMetadata({ @@ -244,7 +298,11 @@ export class MetaEngine extends EventEmitter { ...normalizedUpdates, }); } + this.projectMetadata.categoryMetadata = this.ensureCategoryMetadataForKnownCategories( + this.projectMetadata.categoryMetadata, + ); await this.saveProjectMetadata(); + await this.saveCategoryMetadata(); this.emit('projectMetadataChanged', this.projectMetadata); } @@ -280,17 +338,19 @@ export class MetaEngine extends EventEmitter { this.categories.add(normalizedCategory); const currentMetadata = this.projectMetadata; if (currentMetadata) { - const currentSettings = normalizeCategorySettings(currentMetadata.categorySettings); - if (!currentSettings[normalizedCategory]) { - currentSettings[normalizedCategory] = { + const currentCategoryMetadata = normalizeCategoryMetadata(currentMetadata.categoryMetadata ?? currentMetadata.categorySettings); + if (!currentCategoryMetadata[normalizedCategory]) { + currentCategoryMetadata[normalizedCategory] = { renderInLists: true, showTitle: true, + title: normalizedCategory, }; this.projectMetadata = normalizeProjectMetadata({ ...currentMetadata, - categorySettings: currentSettings, + categoryMetadata: currentCategoryMetadata, }); await this.saveProjectMetadata(); + await this.saveCategoryMetadata(); } } this.emit('categoriesChanged', await this.getCategories()); @@ -305,14 +365,18 @@ export class MetaEngine extends EventEmitter { const normalizedCategory = normalizeTaxonomyTerm(category); if (this.categories.delete(normalizedCategory)) { const currentMetadata = this.projectMetadata; - if (currentMetadata?.categorySettings?.[normalizedCategory]) { - const nextSettings = { ...currentMetadata.categorySettings }; - delete nextSettings[normalizedCategory]; + const currentCategoryMetadata = currentMetadata + ? normalizeCategoryMetadata(currentMetadata.categoryMetadata ?? currentMetadata.categorySettings) + : null; + if (currentMetadata && currentCategoryMetadata?.[normalizedCategory]) { + const nextCategoryMetadata = { ...currentCategoryMetadata }; + delete nextCategoryMetadata[normalizedCategory]; this.projectMetadata = normalizeProjectMetadata({ ...currentMetadata, - categorySettings: nextSettings, + categoryMetadata: nextCategoryMetadata, }); await this.saveProjectMetadata(); + await this.saveCategoryMetadata(); } this.emit('categoriesChanged', await this.getCategories()); await this.saveCategories(); @@ -341,7 +405,12 @@ export class MetaEngine extends EventEmitter { try { await this.ensureMetaDirExists(); const filePath = this.getProjectMetadataFilePath(); - const { dataPath: _dataPath, ...persistedMetadata } = this.projectMetadata || {}; + const { + dataPath: _dataPath, + categoryMetadata: _categoryMetadata, + categorySettings: _categorySettings, + ...persistedMetadata + } = this.projectMetadata || {}; const content = JSON.stringify(persistedMetadata, null, 2); await fs.writeFile(filePath, content, 'utf-8'); } catch (error) { @@ -350,6 +419,24 @@ export class MetaEngine extends EventEmitter { } } + /** + * Save category metadata to the filesystem. + */ + async saveCategoryMetadata(): Promise { + try { + await this.ensureMetaDirExists(); + const filePath = this.getCategoryMetadataFilePath(); + const metadata = this.ensureCategoryMetadataForKnownCategories( + this.projectMetadata?.categoryMetadata, + ); + const content = JSON.stringify(metadata, null, 2); + await fs.writeFile(filePath, content, 'utf-8'); + } catch (error) { + console.error('[MetaEngine] Failed to save category metadata:', error); + throw error; + } + } + /** * Load project metadata from the filesystem. */ @@ -369,6 +456,24 @@ export class MetaEngine extends EventEmitter { } } + /** + * Load category metadata from the filesystem. + */ + async loadCategoryMetadata(): Promise | null> { + try { + const filePath = this.getCategoryMetadataFilePath(); + const content = await fs.readFile(filePath, 'utf-8'); + const parsed = JSON.parse(content) as Record; + return normalizeCategoryMetadata(parsed); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + console.error('[MetaEngine] Failed to load category metadata:', error); + throw error; + } + return null; + } + } + /** * Load categories from the filesystem. */ @@ -459,6 +564,26 @@ export class MetaEngine extends EventEmitter { } } + private ensureCategoryMetadataForKnownCategories( + categoryMetadata: Record | undefined, + ): Record { + const merged = normalizeCategoryMetadata(categoryMetadata); + + for (const category of this.categories) { + if (!merged[category]) { + merged[category] = { + renderInLists: true, + showTitle: true, + title: category, + }; + } else if (!merged[category].title || merged[category].title.trim().length === 0) { + merged[category].title = category; + } + } + + return merged; + } + /** * Sync tags and categories on startup. * @@ -474,9 +599,11 @@ export class MetaEngine extends EventEmitter { const categoriesFilePath = this.getCategoriesFilePath(); const projectMetadataFilePath = this.getProjectMetadataFilePath(); + const categoryMetadataFilePath = this.getCategoryMetadataFilePath(); const categoriesFileExists = await this.fileExists(categoriesFilePath); const projectMetadataFileExists = await this.fileExists(projectMetadataFilePath); + const categoryMetadataFileExists = await this.fileExists(categoryMetadataFilePath); // Collect tags/categories from database (posts) const dbTags = await this.collectTagsFromPosts(); @@ -542,29 +669,28 @@ export class MetaEngine extends EventEmitter { name: projectData.name, description: projectData.description || undefined, maxPostsPerPage: DEFAULT_MAX_POSTS_PER_PAGE, - categorySettings: getDefaultCategorySettings(), }; await this.saveProjectMetadata(); } if (this.projectMetadata) { - const mergedSettings = normalizeCategorySettings(this.projectMetadata.categorySettings); - let metadataChanged = false; + const legacyCategoryMetadata = normalizeCategoryMetadata( + this.projectMetadata.categoryMetadata ?? this.projectMetadata.categorySettings, + ); + const fileCategoryMetadata = categoryMetadataFileExists + ? await this.loadCategoryMetadata() + : null; + const mergedCategoryMetadata = this.ensureCategoryMetadataForKnownCategories( + fileCategoryMetadata ?? legacyCategoryMetadata, + ); - for (const category of this.categories) { - if (!mergedSettings[category]) { - mergedSettings[category] = { renderInLists: true, showTitle: true }; - metadataChanged = true; - } - } + this.projectMetadata = normalizeProjectMetadata({ + ...this.projectMetadata, + categoryMetadata: mergedCategoryMetadata, + }); - if (metadataChanged) { - this.projectMetadata = normalizeProjectMetadata({ - ...this.projectMetadata, - categorySettings: mergedSettings, - }); - await this.saveProjectMetadata(); - } + await this.saveProjectMetadata(); + await this.saveCategoryMetadata(); } this.initialized = true; diff --git a/src/main/engine/PageRenderer.ts b/src/main/engine/PageRenderer.ts index 4e7c607..b0b5d0d 100644 --- a/src/main/engine/PageRenderer.ts +++ b/src/main/engine/PageRenderer.ts @@ -294,23 +294,47 @@ function buildMenuItemHref(item: MenuItemData): string { return '#'; } -function toTemplateMenuItem(item: MenuItemData): TemplateMenuItem { - const children = (Array.isArray(item.children) ? item.children : []).map((child) => toTemplateMenuItem(child)); +function resolveMenuItemTitle( + item: MenuItemData, + categoryMetadata?: Record, +): string { + if (item.kind === 'category-archive') { + const categoryName = (item.categoryName || '').trim(); + const metadataTitle = categoryName.length > 0 + ? (categoryMetadata?.[categoryName]?.title || '').trim() + : ''; + + if (metadataTitle.length > 0) { + return metadataTitle; + } + } + + return item.title; +} + +function toTemplateMenuItem( + item: MenuItemData, + categoryMetadata?: Record, +): TemplateMenuItem { + const children = (Array.isArray(item.children) ? item.children : []).map((child) => toTemplateMenuItem(child, categoryMetadata)); return { - title: item.title, + title: resolveMenuItemTitle(item, categoryMetadata), href: buildMenuItemHref(item), has_children: children.length > 0, children, }; } -export function buildTemplateMenuItems(menu: MenuDocument | null | undefined): TemplateMenuItem[] { +export function buildTemplateMenuItems( + menu: MenuDocument | null | undefined, + categoryMetadata?: Record, +): TemplateMenuItem[] { const items = menu?.items; if (!Array.isArray(items)) { return []; } - return items.map((item) => toTemplateMenuItem(item)); + return items.map((item) => toTemplateMenuItem(item, categoryMetadata)); } export function normalizeMacroName(name: string): string { diff --git a/src/main/engine/PreviewServer.ts b/src/main/engine/PreviewServer.ts index 31c03f3..ee01619 100644 --- a/src/main/engine/PreviewServer.ts +++ b/src/main/engine/PreviewServer.ts @@ -1,7 +1,7 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'http'; import { readFile } from 'node:fs/promises'; import path from 'node:path'; -import { getMetaEngine, type ProjectMetadata } from './MetaEngine'; +import { getMetaEngine, type CategoryMetadata, type ProjectMetadata } from './MetaEngine'; import { getMediaEngine, type MediaData } from './MediaEngine'; import { getMenuEngine, type MenuDocument } from './MenuEngine'; import { getPostMediaEngine } from './PostMediaEngine'; @@ -182,8 +182,9 @@ export class PreviewServer { } const metadata = await this.settingsEngine.getProjectMetadata(); + const categoryMetadata = this.resolveCategoryMetadata(metadata); const menu = await this.menuEngine.getMenu().catch(() => ({ items: [] })); - const menuItems = buildTemplateMenuItems(menu); + const menuItems = buildTemplateMenuItems(menu, categoryMetadata); const categorySettings = this.resolveCategorySettings(metadata); const listExcludedCategories = this.resolveListExcludedCategories(categorySettings); const language = metadata?.mainLanguage?.trim() || 'en'; @@ -235,7 +236,7 @@ export class PreviewServer { menuItems, picoStylesheetHref, htmlThemeAttribute: undefined, - }, categorySettings, listExcludedCategories, { + }, categorySettings, categoryMetadata, listExcludedCategories, { useDraftContent, draftPostId, }); @@ -264,6 +265,7 @@ export class PreviewServer { rewriteContext: HtmlRewriteContext, pageContext: { pageTitle: string; language: string; menuItems: ReturnType; picoStylesheetHref: string; htmlThemeAttribute?: string }, categorySettings: Record, + categoryMetadata: Record, listExcludedCategories: string[], singlePostOptions?: { useDraftContent?: boolean; draftPostId?: string }, ): Promise { @@ -380,11 +382,12 @@ export class PreviewServer { const categoryMatch = pagedPathname.match(/^\/category\/([^/]+)$/); if (categoryMatch) { const category = categoryMatch[1]; + const categoryDisplayTitle = categoryMetadata[category]?.title?.trim() || category; const result = await this.loadPublishedSnapshotsPage({ status: 'published', categories: [category], excludeCategories: listExcludedCategories }, pageOptions); return this.pageRenderer.renderPostList(result.posts, rewriteContext, { archiveGrouping: true, routeKind: 'non-date', - archiveContext: { kind: 'category', name: category }, + archiveContext: { kind: 'category', name: categoryDisplayTitle }, basePathname: pagedPathname, pagination: { page, maxPostsPerPage, totalPosts: result.totalPosts }, categorySettings, @@ -840,32 +843,51 @@ export class PreviewServer { } } - private resolveCategorySettings(metadata: ProjectMetadata | null): Record { - const defaults: Record = { - article: { renderInLists: true, showTitle: true }, - picture: { renderInLists: true, showTitle: true }, - aside: { renderInLists: true, showTitle: false }, - page: { renderInLists: false, showTitle: true }, + private resolveCategoryMetadata(metadata: ProjectMetadata | null): Record { + const defaults: Record = { + article: { renderInLists: true, showTitle: true, title: 'article' }, + picture: { renderInLists: true, showTitle: true, title: 'picture' }, + aside: { renderInLists: true, showTitle: false, title: 'aside' }, + page: { renderInLists: false, showTitle: true, title: 'page' }, }; - const rawSettings = (metadata as { categorySettings?: unknown } | null)?.categorySettings; - if (!rawSettings || typeof rawSettings !== 'object') { + const rawMetadata = (metadata as { categoryMetadata?: unknown } | null)?.categoryMetadata; + const rawLegacySettings = (metadata as { categorySettings?: unknown } | null)?.categorySettings; + const source = rawMetadata && typeof rawMetadata === 'object' ? rawMetadata : rawLegacySettings; + + if (!source || typeof source !== 'object') { return defaults; } - const mergedSettings: Record = { ...defaults }; - for (const [category, rawValue] of Object.entries(rawSettings as Record)) { + const merged: Record = { ...defaults }; + for (const [category, rawValue] of Object.entries(source as Record)) { if (!rawValue || typeof rawValue !== 'object') { continue; } const typedRawValue = rawValue as Record; - mergedSettings[category] = { + const title = typeof typedRawValue.title === 'string' && typedRawValue.title.trim().length > 0 + ? typedRawValue.title.trim() + : category; + merged[category] = { renderInLists: typedRawValue.renderInLists !== false, showTitle: typedRawValue.showTitle !== false, + title, }; } + return merged; + } + + private resolveCategorySettings(metadata: ProjectMetadata | null): Record { + const categoryMetadata = this.resolveCategoryMetadata(metadata); + const mergedSettings: Record = {}; + for (const [category, value] of Object.entries(categoryMetadata)) { + mergedSettings[category] = { + renderInLists: value.renderInLists, + showTitle: value.showTitle, + }; + } return mergedSettings; } diff --git a/src/main/ipc/blogHandlers.ts b/src/main/ipc/blogHandlers.ts index c265466..7bdfd09 100644 --- a/src/main/ipc/blogHandlers.ts +++ b/src/main/ipc/blogHandlers.ts @@ -69,6 +69,7 @@ export function registerBlogHandlers(safeHandle: SafeHandle): void { language, pageTitle, picoTheme: metadata?.picoTheme, + categoryMetadata: (metadata as any)?.categoryMetadata, categorySettings: (metadata as any)?.categorySettings, menu, }; diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 67dcbff..0f7456c 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -402,11 +402,15 @@ export function registerIpcHandlers(): void { const projectEngine = getProjectEngine(); const project = await projectEngine.getActiveProject(); const engine = getPostEngine(); + const metaEngine = getMetaEngine(); if (project) { const dataDir = projectEngine.getDataDir(project.id, project.dataPath); engine.setProjectContext(project.id, dataDir); + metaEngine.setProjectContext(project.id, dataDir); } - return engine.rebuildDatabaseFromFiles(); + await engine.rebuildDatabaseFromFiles(); + await metaEngine.syncOnStartup(); + return true; }); safeHandle('posts:search', async (_, query: string) => { @@ -818,6 +822,24 @@ export function registerIpcHandlers(): void { // ============ Meta Handlers ============ + const ensureMetaContext = async (engine: ReturnType) => { + const projectEngine = getProjectEngine(); + const activeProject = await projectEngine.getActiveProject(); + if (!activeProject) { + return; + } + + const dataDir = projectEngine.getDataDir(activeProject.id, activeProject.dataPath); + engine.setProjectContext(activeProject.id, dataDir); + }; + + const ensureMetaReady = async (engine: ReturnType) => { + await ensureMetaContext(engine); + if (!engine.isInitialized()) { + await engine.syncOnStartup(); + } + }; + safeHandle('menu:get', async () => { const projectEngine = getProjectEngine(); const menuEngine = getMenuEngine(); @@ -848,40 +870,47 @@ export function registerIpcHandlers(): void { safeHandle('meta:getTags', async () => { const engine = getMetaEngine(); + await ensureMetaReady(engine); return engine.getTags(); }); safeHandle('meta:getCategories', async () => { const engine = getMetaEngine(); + await ensureMetaReady(engine); return engine.getCategories(); }); safeHandle('meta:addTag', async (_, tag: string) => { const engine = getMetaEngine(); + await ensureMetaReady(engine); await engine.addTag(tag); return engine.getTags(); }); safeHandle('meta:removeTag', async (_, tag: string) => { const engine = getMetaEngine(); + await ensureMetaReady(engine); await engine.removeTag(tag); return engine.getTags(); }); safeHandle('meta:addCategory', async (_, category: string) => { const engine = getMetaEngine(); + await ensureMetaReady(engine); await engine.addCategory(category); return engine.getCategories(); }); safeHandle('meta:removeCategory', async (_, category: string) => { const engine = getMetaEngine(); + await ensureMetaReady(engine); await engine.removeCategory(category); return engine.getCategories(); }); safeHandle('meta:syncOnStartup', async () => { const engine = getMetaEngine(); + await ensureMetaContext(engine); await engine.syncOnStartup(); return { tags: await engine.getTags(), @@ -892,20 +921,20 @@ export function registerIpcHandlers(): void { safeHandle('meta:getProjectMetadata', async () => { const engine = getMetaEngine(); - if (!engine.isInitialized()) { - await engine.syncOnStartup(); - } + await ensureMetaReady(engine); return engine.getProjectMetadata(); }); safeHandle('meta:setProjectMetadata', async (_, metadata: { name: string; description?: string }) => { const engine = getMetaEngine(); + await ensureMetaContext(engine); await engine.setProjectMetadata(metadata); return engine.getProjectMetadata(); }); - safeHandle('meta:updateProjectMetadata', async (_, updates: { name?: string; description?: string; dataPath?: string; publicUrl?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number; picoTheme?: import('../shared/picoThemes').PicoThemeName; categorySettings?: Record }) => { + safeHandle('meta:updateProjectMetadata', async (_, updates: { name?: string; description?: string; dataPath?: string; publicUrl?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number; picoTheme?: import('../shared/picoThemes').PicoThemeName; categoryMetadata?: Record; categorySettings?: Record }) => { const engine = getMetaEngine(); + await ensureMetaContext(engine); await engine.updateProjectMetadata(updates); return engine.getProjectMetadata(); }); diff --git a/src/main/preload.ts b/src/main/preload.ts index 464d9e3..4945a2b 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -160,7 +160,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; publicUrl?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number; picoTheme?: import('./shared/picoThemes').PicoThemeName; categorySettings?: Record }) => ipcRenderer.invoke('meta:updateProjectMetadata', updates), + updateProjectMetadata: (updates: { name?: string; description?: string; dataPath?: string; publicUrl?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number; picoTheme?: import('./shared/picoThemes').PicoThemeName; categoryMetadata?: Record; categorySettings?: Record }) => 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 8a02eaa..12007da 100644 --- a/src/main/shared/electronApi.ts +++ b/src/main/shared/electronApi.ts @@ -43,6 +43,7 @@ export interface ProjectMetadata { defaultAuthor?: string; maxPostsPerPage?: number; picoTheme?: import('./picoThemes').PicoThemeName; + categoryMetadata?: Record; categorySettings?: Record; } @@ -51,6 +52,10 @@ export interface CategoryRenderSettings { showTitle: boolean; } +export interface CategoryMetadata extends CategoryRenderSettings { + title: string; +} + export interface ProjectData { id: string; name: string; @@ -567,7 +572,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; publicUrl?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number; picoTheme?: import('./picoThemes').PicoThemeName; categorySettings?: Record }) => Promise; + updateProjectMetadata: (updates: { name?: string; description?: string; dataPath?: string; publicUrl?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number; picoTheme?: import('./picoThemes').PicoThemeName; categoryMetadata?: Record; categorySettings?: Record }) => Promise; }; tags: { getAll: () => Promise; diff --git a/src/renderer/components/SettingsView/SettingsView.css b/src/renderer/components/SettingsView/SettingsView.css index b02ff53..138ff05 100644 --- a/src/renderer/components/SettingsView/SettingsView.css +++ b/src/renderer/components/SettingsView/SettingsView.css @@ -343,67 +343,80 @@ } /* Categories management styles */ -.categories-list { - display: flex; - flex-wrap: wrap; - gap: 8px; +.categories-table-wrapper { padding: 12px 16px; + overflow-x: auto; } -.category-item { - display: flex; - align-items: center; - gap: 12px; - padding: 8px 10px; - background-color: var(--vscode-badge-background); - color: var(--vscode-badge-foreground); - border-radius: 4px; +.categories-table { + width: 100%; + border-collapse: collapse; font-size: 13px; } -.category-name { +.categories-table th, +.categories-table td { + border-bottom: 1px solid var(--vscode-panel-border); + padding: 8px 10px; + text-align: left; + vertical-align: middle; +} + +.categories-table th { + color: var(--vscode-descriptionForeground); + font-weight: 600; +} + +.categories-table td input[type="text"] { + width: 100%; + min-width: 200px; + padding: 6px 10px; + font-size: 13px; + background-color: var(--vscode-input-background); + border: 1px solid var(--vscode-input-border); + color: var(--vscode-input-foreground); + border-radius: 4px; + outline: none; +} + +.categories-table td input[type="text"]:focus { + border-color: var(--vscode-focusBorder); +} + +.category-name-cell { font-weight: 500; - min-width: 140px; + white-space: nowrap; } -.category-settings-controls { - display: flex; - align-items: center; - gap: 12px; - flex-wrap: wrap; +.category-checkbox-cell { + text-align: center; } -.category-setting-toggle { - display: inline-flex; - align-items: center; - gap: 6px; - font-size: 12px; -} - -.category-setting-toggle input[type="checkbox"] { - margin: 0; +.category-actions-cell { + text-align: center; } .category-remove { display: flex; align-items: center; justify-content: center; - width: 16px; - height: 16px; + width: 24px; + height: 24px; padding: 0; background: transparent; - border: none; - color: var(--vscode-badge-foreground); + border: 1px solid var(--vscode-panel-border); + color: var(--vscode-foreground); cursor: pointer; - opacity: 0.6; - font-size: 10px; - border-radius: 50%; - transition: opacity 0.15s, background-color 0.15s; + opacity: 0.8; + font-size: 12px; + border-radius: 4px; + transition: opacity 0.15s, background-color 0.15s, border-color 0.15s; } .category-remove:hover { opacity: 1; - background-color: rgba(255, 255, 255, 0.1); + background-color: var(--vscode-toolbar-hoverBackground); + border-color: var(--vscode-focusBorder); } .category-add-form { diff --git a/src/renderer/components/SettingsView/SettingsView.tsx b/src/renderer/components/SettingsView/SettingsView.tsx index 4d554e3..68b1638 100644 --- a/src/renderer/components/SettingsView/SettingsView.tsx +++ b/src/renderer/components/SettingsView/SettingsView.tsx @@ -33,9 +33,10 @@ interface Credentials { sshKeyPath: string; } -interface CategoryRenderSettings { +interface CategoryMetadata { renderInLists: boolean; showTitle: boolean; + title: string; } const RENDER_LANGUAGE_LABEL_KEY: Record = { @@ -65,11 +66,11 @@ const SearchIcon = () => ( // Default post categories based on VISION.md const DEFAULT_POST_CATEGORIES = ['article', 'picture', 'aside', 'page']; -const DEFAULT_CATEGORY_SETTINGS: Record = { - article: { renderInLists: true, showTitle: true }, - picture: { renderInLists: true, showTitle: true }, - aside: { renderInLists: true, showTitle: false }, - page: { renderInLists: false, showTitle: true }, +const DEFAULT_CATEGORY_METADATA: Record = { + article: { renderInLists: true, showTitle: true, title: 'article' }, + picture: { renderInLists: true, showTitle: true, title: 'picture' }, + aside: { renderInLists: true, showTitle: false, title: 'aside' }, + page: { renderInLists: false, showTitle: true, title: 'page' }, }; // Standard categories that cannot be deleted @@ -142,7 +143,7 @@ export const SettingsView: React.FC = () => { // Post categories management const [postCategories, setPostCategories] = useState(DEFAULT_POST_CATEGORIES); - const [categorySettings, setCategorySettings] = useState>(DEFAULT_CATEGORY_SETTINGS); + const [categoryMetadata, setCategoryMetadata] = useState>(DEFAULT_CATEGORY_METADATA); const [newCategoryInput, setNewCategoryInput] = useState(''); // AI Assistant settings @@ -194,14 +195,21 @@ export const SettingsView: React.FC = () => { : 50; setProjectMaxPostsPerPage(maxPostsPerPage); - const incomingCategorySettings = (metadata as any)?.categorySettings as Record | undefined; - setCategorySettings((current) => { - const merged = { ...DEFAULT_CATEGORY_SETTINGS, ...current }; - if (incomingCategorySettings && typeof incomingCategorySettings === 'object') { - for (const [category, settings] of Object.entries(incomingCategorySettings)) { + const incomingCategoryMetadata = (metadata as any)?.categoryMetadata as Record | undefined; + const incomingLegacyCategorySettings = (metadata as any)?.categorySettings as Record | undefined; + setCategoryMetadata((current) => { + const merged = { ...DEFAULT_CATEGORY_METADATA, ...current }; + const source = incomingCategoryMetadata && typeof incomingCategoryMetadata === 'object' + ? incomingCategoryMetadata + : incomingLegacyCategorySettings; + if (source && typeof source === 'object') { + for (const [category, settings] of Object.entries(source)) { merged[category] = { renderInLists: settings?.renderInLists !== false, showTitle: settings?.showTitle !== false, + title: typeof (settings as any)?.title === 'string' && (settings as any).title.trim().length > 0 + ? (settings as any).title.trim() + : category, }; } } @@ -224,11 +232,11 @@ export const SettingsView: React.FC = () => { const categories = await window.electronAPI?.meta.getCategories(); if (categories && categories.length > 0) { setPostCategories(categories); - setCategorySettings((current) => { - const next = { ...DEFAULT_CATEGORY_SETTINGS, ...current }; + setCategoryMetadata((current) => { + const next = { ...DEFAULT_CATEGORY_METADATA, ...current }; for (const category of categories) { if (!next[category]) { - next[category] = { renderInLists: true, showTitle: true }; + next[category] = { renderInLists: true, showTitle: true, title: category }; } } return next; @@ -236,7 +244,7 @@ export const SettingsView: React.FC = () => { } else { // Initialize with defaults if no categories exist setPostCategories(DEFAULT_POST_CATEGORIES); - setCategorySettings(DEFAULT_CATEGORY_SETTINGS); + setCategoryMetadata(DEFAULT_CATEGORY_METADATA); } // Load AI settings @@ -318,7 +326,7 @@ export const SettingsView: React.FC = () => { mainLanguage: resolveSupportedRenderLanguage(projectMainLanguage), defaultAuthor: projectDefaultAuthor.trim() || undefined, maxPostsPerPage: Math.min(500, Math.max(1, Math.floor(projectMaxPostsPerPage || 50))), - categorySettings, + categoryMetadata, }); } showToast.success(t('settings.toast.projectSaved')); @@ -573,12 +581,12 @@ export const SettingsView: React.FC = () => { if (updatedCategories) { setPostCategories(updatedCategories); } - const nextSettings = { - ...categorySettings, - [trimmed]: categorySettings[trimmed] || { renderInLists: true, showTitle: true }, + const nextCategoryMetadata = { + ...categoryMetadata, + [trimmed]: categoryMetadata[trimmed] || { renderInLists: true, showTitle: true, title: trimmed }, }; - setCategorySettings(nextSettings); - await window.electronAPI?.meta.updateProjectMetadata({ categorySettings: nextSettings }); + setCategoryMetadata(nextCategoryMetadata); + await window.electronAPI?.meta.updateProjectMetadata({ categoryMetadata: nextCategoryMetadata }); setNewCategoryInput(''); showToast.success(t('settings.toast.categoryAdded', { category: trimmed })); } catch (error) { @@ -604,10 +612,10 @@ export const SettingsView: React.FC = () => { if (updatedCategories) { setPostCategories(updatedCategories); } - const nextSettings = { ...categorySettings }; - delete nextSettings[categoryToRemove]; - setCategorySettings(nextSettings); - await window.electronAPI?.meta.updateProjectMetadata({ categorySettings: nextSettings }); + const nextCategoryMetadata = { ...categoryMetadata }; + delete nextCategoryMetadata[categoryToRemove]; + setCategoryMetadata(nextCategoryMetadata); + await window.electronAPI?.meta.updateProjectMetadata({ categoryMetadata: nextCategoryMetadata }); showToast.success(t('settings.toast.categoryRemoved', { category: categoryToRemove })); } catch (error) { console.error('Failed to remove category:', error); @@ -631,9 +639,9 @@ export const SettingsView: React.FC = () => { // Refresh the list const updatedCategories = await window.electronAPI?.meta.getCategories(); setPostCategories(updatedCategories || DEFAULT_POST_CATEGORIES); - const defaults = { ...DEFAULT_CATEGORY_SETTINGS }; - setCategorySettings(defaults); - await window.electronAPI?.meta.updateProjectMetadata({ categorySettings: defaults }); + const defaults = { ...DEFAULT_CATEGORY_METADATA }; + setCategoryMetadata(defaults); + await window.electronAPI?.meta.updateProjectMetadata({ categoryMetadata: defaults }); showToast.success(t('settings.toast.categoriesReset')); } catch (error) { console.error('Failed to reset categories:', error); @@ -643,21 +651,51 @@ export const SettingsView: React.FC = () => { const handleCategorySettingToggle = async ( category: string, - field: keyof CategoryRenderSettings, + field: keyof Pick, value: boolean, ) => { - const nextSettings: Record = { - ...categorySettings, + const nextCategoryMetadata: Record = { + ...categoryMetadata, [category]: { - ...(categorySettings[category] || { renderInLists: true, showTitle: true }), + ...(categoryMetadata[category] || { renderInLists: true, showTitle: true, title: category }), [field]: value, }, }; - setCategorySettings(nextSettings); + setCategoryMetadata(nextCategoryMetadata); try { - await window.electronAPI?.meta.updateProjectMetadata({ categorySettings: nextSettings }); + await window.electronAPI?.meta.updateProjectMetadata({ categoryMetadata: nextCategoryMetadata }); + } catch (error) { + console.error('Failed to update category settings:', error); + showToast.error(t('settings.toast.categorySettingsUpdateFailed')); + } + }; + + const handleCategoryTitleChange = (category: string, value: string) => { + setCategoryMetadata((current) => ({ + ...current, + [category]: { + ...(current[category] || { renderInLists: true, showTitle: true, title: category }), + title: value, + }, + })); + }; + + const persistCategoryTitle = async (category: string) => { + const current = categoryMetadata[category] || { renderInLists: true, showTitle: true, title: category }; + const nextCategoryMetadata = { + ...categoryMetadata, + [category]: { + ...current, + title: current.title.trim().length > 0 ? current.title.trim() : category, + }, + }; + + setCategoryMetadata(nextCategoryMetadata); + + try { + await window.electronAPI?.meta.updateProjectMetadata({ categoryMetadata: nextCategoryMetadata }); } catch (error) { console.error('Failed to update category settings:', error); showToast.error(t('settings.toast.categorySettingsUpdateFailed')); @@ -671,47 +709,67 @@ export const SettingsView: React.FC = () => { description={t('settings.content.description')} hidden={!sectionHasMatches(contentKeywords)} > -
- {postCategories.map((cat) => { - const isProtected = PROTECTED_CATEGORIES.includes(cat); - const setting = categorySettings[cat] || { renderInLists: true, showTitle: true }; - return ( -
- {cat}{isProtected && t('settings.content.standardSuffix')} -
- - -
- {!isProtected && ( - - )} -
- ); - })} +
+ + + + + + + + + + + + {postCategories.map((cat) => { + const isProtected = PROTECTED_CATEGORIES.includes(cat); + const metadata = categoryMetadata[cat] || { renderInLists: true, showTitle: true, title: cat }; + return ( + + + + + + + + ); + })} + +
{t('settings.content.categoryColumn')}{t('settings.content.titleColumn')}{t('settings.content.renderInLists')}{t('settings.content.showTitles')}{t('settings.content.actionsColumn')}
{cat}{isProtected && t('settings.content.standardSuffix')} + handleCategoryTitleChange(cat, event.target.value)} + onBlur={() => void persistCategoryTitle(cat)} + aria-label={t('settings.content.categoryTitleAria', { category: cat })} + /> + + handleCategorySettingToggle(cat, 'renderInLists', event.target.checked)} + /> + + handleCategorySettingToggle(cat, 'showTitle', event.target.checked)} + /> + + {!isProtected && ( + + )} +
diff --git a/src/renderer/i18n/locales/de.json b/src/renderer/i18n/locales/de.json index dcbe98b..45bbf15 100644 --- a/src/renderer/i18n/locales/de.json +++ b/src/renderer/i18n/locales/de.json @@ -630,8 +630,12 @@ "settings.content.resetDefaults": "Auf Standard zurücksetzen", "settings.content.description": "Verwalte die verfügbaren Kategorien für Blogbeiträge. Jeder Beitrag kann genau eine Kategorie haben, die seine Darstellungsvorlage bestimmt.", "settings.content.standardSuffix": " (Standard)", + "settings.content.categoryColumn": "Kategorie", + "settings.content.titleColumn": "Titel", + "settings.content.actionsColumn": "Aktionen", "settings.content.renderInListsAria": "{category} in Listen anzeigen", "settings.content.showTitlesAria": "{category} Titel anzeigen", + "settings.content.categoryTitleAria": "{category} Anzeigename", "settings.content.removeCategoryTitle": "Kategorie \"{category}\" entfernen", "settings.ai.description": "Konfiguriere den KI-Chat-Assistenten, der dir bei der Verwaltung deiner Bloginhalte hilft.", "settings.ai.apiKeyLabel": "OpenCode-API-Schlüssel", diff --git a/src/renderer/i18n/locales/en.json b/src/renderer/i18n/locales/en.json index 854f522..3062650 100644 --- a/src/renderer/i18n/locales/en.json +++ b/src/renderer/i18n/locales/en.json @@ -630,8 +630,12 @@ "settings.content.resetDefaults": "Reset to Defaults", "settings.content.description": "Manage the available categories for blog posts. Each post can have one category that determines its display template.", "settings.content.standardSuffix": " (standard)", + "settings.content.categoryColumn": "Category", + "settings.content.titleColumn": "Title", + "settings.content.actionsColumn": "Actions", "settings.content.renderInListsAria": "{category} render in lists", "settings.content.showTitlesAria": "{category} show titles", + "settings.content.categoryTitleAria": "{category} display title", "settings.content.removeCategoryTitle": "Remove \"{category}\" category", "settings.ai.description": "Configure the AI chat assistant that helps you manage your blog content.", "settings.ai.apiKeyLabel": "OpenCode API Key", diff --git a/src/renderer/i18n/locales/es.json b/src/renderer/i18n/locales/es.json index d854ae7..138afd8 100644 --- a/src/renderer/i18n/locales/es.json +++ b/src/renderer/i18n/locales/es.json @@ -630,8 +630,12 @@ "settings.content.resetDefaults": "Restablecer valores predeterminados", "settings.content.description": "Gestiona las categorías disponibles para las entradas del blog. Cada entrada puede tener una sola categoría que determina su plantilla de visualización.", "settings.content.standardSuffix": " (estándar)", + "settings.content.categoryColumn": "Categoría", + "settings.content.titleColumn": "Título", + "settings.content.actionsColumn": "Acciones", "settings.content.renderInListsAria": "{category} mostrar en listas", "settings.content.showTitlesAria": "{category} mostrar títulos", + "settings.content.categoryTitleAria": "Título visible para {category}", "settings.content.removeCategoryTitle": "Eliminar categoría \"{category}\"", "settings.ai.description": "Configura el asistente de chat con IA que te ayuda a gestionar el contenido de tu blog.", "settings.ai.apiKeyLabel": "Clave API", diff --git a/src/renderer/i18n/locales/fr.json b/src/renderer/i18n/locales/fr.json index 6f284a0..786d5f5 100644 --- a/src/renderer/i18n/locales/fr.json +++ b/src/renderer/i18n/locales/fr.json @@ -630,8 +630,12 @@ "settings.content.resetDefaults": "Réinitialiser par défaut", "settings.content.description": "Gérez les catégories disponibles pour les articles du blog. Chaque article peut avoir une seule catégorie qui détermine son modèle d’affichage.", "settings.content.standardSuffix": " (standard)", + "settings.content.categoryColumn": "Catégorie", + "settings.content.titleColumn": "Titre", + "settings.content.actionsColumn": "Actions", "settings.content.renderInListsAria": "{category} afficher dans les listes", "settings.content.showTitlesAria": "{category} afficher les titres", + "settings.content.categoryTitleAria": "Titre affiché pour {category}", "settings.content.removeCategoryTitle": "Supprimer la catégorie \"{category}\"", "settings.ai.description": "Configurez l’assistant de chat IA qui vous aide à gérer le contenu de votre blog.", "settings.ai.apiKeyLabel": "Clé API", diff --git a/src/renderer/i18n/locales/it.json b/src/renderer/i18n/locales/it.json index aad747c..d5156a3 100644 --- a/src/renderer/i18n/locales/it.json +++ b/src/renderer/i18n/locales/it.json @@ -630,8 +630,12 @@ "settings.content.resetDefaults": "Ripristina predefiniti", "settings.content.description": "Gestisci le categorie disponibili per i post del blog. Ogni post può avere una sola categoria che ne determina il template di visualizzazione.", "settings.content.standardSuffix": " (standard)", + "settings.content.categoryColumn": "Categoria", + "settings.content.titleColumn": "Titolo", + "settings.content.actionsColumn": "Azioni", "settings.content.renderInListsAria": "{category} mostra negli elenchi", "settings.content.showTitlesAria": "{category} mostra i titoli", + "settings.content.categoryTitleAria": "Titolo visualizzato per {category}", "settings.content.removeCategoryTitle": "Rimuovi la categoria \"{category}\"", "settings.ai.description": "Configura l’assistente chat IA che ti aiuta a gestire i contenuti del tuo blog.", "settings.ai.apiKeyLabel": "Chiave API", diff --git a/tests/engine/BlogGenerationEngine.test.ts b/tests/engine/BlogGenerationEngine.test.ts index 39d509a..4065641 100644 --- a/tests/engine/BlogGenerationEngine.test.ts +++ b/tests/engine/BlogGenerationEngine.test.ts @@ -184,6 +184,7 @@ describe('BlogGenerationEngine', () => { language: string; pageTitle: string; categorySettings: Record; + categoryMetadata: Record; menu: MenuDocument; }>, ) { @@ -200,6 +201,7 @@ describe('BlogGenerationEngine', () => { language: options?.language, pageTitle: options?.pageTitle, categorySettings: options?.categorySettings, + categoryMetadata: options?.categoryMetadata, menu: options?.menu, }, onProgress); } @@ -292,6 +294,35 @@ describe('BlogGenerationEngine', () => { expect(tagHtml).toContain('class="blog-menu"'); }); + it('renders category menu links with category metadata title while keeping category URL', async () => { + const posts = [ + makePost({ + id: '1', + slug: 'news-post', + title: 'News Post', + categories: ['news'], + createdAt: new Date('2025-03-15T10:00:00Z'), + }), + ]; + + await generate(posts, { + categoryMetadata: { + news: { renderInLists: true, showTitle: true, title: 'Newsroom' }, + }, + menu: { + items: [ + { id: 'home', title: 'Home', kind: 'home', pageSlug: 'home', children: [] }, + { id: 'news', title: 'news', kind: 'category-archive', categoryName: 'news', children: [] }, + ], + }, + }); + + const indexHtml = await readFile(path.join(tempDir, 'html', 'index.html'), 'utf-8'); + expect(indexHtml).toContain('href="/category/news/"'); + expect(indexHtml).toContain('>Newsroom'); + expect(indexHtml).not.toContain('>news'); + }); + it('copies all required asset files to html/assets/ and html/images/', async () => { const result = await generate([]); @@ -367,6 +398,25 @@ describe('BlogGenerationEngine', () => { expect(newsHtml).toContain('data-template="post-list"'); }); + it('uses category title in rendered archive heading while keeping category name in URL path', async () => { + const posts = [ + makePost({ id: '1', slug: 'news-1', title: 'News 1', categories: ['news'] }), + ]; + + await generate(posts, { + categoryMetadata: { + news: { renderInLists: true, showTitle: true, title: 'Newsroom' }, + }, + }); + + const newsPath = path.join(tempDir, 'html', 'category', 'news', 'index.html'); + expect(await fileExists(newsPath)).toBe(true); + + const newsHtml = await readFile(newsPath, 'utf-8'); + expect(newsHtml).toContain('

Newsroom

'); + expect(newsHtml).not.toContain('

news

'); + }); + it('generates tag pages with correct archive context', async () => { const posts = [ makePost({ id: '1', slug: 'tagged-1', title: 'Tagged 1', tags: ['javascript'] }), diff --git a/tests/engine/MetaEngine.test.ts b/tests/engine/MetaEngine.test.ts index 85eb3a5..c646594 100644 --- a/tests/engine/MetaEngine.test.ts +++ b/tests/engine/MetaEngine.test.ts @@ -593,23 +593,23 @@ describe('MetaEngine', () => { expect(parsed.picoTheme).toBe('slate'); }); - it('should apply default category settings for standard categories', async () => { + it('should apply default category metadata for standard categories', async () => { await metaEngine.setProjectMetadata({ name: 'Category Defaults Project', } as any); const metadata = await metaEngine.getProjectMetadata() as any; - expect(metadata.categorySettings).toEqual( + expect(metadata.categoryMetadata).toEqual( expect.objectContaining({ - article: { renderInLists: true, showTitle: true }, - picture: { renderInLists: true, showTitle: true }, - aside: { renderInLists: true, showTitle: false }, - page: { renderInLists: false, showTitle: true }, + article: { renderInLists: true, showTitle: true, title: 'article' }, + picture: { renderInLists: true, showTitle: true, title: 'picture' }, + aside: { renderInLists: true, showTitle: false, title: 'aside' }, + page: { renderInLists: false, showTitle: true, title: 'page' }, }) ); }); - it('should persist category settings to project.json', async () => { + it('should persist legacy categorySettings input to category-meta.json', async () => { await metaEngine.setProjectMetadata({ name: 'Persisted Category Settings', categorySettings: { @@ -621,20 +621,47 @@ describe('MetaEngine', () => { } as any); const metaDir = metaEngine.getMetaDir(); - const projectPath = normalizePath(`${metaDir}/project.json`); - const content = mockFiles.get(projectPath); + const categoryMetaPath = normalizePath(`${metaDir}/category-meta.json`); + const content = mockFiles.get(categoryMetaPath); const parsed = JSON.parse(content!); - expect(parsed.categorySettings).toEqual( + expect(parsed).toEqual( expect.objectContaining({ - custom: { renderInLists: false, showTitle: true }, - aside: { renderInLists: true, showTitle: false }, - page: { renderInLists: false, showTitle: true }, + custom: { renderInLists: false, showTitle: true, title: 'custom' }, + aside: { renderInLists: true, showTitle: false, title: 'aside' }, + page: { renderInLists: false, showTitle: true, title: 'page' }, }) ); }); - it('should merge missing category settings with defaults when loading from filesystem', async () => { + it('should persist category metadata to category-meta.json and not to project.json', async () => { + await metaEngine.setProjectMetadata({ + name: 'Persisted Category Metadata', + categoryMetadata: { + article: { renderInLists: true, showTitle: true, title: 'Articles' }, + updates: { renderInLists: false, showTitle: true, title: 'Project Updates' }, + }, + } as any); + + const metaDir = metaEngine.getMetaDir(); + const projectPath = normalizePath(`${metaDir}/project.json`); + const categoryMetaPath = normalizePath(`${metaDir}/category-meta.json`); + + const projectContent = mockFiles.get(projectPath); + const categoryMetaContent = mockFiles.get(categoryMetaPath); + + expect(projectContent).toBeDefined(); + expect(categoryMetaContent).toBeDefined(); + + const parsedProject = JSON.parse(projectContent!); + const parsedCategoryMeta = JSON.parse(categoryMetaContent!); + + expect(parsedProject.categorySettings).toBeUndefined(); + expect(parsedProject.categoryMetadata).toBeUndefined(); + expect(parsedCategoryMeta.updates).toEqual({ renderInLists: false, showTitle: true, title: 'Project Updates' }); + }); + + it('should merge missing category settings with defaults when loading legacy project metadata', async () => { const metaDir = metaEngine.getMetaDir(); const projectPath = normalizePath(`${metaDir}/project.json`); mockFiles.set(projectPath, JSON.stringify({ @@ -647,12 +674,12 @@ describe('MetaEngine', () => { await metaEngine.loadProjectMetadata(); const metadata = await metaEngine.getProjectMetadata() as any; - expect(metadata.categorySettings).toEqual( + expect(metadata.categoryMetadata).toEqual( expect.objectContaining({ - custom: { renderInLists: false, showTitle: false }, - article: { renderInLists: true, showTitle: true }, - aside: { renderInLists: true, showTitle: false }, - page: { renderInLists: false, showTitle: true }, + custom: { renderInLists: false, showTitle: false, title: 'custom' }, + article: { renderInLists: true, showTitle: true, title: 'article' }, + aside: { renderInLists: true, showTitle: false, title: 'aside' }, + page: { renderInLists: false, showTitle: true, title: 'page' }, }) ); }); @@ -794,6 +821,46 @@ describe('MetaEngine', () => { expect(metadata?.description).toBe('Synced description'); }); + it('should load category metadata from category-meta.json during syncOnStartup', async () => { + const metaDir = metaEngine.getMetaDir(); + mockFiles.set(normalizePath(`${metaDir}/categories.json`), JSON.stringify(['news'])); + mockFiles.set(normalizePath(`${metaDir}/project.json`), JSON.stringify({ + name: 'Synced Project', + })); + mockFiles.set(normalizePath(`${metaDir}/category-meta.json`), JSON.stringify({ + news: { renderInLists: true, showTitle: true, title: 'Newsroom' }, + })); + + await metaEngine.syncOnStartup(); + + const metadata = await metaEngine.getProjectMetadata() as any; + expect(metadata?.categoryMetadata?.news).toEqual({ renderInLists: true, showTitle: true, title: 'Newsroom' }); + }); + + it('should preserve customized category titles from category-meta.json across syncOnStartup', async () => { + const metaDir = metaEngine.getMetaDir(); + mockFiles.set(normalizePath(`${metaDir}/categories.json`), JSON.stringify(['article', 'picture', 'aside', 'page', 'news'])); + mockFiles.set(normalizePath(`${metaDir}/project.json`), JSON.stringify({ + name: 'Synced Project', + })); + mockFiles.set(normalizePath(`${metaDir}/category-meta.json`), JSON.stringify({ + article: { renderInLists: true, showTitle: true, title: 'Articles' }, + picture: { renderInLists: true, showTitle: true, title: 'Photos' }, + aside: { renderInLists: true, showTitle: false, title: 'Asides' }, + page: { renderInLists: false, showTitle: true, title: 'Pages' }, + news: { renderInLists: true, showTitle: true, title: 'Newsroom' }, + })); + + await metaEngine.syncOnStartup(); + + const metadata = await metaEngine.getProjectMetadata() as any; + expect(metadata?.categoryMetadata?.article?.title).toBe('Articles'); + expect(metadata?.categoryMetadata?.picture?.title).toBe('Photos'); + expect(metadata?.categoryMetadata?.aside?.title).toBe('Asides'); + expect(metadata?.categoryMetadata?.page?.title).toBe('Pages'); + expect(metadata?.categoryMetadata?.news?.title).toBe('Newsroom'); + }); + it('should create project.json with data from database during syncOnStartup if file does not exist', async () => { const metaDir = metaEngine.getMetaDir(); const projectPath = normalizePath(`${metaDir}/project.json`); diff --git a/tests/engine/PreviewServer.test.ts b/tests/engine/PreviewServer.test.ts index d2b7b00..7e18c62 100644 --- a/tests/engine/PreviewServer.test.ts +++ b/tests/engine/PreviewServer.test.ts @@ -254,6 +254,41 @@ describe('PreviewServer', () => { expect(tagHtml).toContain('class="blog-menu"'); }); + it('renders category menu link labels from category metadata title', async () => { + const posts = [ + makePost({ id: '1', slug: 'news-post', title: 'News Post', categories: ['news'], createdAt: new Date('2025-01-03T10:00:00.000Z') }), + ]; + + server = new PreviewServer({ + postEngine: makeEngine(posts), + settingsEngine: { + setProjectContext: vi.fn(), + async getProjectMetadata() { + return { + maxPostsPerPage: 50, + categoryMetadata: { + news: { renderInLists: true, showTitle: true, title: 'Newsroom' }, + }, + } as any; + }, + } as any, + menuEngine: makeMenuEngine({ + items: [ + { id: 'home', title: 'Home', kind: 'home', pageSlug: 'home', children: [] }, + { id: 'news', title: 'news', kind: 'category-archive', categoryName: 'news', children: [] }, + ], + }), + getActiveProjectContext: async () => ({ projectId: 'default' }), + }); + + await server.start(0); + + const rootHtml = await (await fetch(`${server.getBaseUrl()}/`)).text(); + expect(rootHtml).toContain('href="/category/news/"'); + expect(rootHtml).toContain('>Newsroom'); + expect(rootHtml).not.toContain('>news'); + }); + it('uses local CSS/JS assets and serves them from the preview server', async () => { server = new PreviewServer({ postEngine: makeEngine([makePost()]), diff --git a/tests/ipc/handlers.test.ts b/tests/ipc/handlers.test.ts index f11a43c..98066d6 100644 --- a/tests/ipc/handlers.test.ts +++ b/tests/ipc/handlers.test.ts @@ -118,6 +118,7 @@ const mockMetaEngine = { removeCategory: vi.fn(), getProjectMetadata: vi.fn(), setProjectMetadata: vi.fn(), + updateProjectMetadata: vi.fn(), }; const mockTagEngine = { @@ -1222,6 +1223,23 @@ describe('IPC Handlers', () => { }); }); + describe('meta:getCategories', () => { + it('should set context and sync before returning categories when uninitialized', async () => { + const activeProject = createMockProject({ id: 'project-cats', dataPath: '/cats/data' }); + mockProjectEngine.getActiveProject.mockResolvedValue(activeProject); + mockProjectEngine.getDataDir.mockReturnValue('/resolved/cats-data'); + mockMetaEngine.isInitialized.mockReturnValue(false); + mockMetaEngine.syncOnStartup.mockResolvedValue(undefined); + mockMetaEngine.getCategories.mockResolvedValue(['article', 'news', 'travel']); + + const result = await invokeHandler('meta:getCategories'); + + expect(mockMetaEngine.setProjectContext).toHaveBeenCalledWith('project-cats', '/resolved/cats-data'); + expect(mockMetaEngine.syncOnStartup).toHaveBeenCalled(); + expect(result).toEqual(['article', 'news', 'travel']); + }); + }); + describe('meta:getProjectMetadata', () => { it('should return project metadata', async () => { const metadata = { name: 'Test Blog', description: 'A test blog', mainLanguage: 'de' }; @@ -1234,6 +1252,20 @@ describe('IPC Handlers', () => { expect(result).toEqual(metadata); }); + it('should set meta engine context from active project before reading metadata', async () => { + const activeProject = createMockProject({ id: 'project-ctx', dataPath: '/ctx/data' }); + const metadata = { name: 'Ctx Blog' }; + mockProjectEngine.getActiveProject.mockResolvedValue(activeProject); + mockProjectEngine.getDataDir.mockReturnValue('/resolved/ctx-data'); + mockMetaEngine.isInitialized.mockReturnValue(true); + mockMetaEngine.getProjectMetadata.mockResolvedValue(metadata); + + const result = await invokeHandler('meta:getProjectMetadata'); + + expect(mockMetaEngine.setProjectContext).toHaveBeenCalledWith('project-ctx', '/resolved/ctx-data'); + expect(result).toEqual(metadata); + }); + it('should sync metadata before reading when engine is not initialized', async () => { const metadata = { name: 'Test Blog', mainLanguage: 'de', defaultAuthor: 'Max' }; mockMetaEngine.isInitialized.mockReturnValue(false); @@ -1260,6 +1292,24 @@ describe('IPC Handlers', () => { expect(result).toEqual(newMetadata); }); }); + + describe('meta:updateProjectMetadata', () => { + it('should set meta engine context from active project before updating metadata', async () => { + const activeProject = createMockProject({ id: 'project-update', dataPath: '/update/data' }); + mockProjectEngine.getActiveProject.mockResolvedValue(activeProject); + mockProjectEngine.getDataDir.mockReturnValue('/resolved/update-data'); + mockMetaEngine.updateProjectMetadata.mockResolvedValue(undefined); + const updatedMetadata = { name: 'Updated' }; + mockMetaEngine.getProjectMetadata.mockResolvedValue(updatedMetadata); + + const updates = { defaultAuthor: 'Author Name' }; + const result = await invokeHandler('meta:updateProjectMetadata', updates); + + expect(mockMetaEngine.setProjectContext).toHaveBeenCalledWith('project-update', '/resolved/update-data'); + expect(mockMetaEngine.updateProjectMetadata).toHaveBeenCalledWith(updates); + expect(result).toEqual(updatedMetadata); + }); + }); }); // ============ Menu Handlers ============ diff --git a/tests/renderer/components/SettingsView.test.tsx b/tests/renderer/components/SettingsView.test.tsx index d05d3aa..3226bb0 100644 --- a/tests/renderer/components/SettingsView.test.tsx +++ b/tests/renderer/components/SettingsView.test.tsx @@ -141,8 +141,8 @@ describe('SettingsView Diff Preferences', () => { expect((window as any).electronAPI.meta.updateProjectMetadata).toHaveBeenCalledWith( expect.objectContaining({ - categorySettings: expect.objectContaining({ - page: expect.objectContaining({ renderInLists: true, showTitle: true }), + categoryMetadata: expect.objectContaining({ + page: expect.objectContaining({ renderInLists: true, showTitle: true, title: 'page' }), }), }) );