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
|
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
|
||||||
|
|||||||
@@ -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(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user