diff --git a/src/main/engine/TagEngine.ts b/src/main/engine/TagEngine.ts index aeb35ac..8462ac2 100644 --- a/src/main/engine/TagEngine.ts +++ b/src/main/engine/TagEngine.ts @@ -137,6 +137,81 @@ export class TagEngine extends EventEmitter { return getDatabase().getLocalClient(); } + private async getTagRowOrThrow(tagId: string): Promise { + const db = this.getDb(); + + const tagRows = await db + .select() + .from(tags) + .where(and( + eq(tags.id, tagId), + eq(tags.projectId, this.currentProjectId) + )); + + if (tagRows.length === 0) { + throw new Error('Tag not found'); + } + + return tagRows[0]; + } + + private async queryPostsContainingTag(tagName: string): Promise> { + const client = this.getClient(); + if (!client) { + throw new Error('Database not initialized'); + } + + const postsResult = await client.execute({ + sql: `SELECT id, tags FROM posts WHERE project_id = ? AND tags LIKE ?`, + args: [this.currentProjectId, `%"${tagName}"%`], + }); + + return postsResult.rows + .map((row: any) => ({ + postId: row.id as string, + postTags: JSON.parse(row.tags || '[]') as string[], + })) + .filter((row) => row.postTags.includes(tagName)); + } + + private async updatePostTagsAndSync(postId: string, updatedTags: string[]): Promise { + const db = this.getDb(); + + await db + .update(posts) + .set({ + tags: JSON.stringify(updatedTags), + updatedAt: new Date(), + }) + .where(eq(posts.id, postId)); + + await getPostEngine().syncPublishedPostFile(postId); + } + + private async updateMatchingPosts( + tagName: string, + transform: (postTags: string[]) => string[] + ): Promise<{ total: number; process: (onEachUpdated: (updated: number, total: number) => void) => Promise }> { + const postsToUpdate = await this.queryPostsContainingTag(tagName); + const total = postsToUpdate.length; + + return { + total, + process: async (onEachUpdated) => { + let updated = 0; + + for (const row of postsToUpdate) { + const updatedTags = transform(row.postTags); + await this.updatePostTagsAndSync(row.postId, updatedTags); + updated++; + onEachUpdated(updated, total); + } + + return updated; + }, + }; + } + /** * Returns the default internal project directory (in userData). */ @@ -327,23 +402,7 @@ export class TagEngine extends EventEmitter { */ async deleteTag(id: string): Promise { const db = this.getDb(); - const client = this.getClient(); - if (!client) throw new Error('Database not initialized'); - - // Get tag - const tagRows = await db - .select() - .from(tags) - .where(and( - eq(tags.id, id), - eq(tags.projectId, this.currentProjectId) - )); - - if (tagRows.length === 0) { - throw new Error('Tag not found'); - } - - const tag = tagRows[0]; + const tag = await this.getTagRowOrThrow(id); const tagName = tag.name; // Run the deletion as a background task @@ -353,40 +412,15 @@ export class TagEngine extends EventEmitter { execute: async (onProgress) => { onProgress(0, `Finding posts with tag "${tagName}"...`); - // Find all posts with this tag - requires raw SQL for JSON - const postsResult = await client.execute({ - sql: `SELECT id, tags FROM posts WHERE project_id = ? AND tags LIKE ?`, - args: [this.currentProjectId, `%"${tagName}"%`], + const updateOperation = await this.updateMatchingPosts( + tagName, + (postTags) => postTags.filter((tagEntry) => tagEntry !== tagName) + ); + + const updated = await updateOperation.process((updatedCount, totalCount) => { + onProgress((updatedCount / totalCount) * 80, `Updated ${updatedCount}/${totalCount} posts...`); }); - const postsToUpdate = postsResult.rows.filter((row: any) => { - const postTags: string[] = JSON.parse(row.tags || '[]'); - return postTags.includes(tagName); - }); - - const total = postsToUpdate.length; - let updated = 0; - - for (const row of postsToUpdate) { - const postId = row.id as string; - const postTags: string[] = JSON.parse((row as any).tags || '[]'); - const newTags = postTags.filter(t => t !== tagName); - - await db - .update(posts) - .set({ - tags: JSON.stringify(newTags), - updatedAt: new Date(), - }) - .where(eq(posts.id, postId)); - - // Sync published post's file with updated tags - await getPostEngine().syncPublishedPostFile(postId); - - updated++; - onProgress((updated / total) * 80, `Updated ${updated}/${total} posts...`); - } - onProgress(90, 'Deleting tag...'); // Delete the tag @@ -412,8 +446,6 @@ export class TagEngine extends EventEmitter { */ async mergeTags(sourceTagIds: string[], targetTagId: string): Promise { const db = this.getDb(); - const client = this.getClient(); - if (!client) throw new Error('Database not initialized'); if (sourceTagIds.length === 0) { throw new Error('Source tags are required'); @@ -458,46 +490,35 @@ export class TagEngine extends EventEmitter { execute: async (onProgress) => { onProgress(0, 'Finding posts to update...'); - let totalPostsUpdated = 0; + const updatedPostTagsById = new Map(); - // 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}"...`); + // For each source tag, compute final post tags per post ID + for (let index = 0; index < sourceNames.length; index++) { + const sourceName = sourceNames[index]; + onProgress((index / 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 postTags: string[] = JSON.parse((row as any).tags || '[]'); - - if (postTags.includes(sourceName)) { - // Remove source tag and add target if not already present - const newTags = postTags.filter(t => t !== sourceName); - if (!newTags.includes(targetName)) { - newTags.push(targetName); - } - - await db - .update(posts) - .set({ - tags: JSON.stringify(newTags), - updatedAt: new Date(), - }) - .where(eq(posts.id, postId)); - - // Sync published post's file with updated tags - await getPostEngine().syncPublishedPostFile(postId); - - totalPostsUpdated++; + const postsWithSourceTag = await this.queryPostsContainingTag(sourceName); + for (const row of postsWithSourceTag) { + const currentTags = updatedPostTagsById.get(row.postId) ?? row.postTags; + if (!currentTags.includes(sourceName)) { + continue; } + + const transformedTags = currentTags.filter((tagEntry) => tagEntry !== sourceName); + if (!transformedTags.includes(targetName)) { + transformedTags.push(targetName); + } + + updatedPostTagsById.set(row.postId, transformedTags); } } + for (const [postId, updatedTags] of updatedPostTagsById.entries()) { + await this.updatePostTagsAndSync(postId, updatedTags); + } + + const totalPostsUpdated = updatedPostTagsById.size; + onProgress(90, 'Deleting source tags...'); // Delete source tags @@ -532,8 +553,6 @@ export class TagEngine extends EventEmitter { */ async renameTag(id: string, newName: string): Promise { const db = this.getDb(); - const client = this.getClient(); - if (!client) throw new Error('Database not initialized'); newName = newName.trim().toLowerCase(); if (!newName) { @@ -581,40 +600,15 @@ export class TagEngine extends EventEmitter { 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 updateOperation = await this.updateMatchingPosts( + oldName, + (postTags) => postTags.map((tagEntry) => tagEntry === oldName ? newName : tagEntry) + ); + + const updated = await updateOperation.process((updatedCount, totalCount) => { + onProgress((updatedCount / totalCount) * 80, `Updated ${updatedCount}/${totalCount} posts...`); }); - const postsToUpdate = postsResult.rows.filter((row: any) => { - const postTags: string[] = JSON.parse(row.tags || '[]'); - return postTags.includes(oldName); - }); - - const total = postsToUpdate.length; - let updated = 0; - - for (const row of postsToUpdate) { - const postId = row.id as string; - const postTags: string[] = JSON.parse((row as any).tags || '[]'); - const updatedTags = postTags.map(t => t === oldName ? newName : t); - - await db - .update(posts) - .set({ - tags: JSON.stringify(updatedTags), - updatedAt: new Date(), - }) - .where(eq(posts.id, postId)); - - // Sync published post's file with updated tags - await getPostEngine().syncPublishedPostFile(postId); - - updated++; - onProgress((updated / total) * 80, `Updated ${updated}/${total} posts...`); - } - onProgress(90, 'Updating tag record...'); // Update the tag name diff --git a/tests/engine/TagEngine.test.ts b/tests/engine/TagEngine.test.ts index 8b148ab..eedb99d 100644 --- a/tests/engine/TagEngine.test.ts +++ b/tests/engine/TagEngine.test.ts @@ -124,10 +124,12 @@ vi.mock('../../src/main/engine/TaskManager', () => ({ })); // Mock PostEngine - only mock the syncPublishedPostFile method used by TagEngine +const mockPostEngine = { + syncPublishedPostFile: vi.fn(async () => true), +}; + vi.mock('../../src/main/engine/PostEngine', () => ({ - getPostEngine: vi.fn(() => ({ - syncPublishedPostFile: vi.fn(async () => true), - })), + getPostEngine: vi.fn(() => mockPostEngine), })); describe('TagEngine', () => { @@ -140,6 +142,7 @@ describe('TagEngine', () => { mockExecuteArgs = []; mockSelectDataQueue = []; mockSelectDataDefault = []; + mockPostEngine.syncPublishedPostFile.mockClear(); resetMockCounters(); tagEngine = new TagEngine(); }); @@ -348,6 +351,23 @@ describe('TagEngine', () => { await expect(tagEngine.mergeTags(['tag-1'], 'non-existent')).rejects.toThrow('Target tag not found'); }); + + it('should only update each post once when multiple source tags exist on the same post', async () => { + mockSelectDataQueue = [ + [{ id: 'tag-1', name: 'js', projectId: 'default', createdAt: new Date(), updatedAt: new Date() }], + [{ id: 'tag-2', name: 'javascript', projectId: 'default', createdAt: new Date(), updatedAt: new Date() }], + [{ id: 'tag-3', name: 'ecmascript', projectId: 'default', createdAt: new Date(), updatedAt: new Date() }], + ]; + + mockLocalClient.execute + .mockResolvedValueOnce({ rows: [{ id: 'post-1', tags: '["js", "javascript"]' }] }) + .mockResolvedValueOnce({ rows: [{ id: 'post-1', tags: '["js", "javascript"]' }] }); + + const result = await tagEngine.mergeTags(['tag-1', 'tag-2'], 'tag-3'); + + expect(result.postsUpdated).toBe(1); + expect(mockPostEngine.syncPublishedPostFile).toHaveBeenCalledTimes(1); + }); }); describe('renameTags (batch rename)', () => {