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:
@@ -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 재투입).
|
||||
|
||||
32
src/main/repository/ftsHelpers.ts
Normal file
32
src/main/repository/ftsHelpers.ts
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user