feat(v0211): ftsHelpers + NoteRepository.search + reviewAggregate

- 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)
This commit is contained in:
altair823
2026-05-10 00:24:24 +09:00
parent 726d155d04
commit e60a2a23c8
5 changed files with 277 additions and 0 deletions

View File

@@ -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<string, unknown>[];
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<string, unknown>[];
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 재투입).

View File

@@ -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();
}