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_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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
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 { 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';
|
||||||
|
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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"/>
|
||||||
@@ -36,11 +42,19 @@ 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">
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
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 { 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';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
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;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
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