From 325114681f4971651d47881717774abaf73e9fab Mon Sep 17 00:00:00 2001 From: hugo Date: Wed, 11 Feb 2026 14:30:57 +0100 Subject: [PATCH] feat: tag management --- src/main/database/connection.ts | 33 + src/main/database/schema.ts | 15 + src/main/engine/TagEngine.ts | 774 ++++++++++++++++++ src/main/engine/index.ts | 13 + src/main/ipc/handlers.ts | 70 ++ src/main/preload.ts | 15 + .../components/ActivityBar/ActivityBar.tsx | 21 + src/renderer/components/Editor/Editor.tsx | 12 + src/renderer/components/Sidebar/Sidebar.tsx | 45 + src/renderer/components/TabBar/TabBar.tsx | 10 + src/renderer/components/TagsView/TagsView.css | 366 +++++++++ src/renderer/components/TagsView/TagsView.tsx | 615 ++++++++++++++ src/renderer/components/TagsView/index.ts | 2 + src/renderer/components/index.ts | 1 + src/renderer/store/appStore.ts | 6 +- src/renderer/types/electron.d.ts | 52 ++ tests/engine/TagEngine.test.ts | 482 +++++++++++ 17 files changed, 2529 insertions(+), 3 deletions(-) create mode 100644 src/main/engine/TagEngine.ts create mode 100644 src/renderer/components/TagsView/TagsView.css create mode 100644 src/renderer/components/TagsView/TagsView.tsx create mode 100644 src/renderer/components/TagsView/index.ts create mode 100644 tests/engine/TagEngine.test.ts diff --git a/src/main/database/connection.ts b/src/main/database/connection.ts index 78cc3b8..4c9f51f 100644 --- a/src/main/database/connection.ts +++ b/src/main/database/connection.ts @@ -184,6 +184,18 @@ export class DatabaseConnection { 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 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) @@ -368,6 +380,27 @@ export class DatabaseConnection { console.log('FTS table migrated - rebuild index required'); } + // 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'); + } + // 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) { diff --git a/src/main/database/schema.ts b/src/main/database/schema.ts index 1509324..5e523ff 100644 --- a/src/main/database/schema.ts +++ b/src/main/database/schema.ts @@ -95,6 +95,19 @@ export const postLinks = sqliteTable('post_links', { createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), }); +// Tags table - stores tag metadata with optional colors +export const tags = sqliteTable('tags', { + id: text('id').primaryKey(), + projectId: text('project_id').notNull(), + name: text('name').notNull(), + color: text('color'), // Optional hex color like #ff0000 + createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), +}, (table) => ({ + // Composite unique index: tag name must be unique within each project + projectNameIdx: uniqueIndex('tags_project_name_idx').on(table.projectId, table.name), +})); + // Types for TypeScript export type Project = typeof projects.$inferSelect; export type NewProject = typeof projects.$inferInsert; @@ -108,3 +121,5 @@ export type Setting = typeof settings.$inferSelect; export type NewSetting = typeof settings.$inferInsert; export type PostLink = typeof postLinks.$inferSelect; export type NewPostLink = typeof postLinks.$inferInsert; +export type Tag = typeof tags.$inferSelect; +export type NewTag = typeof tags.$inferInsert; diff --git a/src/main/engine/TagEngine.ts b/src/main/engine/TagEngine.ts new file mode 100644 index 0000000..37c74a2 --- /dev/null +++ b/src/main/engine/TagEngine.ts @@ -0,0 +1,774 @@ +import { EventEmitter } from 'events'; +import { v4 as uuidv4 } from 'uuid'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { app } from 'electron'; +import { getDatabase } from '../database'; +import { taskManager } from './TaskManager'; + +/** + * Tag data stored in the database + */ +export interface TagData { + id: string; + projectId: string; + name: string; + color?: string; + createdAt: Date; + updatedAt: Date; +} + +/** + * Tag with post count for tag cloud display + */ +export interface TagWithCount { + name: string; + color: string | null; + count: number; +} + +/** + * Input for creating a new tag + */ +export interface CreateTagInput { + name: string; + color?: string; +} + +/** + * Input for updating a tag + */ +export interface UpdateTagInput { + name?: string; + color?: string | null; +} + +/** + * Result of tag deletion + */ +export interface DeleteTagResult { + success: boolean; + postsUpdated: number; +} + +/** + * Result of merging tags + */ +export interface MergeTagsResult { + success: boolean; + postsUpdated: number; + tagsDeleted: number; + targetTag: string; +} + +/** + * Result of renaming a tag + */ +export interface RenameTagResult { + success: boolean; + postsUpdated: number; + oldName: string; + newName: string; +} + +/** + * Result of syncing tags from posts + */ +export interface SyncTagsResult { + discovered: number; + added: string[]; +} + +// Singleton instance +let tagEngineInstance: TagEngine | null = null; + +/** + * Get the singleton TagEngine instance + */ +export function getTagEngine(): TagEngine { + if (!tagEngineInstance) { + tagEngineInstance = new TagEngine(); + } + return tagEngineInstance; +} + +/** + * Validate hex color format + */ +function isValidHexColor(color: string): boolean { + return /^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/.test(color); +} + +/** + * TagEngine manages tag metadata and operations. + * + * Tags are stored in a dedicated tags table with optional colors. + * This engine handles: + * - CRUD operations on tags + * - Mass operations (merge, rename) that run as background tasks + * - Syncing tags discovered from posts + */ +export class TagEngine extends EventEmitter { + private currentProjectId: string = 'default'; + + constructor() { + super(); + } + + /** + * Set the current project context + */ + setProjectContext(projectId: string): void { + this.currentProjectId = projectId; + } + + /** + * Get the current project context + */ + getProjectContext(): string { + return this.currentProjectId; + } + + /** + * Get the tags file path for filesystem persistence + */ + private getTagsFilePath(): string { + const userDataPath = app.getPath('userData'); + return path.join(userDataPath, 'projects', this.currentProjectId, 'meta', 'tags-metadata.json'); + } + + /** + * Get all tags with their post counts for the tag cloud + */ + async getTagsWithCounts(): Promise { + const client = getDatabase().getLocalClient(); + if (!client) return []; + + // Query tags with counts from posts + // Use a subquery to count posts per tag name + const result = await client.execute({ + sql: ` + SELECT + t.name, + t.color, + COALESCE(pc.post_count, 0) as post_count + FROM tags t + LEFT JOIN ( + SELECT je.value as tag_name, COUNT(DISTINCT p.id) as post_count + FROM posts p, json_each(p.tags) je + WHERE p.project_id = ? + GROUP BY je.value + ) pc ON LOWER(pc.tag_name) = LOWER(t.name) + WHERE t.project_id = ? + ORDER BY post_count DESC, t.name ASC + `, + args: [this.currentProjectId, this.currentProjectId], + }); + + return result.rows.map((row: any) => ({ + name: row.name as string, + color: row.color as string | null, + count: Number(row.post_count) || 0, + })); + } + + /** + * Create a new tag + */ + async createTag(input: CreateTagInput): Promise { + const client = getDatabase().getLocalClient(); + if (!client) throw new Error('Database not initialized'); + + const name = input.name.trim().toLowerCase(); + if (!name) { + throw new Error('Tag name is required'); + } + + // Validate color if provided + if (input.color && !isValidHexColor(input.color)) { + 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], + }); + + if (existing.rows.length > 0) { + throw new Error(`Tag "${name}" already exists`); + } + + const now = Date.now(); + const tag: TagData = { + id: uuidv4(), + projectId: this.currentProjectId, + name, + color: input.color, + createdAt: new Date(now), + updatedAt: new Date(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], + }); + + this.emit('tagCreated', tag); + await this.saveTagsToFile(); + + return tag; + } + + /** + * Update a tag + */ + async updateTag(id: string, input: UpdateTagInput): Promise { + const client = getDatabase().getLocalClient(); + if (!client) return null; + + // Get existing tag + const existing = await client.execute({ + sql: 'SELECT * FROM tags WHERE id = ? AND project_id = ?', + args: [id, this.currentProjectId], + }); + + if (existing.rows.length === 0) { + return null; + } + + const row = existing.rows[0] as any; + + // 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) { + // No updates + return this.rowToTagData(row); + } + + updates.push('updated_at = ?'); + args.push(now); + args.push(id); + args.push(this.currentProjectId); + + await client.execute({ + sql: `UPDATE tags SET ${updates.join(', ')} WHERE id = ? AND project_id = ?`, + args, + }); + + const updatedTag: TagData = { + id: row.id, + projectId: row.project_id, + name: row.name, + color: input.color !== undefined ? input.color || undefined : row.color, + createdAt: new Date(row.created_at), + updatedAt: new Date(now), + }; + + this.emit('tagUpdated', updatedTag); + await this.saveTagsToFile(); + + return updatedTag; + } + + /** + * Delete a tag and remove it from all posts (runs as background task) + */ + async deleteTag(id: string): Promise { + const client = getDatabase().getLocalClient(); + 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], + }); + + if (tagResult.rows.length === 0) { + throw new Error('Tag not found'); + } + + const tag = tagResult.rows[0] as any; + const tagName = tag.name as string; + + // Run the deletion as a background task + return taskManager.runTask({ + id: `delete-tag-${id}-${Date.now()}`, + name: `Delete tag "${tagName}"`, + execute: async (onProgress) => { + onProgress(0, `Finding posts with tag "${tagName}"...`); + + // Find all posts with this tag + 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 total = postsToUpdate.length; + let updated = 0; + + 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); + + await client.execute({ + sql: 'UPDATE posts SET tags = ?, updated_at = ? WHERE id = ?', + args: [JSON.stringify(newTags), Date.now(), postId], + }); + + updated++; + onProgress((updated / total) * 80, `Updated ${updated}/${total} posts...`); + } + + onProgress(90, 'Deleting tag...'); + + // Delete the tag + await client.execute({ + sql: 'DELETE FROM tags WHERE id = ? AND project_id = ?', + args: [id, this.currentProjectId], + }); + + onProgress(100, 'Complete'); + + this.emit('tagDeleted', id); + await this.saveTagsToFile(); + + return { success: true, postsUpdated: updated }; + }, + }); + } + + /** + * Merge multiple source tags into a target tag (runs as background task) + */ + async mergeTags(sourceTagIds: string[], targetTagId: string): Promise { + const client = getDatabase().getLocalClient(); + if (!client) throw new Error('Database not initialized'); + + if (sourceTagIds.length === 0) { + throw new Error('Source tags are required'); + } + + // Verify all source tags exist + const sourceTags: any[] = []; + 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]); + } + } + + // Verify target tag exists + const targetResult = await client.execute({ + sql: 'SELECT * FROM tags WHERE id = ? AND project_id = ?', + args: [targetTagId, this.currentProjectId], + }); + + if (targetResult.rows.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); + + // Run as background task + return taskManager.runTask({ + id: `merge-tags-${Date.now()}`, + name: `Merge tags into "${targetName}"`, + execute: async (onProgress) => { + onProgress(0, 'Finding posts to update...'); + + let totalPostsUpdated = 0; + + // For each source tag, update posts and delete the tag + for (let i = 0; i < sourceNames.length; i++) { + const sourceName = sourceNames[i]; + onProgress((i / sourceNames.length) * 80, `Processing tag "${sourceName}"...`); + + // Find posts with this source tag + const postsResult = await client.execute({ + sql: `SELECT id, tags FROM posts WHERE project_id = ? AND tags LIKE ?`, + args: [this.currentProjectId, `%"${sourceName}"%`], + }); + + for (const row of postsResult.rows) { + const postId = row.id as string; + const tags: string[] = JSON.parse((row as any).tags || '[]'); + + if (tags.includes(sourceName)) { + // Remove source tag and add target if not already present + const newTags = tags.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], + }); + + totalPostsUpdated++; + } + } + } + + onProgress(90, 'Deleting source tags...'); + + // Delete source tags + for (const id of sourceTagIds) { + await client.execute({ + sql: 'DELETE FROM tags WHERE id = ? AND project_id = ?', + args: [id, this.currentProjectId], + }); + } + + onProgress(100, 'Complete'); + + const result: MergeTagsResult = { + success: true, + postsUpdated: totalPostsUpdated, + tagsDeleted: sourceTagIds.length, + targetTag: targetName, + }; + + this.emit('tagsMerged', result); + await this.saveTagsToFile(); + + return result; + }, + }); + } + + /** + * Rename a tag (runs as background task to update posts) + */ + async renameTag(id: string, newName: string): Promise { + const client = getDatabase().getLocalClient(); + if (!client) throw new Error('Database not initialized'); + + newName = newName.trim().toLowerCase(); + if (!newName) { + throw new Error('New name is required'); + } + + // Get existing tag + const tagResult = await client.execute({ + sql: 'SELECT * FROM tags WHERE id = ? AND project_id = ?', + args: [id, this.currentProjectId], + }); + + if (tagResult.rows.length === 0) { + throw new Error('Tag not found'); + } + + const tag = tagResult.rows[0] as any; + const oldName = tag.name as string; + + 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], + }); + + if (duplicateResult.rows.length > 0) { + throw new Error(`Tag "${newName}" already exists`); + } + + // Run as background task + return taskManager.runTask({ + id: `rename-tag-${id}-${Date.now()}`, + name: `Rename tag "${oldName}" to "${newName}"`, + execute: async (onProgress) => { + onProgress(0, 'Finding posts to update...'); + + // Find posts with this tag + const postsResult = await client.execute({ + sql: `SELECT id, tags FROM posts WHERE project_id = ? AND tags LIKE ?`, + args: [this.currentProjectId, `%"${oldName}"%`], + }); + + const postsToUpdate = postsResult.rows.filter((row: any) => { + const tags: string[] = JSON.parse(row.tags || '[]'); + return tags.includes(oldName); + }); + + const total = postsToUpdate.length; + let updated = 0; + + 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); + + await client.execute({ + sql: 'UPDATE posts SET tags = ?, updated_at = ? WHERE id = ?', + args: [JSON.stringify(newTags), Date.now(), postId], + }); + + updated++; + onProgress((updated / total) * 80, `Updated ${updated}/${total} posts...`); + } + + 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], + }); + + onProgress(100, 'Complete'); + + const result: RenameTagResult = { + success: true, + postsUpdated: updated, + oldName, + newName, + }; + + this.emit('tagRenamed', result); + await this.saveTagsToFile(); + + return result; + }, + }); + } + + /** + * Get a tag by ID + */ + async getTag(id: string): Promise { + const client = getDatabase().getLocalClient(); + if (!client) return null; + + const result = await client.execute({ + sql: 'SELECT * FROM tags WHERE id = ? AND project_id = ?', + args: [id, this.currentProjectId], + }); + + if (result.rows.length === 0) { + return null; + } + + return this.rowToTagData(result.rows[0] as any); + } + + /** + * Get a tag by name (case-insensitive) + */ + async getTagByName(name: string): Promise { + const client = getDatabase().getLocalClient(); + if (!client) return null; + + const result = await client.execute({ + sql: 'SELECT * FROM tags WHERE project_id = ? AND LOWER(name) = LOWER(?)', + args: [this.currentProjectId, name.trim().toLowerCase()], + }); + + if (result.rows.length === 0) { + return null; + } + + return this.rowToTagData(result.rows[0] as any); + } + + /** + * Get all tags for the current project + */ + async getAllTags(): Promise { + const client = getDatabase().getLocalClient(); + if (!client) return []; + + const result = await client.execute({ + sql: 'SELECT * FROM tags WHERE project_id = ? ORDER BY name ASC', + args: [this.currentProjectId], + }); + + return result.rows.map((row: any) => this.rowToTagData(row)); + } + + /** + * Get post IDs that have a specific tag + */ + async getPostsWithTag(tagId: string): Promise { + const client = getDatabase().getLocalClient(); + 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], + }); + + if (tagResult.rows.length === 0) { + return []; + } + + const tagName = (tagResult.rows[0] as any).name as string; + + // Find posts with this tag + const postsResult = await client.execute({ + sql: `SELECT id, tags FROM posts WHERE project_id = ? AND tags LIKE ?`, + args: [this.currentProjectId, `%"${tagName}"%`], + }); + + return postsResult.rows + .filter((row: any) => { + const tags: string[] = JSON.parse(row.tags || '[]'); + return tags.includes(tagName); + }) + .map((row: any) => row.id as string); + } + + /** + * Sync tags from existing posts - discover tags that exist in posts but not in tags table + */ + async syncTagsFromPosts(): Promise { + const client = getDatabase().getLocalClient(); + if (!client) throw new Error('Database not initialized'); + + // Get all tags from posts + const postsResult = await client.execute({ + sql: 'SELECT tags FROM posts WHERE project_id = ?', + args: [this.currentProjectId], + }); + + const discoveredTags = new Set(); + for (const row of postsResult.rows) { + const tags: string[] = JSON.parse((row as any).tags || '[]'); + for (const tag of tags) { + if (tag.trim()) { + discoveredTags.add(tag.trim().toLowerCase()); + } + } + } + + // Get existing tags + const existingResult = await client.execute({ + sql: 'SELECT name FROM tags WHERE project_id = ?', + args: [this.currentProjectId], + }); + + const existingNames = new Set(existingResult.rows.map((row: any) => (row.name as string).toLowerCase())); + + // Find missing tags + const missingTags = Array.from(discoveredTags).filter(t => !existingNames.has(t)); + const added: string[] = []; + + // Add missing tags + const now = Date.now(); + 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], + }); + added.push(tagName); + } + + if (added.length > 0) { + this.emit('tagsSynced', { discovered: discoveredTags.size, added }); + await this.saveTagsToFile(); + } + + return { + discovered: discoveredTags.size, + added, + }; + } + + /** + * Convert database row to TagData + */ + private rowToTagData(row: any): TagData { + return { + id: row.id, + projectId: row.project_id, + name: row.name, + color: row.color || undefined, + createdAt: new Date(row.created_at), + updatedAt: new Date(row.updated_at), + }; + } + + /** + * Save tags metadata to filesystem for sync + */ + private async saveTagsToFile(): Promise { + try { + const tags = await this.getAllTags(); + const filePath = this.getTagsFilePath(); + const dir = path.dirname(filePath); + + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(filePath, JSON.stringify(tags, null, 2), 'utf-8'); + } catch (error) { + console.error('[TagEngine] Failed to save tags to file:', error); + } + } + + /** + * Load tags from filesystem (for initial sync) + */ + async loadTagsFromFile(): Promise { + try { + const filePath = this.getTagsFilePath(); + const content = await fs.readFile(filePath, 'utf-8'); + const tags: TagData[] = JSON.parse(content); + + const client = getDatabase().getLocalClient(); + if (!client) return; + + for (const tag of tags) { + // Check if tag exists + const existing = await client.execute({ + sql: 'SELECT id FROM tags WHERE id = ? AND project_id = ?', + args: [tag.id, this.currentProjectId], + }); + + if (existing.rows.length === 0) { + await client.execute({ + sql: 'INSERT INTO tags (id, project_id, name, color, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)', + args: [ + tag.id, + this.currentProjectId, + tag.name, + tag.color || null, + tag.createdAt instanceof Date ? tag.createdAt.getTime() : tag.createdAt, + tag.updatedAt instanceof Date ? tag.updatedAt.getTime() : tag.updatedAt, + ], + }); + } + } + } catch (error: any) { + if (error.code !== 'ENOENT') { + console.error('[TagEngine] Failed to load tags from file:', error); + } + } + } +} diff --git a/src/main/engine/index.ts b/src/main/engine/index.ts index 1c5a314..d7c454c 100644 --- a/src/main/engine/index.ts +++ b/src/main/engine/index.ts @@ -4,6 +4,18 @@ export { MediaEngine, getMediaEngine, type MediaData } from './MediaEngine'; export { SyncEngine, getSyncEngine, type SyncConfig, type SyncResult, type SyncDirection, type SyncStatus } from './SyncEngine'; export { ProjectEngine, getProjectEngine, type ProjectData } from './ProjectEngine'; export { MetaEngine, getMetaEngine, type ProjectMetadata, DEFAULT_CATEGORIES } from './MetaEngine'; +export { + TagEngine, + getTagEngine, + type TagData, + type TagWithCount, + type CreateTagInput, + type UpdateTagInput, + type DeleteTagResult, + type MergeTagsResult, + type RenameTagResult, + type SyncTagsResult, +} from './TagEngine'; export { stemText, stemWord, @@ -25,3 +37,4 @@ export { type FileDownloadResult, type ConflictResolution, } from './DropboxSyncEngine'; + diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 1bf0f2e..93863ce 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -6,6 +6,7 @@ import { getSyncEngine, SyncConfig, SyncDirection } from '../engine/SyncEngine'; import { getDropboxSyncEngine, DropboxSyncConfig, ConflictResolution } from '../engine/DropboxSyncEngine'; import { getProjectEngine, ProjectData } from '../engine/ProjectEngine'; import { getMetaEngine } from '../engine/MetaEngine'; +import { getTagEngine } from '../engine/TagEngine'; import { taskManager, TaskProgress } from '../engine/TaskManager'; import { getDatabase } from '../database'; import { media } from '../database/schema'; @@ -52,9 +53,11 @@ export function registerIpcHandlers(): void { const postEngine = getPostEngine(); const mediaEngine = getMediaEngine(); const metaEngine = getMetaEngine(); + const tagEngine = getTagEngine(); postEngine.setProjectContext(project.id); mediaEngine.setProjectContext(project.id); metaEngine.setProjectContext(project.id); + tagEngine.setProjectContext(project.id); // Sync meta on startup await metaEngine.syncOnStartup(); @@ -72,9 +75,11 @@ export function registerIpcHandlers(): void { const postEngine = getPostEngine(); const mediaEngine = getMediaEngine(); const metaEngine = getMetaEngine(); + const tagEngine = getTagEngine(); postEngine.setProjectContext(project.id); mediaEngine.setProjectContext(project.id); metaEngine.setProjectContext(project.id); + tagEngine.setProjectContext(project.id); // Sync meta on project switch await metaEngine.syncOnStartup(); @@ -549,6 +554,63 @@ export function registerIpcHandlers(): void { return engine.getProjectMetadata(); }); + // ============ Tag Management Handlers ============ + + ipcMain.handle('tags:getAll', async () => { + const engine = getTagEngine(); + return engine.getAllTags(); + }); + + ipcMain.handle('tags:getWithCounts', async () => { + const engine = getTagEngine(); + return engine.getTagsWithCounts(); + }); + + ipcMain.handle('tags:get', async (_, id: string) => { + const engine = getTagEngine(); + return engine.getTag(id); + }); + + ipcMain.handle('tags:getByName', async (_, name: string) => { + const engine = getTagEngine(); + return engine.getTagByName(name); + }); + + ipcMain.handle('tags:create', async (_, data: { name: string; color?: string }) => { + const engine = getTagEngine(); + return engine.createTag(data); + }); + + ipcMain.handle('tags:update', async (_, id: string, data: { name?: string; color?: string | null }) => { + const engine = getTagEngine(); + return engine.updateTag(id, data); + }); + + ipcMain.handle('tags:delete', async (_, id: string) => { + const engine = getTagEngine(); + return engine.deleteTag(id); + }); + + ipcMain.handle('tags:merge', async (_, sourceTagIds: string[], targetTagId: string) => { + const engine = getTagEngine(); + return engine.mergeTags(sourceTagIds, targetTagId); + }); + + ipcMain.handle('tags:rename', async (_, id: string, newName: string) => { + const engine = getTagEngine(); + return engine.renameTag(id, newName); + }); + + ipcMain.handle('tags:getPostsWithTag', async (_, tagId: string) => { + const engine = getTagEngine(); + return engine.getPostsWithTag(tagId); + }); + + ipcMain.handle('tags:syncFromPosts', async () => { + const engine = getTagEngine(); + return engine.syncTagsFromPosts(); + }); + // ============ Event Forwarding ============ // Forward engine events to renderer @@ -557,6 +619,7 @@ export function registerIpcHandlers(): void { const syncEngine = getSyncEngine(); const projectEngine = getProjectEngine(); const metaEngine = getMetaEngine(); + const tagEngine = getTagEngine(); const forwardEvent = (eventName: string) => { return (...args: unknown[]) => { @@ -586,6 +649,13 @@ export function registerIpcHandlers(): void { metaEngine.on('categoriesChanged', forwardEvent('meta:categoriesChanged')); metaEngine.on('projectMetadataChanged', forwardEvent('meta:projectMetadataChanged')); + tagEngine.on('tagCreated', forwardEvent('tag:created')); + tagEngine.on('tagUpdated', forwardEvent('tag:updated')); + tagEngine.on('tagDeleted', forwardEvent('tag:deleted')); + tagEngine.on('tagRenamed', forwardEvent('tag:renamed')); + tagEngine.on('tagsMerged', forwardEvent('tags:merged')); + tagEngine.on('tagsSynced', forwardEvent('tags:synced')); + syncEngine.on('syncStarted', forwardEvent('sync:started')); syncEngine.on('syncCompleted', forwardEvent('sync:completed')); syncEngine.on('syncFailed', forwardEvent('sync:failed')); diff --git a/src/main/preload.ts b/src/main/preload.ts index fb1cc19..c9d9f7a 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -115,6 +115,21 @@ contextBridge.exposeInMainWorld('electronAPI', { updateProjectMetadata: (updates: { name?: string; description?: string }) => ipcRenderer.invoke('meta:updateProjectMetadata', updates), }, + // Tag Management (advanced tag operations) + tags: { + getAll: () => ipcRenderer.invoke('tags:getAll'), + getWithCounts: () => ipcRenderer.invoke('tags:getWithCounts'), + get: (id: string) => ipcRenderer.invoke('tags:get', id), + getByName: (name: string) => ipcRenderer.invoke('tags:getByName', name), + create: (data: { name: string; color?: string }) => ipcRenderer.invoke('tags:create', data), + update: (id: string, data: { name?: string; color?: string | null }) => ipcRenderer.invoke('tags:update', id, data), + delete: (id: string) => ipcRenderer.invoke('tags:delete', id), + merge: (sourceTagIds: string[], targetTagId: string) => ipcRenderer.invoke('tags:merge', sourceTagIds, targetTagId), + rename: (id: string, newName: string) => ipcRenderer.invoke('tags:rename', id, newName), + getPostsWithTag: (tagId: string) => ipcRenderer.invoke('tags:getPostsWithTag', tagId), + syncFromPosts: () => ipcRenderer.invoke('tags:syncFromPosts'), + }, + // Event listeners on: (channel: string, callback: (...args: unknown[]) => void) => { const subscription = (_event: Electron.IpcRendererEvent, ...args: unknown[]) => callback(...args); diff --git a/src/renderer/components/ActivityBar/ActivityBar.tsx b/src/renderer/components/ActivityBar/ActivityBar.tsx index ca530e6..8c3144d 100644 --- a/src/renderer/components/ActivityBar/ActivityBar.tsx +++ b/src/renderer/components/ActivityBar/ActivityBar.tsx @@ -22,6 +22,12 @@ const SettingsIcon = () => ( ); +const TagsIcon = () => ( + + + +); + const SyncIcon = () => ( @@ -35,12 +41,20 @@ export const ActivityBar: React.FC = () => { // Check if settings tab is currently active const isSettingsTabActive = tabs.some(t => t.type === 'settings' && t.id === activeTabId); + + // Check if tags tab is currently active + const isTagsTabActive = tabs.some(t => t.type === 'tags' && t.id === activeTabId); const handleSettingsClick = () => { // Open settings as a dedicated (non-transient) tab openTab({ type: 'settings', id: 'settings', isTransient: false }); }; + const handleTagsClick = () => { + // Open tags as a dedicated (non-transient) tab + openTab({ type: 'tags', id: 'tags', isTransient: false }); + }; + return (
@@ -58,6 +72,13 @@ export const ActivityBar: React.FC = () => { > +
diff --git a/src/renderer/components/Editor/Editor.tsx b/src/renderer/components/Editor/Editor.tsx index 0c88b71..3a39ad1 100644 --- a/src/renderer/components/Editor/Editor.tsx +++ b/src/renderer/components/Editor/Editor.tsx @@ -7,6 +7,7 @@ import { Lightbox, useMarkdownImages } from '../Lightbox'; import { PostLinks } from '../PostLinks'; import { ErrorModal } from '../ErrorModal'; import { SettingsView } from '../SettingsView'; +import { TagsView } from '../TagsView'; import { AutoSaveManager } from '../../utils'; import './Editor.css'; @@ -1029,6 +1030,7 @@ export const Editor: React.FC = () => { const showPost = activeTab?.type === 'post'; const showMedia = activeTab?.type === 'media'; const showSettings = activeTab?.type === 'settings' || (activeView === 'settings' && !activeTab); + const showTags = activeTab?.type === 'tags' || (activeView === 'tags' && !activeTab); // Clear selectedPostId if the post doesn't exist (e.g., after project switch) useEffect(() => { @@ -1082,6 +1084,16 @@ export const Editor: React.FC = () => { ); } + // Show tags if tags tab is active + if (showTags) { + return ( + <> + + {renderErrorModal()} + + ); + } + // Show post editor if a post tab is active if (showPost && activeTabId) { const post = posts.find(p => p.id === activeTabId); diff --git a/src/renderer/components/Sidebar/Sidebar.tsx b/src/renderer/components/Sidebar/Sidebar.tsx index 38666a3..199d36d 100644 --- a/src/renderer/components/Sidebar/Sidebar.tsx +++ b/src/renderer/components/Sidebar/Sidebar.tsx @@ -636,6 +636,50 @@ const MediaList: React.FC = () => { }; import { scrollToSettingsSection, SettingsCategory } from '../SettingsView/SettingsView'; +import { scrollToTagsSection, TagsCategory } from '../TagsView'; + +const TagsNav: React.FC = () => { + const [activeSection, setActiveSection] = useState(null); + + const handleNavClick = (category: TagsCategory) => { + setActiveSection(category); + scrollToTagsSection(category); + }; + + return ( +
+
+
+ TAGS +
+
+ +
+ + + +
+
+ ); +}; const SettingsNav: React.FC = () => { const { syncConfigured } = useAppStore(); @@ -715,6 +759,7 @@ export const Sidebar: React.FC = () => { {activeView === 'posts' && } {activeView === 'media' && } {activeView === 'settings' && } + {activeView === 'tags' && }
); }; diff --git a/src/renderer/components/TabBar/TabBar.tsx b/src/renderer/components/TabBar/TabBar.tsx index eb8b173..84fb56b 100644 --- a/src/renderer/components/TabBar/TabBar.tsx +++ b/src/renderer/components/TabBar/TabBar.tsx @@ -7,6 +7,10 @@ const getTabTitle = (tab: Tab, posts: { id: string; title: string }[], media: { return 'Settings'; } + if (tab.type === 'tags') { + return 'Tags'; + } + if (tab.type === 'post') { const post = posts.find(p => p.id === tab.id); return post?.title || 'Untitled'; @@ -40,6 +44,12 @@ const getTabIcon = (tab: Tab): React.ReactNode => { ); + case 'tags': + return ( + + + + ); default: return ( diff --git a/src/renderer/components/TagsView/TagsView.css b/src/renderer/components/TagsView/TagsView.css new file mode 100644 index 0000000..8baeda6 --- /dev/null +++ b/src/renderer/components/TagsView/TagsView.css @@ -0,0 +1,366 @@ +.tags-view { + height: 100%; + overflow-y: auto; + padding: 24px; + background-color: var(--background-primary); +} + +.tags-view-header { + margin-bottom: 32px; +} + +.tags-view-header h2 { + margin: 0 0 8px 0; + font-size: 1.75rem; + font-weight: 600; + color: var(--text-primary); +} + +.tags-view-content { + max-width: 900px; +} + +/* Text utilities */ +.text-muted { + color: var(--text-secondary); +} + +.text-small { + font-size: 0.85rem; +} + +/* Section */ +.tags-section { + margin-bottom: 40px; + padding-top: 8px; +} + +.tags-section-header { + margin-bottom: 16px; +} + +.tags-section-header h3 { + margin: 0 0 4px 0; + font-size: 1.25rem; + font-weight: 600; + color: var(--text-primary); +} + +.tags-section-description { + margin: 0; + color: var(--text-secondary); + font-size: 0.9rem; +} + +.tags-section-content { + padding: 16px; + background-color: var(--background-secondary); + border-radius: 8px; + border: 1px solid var(--border-color); +} + +/* Loading and Empty States */ +.tags-loading, +.tags-empty { + padding: 40px; + text-align: center; + color: var(--text-secondary); +} + +.tags-empty button { + margin-top: 16px; +} + +/* Tag Cloud */ +.tag-cloud { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + padding: 8px; +} + +.tag-cloud-item { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--background-primary); + color: var(--text-primary); + cursor: pointer; + transition: all 0.15s ease; + font-family: inherit; +} + +.tag-cloud-item:hover { + border-color: var(--accent-color); + background: var(--background-hover); +} + +.tag-cloud-item.selected { + border-color: var(--accent-color); + background: var(--accent-color-transparent); + box-shadow: 0 0 0 2px var(--accent-color-transparent); +} + +.tag-cloud-item.has-color { + border: none; + padding: 7px 13px; + border-radius: 16px; +} + +.tag-cloud-item.has-color:hover { + opacity: 0.9; +} + +.tag-cloud-item.has-color.selected { + box-shadow: 0 0 0 3px var(--accent-color); +} + +.tag-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 20px; + padding: 0 6px; + font-size: 0.7rem; + font-weight: 600; + background: rgba(0, 0, 0, 0.15); + border-radius: 10px; +} + +.tag-cloud-item:not(.has-color) .tag-count { + background: var(--background-tertiary); +} + +/* Selection Info */ +.tag-selection-info { + display: flex; + align-items: center; + gap: 12px; + margin-top: 16px; + padding: 12px 16px; + background: var(--accent-color-transparent); + border-radius: 6px; + font-size: 0.9rem; +} + +.tag-selection-info button { + font-size: 0.85rem; + padding: 4px 12px; +} + +/* Forms */ +.tag-create-form, +.tag-edit-form, +.merge-form { + margin-bottom: 16px; +} + +.tag-create-form h4, +.tag-edit-form h4 { + margin: 0 0 12px 0; + font-size: 1rem; + font-weight: 500; + color: var(--text-primary); +} + +.tag-form-row { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.tag-form-row input[type="text"] { + flex: 1; + min-width: 200px; + padding: 8px 12px; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--background-primary); + color: var(--text-primary); + font-family: inherit; + font-size: 0.95rem; +} + +.tag-form-row input[type="text"]:focus { + outline: none; + border-color: var(--accent-color); +} + +.tag-form-row select { + flex: 1; + min-width: 200px; + padding: 8px 12px; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--background-primary); + color: var(--text-primary); + font-family: inherit; + font-size: 0.95rem; +} + +.tag-form-row select:focus { + outline: none; + border-color: var(--accent-color); +} + +/* Color picker group */ +.color-picker-group { + display: flex; + align-items: center; + gap: 4px; +} + +.color-picker-group input[type="color"] { + width: 36px; + height: 36px; + padding: 2px; + border: 1px solid var(--border-color); + border-radius: 4px; + cursor: pointer; + background: var(--background-primary); +} + +.color-picker-group input[type="color"]::-webkit-color-swatch-wrapper { + padding: 2px; +} + +.color-picker-group input[type="color"]::-webkit-color-swatch { + border-radius: 2px; + border: none; +} + +.clear-color { + padding: 4px 8px !important; + font-size: 0.75rem !important; + min-width: auto !important; + background: var(--background-tertiary) !important; + border: none !important; +} + +.clear-color:hover { + background: var(--background-hover) !important; +} + +/* Color presets */ +.color-presets { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 12px; +} + +.color-preset { + width: 24px; + height: 24px; + border: 2px solid transparent; + border-radius: 4px; + cursor: pointer; + transition: transform 0.1s ease, border-color 0.1s ease; +} + +.color-preset:hover { + transform: scale(1.15); + border-color: var(--text-secondary); +} + +/* Tag preview */ +.tag-preview { + display: inline-flex; + align-items: center; + padding: 6px 12px; + border: 1px solid var(--border-color); + border-radius: 4px; + font-size: 0.95rem; +} + +/* Buttons */ +.tags-view button { + padding: 8px 16px; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--background-primary); + color: var(--text-primary); + font-family: inherit; + font-size: 0.9rem; + cursor: pointer; + transition: all 0.15s ease; +} + +.tags-view button:hover { + background: var(--background-hover); + border-color: var(--text-secondary); +} + +.tags-view button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.tags-view button.primary { + background: var(--accent-color); + border-color: var(--accent-color); + color: white; +} + +.tags-view button.primary:hover { + opacity: 0.9; +} + +.tags-view button.danger { + background: #ef4444; + border-color: #ef4444; + color: white; +} + +.tags-view button.danger:hover { + opacity: 0.9; +} + +/* Confirm Dialog */ +.confirm-dialog-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.confirm-dialog { + background: var(--background-primary); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 24px; + max-width: 450px; + width: 90%; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.2); +} + +.confirm-dialog h3 { + margin: 0 0 12px 0; + font-size: 1.2rem; + font-weight: 600; + color: var(--text-primary); +} + +.confirm-dialog p { + margin: 0 0 20px 0; + color: var(--text-secondary); + line-height: 1.5; +} + +.confirm-dialog-actions { + display: flex; + justify-content: flex-end; + gap: 10px; +} diff --git a/src/renderer/components/TagsView/TagsView.tsx b/src/renderer/components/TagsView/TagsView.tsx new file mode 100644 index 0000000..bc58747 --- /dev/null +++ b/src/renderer/components/TagsView/TagsView.tsx @@ -0,0 +1,615 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { useAppStore } from '../../store'; +import { showToast } from '../Toast'; +import './TagsView.css'; + +// Types +interface TagWithCount { + name: string; + color: string | null; + count: number; +} + +interface TagData { + id: string; + projectId: string; + name: string; + color?: string; + createdAt: string; + updatedAt: string; +} + +// Export category IDs for sidebar navigation +export type TagsCategory = 'cloud' | 'manage' | 'merge'; + +// Scroll to a tags section by category ID +export const scrollToTagsSection = (category: TagsCategory) => { + const element = document.getElementById(`tags-section-${category}`); + if (element) { + element.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } +}; + +// Get contrasting text color for background +const getContrastColor = (hex: string): string => { + // Remove # if present + const color = hex.replace('#', ''); + + // Parse hex to RGB + let r: number, g: number, b: number; + if (color.length === 3) { + r = parseInt(color[0] + color[0], 16); + g = parseInt(color[1] + color[1], 16); + b = parseInt(color[2] + color[2], 16); + } else { + r = parseInt(color.substring(0, 2), 16); + g = parseInt(color.substring(2, 4), 16); + b = parseInt(color.substring(4, 6), 16); + } + + // Calculate relative luminance + const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; + + return luminance > 0.5 ? '#000000' : '#ffffff'; +}; + +// Color picker presets +const COLOR_PRESETS = [ + '#ef4444', '#f97316', '#f59e0b', '#eab308', '#84cc16', + '#22c55e', '#10b981', '#14b8a6', '#06b6d4', '#0ea5e9', + '#3b82f6', '#6366f1', '#8b5cf6', '#a855f7', '#d946ef', + '#ec4899', '#f43f5e', +]; + +// Tag Cloud Item +const TagCloudItem: React.FC<{ + tag: TagWithCount; + isSelected: boolean; + onSelect: (name: string) => void; + maxCount: number; +}> = ({ tag, isSelected, onSelect, maxCount }) => { + // Calculate font size based on count (range: 0.8rem to 2rem) + const minSize = 0.85; + const maxSize = 1.8; + const ratio = maxCount > 1 ? (tag.count - 1) / (maxCount - 1) : 0; + const fontSize = minSize + (maxSize - minSize) * ratio; + + const hasColor = !!tag.color; + const style: React.CSSProperties = hasColor + ? { + backgroundColor: tag.color!, + color: getContrastColor(tag.color!), + fontSize: `${fontSize}rem`, + } + : { + fontSize: `${fontSize}rem`, + }; + + return ( + {tag.count} + + ); +}; + +// Confirm Dialog for destructive actions +const ConfirmDialog: React.FC<{ + isOpen: boolean; + title: string; + message: string; + confirmText: string; + cancelText?: string; + isDestructive?: boolean; + onConfirm: () => void; + onCancel: () => void; +}> = ({ isOpen, title, message, confirmText, cancelText = 'Cancel', isDestructive, onConfirm, onCancel }) => { + if (!isOpen) return null; + + return ( +
+
+

{title}

+

{message}

+
+ + +
+
+
+ ); +}; + +// Section Header +const SectionHeader: React.FC<{ + id?: string; + title: string; + description?: string; + children: React.ReactNode; +}> = ({ id, title, description, children }) => ( +
+
+

