feat: more cleanup work in UI

This commit is contained in:
2026-02-10 15:24:36 +01:00
parent 46970de656
commit 0a6710b684
22 changed files with 1945 additions and 461 deletions

View File

@@ -149,7 +149,12 @@ export class DatabaseConnection {
synced_at INTEGER,
checksum TEXT,
tags TEXT,
categories TEXT
categories TEXT,
published_title TEXT,
published_content TEXT,
published_tags TEXT,
published_categories TEXT,
published_excerpt TEXT
);
CREATE TABLE IF NOT EXISTS media (
@@ -242,6 +247,18 @@ export class DatabaseConnection {
);
}
// Migration: Add published snapshot columns for discard functionality
const publishedContentCol = await this.localClient.execute(
"SELECT name FROM pragma_table_info('posts') WHERE name = 'published_content'"
);
if (publishedContentCol.rows.length === 0) {
await this.localClient.execute("ALTER TABLE posts ADD COLUMN published_title TEXT");
await this.localClient.execute("ALTER TABLE posts ADD COLUMN published_content TEXT");
await this.localClient.execute("ALTER TABLE posts ADD COLUMN published_tags TEXT");
await this.localClient.execute("ALTER TABLE posts ADD COLUMN published_categories TEXT");
await this.localClient.execute("ALTER TABLE posts ADD COLUMN published_excerpt TEXT");
}
// Create FTS5 virtual table for full-text search
await this.localClient.execute(`
CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts USING fts5(

View File

@@ -29,6 +29,12 @@ export const posts = sqliteTable('posts', {
checksum: text('checksum'),
tags: text('tags'), // JSON array stored as text
categories: text('categories'), // JSON array stored as text
// Published snapshot - stores the last published version for discard functionality
publishedTitle: text('published_title'),
publishedContent: text('published_content'),
publishedTags: text('published_tags'), // JSON array stored as text
publishedCategories: text('published_categories'), // JSON array stored as text
publishedExcerpt: text('published_excerpt'),
});
// Media table - stores metadata for images and other media

View File

@@ -112,6 +112,52 @@ export class PostEngine extends EventEmitter {
.replace(/^-|-$/g, '');
}
/**
* Check if a slug is available (not used by any existing post)
* @param slug The slug to check
* @param excludePostId Optional post ID to exclude (for updates)
*/
async isSlugAvailable(slug: string, excludePostId?: string): Promise<boolean> {
const db = getDatabase().getLocal();
const existing = await db
.select({ id: posts.id })
.from(posts)
.where(and(
eq(posts.slug, slug),
eq(posts.projectId, this.currentProjectId)
))
.get();
if (!existing) return true;
if (excludePostId && existing.id === excludePostId) return true;
return false;
}
/**
* Generate a unique slug based on a title
* If the slug already exists, appends -2, -3, etc.
*/
async generateUniqueSlug(title: string, excludePostId?: string): Promise<string> {
const baseSlug = this.generateSlug(title || 'untitled');
if (await this.isSlugAvailable(baseSlug, excludePostId)) {
return baseSlug;
}
// Find next available number
let counter = 2;
while (counter < 1000) {
const candidateSlug = `${baseSlug}-${counter}`;
if (await this.isSlugAvailable(candidateSlug, excludePostId)) {
return candidateSlug;
}
counter++;
}
// Fallback: add timestamp
return `${baseSlug}-${Date.now()}`;
}
private calculateChecksum(content: string): string {
return crypto.createHash('md5').update(content).digest('hex');
}
@@ -177,7 +223,11 @@ export class PostEngine extends EventEmitter {
const client = getDatabase().getLocalClient();
const now = new Date();
const id = uuidv4();
const slug = data.slug || this.generateSlug(data.title || 'untitled');
// Use provided slug or generate a unique one from title
const slug = data.slug
? (await this.isSlugAvailable(data.slug) ? data.slug : await this.generateUniqueSlug(data.title || 'untitled'))
: await this.generateUniqueSlug(data.title || 'untitled');
const post: PostData = {
id,
@@ -539,10 +589,58 @@ export class PostEngine extends EventEmitter {
}
async publishPost(id: string): Promise<PostData | null> {
return this.updatePost(id, {
const db = getDatabase().getLocal();
const existing = await this.getPost(id);
if (!existing) {
return null;
}
// First update the post with published status
const result = await this.updatePost(id, {
status: 'published',
publishedAt: new Date(),
});
if (result) {
// Save the published snapshot for discard functionality
await db.update(posts)
.set({
publishedTitle: result.title,
publishedContent: result.content,
publishedExcerpt: result.excerpt,
publishedTags: JSON.stringify(result.tags),
publishedCategories: JSON.stringify(result.categories),
})
.where(eq(posts.id, id));
}
return result;
}
async discardChanges(id: string): Promise<PostData | null> {
const db = getDatabase().getLocal();
const dbPost = await db.select().from(posts).where(eq(posts.id, id)).get();
if (!dbPost || !dbPost.publishedContent) {
// No published version to revert to
return null;
}
// Revert to the published snapshot
return this.updatePost(id, {
title: dbPost.publishedTitle || dbPost.title,
content: dbPost.publishedContent,
excerpt: dbPost.publishedExcerpt || undefined,
tags: JSON.parse(dbPost.publishedTags || '[]'),
categories: JSON.parse(dbPost.publishedCategories || '[]'),
});
}
async hasPublishedVersion(id: string): Promise<boolean> {
const db = getDatabase().getLocal();
const dbPost = await db.select().from(posts).where(eq(posts.id, id)).get();
return !!(dbPost && dbPost.publishedContent);
}
async unpublishPost(id: string): Promise<PostData | null> {

View File

@@ -63,6 +63,16 @@ export function registerIpcHandlers(): void {
return engine.createPost(data);
});
ipcMain.handle('posts:isSlugAvailable', async (_, slug: string, excludePostId?: string) => {
const engine = getPostEngine();
return engine.isSlugAvailable(slug, excludePostId);
});
ipcMain.handle('posts:generateUniqueSlug', async (_, title: string, excludePostId?: string) => {
const engine = getPostEngine();
return engine.generateUniqueSlug(title, excludePostId);
});
ipcMain.handle('posts:update', async (_, id: string, data: Partial<PostData>) => {
const engine = getPostEngine();
return engine.updatePost(id, data);
@@ -98,6 +108,16 @@ export function registerIpcHandlers(): void {
return engine.unpublishPost(id);
});
ipcMain.handle('posts:discard', async (_, id: string) => {
const engine = getPostEngine();
return engine.discardChanges(id);
});
ipcMain.handle('posts:hasPublishedVersion', async (_, id: string) => {
const engine = getPostEngine();
return engine.hasPublishedVersion(id);
});
ipcMain.handle('posts:rebuildFromFiles', async () => {
const engine = getPostEngine();
return engine.rebuildDatabaseFromFiles();

View File

@@ -24,6 +24,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
getByStatus: (status: string) => ipcRenderer.invoke('posts:getByStatus', status),
publish: (id: string) => ipcRenderer.invoke('posts:publish', id),
unpublish: (id: string) => ipcRenderer.invoke('posts:unpublish', id),
discard: (id: string) => ipcRenderer.invoke('posts:discard', id),
hasPublishedVersion: (id: string) => ipcRenderer.invoke('posts:hasPublishedVersion', id),
rebuildFromFiles: () => ipcRenderer.invoke('posts:rebuildFromFiles'),
search: (query: string) => ipcRenderer.invoke('posts:search', query),
filter: (filter: unknown) => ipcRenderer.invoke('posts:filter', filter),
@@ -33,6 +35,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
getLinksTo: (id: string) => ipcRenderer.invoke('posts:getLinksTo', id),
getLinkedBy: (id: string) => ipcRenderer.invoke('posts:getLinkedBy', id),
rebuildLinks: () => ipcRenderer.invoke('posts:rebuildLinks'),
isSlugAvailable: (slug: string, excludePostId?: string) => ipcRenderer.invoke('posts:isSlugAvailable', slug, excludePostId),
generateUniqueSlug: (title: string, excludePostId?: string) => ipcRenderer.invoke('posts:generateUniqueSlug', title, excludePostId),
},
// Media