Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e9a82face | |||
|
|
735d5494f2 | ||
|
|
5801a98a00 | ||
|
|
9feb712c60 | ||
|
|
be125b8ace | ||
|
|
f5e43133be | ||
|
|
143684ce8a | ||
|
|
e60a2a23c8 | ||
|
|
726d155d04 | ||
|
|
19edeab7b1 | ||
|
|
1104a8c666 | ||
| c4e7536086 | |||
|
|
39b8d1e728 | ||
|
|
e32223d28c | ||
|
|
81fbacb21e | ||
|
|
ff1a015226 | ||
|
|
b4c2d85b26 | ||
|
|
7541d3c9e4 | ||
|
|
18deee5900 | ||
|
|
76c23457ee | ||
|
|
88ce78d860 | ||
|
|
07e61bc9e1 |
1314
docs/superpowers/plans/2026-05-09-v0210-cut-c-raw-text-revisions.md
Normal file
1314
docs/superpowers/plans/2026-05-09-v0210-cut-c-raw-text-revisions.md
Normal file
File diff suppressed because it is too large
Load Diff
1867
docs/superpowers/plans/2026-05-09-v029-cut-b.md
Normal file
1867
docs/superpowers/plans/2026-05-09-v029-cut-b.md
Normal file
File diff suppressed because it is too large
Load Diff
1443
docs/superpowers/plans/2026-05-10-v0211-cut-d-fts5-review.md
Normal file
1443
docs/superpowers/plans/2026-05-10-v0211-cut-d-fts5-review.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1423,9 +1423,9 @@ app.on('activate', () => {
|
||||
|
||||
---
|
||||
|
||||
## F19. 획기적 recall 메커니즘 (🌱 raw — v0.2.8+ 큰 영역, 본질 재설계 가능)
|
||||
## F19. 획기적 recall 메커니즘 (✅ promoted v0.2.11 Cut D — A+D 옵션)
|
||||
|
||||
**진행 상태:** 🌱 raw — 핵심 가치 영역. v0.2.8 brainstorm 시 별도 spec 후보 (recall 만 단독 cut 가치).
|
||||
**진행 상태:** ✅ promoted v0.2.11 Cut D — A (FTS5 search) + D (일/주/월 회고 view) 적용. m007 마이그레이션 + `NoteRepository.search` + `reviewAggregate` + SearchBox + ReviewView. B/C/E/F 옵션은 v0.3+ deferred.
|
||||
|
||||
**발견:** 2026-05-09 v0.2.7 release 후 본인 dogfood. "메모의 빠른 기록도 중요하지만 적절한 recall 도 훨씬 중요" — 본인 표현.
|
||||
|
||||
@@ -1507,9 +1507,9 @@ app.on('activate', () => {
|
||||
|
||||
---
|
||||
|
||||
## F20. 기존 메모 본문 (raw_text) 수정 가능성 (🌱 raw — v0.2.8 후보, **load-bearing invariant 재검토**)
|
||||
## F20. 기존 메모 본문 (raw_text) 수정 가능성 (✅ promoted v0.2.10 Cut C — invariant 폐기)
|
||||
|
||||
**진행 상태:** 🌱 raw — 메모리 정책 `raw_text 불변` 재논의 트리거. v0.2.8 brainstorm 시 invariant 변경 여부 결정.
|
||||
**진행 상태:** ✅ promoted v0.2.10 Cut C — `raw_text 불변` invariant 폐기, `note_revisions` 테이블로 변경 이력 보존. m006 마이그레이션 + `updateRawText`/`listRevisions`/`restoreRevision` repo API + RevisionHistoryModal UI. AI 재실행 input = current latest raw_text (옵션 B).
|
||||
|
||||
**발견:** 2026-05-09 v0.2.7 release 후 본인 dogfood.
|
||||
|
||||
|
||||
@@ -31,7 +31,9 @@
|
||||
|
||||
---
|
||||
|
||||
## 3. Schema 마이그레이션 (m005)
|
||||
## 3. Schema 마이그레이션 (m006)
|
||||
|
||||
> 메모: 본 스펙 작성 시점에는 m005 로 예상했으나 Cut B (v0.2.9) 에서 m005 (ai_disabled CHECK relax) 가 선점됨 → 실제 번호는 **m006**.
|
||||
|
||||
```sql
|
||||
CREATE TABLE note_revisions (
|
||||
@@ -171,7 +173,7 @@ interface NoteRevision {
|
||||
|
||||
| 영역 | 단위 |
|
||||
|---|---|
|
||||
| m005 마이그레이션 | 기존 notes → revision backfill (edited_by='capture') |
|
||||
| m006 마이그레이션 | 기존 notes → revision backfill (edited_by='capture') |
|
||||
| `updateRawText` | notes.raw_text 갱신 + 새 revision INSERT atomic |
|
||||
| `listRevisions` | DESC 순 + edited_by 정확 |
|
||||
| `restoreRevision` | 옛 raw_text 가 새 revision 으로 INSERT + notes.raw_text 갱신 |
|
||||
@@ -179,7 +181,7 @@ interface NoteRevision {
|
||||
| 이력 modal | revision 목록 표시 + 회수 클릭 → confirm + IPC |
|
||||
| AiWorker input | current notes.raw_text 사용 (revision X) 회귀 |
|
||||
|
||||
**목표**: 단위 490 → 약 505 (+15), typecheck 0.
|
||||
**목표**: 단위 548 → 약 567 (+19, m006 5 + create rev 1 + updateRawText 2 + listRevisions 1 + restoreRevision 2 + IPC 4 + NoteCard 편집 1 + RevisionHistoryModal 2 + findById 회귀 1), typecheck 0.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -27,35 +27,52 @@ recall 핵심 가치 도달 — search + 회고 view. F19 의 6 옵션 중 **A (
|
||||
|
||||
## 3. F19-A 디테일 (FTS5)
|
||||
|
||||
### 3-1. Schema 마이그레이션 (m006)
|
||||
### 3-1. Schema 마이그레이션 (m007)
|
||||
|
||||
> 메모: 본 스펙 작성 시점에는 m006 로 예상했으나 Cut C (v0.2.10) 에서 m006 (note_revisions) 가 선점됨 → 실제 번호는 **m007**.
|
||||
|
||||
실제 schema 정정:
|
||||
- `notes.title`/`notes.summary` 컬럼 없음 → 실제 `notes.ai_title`/`notes.ai_summary` 사용
|
||||
- `notes.tags_csv` 컬럼 없음 → tags 는 `note_tags` join (note_tags.note_id ↔ tags.id)
|
||||
- `notes.status` (Cut B m004 도입) 사용 가능 — `status != 'trashed'` 필터
|
||||
|
||||
```sql
|
||||
CREATE VIRTUAL TABLE notes_fts USING fts5(
|
||||
note_id UNINDEXED,
|
||||
raw_text,
|
||||
title,
|
||||
summary,
|
||||
ai_title,
|
||||
ai_summary,
|
||||
tags,
|
||||
tokenize='unicode61'
|
||||
);
|
||||
|
||||
-- 기존 notes 모두 인덱스
|
||||
INSERT INTO notes_fts (note_id, raw_text, title, summary, tags)
|
||||
SELECT id, raw_text, title, summary, tags_csv FROM notes WHERE status != 'trashed';
|
||||
-- 기존 notes (active/completed/archived 만 — trashed 제외) 모두 인덱스.
|
||||
-- tags 는 note_tags+tags JOIN 후 GROUP_CONCAT 으로 csv 구성.
|
||||
INSERT INTO notes_fts (note_id, raw_text, ai_title, ai_summary, tags)
|
||||
SELECT
|
||||
n.id,
|
||||
n.raw_text,
|
||||
COALESCE(n.ai_title, ''),
|
||||
COALESCE(n.ai_summary, ''),
|
||||
COALESCE((SELECT GROUP_CONCAT(t.name, ' ')
|
||||
FROM note_tags nt JOIN tags t ON t.id = nt.tag_id
|
||||
WHERE nt.note_id = n.id), '')
|
||||
FROM notes n
|
||||
WHERE n.status != 'trashed';
|
||||
```
|
||||
|
||||
`tokenize='unicode61'` — 한국어 partial tokenize 가능 (단어 boundary). 향후 `tokenize='porter unicode61'` 또는 한국어 전용 tokenizer (예: `mecab-ko-fts5`) 검토 가능 — Cut D 는 unicode61 default.
|
||||
|
||||
`tags_csv` — notes.tags (JSON array) 를 csv 로 flatten 하여 인덱스 (예: `"기획 회의 결재"`).
|
||||
`tags` 컬럼 = note_tags JOIN 결과 csv (예: `"기획 회의 결재"`). `note_tags` 변경 시 NoteRepository 에서 명시적 헬퍼 (`rebuildFtsTagsForNote(noteId)`) 호출 — trigger 로 sync 어려움 (`note_tags` INSERT/DELETE 가 다른 노트 row 재계산 트리거하기 부담). 단일 write path 패턴 (Cut C 확립) 으로 강제.
|
||||
|
||||
### 3-2. Trigger — auto-sync
|
||||
### 3-2. Trigger — auto-sync (notes 컬럼 한정)
|
||||
|
||||
`notes` INSERT/UPDATE/DELETE 시 `notes_fts` 자동 sync:
|
||||
`notes` INSERT/UPDATE/DELETE 시 `notes_fts` 자동 sync (raw_text/ai_title/ai_summary 만; tags 는 별도 헬퍼):
|
||||
|
||||
```sql
|
||||
CREATE TRIGGER notes_ai AFTER INSERT ON notes BEGIN
|
||||
INSERT INTO notes_fts (note_id, raw_text, title, summary, tags)
|
||||
VALUES (NEW.id, NEW.raw_text, NEW.title, NEW.summary, NEW.tags_csv);
|
||||
INSERT INTO notes_fts (note_id, raw_text, ai_title, ai_summary, tags)
|
||||
VALUES (NEW.id, NEW.raw_text, COALESCE(NEW.ai_title, ''), COALESCE(NEW.ai_summary, ''), '');
|
||||
END;
|
||||
|
||||
CREATE TRIGGER notes_ad AFTER DELETE ON notes BEGIN
|
||||
@@ -63,32 +80,45 @@ CREATE TRIGGER notes_ad AFTER DELETE ON notes BEGIN
|
||||
END;
|
||||
|
||||
CREATE TRIGGER notes_au AFTER UPDATE ON notes BEGIN
|
||||
UPDATE notes_fts SET raw_text=NEW.raw_text, title=NEW.title, summary=NEW.summary, tags=NEW.tags_csv
|
||||
WHERE note_id = NEW.id;
|
||||
UPDATE notes_fts
|
||||
SET raw_text = NEW.raw_text,
|
||||
ai_title = COALESCE(NEW.ai_title, ''),
|
||||
ai_summary = COALESCE(NEW.ai_summary, '')
|
||||
WHERE note_id = NEW.id;
|
||||
END;
|
||||
```
|
||||
|
||||
Cut C 의 `updateRawText` 가 `notes.raw_text` UPDATE → trigger 자동 발동 → FTS5 갱신.
|
||||
|
||||
`tags_csv` 는 별도 generated column 또는 NoteRepository 에서 수동 갱신 (zod parse 후 csv join). YAGNI: 수동 갱신.
|
||||
`tags` 갱신 path:
|
||||
- `NoteRepository.updateAiResult` (AI tags) / `updateUserAiFields` (사용자 tags) 모두 `note_tags` 변경 후 동일 transaction 안에서 `rebuildFtsTagsForNote(noteId)` 호출.
|
||||
|
||||
trashed 노트 처리 — `setStatus(id, 'trashed', ...)` 시 trigger AFTER UPDATE 발동되어 FTS row 가 그대로 유지됨. 검색 시 query 단계에서 `n.status != 'trashed'` 필터로 제외 (별도 FTS row cleanup 안 함 — YAGNI).
|
||||
|
||||
### 3-3. NoteRepository.search
|
||||
|
||||
```ts
|
||||
search(query: string, opts: { limit?: number; status?: NoteStatus }): Note[] {
|
||||
const limit = opts.limit ?? 50;
|
||||
const statusClause = opts.status ? `AND n.status = ?` : '';
|
||||
search(query: string, opts: { limit?: number; status?: NoteStatus } = {}): Note[] {
|
||||
if (query.trim().length === 0) return [];
|
||||
const limit = Math.max(1, Math.min(200, opts.limit ?? 50));
|
||||
const ftsQuery = sanitizeFtsQuery(query); // FTS5 special char escape
|
||||
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 = opts.status ? [query, opts.status, limit] : [query, limit];
|
||||
return this.db.prepare(sql).all(...args) as Note[];
|
||||
const args = opts.status ? [ftsQuery, opts.status, limit] : [ftsQuery, limit];
|
||||
const rows = this.db.prepare(sql).all(...args) as Record<string, unknown>[];
|
||||
return rows.map((r) => this.hydrate(r));
|
||||
}
|
||||
```
|
||||
|
||||
`hydrate` — 기존 패턴 (tags + media join). `sanitizeFtsQuery` — FTS5 special chars (`"`, `*`, `(`, `)`, `:`) 이스케이프 및 multi-word AND 결합 (예: `기획 회의` → `"기획" AND "회의"` 또는 `기획 회의` 그대로 수용). YAGNI: 다중 토큰을 그대로 FTS5 implicit AND 로 보냄 + 따옴표 제거.
|
||||
|
||||
`status` 미지정 시 default = trashed 제외.
|
||||
|
||||
`MATCH` 쿼리 syntax — FTS5 standard (`"기획 회의"`, `회의 OR 결재`, `기획*` 등).
|
||||
|
||||
### 3-4. UI — inbox 헤더 search box
|
||||
@@ -149,23 +179,55 @@ export function ReviewView({ period }: { period: 'daily' | 'weekly' | 'monthly'
|
||||
NoteRepository:
|
||||
|
||||
```ts
|
||||
reviewAggregate(period: 'daily' | 'weekly' | 'monthly', now: Date): {
|
||||
reviewAggregate(period: 'daily' | 'weekly' | 'monthly', 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);
|
||||
// 단일 transaction 안에 N개 query
|
||||
const totalCount = this.db.prepare(`SELECT COUNT(*) as c FROM notes WHERE created_at >= ? AND status != 'trashed'`).get(cutoff).c;
|
||||
const recentNotes = this.db.prepare(`SELECT * FROM notes WHERE created_at >= ? AND status != 'trashed' ORDER BY created_at DESC LIMIT 50`).all(cutoff);
|
||||
// tagCounts — JSON tags array unnest → group by
|
||||
// dueProgress — due_date 컬럼 + KST 비교
|
||||
return { ... };
|
||||
const cutoff = computeCutoff(period, now); // ISO string — KST 자정 / 7일전 / 30일전
|
||||
const todayIso = kstTodayIso(now); // YYYY-MM-DD
|
||||
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));
|
||||
// tag counts via note_tags JOIN — period 안 노트의 태그만 집계
|
||||
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 }>;
|
||||
// due progress — period 안 created 노트 중 due_date 가 있는 것
|
||||
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 };
|
||||
}
|
||||
```
|
||||
|
||||
`computeCutoff('daily', now)` = KST 자정. `'weekly'` = 7일 전 KST. `'monthly'` = 30일 전 KST.
|
||||
`computeCutoff('daily', now)` = KST 자정 (오늘 시작) ISO. `'weekly'` = 7일 전 KST 자정 ISO. `'monthly'` = 30일 전 KST 자정 ISO. `kstTodayIso` 는 `src/shared/util/kstDate.ts` 에 이미 존재 (Cut B 활용).
|
||||
|
||||
period 별 query 는 동일 transaction 으로 wrap 해도 되나, read-only + 단일 호출이라 단순 sequential 호출로 충분 (better-sqlite3 동기 API).
|
||||
|
||||
### 4-4. Tag distribution chart
|
||||
|
||||
@@ -195,14 +257,19 @@ reviewAggregate(period: 'daily' | 'weekly' | 'monthly', now: Date): {
|
||||
|
||||
| 영역 | 단위 |
|
||||
|---|---|
|
||||
| m006 마이그레이션 | FTS5 virtual table 생성 + 기존 notes backfill (status != 'trashed' 만) |
|
||||
| Trigger sync | INSERT/UPDATE/DELETE → notes_fts 자동 sync |
|
||||
| `search` | 한국어 token 매칭 + status filter |
|
||||
| m007 마이그레이션 | FTS5 virtual table + trigger 3개 + 기존 notes backfill (status != 'trashed' + tags JOIN) |
|
||||
| Trigger sync | INSERT/UPDATE/DELETE → notes_fts 자동 sync (raw_text/ai_title/ai_summary) |
|
||||
| `rebuildFtsTagsForNote` 헬퍼 | note_tags 변경 후 FTS tags 컬럼 재구성 |
|
||||
| `updateAiResult` / `updateUserAiFields` | tags 변경 path 가 헬퍼 호출하여 FTS sync (회귀) |
|
||||
| `updateRawText` (Cut C) FTS sync 회귀 | trigger 자동 발동 검증 |
|
||||
| `search` | 한국어 token 매칭 + status filter + trashed 기본 제외 + 빈 query → [] |
|
||||
| `sanitizeFtsQuery` | FTS5 special char 이스케이프 + multi-word 통과 |
|
||||
| inbox header search box | debounce + 빈 값 → 기본 list 복귀 |
|
||||
| ReviewView 단위 | aggregate query 결과 렌더 |
|
||||
| `reviewAggregate` | period 별 cutoff 정확 + tag count + due progress |
|
||||
| ReviewView 단위 | aggregate query 결과 렌더 + period 라벨 |
|
||||
| `reviewAggregate` | period 별 cutoff 정확 + tag count + due progress (passed/pending KST 비교) |
|
||||
| `computeCutoff` | daily/weekly/monthly KST 자정 ISO |
|
||||
|
||||
**목표**: 단위 505 → 약 528 (+23), typecheck 0.
|
||||
**목표**: 단위 569 → 약 595 (+26), typecheck 0.
|
||||
|
||||
---
|
||||
|
||||
@@ -213,7 +280,9 @@ reviewAggregate(period: 'daily' | 'weekly' | 'monthly', now: Date): {
|
||||
| FTS5 한국어 token 정확도 (unicode61 가 word boundary 부정확) | dogfood 검증. 부족 시 v0.3+ 에서 mecab-ko 또는 trigram tokenize 검토 |
|
||||
| FTS5 인덱스 size (notes 수만건 시 DB 크기 ↑) | 수만건 도달 전엔 무시. v0.3+ 에서 prune 또는 partial 인덱스 |
|
||||
| 회고 aggregate query latency | LIMIT 50 + index 활용 (`created_at DESC`). 수만건도 sub-second 예상 |
|
||||
| Cut C revision 추가 시 FTS 영향 | revision 은 인덱스 X (latest only). 정책 일관 |
|
||||
| Cut C revision 추가 시 FTS 영향 | revision 은 인덱스 X (latest only). `notes` AFTER UPDATE trigger 가 raw_text 변경 자동 반영 |
|
||||
| `note_tags` 변경 누락 시 FTS tags stale | NoteRepository 의 tags 변경 path 모두에서 `rebuildFtsTagsForNote` 명시 호출 — single write path 패턴 강제 |
|
||||
| FTS5 special char crash | `sanitizeFtsQuery` 에서 `"`/`*`/`(`/`)`/`:` 이스케이프 또는 제거 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "inkling",
|
||||
"version": "0.2.8",
|
||||
"version": "0.2.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "inkling",
|
||||
"version": "0.2.8",
|
||||
"version": "0.2.11",
|
||||
"dependencies": {
|
||||
"better-sqlite3": "12.9.0",
|
||||
"electron-log": "5.2.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "inkling",
|
||||
"version": "0.2.9",
|
||||
"version": "0.2.11",
|
||||
"private": true,
|
||||
"description": "Inkling — local-first 한 줄 보관 도구",
|
||||
"author": "altair823 <dlsrks0734@gmail.com>",
|
||||
|
||||
@@ -4,8 +4,10 @@ import * as m002 from './m002_due_date.js';
|
||||
import * as m003 from './m003_soft_delete.js';
|
||||
import * as m004 from './m004_status.js';
|
||||
import * as m005 from './m005_ai_disabled.js';
|
||||
import * as m006 from './m006_revisions.js';
|
||||
import * as m007 from './m007_fts.js';
|
||||
|
||||
const migrations = [m001, m002, m003, m004, m005];
|
||||
const migrations = [m001, m002, m003, m004, m005, m006, m007];
|
||||
|
||||
export function latestVersion(): number {
|
||||
return migrations[migrations.length - 1]!.version;
|
||||
|
||||
23
src/main/db/migrations/m006_revisions.ts
Normal file
23
src/main/db/migrations/m006_revisions.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// v6: note_revisions 테이블 + 기존 notes 의 raw_text 를 edited_by='capture' revision 으로 backfill.
|
||||
// FK ON DELETE CASCADE — notes 영구 삭제 시 revision 도 함께 삭제.
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
export const version = 6;
|
||||
|
||||
export function up(db: Database.Database): void {
|
||||
db.exec(`
|
||||
CREATE TABLE note_revisions (
|
||||
rev_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
note_id TEXT NOT NULL,
|
||||
raw_text TEXT NOT NULL,
|
||||
edited_at TEXT NOT NULL,
|
||||
edited_by TEXT NOT NULL DEFAULT 'user'
|
||||
CHECK (edited_by IN ('user','capture')),
|
||||
FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX idx_note_revisions_note_id ON note_revisions(note_id, edited_at DESC);
|
||||
|
||||
INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by)
|
||||
SELECT id, raw_text, created_at, 'capture' FROM notes;
|
||||
`);
|
||||
}
|
||||
48
src/main/db/migrations/m007_fts.ts
Normal file
48
src/main/db/migrations/m007_fts.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
// v7: notes_fts FTS5 virtual table + trigger 3개 + 기존 notes (status != 'trashed') backfill.
|
||||
// raw_text/ai_title/ai_summary 는 trigger 자동 sync. tags 는 note_tags JOIN 결과를
|
||||
// NoteRepository 의 명시 헬퍼 (rebuildFtsTagsForNote) 로 갱신 — Cut D 의 single write path.
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
export const version = 7;
|
||||
|
||||
export function up(db: Database.Database): void {
|
||||
db.exec(`
|
||||
CREATE VIRTUAL TABLE notes_fts USING fts5(
|
||||
note_id UNINDEXED,
|
||||
raw_text,
|
||||
ai_title,
|
||||
ai_summary,
|
||||
tags,
|
||||
tokenize='unicode61'
|
||||
);
|
||||
|
||||
CREATE TRIGGER notes_fts_ai AFTER INSERT ON notes BEGIN
|
||||
INSERT INTO notes_fts (note_id, raw_text, ai_title, ai_summary, tags)
|
||||
VALUES (NEW.id, NEW.raw_text, COALESCE(NEW.ai_title, ''), COALESCE(NEW.ai_summary, ''), '');
|
||||
END;
|
||||
|
||||
CREATE TRIGGER notes_fts_ad AFTER DELETE ON notes BEGIN
|
||||
DELETE FROM notes_fts WHERE note_id = OLD.id;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER notes_fts_au AFTER UPDATE ON notes BEGIN
|
||||
UPDATE notes_fts
|
||||
SET raw_text = NEW.raw_text,
|
||||
ai_title = COALESCE(NEW.ai_title, ''),
|
||||
ai_summary = COALESCE(NEW.ai_summary, '')
|
||||
WHERE note_id = NEW.id;
|
||||
END;
|
||||
|
||||
INSERT INTO notes_fts (note_id, raw_text, ai_title, ai_summary, tags)
|
||||
SELECT
|
||||
n.id,
|
||||
n.raw_text,
|
||||
COALESCE(n.ai_title, ''),
|
||||
COALESCE(n.ai_summary, ''),
|
||||
COALESCE((SELECT GROUP_CONCAT(t.name, ' ')
|
||||
FROM note_tags nt JOIN tags t ON t.id = nt.tag_id
|
||||
WHERE nt.note_id = n.id), '')
|
||||
FROM notes n
|
||||
WHERE n.status != 'trashed';
|
||||
`);
|
||||
}
|
||||
@@ -269,6 +269,49 @@ export function registerInboxApi(deps: InboxIpcDeps): void {
|
||||
await deps.health.runOnce();
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
// v0.2.10 Cut C — raw_text 가변 + revision 보존.
|
||||
// updateRawText: 빈 문자열 reject (trim 후 length===0). 그 외엔 그대로 (newline/space 보존).
|
||||
// listRevisions: 그대로 반환 (camelCase 이미 hydrate 됨).
|
||||
// restoreRevision: repo throw → { ok: false } (UI 가 에러 표시).
|
||||
ipcMain.handle('inbox:update-raw-text', async (_e, id: string, newText: string) => {
|
||||
if (typeof newText !== 'string' || newText.trim().length === 0) {
|
||||
return { ok: false as const, reason: 'empty' as const };
|
||||
}
|
||||
deps.repo.updateRawText(id, newText);
|
||||
return { ok: true as const };
|
||||
});
|
||||
|
||||
ipcMain.handle('inbox:list-revisions', (_e, id: string) => deps.repo.listRevisions(id));
|
||||
|
||||
ipcMain.handle('inbox:restore-revision', async (_e, id: string, revId: number) => {
|
||||
try {
|
||||
deps.repo.restoreRevision(id, revId);
|
||||
return { ok: true as const };
|
||||
} catch (e) {
|
||||
return { ok: false as const, reason: (e as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
// v0.2.11 Cut D — FTS5 검색 + 회고 aggregate.
|
||||
ipcMain.handle(
|
||||
'inbox:search',
|
||||
(_e, query: string, opts: { limit?: number; status?: NoteStatus } = {}) =>
|
||||
deps.repo.search(query, opts)
|
||||
);
|
||||
|
||||
ipcMain.handle('inbox:review-aggregate', (_e, period: 'daily' | 'weekly' | 'monthly') => {
|
||||
const VALID = ['daily', 'weekly', 'monthly'] as const;
|
||||
if (!(VALID as readonly string[]).includes(period)) {
|
||||
return {
|
||||
totalCount: 0,
|
||||
recentNotes: [],
|
||||
tagCounts: [],
|
||||
dueProgress: { total: 0, passed: 0, pending: 0 }
|
||||
};
|
||||
}
|
||||
return deps.repo.reviewAggregate(period);
|
||||
});
|
||||
}
|
||||
|
||||
export function pushNoteUpdated(getWin: () => BrowserWindow | null, note: Note): void {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
import { v7 as uuidv7, v4 as uuidv4 } from 'uuid';
|
||||
import type { AiStatus, Note, NoteMedia, NoteStatus, NoteTag } from '@shared/types';
|
||||
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;
|
||||
@@ -22,7 +23,10 @@ export interface NewMediaRow {
|
||||
|
||||
export interface ImportNoteInput {
|
||||
/** Proposed id from the export file. May be replaced if it collides with
|
||||
* an existing row whose `raw_text` differs (raw_text invariant guard). */
|
||||
* an existing row whose `raw_text` differs — fork-on-conflict so a single
|
||||
* id never resolves to two distinct historical baselines (v0.2.10 Cut C
|
||||
* changed `raw_text 불변` policy → `raw_text 가변` + revision history; the
|
||||
* baseline distinction is now preserved per-id, edit history per-note). */
|
||||
id: string;
|
||||
rawText: string;
|
||||
createdAt: string;
|
||||
@@ -61,6 +65,10 @@ export class NoteRepository {
|
||||
.prepare(`INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)`)
|
||||
.run(id, input.rawText, aiStatus, now, now);
|
||||
this.db
|
||||
.prepare(`INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by)
|
||||
VALUES (?, ?, ?, 'capture')`)
|
||||
.run(id, input.rawText, now);
|
||||
// pending_jobs 는 'pending' 일 때만 생성 — 'disabled' 노트는 worker 가 처리 안 함.
|
||||
if (aiStatus === 'pending') {
|
||||
this.db
|
||||
@@ -154,6 +162,7 @@ export class NoteRepository {
|
||||
linkTag.run(id, tagRow.id);
|
||||
}
|
||||
this.db.prepare(`DELETE FROM pending_jobs WHERE note_id=?`).run(id);
|
||||
this.rebuildFtsTagsForNote(id);
|
||||
});
|
||||
tx();
|
||||
}
|
||||
@@ -383,6 +392,7 @@ export class NoteRepository {
|
||||
const row = getOrInsert.get(t) as { id: number };
|
||||
link.run(id, row.id);
|
||||
}
|
||||
this.rebuildFtsTagsForNote(id);
|
||||
}
|
||||
});
|
||||
tx();
|
||||
@@ -465,6 +475,69 @@ export class NoteRepository {
|
||||
.run(now, id);
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.10 Cut C — 사용자가 raw_text 정정. notes.raw_text 갱신 + note_revisions 에
|
||||
* edited_by='user' 새 row INSERT. 단일 transaction. 호출자 `now` 주입 가능 (테스트성).
|
||||
*
|
||||
* 옛 raw_text 는 backfill (m006) 으로 capture revision 에 이미 보존됨.
|
||||
*/
|
||||
updateRawText(id: string, newText: string, now: Date = new Date()): void {
|
||||
const ts = now.toISOString();
|
||||
const tx = this.db.transaction(() => {
|
||||
this.db
|
||||
.prepare(`UPDATE notes SET raw_text=?, updated_at=? WHERE id=?`)
|
||||
.run(newText, ts, id);
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by)
|
||||
VALUES (?, ?, ?, 'user')`
|
||||
)
|
||||
.run(id, newText, ts);
|
||||
});
|
||||
tx();
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.10 Cut C — 노트의 모든 revision (capture + user) 을 최신순 반환.
|
||||
* NoteCard 의 "이력" modal 에서 사용. edited_at DESC + rev_id DESC tiebreak.
|
||||
*/
|
||||
listRevisions(id: string): NoteRevision[] {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT rev_id, note_id, raw_text, edited_at, edited_by
|
||||
FROM note_revisions
|
||||
WHERE note_id = ?
|
||||
ORDER BY edited_at DESC, rev_id DESC`
|
||||
)
|
||||
.all(id) as Array<{
|
||||
rev_id: number;
|
||||
note_id: string;
|
||||
raw_text: string;
|
||||
edited_at: string;
|
||||
edited_by: 'user' | 'capture';
|
||||
}>;
|
||||
return rows.map((r) => ({
|
||||
revId: r.rev_id,
|
||||
noteId: r.note_id,
|
||||
rawText: r.raw_text,
|
||||
editedAt: r.edited_at,
|
||||
editedBy: r.edited_by
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.10 Cut C — 옛 revision 의 raw_text 를 latest 로 복원. chain 끊지 않고
|
||||
* 새 user revision 으로 INSERT (linear history 유지). revId 가 해당 note 의 것이
|
||||
* 아니면 throw — restore 대상 잘못 매칭 방지.
|
||||
*/
|
||||
restoreRevision(id: string, revId: number, now: Date = new Date()): void {
|
||||
const rev = this.db
|
||||
.prepare(`SELECT raw_text FROM note_revisions WHERE rev_id=? AND note_id=?`)
|
||||
.get(revId, id) as { raw_text: string } | undefined;
|
||||
if (!rev) throw new Error(`revision ${revId} not found for note ${id}`);
|
||||
this.updateRawText(id, rev.raw_text, now);
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.9 Cut B — 노트 status 4분기 전이 (active/completed/archived/trashed).
|
||||
* status + status_changed_at + move_reason + updated_at 갱신 + deleted_at
|
||||
@@ -528,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 재투입).
|
||||
@@ -614,11 +768,22 @@ export class NoteRepository {
|
||||
|
||||
/**
|
||||
* Import a note from an external source (F5 export tree).
|
||||
* Conflict policy:
|
||||
* Conflict policy (fork-on-id-collision):
|
||||
* - id missing in DB → INSERT (status: 'inserted')
|
||||
* - id present + raw_text identical → no-op (status: 'skipped')
|
||||
* - id present + raw_text differs → INSERT under fresh uuidv7
|
||||
* to preserve the raw_text-immutable invariant (status: 'forked')
|
||||
* - id present + raw_text differs → INSERT under fresh uuidv7 so the same id
|
||||
* never points at two different baselines (status: 'forked'). v0.2.10 Cut C
|
||||
* relaxed the `raw_text 불변` policy → `raw_text 가변 + note_revisions 보존`,
|
||||
* but per-id baseline distinction is still required for sync determinism.
|
||||
*
|
||||
* v0.2.10 Cut C — INSERT/fork 시 동일 transaction 안에서 note_revisions 에
|
||||
* 'capture' 첫 revision INSERT (createdAt = edited_at). 미수행 시 first user
|
||||
* edit 직후 import 시점 본문이 history 에서 사라지는 회귀 (final review 발견).
|
||||
*
|
||||
* v0.2.11 Cut D — INSERT/fork 시 tags 추가 후 rebuildFtsTagsForNote(finalId)
|
||||
* 호출 — m007 trigger 가 빈 tags='' 로 FTS row 만들고, note_tags INSERT 만으로는
|
||||
* notes_fts.tags 갱신 안 됨. 미수행 시 import 한 노트가 tag keyword 검색에서
|
||||
* 매칭 안 되는 회귀 (final review 발견).
|
||||
*
|
||||
* deletedAt merge (v0.2.3 #4, spec §8.2): source/dest 중 IS NOT NULL 우선
|
||||
* (삭제 보존). skip 케이스에서 source NN + dest NULL 일 때만 dest 갱신.
|
||||
@@ -669,6 +834,12 @@ export class NoteRepository {
|
||||
input.createdAt,
|
||||
input.updatedAt
|
||||
);
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by)
|
||||
VALUES (?, ?, ?, 'capture')`
|
||||
)
|
||||
.run(finalId, input.rawText, input.createdAt);
|
||||
if (input.tags.length > 0) {
|
||||
const getOrInsertTag = this.db.prepare(
|
||||
`INSERT INTO tags(name) VALUES(?) ON CONFLICT(name) DO UPDATE SET name=name RETURNING id`
|
||||
@@ -684,6 +855,8 @@ export class NoteRepository {
|
||||
if (t.source === 'ai') linkAi.run(finalId, row.id);
|
||||
else linkUser.run(finalId, row.id);
|
||||
}
|
||||
// v0.2.11 Cut D — note_tags 변경 후 notes_fts.tags 동기화 (single write path).
|
||||
this.rebuildFtsTagsForNote(finalId);
|
||||
}
|
||||
});
|
||||
tx();
|
||||
@@ -769,6 +942,23 @@ export class NoteRepository {
|
||||
.run(nextRunAt, lastError.slice(0, 500), noteId);
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.11 Cut D — note_tags 변경 후 notes_fts.tags 컬럼 (csv) 재구성.
|
||||
* 단일 write path 패턴: tags 변경하는 모든 메서드가 같은 transaction 끝에서 호출.
|
||||
*/
|
||||
private rebuildFtsTagsForNote(noteId: string): void {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
`SELECT COALESCE(GROUP_CONCAT(t.name, ' '), '') AS csv
|
||||
FROM note_tags nt JOIN tags t ON t.id = nt.tag_id
|
||||
WHERE nt.note_id = ?`
|
||||
)
|
||||
.get(noteId) as { csv: string };
|
||||
this.db
|
||||
.prepare(`UPDATE notes_fts SET tags = ? WHERE note_id = ?`)
|
||||
.run(row.csv, noteId);
|
||||
}
|
||||
|
||||
private hydrate(row: Record<string, unknown>): Note {
|
||||
const tags = this.db
|
||||
.prepare(
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -81,6 +81,13 @@ const api: InklingApi = {
|
||||
// v0.2.9 Cut B Task 16 — disabled 메모 재투입 + count.
|
||||
enqueueDisabled: () => ipcRenderer.invoke('inbox:enqueue-disabled'),
|
||||
getDisabledCount: () => ipcRenderer.invoke('inbox:get-disabled-count'),
|
||||
// v0.2.10 Cut C — raw_text 가변 + revision 보존.
|
||||
updateRawText: (noteId: string, newText: string) => ipcRenderer.invoke('inbox:update-raw-text', noteId, newText),
|
||||
listRevisions: (noteId: string) => ipcRenderer.invoke('inbox:list-revisions', noteId),
|
||||
restoreRevision: (noteId: string, revId: number) => ipcRenderer.invoke('inbox:restore-revision', noteId, revId),
|
||||
// v0.2.11 Cut D — search + 회고 aggregate.
|
||||
search: (query, opts) => ipcRenderer.invoke('inbox:search', query, opts ?? {}),
|
||||
reviewAggregate: (period) => ipcRenderer.invoke('inbox:review-aggregate', period),
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -14,6 +14,9 @@ import { FailedBanner } from './components/FailedBanner.js';
|
||||
import { RecallBanner } from './components/RecallBanner.js';
|
||||
import { SettingsPage } from './components/SettingsPage.js';
|
||||
import { OnboardingWizard } from './components/OnboardingWizard.js';
|
||||
import { SearchBox } from './components/SearchBox.js';
|
||||
import { ReviewView } from './components/ReviewView.js';
|
||||
import type { InboxView } from './store.js';
|
||||
|
||||
export function App(): React.ReactElement {
|
||||
const {
|
||||
@@ -28,6 +31,7 @@ export function App(): React.ReactElement {
|
||||
const view = useInbox((s) => s.view);
|
||||
const counts = useInbox((s) => s.counts);
|
||||
const setView = useInbox((s) => s.setView);
|
||||
const searchResults = useInbox((s) => s.searchResults);
|
||||
const [recoveryDismissed, setRecoveryDismissed] = useState(isRecoveryDismissedToday());
|
||||
// v0.2.9 Cut B Task 12 — 첫 launch onboarding 분기. null = 로딩, true = 표시, false = 미표시.
|
||||
const [showOnboarding, setShowOnboarding] = useState<boolean | null>(null);
|
||||
@@ -67,10 +71,15 @@ export function App(): React.ReactElement {
|
||||
if (showOnboarding === null) return <></>;
|
||||
if (showOnboarding) return <OnboardingWizard onClose={() => setShowOnboarding(false)} />;
|
||||
|
||||
if (view === 'review-daily') return <ReviewView period="daily" />;
|
||||
if (view === 'review-weekly') return <ReviewView period="weekly" />;
|
||||
if (view === 'review-monthly') return <ReviewView period="monthly" />;
|
||||
|
||||
if (showSettings) return <SettingsPage />;
|
||||
|
||||
const showRecovery = continuity.showRecoveryToast && !recoveryDismissed;
|
||||
const filtered = selectFilteredNotes({ notes, tagFilter });
|
||||
const displayed = searchResults !== null ? searchResults : filtered;
|
||||
|
||||
const tabBtnStyle = (active: boolean): React.CSSProperties => ({
|
||||
background: active ? '#0a4b80' : 'transparent',
|
||||
@@ -105,6 +114,21 @@ export function App(): React.ReactElement {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<select
|
||||
aria-label="회고 기간"
|
||||
value={view.startsWith('review-') ? view.replace('review-', '') : ''}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
if (v === 'daily' || v === 'weekly' || v === 'monthly') setView(`review-${v}` as InboxView);
|
||||
}}
|
||||
style={{ marginLeft: 8, fontSize: 12, padding: '4px 6px', border: '1px solid #0a4b80', borderRadius: 4, color: '#0a4b80', background: 'transparent' }}
|
||||
>
|
||||
<option value="">📅 회고…</option>
|
||||
<option value="daily">일간</option>
|
||||
<option value="weekly">주간</option>
|
||||
<option value="monthly">월간</option>
|
||||
</select>
|
||||
<SearchBox />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 2, marginLeft: 'auto' }}>
|
||||
<ContinuityBadge />
|
||||
<IdentityCounter />
|
||||
@@ -155,12 +179,14 @@ export function App(): React.ReactElement {
|
||||
)}
|
||||
{loading && notes.length === 0 ? (
|
||||
<div className="empty">불러오는 중…</div>
|
||||
) : searchResults !== null && displayed.length === 0 ? (
|
||||
<div className="empty">검색 결과가 없습니다.</div>
|
||||
) : notes.length === 0 ? (
|
||||
<div className="empty">머릿속에 떠다니는 한 줄을 적어보세요. <code>Ctrl+Shift+J</code></div>
|
||||
) : filtered.length === 0 ? (
|
||||
) : displayed.length === 0 ? (
|
||||
<div className="empty">이 태그의 노트가 없습니다.</div>
|
||||
) : (
|
||||
filtered.map((n) => (
|
||||
displayed.map((n) => (
|
||||
<NoteCard
|
||||
key={n.id} note={n} mode="inbox"
|
||||
onDeleted={() => removeNote(n.id)}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { EditableField } from './EditableField.js';
|
||||
import { IntentBanner } from './IntentBanner.js';
|
||||
import { pushTagUndo } from './TagUndoToast.js';
|
||||
import { MoveStatusModal, statusLabelWithParticle } from './MoveStatusModal.js';
|
||||
import { RevisionHistoryModal } from './RevisionHistoryModal.js';
|
||||
|
||||
interface Props {
|
||||
note: Note;
|
||||
@@ -118,6 +119,9 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
|
||||
// v0.2.9 Cut B Task 6 — 이동 메뉴 dropdown + MoveStatusModal target.
|
||||
const [moveTarget, setMoveTarget] = useState<NoteStatus | null>(null);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [editingRaw, setEditingRaw] = useState(false);
|
||||
const [draftRaw, setDraftRaw] = useState('');
|
||||
const [showRevisions, setShowRevisions] = useState(false);
|
||||
|
||||
const possibleTargets: NoteStatus[] = (
|
||||
['active', 'completed', 'archived', 'trashed'] as NoteStatus[]
|
||||
@@ -150,6 +154,17 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
|
||||
setLocal(updated); onUpdated(updated);
|
||||
}
|
||||
|
||||
async function saveRaw() {
|
||||
const next = draftRaw;
|
||||
if (next.trim().length === 0) return;
|
||||
const r = await inboxApi.updateRawText(note.id, next);
|
||||
if (!r.ok) return;
|
||||
const updated = { ...local, rawText: next, updatedAt: new Date().toISOString() };
|
||||
setLocal(updated);
|
||||
onUpdated(updated);
|
||||
setEditingRaw(false);
|
||||
}
|
||||
|
||||
async function removeTag(tagName: string) {
|
||||
const removed = local.tags.find((t) => t.name === tagName);
|
||||
const nextTagNames = local.tags.filter((t) => t.name !== tagName).map((t) => t.name);
|
||||
@@ -371,9 +386,32 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
|
||||
{rawOpen ? '▾ 원문 접기' : '▸ 원문 보기'}
|
||||
</button>
|
||||
{rawOpen && (
|
||||
<pre style={{ marginTop: 6, whiteSpace: 'pre-wrap', fontSize: 12, color: '#555', background: '#fafafa', padding: 8, borderRadius: 4 }}>
|
||||
{local.rawText}
|
||||
</pre>
|
||||
<div style={{ marginTop: 6 }}>
|
||||
{editingRaw ? (
|
||||
<div>
|
||||
<textarea
|
||||
aria-label="원문 편집"
|
||||
value={draftRaw}
|
||||
onChange={(e) => setDraftRaw(e.target.value)}
|
||||
style={{ width: '100%', minHeight: 80, fontSize: 12, fontFamily: 'inherit', padding: 8, border: '1px solid #ddd', borderRadius: 4, boxSizing: 'border-box' }}
|
||||
/>
|
||||
<div style={{ marginTop: 4, display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
|
||||
<button onClick={() => setEditingRaw(false)} style={{ background: 'none', border: '1px solid #ccc', color: '#444', cursor: 'pointer', fontSize: 12, padding: '3px 10px', borderRadius: 4 }}>취소</button>
|
||||
<button onClick={() => { void saveRaw(); }} style={{ background: '#0a4b80', border: 'none', color: '#fff', cursor: 'pointer', fontSize: 12, padding: '3px 10px', borderRadius: 4 }}>저장</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', fontSize: 12, color: '#555', background: '#fafafa', padding: 8, borderRadius: 4 }}>
|
||||
{local.rawText}
|
||||
</pre>
|
||||
<div style={{ marginTop: 4, display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
|
||||
<button onClick={() => setShowRevisions(true)} style={{ background: 'none', border: 'none', color: '#0a4b80', cursor: 'pointer', fontSize: 12, padding: 0 }}>이력</button>
|
||||
<button onClick={() => { setDraftRaw(local.rawText); setEditingRaw(true); }} style={{ background: 'none', border: '1px solid #ccc', color: '#444', cursor: 'pointer', fontSize: 12, padding: '3px 10px', borderRadius: 4 }}>편집</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -487,6 +525,17 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showRevisions && (
|
||||
<RevisionHistoryModal
|
||||
noteId={local.id}
|
||||
onClose={() => setShowRevisions(false)}
|
||||
onRestored={(newRawText) => {
|
||||
const updated = { ...local, rawText: newRawText, updatedAt: new Date().toISOString() };
|
||||
setLocal(updated);
|
||||
onUpdated(updated);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
56
src/renderer/inbox/components/ReviewView.tsx
Normal file
56
src/renderer/inbox/components/ReviewView.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import React from 'react';
|
||||
import { useInbox } from '../store.js';
|
||||
import { NoteCard } from './NoteCard.js';
|
||||
|
||||
interface Props {
|
||||
period: 'daily' | 'weekly' | 'monthly';
|
||||
}
|
||||
|
||||
const periodLabel: Record<Props['period'], string> = {
|
||||
daily: '일간',
|
||||
weekly: '주간',
|
||||
monthly: '월간'
|
||||
};
|
||||
|
||||
export function ReviewView({ period }: Props): React.ReactElement {
|
||||
const reviewData = useInbox((s) => s.reviewData);
|
||||
if (!reviewData) {
|
||||
return <div style={{ padding: 16, fontSize: 13, color: '#666' }}>불러오는 중…</div>;
|
||||
}
|
||||
const max = reviewData.tagCounts[0]?.count ?? 1;
|
||||
return (
|
||||
<div style={{ padding: 16 }}>
|
||||
<h2 style={{ fontSize: 18, margin: 0 }}>{periodLabel[period]} 회고</h2>
|
||||
<div style={{ marginTop: 8, fontSize: 13, color: '#444' }}>
|
||||
총 {reviewData.totalCount}건
|
||||
</div>
|
||||
<section style={{ marginTop: 16 }}>
|
||||
<h3 style={{ fontSize: 14, marginBottom: 4 }}>태그 분포</h3>
|
||||
{reviewData.tagCounts.length === 0 && (
|
||||
<div style={{ fontSize: 12, color: '#888' }}>태그 없음</div>
|
||||
)}
|
||||
{reviewData.tagCounts.slice(0, 10).map((t) => (
|
||||
<div key={t.tag} style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 2 }}>
|
||||
<span style={{ fontSize: 12, width: 80 }}>{t.tag}</span>
|
||||
<div style={{ flex: 1, background: '#eee', height: 8, borderRadius: 2 }}>
|
||||
<div style={{ width: `${(t.count / max) * 100}%`, background: '#4ec5b8', height: 8, borderRadius: 2 }} />
|
||||
</div>
|
||||
<span style={{ fontSize: 12, color: '#666', width: 30, textAlign: 'right' }}>{t.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
<section style={{ marginTop: 16 }}>
|
||||
<h3 style={{ fontSize: 14, marginBottom: 4 }}>마감 진행</h3>
|
||||
<div style={{ fontSize: 13, color: '#444' }}>
|
||||
완료 {reviewData.dueProgress.passed} / {reviewData.dueProgress.total} · 대기 {reviewData.dueProgress.pending}
|
||||
</div>
|
||||
</section>
|
||||
<section style={{ marginTop: 16 }}>
|
||||
<h3 style={{ fontSize: 14, marginBottom: 4 }}>최근 노트 ({reviewData.recentNotes.length})</h3>
|
||||
{reviewData.recentNotes.map((n) => (
|
||||
<NoteCard key={n.id} note={n} mode="inbox" onUpdated={() => {}} />
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
95
src/renderer/inbox/components/RevisionHistoryModal.tsx
Normal file
95
src/renderer/inbox/components/RevisionHistoryModal.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import type { NoteRevision } from '@shared/types';
|
||||
import { inboxApi } from '../api.js';
|
||||
|
||||
interface Props {
|
||||
noteId: string;
|
||||
onClose: () => void;
|
||||
/** 회수 성공 후 부모 (NoteCard) 가 local rawText 를 갱신하도록 통지. */
|
||||
onRestored: (newRawText: string) => void;
|
||||
}
|
||||
|
||||
const overlayStyle: React.CSSProperties = {
|
||||
position: 'fixed', top: 0, left: 0, width: '100vw', height: '100vh',
|
||||
background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'center', zIndex: 100
|
||||
};
|
||||
|
||||
const modalStyle: React.CSSProperties = {
|
||||
background: '#fff', borderRadius: 8, padding: 20, width: 520,
|
||||
maxHeight: '70vh', overflow: 'auto', boxShadow: '0 4px 16px rgba(0,0,0,0.2)'
|
||||
};
|
||||
|
||||
const rowStyle: React.CSSProperties = {
|
||||
border: '1px solid #eee', borderRadius: 6, padding: 10, marginTop: 8
|
||||
};
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleString('ko-KR');
|
||||
}
|
||||
|
||||
function editedByLabel(by: 'user' | 'capture'): string {
|
||||
return by === 'capture' ? '캡처' : '사용자';
|
||||
}
|
||||
|
||||
export function RevisionHistoryModal({ noteId, onClose, onRestored }: Props): React.ReactElement {
|
||||
const [revs, setRevs] = useState<NoteRevision[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const r = await inboxApi.listRevisions(noteId);
|
||||
if (!cancelled) setRevs(r);
|
||||
} catch (e) {
|
||||
if (!cancelled) setError((e as Error).message);
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [noteId]);
|
||||
|
||||
async function onRestore(rev: NoteRevision) {
|
||||
if (!window.confirm('이 버전으로 되돌릴까요? 현재 본문도 이력에 보존됩니다.')) return;
|
||||
const r = await inboxApi.restoreRevision(noteId, rev.revId);
|
||||
if (!r.ok) {
|
||||
setError(r.reason ?? '복원 실패');
|
||||
return;
|
||||
}
|
||||
onRestored(rev.rawText);
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={overlayStyle} onClick={onClose}>
|
||||
<div style={modalStyle} onClick={(e) => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h3 style={{ margin: 0, fontSize: 16 }}>이력 ({revs.length}건)</h3>
|
||||
<button onClick={onClose} aria-label="닫기" style={{ background: 'none', border: 'none', fontSize: 18, cursor: 'pointer', color: '#888' }}>×</button>
|
||||
</div>
|
||||
{loading && <div style={{ marginTop: 10, fontSize: 12, color: '#888' }}>불러오는 중…</div>}
|
||||
{error !== null && <div style={{ marginTop: 10, fontSize: 12, color: '#c93030' }}>{error}</div>}
|
||||
{!loading && revs.map((rev) => (
|
||||
<div key={rev.revId} style={rowStyle}>
|
||||
<div style={{ fontSize: 11, color: '#888', display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span>{formatDate(rev.editedAt)} · {editedByLabel(rev.editedBy)}</span>
|
||||
<button
|
||||
onClick={() => { void onRestore(rev); }}
|
||||
aria-label="회수"
|
||||
style={{ background: 'none', border: '1px solid #0a4b80', color: '#0a4b80', cursor: 'pointer', fontSize: 11, padding: '2px 8px', borderRadius: 3 }}
|
||||
>
|
||||
회수
|
||||
</button>
|
||||
</div>
|
||||
<pre style={{ margin: '6px 0 0 0', whiteSpace: 'pre-wrap', fontSize: 12, color: '#444', background: '#fafafa', padding: 6, borderRadius: 3 }}>
|
||||
{rev.rawText}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
src/renderer/inbox/components/SearchBox.tsx
Normal file
34
src/renderer/inbox/components/SearchBox.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useInbox } from '../store.js';
|
||||
|
||||
export function SearchBox(): React.ReactElement {
|
||||
const [draft, setDraft] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(() => {
|
||||
const trimmed = draft.trim();
|
||||
if (trimmed.length === 0) useInbox.getState().clearSearch();
|
||||
else void useInbox.getState().searchNotes(trimmed);
|
||||
}, 200);
|
||||
return () => clearTimeout(handle);
|
||||
}, [draft]);
|
||||
|
||||
return (
|
||||
<input
|
||||
type="search"
|
||||
role="searchbox"
|
||||
placeholder="검색…"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
aria-label="노트 검색"
|
||||
style={{
|
||||
marginLeft: 12,
|
||||
padding: '4px 8px',
|
||||
fontSize: 12,
|
||||
border: '1px solid #bbb',
|
||||
borderRadius: 4,
|
||||
width: 200
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { create } from 'zustand';
|
||||
import type { Note, WeeklyContinuity } from '@shared/types';
|
||||
import type { Note, ReviewAggregate, WeeklyContinuity } from '@shared/types';
|
||||
import { inboxApi } from './api.js';
|
||||
import { nextKstMidnightMs } from '@shared/util/kstDate.js';
|
||||
|
||||
@@ -7,7 +7,9 @@ export { selectFilteredNotes } from './selectFilteredNotes.js';
|
||||
|
||||
// v0.2.9 Cut B Task 4 — 4탭 view enum + settings.
|
||||
// 'inbox' = active, 'completed'/'archived' = NoteStatus 그대로, 'trash' = trashed (mirror), 'settings' = SettingsPage.
|
||||
export type InboxView = 'inbox' | 'completed' | 'archived' | 'trash' | 'settings';
|
||||
export type InboxView =
|
||||
| 'inbox' | 'completed' | 'archived' | 'trash' | 'settings'
|
||||
| 'review-daily' | 'review-weekly' | 'review-monthly';
|
||||
|
||||
export interface InboxCounts {
|
||||
active: number;
|
||||
@@ -39,6 +41,10 @@ interface InboxState {
|
||||
// v0.2.9 Cut B Task 14 — AI 비활성 모드에서는 OllamaBanner/FailedBanner render skip.
|
||||
// 기본 true (기존 사용자 무영향). loadInitial / refreshMeta 가 settings 로드.
|
||||
ai_enabled: boolean;
|
||||
// v0.2.11 Cut D — FTS5 search + review aggregate state.
|
||||
searchQuery: string;
|
||||
searchResults: Note[] | null; // null = 검색 안 한 상태
|
||||
reviewData: ReviewAggregate | null;
|
||||
loadInitial: () => Promise<void>;
|
||||
refreshMeta: () => Promise<void>;
|
||||
upsertNote: (note: Note) => void;
|
||||
@@ -61,6 +67,11 @@ interface InboxState {
|
||||
openRecall: (id: string) => Promise<void>;
|
||||
dismissRecallNote: (id: string) => Promise<void>;
|
||||
snoozeRecall: () => Promise<void>;
|
||||
// v0.2.11 Cut D — search + review actions.
|
||||
setSearchQuery: (q: string) => void;
|
||||
searchNotes: (q: string) => Promise<void>;
|
||||
clearSearch: () => void;
|
||||
loadReview: (period: 'daily' | 'weekly' | 'monthly') => Promise<void>;
|
||||
}
|
||||
|
||||
const emptyContinuity: WeeklyContinuity = {
|
||||
@@ -88,6 +99,9 @@ export const useInbox = create<InboxState>((set, get) => ({
|
||||
recallCandidate: null,
|
||||
recallSnoozeUntilMs: null,
|
||||
ai_enabled: true,
|
||||
searchQuery: '',
|
||||
searchResults: null,
|
||||
reviewData: null,
|
||||
async loadInitial() {
|
||||
set({ loading: true });
|
||||
const [notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, settings] = await Promise.all([
|
||||
@@ -178,6 +192,10 @@ export const useInbox = create<InboxState>((set, get) => ({
|
||||
if (view === 'completed' || view === 'archived' || view === 'trash') {
|
||||
void get().loadByView(view);
|
||||
}
|
||||
// v0.2.11 Cut D — review-* view 진입 시 aggregate 로드.
|
||||
if (view === 'review-daily') void get().loadReview('daily');
|
||||
if (view === 'review-weekly') void get().loadReview('weekly');
|
||||
if (view === 'review-monthly') void get().loadReview('monthly');
|
||||
},
|
||||
async loadByView(view) {
|
||||
const status = view === 'trash' ? 'trashed' : view;
|
||||
@@ -269,5 +287,30 @@ export const useInbox = create<InboxState>((set, get) => ({
|
||||
if (candidate) {
|
||||
await inboxApi.emitRecallSnoozed(candidate.id);
|
||||
}
|
||||
},
|
||||
// v0.2.11 Cut D — FTS5 search + review aggregate actions.
|
||||
setSearchQuery(q) {
|
||||
set({ searchQuery: q });
|
||||
if (q.trim().length === 0) set({ searchResults: null });
|
||||
},
|
||||
async searchNotes(q) {
|
||||
if (q.trim().length === 0) {
|
||||
set({ searchResults: null });
|
||||
return;
|
||||
}
|
||||
const view = get().view;
|
||||
// 회고/설정 view 일 때는 status filter 무의미 → 그대로 전체 검색
|
||||
const status = view === 'completed' || view === 'archived' || view === 'trash'
|
||||
? (view === 'trash' ? 'trashed' : view)
|
||||
: view === 'inbox' ? 'active' : undefined;
|
||||
const r = await inboxApi.search(q, status ? { status } : {});
|
||||
set({ searchResults: r });
|
||||
},
|
||||
clearSearch() {
|
||||
set({ searchQuery: '', searchResults: null });
|
||||
},
|
||||
async loadReview(period) {
|
||||
const data = await inboxApi.reviewAggregate(period);
|
||||
set({ reviewData: data });
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -21,6 +21,25 @@ export interface NoteTag {
|
||||
source: 'ai' | 'user';
|
||||
}
|
||||
|
||||
// v0.2.10 Cut C — note_revisions 테이블 row.
|
||||
// 'capture' = 최초 캡처 시점, 'user' = 사용자가 raw_text 정정한 시점.
|
||||
export interface NoteRevision {
|
||||
revId: number;
|
||||
noteId: string;
|
||||
rawText: string;
|
||||
editedAt: string;
|
||||
editedBy: 'user' | 'capture';
|
||||
}
|
||||
|
||||
// v0.2.11 Cut D — 회고 view aggregate.
|
||||
export type ReviewPeriod = 'daily' | 'weekly' | 'monthly';
|
||||
export interface ReviewAggregate {
|
||||
totalCount: number;
|
||||
recentNotes: Note[];
|
||||
tagCounts: Array<{ tag: string; count: number }>;
|
||||
dueProgress: { total: number; passed: number; pending: number };
|
||||
}
|
||||
|
||||
export interface Note {
|
||||
id: string;
|
||||
rawText: string;
|
||||
@@ -156,6 +175,13 @@ export interface InboxApi {
|
||||
// v0.2.9 Cut B Task 16 — ai_status='disabled' 메모 재투입 (사용자가 ai_enabled OFF→ON 전환 시).
|
||||
enqueueDisabled(): Promise<{ count: number }>;
|
||||
getDisabledCount(): Promise<number>;
|
||||
// v0.2.10 Cut C — raw_text 가변 + revision 보존.
|
||||
updateRawText(noteId: string, newText: string): Promise<{ ok: true } | { ok: false; reason: string }>;
|
||||
listRevisions(noteId: string): Promise<NoteRevision[]>;
|
||||
restoreRevision(noteId: string, revId: number): Promise<{ ok: true } | { ok: false; reason: string }>;
|
||||
// v0.2.11 Cut D — FTS5 search + 회고 aggregate.
|
||||
search(query: string, opts?: { limit?: number; status?: NoteStatus }): Promise<Note[]>;
|
||||
reviewAggregate(period: ReviewPeriod): Promise<ReviewAggregate>;
|
||||
}
|
||||
|
||||
export interface InklingApi {
|
||||
|
||||
@@ -4,13 +4,14 @@ import '@testing-library/jest-dom/vitest';
|
||||
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
|
||||
import type { Note } from '@shared/types';
|
||||
|
||||
const { mockOpenMedia, mockSetStatus, mockClassify } = vi.hoisted(() => ({
|
||||
const { mockOpenMedia, mockSetStatus, mockClassify, mockUpdateRawText } = vi.hoisted(() => ({
|
||||
mockOpenMedia: vi.fn(async () => ({ ok: true })),
|
||||
mockSetStatus: vi.fn(async () => ({ ok: true as const })),
|
||||
mockClassify: vi.fn(async () => ({
|
||||
recommended: 'archived' as const,
|
||||
rationale: 'stub'
|
||||
}))
|
||||
})),
|
||||
mockUpdateRawText: vi.fn(async () => ({ ok: true as const }))
|
||||
}));
|
||||
|
||||
vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
@@ -24,7 +25,10 @@ vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
setIntent: vi.fn(),
|
||||
dismissIntent: vi.fn(),
|
||||
setStatus: mockSetStatus,
|
||||
classifyStatus: mockClassify
|
||||
classifyStatus: mockClassify,
|
||||
updateRawText: mockUpdateRawText,
|
||||
listRevisions: vi.fn(async () => []),
|
||||
restoreRevision: vi.fn(async () => ({ ok: true as const }))
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -154,3 +158,31 @@ describe('NoteCard — 이동 메뉴 (v0.2.9 Cut B Task 6)', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('NoteCard — raw_text editing', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('원문 편집: textarea 저장 → updateRawText 호출 + 로컬 raw 갱신', async () => {
|
||||
const onUpdated = vi.fn();
|
||||
render(<NoteCard note={{ ...baseNote, rawText: 'old' }} onUpdated={onUpdated} mode="inbox" />);
|
||||
// 원문 펼침
|
||||
fireEvent.click(screen.getByRole('button', { name: /원문/ }));
|
||||
// 편집 진입
|
||||
fireEvent.click(screen.getByRole('button', { name: '편집' }));
|
||||
const ta = screen.getByRole('textbox', { name: /원문 편집/ }) as HTMLTextAreaElement;
|
||||
fireEvent.change(ta, { target: { value: 'new' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '저장' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateRawText).toHaveBeenCalledWith('n1', 'new');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(onUpdated).toHaveBeenCalled();
|
||||
});
|
||||
const last = onUpdated.mock.calls.at(-1)![0] as { rawText: string };
|
||||
expect(last.rawText).toBe('new');
|
||||
});
|
||||
});
|
||||
|
||||
72
tests/unit/NoteRepository.reviewAggregate.test.ts
Normal file
72
tests/unit/NoteRepository.reviewAggregate.test.ts
Normal file
@@ -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 });
|
||||
});
|
||||
});
|
||||
57
tests/unit/NoteRepository.search.test.ts
Normal file
57
tests/unit/NoteRepository.search.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -1054,3 +1054,106 @@ describe('NoteRepository.countByAiStatus', () => {
|
||||
expect(repo.countByAiStatus('done')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NoteRepository — note_revisions', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
repo = new NoteRepository(db);
|
||||
});
|
||||
|
||||
it('create() 가 첫 revision (edited_by=capture) 을 INSERT 한다', () => {
|
||||
const { id } = repo.create({ rawText: 'hello' });
|
||||
const rows = db
|
||||
.prepare(`SELECT raw_text, edited_by FROM note_revisions WHERE note_id=?`)
|
||||
.all(id) as Array<{ raw_text: string; edited_by: string }>;
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toEqual({ raw_text: 'hello', edited_by: 'capture' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('NoteRepository — notes_fts tags sync (v0.2.11 Cut D)', () => {
|
||||
let db: Database.Database;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
});
|
||||
|
||||
it('updateAiResult 후 notes_fts.tags 가 csv 로 sync', () => {
|
||||
const repo = new NoteRepository(db);
|
||||
const { id } = repo.create({ rawText: '회의 본문' });
|
||||
repo.updateAiResult(id, { title: '제목', summary: '요약', tags: ['기획', '회의'], provider: 'p' });
|
||||
const row = db
|
||||
.prepare(`SELECT tags FROM notes_fts WHERE note_id=?`)
|
||||
.get(id) as { tags: string };
|
||||
expect(row.tags.split(' ').sort()).toEqual(['기획', '회의']);
|
||||
});
|
||||
|
||||
it('updateUserAiFields tags 갱신 후 notes_fts.tags 동기', () => {
|
||||
const repo = new NoteRepository(db);
|
||||
const { id } = repo.create({ rawText: '본문' });
|
||||
repo.updateAiResult(id, { title: 't', summary: 's', tags: ['old'], provider: 'p' });
|
||||
repo.updateUserAiFields(id, { tags: ['new1', 'new2'] });
|
||||
const row = db
|
||||
.prepare(`SELECT tags FROM notes_fts WHERE note_id=?`)
|
||||
.get(id) as { tags: string };
|
||||
expect(row.tags.split(' ').sort()).toEqual(['new1', 'new2']);
|
||||
});
|
||||
|
||||
it('importNote insert path: notes_fts.tags 가 csv 로 sync (final review fix)', () => {
|
||||
const repo = new NoteRepository(db);
|
||||
const r = repo.importNote({
|
||||
id: '00000000-0000-0000-0000-000000000010',
|
||||
rawText: 'imported with tags',
|
||||
createdAt: '2026-04-01T00:00:00Z',
|
||||
updatedAt: '2026-04-01T00:00:00Z',
|
||||
aiTitle: 'imported title',
|
||||
aiSummary: 'imported summary',
|
||||
titleEditedByUser: false,
|
||||
summaryEditedByUser: false,
|
||||
aiProvider: 'p',
|
||||
aiGeneratedAt: '2026-04-01T00:00:00Z',
|
||||
userIntent: null,
|
||||
intentPromptedAt: null,
|
||||
tags: [
|
||||
{ name: '기획', source: 'ai' },
|
||||
{ name: '회의', source: 'user' }
|
||||
]
|
||||
});
|
||||
expect(r.status).toBe('inserted');
|
||||
const row = db
|
||||
.prepare(`SELECT tags FROM notes_fts WHERE note_id=?`)
|
||||
.get(r.id) as { tags: string };
|
||||
expect(row.tags.split(' ').sort()).toEqual(['기획', '회의']);
|
||||
});
|
||||
|
||||
it('importNote fork path: forked 노트의 notes_fts.tags 동기 (final review fix)', () => {
|
||||
const repo = new NoteRepository(db);
|
||||
const existing = repo.create({ rawText: 'v1' });
|
||||
const r = repo.importNote({
|
||||
id: existing.id,
|
||||
rawText: 'imported v2 with tags',
|
||||
createdAt: '2026-04-01T00:00:00Z',
|
||||
updatedAt: '2026-04-01T00:00:00Z',
|
||||
aiTitle: null,
|
||||
aiSummary: null,
|
||||
titleEditedByUser: false,
|
||||
summaryEditedByUser: false,
|
||||
aiProvider: null,
|
||||
aiGeneratedAt: null,
|
||||
userIntent: null,
|
||||
intentPromptedAt: null,
|
||||
tags: [{ name: '결재', source: 'user' }]
|
||||
});
|
||||
expect(r.status).toBe('forked');
|
||||
expect(r.id).not.toBe(existing.id);
|
||||
const row = db
|
||||
.prepare(`SELECT tags FROM notes_fts WHERE note_id=?`)
|
||||
.get(r.id) as { tags: string };
|
||||
expect(row.tags).toBe('결재');
|
||||
});
|
||||
});
|
||||
|
||||
171
tests/unit/NoteRevisions.test.ts
Normal file
171
tests/unit/NoteRevisions.test.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
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 — note_revisions', () => {
|
||||
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(); });
|
||||
|
||||
describe('updateRawText', () => {
|
||||
it('notes.raw_text 갱신 + 새 user revision INSERT (single transaction)', () => {
|
||||
const { id } = repo.create({ rawText: 'v1' });
|
||||
const t = new Date('2026-05-10T00:00:00Z');
|
||||
repo.updateRawText(id, 'v2', t);
|
||||
|
||||
const note = db.prepare(`SELECT raw_text, updated_at FROM notes WHERE id=?`).get(id) as {
|
||||
raw_text: string;
|
||||
updated_at: string;
|
||||
};
|
||||
expect(note.raw_text).toBe('v2');
|
||||
expect(note.updated_at).toBe('2026-05-10T00:00:00.000Z');
|
||||
|
||||
const revs = db
|
||||
.prepare(`SELECT raw_text, edited_by, edited_at FROM note_revisions WHERE note_id=? ORDER BY rev_id ASC`)
|
||||
.all(id) as Array<{ raw_text: string; edited_by: string; edited_at: string }>;
|
||||
expect(revs).toHaveLength(2); // capture + user
|
||||
expect(revs.at(0)!.edited_by).toBe('capture');
|
||||
expect(revs.at(0)!.raw_text).toBe('v1');
|
||||
expect(revs.at(1)!.edited_by).toBe('user');
|
||||
expect(revs.at(1)!.raw_text).toBe('v2');
|
||||
expect(revs.at(1)!.edited_at).toBe('2026-05-10T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('atomic: 두 번 호출 시 두 revision 모두 누적 (chain history)', () => {
|
||||
const { id } = repo.create({ rawText: 'v1' });
|
||||
repo.updateRawText(id, 'v2', new Date('2026-05-10T00:00:00Z'));
|
||||
repo.updateRawText(id, 'v3', new Date('2026-05-11T00:00:00Z'));
|
||||
const revs = db
|
||||
.prepare(`SELECT raw_text FROM note_revisions WHERE note_id=? ORDER BY rev_id ASC`)
|
||||
.all(id) as Array<{ raw_text: string }>;
|
||||
expect(revs.map((r) => r.raw_text)).toEqual(['v1', 'v2', 'v3']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listRevisions', () => {
|
||||
it('DESC 순서 + edited_by + camelCase hydrate', () => {
|
||||
const { id } = repo.create({ rawText: 'v1' });
|
||||
repo.updateRawText(id, 'v2', new Date('2026-05-10T00:00:00Z'));
|
||||
repo.updateRawText(id, 'v3', new Date('2026-05-11T00:00:00Z'));
|
||||
|
||||
const revs = repo.listRevisions(id);
|
||||
expect(revs).toHaveLength(3);
|
||||
expect(revs.at(0)!.rawText).toBe('v3');
|
||||
expect(revs.at(0)!.editedBy).toBe('user');
|
||||
expect(revs.at(1)!.rawText).toBe('v2');
|
||||
expect(revs.at(1)!.editedBy).toBe('user');
|
||||
expect(revs.at(2)!.rawText).toBe('v1');
|
||||
expect(revs.at(2)!.editedBy).toBe('capture');
|
||||
expect(typeof revs.at(0)!.revId).toBe('number');
|
||||
expect(revs.at(0)!.noteId).toBe(id);
|
||||
expect(revs.at(0)!.editedAt).toBe('2026-05-11T00:00:00.000Z');
|
||||
});
|
||||
});
|
||||
|
||||
describe('restoreRevision', () => {
|
||||
it('옛 raw_text 를 새 user revision 으로 INSERT + notes.raw_text 갱신', () => {
|
||||
const { id } = repo.create({ rawText: 'v1' });
|
||||
repo.updateRawText(id, 'v2', new Date('2026-05-10T00:00:00Z'));
|
||||
repo.updateRawText(id, 'v3', new Date('2026-05-11T00:00:00Z'));
|
||||
|
||||
const revs = repo.listRevisions(id);
|
||||
const v1 = revs.find((r) => r.rawText === 'v1');
|
||||
expect(v1).toBeDefined();
|
||||
|
||||
repo.restoreRevision(id, v1!.revId, new Date('2026-05-12T00:00:00Z'));
|
||||
|
||||
const note = db.prepare(`SELECT raw_text FROM notes WHERE id=?`).get(id) as { raw_text: string };
|
||||
expect(note.raw_text).toBe('v1');
|
||||
|
||||
const after = repo.listRevisions(id);
|
||||
expect(after).toHaveLength(4); // v1(capture) + v2 + v3 + v1 restored (user)
|
||||
expect(after.at(0)!.rawText).toBe('v1');
|
||||
expect(after.at(0)!.editedBy).toBe('user');
|
||||
expect(after.at(0)!.editedAt).toBe('2026-05-12T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('존재하지 않는 revId 는 throw', () => {
|
||||
const { id } = repo.create({ rawText: 'v1' });
|
||||
expect(() => repo.restoreRevision(id, 999_999, new Date())).toThrow(/not found/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AiWorker source 회귀', () => {
|
||||
it('updateRawText 후 findById 가 latest raw_text 반환 (옛 revision 미노출)', () => {
|
||||
const { id } = repo.create({ rawText: 'v1' });
|
||||
repo.updateRawText(id, 'v2 corrected', new Date('2026-05-10T00:00:00Z'));
|
||||
const note = repo.findById(id);
|
||||
expect(note?.rawText).toBe('v2 corrected');
|
||||
});
|
||||
});
|
||||
|
||||
describe('importNote — capture revision 생성 (final review 보강)', () => {
|
||||
it('insert path: imported note 가 capture revision (createdAt = edited_at) 을 함께 갖는다', () => {
|
||||
const r = repo.importNote({
|
||||
id: '00000000-0000-0000-0000-000000000001',
|
||||
rawText: 'imported text',
|
||||
createdAt: '2026-04-01T00:00:00Z',
|
||||
updatedAt: '2026-04-02T00:00:00Z',
|
||||
aiTitle: 't',
|
||||
aiSummary: 's',
|
||||
titleEditedByUser: false,
|
||||
summaryEditedByUser: false,
|
||||
aiProvider: 'p',
|
||||
aiGeneratedAt: '2026-04-02T00:00:00Z',
|
||||
userIntent: null,
|
||||
intentPromptedAt: null,
|
||||
tags: []
|
||||
});
|
||||
expect(r.status).toBe('inserted');
|
||||
|
||||
const revs = repo.listRevisions(r.id);
|
||||
expect(revs).toHaveLength(1);
|
||||
expect(revs[0]!.rawText).toBe('imported text');
|
||||
expect(revs[0]!.editedBy).toBe('capture');
|
||||
expect(revs[0]!.editedAt).toBe('2026-04-01T00:00:00Z');
|
||||
});
|
||||
|
||||
it('fork path: id 충돌 시 fresh uuidv7 + 새 capture revision (옛 노트 revision 보존)', () => {
|
||||
// 기존 노트 (capture 'v1' revision 자동 생성됨)
|
||||
const existing = repo.create({ rawText: 'v1' });
|
||||
// 동일 id 로 다른 raw_text 를 import → fork
|
||||
const r = repo.importNote({
|
||||
id: existing.id,
|
||||
rawText: 'imported v2',
|
||||
createdAt: '2026-04-01T00:00:00Z',
|
||||
updatedAt: '2026-04-02T00:00:00Z',
|
||||
aiTitle: null,
|
||||
aiSummary: null,
|
||||
titleEditedByUser: false,
|
||||
summaryEditedByUser: false,
|
||||
aiProvider: null,
|
||||
aiGeneratedAt: null,
|
||||
userIntent: null,
|
||||
intentPromptedAt: null,
|
||||
tags: []
|
||||
});
|
||||
expect(r.status).toBe('forked');
|
||||
expect(r.id).not.toBe(existing.id);
|
||||
|
||||
// forked 노트에 capture revision
|
||||
const forkRevs = repo.listRevisions(r.id);
|
||||
expect(forkRevs).toHaveLength(1);
|
||||
expect(forkRevs[0]!.rawText).toBe('imported v2');
|
||||
expect(forkRevs[0]!.editedBy).toBe('capture');
|
||||
|
||||
// 기존 노트의 revision 은 그대로 보존
|
||||
const existingRevs = repo.listRevisions(existing.id);
|
||||
expect(existingRevs).toHaveLength(1);
|
||||
expect(existingRevs[0]!.rawText).toBe('v1');
|
||||
});
|
||||
});
|
||||
});
|
||||
64
tests/unit/ReviewView.test.tsx
Normal file
64
tests/unit/ReviewView.test.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { render, screen, cleanup } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
const baseState = {
|
||||
reviewData: {
|
||||
totalCount: 12,
|
||||
recentNotes: [],
|
||||
tagCounts: [{ tag: '회의', count: 5 }, { tag: '결재', count: 3 }],
|
||||
dueProgress: { total: 10, passed: 4, pending: 6 }
|
||||
}
|
||||
};
|
||||
|
||||
vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
inboxApi: {
|
||||
openMedia: vi.fn(),
|
||||
deleteNote: vi.fn(),
|
||||
restoreNote: vi.fn(),
|
||||
permanentDeleteNote: vi.fn(),
|
||||
updateAiFields: vi.fn(),
|
||||
setDueDate: vi.fn(),
|
||||
setIntent: vi.fn(),
|
||||
dismissIntent: vi.fn(),
|
||||
setStatus: vi.fn(async () => ({ ok: true as const })),
|
||||
classifyStatus: vi.fn(async () => ({ recommended: 'archived' as const, rationale: 'stub' })),
|
||||
updateRawText: vi.fn(async () => ({ ok: true as const })),
|
||||
listRevisions: vi.fn(async () => []),
|
||||
getRevision: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('../../src/renderer/inbox/store.js', () => ({
|
||||
useInbox: Object.assign(
|
||||
(selector?: (s: typeof baseState) => unknown) => (selector ? selector(baseState) : baseState),
|
||||
{ getState: () => baseState }
|
||||
)
|
||||
}));
|
||||
|
||||
import { ReviewView } from '../../src/renderer/inbox/components/ReviewView';
|
||||
|
||||
describe('ReviewView', () => {
|
||||
beforeEach(() => { cleanup(); });
|
||||
|
||||
it('daily — 라벨 + totalCount + tagBar + dueProgress 렌더', () => {
|
||||
render(<ReviewView period="daily" />);
|
||||
expect(screen.getByText(/일간/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/총.*12건/)).toBeInTheDocument();
|
||||
expect(screen.getByText('회의')).toBeInTheDocument();
|
||||
expect(screen.getByText('결재')).toBeInTheDocument();
|
||||
expect(screen.getByText(/4.*\/.*10/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('weekly — 라벨 weekly', () => {
|
||||
render(<ReviewView period="weekly" />);
|
||||
expect(screen.getByText(/주간/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('monthly — 라벨 monthly', () => {
|
||||
render(<ReviewView period="monthly" />);
|
||||
expect(screen.getByText(/월간/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
64
tests/unit/RevisionHistoryModal.test.tsx
Normal file
64
tests/unit/RevisionHistoryModal.test.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
const { mockListRevisions, mockRestoreRevision } = vi.hoisted(() => ({
|
||||
mockListRevisions: vi.fn(),
|
||||
mockRestoreRevision: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
inboxApi: {
|
||||
listRevisions: mockListRevisions,
|
||||
restoreRevision: mockRestoreRevision
|
||||
}
|
||||
}));
|
||||
|
||||
import { RevisionHistoryModal } from '../../src/renderer/inbox/components/RevisionHistoryModal';
|
||||
|
||||
describe('RevisionHistoryModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cleanup();
|
||||
mockListRevisions.mockResolvedValue([
|
||||
{ revId: 3, noteId: 'a', rawText: 'v3', editedAt: '2026-05-11T00:00:00Z', editedBy: 'user' },
|
||||
{ revId: 2, noteId: 'a', rawText: 'v2', editedAt: '2026-05-10T00:00:00Z', editedBy: 'user' },
|
||||
{ revId: 1, noteId: 'a', rawText: 'v1', editedAt: '2026-05-01T00:00:00Z', editedBy: 'capture' }
|
||||
]);
|
||||
mockRestoreRevision.mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
it('open 시 listRevisions 호출 + 목록 표시 (capture/user 라벨)', async () => {
|
||||
render(<RevisionHistoryModal noteId="a" onClose={() => {}} onRestored={() => {}} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('v3')).toBeInTheDocument();
|
||||
expect(screen.getByText('v2')).toBeInTheDocument();
|
||||
expect(screen.getByText('v1')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText(/캡처/)).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/사용자/).length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('회수 클릭 → confirm OK → restoreRevision + onRestored 호출 + onClose', async () => {
|
||||
const onRestored = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
render(<RevisionHistoryModal noteId="a" onClose={onClose} onRestored={onRestored} />);
|
||||
await waitFor(() => screen.getByText('v1'));
|
||||
|
||||
const buttons = screen.getAllByRole('button', { name: /회수/ });
|
||||
// last button = oldest (v1)
|
||||
const lastButton = buttons[buttons.length - 1];
|
||||
if (lastButton === undefined) throw new Error('no 회수 button');
|
||||
fireEvent.click(lastButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRestoreRevision).toHaveBeenCalledWith('a', 1);
|
||||
});
|
||||
expect(onRestored).toHaveBeenCalledWith('v1');
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
47
tests/unit/SearchBox.test.tsx
Normal file
47
tests/unit/SearchBox.test.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
const { mockSearchNotes, mockClearSearch } = vi.hoisted(() => ({
|
||||
mockSearchNotes: vi.fn(),
|
||||
mockClearSearch: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('../../src/renderer/inbox/store.js', () => ({
|
||||
useInbox: Object.assign(
|
||||
(selector?: (s: { searchQuery: string }) => unknown) => {
|
||||
const state = { searchQuery: '' };
|
||||
return selector ? selector(state) : state;
|
||||
},
|
||||
{ getState: () => ({ searchNotes: mockSearchNotes, clearSearch: mockClearSearch }) }
|
||||
)
|
||||
}));
|
||||
|
||||
import { SearchBox } from '../../src/renderer/inbox/components/SearchBox';
|
||||
|
||||
describe('SearchBox', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cleanup();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
it('타이핑 → 200ms debounce 후 searchNotes 호출', () => {
|
||||
render(<SearchBox />);
|
||||
const input = screen.getByRole('searchbox');
|
||||
fireEvent.change(input, { target: { value: '회의' } });
|
||||
expect(mockSearchNotes).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(mockSearchNotes).toHaveBeenCalledWith('회의');
|
||||
});
|
||||
|
||||
it('빈 값 → clearSearch 호출', () => {
|
||||
render(<SearchBox />);
|
||||
const input = screen.getByRole('searchbox');
|
||||
fireEvent.change(input, { target: { value: '' } });
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(mockClearSearch).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
34
tests/unit/ftsHelpers.test.ts
Normal file
34
tests/unit/ftsHelpers.test.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
99
tests/unit/inboxApi-revisions.test.ts
Normal file
99
tests/unit/inboxApi-revisions.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
default: {
|
||||
ipcMain: { handle: vi.fn() }
|
||||
}
|
||||
}));
|
||||
|
||||
import electron from 'electron';
|
||||
import { registerInboxApi } from '../../src/main/ipc/inboxApi.js';
|
||||
import type { InboxIpcDeps } from '../../src/main/ipc/inboxApi.js';
|
||||
|
||||
function getHandler(channel: string): (...args: unknown[]) => unknown {
|
||||
const handle = (electron.ipcMain as unknown as { handle: ReturnType<typeof vi.fn> }).handle;
|
||||
const call = handle.mock.calls.find((c) => c[0] === channel);
|
||||
if (!call) throw new Error(`channel ${channel} not registered`);
|
||||
return call[1] as (...args: unknown[]) => unknown;
|
||||
}
|
||||
|
||||
function makeDeps(overrides: Partial<InboxIpcDeps> = {}): InboxIpcDeps {
|
||||
const repo = {
|
||||
updateRawText: vi.fn(),
|
||||
listRevisions: vi.fn(() => []),
|
||||
restoreRevision: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
list: vi.fn(),
|
||||
listByStatus: vi.fn(),
|
||||
countByStatus: vi.fn(() => 0),
|
||||
countByAiStatus: vi.fn(() => 0),
|
||||
countTrashed: vi.fn(() => 0),
|
||||
countFailed: vi.fn(() => 0),
|
||||
listTrashed: vi.fn(() => []),
|
||||
setStatus: vi.fn(),
|
||||
requeueDisabled: vi.fn(() => 0),
|
||||
getAllPendingJobs: vi.fn(() => []),
|
||||
getPendingCount: vi.fn(() => 0),
|
||||
countToday: vi.fn(() => 0)
|
||||
} as unknown as InboxIpcDeps['repo'];
|
||||
return {
|
||||
repo,
|
||||
continuity: { get: vi.fn() } as unknown as InboxIpcDeps['continuity'],
|
||||
capture: {} as InboxIpcDeps['capture'],
|
||||
health: {} as InboxIpcDeps['health'],
|
||||
intent: {} as InboxIpcDeps['intent'],
|
||||
getInboxWindow: () => null,
|
||||
settings: {} as InboxIpcDeps['settings'],
|
||||
providerHolder: {} as InboxIpcDeps['providerHolder'],
|
||||
paths: { profileDir: '/tmp' },
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('inboxApi revisions IPC', () => {
|
||||
beforeEach(() => {
|
||||
(electron.ipcMain as unknown as { handle: ReturnType<typeof vi.fn> }).handle.mockClear();
|
||||
});
|
||||
|
||||
it('inbox:update-raw-text — repo.updateRawText 호출 + ok:true', async () => {
|
||||
const deps = makeDeps();
|
||||
registerInboxApi(deps);
|
||||
const h = getHandler('inbox:update-raw-text');
|
||||
const r = await h({}, 'note-1', 'new text');
|
||||
expect(deps.repo.updateRawText).toHaveBeenCalledWith('note-1', 'new text');
|
||||
expect(r).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('inbox:update-raw-text — 빈 문자열 reject', async () => {
|
||||
const deps = makeDeps();
|
||||
registerInboxApi(deps);
|
||||
const h = getHandler('inbox:update-raw-text');
|
||||
const r = await h({}, 'note-1', ' ');
|
||||
expect(deps.repo.updateRawText).not.toHaveBeenCalled();
|
||||
expect(r).toEqual({ ok: false, reason: 'empty' });
|
||||
});
|
||||
|
||||
it('inbox:list-revisions — repo.listRevisions 결과 반환', async () => {
|
||||
const deps = makeDeps();
|
||||
(deps.repo.listRevisions as ReturnType<typeof vi.fn>).mockReturnValue([
|
||||
{ revId: 1, noteId: 'a', rawText: 'v1', editedAt: 't1', editedBy: 'capture' }
|
||||
]);
|
||||
registerInboxApi(deps);
|
||||
const h = getHandler('inbox:list-revisions');
|
||||
const r = await h({}, 'a');
|
||||
expect(r).toEqual([
|
||||
{ revId: 1, noteId: 'a', rawText: 'v1', editedAt: 't1', editedBy: 'capture' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('inbox:restore-revision — repo throw 시 ok:false', async () => {
|
||||
const deps = makeDeps();
|
||||
(deps.repo.restoreRevision as ReturnType<typeof vi.fn>).mockImplementation(() => {
|
||||
throw new Error('revision 99 not found for note a');
|
||||
});
|
||||
registerInboxApi(deps);
|
||||
const h = getHandler('inbox:restore-revision');
|
||||
const r = await h({}, 'a', 99);
|
||||
expect(r).toEqual({ ok: false, reason: 'revision 99 not found for note a' });
|
||||
});
|
||||
});
|
||||
84
tests/unit/inboxApi-search-review.test.ts
Normal file
84
tests/unit/inboxApi-search-review.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
vi.mock('electron', () => ({ default: { ipcMain: { handle: vi.fn() } } }));
|
||||
import electron from 'electron';
|
||||
import { registerInboxApi } from '../../src/main/ipc/inboxApi.js';
|
||||
import type { InboxIpcDeps } from '../../src/main/ipc/inboxApi.js';
|
||||
|
||||
function getHandler(channel: string): (...args: unknown[]) => unknown {
|
||||
const handle = (electron.ipcMain as unknown as { handle: ReturnType<typeof vi.fn> }).handle;
|
||||
const call = handle.mock.calls.find((c) => c[0] === channel);
|
||||
if (!call) throw new Error(`channel ${channel} not registered`);
|
||||
return call[1] as (...args: unknown[]) => unknown;
|
||||
}
|
||||
|
||||
function makeDeps(overrides: Partial<InboxIpcDeps> = {}): InboxIpcDeps {
|
||||
const repo = {
|
||||
search: vi.fn(() => []),
|
||||
reviewAggregate: vi.fn(() => ({ totalCount: 0, recentNotes: [], tagCounts: [], dueProgress: { total: 0, passed: 0, pending: 0 } })),
|
||||
list: vi.fn(),
|
||||
listByStatus: vi.fn(),
|
||||
countByStatus: vi.fn(() => 0),
|
||||
countByAiStatus: vi.fn(() => 0),
|
||||
countTrashed: vi.fn(() => 0),
|
||||
countFailed: vi.fn(() => 0),
|
||||
listTrashed: vi.fn(() => []),
|
||||
setStatus: vi.fn(),
|
||||
requeueDisabled: vi.fn(() => 0),
|
||||
getAllPendingJobs: vi.fn(() => []),
|
||||
getPendingCount: vi.fn(() => 0),
|
||||
countToday: vi.fn(() => 0),
|
||||
findById: vi.fn(),
|
||||
listRevisions: vi.fn(() => []),
|
||||
restoreRevision: vi.fn(),
|
||||
updateRawText: vi.fn()
|
||||
} as unknown as InboxIpcDeps['repo'];
|
||||
return {
|
||||
repo,
|
||||
continuity: { get: vi.fn() } as unknown as InboxIpcDeps['continuity'],
|
||||
capture: {} as InboxIpcDeps['capture'],
|
||||
health: {} as InboxIpcDeps['health'],
|
||||
intent: {} as InboxIpcDeps['intent'],
|
||||
getInboxWindow: () => null,
|
||||
settings: {} as InboxIpcDeps['settings'],
|
||||
providerHolder: {} as InboxIpcDeps['providerHolder'],
|
||||
paths: { profileDir: '/tmp' },
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('inboxApi search/review IPC', () => {
|
||||
beforeEach(() => {
|
||||
(electron.ipcMain as unknown as { handle: ReturnType<typeof vi.fn> }).handle.mockClear();
|
||||
});
|
||||
|
||||
it('inbox:search — repo.search 호출 결과 반환', async () => {
|
||||
const deps = makeDeps();
|
||||
(deps.repo.search as ReturnType<typeof vi.fn>).mockReturnValue([{ id: 'a' }]);
|
||||
registerInboxApi(deps);
|
||||
const h = getHandler('inbox:search');
|
||||
const r = await h({}, '회의', { status: 'active', limit: 10 });
|
||||
expect(deps.repo.search).toHaveBeenCalledWith('회의', { status: 'active', limit: 10 });
|
||||
expect(r).toEqual([{ id: 'a' }]);
|
||||
});
|
||||
|
||||
it('inbox:review-aggregate — repo.reviewAggregate 호출 결과 반환', async () => {
|
||||
const deps = makeDeps();
|
||||
const fake = { totalCount: 5, recentNotes: [], tagCounts: [{ tag: 'x', count: 2 }], dueProgress: { total: 1, passed: 1, pending: 0 } };
|
||||
(deps.repo.reviewAggregate as ReturnType<typeof vi.fn>).mockReturnValue(fake);
|
||||
registerInboxApi(deps);
|
||||
const h = getHandler('inbox:review-aggregate');
|
||||
const r = await h({}, 'weekly');
|
||||
expect(deps.repo.reviewAggregate).toHaveBeenCalledWith('weekly');
|
||||
expect(r).toEqual(fake);
|
||||
});
|
||||
|
||||
it('inbox:review-aggregate — 잘못된 period reject', async () => {
|
||||
const deps = makeDeps();
|
||||
registerInboxApi(deps);
|
||||
const h = getHandler('inbox:review-aggregate');
|
||||
const r = await h({}, 'yearly');
|
||||
expect(deps.repo.reviewAggregate).not.toHaveBeenCalled();
|
||||
expect(r).toMatchObject({ totalCount: 0 });
|
||||
});
|
||||
});
|
||||
95
tests/unit/m006-migration.test.ts
Normal file
95
tests/unit/m006-migration.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import Database from 'better-sqlite3';
|
||||
import { up } from '../../src/main/db/migrations/m006_revisions.js';
|
||||
|
||||
describe('m006 migration — note_revisions table', () => {
|
||||
let db: Database.Database;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
db.pragma('foreign_keys = ON');
|
||||
db.exec(`
|
||||
CREATE TABLE notes (
|
||||
id TEXT PRIMARY KEY,
|
||||
raw_text TEXT NOT NULL,
|
||||
ai_title TEXT,
|
||||
ai_summary TEXT,
|
||||
ai_status TEXT NOT NULL
|
||||
CHECK (ai_status IN ('pending','done','failed','disabled')),
|
||||
ai_error TEXT,
|
||||
ai_provider TEXT,
|
||||
ai_generated_at TEXT,
|
||||
title_edited_by_user INTEGER NOT NULL DEFAULT 0,
|
||||
summary_edited_by_user INTEGER NOT NULL DEFAULT 0,
|
||||
user_intent TEXT,
|
||||
intent_prompted_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
due_date TEXT,
|
||||
due_date_edited_by_user INTEGER NOT NULL DEFAULT 0,
|
||||
deleted_at TEXT,
|
||||
last_recalled_at TEXT,
|
||||
recall_dismissed_at TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
status_changed_at TEXT,
|
||||
move_reason TEXT
|
||||
);
|
||||
INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at)
|
||||
VALUES ('a', 'first text', 'done', '2026-05-01T00:00:00Z', '2026-05-01T00:00:00Z'),
|
||||
('b', 'second text', 'done', '2026-05-02T00:00:00Z', '2026-05-02T00:00:00Z');
|
||||
`);
|
||||
});
|
||||
|
||||
afterEach(() => { db.close(); });
|
||||
|
||||
it('creates note_revisions table with required columns', () => {
|
||||
up(db);
|
||||
const cols = db.prepare(`PRAGMA table_info(note_revisions)`).all() as Array<{ name: string }>;
|
||||
const names = cols.map((c) => c.name);
|
||||
expect(names).toEqual(
|
||||
expect.arrayContaining(['rev_id', 'note_id', 'raw_text', 'edited_at', 'edited_by'])
|
||||
);
|
||||
});
|
||||
|
||||
it('creates idx_note_revisions_note_id index', () => {
|
||||
up(db);
|
||||
const idx = db.prepare(`PRAGMA index_list(note_revisions)`).all() as Array<{ name: string }>;
|
||||
expect(idx.map((i) => i.name)).toContain('idx_note_revisions_note_id');
|
||||
});
|
||||
|
||||
it('cascades on note delete (FK ON DELETE CASCADE)', () => {
|
||||
up(db);
|
||||
db.prepare(
|
||||
`INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by)
|
||||
VALUES ('a', 'manual rev', '2026-05-03T00:00:00Z', 'user')`
|
||||
).run();
|
||||
db.prepare(`DELETE FROM notes WHERE id=?`).run('a');
|
||||
const rows = db.prepare(`SELECT * FROM note_revisions WHERE note_id=?`).all('a');
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("backfills existing notes as edited_by='capture' revisions", () => {
|
||||
up(db);
|
||||
const rows = db
|
||||
.prepare(`SELECT note_id, raw_text, edited_at, edited_by FROM note_revisions ORDER BY note_id`)
|
||||
.all() as Array<{ note_id: string; raw_text: string; edited_at: string; edited_by: string }>;
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0]).toEqual({
|
||||
note_id: 'a',
|
||||
raw_text: 'first text',
|
||||
edited_at: '2026-05-01T00:00:00Z',
|
||||
edited_by: 'capture'
|
||||
});
|
||||
expect(rows[1]).toEqual({
|
||||
note_id: 'b',
|
||||
raw_text: 'second text',
|
||||
edited_at: '2026-05-02T00:00:00Z',
|
||||
edited_by: 'capture'
|
||||
});
|
||||
});
|
||||
|
||||
it('exports version=6', async () => {
|
||||
const mod = await import('../../src/main/db/migrations/m006_revisions.js');
|
||||
expect(mod.version).toBe(6);
|
||||
});
|
||||
});
|
||||
96
tests/unit/m007-migration.test.ts
Normal file
96
tests/unit/m007-migration.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import Database from 'better-sqlite3';
|
||||
import { up } from '../../src/main/db/migrations/m007_fts.js';
|
||||
|
||||
describe('m007 migration — notes_fts virtual table + triggers', () => {
|
||||
let db: Database.Database;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
db.pragma('foreign_keys = ON');
|
||||
db.exec(`
|
||||
CREATE TABLE notes (
|
||||
id TEXT PRIMARY KEY, raw_text TEXT NOT NULL,
|
||||
ai_title TEXT, ai_summary TEXT,
|
||||
ai_status TEXT NOT NULL CHECK (ai_status IN ('pending','done','failed','disabled')),
|
||||
ai_error TEXT, ai_provider TEXT, ai_generated_at TEXT,
|
||||
title_edited_by_user INTEGER NOT NULL DEFAULT 0,
|
||||
summary_edited_by_user INTEGER NOT NULL DEFAULT 0,
|
||||
user_intent TEXT, intent_prompted_at TEXT,
|
||||
created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
||||
due_date TEXT, due_date_edited_by_user INTEGER NOT NULL DEFAULT 0,
|
||||
deleted_at TEXT, last_recalled_at TEXT, recall_dismissed_at TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active', status_changed_at TEXT, move_reason TEXT
|
||||
);
|
||||
CREATE TABLE tags (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE COLLATE NOCASE);
|
||||
CREATE TABLE note_tags (
|
||||
note_id TEXT NOT NULL, tag_id INTEGER NOT NULL, source TEXT NOT NULL,
|
||||
PRIMARY KEY(note_id, tag_id),
|
||||
FOREIGN KEY(note_id) REFERENCES notes(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(tag_id) REFERENCES tags(id) ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO notes (id, raw_text, ai_title, ai_summary, ai_status, created_at, updated_at, status)
|
||||
VALUES
|
||||
('a', '오늘 회의 정리', '회의록', '월요일 회의', 'done', '2026-05-01T00:00:00Z', '2026-05-01T00:00:00Z', 'active'),
|
||||
('b', '예전 메모', '예전 제목', '예전 요약', 'done', '2026-04-01T00:00:00Z', '2026-04-01T00:00:00Z', 'completed'),
|
||||
('c', '버려진 메모', '버린 제목', '버린 요약', 'done', '2026-03-01T00:00:00Z', '2026-03-01T00:00:00Z', 'trashed');
|
||||
INSERT INTO tags (id, name) VALUES (1, '기획'), (2, '회의');
|
||||
INSERT INTO note_tags (note_id, tag_id, source) VALUES ('a', 1, 'ai'), ('a', 2, 'user');
|
||||
`);
|
||||
});
|
||||
|
||||
afterEach(() => { db.close(); });
|
||||
|
||||
it('creates notes_fts virtual table with FTS5 columns', () => {
|
||||
up(db);
|
||||
const rows = db.prepare(`SELECT sql FROM sqlite_master WHERE name='notes_fts'`).all() as Array<{ sql: string }>;
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]!.sql.toLowerCase()).toContain('using fts5');
|
||||
});
|
||||
|
||||
it('backfills active/completed notes; excludes trashed', () => {
|
||||
up(db);
|
||||
const rows = db
|
||||
.prepare(`SELECT note_id, ai_title, tags FROM notes_fts ORDER BY note_id`)
|
||||
.all() as Array<{ note_id: string; ai_title: string; tags: string }>;
|
||||
expect(rows.map((r) => r.note_id)).toEqual(['a', 'b']);
|
||||
const a = rows.find((r) => r.note_id === 'a')!;
|
||||
expect(a.ai_title).toBe('회의록');
|
||||
expect(a.tags.split(' ').sort()).toEqual(['기획', '회의']);
|
||||
const b = rows.find((r) => r.note_id === 'b')!;
|
||||
expect(b.tags).toBe('');
|
||||
});
|
||||
|
||||
it('AFTER INSERT trigger syncs new note', () => {
|
||||
up(db);
|
||||
db.prepare(`INSERT INTO notes (id, raw_text, ai_title, ai_summary, ai_status, created_at, updated_at, status)
|
||||
VALUES ('d', '새 메모', '새 제목', '새 요약', 'pending', '2026-05-09T00:00:00Z', '2026-05-09T00:00:00Z', 'active')`).run();
|
||||
const r = db.prepare(`SELECT raw_text, ai_title FROM notes_fts WHERE note_id=?`).get('d') as { raw_text: string; ai_title: string };
|
||||
expect(r.raw_text).toBe('새 메모');
|
||||
expect(r.ai_title).toBe('새 제목');
|
||||
});
|
||||
|
||||
it('AFTER UPDATE trigger syncs raw_text + ai_title + ai_summary', () => {
|
||||
up(db);
|
||||
db.prepare(`UPDATE notes SET raw_text=?, ai_title=?, ai_summary=?, updated_at=? WHERE id=?`)
|
||||
.run('수정한 본문', '수정 제목', '수정 요약', '2026-05-10T00:00:00Z', 'a');
|
||||
const r = db.prepare(`SELECT raw_text, ai_title, ai_summary FROM notes_fts WHERE note_id=?`).get('a') as {
|
||||
raw_text: string; ai_title: string; ai_summary: string;
|
||||
};
|
||||
expect(r.raw_text).toBe('수정한 본문');
|
||||
expect(r.ai_title).toBe('수정 제목');
|
||||
expect(r.ai_summary).toBe('수정 요약');
|
||||
});
|
||||
|
||||
it('AFTER DELETE trigger removes FTS row', () => {
|
||||
up(db);
|
||||
db.prepare(`DELETE FROM notes WHERE id=?`).run('a');
|
||||
const r = db.prepare(`SELECT * FROM notes_fts WHERE note_id=?`).all('a');
|
||||
expect(r).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('exports version=7', async () => {
|
||||
const mod = await import('../../src/main/db/migrations/m007_fts.js');
|
||||
expect(mod.version).toBe(7);
|
||||
});
|
||||
});
|
||||
@@ -51,11 +51,11 @@ describe('migration v3 — soft delete columns', () => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('user_version reaches latest (5)', () => {
|
||||
it('user_version reaches latest (7)', () => {
|
||||
const db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
const row = db.prepare('PRAGMA user_version').get() as { user_version: number };
|
||||
expect(row.user_version).toBe(5);
|
||||
expect(row.user_version).toBe(7);
|
||||
db.close();
|
||||
});
|
||||
|
||||
|
||||
50
tests/unit/store.search.test.ts
Normal file
50
tests/unit/store.search.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
inboxApi: {
|
||||
search: vi.fn(),
|
||||
reviewAggregate: vi.fn(),
|
||||
listNotes: vi.fn(() => []),
|
||||
getContinuity: vi.fn(() => ({ weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null })),
|
||||
getPendingCount: vi.fn(() => 0),
|
||||
getOllamaStatus: vi.fn(() => ({ ok: true })),
|
||||
getTodayCount: vi.fn(() => 0),
|
||||
getTrashCount: vi.fn(() => 0),
|
||||
listExpired: vi.fn(() => []),
|
||||
getFailedCount: vi.fn(() => 0),
|
||||
listRecallCandidate: vi.fn(() => null),
|
||||
countsByStatus: vi.fn(() => ({ active: 0, completed: 0, archived: 0, trashed: 0 })),
|
||||
getSettings: vi.fn(() => ({ ai_enabled: true })),
|
||||
listByStatus: vi.fn(() => [])
|
||||
}
|
||||
}));
|
||||
|
||||
import { useInbox } from '../../src/renderer/inbox/store';
|
||||
import { inboxApi } from '../../src/renderer/inbox/api.js';
|
||||
|
||||
describe('store — searchNotes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useInbox.setState({ searchQuery: '', searchResults: null, view: 'inbox' });
|
||||
});
|
||||
|
||||
it('빈 query → searchResults null + IPC 미호출', async () => {
|
||||
await useInbox.getState().searchNotes(' ');
|
||||
expect(useInbox.getState().searchResults).toBeNull();
|
||||
expect(inboxApi.search).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keyword query → IPC 호출 + searchResults set', async () => {
|
||||
(inboxApi.search as ReturnType<typeof vi.fn>).mockResolvedValue([{ id: 'a' }]);
|
||||
await useInbox.getState().searchNotes('회의');
|
||||
expect(inboxApi.search).toHaveBeenCalledWith('회의', { status: 'active' });
|
||||
expect(useInbox.getState().searchResults).toEqual([{ id: 'a' }]);
|
||||
});
|
||||
|
||||
it('clearSearch — query + results 모두 초기화', () => {
|
||||
useInbox.setState({ searchQuery: '회의', searchResults: [{ id: 'a' } as never] });
|
||||
useInbox.getState().clearSearch();
|
||||
expect(useInbox.getState().searchQuery).toBe('');
|
||||
expect(useInbox.getState().searchResults).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user