feat: tag management
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
774
src/main/engine/TagEngine.ts
Normal file
774
src/main/engine/TagEngine.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -22,6 +22,12 @@ const SettingsIcon = () => (
|
||||
</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 = () => (
|
||||
<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"/>
|
||||
@@ -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 (
|
||||
<div className="activity-bar">
|
||||
<div className="activity-bar-top">
|
||||
@@ -58,6 +72,13 @@ export const ActivityBar: React.FC = () => {
|
||||
>
|
||||
<MediaIcon />
|
||||
</button>
|
||||
<button
|
||||
className={`activity-bar-item ${isTagsTabActive ? 'active' : ''}`}
|
||||
onClick={handleTagsClick}
|
||||
title="Tags"
|
||||
>
|
||||
<TagsIcon />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="activity-bar-bottom">
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<TagsView />
|
||||
{renderErrorModal()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Show post editor if a post tab is active
|
||||
if (showPost && activeTabId) {
|
||||
const post = posts.find(p => p.id === activeTabId);
|
||||
|
||||
@@ -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<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 { syncConfigured } = useAppStore();
|
||||
@@ -715,6 +759,7 @@ export const Sidebar: React.FC = () => {
|
||||
{activeView === 'posts' && <PostsList />}
|
||||
{activeView === 'media' && <MediaList />}
|
||||
{activeView === 'settings' && <SettingsNav />}
|
||||
{activeView === 'tags' && <TagsNav />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 => {
|
||||
<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>
|
||||
);
|
||||
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:
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
|
||||
366
src/renderer/components/TagsView/TagsView.css
Normal file
366
src/renderer/components/TagsView/TagsView.css
Normal 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;
|
||||
}
|
||||
615
src/renderer/components/TagsView/TagsView.tsx
Normal file
615
src/renderer/components/TagsView/TagsView.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
2
src/renderer/components/TagsView/index.ts
Normal file
2
src/renderer/components/TagsView/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { TagsView, scrollToTagsSection } from './TagsView';
|
||||
export type { TagsCategory } from './TagsView';
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
52
src/renderer/types/electron.d.ts
vendored
52
src/renderer/types/electron.d.ts
vendored
@@ -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<ProjectData>;
|
||||
@@ -223,6 +262,19 @@ export interface ElectronAPI {
|
||||
setProjectMetadata: (metadata: { 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;
|
||||
once: (channel: string, callback: (...args: unknown[]) => void) => void;
|
||||
}
|
||||
|
||||
482
tests/engine/TagEngine.test.ts
Normal file
482
tests/engine/TagEngine.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user