From e7c395e1bddf26cb6a1895e612325fe5acc8d819 Mon Sep 17 00:00:00 2001 From: hugo Date: Mon, 16 Feb 2026 06:57:35 +0100 Subject: [PATCH] fix: phase 8 refactoring --- REFACTOR_DUPLICATION.md | 8 ++++- src/main/engine/MetaEngine.ts | 50 ++++++++++---------------------- src/main/engine/TagEngine.ts | 14 +++++---- src/main/engine/taxonomyUtils.ts | 42 +++++++++++++++++++++++++++ tests/engine/MetaEngine.test.ts | 15 ++++++++++ tests/engine/TagEngine.test.ts | 12 ++++++++ 6 files changed, 99 insertions(+), 42 deletions(-) create mode 100644 src/main/engine/taxonomyUtils.ts diff --git a/REFACTOR_DUPLICATION.md b/REFACTOR_DUPLICATION.md index 839deac..fa70a97 100644 --- a/REFACTOR_DUPLICATION.md +++ b/REFACTOR_DUPLICATION.md @@ -262,7 +262,7 @@ Move color contrast logic into a shared renderer utility. 2. Phase 2 (finish TagEngine workflow dedup) 3. Phase 3 (finish PostMedia single/batch dedup) 4. ~~Phase 7 (WxrParser repeated parse blocks)~~ ✅ Completed -5. Phase 8 (MetaEngine ↔ TagEngine overlap) +5. ~~Phase 8 (MetaEngine ↔ TagEngine overlap)~~ ✅ Completed 6. Phase 9 (renderer tag event subscription helper) 7. Phase 10 (local UI repeated blocks in component files) @@ -307,6 +307,8 @@ Extract shared `parsePubDate` and/or shared item base builder helper to avoid dr ## Phase 8 — Merge Shared Taxonomy/Metadata Logic (MetaEngine + TagEngine) +Status: ✅ Completed + ### Problem A medium-sized duplicate block appears across `MetaEngine` and `TagEngine` for taxonomy/tag-style handling. @@ -323,6 +325,10 @@ Extract a shared helper module or internal utility used by both engines. - Equivalent behavior in both engines. - Single implementation for overlapping logic. +### Progress Check +- Completed: extracted shared taxonomy normalization/collection helper and adopted it in both engines. +- Completed: added cross-engine normalization tests (including empty/whitespace filtering parity). + ### Coverage & Test Quality (fresh run: `npm run test:coverage`) - `src/main/engine/MetaEngine.ts`: 95.03% statements, 96.55% functions, 77.59% branches. - `src/main/engine/TagEngine.ts`: 95.98% statements, 98.00% functions, 77.89% branches. diff --git a/src/main/engine/MetaEngine.ts b/src/main/engine/MetaEngine.ts index 0db2ce7..6f19b16 100644 --- a/src/main/engine/MetaEngine.ts +++ b/src/main/engine/MetaEngine.ts @@ -5,6 +5,11 @@ import { app } from 'electron'; import { eq } from 'drizzle-orm'; import { getDatabase } from '../database'; import { posts, projects } from '../database/schema'; +import { + normalizeTaxonomyTerm, + normalizeNonEmptyTaxonomyTerm, + collectNormalizedTermsFromJsonValues, +} from './taxonomyUtils'; /** * Project metadata stored in meta/project.json @@ -144,7 +149,7 @@ export class MetaEngine extends EventEmitter { * Note: Tag persistence is handled by TagEngine. */ async addTag(tag: string): Promise { - const normalizedTag = tag.trim().toLowerCase(); + const normalizedTag = normalizeTaxonomyTerm(tag); if (normalizedTag && !this.tags.has(normalizedTag)) { this.tags.add(normalizedTag); this.emit('tagsChanged', await this.getTags()); @@ -156,7 +161,7 @@ export class MetaEngine extends EventEmitter { * Note: Tag persistence is handled by TagEngine. */ async removeTag(tag: string): Promise { - const normalizedTag = tag.trim().toLowerCase(); + const normalizedTag = normalizeTaxonomyTerm(tag); if (this.tags.delete(normalizedTag)) { this.emit('tagsChanged', await this.getTags()); } @@ -166,7 +171,7 @@ export class MetaEngine extends EventEmitter { * Add a new category to the available categories list. */ async addCategory(category: string): Promise { - const normalizedCategory = category.trim().toLowerCase(); + const normalizedCategory = normalizeTaxonomyTerm(category); if (normalizedCategory && !this.categories.has(normalizedCategory)) { this.categories.add(normalizedCategory); this.emit('categoriesChanged', await this.getCategories()); @@ -178,7 +183,7 @@ export class MetaEngine extends EventEmitter { * Remove a category from the available categories list. */ async removeCategory(category: string): Promise { - const normalizedCategory = category.trim().toLowerCase(); + const normalizedCategory = normalizeTaxonomyTerm(category); if (this.categories.delete(normalizedCategory)) { this.emit('categoriesChanged', await this.getCategories()); await this.saveCategories(); @@ -244,7 +249,10 @@ export class MetaEngine extends EventEmitter { const parsed = JSON.parse(content) as string[]; this.categories.clear(); for (const cat of parsed) { - this.categories.add(cat.trim().toLowerCase()); + const normalizedCategory = normalizeNonEmptyTaxonomyTerm(cat); + if (normalizedCategory) { + this.categories.add(normalizedCategory); + } } } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { @@ -266,21 +274,7 @@ export class MetaEngine extends EventEmitter { .where(eq(posts.projectId, this.currentProjectId)) .all(); - const allTags = new Set(); - for (const row of dbPosts) { - if (row.tags) { - try { - const parsed: string[] = JSON.parse(row.tags); - for (const tag of parsed) { - allTags.add(tag.trim().toLowerCase()); - } - } catch { - // Invalid JSON, skip - } - } - } - - return Array.from(allTags).sort(); + return collectNormalizedTermsFromJsonValues(dbPosts.map((row) => row.tags)); } /** @@ -294,21 +288,7 @@ export class MetaEngine extends EventEmitter { .where(eq(posts.projectId, this.currentProjectId)) .all(); - const allCategories = new Set(); - for (const row of dbPosts) { - if (row.categories) { - try { - const parsed: string[] = JSON.parse(row.categories); - for (const cat of parsed) { - allCategories.add(cat.trim().toLowerCase()); - } - } catch { - // Invalid JSON, skip - } - } - } - - return Array.from(allCategories).sort(); + return collectNormalizedTermsFromJsonValues(dbPosts.map((row) => row.categories)); } /** diff --git a/src/main/engine/TagEngine.ts b/src/main/engine/TagEngine.ts index 9b3c2a4..bdbee41 100644 --- a/src/main/engine/TagEngine.ts +++ b/src/main/engine/TagEngine.ts @@ -8,6 +8,7 @@ import { getDatabase } from '../database'; import { tags, posts } from '../database/schema'; import { taskManager } from './TaskManager'; import { getPostEngine } from './PostEngine'; +import { normalizeTaxonomyTerm, normalizeNonEmptyTaxonomyTerm } from './taxonomyUtils'; /** * Tag data stored in the database @@ -325,7 +326,7 @@ export class TagEngine extends EventEmitter { async createTag(input: CreateTagInput): Promise { const db = this.getDb(); - const name = input.name.trim().toLowerCase(); + const name = normalizeTaxonomyTerm(input.name); if (!name) { throw new Error('Tag name is required'); } @@ -574,7 +575,7 @@ export class TagEngine extends EventEmitter { async renameTag(id: string, newName: string): Promise { const db = this.getDb(); - newName = newName.trim().toLowerCase(); + newName = normalizeTaxonomyTerm(newName); if (!newName) { throw new Error('New name is required'); } @@ -678,7 +679,7 @@ export class TagEngine extends EventEmitter { */ async getTagByName(name: string): Promise { const db = this.getDb(); - const normalizedName = name.trim().toLowerCase(); + const normalizedName = normalizeTaxonomyTerm(name); const rows = await db .select() @@ -763,8 +764,9 @@ export class TagEngine extends EventEmitter { for (const row of postRows) { const postTags: string[] = JSON.parse(row.tags || '[]'); for (const tag of postTags) { - if (tag.trim()) { - discoveredTags.add(tag.trim().toLowerCase()); + const normalizedTag = normalizeNonEmptyTaxonomyTerm(tag); + if (normalizedTag) { + discoveredTags.add(normalizedTag); } } } @@ -861,7 +863,7 @@ export class TagEngine extends EventEmitter { for (const tag of rawTags) { // Support both portable format { name, color? } and legacy format with id - const name = (tag.name || '').trim().toLowerCase(); + const name = normalizeTaxonomyTerm(tag.name || ''); if (!name) continue; const color = tag.color || null; diff --git a/src/main/engine/taxonomyUtils.ts b/src/main/engine/taxonomyUtils.ts new file mode 100644 index 0000000..d6f1ced --- /dev/null +++ b/src/main/engine/taxonomyUtils.ts @@ -0,0 +1,42 @@ +export function normalizeTaxonomyTerm(term: string): string { + return term.trim().toLowerCase(); +} + +export function normalizeNonEmptyTaxonomyTerm(term: string): string | null { + const normalized = normalizeTaxonomyTerm(term); + return normalized ? normalized : null; +} + +export function collectNormalizedTermsFromJsonValues( + values: Array +): string[] { + const terms = new Set(); + + for (const value of values) { + if (!value) { + continue; + } + + try { + const parsed = JSON.parse(value) as unknown; + if (!Array.isArray(parsed)) { + continue; + } + + for (const entry of parsed) { + if (typeof entry !== 'string') { + continue; + } + + const normalized = normalizeNonEmptyTaxonomyTerm(entry); + if (normalized) { + terms.add(normalized); + } + } + } catch { + // Invalid JSON: skip + } + } + + return Array.from(terms).sort(); +} diff --git a/tests/engine/MetaEngine.test.ts b/tests/engine/MetaEngine.test.ts index 654e9a7..258e24c 100644 --- a/tests/engine/MetaEngine.test.ts +++ b/tests/engine/MetaEngine.test.ts @@ -372,6 +372,21 @@ describe('MetaEngine', () => { const categories = await metaEngine.collectCategoriesFromPosts(); expect(categories).toEqual(['valid-cat']); }); + + it('should ignore empty and whitespace-only taxonomy entries from posts', async () => { + mockPosts = [ + { + tags: JSON.stringify([' valid-tag ', '', ' ']), + categories: JSON.stringify([' valid-cat ', '', ' ']), + }, + ]; + + const tags = await metaEngine.collectTagsFromPosts(); + const categories = await metaEngine.collectCategoriesFromPosts(); + + expect(tags).toEqual(['valid-tag']); + expect(categories).toEqual(['valid-cat']); + }); }); describe('Event Emission', () => { diff --git a/tests/engine/TagEngine.test.ts b/tests/engine/TagEngine.test.ts index 83b7eb1..5ace9f2 100644 --- a/tests/engine/TagEngine.test.ts +++ b/tests/engine/TagEngine.test.ts @@ -634,6 +634,18 @@ describe('TagEngine', () => { expect(result.discovered).toBeGreaterThanOrEqual(0); }); + + it('should ignore empty and whitespace-only tags discovered from posts', async () => { + mockSelectDataQueue = [ + [{ tags: '[" valid-tag ", "", " "]' }], + [], + ]; + + const result = await tagEngine.syncTagsFromPosts(); + + expect(result.discovered).toBe(1); + expect(result.added).toEqual(['valid-tag']); + }); }); describe('loadTagsFromFile', () => {