fix: post creation working again, also slugs

This commit is contained in:
2026-02-10 16:50:45 +01:00
parent 3f0c767809
commit 5c10ed3fd5
7 changed files with 106 additions and 17 deletions

View File

@@ -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 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. 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. 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 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 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 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 ## Organizing II
Since we have an AI assistant planned for the migration phase, we also want an AI chat feature as another Since we have an AI assistant planned for the migration phase, we also want an AI chat feature as another

View File

@@ -232,7 +232,7 @@ export class PostEngine extends EventEmitter {
const post: PostData = { const post: PostData = {
id, id,
projectId: data.projectId || this.currentProjectId, projectId: data.projectId || this.currentProjectId,
title: data.title || 'Untitled', title: data.title ?? '',
slug, slug,
excerpt: data.excerpt, excerpt: data.excerpt,
content: data.content || '', content: data.content || '',
@@ -303,11 +303,18 @@ export class PostEngine extends EventEmitter {
newStatus = 'draft'; 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 = { const updated: PostData = {
...existing, ...existing,
...data, ...data,
id, // Ensure ID doesn't change id, // Ensure ID doesn't change
projectId: existing.projectId, // Ensure projectId doesn't change projectId: existing.projectId, // Ensure projectId doesn't change
slug: newSlug,
status: newStatus as 'draft' | 'published' | 'archived', status: newStatus as 'draft' | 'published' | 'archived',
updatedAt: new Date(), updatedAt: new Date(),
}; };

View File

@@ -44,8 +44,14 @@ function createWindow(): void {
} }
// Forward events to renderer // Forward events to renderer
ipcMain.on('forward-to-renderer', (_event, eventName: string, ...args: unknown[]) => { // Note: ipcMain.emit() (used by forwardEvent in handlers) is a raw EventEmitter emit,
if (mainWindow && !mainWindow.isDestroyed()) { // 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); mainWindow.webContents.send(eventName, ...args);
} }
}); });

View File

@@ -170,8 +170,8 @@ const App: React.FC = () => {
unsubscribers.push( unsubscribers.push(
window.electronAPI?.on('menu:newPost', async () => { window.electronAPI?.on('menu:newPost', async () => {
const post = await window.electronAPI?.posts.create({ const post = await window.electronAPI?.posts.create({
title: 'New Post', title: '',
content: '# New Post\n\nStart writing...', content: '',
}); });
if (post) { if (post) {
setSelectedPost((post as PostData).id); setSelectedPost((post as PostData).id);

View File

@@ -373,7 +373,7 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
type="text" type="text"
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
placeholder="Post title" placeholder="Untitled"
/> />
</div> </div>
<div className="editor-field"> <div className="editor-field">
@@ -667,18 +667,17 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
}; };
const WelcomeScreen: React.FC = () => { const WelcomeScreen: React.FC = () => {
const { addPost, setSelectedPost } = useAppStore(); const { setSelectedPost } = useAppStore();
const handleNewPost = async () => { const handleNewPost = async () => {
try { try {
const newPost = await window.electronAPI?.posts.create({ const newPost = await window.electronAPI?.posts.create({
title: 'Untitled', title: '',
content: '', content: '',
tags: [], tags: [],
categories: [], categories: [],
}); });
if (newPost) { if (newPost) {
addPost(newPost as PostData);
setSelectedPost(newPost.id); setSelectedPost(newPost.id);
} }
} catch (error) { } catch (error) {

View File

@@ -324,15 +324,14 @@ const PostsList: React.FC = () => {
const handleCreatePost = async () => { const handleCreatePost = async () => {
// Create a real post immediately in the database with default empty content // Create a real post immediately in the database with default empty content
try { try {
const { addPost, setSelectedPost: selectPost } = useAppStore.getState(); const { setSelectedPost: selectPost } = useAppStore.getState();
const newPost = await window.electronAPI?.posts.create({ const newPost = await window.electronAPI?.posts.create({
title: 'Untitled', title: '',
content: '', content: '',
tags: [], tags: [],
categories: [], categories: [],
}); });
if (newPost) { if (newPost) {
addPost(newPost as PostData);
selectPost(newPost.id); selectPost(newPost.id);
} }
} catch (error) { } catch (error) {
@@ -434,7 +433,7 @@ const PostsList: React.FC = () => {
> >
<span className="post-type-icon" title={postType.type}>{postType.icon}</span> <span className="post-type-icon" title={postType.type}>{postType.icon}</span>
<div className="sidebar-item-content"> <div className="sidebar-item-content">
<div className="sidebar-item-title">{post.title}</div> <div className="sidebar-item-title">{post.title || 'Untitled'}</div>
<div className="sidebar-item-meta">{formatDate(post.updatedAt)}</div> <div className="sidebar-item-meta">{formatDate(post.updatedAt)}</div>
</div> </div>
</div> </div>
@@ -461,7 +460,7 @@ const PostsList: React.FC = () => {
> >
<span className="post-type-icon" title={postType.type}>{postType.icon}</span> <span className="post-type-icon" title={postType.type}>{postType.icon}</span>
<div className="sidebar-item-content"> <div className="sidebar-item-content">
<div className="sidebar-item-title">{post.title}</div> <div className="sidebar-item-title">{post.title || 'Untitled'}</div>
<div className="sidebar-item-meta">{formatDate(post.publishedAt || post.updatedAt)}</div> <div className="sidebar-item-meta">{formatDate(post.publishedAt || post.updatedAt)}</div>
</div> </div>
</div> </div>
@@ -488,7 +487,7 @@ const PostsList: React.FC = () => {
> >
<span className="post-type-icon" title={postType.type}>{postType.icon}</span> <span className="post-type-icon" title={postType.type}>{postType.icon}</span>
<div className="sidebar-item-content"> <div className="sidebar-item-content">
<div className="sidebar-item-title">{post.title}</div> <div className="sidebar-item-title">{post.title || 'Untitled'}</div>
<div className="sidebar-item-meta">{formatDate(post.updatedAt)}</div> <div className="sidebar-item-meta">{formatDate(post.updatedAt)}</div>
</div> </div>
</div> </div>

View File

@@ -317,9 +317,9 @@ describe('PostEngine', () => {
expect(ftsInsert).toBeDefined(); 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({}); const post = await postEngine.createPost({});
expect(post.title).toBe('Untitled'); expect(post.title).toBe('');
expect(post.slug).toBe('untitled'); expect(post.slug).toBe('untitled');
}); });
@@ -811,6 +811,75 @@ Original content`);
expect(result?.id).toBe(created.id); // ID preserved expect(result?.id).toBe(created.id); // ID preserved
expect(result?.projectId).toBe('original-project'); // projectId 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', () => { describe('deletePost', () => {