diff --git a/VISION.md b/VISION.md index 4220674..9e505c0 100644 --- a/VISION.md +++ b/VISION.md @@ -16,6 +16,11 @@ We need a good way to handle the syncing of the non-metadata components (posts a is not part of the database sync. One way could be using something like dropbox in the background, so that the posts/ and media/ folders are automatically synced to some area in dropbox and transported that way. +In addition to dropbox sync, also provide a file sync handler that uses git as the mechanism. That way +the user can provide a git URL to push to and pull from, where the app does regular fetch to see if stuff +was there and has mechanisms to handle conflicts. This will work without special requirements for some +cloud provider for the file data. + Blog post metadata should be managed in the SQLite database in the user local folder, so it persists application runs properly. for blog posts, create a subfolder /posts/ there where each post is stored as a markdown file with a properties segment in the top of the file with YAML like property definitions, so all metadata can always be reconstructed from posts. Do the same with images, keeping them in /media/ under the user local path, in that case storing the image file sand for each image file a properties sidecar file that uses the same header structure as for posts. The application must be able to support multiple projects (ie web sites), so there must be a way to create @@ -199,6 +204,10 @@ Import runs can be shown in the main panel, so that the user can see what came w manage posts and media from imports that way. Migration is the main interesting part of this tool, because migrating blogs is hard work and needs to be properly supported. +Migrating posts puts them into published state directly, not draft state, because that would make the +database explode. The whole point of migration is to get those posts into a published state as soon as +possible. + ## Organizing II Since we have an AI assistant planned for the migration phase, we also want an AI chat feature as another diff --git a/src/main/engine/PostEngine.ts b/src/main/engine/PostEngine.ts index 49fa87a..2f09352 100644 --- a/src/main/engine/PostEngine.ts +++ b/src/main/engine/PostEngine.ts @@ -232,7 +232,7 @@ export class PostEngine extends EventEmitter { const post: PostData = { id, projectId: data.projectId || this.currentProjectId, - title: data.title || 'Untitled', + title: data.title ?? '', slug, excerpt: data.excerpt, content: data.content || '', @@ -303,11 +303,18 @@ export class PostEngine extends EventEmitter { newStatus = 'draft'; } + // Auto-update slug when title changes, but only if post was never published + let newSlug = data.slug ?? existing.slug; + if (data.title !== undefined && data.title !== existing.title && !existing.publishedAt) { + newSlug = await this.generateUniqueSlug(data.title || 'untitled', id); + } + const updated: PostData = { ...existing, ...data, id, // Ensure ID doesn't change projectId: existing.projectId, // Ensure projectId doesn't change + slug: newSlug, status: newStatus as 'draft' | 'published' | 'archived', updatedAt: new Date(), }; diff --git a/src/main/main.ts b/src/main/main.ts index a669426..e02287c 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -44,8 +44,14 @@ function createWindow(): void { } // Forward events to renderer - ipcMain.on('forward-to-renderer', (_event, eventName: string, ...args: unknown[]) => { - if (mainWindow && !mainWindow.isDestroyed()) { + // Note: ipcMain.emit() (used by forwardEvent in handlers) is a raw EventEmitter emit, + // so the first arg is NOT an IpcMainEvent — it's the event name string directly. + ipcMain.on('forward-to-renderer', (eventNameOrEvent: any, ...args: unknown[]) => { + // When called via ipcMain.emit(), first arg is the channel string directly + const eventName: string = typeof eventNameOrEvent === 'string' + ? eventNameOrEvent + : args.shift() as string; + if (mainWindow && !mainWindow.isDestroyed() && typeof eventName === 'string') { mainWindow.webContents.send(eventName, ...args); } }); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 9134aad..3a3b5a5 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -170,8 +170,8 @@ const App: React.FC = () => { unsubscribers.push( window.electronAPI?.on('menu:newPost', async () => { const post = await window.electronAPI?.posts.create({ - title: 'New Post', - content: '# New Post\n\nStart writing...', + title: '', + content: '', }); if (post) { setSelectedPost((post as PostData).id); diff --git a/src/renderer/components/Editor/Editor.tsx b/src/renderer/components/Editor/Editor.tsx index 4ceb875..5168221 100644 --- a/src/renderer/components/Editor/Editor.tsx +++ b/src/renderer/components/Editor/Editor.tsx @@ -373,7 +373,7 @@ const PostEditor: React.FC = ({ post }) => { type="text" value={title} onChange={(e) => setTitle(e.target.value)} - placeholder="Post title" + placeholder="Untitled" />
@@ -667,18 +667,17 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => { }; const WelcomeScreen: React.FC = () => { - const { addPost, setSelectedPost } = useAppStore(); + const { setSelectedPost } = useAppStore(); const handleNewPost = async () => { try { const newPost = await window.electronAPI?.posts.create({ - title: 'Untitled', + title: '', content: '', tags: [], categories: [], }); if (newPost) { - addPost(newPost as PostData); setSelectedPost(newPost.id); } } catch (error) { diff --git a/src/renderer/components/Sidebar/Sidebar.tsx b/src/renderer/components/Sidebar/Sidebar.tsx index b2ca0a2..f751209 100644 --- a/src/renderer/components/Sidebar/Sidebar.tsx +++ b/src/renderer/components/Sidebar/Sidebar.tsx @@ -324,15 +324,14 @@ const PostsList: React.FC = () => { const handleCreatePost = async () => { // Create a real post immediately in the database with default empty content try { - const { addPost, setSelectedPost: selectPost } = useAppStore.getState(); + const { setSelectedPost: selectPost } = useAppStore.getState(); const newPost = await window.electronAPI?.posts.create({ - title: 'Untitled', + title: '', content: '', tags: [], categories: [], }); if (newPost) { - addPost(newPost as PostData); selectPost(newPost.id); } } catch (error) { @@ -434,7 +433,7 @@ const PostsList: React.FC = () => { > {postType.icon}
-
{post.title}
+
{post.title || 'Untitled'}
{formatDate(post.updatedAt)}
@@ -461,7 +460,7 @@ const PostsList: React.FC = () => { > {postType.icon}
-
{post.title}
+
{post.title || 'Untitled'}
{formatDate(post.publishedAt || post.updatedAt)}
@@ -488,7 +487,7 @@ const PostsList: React.FC = () => { > {postType.icon}
-
{post.title}
+
{post.title || 'Untitled'}
{formatDate(post.updatedAt)}
diff --git a/tests/engine/PostEngine.test.ts b/tests/engine/PostEngine.test.ts index c4a9ae2..cd03187 100644 --- a/tests/engine/PostEngine.test.ts +++ b/tests/engine/PostEngine.test.ts @@ -317,9 +317,9 @@ describe('PostEngine', () => { expect(ftsInsert).toBeDefined(); }); - it('should handle post without title using "Untitled"', async () => { + it('should handle post without title using empty string', async () => { const post = await postEngine.createPost({}); - expect(post.title).toBe('Untitled'); + expect(post.title).toBe(''); expect(post.slug).toBe('untitled'); }); @@ -811,6 +811,75 @@ Original content`); expect(result?.id).toBe(created.id); // ID preserved expect(result?.projectId).toBe('original-project'); // projectId preserved }); + + it('should auto-update slug when title changes on a never-published draft', async () => { + const created = await postEngine.createPost({ title: 'Original Title' }); + expect(created.slug).toBe('original-title'); + + vi.mocked(mockLocalDb.select).mockImplementation(() => { + const chain = createSelectChain(); + chain.where = vi.fn().mockReturnValue({ + ...chain, + get: vi.fn().mockResolvedValue({ + id: created.id, + projectId: created.projectId, + title: created.title, + slug: created.slug, + status: 'draft', + content: created.content || '', + filePath: '', + tags: '[]', + categories: '[]', + createdAt: created.createdAt, + updatedAt: created.updatedAt, + publishedAt: null, + }), + }); + return chain; + }); + + const result = await postEngine.updatePost(created.id, { title: 'New Title' }); + + expect(result).not.toBeNull(); + expect(result?.slug).toBe('new-title'); + }); + + it('should NOT auto-update slug when title changes on a previously published post', async () => { + const created = await postEngine.createPost({ title: 'Published Post' }); + + vi.mocked(mockLocalDb.select).mockImplementation(() => { + const chain = createSelectChain(); + chain.where = vi.fn().mockReturnValue({ + ...chain, + get: vi.fn().mockResolvedValue({ + id: created.id, + projectId: created.projectId, + title: created.title, + slug: created.slug, + status: 'draft', + content: created.content || '', + filePath: '', + tags: '[]', + categories: '[]', + createdAt: created.createdAt, + updatedAt: created.updatedAt, + publishedAt: new Date('2025-01-01'), + }), + }); + return chain; + }); + + const result = await postEngine.updatePost(created.id, { title: 'Changed Title' }); + + expect(result).not.toBeNull(); + expect(result?.slug).toBe('published-post'); // slug preserved + }); + + it('should allow empty title and use untitled as slug base', async () => { + const created = await postEngine.createPost({ title: '' }); + expect(created.title).toBe(''); + expect(created.slug).toBe('untitled'); + }); }); describe('deletePost', () => {