fix: phase 8 refactoring
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
42
src/main/engine/taxonomyUtils.ts
Normal file
42
src/main/engine/taxonomyUtils.ts
Normal 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();
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user