fix: phase 8 refactoring

This commit is contained in:
2026-02-16 06:57:35 +01:00
parent 6ec25d2705
commit e7c395e1bd
6 changed files with 99 additions and 42 deletions

View File

@@ -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.

View File

@@ -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<void> {
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<void> {
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<void> {
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<void> {
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<string>();
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<string>();
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));
}
/**

View File

@@ -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<TagData> {
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<RenameTagResult> {
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<TagData | null> {
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;

View File

@@ -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 | null | undefined>
): string[] {
const terms = new Set<string>();
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();
}

View File

@@ -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', () => {

View File

@@ -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', () => {