fix: post creation working again, also slugs
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -373,7 +373,7 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Post title"
|
||||
placeholder="Untitled"
|
||||
/>
|
||||
</div>
|
||||
<div className="editor-field">
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = () => {
|
||||
>
|
||||
<span className="post-type-icon" title={postType.type}>{postType.icon}</span>
|
||||
<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>
|
||||
</div>
|
||||
@@ -461,7 +460,7 @@ const PostsList: React.FC = () => {
|
||||
>
|
||||
<span className="post-type-icon" title={postType.type}>{postType.icon}</span>
|
||||
<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>
|
||||
</div>
|
||||
@@ -488,7 +487,7 @@ const PostsList: React.FC = () => {
|
||||
>
|
||||
<span className="post-type-icon" title={postType.type}>{postType.icon}</span>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user