feat: tag management

This commit is contained in:
2026-02-11 14:30:57 +01:00
parent 6b9aa3fb1e
commit 325114681f
17 changed files with 2529 additions and 3 deletions

View File

@@ -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_source ON post_links(source_post_id);
CREATE INDEX IF NOT EXISTS idx_post_links_target ON post_links(target_post_id); CREATE INDEX IF NOT EXISTS idx_post_links_target ON post_links(target_post_id);
CREATE UNIQUE INDEX IF NOT EXISTS posts_project_slug_idx ON posts(project_id, slug); 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) // 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'); 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 // Create default project if none exists
const existingProjects = await this.localClient.execute('SELECT COUNT(*) as count FROM projects'); const existingProjects = await this.localClient.execute('SELECT COUNT(*) as count FROM projects');
if (existingProjects.rows[0] && (existingProjects.rows[0].count as number) === 0) { if (existingProjects.rows[0] && (existingProjects.rows[0].count as number) === 0) {

View File

@@ -95,6 +95,19 @@ export const postLinks = sqliteTable('post_links', {
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), 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 // Types for TypeScript
export type Project = typeof projects.$inferSelect; export type Project = typeof projects.$inferSelect;
export type NewProject = typeof projects.$inferInsert; export type NewProject = typeof projects.$inferInsert;
@@ -108,3 +121,5 @@ export type Setting = typeof settings.$inferSelect;
export type NewSetting = typeof settings.$inferInsert; export type NewSetting = typeof settings.$inferInsert;
export type PostLink = typeof postLinks.$inferSelect; export type PostLink = typeof postLinks.$inferSelect;
export type NewPostLink = typeof postLinks.$inferInsert; export type NewPostLink = typeof postLinks.$inferInsert;
export type Tag = typeof tags.$inferSelect;
export type NewTag = typeof tags.$inferInsert;

View File

@@ -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<TagWithCount[]> {
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<TagData> {
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<TagData | null> {
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<DeleteTagResult> {
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<MergeTagsResult> {
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<RenameTagResult> {
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<TagData | null> {
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<TagData | null> {
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<TagData[]> {
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<string[]> {
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<SyncTagsResult> {
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<string>();
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<void> {
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<void> {
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);
}
}
}
}

View File

@@ -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 { SyncEngine, getSyncEngine, type SyncConfig, type SyncResult, type SyncDirection, type SyncStatus } from './SyncEngine';
export { ProjectEngine, getProjectEngine, type ProjectData } from './ProjectEngine'; export { ProjectEngine, getProjectEngine, type ProjectData } from './ProjectEngine';
export { MetaEngine, getMetaEngine, type ProjectMetadata, DEFAULT_CATEGORIES } from './MetaEngine'; 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 { export {
stemText, stemText,
stemWord, stemWord,
@@ -25,3 +37,4 @@ export {
type FileDownloadResult, type FileDownloadResult,
type ConflictResolution, type ConflictResolution,
} from './DropboxSyncEngine'; } from './DropboxSyncEngine';

View File

@@ -6,6 +6,7 @@ import { getSyncEngine, SyncConfig, SyncDirection } from '../engine/SyncEngine';
import { getDropboxSyncEngine, DropboxSyncConfig, ConflictResolution } from '../engine/DropboxSyncEngine'; import { getDropboxSyncEngine, DropboxSyncConfig, ConflictResolution } from '../engine/DropboxSyncEngine';
import { getProjectEngine, ProjectData } from '../engine/ProjectEngine'; import { getProjectEngine, ProjectData } from '../engine/ProjectEngine';
import { getMetaEngine } from '../engine/MetaEngine'; import { getMetaEngine } from '../engine/MetaEngine';
import { getTagEngine } from '../engine/TagEngine';
import { taskManager, TaskProgress } from '../engine/TaskManager'; import { taskManager, TaskProgress } from '../engine/TaskManager';
import { getDatabase } from '../database'; import { getDatabase } from '../database';
import { media } from '../database/schema'; import { media } from '../database/schema';
@@ -52,9 +53,11 @@ export function registerIpcHandlers(): void {
const postEngine = getPostEngine(); const postEngine = getPostEngine();
const mediaEngine = getMediaEngine(); const mediaEngine = getMediaEngine();
const metaEngine = getMetaEngine(); const metaEngine = getMetaEngine();
const tagEngine = getTagEngine();
postEngine.setProjectContext(project.id); postEngine.setProjectContext(project.id);
mediaEngine.setProjectContext(project.id); mediaEngine.setProjectContext(project.id);
metaEngine.setProjectContext(project.id); metaEngine.setProjectContext(project.id);
tagEngine.setProjectContext(project.id);
// Sync meta on startup // Sync meta on startup
await metaEngine.syncOnStartup(); await metaEngine.syncOnStartup();
@@ -72,9 +75,11 @@ export function registerIpcHandlers(): void {
const postEngine = getPostEngine(); const postEngine = getPostEngine();
const mediaEngine = getMediaEngine(); const mediaEngine = getMediaEngine();
const metaEngine = getMetaEngine(); const metaEngine = getMetaEngine();
const tagEngine = getTagEngine();
postEngine.setProjectContext(project.id); postEngine.setProjectContext(project.id);
mediaEngine.setProjectContext(project.id); mediaEngine.setProjectContext(project.id);
metaEngine.setProjectContext(project.id); metaEngine.setProjectContext(project.id);
tagEngine.setProjectContext(project.id);
// Sync meta on project switch // Sync meta on project switch
await metaEngine.syncOnStartup(); await metaEngine.syncOnStartup();
@@ -549,6 +554,63 @@ export function registerIpcHandlers(): void {
return engine.getProjectMetadata(); 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 ============ // ============ Event Forwarding ============
// Forward engine events to renderer // Forward engine events to renderer
@@ -557,6 +619,7 @@ export function registerIpcHandlers(): void {
const syncEngine = getSyncEngine(); const syncEngine = getSyncEngine();
const projectEngine = getProjectEngine(); const projectEngine = getProjectEngine();
const metaEngine = getMetaEngine(); const metaEngine = getMetaEngine();
const tagEngine = getTagEngine();
const forwardEvent = (eventName: string) => { const forwardEvent = (eventName: string) => {
return (...args: unknown[]) => { return (...args: unknown[]) => {
@@ -586,6 +649,13 @@ export function registerIpcHandlers(): void {
metaEngine.on('categoriesChanged', forwardEvent('meta:categoriesChanged')); metaEngine.on('categoriesChanged', forwardEvent('meta:categoriesChanged'));
metaEngine.on('projectMetadataChanged', forwardEvent('meta:projectMetadataChanged')); 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('syncStarted', forwardEvent('sync:started'));
syncEngine.on('syncCompleted', forwardEvent('sync:completed')); syncEngine.on('syncCompleted', forwardEvent('sync:completed'));
syncEngine.on('syncFailed', forwardEvent('sync:failed')); syncEngine.on('syncFailed', forwardEvent('sync:failed'));

View File

@@ -115,6 +115,21 @@ contextBridge.exposeInMainWorld('electronAPI', {
updateProjectMetadata: (updates: { name?: string; description?: string }) => ipcRenderer.invoke('meta:updateProjectMetadata', updates), 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 // Event listeners
on: (channel: string, callback: (...args: unknown[]) => void) => { on: (channel: string, callback: (...args: unknown[]) => void) => {
const subscription = (_event: Electron.IpcRendererEvent, ...args: unknown[]) => callback(...args); const subscription = (_event: Electron.IpcRendererEvent, ...args: unknown[]) => callback(...args);

View File

@@ -22,6 +22,12 @@ const SettingsIcon = () => (
</svg> </svg>
); );
const TagsIcon = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M21.41 11.58l-9-9C12.05 2.22 11.55 2 11 2H4c-1.1 0-2 .9-2 2v7c0 .55.22 1.05.59 1.42l9 9c.36.36.86.58 1.41.58s1.05-.22 1.41-.59l7-7c.37-.36.59-.86.59-1.41s-.23-1.06-.59-1.42zM5.5 7C4.67 7 4 6.33 4 5.5S4.67 4 5.5 4 7 4.67 7 5.5 6.33 7 5.5 7z"/>
</svg>
);
const SyncIcon = () => ( const SyncIcon = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"> <svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74C4.46 8.97 4 10.43 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"/> <path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74C4.46 8.97 4 10.43 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"/>
@@ -35,12 +41,20 @@ export const ActivityBar: React.FC = () => {
// Check if settings tab is currently active // Check if settings tab is currently active
const isSettingsTabActive = tabs.some(t => t.type === 'settings' && t.id === activeTabId); 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 = () => { const handleSettingsClick = () => {
// Open settings as a dedicated (non-transient) tab // Open settings as a dedicated (non-transient) tab
openTab({ type: 'settings', id: 'settings', isTransient: false }); 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 ( return (
<div className="activity-bar"> <div className="activity-bar">
<div className="activity-bar-top"> <div className="activity-bar-top">
@@ -58,6 +72,13 @@ export const ActivityBar: React.FC = () => {
> >
<MediaIcon /> <MediaIcon />
</button> </button>
<button
className={`activity-bar-item ${isTagsTabActive ? 'active' : ''}`}
onClick={handleTagsClick}
title="Tags"
>
<TagsIcon />
</button>
</div> </div>
<div className="activity-bar-bottom"> <div className="activity-bar-bottom">

View File

@@ -7,6 +7,7 @@ import { Lightbox, useMarkdownImages } from '../Lightbox';
import { PostLinks } from '../PostLinks'; import { PostLinks } from '../PostLinks';
import { ErrorModal } from '../ErrorModal'; import { ErrorModal } from '../ErrorModal';
import { SettingsView } from '../SettingsView'; import { SettingsView } from '../SettingsView';
import { TagsView } from '../TagsView';
import { AutoSaveManager } from '../../utils'; import { AutoSaveManager } from '../../utils';
import './Editor.css'; import './Editor.css';
@@ -1029,6 +1030,7 @@ export const Editor: React.FC = () => {
const showPost = activeTab?.type === 'post'; const showPost = activeTab?.type === 'post';
const showMedia = activeTab?.type === 'media'; const showMedia = activeTab?.type === 'media';
const showSettings = activeTab?.type === 'settings' || (activeView === 'settings' && !activeTab); 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) // Clear selectedPostId if the post doesn't exist (e.g., after project switch)
useEffect(() => { useEffect(() => {
@@ -1082,6 +1084,16 @@ export const Editor: React.FC = () => {
); );
} }
// Show tags if tags tab is active
if (showTags) {
return (
<>
<TagsView />
{renderErrorModal()}
</>
);
}
// Show post editor if a post tab is active // Show post editor if a post tab is active
if (showPost && activeTabId) { if (showPost && activeTabId) {
const post = posts.find(p => p.id === activeTabId); const post = posts.find(p => p.id === activeTabId);

View File

@@ -636,6 +636,50 @@ const MediaList: React.FC = () => {
}; };
import { scrollToSettingsSection, SettingsCategory } from '../SettingsView/SettingsView'; import { scrollToSettingsSection, SettingsCategory } from '../SettingsView/SettingsView';
import { scrollToTagsSection, TagsCategory } from '../TagsView';
const TagsNav: React.FC = () => {
const [activeSection, setActiveSection] = useState<TagsCategory | null>(null);
const handleNavClick = (category: TagsCategory) => {
setActiveSection(category);
scrollToTagsSection(category);
};
return (
<div className="sidebar-content settings-panel">
<div className="sidebar-section">
<div className="sidebar-section-header">
<span>TAGS</span>
</div>
</div>
<div className="settings-nav-list">
<button
className={`settings-nav-entry ${activeSection === 'cloud' ? 'active' : ''}`}
onClick={() => handleNavClick('cloud')}
>
<span className="settings-nav-entry-icon"></span>
<span>Tag Cloud</span>
</button>
<button
className={`settings-nav-entry ${activeSection === 'manage' ? 'active' : ''}`}
onClick={() => handleNavClick('manage')}
>
<span className="settings-nav-entry-icon"></span>
<span>Create & Edit</span>
</button>
<button
className={`settings-nav-entry ${activeSection === 'merge' ? 'active' : ''}`}
onClick={() => handleNavClick('merge')}
>
<span className="settings-nav-entry-icon">🔀</span>
<span>Merge Tags</span>
</button>
</div>
</div>
);
};
const SettingsNav: React.FC = () => { const SettingsNav: React.FC = () => {
const { syncConfigured } = useAppStore(); const { syncConfigured } = useAppStore();
@@ -715,6 +759,7 @@ export const Sidebar: React.FC = () => {
{activeView === 'posts' && <PostsList />} {activeView === 'posts' && <PostsList />}
{activeView === 'media' && <MediaList />} {activeView === 'media' && <MediaList />}
{activeView === 'settings' && <SettingsNav />} {activeView === 'settings' && <SettingsNav />}
{activeView === 'tags' && <TagsNav />}
</div> </div>
); );
}; };

View File

@@ -7,6 +7,10 @@ const getTabTitle = (tab: Tab, posts: { id: string; title: string }[], media: {
return 'Settings'; return 'Settings';
} }
if (tab.type === 'tags') {
return 'Tags';
}
if (tab.type === 'post') { if (tab.type === 'post') {
const post = posts.find(p => p.id === tab.id); const post = posts.find(p => p.id === tab.id);
return post?.title || 'Untitled'; return post?.title || 'Untitled';
@@ -40,6 +44,12 @@ const getTabIcon = (tab: Tab): React.ReactNode => {
<path d="M9.1 4.4L8.6 2H7.4l-.5 2.4-.7.3-2-1.3-.9.8 1.3 2-.2.7-2.4.5v1.2l2.4.5.3.8-1.3 2 .8.8 2-1.3.8.3.4 2.3h1.2l.5-2.4.8-.3 2 1.3.8-.8-1.3-2 .3-.8 2.3-.4V7.4l-2.4-.5-.3-.8 1.3-2-.8-.8-2 1.3-.7-.2zM9.4 1l.5 2.4L12 2.1l2 2-1.4 2.1 2.4.4v3l-2.4.5L14 12l-2 2-2.1-1.4-.5 2.4h-3L5.9 12.5 4 14l-2-2 1.4-2.1L1 9.4v-3l2.4-.5L2 4l2-2 2.1 1.4.4-2.4h3zm.6 7c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2zM8 9c.6 0 1-.4 1-1s-.4-1-1-1-1 .4-1 1 .4 1 1 1z"/> <path d="M9.1 4.4L8.6 2H7.4l-.5 2.4-.7.3-2-1.3-.9.8 1.3 2-.2.7-2.4.5v1.2l2.4.5.3.8-1.3 2 .8.8 2-1.3.8.3.4 2.3h1.2l.5-2.4.8-.3 2 1.3.8-.8-1.3-2 .3-.8 2.3-.4V7.4l-2.4-.5-.3-.8 1.3-2-.8-.8-2 1.3-.7-.2zM9.4 1l.5 2.4L12 2.1l2 2-1.4 2.1 2.4.4v3l-2.4.5L14 12l-2 2-2.1-1.4-.5 2.4h-3L5.9 12.5 4 14l-2-2 1.4-2.1L1 9.4v-3l2.4-.5L2 4l2-2 2.1 1.4.4-2.4h3zm.6 7c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2zM8 9c.6 0 1-.4 1-1s-.4-1-1-1-1 .4-1 1 .4 1 1 1z"/>
</svg> </svg>
); );
case 'tags':
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M14.28 7.72l-6-6A1 1 0 007.57 1.5H2.5A1 1 0 001.5 2.5v5.07a1 1 0 00.22.56l6 6a1 1 0 001.41 0l5.15-5a1 1 0 000-1.41zM4 5a1 1 0 110-2 1 1 0 010 2z"/>
</svg>
);
default: default:
return ( return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"> <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">

View File

@@ -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;
}

View File

@@ -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 (
<button
className={`tag-cloud-item ${isSelected ? 'selected' : ''} ${hasColor ? 'has-color' : ''}`}
style={style}
onClick={() => onSelect(tag.name)}
title={`${tag.count} post${tag.count !== 1 ? 's' : ''}`}
>
{tag.name}
<span className="tag-count">{tag.count}</span>
</button>
);
};
// 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 (
<div className="confirm-dialog-overlay">
<div className="confirm-dialog">
<h3>{title}</h3>
<p>{message}</p>
<div className="confirm-dialog-actions">
<button onClick={onCancel}>{cancelText}</button>
<button
className={isDestructive ? 'danger' : 'primary'}
onClick={onConfirm}
>
{confirmText}
</button>
</div>
</div>
</div>
);
};
// Section Header
const SectionHeader: React.FC<{
id?: string;
title: string;
description?: string;
children: React.ReactNode;
}> = ({ id, title, description, children }) => (
<div className="tags-section" id={id}>
<div className="tags-section-header">
<h3>{title}</h3>
{description && <p className="tags-section-description">{description}</p>}
</div>
<div className="tags-section-content">
{children}
</div>
</div>
);
export const TagsView: React.FC = () => {
const { showErrorModal } = useAppStore();
// State
const [tagsWithCounts, setTagsWithCounts] = useState<TagWithCount[]>([]);
const [allTags, setAllTags] = useState<TagData[]>([]);
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(true);
// Create tag form
const [newTagName, setNewTagName] = useState('');
const [newTagColor, setNewTagColor] = useState('');
// Edit tag state
const [editingTagId, setEditingTagId] = useState<string | null>(null);
const [editTagColor, setEditTagColor] = useState<string>('');
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 (
<div className="tags-view">
<div className="tags-view-header">
<h2>Tag Management</h2>
<p className="text-muted">Manage your blog's tags, assign colors, and perform bulk operations.</p>
</div>
<div className="tags-view-content">
{/* Tag Cloud Section */}
<SectionHeader
id="tags-section-cloud"
title="Tag Cloud"
description="Click tags to select them for bulk operations. Hover to see post counts."
>
{isLoading ? (
<div className="tags-loading">Loading tags...</div>
) : tagsWithCounts.length === 0 ? (
<div className="tags-empty">
<p>No tags found</p>
<button onClick={handleSyncFromPosts}>Discover tags from posts</button>
</div>
) : (
<>
<div className="tag-cloud">
{tagsWithCounts.map(tag => (
<TagCloudItem
key={tag.name}
tag={tag}
isSelected={selectedTags.includes(tag.name)}
onSelect={handleTagSelect}
maxCount={maxCount}
/>
))}
</div>
{selectedTags.length > 0 && (
<div className="tag-selection-info">
<span>{selectedTags.length} tag(s) selected</span>
<button onClick={handleClearSelection}>Clear selection</button>
</div>
)}
</>
)}
</SectionHeader>
{/* Tag Management Section */}
<SectionHeader
id="tags-section-manage"
title="Create & Edit Tags"
description="Create new tags or edit existing ones. Assign colors to make tags visually distinct."
>
{/* Create new tag */}
<div className="tag-create-form">
<h4>Create New Tag</h4>
<div className="tag-form-row">
<input
type="text"
placeholder="Tag name"
value={newTagName}
onChange={(e) => setNewTagName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleCreateTag()}
/>
<div className="color-picker-group">
<input
type="color"
value={newTagColor || '#808080'}
onChange={(e) => setNewTagColor(e.target.value)}
title="Choose color"
/>
{newTagColor && (
<button
className="clear-color"
onClick={() => setNewTagColor('')}
title="Remove color"
>
</button>
)}
</div>
<button onClick={handleCreateTag} className="primary">Create</button>
</div>
<div className="color-presets">
{COLOR_PRESETS.map(color => (
<button
key={color}
className="color-preset"
style={{ backgroundColor: color }}
onClick={() => setNewTagColor(color)}
title={color}
/>
))}
</div>
</div>
{/* Selected tag editor */}
{selectedTagObjects.length === 1 && (
<div className="tag-edit-form">
<h4>Edit Tag: {selectedTagObjects[0].name}</h4>
{editingTagId === selectedTagObjects[0].id ? (
<div className="tag-form-row">
<input
type="text"
value={editTagName}
onChange={(e) => setEditTagName(e.target.value)}
placeholder="Tag name"
/>
<div className="color-picker-group">
<input
type="color"
value={editTagColor || '#808080'}
onChange={(e) => setEditTagColor(e.target.value)}
/>
{editTagColor && (
<button
className="clear-color"
onClick={() => setEditTagColor('')}
title="Remove color"
>
</button>
)}
</div>
<button onClick={handleSaveEdit} className="primary">Save</button>
<button onClick={() => setEditingTagId(null)}>Cancel</button>
</div>
) : (
<div className="tag-form-row">
<span className="tag-preview" style={
selectedTagObjects[0].color
? { backgroundColor: selectedTagObjects[0].color, color: getContrastColor(selectedTagObjects[0].color) }
: {}
}>
{selectedTagObjects[0].name}
</span>
<button onClick={() => handleStartEdit(selectedTagObjects[0])}>
Edit
</button>
<button
className="danger"
onClick={() => setDeleteConfirm({
tagId: selectedTagObjects[0].id,
tagName: selectedTagObjects[0].name
})}
>
Delete
</button>
</div>
)}
</div>
)}
</SectionHeader>
{/* Merge Tags Section */}
<SectionHeader
id="tags-section-merge"
title="Merge Tags"
description="Select multiple tags above, then merge them into a single tag. All posts will be updated."
>
{selectedTags.length < 2 ? (
<p className="text-muted">Select 2 or more tags from the cloud above to merge them.</p>
) : (
<div className="merge-form">
<p>Merge <strong>{selectedTags.length}</strong> tags into:</p>
<div className="tag-form-row">
<select
value={mergeTargetName}
onChange={(e) => setMergeTargetName(e.target.value)}
>
<option value="">Select target tag...</option>
{selectedTags.map(name => (
<option key={name} value={name}>{name}</option>
))}
</select>
<button
className="primary"
disabled={!mergeTargetName}
onClick={() => {
if (mergeTargetName) {
setMergeConfirm({
sourceNames: selectedTags.filter(n => n !== mergeTargetName),
targetName: mergeTargetName,
});
}
}}
>
Merge Tags
</button>
</div>
<p className="text-muted text-small">
Tags to be deleted: {selectedTags.filter(n => n !== mergeTargetName).join(', ') || '(none)'}
</p>
</div>
)}
</SectionHeader>
{/* Sync Section */}
<SectionHeader
id="tags-section-sync"
title="Sync Tags"
description="Discover tags that exist in posts but not in the tag database."
>
<button onClick={handleSyncFromPosts}>
Sync Tags from Posts
</button>
</SectionHeader>
</div>
{/* Confirm Dialogs */}
<ConfirmDialog
isOpen={!!deleteConfirm}
title="Delete Tag"
message={`Are you sure you want to delete the tag "${deleteConfirm?.tagName}"? This will remove it from all posts. This action runs as a background task.`}
confirmText="Delete Tag"
isDestructive
onConfirm={handleDeleteTag}
onCancel={() => setDeleteConfirm(null)}
/>
<ConfirmDialog
isOpen={!!mergeConfirm}
title="Merge Tags"
message={`Are you sure you want to merge ${mergeConfirm?.sourceNames.length} tag(s) into "${mergeConfirm?.targetName}"? The source tags will be deleted and all posts will be updated. This runs as a background task.`}
confirmText="Merge Tags"
onConfirm={handleMergeTags}
onCancel={() => setMergeConfirm(null)}
/>
</div>
);
};

View File

@@ -0,0 +1,2 @@
export { TagsView, scrollToTagsSection } from './TagsView';
export type { TagsCategory } from './TagsView';

View File

@@ -12,5 +12,6 @@ export { TaskPopup } from './TaskPopup';
export { ResizablePanel } from './ResizablePanel'; export { ResizablePanel } from './ResizablePanel';
export { CredentialsPanel } from './CredentialsPanel'; export { CredentialsPanel } from './CredentialsPanel';
export { SettingsView } from './SettingsView'; export { SettingsView } from './SettingsView';
export { TagsView, scrollToTagsSection, type TagsCategory } from './TagsView';
export { PostLinks } from './PostLinks'; export { PostLinks } from './PostLinks';
export { ErrorModal, type ErrorDetails } from './ErrorModal'; export { ErrorModal, type ErrorDetails } from './ErrorModal';

View File

@@ -5,7 +5,7 @@ import { persist } from 'zustand/middleware';
const STORAGE_KEY = 'bds-app-state'; const STORAGE_KEY = 'bds-app-state';
// Tab types // Tab types
export type TabType = 'post' | 'media' | 'settings'; export type TabType = 'post' | 'media' | 'settings' | 'tags';
export interface Tab { export interface Tab {
type: TabType; type: TabType;
@@ -88,7 +88,7 @@ interface AppState {
activeTabId: string | null; activeTabId: string | null;
// UI State // UI State
activeView: 'posts' | 'media' | 'settings'; activeView: 'posts' | 'media' | 'settings' | 'tags';
sidebarVisible: boolean; sidebarVisible: boolean;
panelVisible: boolean; panelVisible: boolean;
selectedPostId: string | null; selectedPostId: string | null;
@@ -136,7 +136,7 @@ interface AppState {
restoreTabState: (state: TabState) => void; restoreTabState: (state: TabState) => void;
// Actions // Actions
setActiveView: (view: 'posts' | 'media' | 'settings') => void; setActiveView: (view: 'posts' | 'media' | 'settings' | 'tags') => void;
toggleSidebar: () => void; toggleSidebar: () => void;
togglePanel: () => void; togglePanel: () => void;
setSelectedPost: (id: string | null) => void; setSelectedPost: (id: string | null) => void;

View File

@@ -131,6 +131,45 @@ export interface CategoryCount {
count: number; 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 { export interface ElectronAPI {
projects: { projects: {
create: (data: { name: string; description?: string; slug?: string }) => Promise<ProjectData>; create: (data: { name: string; description?: string; slug?: string }) => Promise<ProjectData>;
@@ -223,6 +262,19 @@ export interface ElectronAPI {
setProjectMetadata: (metadata: { name: string; description?: string }) => Promise<ProjectMetadata | null>; setProjectMetadata: (metadata: { name: string; description?: string }) => Promise<ProjectMetadata | null>;
updateProjectMetadata: (updates: { name?: string; description?: string }) => Promise<ProjectMetadata | null>; updateProjectMetadata: (updates: { name?: string; description?: string }) => Promise<ProjectMetadata | null>;
}; };
tags: {
getAll: () => Promise<TagData[]>;
getWithCounts: () => Promise<TagWithCount[]>;
get: (id: string) => Promise<TagData | null>;
getByName: (name: string) => Promise<TagData | null>;
create: (data: { name: string; color?: string }) => Promise<TagData>;
update: (id: string, data: { name?: string; color?: string | null }) => Promise<TagData | null>;
delete: (id: string) => Promise<DeleteTagResult>;
merge: (sourceTagIds: string[], targetTagId: string) => Promise<MergeTagsResult>;
rename: (id: string, newName: string) => Promise<RenameTagResult>;
getPostsWithTag: (tagId: string) => Promise<string[]>;
syncFromPosts: () => Promise<SyncTagsResult>;
};
on: (channel: string, callback: (...args: unknown[]) => void) => () => void; on: (channel: string, callback: (...args: unknown[]) => void) => () => void;
once: (channel: string, callback: (...args: unknown[]) => void) => void; once: (channel: string, callback: (...args: unknown[]) => void) => void;
} }

View File

@@ -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<string, any>();
const mockPosts = new Map<string, any>();
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);
});
});
});