From e60a2a23c85318619c75c90111ed525a81228335 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 10 May 2026 00:24:24 +0900 Subject: [PATCH] feat(v0211): ftsHelpers + NoteRepository.search + reviewAggregate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ftsHelpers: sanitizeFtsQuery (FTS5 special char escape) + computeCutoff (period → KST 자정) - search: notes_fts MATCH + status filter + rank order + sanitize + 빈 query → [] - reviewAggregate: period 별 totalCount/recentNotes(50)/tagCounts(DESC)/dueProgress(passed/pending) --- src/main/repository/NoteRepository.ts | 82 +++++++++++++++++++ src/main/repository/ftsHelpers.ts | 32 ++++++++ .../NoteRepository.reviewAggregate.test.ts | 72 ++++++++++++++++ tests/unit/NoteRepository.search.test.ts | 57 +++++++++++++ tests/unit/ftsHelpers.test.ts | 34 ++++++++ 5 files changed, 277 insertions(+) create mode 100644 src/main/repository/ftsHelpers.ts create mode 100644 tests/unit/NoteRepository.reviewAggregate.test.ts create mode 100644 tests/unit/NoteRepository.search.test.ts create mode 100644 tests/unit/ftsHelpers.test.ts diff --git a/src/main/repository/NoteRepository.ts b/src/main/repository/NoteRepository.ts index 7ed96b0..0ed8fe2 100644 --- a/src/main/repository/NoteRepository.ts +++ b/src/main/repository/NoteRepository.ts @@ -2,6 +2,7 @@ import type Database from 'better-sqlite3'; import { v7 as uuidv7, v4 as uuidv4 } from 'uuid'; import type { AiStatus, Note, NoteMedia, NoteRevision, NoteStatus, NoteTag } from '@shared/types'; import { kstTodayIso } from '../../shared/util/kstDate.js'; +import { sanitizeFtsQuery, computeCutoff, type ReviewPeriod } from './ftsHelpers.js'; export interface CreateNoteInput { rawText: string; @@ -600,6 +601,87 @@ export class NoteRepository { return rows.map((r) => this.hydrate(r)); } + /** + * v0.2.11 Cut D — FTS5 검색. notes_fts MATCH + rank 정렬 + 기본 trashed 제외. + * 빈/공백 query → []. multi-token 은 implicit AND. FTS5 special chars 는 sanitize. + */ + search(query: string, opts: { limit?: number; status?: NoteStatus } = {}): Note[] { + const sanitized = sanitizeFtsQuery(query); + if (sanitized.length === 0) return []; + const limit = Math.max(1, Math.min(200, opts.limit ?? 50)); + const statusClause = opts.status ? `AND n.status = ?` : `AND n.status != 'trashed'`; + const sql = ` + SELECT n.* FROM notes n + JOIN notes_fts f ON n.id = f.note_id + WHERE notes_fts MATCH ? ${statusClause} + ORDER BY rank + LIMIT ? + `; + const args: unknown[] = opts.status ? [sanitized, opts.status, limit] : [sanitized, limit]; + const rows = this.db.prepare(sql).all(...args) as Record[]; + return rows.map((r) => this.hydrate(r)); + } + + /** + * v0.2.11 Cut D — 회고 view aggregate. period 별 KST 자정 cutoff 이후 노트 + * (status != 'trashed') 의 totalCount / recentNotes(50) / tagCounts(DESC) / + * dueProgress(passed/pending KST today 기준). + */ + reviewAggregate(period: ReviewPeriod, now: Date = new Date()): { + totalCount: number; + recentNotes: Note[]; + tagCounts: Array<{ tag: string; count: number }>; + dueProgress: { total: number; passed: number; pending: number }; + } { + const cutoff = computeCutoff(period, now); + const todayIso = kstTodayIso(now); + + const totalCount = (this.db + .prepare(`SELECT COUNT(*) AS c FROM notes WHERE created_at >= ? AND status != 'trashed'`) + .get(cutoff) as { c: number }).c; + + const recentRows = this.db + .prepare( + `SELECT * FROM notes + WHERE created_at >= ? AND status != 'trashed' + ORDER BY created_at DESC, id DESC LIMIT 50` + ) + .all(cutoff) as Record[]; + const recentNotes = recentRows.map((r) => this.hydrate(r)); + + const tagCounts = this.db + .prepare( + `SELECT t.name AS tag, COUNT(*) AS count + FROM note_tags nt + JOIN notes n ON n.id = nt.note_id + JOIN tags t ON t.id = nt.tag_id + WHERE n.created_at >= ? AND n.status != 'trashed' + GROUP BY t.id + ORDER BY count DESC, t.name ASC` + ) + .all(cutoff) as Array<{ tag: string; count: number }>; + + const dueRow = this.db + .prepare( + `SELECT + COUNT(*) AS total, + SUM(CASE WHEN due_date < ? THEN 1 ELSE 0 END) AS passed, + SUM(CASE WHEN due_date >= ? THEN 1 ELSE 0 END) AS pending + FROM notes + WHERE created_at >= ? + AND status != 'trashed' + AND due_date IS NOT NULL` + ) + .get(todayIso, todayIso, cutoff) as { total: number; passed: number | null; pending: number | null }; + const dueProgress = { + total: dueRow.total, + passed: dueRow.passed ?? 0, + pending: dueRow.pending ?? 0 + }; + + return { totalCount, recentNotes, tagCounts, dueProgress }; + } + /** * 휴지통에서 active 로 복원. setStatus('active') 로 status + deleted_at 동기화 + * v0.2.6 #10 round 1 fix 보존 (ai_status='failed' / 'pending' 시 pending_jobs 재투입). diff --git a/src/main/repository/ftsHelpers.ts b/src/main/repository/ftsHelpers.ts new file mode 100644 index 0000000..47ccac0 --- /dev/null +++ b/src/main/repository/ftsHelpers.ts @@ -0,0 +1,32 @@ +/** + * v0.2.11 Cut D — FTS5 검색 + 회고 view 의 순수 함수 헬퍼. + */ + +const FTS5_SPECIAL_CHARS_RE = /["*():]/g; +const WS_COLLAPSE_RE = /\s+/g; + +/** + * FTS5 MATCH 쿼리에 안전한 형태로 변환. " * ( ) : 제거 + 공백 정리. + * 다중 토큰은 그대로 두어 FTS5 implicit AND 활용. + */ +export function sanitizeFtsQuery(input: string): string { + return input.replace(FTS5_SPECIAL_CHARS_RE, ' ').replace(WS_COLLAPSE_RE, ' ').trim(); +} + +export type ReviewPeriod = 'daily' | 'weekly' | 'monthly'; + +const KST_OFFSET_MS = 9 * 60 * 60 * 1000; + +/** + * 회고 cutoff = period 시작점의 KST 자정 (UTC ISO). + * daily = 오늘 0시, weekly = 7일 전 0시, monthly = 30일 전 0시. + */ +export function computeCutoff(period: ReviewPeriod, now: Date): string { + const kstNow = new Date(now.getTime() + KST_OFFSET_MS); + const y = kstNow.getUTCFullYear(); + const m = kstNow.getUTCMonth(); + const d = kstNow.getUTCDate(); + const todayMidKstUtc = Date.UTC(y, m, d) - KST_OFFSET_MS; + const days = period === 'daily' ? 0 : period === 'weekly' ? 7 : 30; + return new Date(todayMidKstUtc - days * 24 * 60 * 60 * 1000).toISOString(); +} diff --git a/tests/unit/NoteRepository.reviewAggregate.test.ts b/tests/unit/NoteRepository.reviewAggregate.test.ts new file mode 100644 index 0000000..75595cd --- /dev/null +++ b/tests/unit/NoteRepository.reviewAggregate.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import Database from 'better-sqlite3'; +import { runMigrations } from '../../src/main/db/migrations/index.js'; +import { NoteRepository } from '../../src/main/repository/NoteRepository.js'; + +describe('NoteRepository.reviewAggregate', () => { + let db: Database.Database; + let repo: NoteRepository; + + beforeEach(() => { + db = new Database(':memory:'); + db.pragma('foreign_keys = ON'); + runMigrations(db); + repo = new NoteRepository(db); + }); + + afterEach(() => { db.close(); }); + + it('daily — 오늘 KST 자정 이후 노트만 카운트', () => { + const now = new Date('2026-05-10T05:00:00Z'); // KST 14:00 + db.prepare(`INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at, status) + VALUES (?, ?, 'done', ?, ?, 'active')`).run('today', '오늘 메모', '2026-05-10T00:30:00Z', '2026-05-10T00:30:00Z'); + db.prepare(`INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at, status) + VALUES (?, ?, 'done', ?, ?, 'active')`).run('yesterday', '어제 메모', '2026-05-09T10:00:00Z', '2026-05-09T10:00:00Z'); + const r = repo.reviewAggregate('daily', now); + expect(r.totalCount).toBe(1); + expect(r.recentNotes).toHaveLength(1); + expect(r.recentNotes[0]!.id).toBe('today'); + }); + + it('weekly — 7일 전 KST 자정 이후', () => { + const now = new Date('2026-05-10T05:00:00Z'); + db.prepare(`INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at, status) + VALUES (?, ?, 'done', ?, ?, 'active')`).run('5dago', '5일 전', '2026-05-05T00:00:00Z', '2026-05-05T00:00:00Z'); + db.prepare(`INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at, status) + VALUES (?, ?, 'done', ?, ?, 'active')`).run('10dago', '10일 전', '2026-04-30T00:00:00Z', '2026-04-30T00:00:00Z'); + const r = repo.reviewAggregate('weekly', now); + expect(r.totalCount).toBe(1); + }); + + it('trashed 제외', () => { + const now = new Date('2026-05-10T05:00:00Z'); + const a = repo.create({ rawText: '활성' }); + const b = repo.create({ rawText: '버린' }); + repo.setStatus(b.id, 'trashed', null); + const r = repo.reviewAggregate('monthly', now); + expect(r.recentNotes.map((n) => n.id)).toContain(a.id); + expect(r.recentNotes.map((n) => n.id)).not.toContain(b.id); + }); + + it('tagCounts — period 안 노트의 태그만 DESC', () => { + const now = new Date('2026-05-10T05:00:00Z'); + const a = repo.create({ rawText: 'a' }); + const b = repo.create({ rawText: 'b' }); + repo.updateAiResult(a.id, { title: 't', summary: 's', tags: ['x', 'y'], provider: 'p' }); + repo.updateAiResult(b.id, { title: 't', summary: 's', tags: ['x'], provider: 'p' }); + const r = repo.reviewAggregate('monthly', now); + expect(r.tagCounts[0]).toEqual({ tag: 'x', count: 2 }); + expect(r.tagCounts[1]).toEqual({ tag: 'y', count: 1 }); + }); + + it('dueProgress — passed / pending KST today 기준', () => { + const now = new Date('2026-05-10T05:00:00Z'); + const a = repo.create({ rawText: 'a' }); + const b = repo.create({ rawText: 'b' }); + repo.create({ rawText: 'c' }); // due 없음 → 카운트 X + repo.setDueDate(a.id, '2026-05-01'); // passed + repo.setDueDate(b.id, '2026-05-15'); // pending + const r = repo.reviewAggregate('monthly', now); + expect(r.dueProgress).toEqual({ total: 2, passed: 1, pending: 1 }); + }); +}); diff --git a/tests/unit/NoteRepository.search.test.ts b/tests/unit/NoteRepository.search.test.ts new file mode 100644 index 0000000..06327d6 --- /dev/null +++ b/tests/unit/NoteRepository.search.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import Database from 'better-sqlite3'; +import { runMigrations } from '../../src/main/db/migrations/index.js'; +import { NoteRepository } from '../../src/main/repository/NoteRepository.js'; + +describe('NoteRepository.search — FTS5', () => { + let db: Database.Database; + let repo: NoteRepository; + + beforeEach(() => { + db = new Database(':memory:'); + db.pragma('foreign_keys = ON'); + runMigrations(db); + repo = new NoteRepository(db); + const a = repo.create({ rawText: '오늘 월요일 회의 정리' }); + repo.updateAiResult(a.id, { title: '회의록', summary: '월요일', tags: ['기획', '회의'], provider: 'p' }); + const b = repo.create({ rawText: '결재 요청 본문' }); + repo.updateAiResult(b.id, { title: '결재', summary: '요청서', tags: ['결재'], provider: 'p' }); + const c = repo.create({ rawText: '버려진 메모' }); + repo.setStatus(c.id, 'trashed', null); + }); + + afterEach(() => { db.close(); }); + + it('빈 query → 빈 배열', () => { + expect(repo.search('')).toEqual([]); + expect(repo.search(' ')).toEqual([]); + }); + + it('keyword 매칭 → hydrated Note', () => { + const r = repo.search('월요일'); + expect(r.length).toBeGreaterThan(0); + const titles = r.map((n) => n.aiTitle); + expect(titles).toContain('회의록'); + }); + + it('multi-token implicit AND', () => { + const r1 = repo.search('회의 월요일'); + expect(r1.length).toBeGreaterThan(0); + const r2 = repo.search('회의 결재'); // 동시 매칭 노트 없음 + expect(r2).toEqual([]); + }); + + it('default 는 trashed 제외', () => { + const r = repo.search('버려진'); + expect(r).toEqual([]); + }); + + it('status filter 명시 시 해당 status 만', () => { + const r = repo.search('버려진', { status: 'trashed' }); + expect(r.length).toBe(1); + }); + + it('FTS5 special char 안전 처리', () => { + expect(() => repo.search('"회의*" (월요일):')).not.toThrow(); + }); +}); diff --git a/tests/unit/ftsHelpers.test.ts b/tests/unit/ftsHelpers.test.ts new file mode 100644 index 0000000..daf1f9b --- /dev/null +++ b/tests/unit/ftsHelpers.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest'; +import { sanitizeFtsQuery, computeCutoff } from '../../src/main/repository/ftsHelpers.js'; + +describe('sanitizeFtsQuery', () => { + it('strips FTS5 special chars', () => { + expect(sanitizeFtsQuery('"기획" *회의*')).toBe('기획 회의'); + expect(sanitizeFtsQuery('foo: (bar)')).toBe('foo bar'); + }); + it('keeps Korean + alphanumeric tokens', () => { + expect(sanitizeFtsQuery('회의 결재 v2')).toBe('회의 결재 v2'); + }); + it('collapses whitespace', () => { + expect(sanitizeFtsQuery(' 회의 ')).toBe('회의'); + }); + it('returns empty string for whitespace-only', () => { + expect(sanitizeFtsQuery(' ')).toBe(''); + }); +}); + +describe('computeCutoff', () => { + // KST = UTC+9. KST 자정 = UTC 전날 15:00. + it('daily — KST 오늘 자정 ISO', () => { + const now = new Date('2026-05-10T05:30:00Z'); // KST 14:30 + expect(computeCutoff('daily', now)).toBe('2026-05-09T15:00:00.000Z'); + }); + it('weekly — 7일 전 KST 자정', () => { + const now = new Date('2026-05-10T05:30:00Z'); + expect(computeCutoff('weekly', now)).toBe('2026-05-02T15:00:00.000Z'); + }); + it('monthly — 30일 전 KST 자정', () => { + const now = new Date('2026-05-10T05:30:00Z'); + expect(computeCutoff('monthly', now)).toBe('2026-04-09T15:00:00.000Z'); + }); +});