chore: moved to proper drizzle orm and migrations
This commit is contained in:
@@ -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(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user