chore: moved to proper drizzle orm and migrations

This commit is contained in:
2026-02-14 16:27:25 +01:00
parent b28993e8b2
commit 0c4f6c2c9c
13 changed files with 2329 additions and 1065 deletions

View File

@@ -1,6 +1,9 @@
import { createClient, Client } from '@libsql/client';
import { drizzle } from 'drizzle-orm/libsql';
import { migrate } from 'drizzle-orm/libsql/migrator';
import { eq, sql } from 'drizzle-orm';
import * as schema from './schema';
import { projects } from './schema';
import { app } from 'electron';
import * as path from 'path';
import * as fs from 'fs';
@@ -77,421 +80,78 @@ export class DatabaseConnection {
}
async getActiveProject(): Promise<{ id: string; name: string; slug: string } | null> {
if (!this.localClient) return null;
const result = await this.localClient.execute('SELECT id, name, slug FROM projects WHERE is_active = 1 LIMIT 1');
if (result.rows.length === 0) return null;
const row = result.rows[0];
return {
id: row.id as string,
name: row.name as string,
slug: row.slug as string,
};
if (!this.localDb) return null;
const rows = await this.localDb
.select({ id: projects.id, name: projects.name, slug: projects.slug })
.from(projects)
.where(eq(projects.isActive, true))
.limit(1);
if (rows.length === 0) return null;
return rows[0];
}
async setActiveProject(projectId: string): Promise<void> {
if (!this.localClient) return;
await this.localClient.execute('UPDATE projects SET is_active = 0');
await this.localClient.execute({
sql: 'UPDATE projects SET is_active = 1 WHERE id = ?',
args: [projectId],
});
if (!this.localDb) return;
// Deactivate all projects
await this.localDb
.update(projects)
.set({ isActive: false });
// Activate the selected project
await this.localDb
.update(projects)
.set({ isActive: true })
.where(eq(projects.id, projectId));
}
private async runMigrations(): Promise<void> {
if (!this.localClient) return;
if (!this.localClient || !this.localDb) return;
// Create tables if they don't exist using batch execution
await this.localClient.executeMultiple(`
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
description TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
is_active INTEGER NOT NULL DEFAULT 0
);
// Determine migrations folder path (works in both dev and production)
// In production, migrations are bundled in the app resources
const isDev = !app.isPackaged;
const migrationsFolder = isDev
? path.join(app.getAppPath(), 'drizzle')
: path.join(process.resourcesPath, 'drizzle');
CREATE TABLE IF NOT EXISTS posts (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL DEFAULT 'default',
title TEXT NOT NULL,
slug TEXT NOT NULL,
excerpt TEXT,
status TEXT NOT NULL DEFAULT 'draft',
author TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
published_at INTEGER,
file_path TEXT NOT NULL,
sync_status TEXT NOT NULL DEFAULT 'pending',
synced_at INTEGER,
checksum TEXT,
tags TEXT,
categories TEXT,
published_title TEXT,
published_content TEXT,
published_tags TEXT,
published_categories TEXT,
published_excerpt TEXT
);
// Run Drizzle migrations (creates __drizzle_migrations table automatically)
await migrate(this.localDb, { migrationsFolder });
CREATE TABLE IF NOT EXISTS media (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL DEFAULT 'default',
filename TEXT NOT NULL,
original_name TEXT NOT NULL,
mime_type TEXT NOT NULL,
size INTEGER NOT NULL,
width INTEGER,
height INTEGER,
alt TEXT,
caption TEXT,
file_path TEXT NOT NULL,
sidecar_path TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
sync_status TEXT NOT NULL DEFAULT 'pending',
synced_at INTEGER,
checksum TEXT,
tags TEXT
);
CREATE TABLE IF NOT EXISTS sync_log (
id TEXT PRIMARY KEY,
entity_type TEXT NOT NULL,
entity_id TEXT NOT NULL,
operation TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
timestamp INTEGER NOT NULL,
error_message TEXT,
retry_count INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS post_links (
id TEXT PRIMARY KEY,
source_post_id TEXT NOT NULL,
target_post_id TEXT NOT NULL,
link_text TEXT,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS post_media (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
post_id TEXT NOT NULL,
media_id TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_posts_slug ON posts(slug);
CREATE INDEX IF NOT EXISTS idx_posts_status ON posts(status);
CREATE INDEX IF NOT EXISTS idx_posts_sync_status ON posts(sync_status);
CREATE INDEX IF NOT EXISTS idx_posts_created_at ON posts(created_at);
CREATE INDEX IF NOT EXISTS idx_media_sync_status ON media(sync_status);
CREATE INDEX IF NOT EXISTS idx_sync_log_status ON sync_log(status);
CREATE INDEX IF NOT EXISTS idx_post_links_source ON post_links(source_post_id);
CREATE INDEX IF NOT EXISTS idx_post_links_target ON post_links(target_post_id);
CREATE INDEX IF NOT EXISTS idx_post_media_post ON post_media(post_id);
CREATE INDEX IF NOT EXISTS idx_post_media_media ON post_media(media_id);
CREATE UNIQUE INDEX IF NOT EXISTS post_media_post_media_idx ON post_media(post_id, media_id);
CREATE UNIQUE INDEX IF NOT EXISTS posts_project_slug_idx ON posts(project_id, slug);
CREATE TABLE IF NOT EXISTS tags (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
name TEXT NOT NULL,
color TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_tags_project_id ON tags(project_id);
CREATE UNIQUE INDEX IF NOT EXISTS tags_project_name_idx ON tags(project_id, name);
`);
// Check if project_id column exists in posts table, add if missing (migration)
const postsColumns = await this.localClient.execute(
"SELECT name FROM pragma_table_info('posts') WHERE name = 'project_id'"
);
if (postsColumns.rows.length === 0) {
await this.localClient.execute(
"ALTER TABLE posts ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default'"
);
await this.localClient.execute(
"CREATE INDEX IF NOT EXISTS idx_posts_project_id ON posts(project_id)"
);
} else {
await this.localClient.execute(
"CREATE INDEX IF NOT EXISTS idx_posts_project_id ON posts(project_id)"
);
}
// Check if project_id column exists in media table, add if missing (migration)
const mediaColumns = await this.localClient.execute(
"SELECT name FROM pragma_table_info('media') WHERE name = 'project_id'"
);
if (mediaColumns.rows.length === 0) {
await this.localClient.execute(
"ALTER TABLE media ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default'"
);
await this.localClient.execute(
"CREATE INDEX IF NOT EXISTS idx_media_project_id ON media(project_id)"
);
} else {
await this.localClient.execute(
"CREATE INDEX IF NOT EXISTS idx_media_project_id ON media(project_id)"
);
}
// 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");
}
// Migration: Add content column for draft body text stored in DB
const contentCol = await this.localClient.execute(
"SELECT name FROM pragma_table_info('posts') WHERE name = 'content'"
);
if (contentCol.rows.length === 0) {
await this.localClient.execute("ALTER TABLE posts ADD COLUMN content TEXT");
}
// Migration: Update slug unique constraint to be project-scoped
// SQLite doesn't allow dropping column-level UNIQUE constraints, so we must recreate the table
// Check if the posts table has a column-level UNIQUE on slug (from the table definition)
const tableInfo = await this.localClient.execute(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='posts'"
);
const tableSql = tableInfo.rows[0]?.sql as string || '';
const hasColumnLevelUnique = tableSql.includes('slug TEXT NOT NULL UNIQUE') ||
tableSql.includes('slug TEXT UNIQUE') ||
/slug\s+TEXT[^,]*UNIQUE/i.test(tableSql);
if (hasColumnLevelUnique) {
console.log('Migrating posts table to remove column-level UNIQUE constraint on slug...');
// Create new table without the UNIQUE constraint
await this.localClient.execute(`
CREATE TABLE IF NOT EXISTS posts_new (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL DEFAULT 'default',
title TEXT NOT NULL,
slug TEXT NOT NULL,
excerpt TEXT,
content TEXT,
status TEXT NOT NULL DEFAULT 'draft',
author TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
published_at INTEGER,
file_path TEXT NOT NULL DEFAULT '',
sync_status TEXT NOT NULL DEFAULT 'pending',
synced_at INTEGER,
checksum TEXT,
tags TEXT,
categories TEXT,
published_title TEXT,
published_content TEXT,
published_tags TEXT,
published_categories TEXT,
published_excerpt TEXT
)
`);
// Copy data
await this.localClient.execute(`
INSERT INTO posts_new
SELECT id, project_id, title, slug, excerpt, content, status, author,
created_at, updated_at, published_at, file_path, sync_status,
synced_at, checksum, tags, categories, published_title,
published_content, published_tags, published_categories, published_excerpt
FROM posts
`);
// Drop old table and rename new one
await this.localClient.execute('DROP TABLE posts');
await this.localClient.execute('ALTER TABLE posts_new RENAME TO posts');
// Recreate indexes
await this.localClient.execute('CREATE INDEX IF NOT EXISTS idx_posts_slug ON posts(slug)');
await this.localClient.execute('CREATE INDEX IF NOT EXISTS idx_posts_status ON posts(status)');
await this.localClient.execute('CREATE INDEX IF NOT EXISTS idx_posts_sync_status ON posts(sync_status)');
await this.localClient.execute('CREATE INDEX IF NOT EXISTS idx_posts_created_at ON posts(created_at)');
await this.localClient.execute('CREATE INDEX IF NOT EXISTS idx_posts_project_id ON posts(project_id)');
await this.localClient.execute('CREATE UNIQUE INDEX IF NOT EXISTS posts_project_slug_idx ON posts(project_id, slug)');
console.log('Posts table migration complete');
} else {
// Just ensure the composite unique index exists
const compositeSlugIndex = await this.localClient.execute(
"SELECT name FROM sqlite_master WHERE type='index' AND name='posts_project_slug_idx' AND tbl_name='posts'"
);
if (compositeSlugIndex.rows.length === 0) {
await this.localClient.execute(
"CREATE UNIQUE INDEX posts_project_slug_idx ON posts(project_id, slug)"
);
}
}
// Create FTS5 virtual table for full-text search
// Stores: id (unindexed, for lookups), project_id (unindexed, for filtering), content (stemmed text for matching)
// Post data for display comes from the posts table or filesystem files
// Create FTS5 virtual tables (not supported by Drizzle schema)
// These use IF NOT EXISTS so they're safe to run every time
await this.localClient.execute(`
CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts USING fts5(
id UNINDEXED,
project_id UNINDEXED,
content,
content_rowid=rowid
);
)
`);
// Migration: Check if old FTS schema exists and recreate with project_id
// Old schema had: id, content (or even older: id, title, content, excerpt, tags, categories)
// New schema has: id, project_id, content (for project-scoped search)
try {
// Try to query project_id - if it doesn't exist, we need to migrate
await this.localClient.execute("SELECT project_id FROM posts_fts LIMIT 0");
// project_id exists, check for old multi-column schema
try {
await this.localClient.execute("SELECT title FROM posts_fts LIMIT 0");
// Old multi-column schema exists - recreate
console.log('Migrating posts_fts table to new schema with project_id...');
await this.localClient.execute('DROP TABLE IF EXISTS posts_fts');
await this.localClient.execute(`
CREATE VIRTUAL TABLE posts_fts USING fts5(
id UNINDEXED,
project_id UNINDEXED,
content,
content_rowid=rowid
);
`);
console.log('FTS table migrated - rebuild index required');
} catch {
// No title column - we have the correct new schema
}
} catch {
// project_id doesn't exist - migrate from old schema
console.log('Migrating posts_fts table to add project_id...');
await this.localClient.execute('DROP TABLE IF EXISTS posts_fts');
await this.localClient.execute(`
CREATE VIRTUAL TABLE posts_fts USING fts5(
id UNINDEXED,
project_id UNINDEXED,
content,
content_rowid=rowid
);
`);
console.log('FTS table migrated - rebuild index required');
}
// Create FTS5 virtual table for media full-text search
// Stores: id (unindexed, for lookups), project_id (unindexed, for filtering),
// content (stemmed text from original_name, alt, caption, tags)
await this.localClient.execute(`
CREATE VIRTUAL TABLE IF NOT EXISTS media_fts USING fts5(
id UNINDEXED,
project_id UNINDEXED,
content,
content_rowid=rowid
);
)
`);
// Migration: Ensure tags table exists (for databases created before tags feature)
const tagsTableExists = await this.localClient.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='tags'"
);
if (tagsTableExists.rows.length === 0) {
console.log('Creating tags table...');
await this.localClient.execute(`
CREATE TABLE tags (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
name TEXT NOT NULL,
color TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`);
await this.localClient.execute('CREATE INDEX idx_tags_project_id ON tags(project_id)');
await this.localClient.execute('CREATE UNIQUE INDEX tags_project_name_idx ON tags(project_id, name)');
console.log('Tags table created successfully');
}
// Migration: Add data_path column to projects table
const dataPathCol = await this.localClient.execute(
"SELECT name FROM pragma_table_info('projects') WHERE name = 'data_path'"
);
if (dataPathCol.rows.length === 0) {
await this.localClient.execute("ALTER TABLE projects ADD COLUMN data_path TEXT");
}
// Create default project if none exists
const existingProjects = await this.localClient.execute('SELECT COUNT(*) as count FROM projects');
if (existingProjects.rows[0] && (existingProjects.rows[0].count as number) === 0) {
const now = Date.now();
await this.localClient.execute({
sql: 'INSERT INTO projects (id, name, slug, description, created_at, updated_at, is_active) VALUES (?, ?, ?, ?, ?, ?, ?)',
args: ['default', 'Default Project', 'default', 'Your first blog project', now, now, 1],
const existingProjects = await this.localDb
.select({ count: sql<number>`COUNT(*)` })
.from(projects);
if (existingProjects[0] && existingProjects[0].count === 0) {
const now = new Date();
await this.localDb.insert(projects).values({
id: 'default',
name: 'Default Project',
slug: 'default',
description: 'Your first blog project',
createdAt: now,
updatedAt: now,
isActive: true,
});
}
// Create chat_conversations table for AI chat persistence
await this.localClient.execute(`
CREATE TABLE IF NOT EXISTS chat_conversations (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
model TEXT,
copilot_session_id TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`);
await this.localClient.execute('CREATE INDEX IF NOT EXISTS idx_chat_conversations_updated_at ON chat_conversations(updated_at)');
// Create chat_messages table for storing conversation messages
await this.localClient.execute(`
CREATE TABLE IF NOT EXISTS chat_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT,
tool_call_id TEXT,
tool_calls TEXT,
created_at INTEGER NOT NULL,
FOREIGN KEY (conversation_id) REFERENCES chat_conversations(id) ON DELETE CASCADE
)
`);
await this.localClient.execute('CREATE INDEX IF NOT EXISTS idx_chat_messages_conversation_id ON chat_messages(conversation_id)');
// Create import_definitions table for WXR import configurations
await this.localClient.execute(`
CREATE TABLE IF NOT EXISTS import_definitions (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
name TEXT NOT NULL,
wxr_file_path TEXT,
uploads_folder_path TEXT,
last_analysis_result TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`);
await this.localClient.execute('CREATE INDEX IF NOT EXISTS idx_import_definitions_project_id ON import_definitions(project_id)');
}
async close(): Promise<void> {

View File

@@ -8,7 +8,9 @@
*/
import { v4 as uuidv4 } from 'uuid';
import { eq, desc, asc } from 'drizzle-orm';
import { DatabaseConnection } from '../database/connection';
import { chatConversations, chatMessages, settings } from '../database/schema';
export interface ChatConversationData {
id: string;
@@ -45,19 +47,18 @@ export class ChatEngine {
* Create a new chat conversation
*/
async createConversation(input: CreateConversationInput = {}): Promise<ChatConversationData> {
const client = this.db.getLocalClient();
if (!client) {
throw new Error('Database not initialized');
}
const drizzle = this.db.getLocal();
const id = `chat_${uuidv4()}`;
const title = input.title || 'New Chat';
const model = input.model || 'claude-sonnet-4';
const now = Date.now();
const now = new Date();
await client.execute({
sql: `INSERT INTO chat_conversations (id, title, model, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`,
args: [id, title, model, now, now],
await drizzle.insert(chatConversations).values({
id,
title,
model,
createdAt: now,
updatedAt: now,
});
// Add system prompt as first message if provided
@@ -66,7 +67,7 @@ export class ChatEngine {
conversationId: id,
role: 'system',
content: input.systemPrompt,
createdAt: new Date(now),
createdAt: now,
});
}
@@ -74,8 +75,8 @@ export class ChatEngine {
id,
title,
model,
createdAt: new Date(now),
updatedAt: new Date(now),
createdAt: now,
updatedAt: now,
};
}
@@ -83,42 +84,40 @@ export class ChatEngine {
* Get a conversation by ID with all messages
*/
async getConversation(id: string): Promise<(ChatConversationData & { messages: ChatMessageData[] }) | null> {
const client = this.db.getLocalClient();
if (!client) {
throw new Error('Database not initialized');
}
const drizzle = this.db.getLocal();
const convResult = await client.execute({
sql: `SELECT * FROM chat_conversations WHERE id = ?`,
args: [id],
});
const rows = await drizzle
.select()
.from(chatConversations)
.where(eq(chatConversations.id, id));
if (convResult.rows.length === 0) {
if (rows.length === 0) {
return null;
}
const row = convResult.rows[0];
const row = rows[0];
const conversation: ChatConversationData = {
id: row.id as string,
title: row.title as string,
model: row.model as string | undefined,
createdAt: new Date(row.created_at as number),
updatedAt: new Date(row.updated_at as number),
id: row.id,
title: row.title,
model: row.model || undefined,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
const messagesResult = await client.execute({
sql: `SELECT * FROM chat_messages WHERE conversation_id = ? ORDER BY created_at ASC`,
args: [id],
});
const messageRows = await drizzle
.select()
.from(chatMessages)
.where(eq(chatMessages.conversationId, id))
.orderBy(asc(chatMessages.createdAt));
const messages: ChatMessageData[] = messagesResult.rows.map(r => ({
id: r.id as number,
conversationId: r.conversation_id as string,
role: r.role as 'system' | 'user' | 'assistant' | 'tool',
content: r.content as string | undefined,
toolCallId: r.tool_call_id as string | undefined,
toolCalls: r.tool_calls as string | undefined,
createdAt: new Date(r.created_at as number),
const messages: ChatMessageData[] = messageRows.map(r => ({
id: r.id,
conversationId: r.conversationId,
role: r.role,
content: r.content || undefined,
toolCallId: r.toolCallId || undefined,
toolCalls: r.toolCalls || undefined,
createdAt: r.createdAt,
}));
return { ...conversation, messages };
@@ -128,22 +127,20 @@ export class ChatEngine {
* Get all conversations, sorted by most recently updated
*/
async getRecentConversations(limit: number = 50): Promise<ChatConversationData[]> {
const client = this.db.getLocalClient();
if (!client) {
throw new Error('Database not initialized');
}
const drizzle = this.db.getLocal();
const result = await client.execute({
sql: `SELECT * FROM chat_conversations ORDER BY updated_at DESC LIMIT ?`,
args: [limit],
});
const rows = await drizzle
.select()
.from(chatConversations)
.orderBy(desc(chatConversations.updatedAt))
.limit(limit);
return result.rows.map(row => ({
id: row.id as string,
title: row.title as string,
model: row.model as string | undefined,
createdAt: new Date(row.created_at as number),
updatedAt: new Date(row.updated_at as number),
return rows.map(row => ({
id: row.id,
title: row.title,
model: row.model || undefined,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
}));
}
@@ -151,90 +148,66 @@ export class ChatEngine {
* Update a conversation's metadata
*/
async updateConversation(id: string, updates: Partial<Pick<ChatConversationData, 'title' | 'model'>>): Promise<void> {
const client = this.db.getLocalClient();
if (!client) {
throw new Error('Database not initialized');
}
const drizzle = this.db.getLocal();
const setClauses: string[] = ['updated_at = ?'];
const args: (string | number | null)[] = [Date.now()];
if (updates.title !== undefined) {
setClauses.push('title = ?');
args.push(updates.title);
}
if (updates.model !== undefined) {
setClauses.push('model = ?');
args.push(updates.model);
}
args.push(id);
await client.execute({
sql: `UPDATE chat_conversations SET ${setClauses.join(', ')} WHERE id = ?`,
args,
});
await drizzle
.update(chatConversations)
.set({
...updates,
updatedAt: new Date(),
})
.where(eq(chatConversations.id, id));
}
/**
* Delete a conversation and all its messages
*/
async deleteConversation(id: string): Promise<void> {
const client = this.db.getLocalClient();
if (!client) {
throw new Error('Database not initialized');
}
const drizzle = this.db.getLocal();
// Messages are deleted via CASCADE, but let's be explicit
await client.execute({
sql: `DELETE FROM chat_messages WHERE conversation_id = ?`,
args: [id],
});
await drizzle
.delete(chatMessages)
.where(eq(chatMessages.conversationId, id));
await client.execute({
sql: `DELETE FROM chat_conversations WHERE id = ?`,
args: [id],
});
await drizzle
.delete(chatConversations)
.where(eq(chatConversations.id, id));
}
/**
* Add a message to a conversation
*/
async addMessage(message: Omit<ChatMessageData, 'id'>): Promise<ChatMessageData> {
const client = this.db.getLocalClient();
if (!client) {
throw new Error('Database not initialized');
}
const drizzle = this.db.getLocal();
const createdAt = message.createdAt || new Date();
const createdAt = message.createdAt?.getTime() || Date.now();
const result = await client.execute({
sql: `INSERT INTO chat_messages (conversation_id, role, content, tool_call_id, tool_calls, created_at)
VALUES (?, ?, ?, ?, ?, ?)`,
args: [
message.conversationId,
message.role,
message.content || null,
message.toolCallId || null,
message.toolCalls || null,
const result = await drizzle
.insert(chatMessages)
.values({
conversationId: message.conversationId,
role: message.role,
content: message.content || null,
toolCallId: message.toolCallId || null,
toolCalls: message.toolCalls || null,
createdAt,
],
});
})
.returning({ id: chatMessages.id });
// Update conversation's updated_at timestamp
await client.execute({
sql: `UPDATE chat_conversations SET updated_at = ? WHERE id = ?`,
args: [createdAt, message.conversationId],
});
await drizzle
.update(chatConversations)
.set({ updatedAt: createdAt })
.where(eq(chatConversations.id, message.conversationId));
return {
id: Number(result.lastInsertRowid),
id: result[0].id,
conversationId: message.conversationId,
role: message.role,
content: message.content,
toolCallId: message.toolCallId,
toolCalls: message.toolCalls,
createdAt: new Date(createdAt),
createdAt,
};
}
@@ -242,24 +215,22 @@ export class ChatEngine {
* Get messages for a conversation
*/
async getMessages(conversationId: string): Promise<ChatMessageData[]> {
const client = this.db.getLocalClient();
if (!client) {
throw new Error('Database not initialized');
}
const drizzle = this.db.getLocal();
const result = await client.execute({
sql: `SELECT * FROM chat_messages WHERE conversation_id = ? ORDER BY created_at ASC`,
args: [conversationId],
});
const rows = await drizzle
.select()
.from(chatMessages)
.where(eq(chatMessages.conversationId, conversationId))
.orderBy(asc(chatMessages.createdAt));
return result.rows.map(r => ({
id: r.id as number,
conversationId: r.conversation_id as string,
role: r.role as 'system' | 'user' | 'assistant' | 'tool',
content: r.content as string | undefined,
toolCallId: r.tool_call_id as string | undefined,
toolCalls: r.tool_calls as string | undefined,
createdAt: new Date(r.created_at as number),
return rows.map(r => ({
id: r.id,
conversationId: r.conversationId,
role: r.role,
content: r.content || undefined,
toolCallId: r.toolCallId || undefined,
toolCalls: r.toolCalls || undefined,
createdAt: r.createdAt,
}));
}
@@ -267,34 +238,27 @@ export class ChatEngine {
* Clear all messages from a conversation (but keep the conversation)
*/
async clearMessages(conversationId: string): Promise<void> {
const client = this.db.getLocalClient();
if (!client) {
throw new Error('Database not initialized');
}
const drizzle = this.db.getLocal();
await client.execute({
sql: `DELETE FROM chat_messages WHERE conversation_id = ?`,
args: [conversationId],
});
await drizzle
.delete(chatMessages)
.where(eq(chatMessages.conversationId, conversationId));
}
/**
* Get default system prompt for new conversations
*/
async getDefaultSystemPrompt(): Promise<string> {
const client = this.db.getLocalClient();
if (!client) {
return this.getBuiltInSystemPrompt();
}
const drizzle = this.db.getLocal();
const result = await client.execute({
sql: `SELECT value FROM settings WHERE key = 'chat_system_prompt'`,
args: [],
});
const rows = await drizzle
.select()
.from(settings)
.where(eq(settings.key, 'chat_system_prompt'));
// Return saved prompt if it exists and is non-empty
if (result.rows.length > 0 && result.rows[0].value) {
return result.rows[0].value as string;
if (rows.length > 0 && rows[0].value) {
return rows[0].value;
}
return this.getBuiltInSystemPrompt();
@@ -305,25 +269,30 @@ export class ChatEngine {
* Pass empty string to reset to built-in default.
*/
async setDefaultSystemPrompt(prompt: string): Promise<void> {
const client = this.db.getLocalClient();
if (!client) {
throw new Error('Database not initialized');
}
const drizzle = this.db.getLocal();
// If empty string, delete the setting to use built-in default
if (!prompt || prompt.trim() === '') {
await client.execute({
sql: `DELETE FROM settings WHERE key = ?`,
args: ['chat_system_prompt'],
});
await drizzle
.delete(settings)
.where(eq(settings.key, 'chat_system_prompt'));
return;
}
const now = Date.now();
await client.execute({
sql: `INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, ?)`,
args: ['chat_system_prompt', prompt, now],
});
await drizzle
.insert(settings)
.values({
key: 'chat_system_prompt',
value: prompt,
updatedAt: new Date(),
})
.onConflictDoUpdate({
target: settings.key,
set: {
value: prompt,
updatedAt: new Date(),
},
});
}
/**
@@ -360,16 +329,15 @@ When answering questions:
* Get a setting by key
*/
async getSetting(key: string): Promise<string | null> {
const client = this.db.getLocalClient();
if (!client) return null;
const drizzle = this.db.getLocal();
const result = await client.execute({
sql: `SELECT value FROM settings WHERE key = ?`,
args: [key],
});
const rows = await drizzle
.select()
.from(settings)
.where(eq(settings.key, key));
if (result.rows.length > 0) {
return result.rows[0].value as string;
if (rows.length > 0) {
return rows[0].value;
}
return null;
}
@@ -378,34 +346,37 @@ When answering questions:
* Set a setting by key
*/
async setSetting(key: string, value: string): Promise<void> {
const client = this.db.getLocalClient();
if (!client) {
throw new Error('Database not initialized');
}
const drizzle = this.db.getLocal();
const now = Date.now();
await client.execute({
sql: `INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, ?)`,
args: [key, value, now],
});
await drizzle
.insert(settings)
.values({
key,
value,
updatedAt: new Date(),
})
.onConflictDoUpdate({
target: settings.key,
set: {
value,
updatedAt: new Date(),
},
});
}
/**
* Get selected model for new conversations
*/
async getSelectedModel(): Promise<string> {
const client = this.db.getLocalClient();
if (!client) {
return 'claude-sonnet-4';
}
const drizzle = this.db.getLocal();
const result = await client.execute({
sql: `SELECT value FROM settings WHERE key = 'chat_model'`,
args: [],
});
const rows = await drizzle
.select()
.from(settings)
.where(eq(settings.key, 'chat_model'));
if (result.rows.length > 0) {
return result.rows[0].value as string;
if (rows.length > 0) {
return rows[0].value;
}
return 'claude-sonnet-4';
@@ -415,15 +386,21 @@ When answering questions:
* Set selected model for new conversations
*/
async setSelectedModel(model: string): Promise<void> {
const client = this.db.getLocalClient();
if (!client) {
throw new Error('Database not initialized');
}
const drizzle = this.db.getLocal();
const now = Date.now();
await client.execute({
sql: `INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, ?)`,
args: ['chat_model', model, now],
});
await drizzle
.insert(settings)
.values({
key: 'chat_model',
value: model,
updatedAt: new Date(),
})
.onConflictDoUpdate({
target: settings.key,
set: {
value: model,
updatedAt: new Date(),
},
});
}
}

View File

@@ -6,7 +6,9 @@
*/
import { v4 as uuidv4 } from 'uuid';
import { eq, and, desc } from 'drizzle-orm';
import { getDatabase } from '../database';
import { importDefinitions } from '../database/schema';
export interface ImportDefinitionData {
id: string;
@@ -22,12 +24,8 @@ export interface ImportDefinitionData {
export class ImportDefinitionEngine {
private currentProjectId: string = 'default';
private getClient() {
const client = getDatabase().getLocalClient();
if (!client) {
throw new Error('Database not initialized');
}
return client;
private getDb() {
return getDatabase().getLocal();
}
setProjectContext(projectId: string): void {
@@ -39,15 +37,20 @@ export class ImportDefinitionEngine {
}
async createDefinition(name?: string): Promise<ImportDefinitionData> {
const client = this.getClient();
const db = this.getDb();
const id = `import_${uuidv4()}`;
const now = Date.now();
const now = new Date();
const defName = name || 'Untitled Import';
await client.execute({
sql: `INSERT INTO import_definitions (id, project_id, name, wxr_file_path, uploads_folder_path, last_analysis_result, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
args: [id, this.currentProjectId, defName, null, null, null, now, now],
await db.insert(importDefinitions).values({
id,
projectId: this.currentProjectId,
name: defName,
wxrFilePath: null,
uploadsFolderPath: null,
lastAnalysisResult: null,
createdAt: now,
updatedAt: now,
});
return {
@@ -57,31 +60,37 @@ export class ImportDefinitionEngine {
wxrFilePath: null,
uploadsFolderPath: null,
lastAnalysisResult: null,
createdAt: new Date(now).toISOString(),
updatedAt: new Date(now).toISOString(),
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
};
}
async getDefinition(id: string): Promise<ImportDefinitionData | null> {
const client = this.getClient();
const result = await client.execute({
sql: `SELECT * FROM import_definitions WHERE id = ? AND project_id = ?`,
args: [id, this.currentProjectId],
});
const db = this.getDb();
if (result.rows.length === 0) return null;
const rows = await db
.select()
.from(importDefinitions)
.where(and(
eq(importDefinitions.id, id),
eq(importDefinitions.projectId, this.currentProjectId)
));
return this.rowToData(result.rows[0] as any);
if (rows.length === 0) return null;
return this.rowToData(rows[0]);
}
async getAllForProject(): Promise<ImportDefinitionData[]> {
const client = this.getClient();
const result = await client.execute({
sql: `SELECT * FROM import_definitions WHERE project_id = ? ORDER BY updated_at DESC`,
args: [this.currentProjectId],
});
const db = this.getDb();
return result.rows.map((row: any) => this.rowToData(row));
const rows = await db
.select()
.from(importDefinitions)
.where(eq(importDefinitions.projectId, this.currentProjectId))
.orderBy(desc(importDefinitions.updatedAt));
return rows.map(row => this.rowToData(row));
}
async updateDefinition(
@@ -92,42 +101,35 @@ export class ImportDefinitionEngine {
const existing = await this.getDefinition(id);
if (!existing) return null;
const setClauses: string[] = [];
const args: any[] = [];
const db = this.getDb();
// Build update object
const updateData: Record<string, unknown> = {
updatedAt: new Date(),
};
if (updates.name !== undefined) {
setClauses.push('name = ?');
args.push(updates.name);
updateData.name = updates.name;
}
if (updates.wxrFilePath !== undefined) {
setClauses.push('wxr_file_path = ?');
args.push(updates.wxrFilePath);
updateData.wxrFilePath = updates.wxrFilePath;
}
if (updates.uploadsFolderPath !== undefined) {
setClauses.push('uploads_folder_path = ?');
args.push(updates.uploadsFolderPath);
updateData.uploadsFolderPath = updates.uploadsFolderPath;
}
if (updates.lastAnalysisResult !== undefined) {
setClauses.push('last_analysis_result = ?');
args.push(typeof updates.lastAnalysisResult === 'string'
updateData.lastAnalysisResult = typeof updates.lastAnalysisResult === 'string'
? updates.lastAnalysisResult
: JSON.stringify(updates.lastAnalysisResult));
: JSON.stringify(updates.lastAnalysisResult);
}
if (setClauses.length === 0) return existing;
const now = Date.now();
setClauses.push('updated_at = ?');
args.push(now);
// WHERE clause args
args.push(id, this.currentProjectId);
const client = this.getClient();
await client.execute({
sql: `UPDATE import_definitions SET ${setClauses.join(', ')} WHERE id = ? AND project_id = ?`,
args,
});
await db
.update(importDefinitions)
.set(updateData)
.where(and(
eq(importDefinitions.id, id),
eq(importDefinitions.projectId, this.currentProjectId)
));
return this.getDefinition(id);
}
@@ -137,38 +139,41 @@ export class ImportDefinitionEngine {
const existing = await this.getDefinition(id);
if (!existing) return false;
const client = this.getClient();
await client.execute({
sql: `DELETE FROM import_definitions WHERE id = ? AND project_id = ?`,
args: [id, this.currentProjectId],
});
const db = this.getDb();
await db
.delete(importDefinitions)
.where(and(
eq(importDefinitions.id, id),
eq(importDefinitions.projectId, this.currentProjectId)
));
return true;
}
private rowToData(row: any): ImportDefinitionData {
private rowToData(row: typeof importDefinitions.$inferSelect): ImportDefinitionData {
let parsedResult: unknown | null = null;
if (row.last_analysis_result) {
if (row.lastAnalysisResult) {
try {
parsedResult = JSON.parse(row.last_analysis_result);
parsedResult = JSON.parse(row.lastAnalysisResult);
} catch {
parsedResult = row.last_analysis_result;
parsedResult = row.lastAnalysisResult;
}
}
return {
id: row.id,
projectId: row.project_id,
projectId: row.projectId,
name: row.name,
wxrFilePath: row.wxr_file_path ?? null,
uploadsFolderPath: row.uploads_folder_path ?? null,
wxrFilePath: row.wxrFilePath ?? null,
uploadsFolderPath: row.uploadsFolderPath ?? null,
lastAnalysisResult: parsedResult,
createdAt: typeof row.created_at === 'number'
? new Date(row.created_at).toISOString()
: row.created_at,
updatedAt: typeof row.updated_at === 'number'
? new Date(row.updated_at).toISOString()
: row.updated_at,
createdAt: row.createdAt instanceof Date
? row.createdAt.toISOString()
: new Date(row.createdAt as unknown as number).toISOString(),
updatedAt: row.updatedAt instanceof Date
? row.updatedAt.toISOString()
: new Date(row.updatedAt as unknown as number).toISOString(),
};
}
}

View File

@@ -3,7 +3,9 @@ import { v4 as uuidv4 } from 'uuid';
import * as fs from 'fs/promises';
import * as path from 'path';
import { app } from 'electron';
import { eq, and, asc, sql, like } from 'drizzle-orm';
import { getDatabase } from '../database';
import { tags, posts } from '../database/schema';
import { taskManager } from './TaskManager';
/**
@@ -125,6 +127,15 @@ export class TagEngine extends EventEmitter {
super();
}
private getDb() {
return getDatabase().getLocal();
}
// For JSON operations that Drizzle doesn't support natively
private getClient() {
return getDatabase().getLocalClient();
}
/**
* Returns the default internal project directory (in userData).
*/
@@ -167,11 +178,10 @@ export class TagEngine extends EventEmitter {
* Get all tags with their post counts for the tag cloud
*/
async getTagsWithCounts(): Promise<TagWithCount[]> {
const client = getDatabase().getLocalClient();
const client = this.getClient();
if (!client) return [];
// Query tags with counts from posts
// Use a subquery to count posts per tag name
// Query tags with counts from posts - requires raw SQL for JSON operations
const result = await client.execute({
sql: `
SELECT
@@ -202,8 +212,7 @@ export class TagEngine extends EventEmitter {
* Create a new tag
*/
async createTag(input: CreateTagInput): Promise<TagData> {
const client = getDatabase().getLocalClient();
if (!client) throw new Error('Database not initialized');
const db = this.getDb();
const name = input.name.trim().toLowerCase();
if (!name) {
@@ -215,29 +224,36 @@ export class TagEngine extends EventEmitter {
throw new Error('Invalid color format. Use hex format like #ff0000 or #f00');
}
// Check for duplicate
const existing = await client.execute({
sql: 'SELECT id, name FROM tags WHERE project_id = ? AND LOWER(name) = LOWER(?)',
args: [this.currentProjectId, name],
});
// Check for duplicate using Drizzle
const existing = await db
.select({ id: tags.id })
.from(tags)
.where(and(
eq(tags.projectId, this.currentProjectId),
sql`LOWER(${tags.name}) = LOWER(${name})`
));
if (existing.rows.length > 0) {
if (existing.length > 0) {
throw new Error(`Tag "${name}" already exists`);
}
const now = Date.now();
const now = new Date();
const tag: TagData = {
id: uuidv4(),
projectId: this.currentProjectId,
name,
color: input.color,
createdAt: new Date(now),
updatedAt: new Date(now),
createdAt: now,
updatedAt: now,
};
await client.execute({
sql: 'INSERT INTO tags (id, project_id, name, color, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)',
args: [tag.id, tag.projectId, tag.name, tag.color || null, now, now],
await db.insert(tags).values({
id: tag.id,
projectId: tag.projectId,
name: tag.name,
color: tag.color || null,
createdAt: now,
updatedAt: now,
});
this.emit('tagCreated', tag);
@@ -250,57 +266,53 @@ export class TagEngine extends EventEmitter {
* Update a tag
*/
async updateTag(id: string, input: UpdateTagInput): Promise<TagData | null> {
const client = getDatabase().getLocalClient();
if (!client) return null;
const db = this.getDb();
// Get existing tag
const existing = await client.execute({
sql: 'SELECT * FROM tags WHERE id = ? AND project_id = ?',
args: [id, this.currentProjectId],
});
const existing = await db
.select()
.from(tags)
.where(and(
eq(tags.id, id),
eq(tags.projectId, this.currentProjectId)
));
if (existing.rows.length === 0) {
if (existing.length === 0) {
return null;
}
const row = existing.rows[0] as any;
const row = existing[0];
// Validate color if provided
if (input.color !== undefined && input.color !== null && !isValidHexColor(input.color)) {
throw new Error('Invalid color format. Use hex format like #ff0000 or #f00');
}
const now = Date.now();
const updates: string[] = [];
const args: any[] = [];
if (input.color !== undefined) {
updates.push('color = ?');
args.push(input.color);
}
if (updates.length === 0) {
if (input.color === undefined) {
// No updates
return this.rowToTagData(row);
}
updates.push('updated_at = ?');
args.push(now);
args.push(id);
args.push(this.currentProjectId);
const now = new Date();
await client.execute({
sql: `UPDATE tags SET ${updates.join(', ')} WHERE id = ? AND project_id = ?`,
args,
});
await db
.update(tags)
.set({
color: input.color,
updatedAt: now,
})
.where(and(
eq(tags.id, id),
eq(tags.projectId, this.currentProjectId)
));
const updatedTag: TagData = {
id: row.id,
projectId: row.project_id,
projectId: row.projectId,
name: row.name,
color: input.color !== undefined ? input.color || undefined : row.color,
createdAt: new Date(row.created_at),
updatedAt: new Date(now),
color: input.color !== undefined ? input.color || undefined : row.color || undefined,
createdAt: row.createdAt,
updatedAt: now,
};
this.emit('tagUpdated', updatedTag);
@@ -313,21 +325,25 @@ export class TagEngine extends EventEmitter {
* Delete a tag and remove it from all posts (runs as background task)
*/
async deleteTag(id: string): Promise<DeleteTagResult> {
const client = getDatabase().getLocalClient();
const db = this.getDb();
const client = this.getClient();
if (!client) throw new Error('Database not initialized');
// Get tag
const tagResult = await client.execute({
sql: 'SELECT * FROM tags WHERE id = ? AND project_id = ?',
args: [id, this.currentProjectId],
});
const tagRows = await db
.select()
.from(tags)
.where(and(
eq(tags.id, id),
eq(tags.projectId, this.currentProjectId)
));
if (tagResult.rows.length === 0) {
if (tagRows.length === 0) {
throw new Error('Tag not found');
}
const tag = tagResult.rows[0] as any;
const tagName = tag.name as string;
const tag = tagRows[0];
const tagName = tag.name;
// Run the deletion as a background task
return taskManager.runTask({
@@ -336,15 +352,15 @@ export class TagEngine extends EventEmitter {
execute: async (onProgress) => {
onProgress(0, `Finding posts with tag "${tagName}"...`);
// Find all posts with this tag
// Find all posts with this tag - requires raw SQL for JSON
const postsResult = await client.execute({
sql: `SELECT id, tags FROM posts WHERE project_id = ? AND tags LIKE ?`,
args: [this.currentProjectId, `%"${tagName}"%`],
});
const postsToUpdate = postsResult.rows.filter((row: any) => {
const tags: string[] = JSON.parse(row.tags || '[]');
return tags.includes(tagName);
const postTags: string[] = JSON.parse(row.tags || '[]');
return postTags.includes(tagName);
});
const total = postsToUpdate.length;
@@ -352,13 +368,16 @@ export class TagEngine extends EventEmitter {
for (const row of postsToUpdate) {
const postId = row.id as string;
const tags: string[] = JSON.parse((row as any).tags || '[]');
const newTags = tags.filter(t => t !== tagName);
const postTags: string[] = JSON.parse((row as any).tags || '[]');
const newTags = postTags.filter(t => t !== tagName);
await client.execute({
sql: 'UPDATE posts SET tags = ?, updated_at = ? WHERE id = ?',
args: [JSON.stringify(newTags), Date.now(), postId],
});
await db
.update(posts)
.set({
tags: JSON.stringify(newTags),
updatedAt: new Date(),
})
.where(eq(posts.id, postId));
updated++;
onProgress((updated / total) * 80, `Updated ${updated}/${total} posts...`);
@@ -367,10 +386,12 @@ export class TagEngine extends EventEmitter {
onProgress(90, 'Deleting tag...');
// Delete the tag
await client.execute({
sql: 'DELETE FROM tags WHERE id = ? AND project_id = ?',
args: [id, this.currentProjectId],
});
await db
.delete(tags)
.where(and(
eq(tags.id, id),
eq(tags.projectId, this.currentProjectId)
));
onProgress(100, 'Complete');
@@ -386,7 +407,8 @@ export class TagEngine extends EventEmitter {
* Merge multiple source tags into a target tag (runs as background task)
*/
async mergeTags(sourceTagIds: string[], targetTagId: string): Promise<MergeTagsResult> {
const client = getDatabase().getLocalClient();
const db = this.getDb();
const client = this.getClient();
if (!client) throw new Error('Database not initialized');
if (sourceTagIds.length === 0) {
@@ -394,30 +416,36 @@ export class TagEngine extends EventEmitter {
}
// Verify all source tags exist
const sourceTags: any[] = [];
const sourceTags: (typeof tags.$inferSelect)[] = [];
for (const id of sourceTagIds) {
const result = await client.execute({
sql: 'SELECT * FROM tags WHERE id = ? AND project_id = ?',
args: [id, this.currentProjectId],
});
if (result.rows.length > 0) {
sourceTags.push(result.rows[0]);
const rows = await db
.select()
.from(tags)
.where(and(
eq(tags.id, id),
eq(tags.projectId, this.currentProjectId)
));
if (rows.length > 0) {
sourceTags.push(rows[0]);
}
}
// Verify target tag exists
const targetResult = await client.execute({
sql: 'SELECT * FROM tags WHERE id = ? AND project_id = ?',
args: [targetTagId, this.currentProjectId],
});
const targetRows = await db
.select()
.from(tags)
.where(and(
eq(tags.id, targetTagId),
eq(tags.projectId, this.currentProjectId)
));
if (targetResult.rows.length === 0) {
if (targetRows.length === 0) {
throw new Error('Target tag not found');
}
const targetTag = targetResult.rows[0] as any;
const targetName = targetTag.name as string;
const sourceNames = sourceTags.map((t: any) => t.name as string);
const targetTag = targetRows[0];
const targetName = targetTag.name;
const sourceNames = sourceTags.map(t => t.name);
// Run as background task
return taskManager.runTask({
@@ -441,19 +469,22 @@ export class TagEngine extends EventEmitter {
for (const row of postsResult.rows) {
const postId = row.id as string;
const tags: string[] = JSON.parse((row as any).tags || '[]');
const postTags: string[] = JSON.parse((row as any).tags || '[]');
if (tags.includes(sourceName)) {
if (postTags.includes(sourceName)) {
// Remove source tag and add target if not already present
const newTags = tags.filter(t => t !== sourceName);
const newTags = postTags.filter(t => t !== sourceName);
if (!newTags.includes(targetName)) {
newTags.push(targetName);
}
await client.execute({
sql: 'UPDATE posts SET tags = ?, updated_at = ? WHERE id = ?',
args: [JSON.stringify(newTags), Date.now(), postId],
});
await db
.update(posts)
.set({
tags: JSON.stringify(newTags),
updatedAt: new Date(),
})
.where(eq(posts.id, postId));
totalPostsUpdated++;
}
@@ -464,10 +495,12 @@ export class TagEngine extends EventEmitter {
// Delete source tags
for (const id of sourceTagIds) {
await client.execute({
sql: 'DELETE FROM tags WHERE id = ? AND project_id = ?',
args: [id, this.currentProjectId],
});
await db
.delete(tags)
.where(and(
eq(tags.id, id),
eq(tags.projectId, this.currentProjectId)
));
}
onProgress(100, 'Complete');
@@ -491,7 +524,8 @@ export class TagEngine extends EventEmitter {
* Rename a tag (runs as background task to update posts)
*/
async renameTag(id: string, newName: string): Promise<RenameTagResult> {
const client = getDatabase().getLocalClient();
const db = this.getDb();
const client = this.getClient();
if (!client) throw new Error('Database not initialized');
newName = newName.trim().toLowerCase();
@@ -500,29 +534,36 @@ export class TagEngine extends EventEmitter {
}
// Get existing tag
const tagResult = await client.execute({
sql: 'SELECT * FROM tags WHERE id = ? AND project_id = ?',
args: [id, this.currentProjectId],
});
const tagRows = await db
.select()
.from(tags)
.where(and(
eq(tags.id, id),
eq(tags.projectId, this.currentProjectId)
));
if (tagResult.rows.length === 0) {
if (tagRows.length === 0) {
throw new Error('Tag not found');
}
const tag = tagResult.rows[0] as any;
const oldName = tag.name as string;
const tag = tagRows[0];
const oldName = tag.name;
if (oldName === newName) {
return { success: true, postsUpdated: 0, oldName, newName };
}
// Check for duplicate
const duplicateResult = await client.execute({
sql: 'SELECT id FROM tags WHERE project_id = ? AND LOWER(name) = LOWER(?) AND id != ?',
args: [this.currentProjectId, newName, id],
});
const duplicateRows = await db
.select({ id: tags.id })
.from(tags)
.where(and(
eq(tags.projectId, this.currentProjectId),
sql`LOWER(${tags.name}) = LOWER(${newName})`,
sql`${tags.id} != ${id}`
));
if (duplicateResult.rows.length > 0) {
if (duplicateRows.length > 0) {
throw new Error(`Tag "${newName}" already exists`);
}
@@ -540,8 +581,8 @@ export class TagEngine extends EventEmitter {
});
const postsToUpdate = postsResult.rows.filter((row: any) => {
const tags: string[] = JSON.parse(row.tags || '[]');
return tags.includes(oldName);
const postTags: string[] = JSON.parse(row.tags || '[]');
return postTags.includes(oldName);
});
const total = postsToUpdate.length;
@@ -549,13 +590,16 @@ export class TagEngine extends EventEmitter {
for (const row of postsToUpdate) {
const postId = row.id as string;
const tags: string[] = JSON.parse((row as any).tags || '[]');
const newTags = tags.map(t => t === oldName ? newName : t);
const postTags: string[] = JSON.parse((row as any).tags || '[]');
const updatedTags = postTags.map(t => t === oldName ? newName : t);
await client.execute({
sql: 'UPDATE posts SET tags = ?, updated_at = ? WHERE id = ?',
args: [JSON.stringify(newTags), Date.now(), postId],
});
await db
.update(posts)
.set({
tags: JSON.stringify(updatedTags),
updatedAt: new Date(),
})
.where(eq(posts.id, postId));
updated++;
onProgress((updated / total) * 80, `Updated ${updated}/${total} posts...`);
@@ -564,10 +608,16 @@ export class TagEngine extends EventEmitter {
onProgress(90, 'Updating tag record...');
// Update the tag name
await client.execute({
sql: 'UPDATE tags SET name = ?, updated_at = ? WHERE id = ? AND project_id = ?',
args: [newName, Date.now(), id, this.currentProjectId],
});
await db
.update(tags)
.set({
name: newName,
updatedAt: new Date(),
})
.where(and(
eq(tags.id, id),
eq(tags.projectId, this.currentProjectId)
));
onProgress(100, 'Complete');
@@ -590,75 +640,84 @@ export class TagEngine extends EventEmitter {
* Get a tag by ID
*/
async getTag(id: string): Promise<TagData | null> {
const client = getDatabase().getLocalClient();
if (!client) return null;
const db = this.getDb();
const result = await client.execute({
sql: 'SELECT * FROM tags WHERE id = ? AND project_id = ?',
args: [id, this.currentProjectId],
});
const rows = await db
.select()
.from(tags)
.where(and(
eq(tags.id, id),
eq(tags.projectId, this.currentProjectId)
));
if (result.rows.length === 0) {
if (rows.length === 0) {
return null;
}
return this.rowToTagData(result.rows[0] as any);
return this.rowToTagData(rows[0]);
}
/**
* Get a tag by name (case-insensitive)
*/
async getTagByName(name: string): Promise<TagData | null> {
const client = getDatabase().getLocalClient();
if (!client) return null;
const db = this.getDb();
const normalizedName = name.trim().toLowerCase();
const result = await client.execute({
sql: 'SELECT * FROM tags WHERE project_id = ? AND LOWER(name) = LOWER(?)',
args: [this.currentProjectId, name.trim().toLowerCase()],
});
const rows = await db
.select()
.from(tags)
.where(and(
eq(tags.projectId, this.currentProjectId),
sql`LOWER(${tags.name}) = LOWER(${normalizedName})`
));
if (result.rows.length === 0) {
if (rows.length === 0) {
return null;
}
return this.rowToTagData(result.rows[0] as any);
return this.rowToTagData(rows[0]);
}
/**
* Get all tags for the current project
*/
async getAllTags(): Promise<TagData[]> {
const client = getDatabase().getLocalClient();
if (!client) return [];
const db = this.getDb();
const result = await client.execute({
sql: 'SELECT * FROM tags WHERE project_id = ? ORDER BY name ASC',
args: [this.currentProjectId],
});
const rows = await db
.select()
.from(tags)
.where(eq(tags.projectId, this.currentProjectId))
.orderBy(asc(tags.name));
return result.rows.map((row: any) => this.rowToTagData(row));
return rows.map(row => this.rowToTagData(row));
}
/**
* Get post IDs that have a specific tag
*/
async getPostsWithTag(tagId: string): Promise<string[]> {
const client = getDatabase().getLocalClient();
const db = this.getDb();
const client = this.getClient();
if (!client) return [];
// First get the tag name
const tagResult = await client.execute({
sql: 'SELECT name FROM tags WHERE id = ? AND project_id = ?',
args: [tagId, this.currentProjectId],
});
const tagRows = await db
.select({ name: tags.name })
.from(tags)
.where(and(
eq(tags.id, tagId),
eq(tags.projectId, this.currentProjectId)
));
if (tagResult.rows.length === 0) {
if (tagRows.length === 0) {
return [];
}
const tagName = (tagResult.rows[0] as any).name as string;
const tagName = tagRows[0].name;
// Find posts with this tag
// Find posts with this tag - requires raw SQL for JSON
const postsResult = await client.execute({
sql: `SELECT id, tags FROM posts WHERE project_id = ? AND tags LIKE ?`,
args: [this.currentProjectId, `%"${tagName}"%`],
@@ -666,8 +725,8 @@ export class TagEngine extends EventEmitter {
return postsResult.rows
.filter((row: any) => {
const tags: string[] = JSON.parse(row.tags || '[]');
return tags.includes(tagName);
const postTags: string[] = JSON.parse(row.tags || '[]');
return postTags.includes(tagName);
})
.map((row: any) => row.id as string);
}
@@ -676,19 +735,18 @@ export class TagEngine extends EventEmitter {
* Sync tags from existing posts - discover tags that exist in posts but not in tags table
*/
async syncTagsFromPosts(): Promise<SyncTagsResult> {
const client = getDatabase().getLocalClient();
if (!client) throw new Error('Database not initialized');
const db = this.getDb();
// Get all tags from posts
const postsResult = await client.execute({
sql: 'SELECT tags FROM posts WHERE project_id = ?',
args: [this.currentProjectId],
});
const postRows = await db
.select({ tags: posts.tags })
.from(posts)
.where(eq(posts.projectId, this.currentProjectId));
const discoveredTags = new Set<string>();
for (const row of postsResult.rows) {
const tags: string[] = JSON.parse((row as any).tags || '[]');
for (const tag of tags) {
for (const row of postRows) {
const postTags: string[] = JSON.parse(row.tags || '[]');
for (const tag of postTags) {
if (tag.trim()) {
discoveredTags.add(tag.trim().toLowerCase());
}
@@ -696,23 +754,27 @@ export class TagEngine extends EventEmitter {
}
// Get existing tags
const existingResult = await client.execute({
sql: 'SELECT name FROM tags WHERE project_id = ?',
args: [this.currentProjectId],
});
const existingRows = await db
.select({ name: tags.name })
.from(tags)
.where(eq(tags.projectId, this.currentProjectId));
const existingNames = new Set(existingResult.rows.map((row: any) => (row.name as string).toLowerCase()));
const existingNames = new Set(existingRows.map(row => row.name.toLowerCase()));
// Find missing tags
const missingTags = Array.from(discoveredTags).filter(t => !existingNames.has(t));
const added: string[] = [];
// Add missing tags
const now = Date.now();
const now = new Date();
for (const tagName of missingTags) {
await client.execute({
sql: 'INSERT INTO tags (id, project_id, name, color, created_at, updated_at) VALUES (?, ?, ?, NULL, ?, ?)',
args: [uuidv4(), this.currentProjectId, tagName, now, now],
await db.insert(tags).values({
id: uuidv4(),
projectId: this.currentProjectId,
name: tagName,
color: null,
createdAt: now,
updatedAt: now,
});
added.push(tagName);
}
@@ -731,14 +793,14 @@ export class TagEngine extends EventEmitter {
/**
* Convert database row to TagData
*/
private rowToTagData(row: any): TagData {
private rowToTagData(row: typeof tags.$inferSelect): TagData {
return {
id: row.id,
projectId: row.project_id,
projectId: row.projectId,
name: row.name,
color: row.color || undefined,
createdAt: new Date(row.created_at),
updatedAt: new Date(row.updated_at),
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
@@ -748,12 +810,12 @@ export class TagEngine extends EventEmitter {
*/
private async saveTagsToFile(): Promise<void> {
try {
const tags = await this.getAllTags();
const allTags = await this.getAllTags();
const filePath = this.getTagsFilePath();
const dir = path.dirname(filePath);
// Serialize to portable format - only name and optional color
const serialized: SerializedTag[] = tags.map(tag => {
const serialized: SerializedTag[] = allTags.map(tag => {
const entry: SerializedTag = { name: tag.name };
if (tag.color) {
entry.color = tag.color;
@@ -778,10 +840,8 @@ export class TagEngine extends EventEmitter {
const content = await fs.readFile(filePath, 'utf-8');
const rawTags: any[] = JSON.parse(content);
const client = getDatabase().getLocalClient();
if (!client) return;
const now = Date.now();
const db = this.getDb();
const now = new Date();
for (const tag of rawTags) {
// Support both portable format { name, color? } and legacy format with id
@@ -791,23 +851,36 @@ export class TagEngine extends EventEmitter {
const color = tag.color || null;
// Check if tag with this name already exists
const existing = await client.execute({
sql: 'SELECT id FROM tags WHERE project_id = ? AND LOWER(name) = LOWER(?)',
args: [this.currentProjectId, name],
});
const existing = await db
.select({ id: tags.id })
.from(tags)
.where(and(
eq(tags.projectId, this.currentProjectId),
sql`LOWER(${tags.name}) = LOWER(${name})`
));
if (existing.rows.length === 0) {
if (existing.length === 0) {
// Create new tag with fresh ID
await client.execute({
sql: 'INSERT INTO tags (id, project_id, name, color, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)',
args: [uuidv4(), this.currentProjectId, name, color, now, now],
await db.insert(tags).values({
id: uuidv4(),
projectId: this.currentProjectId,
name,
color,
createdAt: now,
updatedAt: now,
});
} else if (color) {
// Update color if provided and tag exists
await client.execute({
sql: 'UPDATE tags SET color = ?, updated_at = ? WHERE project_id = ? AND LOWER(name) = LOWER(?)',
args: [color, now, this.currentProjectId, name],
});
await db
.update(tags)
.set({
color,
updatedAt: now,
})
.where(and(
eq(tags.projectId, this.currentProjectId),
sql`LOWER(${tags.name}) = LOWER(${name})`
));
}
}
} catch (error: any) {