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)
|
2. Phase 2 (finish TagEngine workflow dedup)
|
||||||
3. Phase 3 (finish PostMedia single/batch dedup)
|
3. Phase 3 (finish PostMedia single/batch dedup)
|
||||||
4. ~~Phase 7 (WxrParser repeated parse blocks)~~ ✅ Completed
|
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)
|
6. Phase 9 (renderer tag event subscription helper)
|
||||||
7. Phase 10 (local UI repeated blocks in component files)
|
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)
|
## Phase 8 — Merge Shared Taxonomy/Metadata Logic (MetaEngine + TagEngine)
|
||||||
|
|
||||||
|
Status: ✅ Completed
|
||||||
|
|
||||||
### Problem
|
### Problem
|
||||||
A medium-sized duplicate block appears across `MetaEngine` and `TagEngine` for taxonomy/tag-style handling.
|
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.
|
- Equivalent behavior in both engines.
|
||||||
- Single implementation for overlapping logic.
|
- 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`)
|
### 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/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.
|
- `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 { eq } from 'drizzle-orm';
|
||||||
import { getDatabase } from '../database';
|
import { getDatabase } from '../database';
|
||||||
import { posts, projects } from '../database/schema';
|
import { posts, projects } from '../database/schema';
|
||||||
|
import {
|
||||||
|
normalizeTaxonomyTerm,
|
||||||
|
normalizeNonEmptyTaxonomyTerm,
|
||||||
|
collectNormalizedTermsFromJsonValues,
|
||||||
|
} from './taxonomyUtils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Project metadata stored in meta/project.json
|
* Project metadata stored in meta/project.json
|
||||||
@@ -144,7 +149,7 @@ export class MetaEngine extends EventEmitter {
|
|||||||
* Note: Tag persistence is handled by TagEngine.
|
* Note: Tag persistence is handled by TagEngine.
|
||||||
*/
|
*/
|
||||||
async addTag(tag: string): Promise<void> {
|
async addTag(tag: string): Promise<void> {
|
||||||
const normalizedTag = tag.trim().toLowerCase();
|
const normalizedTag = normalizeTaxonomyTerm(tag);
|
||||||
if (normalizedTag && !this.tags.has(normalizedTag)) {
|
if (normalizedTag && !this.tags.has(normalizedTag)) {
|
||||||
this.tags.add(normalizedTag);
|
this.tags.add(normalizedTag);
|
||||||
this.emit('tagsChanged', await this.getTags());
|
this.emit('tagsChanged', await this.getTags());
|
||||||
@@ -156,7 +161,7 @@ export class MetaEngine extends EventEmitter {
|
|||||||
* Note: Tag persistence is handled by TagEngine.
|
* Note: Tag persistence is handled by TagEngine.
|
||||||
*/
|
*/
|
||||||
async removeTag(tag: string): Promise<void> {
|
async removeTag(tag: string): Promise<void> {
|
||||||
const normalizedTag = tag.trim().toLowerCase();
|
const normalizedTag = normalizeTaxonomyTerm(tag);
|
||||||
if (this.tags.delete(normalizedTag)) {
|
if (this.tags.delete(normalizedTag)) {
|
||||||
this.emit('tagsChanged', await this.getTags());
|
this.emit('tagsChanged', await this.getTags());
|
||||||
}
|
}
|
||||||
@@ -166,7 +171,7 @@ export class MetaEngine extends EventEmitter {
|
|||||||
* Add a new category to the available categories list.
|
* Add a new category to the available categories list.
|
||||||
*/
|
*/
|
||||||
async addCategory(category: string): Promise<void> {
|
async addCategory(category: string): Promise<void> {
|
||||||
const normalizedCategory = category.trim().toLowerCase();
|
const normalizedCategory = normalizeTaxonomyTerm(category);
|
||||||
if (normalizedCategory && !this.categories.has(normalizedCategory)) {
|
if (normalizedCategory && !this.categories.has(normalizedCategory)) {
|
||||||
this.categories.add(normalizedCategory);
|
this.categories.add(normalizedCategory);
|
||||||
this.emit('categoriesChanged', await this.getCategories());
|
this.emit('categoriesChanged', await this.getCategories());
|
||||||
@@ -178,7 +183,7 @@ export class MetaEngine extends EventEmitter {
|
|||||||
* Remove a category from the available categories list.
|
* Remove a category from the available categories list.
|
||||||
*/
|
*/
|
||||||
async removeCategory(category: string): Promise<void> {
|
async removeCategory(category: string): Promise<void> {
|
||||||
const normalizedCategory = category.trim().toLowerCase();
|
const normalizedCategory = normalizeTaxonomyTerm(category);
|
||||||
if (this.categories.delete(normalizedCategory)) {
|
if (this.categories.delete(normalizedCategory)) {
|
||||||
this.emit('categoriesChanged', await this.getCategories());
|
this.emit('categoriesChanged', await this.getCategories());
|
||||||
await this.saveCategories();
|
await this.saveCategories();
|
||||||
@@ -244,7 +249,10 @@ export class MetaEngine extends EventEmitter {
|
|||||||
const parsed = JSON.parse(content) as string[];
|
const parsed = JSON.parse(content) as string[];
|
||||||
this.categories.clear();
|
this.categories.clear();
|
||||||
for (const cat of parsed) {
|
for (const cat of parsed) {
|
||||||
this.categories.add(cat.trim().toLowerCase());
|
const normalizedCategory = normalizeNonEmptyTaxonomyTerm(cat);
|
||||||
|
if (normalizedCategory) {
|
||||||
|
this.categories.add(normalizedCategory);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||||
@@ -266,21 +274,7 @@ export class MetaEngine extends EventEmitter {
|
|||||||
.where(eq(posts.projectId, this.currentProjectId))
|
.where(eq(posts.projectId, this.currentProjectId))
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
const allTags = new Set<string>();
|
return collectNormalizedTermsFromJsonValues(dbPosts.map((row) => row.tags));
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -294,21 +288,7 @@ export class MetaEngine extends EventEmitter {
|
|||||||
.where(eq(posts.projectId, this.currentProjectId))
|
.where(eq(posts.projectId, this.currentProjectId))
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
const allCategories = new Set<string>();
|
return collectNormalizedTermsFromJsonValues(dbPosts.map((row) => row.categories));
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { getDatabase } from '../database';
|
|||||||
import { tags, posts } from '../database/schema';
|
import { tags, posts } from '../database/schema';
|
||||||
import { taskManager } from './TaskManager';
|
import { taskManager } from './TaskManager';
|
||||||
import { getPostEngine } from './PostEngine';
|
import { getPostEngine } from './PostEngine';
|
||||||
|
import { normalizeTaxonomyTerm, normalizeNonEmptyTaxonomyTerm } from './taxonomyUtils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tag data stored in the database
|
* Tag data stored in the database
|
||||||
@@ -325,7 +326,7 @@ export class TagEngine extends EventEmitter {
|
|||||||
async createTag(input: CreateTagInput): Promise<TagData> {
|
async createTag(input: CreateTagInput): Promise<TagData> {
|
||||||
const db = this.getDb();
|
const db = this.getDb();
|
||||||
|
|
||||||
const name = input.name.trim().toLowerCase();
|
const name = normalizeTaxonomyTerm(input.name);
|
||||||
if (!name) {
|
if (!name) {
|
||||||
throw new Error('Tag name is required');
|
throw new Error('Tag name is required');
|
||||||
}
|
}
|
||||||
@@ -574,7 +575,7 @@ 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();
|
||||||
|
|
||||||
newName = newName.trim().toLowerCase();
|
newName = normalizeTaxonomyTerm(newName);
|
||||||
if (!newName) {
|
if (!newName) {
|
||||||
throw new Error('New name is required');
|
throw new Error('New name is required');
|
||||||
}
|
}
|
||||||
@@ -678,7 +679,7 @@ export class TagEngine extends EventEmitter {
|
|||||||
*/
|
*/
|
||||||
async getTagByName(name: string): Promise<TagData | null> {
|
async getTagByName(name: string): Promise<TagData | null> {
|
||||||
const db = this.getDb();
|
const db = this.getDb();
|
||||||
const normalizedName = name.trim().toLowerCase();
|
const normalizedName = normalizeTaxonomyTerm(name);
|
||||||
|
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -763,8 +764,9 @@ export class TagEngine extends EventEmitter {
|
|||||||
for (const row of postRows) {
|
for (const row of postRows) {
|
||||||
const postTags: string[] = JSON.parse(row.tags || '[]');
|
const postTags: string[] = JSON.parse(row.tags || '[]');
|
||||||
for (const tag of postTags) {
|
for (const tag of postTags) {
|
||||||
if (tag.trim()) {
|
const normalizedTag = normalizeNonEmptyTaxonomyTerm(tag);
|
||||||
discoveredTags.add(tag.trim().toLowerCase());
|
if (normalizedTag) {
|
||||||
|
discoveredTags.add(normalizedTag);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -861,7 +863,7 @@ export class TagEngine extends EventEmitter {
|
|||||||
|
|
||||||
for (const tag of rawTags) {
|
for (const tag of rawTags) {
|
||||||
// Support both portable format { name, color? } and legacy format with id
|
// 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;
|
if (!name) continue;
|
||||||
|
|
||||||
const color = tag.color || null;
|
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();
|
const categories = await metaEngine.collectCategoriesFromPosts();
|
||||||
expect(categories).toEqual(['valid-cat']);
|
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', () => {
|
describe('Event Emission', () => {
|
||||||
|
|||||||
@@ -634,6 +634,18 @@ describe('TagEngine', () => {
|
|||||||
|
|
||||||
expect(result.discovered).toBeGreaterThanOrEqual(0);
|
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', () => {
|
describe('loadTagsFromFile', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user