{title}

+ {description &&

{description}

} +
+
+ {children} +
+
+); + +export const TagsView: React.FC = () => { + const { showErrorModal } = useAppStore(); + + // State + const [tagsWithCounts, setTagsWithCounts] = useState([]); + const [allTags, setAllTags] = useState([]); + const [selectedTags, setSelectedTags] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + // Create tag form + const [newTagName, setNewTagName] = useState(''); + const [newTagColor, setNewTagColor] = useState(''); + + // Edit tag state + const [editingTagId, setEditingTagId] = useState(null); + const [editTagColor, setEditTagColor] = useState(''); + const [editTagName, setEditTagName] = useState(''); + + // Merge tags state + const [mergeTargetName, setMergeTargetName] = useState(''); + + // Confirm dialogs + const [deleteConfirm, setDeleteConfirm] = useState<{ tagId: string; tagName: string } | null>(null); + const [mergeConfirm, setMergeConfirm] = useState<{ sourceNames: string[]; targetName: string } | null>(null); + + // Load tags + const loadTags = useCallback(async () => { + try { + setIsLoading(true); + const [tagsWithCountsResult, allTagsResult] = await Promise.all([ + window.electronAPI?.tags.getWithCounts(), + window.electronAPI?.tags.getAll(), + ]); + + if (tagsWithCountsResult) { + setTagsWithCounts(tagsWithCountsResult as TagWithCount[]); + } + if (allTagsResult) { + setAllTags(allTagsResult as TagData[]); + } + } catch (error) { + console.error('Failed to load tags:', error); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + loadTags(); + }, [loadTags]); + + // Listen for tag events + useEffect(() => { + const unsubscribers: Array<() => void> = []; + + unsubscribers.push( + window.electronAPI?.on('tag:created', () => loadTags()) || (() => {}) + ); + unsubscribers.push( + window.electronAPI?.on('tag:updated', () => loadTags()) || (() => {}) + ); + unsubscribers.push( + window.electronAPI?.on('tag:deleted', () => loadTags()) || (() => {}) + ); + unsubscribers.push( + window.electronAPI?.on('tag:renamed', () => loadTags()) || (() => {}) + ); + unsubscribers.push( + window.electronAPI?.on('tags:merged', () => loadTags()) || (() => {}) + ); + + return () => { + unsubscribers.forEach(unsub => unsub()); + }; + }, [loadTags]); + + // Handle tag selection + const handleTagSelect = (name: string) => { + setSelectedTags(prev => { + if (prev.includes(name)) { + return prev.filter(n => n !== name); + } + return [...prev, name]; + }); + }; + + // Create tag + const handleCreateTag = async () => { + if (!newTagName.trim()) { + showToast.error('Tag name is required'); + return; + } + + try { + await window.electronAPI?.tags.create({ + name: newTagName.trim(), + color: newTagColor || undefined, + }); + setNewTagName(''); + setNewTagColor(''); + showToast.success('Tag created'); + loadTags(); + } catch (error) { + const err = error as Error; + showToast.error(err.message); + } + }; + + // Delete tag (with confirmation) + const handleDeleteTag = async () => { + if (!deleteConfirm) return; + + try { + const result = await window.electronAPI?.tags.delete(deleteConfirm.tagId); + if (result?.success) { + showToast.success(`Tag deleted. ${result.postsUpdated} post(s) updated.`); + setSelectedTags(prev => prev.filter(n => n !== deleteConfirm.tagName)); + loadTags(); + } + } catch (error) { + const err = error as Error; + showErrorModal({ + title: 'Delete Failed', + message: err.message, + }); + } finally { + setDeleteConfirm(null); + } + }; + + // Start editing tag + const handleStartEdit = (tag: TagData) => { + setEditingTagId(tag.id); + setEditTagColor(tag.color || ''); + setEditTagName(tag.name); + }; + + // Save tag edit + const handleSaveEdit = async () => { + if (!editingTagId) return; + + try { + // Update color + await window.electronAPI?.tags.update(editingTagId, { + color: editTagColor || null, + }); + + // If name changed, rename the tag + const originalTag = allTags.find(t => t.id === editingTagId); + if (originalTag && originalTag.name !== editTagName.trim().toLowerCase()) { + await window.electronAPI?.tags.rename(editingTagId, editTagName.trim()); + } + + showToast.success('Tag updated'); + setEditingTagId(null); + loadTags(); + } catch (error) { + const err = error as Error; + showToast.error(err.message); + } + }; + + // Merge tags (with confirmation) + const handleMergeTags = async () => { + if (!mergeConfirm) return; + + try { + // Find target tag + const targetTag = allTags.find(t => t.name === mergeConfirm.targetName); + if (!targetTag) { + showToast.error('Target tag not found'); + return; + } + + // Find source tag IDs + const sourceTags = allTags.filter(t => + mergeConfirm.sourceNames.includes(t.name) && t.id !== targetTag.id + ); + + if (sourceTags.length === 0) { + showToast.error('No source tags to merge'); + return; + } + + const result = await window.electronAPI?.tags.merge( + sourceTags.map(t => t.id), + targetTag.id + ); + + if (result?.success) { + showToast.success( + `Merged ${result.tagsDeleted} tag(s) into "${result.targetTag}". ${result.postsUpdated} post(s) updated.` + ); + setSelectedTags([]); + setMergeTargetName(''); + loadTags(); + } + } catch (error) { + const err = error as Error; + showErrorModal({ + title: 'Merge Failed', + message: err.message, + }); + } finally { + setMergeConfirm(null); + } + }; + + // Sync tags from posts + const handleSyncFromPosts = async () => { + try { + const result = await window.electronAPI?.tags.syncFromPosts(); + if (result) { + if (result.added.length > 0) { + showToast.success(`Discovered ${result.added.length} new tag(s)`); + } else { + showToast.info('All tags are already synced'); + } + loadTags(); + } + } catch (error) { + const err = error as Error; + showToast.error(err.message); + } + }; + + // Clear selection + const handleClearSelection = () => { + setSelectedTags([]); + }; + + // Get max count for sizing + const maxCount = Math.max(...tagsWithCounts.map(t => t.count), 1); + + // Selected tag objects + const selectedTagObjects = allTags.filter(t => selectedTags.includes(t.name)); + + return ( +
+
+

Tag Management

+

Manage your blog's tags, assign colors, and perform bulk operations.

+
+ +
+ {/* Tag Cloud Section */} + + {isLoading ? ( +
Loading tags...
+ ) : tagsWithCounts.length === 0 ? ( +
+

No tags found

+ +
+ ) : ( + <> +
+ {tagsWithCounts.map(tag => ( + + ))} +
+ {selectedTags.length > 0 && ( +
+ {selectedTags.length} tag(s) selected + +
+ )} + + )} +
+ + {/* Tag Management Section */} + + {/* Create new tag */} +
+

Create New Tag

+
+ setNewTagName(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleCreateTag()} + /> +
+ setNewTagColor(e.target.value)} + title="Choose color" + /> + {newTagColor && ( + + )} +
+ +
+
+ {COLOR_PRESETS.map(color => ( +
+
+ + {/* Selected tag editor */} + {selectedTagObjects.length === 1 && ( +
+

Edit Tag: {selectedTagObjects[0].name}

+ {editingTagId === selectedTagObjects[0].id ? ( +
+ setEditTagName(e.target.value)} + placeholder="Tag name" + /> +
+ setEditTagColor(e.target.value)} + /> + {editTagColor && ( + + )} +
+ + +
+ ) : ( +
+ + {selectedTagObjects[0].name} + + + +
+ )} +
+ )} +
+ + {/* Merge Tags Section */} + + {selectedTags.length < 2 ? ( +

Select 2 or more tags from the cloud above to merge them.

+ ) : ( +
+

Merge {selectedTags.length} tags into:

+
+ + +
+

+ Tags to be deleted: {selectedTags.filter(n => n !== mergeTargetName).join(', ') || '(none)'} +

+
+ )} +
+ + {/* Sync Section */} + + + +
+ + {/* Confirm Dialogs */} + setDeleteConfirm(null)} + /> + + setMergeConfirm(null)} + /> +
+ ); +}; diff --git a/src/renderer/components/TagsView/index.ts b/src/renderer/components/TagsView/index.ts new file mode 100644 index 0000000..b19e460 --- /dev/null +++ b/src/renderer/components/TagsView/index.ts @@ -0,0 +1,2 @@ +export { TagsView, scrollToTagsSection } from './TagsView'; +export type { TagsCategory } from './TagsView'; diff --git a/src/renderer/components/index.ts b/src/renderer/components/index.ts index 7298f7f..bf4572a 100644 --- a/src/renderer/components/index.ts +++ b/src/renderer/components/index.ts @@ -12,5 +12,6 @@ export { TaskPopup } from './TaskPopup'; export { ResizablePanel } from './ResizablePanel'; export { CredentialsPanel } from './CredentialsPanel'; export { SettingsView } from './SettingsView'; +export { TagsView, scrollToTagsSection, type TagsCategory } from './TagsView'; export { PostLinks } from './PostLinks'; export { ErrorModal, type ErrorDetails } from './ErrorModal'; diff --git a/src/renderer/store/appStore.ts b/src/renderer/store/appStore.ts index 03d22a2..82fb2db 100644 --- a/src/renderer/store/appStore.ts +++ b/src/renderer/store/appStore.ts @@ -5,7 +5,7 @@ import { persist } from 'zustand/middleware'; const STORAGE_KEY = 'bds-app-state'; // Tab types -export type TabType = 'post' | 'media' | 'settings'; +export type TabType = 'post' | 'media' | 'settings' | 'tags'; export interface Tab { type: TabType; @@ -88,7 +88,7 @@ interface AppState { activeTabId: string | null; // UI State - activeView: 'posts' | 'media' | 'settings'; + activeView: 'posts' | 'media' | 'settings' | 'tags'; sidebarVisible: boolean; panelVisible: boolean; selectedPostId: string | null; @@ -136,7 +136,7 @@ interface AppState { restoreTabState: (state: TabState) => void; // Actions - setActiveView: (view: 'posts' | 'media' | 'settings') => void; + setActiveView: (view: 'posts' | 'media' | 'settings' | 'tags') => void; toggleSidebar: () => void; togglePanel: () => void; setSelectedPost: (id: string | null) => void; diff --git a/src/renderer/types/electron.d.ts b/src/renderer/types/electron.d.ts index d09f345..39107e0 100644 --- a/src/renderer/types/electron.d.ts +++ b/src/renderer/types/electron.d.ts @@ -131,6 +131,45 @@ export interface CategoryCount { count: number; } +export interface TagData { + id: string; + projectId: string; + name: string; + color?: string; + createdAt: string; + updatedAt: string; +} + +export interface TagWithCount { + name: string; + color: string | null; + count: number; +} + +export interface DeleteTagResult { + success: boolean; + postsUpdated: number; +} + +export interface MergeTagsResult { + success: boolean; + postsUpdated: number; + tagsDeleted: number; + targetTag: string; +} + +export interface RenameTagResult { + success: boolean; + postsUpdated: number; + oldName: string; + newName: string; +} + +export interface SyncTagsResult { + discovered: number; + added: string[]; +} + export interface ElectronAPI { projects: { create: (data: { name: string; description?: string; slug?: string }) => Promise; @@ -223,6 +262,19 @@ export interface ElectronAPI { setProjectMetadata: (metadata: { name: string; description?: string }) => Promise; updateProjectMetadata: (updates: { name?: string; description?: string }) => Promise; }; + tags: { + getAll: () => Promise; + getWithCounts: () => Promise; + get: (id: string) => Promise; + getByName: (name: string) => Promise; + create: (data: { name: string; color?: string }) => Promise; + update: (id: string, data: { name?: string; color?: string | null }) => Promise; + delete: (id: string) => Promise; + merge: (sourceTagIds: string[], targetTagId: string) => Promise; + rename: (id: string, newName: string) => Promise; + getPostsWithTag: (tagId: string) => Promise; + syncFromPosts: () => Promise; + }; on: (channel: string, callback: (...args: unknown[]) => void) => () => void; once: (channel: string, callback: (...args: unknown[]) => void) => void; } diff --git a/tests/engine/TagEngine.test.ts b/tests/engine/TagEngine.test.ts new file mode 100644 index 0000000..22850ed --- /dev/null +++ b/tests/engine/TagEngine.test.ts @@ -0,0 +1,482 @@ +/** + * TagEngine Unit Tests + * + * Tests the REAL TagEngine class with mocked dependencies. + * Following TDD best practices: mock external dependencies, test real implementation. + */ + +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { TagEngine, TagData, TagWithCount, MergeTagsResult, DeleteTagResult } from '../../src/main/engine/TagEngine'; +import { resetMockCounters } from '../utils/factories'; + +// Create mock data stores +const mockTags = new Map(); +const mockPosts = new Map(); +let mockExecuteArgs: any[] = []; + +// Create chainable mock for Drizzle ORM +function createSelectChain() { + return { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockImplementation(function(this: any) { + return this; + }), + orderBy: vi.fn().mockReturnThis(), + limit: vi.fn().mockReturnThis(), + offset: vi.fn().mockReturnThis(), + all: vi.fn().mockImplementation(() => Promise.resolve(Array.from(mockTags.values()))), + get: vi.fn().mockImplementation(() => Promise.resolve(undefined)), + }; +} + +function createDrizzleMock() { + return { + select: vi.fn(() => createSelectChain()), + insert: vi.fn(() => ({ + values: vi.fn((data: any) => { + if (data && data.id) { + mockTags.set(data.id, data); + } + return Promise.resolve(); + }), + })), + update: vi.fn(() => ({ + set: vi.fn(() => ({ + where: vi.fn(() => Promise.resolve()), + })), + })), + delete: vi.fn(() => ({ + where: vi.fn(() => Promise.resolve()), + })), + }; +} + +const mockLocalDb = createDrizzleMock(); +const mockLocalClient = { + execute: vi.fn(async (query: { sql: string; args: any[] }) => { + mockExecuteArgs.push(query); + return { rows: [] }; + }), +}; + +// Mock the database module +vi.mock('../../src/main/database', () => ({ + getDatabase: vi.fn(() => ({ + getLocal: vi.fn(() => mockLocalDb), + getLocalClient: vi.fn(() => mockLocalClient), + getRemote: vi.fn(() => null), + getDataPaths: vi.fn(() => ({ + database: '/mock/userData/bds.db', + posts: '/mock/userData/posts', + media: '/mock/userData/media', + })), + initializeLocal: vi.fn(), + initializeRemote: vi.fn(), + close: vi.fn(), + })), +})); + +// Mock fs/promises +vi.mock('fs/promises', () => ({ + readFile: vi.fn(async () => '[]'), + writeFile: vi.fn(async () => {}), + unlink: vi.fn(async () => {}), + mkdir: vi.fn(async () => {}), + readdir: vi.fn(async () => []), + stat: vi.fn(async () => ({ + isFile: () => false, + isDirectory: () => true, + })), + access: vi.fn(async () => {}), +})); + +// Mock electron app +vi.mock('electron', () => ({ + app: { + getPath: vi.fn(() => '/mock/userData'), + }, +})); + +// Mock TaskManager +vi.mock('../../src/main/engine/TaskManager', () => ({ + taskManager: { + runTask: vi.fn(async (task: any) => { + // Execute the task for testing + return task.execute((progress: number, message: string) => {}); + }), + }, +})); + +describe('TagEngine', () => { + let tagEngine: TagEngine; + + beforeEach(() => { + vi.clearAllMocks(); + mockTags.clear(); + mockPosts.clear(); + mockExecuteArgs = []; + resetMockCounters(); + tagEngine = new TagEngine(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('constructor', () => { + it('should create a new TagEngine instance', () => { + expect(tagEngine).toBeDefined(); + expect(tagEngine).toBeInstanceOf(TagEngine); + }); + }); + + describe('setProjectContext', () => { + it('should set the current project ID', () => { + tagEngine.setProjectContext('project-123'); + expect(tagEngine.getProjectContext()).toBe('project-123'); + }); + }); + + describe('getTagsWithCounts', () => { + it('should return an empty array when no tags exist', async () => { + mockLocalClient.execute.mockResolvedValueOnce({ rows: [] }); + + const result = await tagEngine.getTagsWithCounts(); + + expect(result).toEqual([]); + }); + + it('should return tags with their post counts', async () => { + mockLocalClient.execute.mockResolvedValueOnce({ + rows: [ + { name: 'javascript', color: null, post_count: 5 }, + { name: 'typescript', color: '#3178c6', post_count: 3 }, + ], + }); + + const result = await tagEngine.getTagsWithCounts(); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ name: 'javascript', color: null, count: 5 }); + expect(result[1]).toEqual({ name: 'typescript', color: '#3178c6', count: 3 }); + }); + }); + + describe('createTag', () => { + it('should create a new tag with a name', async () => { + const result = await tagEngine.createTag({ name: 'react' }); + + expect(result).toBeDefined(); + expect(result.name).toBe('react'); + expect(result.id).toBeDefined(); + }); + + it('should normalize tag name to lowercase', async () => { + const result = await tagEngine.createTag({ name: 'React' }); + + expect(result.name).toBe('react'); + }); + + it('should create a new tag with a color', async () => { + const result = await tagEngine.createTag({ name: 'react', color: '#61dafb' }); + + expect(result.name).toBe('react'); + expect(result.color).toBe('#61dafb'); + }); + + it('should emit tagCreated event', async () => { + const handler = vi.fn(); + tagEngine.on('tagCreated', handler); + + await tagEngine.createTag({ name: 'vue' }); + + expect(handler).toHaveBeenCalledWith(expect.objectContaining({ name: 'vue' })); + }); + + it('should throw error for empty tag name', async () => { + await expect(tagEngine.createTag({ name: '' })).rejects.toThrow('Tag name is required'); + }); + + it('should throw error for duplicate tag name', async () => { + mockLocalClient.execute.mockResolvedValueOnce({ + rows: [{ id: 'existing', name: 'react' }], + }); + + await expect(tagEngine.createTag({ name: 'react' })).rejects.toThrow('Tag "react" already exists'); + }); + }); + + describe('updateTag', () => { + it('should update tag color', async () => { + mockLocalClient.execute.mockResolvedValueOnce({ + rows: [{ id: 'tag-1', name: 'react', color: null }], + }); + + const result = await tagEngine.updateTag('tag-1', { color: '#61dafb' }); + + expect(result).toBeDefined(); + expect(result?.color).toBe('#61dafb'); + }); + + it('should emit tagUpdated event', async () => { + mockLocalClient.execute.mockResolvedValueOnce({ + rows: [{ id: 'tag-1', name: 'react', color: null }], + }); + + const handler = vi.fn(); + tagEngine.on('tagUpdated', handler); + + await tagEngine.updateTag('tag-1', { color: '#61dafb' }); + + expect(handler).toHaveBeenCalled(); + }); + + it('should return null for non-existent tag', async () => { + mockLocalClient.execute.mockResolvedValueOnce({ rows: [] }); + + const result = await tagEngine.updateTag('non-existent', { color: '#fff' }); + + expect(result).toBeNull(); + }); + }); + + describe('deleteTag', () => { + it('should delete tag and remove from posts as a background task', async () => { + mockLocalClient.execute + .mockResolvedValueOnce({ rows: [{ id: 'tag-1', name: 'react', color: null }] }) // Find tag + .mockResolvedValueOnce({ rows: [ + { id: 'post-1', tags: '["react", "typescript"]' }, + { id: 'post-2', tags: '["react"]' }, + ] }) // Posts with tag + .mockResolvedValueOnce({ rows: [] }) // Update post-1 + .mockResolvedValueOnce({ rows: [] }) // Update post-2 + .mockResolvedValueOnce({ rows: [] }); // Delete tag + + const result = await tagEngine.deleteTag('tag-1'); + + expect(result.success).toBe(true); + expect(result.postsUpdated).toBe(2); + }); + + it('should emit tagDeleted event', async () => { + mockLocalClient.execute + .mockResolvedValueOnce({ rows: [{ id: 'tag-1', name: 'react', color: null }] }) + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [] }); + + const handler = vi.fn(); + tagEngine.on('tagDeleted', handler); + + await tagEngine.deleteTag('tag-1'); + + expect(handler).toHaveBeenCalledWith('tag-1'); + }); + + it('should throw error for non-existent tag', async () => { + mockLocalClient.execute.mockResolvedValueOnce({ rows: [] }); + + await expect(tagEngine.deleteTag('non-existent')).rejects.toThrow('Tag not found'); + }); + }); + + describe('mergeTags', () => { + it('should merge multiple tags into one', async () => { + mockLocalClient.execute + .mockResolvedValueOnce({ rows: [{ id: 'tag-1', name: 'js' }] }) // Source tag 1 + .mockResolvedValueOnce({ rows: [{ id: 'tag-2', name: 'javascript' }] }) // Source tag 2 + .mockResolvedValueOnce({ rows: [{ id: 'tag-3', name: 'ecmascript' }] }) // Target tag + .mockResolvedValueOnce({ rows: [{ id: 'post-1' }, { id: 'post-2' }] }) // Posts with source tags + .mockResolvedValueOnce({ rows: [] }) // Update posts + .mockResolvedValueOnce({ rows: [] }) // Delete source tag 1 + .mockResolvedValueOnce({ rows: [] }); // Delete source tag 2 + + const result = await tagEngine.mergeTags(['tag-1', 'tag-2'], 'tag-3'); + + expect(result.success).toBe(true); + expect(result.postsUpdated).toBeGreaterThanOrEqual(0); + expect(result.tagsDeleted).toBe(2); + }); + + it('should emit tagsMerged event', async () => { + mockLocalClient.execute + .mockResolvedValueOnce({ rows: [{ id: 'tag-1', name: 'js' }] }) + .mockResolvedValueOnce({ rows: [{ id: 'tag-2', name: 'javascript' }] }) + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [] }); + + const handler = vi.fn(); + tagEngine.on('tagsMerged', handler); + + await tagEngine.mergeTags(['tag-1'], 'tag-2'); + + expect(handler).toHaveBeenCalled(); + }); + + it('should throw error when source tags array is empty', async () => { + await expect(tagEngine.mergeTags([], 'tag-1')).rejects.toThrow('Source tags are required'); + }); + + it('should throw error when target tag does not exist', async () => { + mockLocalClient.execute + .mockResolvedValueOnce({ rows: [{ id: 'tag-1', name: 'js' }] }) + .mockResolvedValueOnce({ rows: [] }); // Target not found + + await expect(tagEngine.mergeTags(['tag-1'], 'non-existent')).rejects.toThrow('Target tag not found'); + }); + }); + + describe('renameTags (batch rename)', () => { + it('should rename multiple tags and update posts', async () => { + mockLocalClient.execute + .mockResolvedValueOnce({ rows: [{ id: 'tag-1', name: 'old-name' }] }) + .mockResolvedValueOnce({ rows: [] }) // Check no duplicate + .mockResolvedValueOnce({ rows: [{ id: 'post-1' }] }) // Posts with tag + .mockResolvedValueOnce({ rows: [] }) // Update posts + .mockResolvedValueOnce({ rows: [] }); // Update tag name + + const result = await tagEngine.renameTag('tag-1', 'new-name'); + + expect(result.success).toBe(true); + expect(result.postsUpdated).toBeGreaterThanOrEqual(0); + }); + + it('should emit tagRenamed event', async () => { + mockLocalClient.execute + .mockResolvedValueOnce({ rows: [{ id: 'tag-1', name: 'old-name' }] }) + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [] }); + + const handler = vi.fn(); + tagEngine.on('tagRenamed', handler); + + await tagEngine.renameTag('tag-1', 'new-name'); + + expect(handler).toHaveBeenCalledWith(expect.objectContaining({ + oldName: 'old-name', + newName: 'new-name', + })); + }); + }); + + describe('getTag', () => { + it('should return tag by ID', async () => { + mockLocalClient.execute.mockResolvedValueOnce({ + rows: [{ id: 'tag-1', name: 'react', color: '#61dafb', project_id: 'default', created_at: Date.now(), updated_at: Date.now() }], + }); + + const result = await tagEngine.getTag('tag-1'); + + expect(result).toBeDefined(); + expect(result?.name).toBe('react'); + expect(result?.color).toBe('#61dafb'); + }); + + it('should return null for non-existent tag', async () => { + mockLocalClient.execute.mockResolvedValueOnce({ rows: [] }); + + const result = await tagEngine.getTag('non-existent'); + + expect(result).toBeNull(); + }); + }); + + describe('getTagByName', () => { + it('should return tag by name', async () => { + mockLocalClient.execute.mockResolvedValueOnce({ + rows: [{ id: 'tag-1', name: 'react', color: '#61dafb', project_id: 'default', created_at: Date.now(), updated_at: Date.now() }], + }); + + const result = await tagEngine.getTagByName('react'); + + expect(result).toBeDefined(); + expect(result?.id).toBe('tag-1'); + }); + + it('should be case-insensitive', async () => { + mockLocalClient.execute.mockResolvedValueOnce({ + rows: [{ id: 'tag-1', name: 'react', color: null }], + }); + + const result = await tagEngine.getTagByName('REACT'); + + expect(result).toBeDefined(); + }); + }); + + describe('getAllTags', () => { + it('should return all tags for the current project', async () => { + mockLocalClient.execute.mockResolvedValueOnce({ + rows: [ + { id: 'tag-1', name: 'react', color: null, project_id: 'default', created_at: Date.now(), updated_at: Date.now() }, + { id: 'tag-2', name: 'vue', color: '#42b883', project_id: 'default', created_at: Date.now(), updated_at: Date.now() }, + ], + }); + + const result = await tagEngine.getAllTags(); + + expect(result).toHaveLength(2); + expect(result.map(t => t.name)).toContain('react'); + expect(result.map(t => t.name)).toContain('vue'); + }); + }); + + describe('getPostsWithTag', () => { + it('should return post IDs that have the specified tag', async () => { + // First call: get tag name from id + mockLocalClient.execute.mockResolvedValueOnce({ + rows: [{ name: 'react' }], + }); + // Second call: find posts with this tag + mockLocalClient.execute.mockResolvedValueOnce({ + rows: [ + { id: 'post-1', tags: '["react", "typescript"]' }, + { id: 'post-2', tags: '["react"]' }, + ], + }); + + const result = await tagEngine.getPostsWithTag('tag-1'); + + expect(result).toHaveLength(2); + expect(result).toContain('post-1'); + expect(result).toContain('post-2'); + }); + }); + + describe('color validation', () => { + it('should accept valid hex color codes', async () => { + const result = await tagEngine.createTag({ name: 'test', color: '#ff0000' }); + expect(result.color).toBe('#ff0000'); + }); + + it('should accept short hex color codes', async () => { + const result = await tagEngine.createTag({ name: 'test', color: '#f00' }); + expect(result.color).toBe('#f00'); + }); + + it('should reject invalid color codes', async () => { + await expect(tagEngine.createTag({ name: 'test', color: 'red' })) + .rejects.toThrow('Invalid color format'); + }); + + it('should allow null/undefined color', async () => { + const result = await tagEngine.createTag({ name: 'test' }); + expect(result.color).toBeUndefined(); + }); + }); + + describe('syncTagsFromPosts', () => { + it('should discover tags from existing posts and add missing ones', async () => { + mockLocalClient.execute + .mockResolvedValueOnce({ rows: [{ tags: '["react", "javascript"]' }, { tags: '["vue", "typescript"]' }] }) // Get posts + .mockResolvedValueOnce({ rows: [{ name: 'react' }] }) // Existing tags + .mockResolvedValueOnce({ rows: [] }) // Insert missing tags + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [] }); + + const result = await tagEngine.syncTagsFromPosts(); + + expect(result.discovered).toBeGreaterThanOrEqual(0); + }); + }); +});