fix: removed code duplication in tag engine
This commit is contained in:
@@ -137,6 +137,81 @@ export class TagEngine extends EventEmitter {
|
|||||||
return getDatabase().getLocalClient();
|
return getDatabase().getLocalClient();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getTagRowOrThrow(tagId: string): Promise<typeof tags.$inferSelect> {
|
||||||
|
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<Array<{ postId: string; postTags: string[] }>> {
|
||||||
|
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<void> {
|
||||||
|
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<number> }> {
|
||||||
|
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).
|
* Returns the default internal project directory (in userData).
|
||||||
*/
|
*/
|
||||||
@@ -327,23 +402,7 @@ export class TagEngine extends EventEmitter {
|
|||||||
*/
|
*/
|
||||||
async deleteTag(id: string): Promise<DeleteTagResult> {
|
async deleteTag(id: string): Promise<DeleteTagResult> {
|
||||||
const db = this.getDb();
|
const db = this.getDb();
|
||||||
const client = this.getClient();
|
const tag = await this.getTagRowOrThrow(id);
|
||||||
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 tagName = tag.name;
|
const tagName = tag.name;
|
||||||
|
|
||||||
// Run the deletion as a background task
|
// Run the deletion as a background task
|
||||||
@@ -353,40 +412,15 @@ export class TagEngine extends EventEmitter {
|
|||||||
execute: async (onProgress) => {
|
execute: async (onProgress) => {
|
||||||
onProgress(0, `Finding posts with tag "${tagName}"...`);
|
onProgress(0, `Finding posts with tag "${tagName}"...`);
|
||||||
|
|
||||||
// Find all posts with this tag - requires raw SQL for JSON
|
const updateOperation = await this.updateMatchingPosts(
|
||||||
const postsResult = await client.execute({
|
tagName,
|
||||||
sql: `SELECT id, tags FROM posts WHERE project_id = ? AND tags LIKE ?`,
|
(postTags) => postTags.filter((tagEntry) => tagEntry !== tagName)
|
||||||
args: [this.currentProjectId, `%"${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...');
|
onProgress(90, 'Deleting tag...');
|
||||||
|
|
||||||
// Delete the tag
|
// Delete the tag
|
||||||
@@ -412,8 +446,6 @@ export class TagEngine extends EventEmitter {
|
|||||||
*/
|
*/
|
||||||
async mergeTags(sourceTagIds: string[], targetTagId: string): Promise<MergeTagsResult> {
|
async mergeTags(sourceTagIds: string[], targetTagId: string): Promise<MergeTagsResult> {
|
||||||
const db = this.getDb();
|
const db = this.getDb();
|
||||||
const client = this.getClient();
|
|
||||||
if (!client) throw new Error('Database not initialized');
|
|
||||||
|
|
||||||
if (sourceTagIds.length === 0) {
|
if (sourceTagIds.length === 0) {
|
||||||
throw new Error('Source tags are required');
|
throw new Error('Source tags are required');
|
||||||
@@ -458,46 +490,35 @@ export class TagEngine extends EventEmitter {
|
|||||||
execute: async (onProgress) => {
|
execute: async (onProgress) => {
|
||||||
onProgress(0, 'Finding posts to update...');
|
onProgress(0, 'Finding posts to update...');
|
||||||
|
|
||||||
let totalPostsUpdated = 0;
|
const updatedPostTagsById = new Map<string, string[]>();
|
||||||
|
|
||||||
// For each source tag, update posts and delete the tag
|
// For each source tag, compute final post tags per post ID
|
||||||
for (let i = 0; i < sourceNames.length; i++) {
|
for (let index = 0; index < sourceNames.length; index++) {
|
||||||
const sourceName = sourceNames[i];
|
const sourceName = sourceNames[index];
|
||||||
onProgress((i / sourceNames.length) * 80, `Processing tag "${sourceName}"...`);
|
onProgress((index / sourceNames.length) * 80, `Processing tag "${sourceName}"...`);
|
||||||
|
|
||||||
// Find posts with this source tag
|
const postsWithSourceTag = await this.queryPostsContainingTag(sourceName);
|
||||||
const postsResult = await client.execute({
|
for (const row of postsWithSourceTag) {
|
||||||
sql: `SELECT id, tags FROM posts WHERE project_id = ? AND tags LIKE ?`,
|
const currentTags = updatedPostTagsById.get(row.postId) ?? row.postTags;
|
||||||
args: [this.currentProjectId, `%"${sourceName}"%`],
|
if (!currentTags.includes(sourceName)) {
|
||||||
});
|
continue;
|
||||||
|
|
||||||
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 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...');
|
onProgress(90, 'Deleting source tags...');
|
||||||
|
|
||||||
// Delete source tags
|
// Delete source tags
|
||||||
@@ -532,8 +553,6 @@ export class TagEngine extends EventEmitter {
|
|||||||
*/
|
*/
|
||||||
async renameTag(id: string, newName: string): Promise<RenameTagResult> {
|
async renameTag(id: string, newName: string): Promise<RenameTagResult> {
|
||||||
const db = this.getDb();
|
const db = this.getDb();
|
||||||
const client = this.getClient();
|
|
||||||
if (!client) throw new Error('Database not initialized');
|
|
||||||
|
|
||||||
newName = newName.trim().toLowerCase();
|
newName = newName.trim().toLowerCase();
|
||||||
if (!newName) {
|
if (!newName) {
|
||||||
@@ -581,40 +600,15 @@ export class TagEngine extends EventEmitter {
|
|||||||
execute: async (onProgress) => {
|
execute: async (onProgress) => {
|
||||||
onProgress(0, 'Finding posts to update...');
|
onProgress(0, 'Finding posts to update...');
|
||||||
|
|
||||||
// Find posts with this tag
|
const updateOperation = await this.updateMatchingPosts(
|
||||||
const postsResult = await client.execute({
|
oldName,
|
||||||
sql: `SELECT id, tags FROM posts WHERE project_id = ? AND tags LIKE ?`,
|
(postTags) => postTags.map((tagEntry) => tagEntry === oldName ? newName : tagEntry)
|
||||||
args: [this.currentProjectId, `%"${oldName}"%`],
|
);
|
||||||
|
|
||||||
|
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...');
|
onProgress(90, 'Updating tag record...');
|
||||||
|
|
||||||
// Update the tag name
|
// Update the tag name
|
||||||
|
|||||||
@@ -124,10 +124,12 @@ vi.mock('../../src/main/engine/TaskManager', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock PostEngine - only mock the syncPublishedPostFile method used by TagEngine
|
// Mock PostEngine - only mock the syncPublishedPostFile method used by TagEngine
|
||||||
|
const mockPostEngine = {
|
||||||
|
syncPublishedPostFile: vi.fn(async () => true),
|
||||||
|
};
|
||||||
|
|
||||||
vi.mock('../../src/main/engine/PostEngine', () => ({
|
vi.mock('../../src/main/engine/PostEngine', () => ({
|
||||||
getPostEngine: vi.fn(() => ({
|
getPostEngine: vi.fn(() => mockPostEngine),
|
||||||
syncPublishedPostFile: vi.fn(async () => true),
|
|
||||||
})),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('TagEngine', () => {
|
describe('TagEngine', () => {
|
||||||
@@ -140,6 +142,7 @@ describe('TagEngine', () => {
|
|||||||
mockExecuteArgs = [];
|
mockExecuteArgs = [];
|
||||||
mockSelectDataQueue = [];
|
mockSelectDataQueue = [];
|
||||||
mockSelectDataDefault = [];
|
mockSelectDataDefault = [];
|
||||||
|
mockPostEngine.syncPublishedPostFile.mockClear();
|
||||||
resetMockCounters();
|
resetMockCounters();
|
||||||
tagEngine = new TagEngine();
|
tagEngine = new TagEngine();
|
||||||
});
|
});
|
||||||
@@ -348,6 +351,23 @@ describe('TagEngine', () => {
|
|||||||
|
|
||||||
await expect(tagEngine.mergeTags(['tag-1'], 'non-existent')).rejects.toThrow('Target tag not found');
|
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)', () => {
|
describe('renameTags (batch rename)', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user