diff --git a/src/main/repository/NoteRepository.ts b/src/main/repository/NoteRepository.ts index 9492c68..8a3a996 100644 --- a/src/main/repository/NoteRepository.ts +++ b/src/main/repository/NoteRepository.ts @@ -40,6 +40,8 @@ export interface ImportNoteResult { status: ImportNoteStatus; } +const KEBAB_CASE_RE = /^[a-z0-9-]+$/; + export class NoteRepository { constructor(private db: Database.Database) {} @@ -219,11 +221,15 @@ export class NoteRepository { * v0.2.3 #3 — AI prompt 의 vocabulary 후보. 사용 빈도 높은 태그 top-N. * source 무시 (AI+user 통합), kebab-case 통과한 것만 (한글/공백/대문자 제외). * deleted_at IS NULL 만 (휴지통 노트 태그 제외). + * + * Note: LIMIT 가 SQL 단계에서 먼저 적용된 후 regex 필터링이 후처리 됨. + * 따라서 반환 배열 length 가 limit 보다 작을 수 있음 (top-N 안에 비-kebab-case + * 태그가 섞여 있을 때). v0.2.3 dogfood 규모에서는 실용적 영향 없음. */ getTopUsedTags(limit = 20): string[] { const rows = this.db .prepare( - `SELECT t.name, COUNT(*) c + `SELECT t.name, COUNT(*) AS c FROM tags t JOIN note_tags nt ON nt.tag_id = t.id JOIN notes n ON n.id = nt.note_id @@ -235,7 +241,7 @@ export class NoteRepository { .all(limit) as Array<{ name: string; c: number }>; return rows .map((r) => r.name) - .filter((n) => /^[a-z0-9-]+$/.test(n)); + .filter((n) => KEBAB_CASE_RE.test(n)); } /** diff --git a/tests/unit/NoteRepository.test.ts b/tests/unit/NoteRepository.test.ts index c875f8a..8097b9b 100644 --- a/tests/unit/NoteRepository.test.ts +++ b/tests/unit/NoteRepository.test.ts @@ -681,11 +681,17 @@ describe('NoteRepository — failed retry helpers', () => { it('getTopUsedTags counts AI + user sources together', () => { const a = repo.create({ rawText: 'a' }).id; const b = repo.create({ rawText: 'b' }).id; + const c = repo.create({ rawText: 'c' }).id; + // design: 1 AI (a) + 1 user (b) = 2 total; meeting: 1 AI (c) = 1 total + // → design must rank first (proves source merging, not AI-only count) + // Note: updateUserAiFields REPLACES tags (DELETE+reinsert), so each note + // gets exactly the tags passed in the call. repo.updateAiResult(a, { title: 't', summary: 'x\ny\nz', tags: ['design'], provider: 'p' }); repo.updateUserAiFields(b, { tags: ['design'] }); - // design count 는 AI 1 + user 1 = 2 + repo.updateAiResult(c, { title: 't', summary: 'x\ny\nz', tags: ['meeting'], provider: 'p' }); const top = repo.getTopUsedTags(); - expect(top).toContain('design'); + expect(top[0]).toBe('design'); // 2 (AI+user) > 1 (AI only) + expect(top.indexOf('meeting')).toBeGreaterThan(0); }); it('getTopUsedTags excludes tags from deleted notes', () => {