From 4b31d9d4215e033b97f74971de667e234673c00d Mon Sep 17 00:00:00 2001 From: hugo Date: Sat, 14 Feb 2026 21:24:01 +0100 Subject: [PATCH] fix: importer bugs and editor bugs --- src/main/engine/ImportExecutionEngine.ts | 4 ++ src/main/engine/MediaEngine.ts | 12 ++++ src/main/engine/PostEngine.ts | 71 +++++++++++++++++++--- src/main/ipc/handlers.ts | 7 ++- src/renderer/components/Editor/Editor.tsx | 17 ++++++ tests/engine/ImportExecutionEngine.test.ts | 67 ++++++++++++++++++++ tests/engine/MediaEngine.test.ts | 41 +++++++++++++ tests/engine/PostEngine.test.ts | 70 +++++++++++++++++---- tests/ipc/handlers.test.ts | 16 ++++- 9 files changed, 281 insertions(+), 24 deletions(-) diff --git a/src/main/engine/ImportExecutionEngine.ts b/src/main/engine/ImportExecutionEngine.ts index 655f422..ce8e38c 100644 --- a/src/main/engine/ImportExecutionEngine.ts +++ b/src/main/engine/ImportExecutionEngine.ts @@ -637,6 +637,10 @@ export class ImportExecutionEngine extends EventEmitter { // Unescape double-bracket macros that TurndownService escaped // \[\[ becomes [[ and \]\] becomes ]] markdown = markdown.replace(/\\\[\\\[/g, '[[').replace(/\\\]\\\]/g, ']]'); + // Remove backslash escapes inside [[macro]] blocks (e.g. photo\_archive → photo_archive) + markdown = markdown.replace(/\[\[([^\]]*?)\]\]/g, (_match, inner: string) => { + return '[[' + inner.replace(/\\(.)/g, '$1') + ']]'; + }); return markdown; } diff --git a/src/main/engine/MediaEngine.ts b/src/main/engine/MediaEngine.ts index 5ccff37..13178d0 100644 --- a/src/main/engine/MediaEngine.ts +++ b/src/main/engine/MediaEngine.ts @@ -839,6 +839,18 @@ export class MediaEngine extends EventEmitter { return path.join(this.getMediaDir(), id); } + /** + * Get the relative path for a media item (e.g. media/2025/01/uuid.jpg). + * This is the path format used in markdown content for image references. + */ + async getRelativePath(id: string): Promise { + const db = getDatabase().getLocal(); + const dbMedia = await db.select().from(media).where(eq(media.id, id)).get(); + if (!dbMedia?.filePath) return null; + const dataDir = this.getDataDir(); + return path.relative(dataDir, dbMedia.filePath); + } + async rebuildDatabaseFromFiles(): Promise { const mediaBaseDir = this.getMediaBaseDir(); console.log(`[MediaEngine] rebuildDatabaseFromFiles: scanning mediaBaseDir=${mediaBaseDir}`); diff --git a/src/main/engine/PostEngine.ts b/src/main/engine/PostEngine.ts index 57c5751..a389b6c 100644 --- a/src/main/engine/PostEngine.ts +++ b/src/main/engine/PostEngine.ts @@ -4,7 +4,7 @@ import * as fs from 'fs/promises'; import * as path from 'path'; import * as crypto from 'crypto'; import matter from 'gray-matter'; -import { eq, and, desc, gte, lte, like, inArray } from 'drizzle-orm'; +import { eq, and, desc, gte, lte, like, inArray, ne } from 'drizzle-orm'; import { app } from 'electron'; import { getDatabase } from '../database'; import { posts, Post, NewPost, postLinks } from '../database/schema'; @@ -532,19 +532,74 @@ export class PostEngine extends EventEmitter { .all(); const total = countResult.length; + // Drafts must ALWAYS be included regardless of pagination. + // On the first page (offset=0), fetch all drafts and fill remaining slots with non-drafts. + // On subsequent pages, only paginate non-draft posts (drafts were already returned). + if (offset === 0) { + // Fetch ALL drafts (typically few) + const draftPosts = await db + .select() + .from(posts) + .where(and( + eq(posts.projectId, this.currentProjectId), + eq(posts.status, 'draft') + )) + .orderBy(desc(posts.createdAt)) + .all(); + + // Fill remaining slots with non-draft posts + const remainingSlots = Math.max(0, limit - draftPosts.length); + const nonDraftPosts = remainingSlots > 0 ? await db + .select() + .from(posts) + .where(and( + eq(posts.projectId, this.currentProjectId), + ne(posts.status, 'draft') + )) + .orderBy(desc(posts.createdAt)) + .limit(remainingSlots) + .all() : []; + + const allDbPosts = [...draftPosts, ...nonDraftPosts]; + const items: PostData[] = allDbPosts.map(dbPost => + this.dbRowToPostData(dbPost, dbPost.content || '') + ); + + return { + items, + hasMore: allDbPosts.length < total, + total, + }; + } + + // Subsequent pages: only paginate non-draft posts + // Count drafts to calculate correct offset into non-draft posts + const draftCount = await db + .select({ count: posts.id }) + .from(posts) + .where(and( + eq(posts.projectId, this.currentProjectId), + eq(posts.status, 'draft') + )) + .all(); + const numDrafts = draftCount.length; + + // Adjust offset: the first page returned numDrafts + (limit - numDrafts) non-draft posts + // So for page 2+, offset into non-draft posts = offset - numDrafts + const nonDraftOffset = offset - numDrafts; const dbPosts = await db .select() .from(posts) - .where(eq(posts.projectId, this.currentProjectId)) + .where(and( + eq(posts.projectId, this.currentProjectId), + ne(posts.status, 'draft') + )) .orderBy(desc(posts.createdAt)) .limit(limit) - .offset(offset) + .offset(nonDraftOffset) .all(); - - // For listing, we don't need to load content from filesystem. - // Use DB content for drafts, empty string for published posts. - // This avoids expensive filesystem reads for each post. - const items: PostData[] = dbPosts.map(dbPost => + + const items: PostData[] = dbPosts.map(dbPost => this.dbRowToPostData(dbPost, dbPost.content || '') ); diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 5b5d73c..1ae5890 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -336,8 +336,11 @@ export function registerIpcHandlers(): void { }); safeHandle('media:getUrl', async (_, id: string) => { - // Returns the bds-media:// protocol URL for a media item - return `bds-media://${id}`; + // Returns the relative path for a media item (e.g. media/2025/01/uuid.jpg) + // This is the format used in markdown content for image references + const engine = getMediaEngine(); + const relativePath = await engine.getRelativePath(id); + return relativePath ?? `media/${id}`; }); safeHandle('media:getFilePath', async (_, id: string) => { diff --git a/src/renderer/components/Editor/Editor.tsx b/src/renderer/components/Editor/Editor.tsx index 4b786a1..24c8c22 100644 --- a/src/renderer/components/Editor/Editor.tsx +++ b/src/renderer/components/Editor/Editor.tsx @@ -42,6 +42,8 @@ const autoSaveManager = new AutoSaveManager({ if (updated) { useAppStore.getState().updatePost(id, updated as Partial); useAppStore.getState().markClean(id); + // Emit event so PostEditor can update its local state + window.dispatchEvent(new CustomEvent('bds:post-auto-saved', { detail: { id, updated } })); } }, onSaveComplete: (id) => { @@ -670,6 +672,18 @@ const PostEditor: React.FC = ({ postId }) => { const isDirty = checkIsDirty(postId); + // Listen for auto-save events to keep local post state in sync + useEffect(() => { + const handler = (e: Event) => { + const { id, updated } = (e as CustomEvent).detail; + if (id === postId && updated) { + setPost(prev => prev ? { ...prev, ...updated as Partial } : prev); + } + }; + window.addEventListener('bds:post-auto-saved', handler); + return () => window.removeEventListener('bds:post-auto-saved', handler); + }, [postId]); + // Check if post has a published version for discard functionality useEffect(() => { window.electronAPI?.posts.hasPublishedVersion(postId).then(setHasPublishedVersion); @@ -764,6 +778,7 @@ const PostEditor: React.FC = ({ postId }) => { if (updated) { useAppStore.getState().updatePost(pending.postId, updated as Partial); useAppStore.getState().markClean(pending.postId); + window.dispatchEvent(new CustomEvent('bds:post-auto-saved', { detail: { id: pending.postId, updated } })); } }).catch((error) => { console.error('Auto-save failed:', error); @@ -835,6 +850,7 @@ const PostEditor: React.FC = ({ postId }) => { if (updated) { updatePost(postId, updated as Partial); + setPost(prev => prev ? { ...prev, ...updated as Partial } : prev); markClean(postId); } } catch (error) { @@ -856,6 +872,7 @@ const PostEditor: React.FC = ({ postId }) => { const updated = await window.electronAPI?.posts.publish(postId); if (updated) { updatePost(postId, updated as Partial); + setPost(prev => prev ? { ...prev, ...updated as Partial } : prev); showToast.success('Post published'); } } catch (error) { diff --git a/tests/engine/ImportExecutionEngine.test.ts b/tests/engine/ImportExecutionEngine.test.ts index 3d7b45d..1eeb1ad 100644 --- a/tests/engine/ImportExecutionEngine.test.ts +++ b/tests/engine/ImportExecutionEngine.test.ts @@ -823,6 +823,73 @@ describe('ImportExecutionEngine', () => { expect(insertedPosts[0].content).toContain('[[video src="test.mp4"]]'); }); + it('should not escape underscores inside macro names during markdown conversion', async () => { + const wxrPost = createMockWxrPost({ + content: '

Here is a photo archive: [photo_archive]

', + }); + const analyzed = createMockAnalyzedPost(wxrPost, 'conflict', 'overwrite'); + analyzed.existingPost = { + id: 'existing-post-id', + title: 'Existing Post', + slug: 'test-post', + checksum: 'old-checksum', + pubDate: null, + excerpt: null, + author: null, + tags: [], + categories: [], + }; + const report = createMockAnalysisReport({ + posts: { + total: 1, + new: 0, + updates: 0, + conflicts: 1, + contentDuplicates: 0, + items: [analyzed], + }, + }); + + await engine.executeImport(report, {}); + + expect(insertedPosts.length).toBe(1); + expect(insertedPosts[0].content).toContain('[[photo_archive]]'); + expect(insertedPosts[0].content).not.toContain('photo\\_archive'); + }); + + it('should not escape underscores in macro attributes during markdown conversion', async () => { + const wxrPost = createMockWxrPost({ + content: '

Show: [my_gallery type="grid_view" size="large_thumb"]

', + }); + const analyzed = createMockAnalyzedPost(wxrPost, 'conflict', 'overwrite'); + analyzed.existingPost = { + id: 'existing-post-id', + title: 'Existing Post', + slug: 'test-post', + checksum: 'old-checksum', + pubDate: null, + excerpt: null, + author: null, + tags: [], + categories: [], + }; + const report = createMockAnalysisReport({ + posts: { + total: 1, + new: 0, + updates: 0, + conflicts: 1, + contentDuplicates: 0, + items: [analyzed], + }, + }); + + await engine.executeImport(report, {}); + + expect(insertedPosts.length).toBe(1); + expect(insertedPosts[0].content).toContain('[[my_gallery type="grid_view" size="large_thumb"]]'); + }); + it('should map tags based on analysis mappings', async () => { const wxrPost = createMockWxrPost({ tags: ['OldTagName', 'NewTag'], diff --git a/tests/engine/MediaEngine.test.ts b/tests/engine/MediaEngine.test.ts index 4f253b7..090a292 100644 --- a/tests/engine/MediaEngine.test.ts +++ b/tests/engine/MediaEngine.test.ts @@ -463,6 +463,47 @@ describe('MediaEngine', () => { }); }); + describe('getRelativePath', () => { + it('should return relative path from dataDir for a media item', async () => { + mediaEngine.setProjectContext('test-project', '/projects/my-blog'); + + const selectChain = createSelectChain(); + selectChain.get.mockResolvedValue({ + id: 'abc-123', + filePath: '/projects/my-blog/media/2025/01/abc-123.jpg', + }); + vi.mocked(mockLocalDb.select).mockReturnValue(selectChain as any); + + const result = await mediaEngine.getRelativePath('abc-123'); + + expect(result).toBe('media/2025/01/abc-123.jpg'); + }); + + it('should return null when media item is not found', async () => { + mediaEngine.setProjectContext('test-project', '/projects/my-blog'); + + const selectChain = createSelectChain(); + selectChain.get.mockResolvedValue(undefined); + vi.mocked(mockLocalDb.select).mockReturnValue(selectChain as any); + + const result = await mediaEngine.getRelativePath('nonexistent'); + + expect(result).toBeNull(); + }); + + it('should return null when filePath is empty', async () => { + mediaEngine.setProjectContext('test-project', '/projects/my-blog'); + + const selectChain = createSelectChain(); + selectChain.get.mockResolvedValue({ id: 'abc-123', filePath: '' }); + vi.mocked(mockLocalDb.select).mockReturnValue(selectChain as any); + + const result = await mediaEngine.getRelativePath('abc-123'); + + expect(result).toBeNull(); + }); + }); + describe('Multiple Media Import', () => { beforeEach(() => { mockFiles.set('/source/image1.jpg', Buffer.from('image1-data')); diff --git a/tests/engine/PostEngine.test.ts b/tests/engine/PostEngine.test.ts index b028601..2a30828 100644 --- a/tests/engine/PostEngine.test.ts +++ b/tests/engine/PostEngine.test.ts @@ -2001,17 +2001,24 @@ Published content`); it('should return all posts for current project', async () => { postEngine.setProjectContext('test-project'); + // getAllPosts now makes 3 queries: count, drafts, non-drafts + let selectCallCount = 0; vi.mocked(mockLocalDb.select).mockImplementation(() => { + selectCallCount++; const chain = createSelectChain(); + const callNum = selectCallCount; chain.where = vi.fn().mockReturnValue({ ...chain, orderBy: vi.fn().mockReturnThis(), limit: vi.fn().mockReturnThis(), offset: vi.fn().mockReturnThis(), - all: vi.fn().mockResolvedValue([ - { id: '1', title: 'Post 1', projectId: 'test-project', tags: '[]', categories: '[]' }, - { id: '2', title: 'Post 2', projectId: 'test-project', tags: '[]', categories: '[]' }, - ]), + all: vi.fn().mockResolvedValue( + callNum === 1 + ? [{ id: '1' }, { id: '2' }] // count query: 2 total + : callNum === 2 + ? [{ id: '1', title: 'Draft Post', projectId: 'test-project', status: 'draft', tags: '[]', categories: '[]' }] // drafts + : [{ id: '2', title: 'Published Post', projectId: 'test-project', status: 'published', tags: '[]', categories: '[]' }] // non-drafts + ), }); return chain; }); @@ -2021,21 +2028,24 @@ Published content`); }); it('should parse tags and categories JSON', async () => { + // getAllPosts makes 3 queries: count, drafts, non-drafts + let selectCallCount = 0; vi.mocked(mockLocalDb.select).mockImplementation(() => { + selectCallCount++; const chain = createSelectChain(); + const callNum = selectCallCount; chain.where = vi.fn().mockReturnValue({ ...chain, orderBy: vi.fn().mockReturnThis(), limit: vi.fn().mockReturnThis(), offset: vi.fn().mockReturnThis(), - all: vi.fn().mockResolvedValue([ - { - id: '1', - title: 'Tagged Post', - tags: '["tag1","tag2"]', - categories: '["cat1"]' - }, - ]), + all: vi.fn().mockResolvedValue( + callNum === 1 + ? [{ id: '1' }] // count + : callNum === 2 + ? [] // no drafts + : [{ id: '1', title: 'Tagged Post', tags: '["tag1","tag2"]', categories: '["cat1"]' }] // non-drafts + ), }); return chain; }); @@ -2044,6 +2054,42 @@ Published content`); expect(result.items[0].tags).toEqual(['tag1', 'tag2']); expect(result.items[0].categories).toEqual(['cat1']); }); + + it('should always include all drafts regardless of pagination limit', async () => { + postEngine.setProjectContext('test-project'); + + // Simulate: 3 drafts + many published, limit=2 + let selectCallCount = 0; + vi.mocked(mockLocalDb.select).mockImplementation(() => { + selectCallCount++; + const chain = createSelectChain(); + const callNum = selectCallCount; + chain.where = vi.fn().mockReturnValue({ + ...chain, + orderBy: vi.fn().mockReturnThis(), + limit: vi.fn().mockReturnThis(), + offset: vi.fn().mockReturnThis(), + all: vi.fn().mockResolvedValue( + callNum === 1 + ? [{ id: '1' }, { id: '2' }, { id: '3' }, { id: '4' }, { id: '5' }] // count: 5 total + : callNum === 2 + ? [ // 3 drafts (ALL of them, regardless of limit) + { id: '1', title: 'Draft 1', status: 'draft', tags: '[]', categories: '[]' }, + { id: '2', title: 'Draft 2', status: 'draft', tags: '[]', categories: '[]' }, + { id: '3', title: 'Draft 3', status: 'draft', tags: '[]', categories: '[]' }, + ] + : [] // no remaining slots for non-drafts (limit=2, 3 drafts > 2) + ), + }); + return chain; + }); + + // Even with limit=2, all 3 drafts must be returned + const result = await postEngine.getAllPosts({ limit: 2, offset: 0 }); + expect(result.items).toHaveLength(3); + expect(result.items.every(p => p.status === 'draft')).toBe(true); + expect(result.hasMore).toBe(true); + }); }); describe('getPostsFiltered', () => { diff --git a/tests/ipc/handlers.test.ts b/tests/ipc/handlers.test.ts index 6b9cf45..2b13a38 100644 --- a/tests/ipc/handlers.test.ts +++ b/tests/ipc/handlers.test.ts @@ -76,6 +76,7 @@ const mockMediaEngine = { reindexText: vi.fn(), getThumbnailDataUrl: vi.fn(), regenerateMissingThumbnails: vi.fn(), + getRelativePath: vi.fn(), }; const mockProjectEngine = { @@ -606,10 +607,21 @@ describe('IPC Handlers', () => { }); describe('media:getUrl', () => { - it('should return bds-media protocol URL', async () => { + it('should return relative media path', async () => { + mockMediaEngine.getRelativePath.mockResolvedValue('media/2025/01/media-123.jpg'); + const result = await invokeHandler('media:getUrl', 'media-123'); - expect(result).toBe('bds-media://media-123'); + expect(mockMediaEngine.getRelativePath).toHaveBeenCalledWith('media-123'); + expect(result).toBe('media/2025/01/media-123.jpg'); + }); + + it('should fall back to media/{id} when relative path is not found', async () => { + mockMediaEngine.getRelativePath.mockResolvedValue(null); + + const result = await invokeHandler('media:getUrl', 'media-unknown'); + + expect(result).toBe('media/media-unknown'); }); });