feat: more cleanup work in UI
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user