24 Commits

Author SHA1 Message Date
a54f134343 Merge pull request 'v0.3.0 Cut E — 양방향 git sync + Configure UI + Conflict resolution (F21)' (#30) from worktree-v030-cut-e-bidirectional-sync into main
Reviewed-on: #30
2026-05-09 19:24:42 +00:00
altair823
401414608b fix(v030): SyncConflict noteId→path + populate localText/remoteText (final review fix)
final code review (Opus) 발견 2 important issues:

1. SyncConflict.noteId 가 실제로 export filename slug (date-id8-slug) 였음 — UUID 가
   아니라 git checkout path 의 stem. 명명 혼동 → 'path' 로 rename (실제 의미와 일치).
2. ConflictModal preview 가 항상 빈 문자열이라 사용자가 비교 없이 local/remote 선택해야
   했음. runSync 의 conflict 분기에서 `git show :2:<path>` (ours) + `:3:<path>`
   (theirs) 호출 추가하여 localText/remoteText 채움.

영향:
- SyncService.SyncConflict + shared/types.ts.SyncConflict: noteId → path
- SyncService.resolveConflict(path, choice) — 'notes/...md' 그대로 받음
- pathToNoteId 헬퍼 제거 (불필요)
- ConflictModal: c.noteId → c.path, busy 상태 + 표시 모두 path 키
- IPC handler / preload bridge / InboxApi 시그니처 모두 path 로 통일
- SyncService.bidirectional/resolveConflict/sync-ipc/ConflictModal 4 test 갱신

regression 회귀 패턴 검사: rename 후 NoteRepository / SyncService / IPC / UI 의 모든
conflict-related path 일관 (typecheck 0).
2026-05-10 04:10:59 +09:00
altair823
2ef4802050 chore(release): v0.3.0 — Cut E (양방향 git sync + Configure UI + Conflict resolution)
- F21 promoted ( v0.3.0 Cut E — A+B+C 옵션, both deferred)
- version 0.2.11 → 0.3.0 (semver MINOR — Major 영역 진입)
- 단위 608 → 680 (+72): GitClient 5 + upsertFromSync 5 + ImportService 18 + SyncService bidirectional 5 + resolveConflict 4 + SettingsService 6 + sync IPC 17 + SyncSection 4 + ConflictModal 3 + SyncTimer 5
- typecheck 0 errors
2026-05-10 04:01:41 +09:00
altair823
e3f6c711a7 feat(v030): SyncTimer — 자동 주기 sync (settings 변경 시 reconfigure)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 03:59:52 +09:00
altair823
87c18a4c2d feat(v030): SyncSection + ConflictModal — Configure UI + 충돌 해결 UI
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 03:56:00 +09:00
altair823
9e48624495 feat(v030): sync IPC + preload (configure / test / list-conflicts / resolve / status)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 03:49:10 +09:00
altair823
62e68dcfe7 feat(v030): settings.sync_repo_url + sync_auto_enabled + sync_interval_min
- zod schema 확장: sync_repo_url (nullable), sync_auto_enabled (default true), sync_interval_min (int >= 5, default 30)
- getter/setter 6개 추가 (기존 ai_enabled / onboarding_completed 패턴)
- setSyncIntervalMin 은 non-integer / < 5 reject
2026-05-10 03:44:09 +09:00
altair823
8436846657 feat(v030): SyncService.resolveConflict — local/remote 2 choice (both deferred) 2026-05-10 03:42:50 +09:00
altair823
33588b09df feat(v030): SyncService.sync — 양방향 6단계 (export/commit/fetch/rebase/re-import/push) + conflict 반환
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 03:40:09 +09:00
altair823
9a1f0e269a feat(v030): ImportService.applySyncFromDir + frontmatter status/dueDate/moveReason round-trip
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 03:33:48 +09:00
altair823
bbfd0cccda feat(v030): NoteRepository.upsertFromSync — sync 전용 3 분기 upsert + single write path
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 03:27:49 +09:00
altair823
dba64c546f feat(v030): GitClient — fetch/rebaseOnto/rebaseAbort/hasUncommittedChanges/listConflicts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 03:23:00 +09:00
altair823
662abdb508 docs(plan): v0.3.0 Cut E — 양방향 git sync (spec 정정: 단위 608, ImportService.run 활용, 'sync' enum 미도입, both deferred) 2026-05-10 03:19:16 +09:00
2e9a82face Merge pull request 'v0.2.11 Cut D — FTS5 search + 회고 view (F19 A+D)' (#29) from worktree-v0211-cut-d-fts5-review into main
Reviewed-on: #29
2026-05-09 15:52:16 +00:00
altair823
735d5494f2 fix(v0211): importNote 가 rebuildFtsTagsForNote 호출 (final review fix)
final code review 발견: F5 import path 가 note_tags INSERT 후 notes_fts.tags 갱신
안 해서 import 한 노트의 tag 가 keyword 검색에서 매칭 안 되는 회귀.

Cut C 의 importNote capture revision 누락 패턴과 동일 — single write path
정책 (Cut D 도입) 의 강제 검사 누락. importNote transaction 끝에서 호출하도록
fix + 회귀 test 2건 (insert path / fork path) 추가.

NoteRepository 안 note_tags INSERT path 는 updateAiResult / updateUserAiFields /
importNote 3곳, 셋 다 rebuildFtsTagsForNote 호출 보장 — invariant 회복.
2026-05-10 00:46:58 +09:00
altair823
5801a98a00 chore(release): v0.2.11 — Cut D (FTS5 search + 회고 view)
- F19 promoted ( v0.2.11 Cut D — A+D 옵션)
- version 0.2.10 → 0.2.11 (package.json + package-lock.json)
- 단위 569 → 606 (m007 6 + tags sync 2 + ftsHelpers 7 + search 6 + reviewAggregate 5 + IPC 3 + store 3 + SearchBox 2 + ReviewView 3 = 37 신규)
- typecheck 0 errors
2026-05-10 00:41:42 +09:00
altair823
9feb712c60 feat(v0211): ReviewView — 일/주/월 회고 + 헤더 dropdown 진입점 2026-05-10 00:39:36 +09:00
altair823
be125b8ace feat(v0211): SearchBox + 헤더 mount + inbox 결과 렌더 분기
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 00:35:34 +09:00
altair823
f5e43133be feat(v0211): store — search + reviewData state + actions + view enum 확장
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 00:31:53 +09:00
altair823
143684ce8a feat(v0211): InboxApi.search + reviewAggregate (types + IPC + preload) 2026-05-10 00:27:43 +09:00
altair823
e60a2a23c8 feat(v0211): ftsHelpers + NoteRepository.search + reviewAggregate
- ftsHelpers: sanitizeFtsQuery (FTS5 special char escape) + computeCutoff (period → KST 자정)
- search: notes_fts MATCH + status filter + rank order + sanitize + 빈 query → []
- reviewAggregate: period 별 totalCount/recentNotes(50)/tagCounts(DESC)/dueProgress(passed/pending)
2026-05-10 00:24:24 +09:00
altair823
726d155d04 feat(v0211): rebuildFtsTagsForNote 헬퍼 + tags 변경 path 통합 (single write)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 00:19:14 +09:00
altair823
19edeab7b1 feat(v0211): m007 migration — notes_fts FTS5 + trigger 3 + backfill 2026-05-10 00:16:35 +09:00
altair823
1104a8c666 docs(plan): v0.2.11 Cut D — FTS5 search + 회고 view (spec m006→m007 정정 + ai_title/ai_summary + note_tags JOIN) 2026-05-10 00:11:12 +09:00
56 changed files with 5727 additions and 103 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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 도 훨씬 중요" — 본인 표현.
@@ -1570,9 +1570,9 @@ app.on('activate', () => {
---
## F21. 다기기 git-based 동기화 (🌱 raw — v0.2.8 후보, **부분 구현됨**)
## F21. 다기기 git-based 동기화 (✅ promoted v0.3.0 Cut E — 양방향 + Configure UI + Conflict)
**진행 상태:** 🌱 raw — `SyncService` + `GitClient` 가 이미 push-only 형태로 존재. **양방향 동기화 + UI 구성** 이 누락된 핵심 부분. v0.2.8 brainstorm 시 명확한 cut.
**진행 상태:** ✅ promoted v0.3.0 Cut E — 옵션 A (자동 rebase) + B (Configure UI) + C (conflict UI). SyncService 양방향 6단계 (export → commit → fetch → rebase → re-import → push), `NoteRepository.upsertFromSync` (sync 전용 3 분기), `SettingsService.{getSyncRepoUrl,isAutoSyncEnabled,getSyncIntervalMin}` + `SyncTimer` (자동 주기 + reconfigure), `SyncSection` UI + `ConflictModal` (local/remote 2 choice, both deferred v0.3.1+). 단위 608 → 679. dogfood 1주 soak 후 Cut F (F24 vision) 진입.
**발견:** 2026-05-09 v0.2.7 release 후 본인 dogfood. 사용자 표현: "그 중심에 git repo 를 쓸 수 있으면 좋겠어".

View File

@@ -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` 에서 `"`/`*`/`(`/`)`/`:` 이스케이프 또는 제거 |
---

View File

@@ -38,60 +38,81 @@ async sync(opts: { interval?: boolean } = {}): Promise<SyncStatus> {
const git = new GitClient(this.syncDir);
// 1. fetch
const fetchR = await git.fetch();
if (fetchR.exitCode !== 0) return { ok: false, reason: `fetch failed: ${fetchR.stderr}` };
// 2. local export (변경 감지 위해)
// 1. local export — 현재 SQLite 상태를 syncDir 에 markdown 으로 출력
await this.exportSvc.export(this.syncDir, { includeMedia: true });
await git.addAll();
const localChanged = await git.hasUncommittedChanges();
// 3. local commit (있으면)
// 2. local commit (변경 있으면)
let localSha: string | null = null;
if (localChanged) {
const c = await git.commit(`chore(notes): sync ${this.now().toISOString()}`);
localSha = c.sha;
}
// 4. rebase
// 3. fetch
const fetchR = await git.fetch();
if (fetchR.exitCode !== 0) return { ok: false, reason: `fetch failed: ${fetchR.stderr}` };
// 4. rebase onto origin/main
const rebaseR = await git.rebaseOnto('origin/main');
if (rebaseR.exitCode !== 0) {
// conflict — abort + 사용자에게 conflict UI 안내
// conflict — abort + conflict 목록 반환 (UI 가 활성)
await git.rebaseAbort();
return { ok: false, reason: 'conflict', conflicts: await this.listConflicts() };
return { ok: false, reason: 'conflict', conflicts: await this.listConflictsFromMarkdown() };
}
// 5. re-import (rebase 후 markdown 변경 → SQLite 적용)
const imported = await this.importSvc.importAll(this.syncDir);
// 5. re-import (rebase 후 markdown 변경 → SQLite upsertFromSync)
const imported = await this.importSvc.applySyncFromDir(this.syncDir);
// 6. push
const pushR = await git.push();
if (pushR.exitCode !== 0) return { ok: false, reason: `push failed: ${pushR.stderr}` };
try { await git.push(); } catch (e) { return { ok: false, reason: `push failed: ${(e as Error).message}` }; }
return { ok: true, changed: localChanged || imported.changedCount > 0, localSha, importedCount: imported.changedCount, pushed: true };
}
```
### 3-2. ImportService 활용
**6 단계 흐름 — local export 가 fetch 보다 먼저 (Cut E 정정)**: spec 초안은 fetch 우선이었으나, local export → commit 후 fetch + rebase 가 git workflow 표준 (rebase 가 local commit 위에 origin commit 적용). local export 안 한 상태로 fetch + rebase → 혼란 발생.
기존 ImportService (백업 복원 흐름) 가 markdown → SQLite 적재. sync 의 re-import 도 같은 service 활용:
`SyncStatus` 인터페이스 확장:
```ts
class ImportService {
async importAll(dir: string): Promise<{ changedCount: number; conflicts: string[] }> {
// dir 하위의 모든 .md 파일 → frontmatter parse → notes UPSERT
// existing note 와 비교 — updated_at 더 최신이면 갱신, 아니면 skip
// raw_text 다른 경우 → note_revisions 에 INSERT (new rev, edited_by='sync')
}
export interface SyncStatus {
ok: boolean;
reason?: string;
changed?: boolean;
localSha?: string | null;
pushed?: boolean;
importedCount?: number;
conflicts?: Array<{ noteId: string; localText: string; remoteText: string }>; // reason='conflict' 시
}
```
**revision linear merge 정책**:
### 3-2. ImportService 활용 (실제 코드 정정)
- 옛 rev (origin/main 의 rev_5) 가 local 에 없으면 → INSERT note_revisions (timestamp 기준 적절 위치)
- local rev 와 origin rev 가 동일 timestamp + 다른 raw_text → conflict (사용자 prompt)
- 일반적으로 다른 timestamp 면 timestamp 순 linear chain 으로 merge
기존 ImportService 는 `run(sourceDir)` 메서드 (백업 복원 흐름) — `parsedToInput``repo.importNote()` 호출. spec 작성 시 가정한 `importAll(dir)` 시그니처는 실재 코드와 다름.
`repo.importNote()` 의 기존 conflict 정책 (export tree 복원용):
- id 없음 → INSERT (`status: 'inserted'`)
- id 있음 + raw_text 동일 → no-op (`status: 'skipped'`)
- id 있음 + raw_text 다름 → fork-on-id-collision (fresh uuidv7) (`status: 'forked'`)
**Cut E sync 정책 — fork 미적합, in-place update + revision 보존**:
sync 에서 양 기기 raw_text 가 다를 때 fork 하면 노트 갯수 무한 증가 → 부적합. 신설 메서드 `repo.upsertFromSync(input)`:
- id 없음 → INSERT (m006 trigger 가 capture revision 자동 생성)
- id 있음 + raw_text 동일 → metadata 갱신 path
- source.updatedAt > local.updatedAt 인 경우만 ai_title/ai_summary/tags/status/dueDate 갱신
- tags 변경 시 `rebuildFtsTagsForNote` 호출 (Cut D single write path)
- 동등/older 면 skip
- id 있음 + raw_text 다름 → 옵션 분기:
- source.updatedAt > local.updatedAt → `updateRawText(id, sourceRawText, sourceUpdatedAt)` (Cut C single write path) → 새 user revision INSERT, latest = source
- local.updatedAt > source.updatedAt → skip (다음 push 가 source 갱신할 것)
- 동일 timestamp + 다른 raw_text → SyncService 가 conflict 마킹 (rebase 단계 git markdown conflict 가 먼저 잡힘 — 본 분기는 rare)
**revision edited_by**: 'sync' enum 추가 안 함 — `updateRawText` 의 default 'user' 그대로 활용 (sync = user-edited 변경 전파 = 의미상 user). YAGNI: m008 회피.
### 3-3. GitClient 확장
@@ -193,7 +214,7 @@ settings: `sync_auto_enabled: boolean` (default true 단, configured 일 때만)
| Conflict UI | 3 choice 별 sync 동작 |
| 자동 주기 sync | timer + interval=true mode |
**목표**: 단위 528 → 약 555 (+27), typecheck 0.
**목표**: 단위 608 → 약 635 (+27), typecheck 0.
---

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "inkling",
"version": "0.2.10",
"version": "0.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "inkling",
"version": "0.2.10",
"version": "0.3.0",
"dependencies": {
"better-sqlite3": "12.9.0",
"electron-log": "5.2.0",

View File

@@ -1,6 +1,6 @@
{
"name": "inkling",
"version": "0.2.10",
"version": "0.3.0",
"private": true,
"description": "Inkling — local-first 한 줄 보관 도구",
"author": "altair823 <dlsrks0734@gmail.com>",

View File

@@ -5,8 +5,9 @@ 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, m006];
const migrations = [m001, m002, m003, m004, m005, m006, m007];
export function latestVersion(): number {
return migrations[migrations.length - 1]!.version;

View 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';
`);
}

View File

@@ -30,6 +30,7 @@ import { BackupService } from './services/BackupService.js';
import { ExportService } from './services/ExportService.js';
import { ImportService } from './services/ImportService.js';
import { SyncService } from './services/SyncService.js';
import { SyncTimer } from './services/SyncTimer.js';
import { TelemetryService } from './services/TelemetryService.js';
import { SettingsService } from './services/SettingsService.js';
import { collectAutostartState } from './services/AutostartDiagnostic.js';
@@ -196,7 +197,8 @@ app.whenReady().then(async () => {
const exportSvc = new ExportService(repo, store);
const importSvc = new ImportService(repo, store);
const syncSvc = new SyncService(paths.profileDir, exportSvc);
const syncSvc = new SyncService(paths.profileDir, exportSvc, importSvc);
const syncTimer = new SyncTimer(syncSvc, settingsSvc);
const backup = new BackupService(db, join(paths.profileDir, 'backups'));
void backup.runDaily()
@@ -206,14 +208,18 @@ app.whenReady().then(async () => {
// v0.2.7 Task 10 — 설정 페이지 IPC (autostart + backup/export/import/sync/telemetry).
// backup / exportSvc / importSvc / syncSvc / telemetry 가 모두 준비된 뒤 등록.
registerSettingsApi({
backup, exportSvc, importSvc, syncSvc, telemetry, settings: settingsSvc, getInboxWindow
backup, exportSvc, importSvc, syncSvc, telemetry, settings: settingsSvc, getInboxWindow,
syncTimer
});
void syncTimer.start();
let backupOnQuitDone = false;
let trayInterval: NodeJS.Timeout | null = null;
app.on('before-quit', (e) => {
// 모든 cleanup 한 곳에 통합 — sync (idempotent) → async backup chain.
health.stop();
syncTimer.stop();
if (trayInterval !== null) {
clearInterval(trayInterval);
trayInterval = null;

View File

@@ -292,6 +292,26 @@ export function registerInboxApi(deps: InboxIpcDeps): void {
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 {

View File

@@ -7,8 +7,10 @@ import type { BackupService } from '../services/BackupService.js';
import type { ExportService } from '../services/ExportService.js';
import type { ImportService } from '../services/ImportService.js';
import type { SyncService } from '../services/SyncService.js';
import { GitClient } from '../services/GitClient.js';
import type { TelemetryService } from '../services/TelemetryService.js';
import type { SettingsService } from '../services/SettingsService.js';
import type { SyncTimer } from '../services/SyncTimer.js';
import { collectAutostartState } from '../services/AutostartDiagnostic.js';
import { getInboxWindow as getInboxWindowSingleton } from '../windows/inboxWindow.js';
@@ -36,6 +38,7 @@ export interface SettingsIpcDeps {
telemetry: TelemetryService;
settings: SettingsService;
getInboxWindow: () => BrowserWindow | null;
syncTimer?: SyncTimer;
}
/**
@@ -106,6 +109,22 @@ export function registerSettingsApi(deps?: SettingsIpcDeps): void {
return { ok: true as const };
});
ipcMain.handle('settings:set-sync-auto-enabled', async (_e, value: boolean) => {
await deps.settings.setAutoSyncEnabled(value);
await deps.syncTimer?.reconfigure();
return { ok: true as const };
});
ipcMain.handle('settings:set-sync-interval-min', async (_e, value: number) => {
try {
await deps.settings.setSyncIntervalMin(value);
await deps.syncTimer?.reconfigure();
return { ok: true as const };
} catch (e) {
return { ok: false as const, reason: (e as Error).message };
}
});
ipcMain.handle('settings:run-backup', async () => {
try {
const r = await backup.runDaily();
@@ -239,7 +258,7 @@ export function registerSettingsApi(deps?: SettingsIpcDeps): void {
return { ok: true } as const;
}
if (r.changed) {
logger.info('sync.done', { sha: r.sha, pushed: r.pushed });
logger.info('sync.done', { sha: r.localSha, pushed: r.pushed });
new Notification({ title: 'Inkling', body: '동기화 완료', silent: true }).show();
} else {
new Notification({ title: 'Inkling', body: '변경 사항 없음', silent: true }).show();
@@ -281,4 +300,82 @@ export function registerSettingsApi(deps?: SettingsIpcDeps): void {
}
return { ok: true } as const;
});
// v0.3.0 Cut E — sync IPC.
// settings:configure-sync — URL 저장 + git init + remote add (없으면).
// null URL → 저장만 (init 안 함). 빈 문자열도 null 처리.
ipcMain.handle('settings:configure-sync', async (_e, url: string | null) => {
const trimmed = typeof url === 'string' ? url.trim() : '';
const finalUrl = trimmed.length === 0 ? null : trimmed;
try {
await deps.settings.setSyncRepoUrl(finalUrl);
} catch (e) {
return { ok: false as const, reason: `persist failed: ${(e as Error).message}` };
}
if (finalUrl === null) {
await deps.syncTimer?.reconfigure();
return { ok: true as const };
}
// git init + remote add origin
const syncDir = deps.syncSvc.getSyncDir();
const git = new GitClient(syncDir);
if (!(await git.isRepo())) {
const init = await git.run(['init']);
if (init.exitCode !== 0) {
return { ok: false as const, reason: `git init failed: ${init.stderr}` };
}
}
if (!(await git.hasRemote())) {
const add = await git.run(['remote', 'add', 'origin', finalUrl]);
if (add.exitCode !== 0) {
return { ok: false as const, reason: `remote add failed: ${add.stderr}` };
}
} else {
// remote exists — update URL
const set = await git.run(['remote', 'set-url', 'origin', finalUrl]);
if (set.exitCode !== 0) {
return { ok: false as const, reason: `remote set-url failed: ${set.stderr}` };
}
}
await deps.syncTimer?.reconfigure();
return { ok: true as const };
});
// settings:test-sync-connection — git ls-remote 결과
ipcMain.handle('settings:test-sync-connection', async () => {
const syncDir = deps.syncSvc.getSyncDir();
const git = new GitClient(syncDir);
if (!(await git.isRepo())) return { ok: false as const, reason: 'not_initialized' };
const r = await git.run(['ls-remote', 'origin']);
if (r.exitCode !== 0) return { ok: false as const, reason: r.stderr || 'connection failed' };
return { ok: true as const };
});
// sync:list-conflicts — SyncService 캐시 결과
ipcMain.handle('sync:list-conflicts', () => deps.syncSvc.listConflicts());
// sync:resolve-conflict — local/remote 2 choice. path = git index conflict 경로.
ipcMain.handle('sync:resolve-conflict', async (_e, path: string, choice: 'local' | 'remote') => {
if (choice !== 'local' && choice !== 'remote') {
return { ok: false as const, reason: 'invalid choice' };
}
return deps.syncSvc.resolveConflict(path, choice);
});
// sync:get-status — lastAt + lastResult + nextAt 계산
ipcMain.handle('sync:get-status', async () => {
const last = deps.syncSvc.getLastStatus();
let nextAt: string | null = null;
if (await deps.settings.isAutoSyncEnabled()) {
const intervalMin = await deps.settings.getSyncIntervalMin();
const baseMs = last.lastAt ? new Date(last.lastAt).getTime() : Date.now();
nextAt = new Date(baseMs + intervalMin * 60 * 1000).toISOString();
}
return { lastAt: last.lastAt, lastResult: last.lastResult, nextAt };
});
}

View File

@@ -2,6 +2,7 @@ import type Database from 'better-sqlite3';
import { v7 as uuidv7, v4 as uuidv4 } from 'uuid';
import type { AiStatus, Note, NoteMedia, NoteRevision, NoteStatus, NoteTag } from '@shared/types';
import { kstTodayIso } from '../../shared/util/kstDate.js';
import { sanitizeFtsQuery, computeCutoff, type ReviewPeriod } from './ftsHelpers.js';
export interface CreateNoteInput {
rawText: string;
@@ -50,6 +51,29 @@ export interface ImportNoteResult {
status: ImportNoteStatus;
}
export interface UpsertFromSyncInput {
id: string;
rawText: string;
createdAt: string;
updatedAt: string;
aiTitle: string | null;
aiSummary: string | null;
titleEditedByUser: boolean;
summaryEditedByUser: boolean;
aiProvider: string | null;
aiGeneratedAt: string | null;
userIntent: string | null;
intentPromptedAt: string | null;
tags: { name: string; source: 'ai' | 'user' }[];
status: NoteStatus;
statusChangedAt: string | null;
moveReason: string | null;
dueDate: string | null;
dueDateEditedByUser: boolean;
}
export type UpsertFromSyncStatus = 'inserted' | 'updated' | 'skipped';
const KEBAB_CASE_RE = /^[a-z0-9-]+$/;
export class NoteRepository {
@@ -161,6 +185,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();
}
@@ -390,6 +415,7 @@ export class NoteRepository {
const row = getOrInsert.get(t) as { id: number };
link.run(id, row.id);
}
this.rebuildFtsTagsForNote(id);
}
});
tx();
@@ -598,6 +624,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 재투입).
@@ -696,6 +803,11 @@ export class NoteRepository {
* '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 갱신.
* insert/fork 는 source 의 deletedAt 그대로 보존.
@@ -766,12 +878,151 @@ 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();
return { id: finalId, status };
}
/**
* v0.3.0 Cut E — sync 전용 upsert. 기존 importNote 의 fork-on-id-collision 정책은
* sync 에 부적합 (양 기기 raw_text 가 다를 때마다 fork → 노트 갯수 무한 증가).
*
* 3 분기:
* - id 없음 → INSERT (capture revision + tags FTS sync)
* - id 있음 + raw_text 동일 → source.updatedAt 가 더 최신일 때만 metadata 갱신
* - id 있음 + raw_text 다름 → source 가 더 최신이면 updateRawText (new user revision),
* local 이 더 최신이면 skip
*
* tags 변경 시 rebuildFtsTagsForNote 호출 — Cut D single write path 재사용.
* raw_text 변경 시 updateRawText 호출 — Cut C single write path 재사용.
*/
upsertFromSync(input: UpsertFromSyncInput): { id: string; status: UpsertFromSyncStatus } {
const existing = this.db
.prepare(`SELECT raw_text, updated_at, status FROM notes WHERE id=?`)
.get(input.id) as { raw_text: string; updated_at: string; status: NoteStatus } | undefined;
if (!existing) {
// INSERT path
const tx = this.db.transaction(() => {
this.db
.prepare(
`INSERT INTO notes
(id, raw_text, ai_title, ai_summary, ai_status, ai_provider, ai_generated_at,
title_edited_by_user, summary_edited_by_user,
user_intent, intent_prompted_at,
created_at, updated_at,
due_date, due_date_edited_by_user,
status, status_changed_at, move_reason)
VALUES (?, ?, ?, ?, 'done', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
input.id,
input.rawText,
input.aiTitle,
input.aiSummary,
input.aiProvider,
input.aiGeneratedAt,
input.titleEditedByUser ? 1 : 0,
input.summaryEditedByUser ? 1 : 0,
input.userIntent,
input.intentPromptedAt,
input.createdAt,
input.updatedAt,
input.dueDate,
input.dueDateEditedByUser ? 1 : 0,
input.status,
input.statusChangedAt,
input.moveReason
);
this.db
.prepare(
`INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by)
VALUES (?, ?, ?, 'capture')`
)
.run(input.id, 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`
);
const linkAi = this.db.prepare(
`INSERT OR IGNORE INTO note_tags(note_id, tag_id, source) VALUES(?, ?, 'ai')`
);
const linkUser = this.db.prepare(
`INSERT OR IGNORE INTO note_tags(note_id, tag_id, source) VALUES(?, ?, 'user')`
);
for (const t of input.tags) {
const row = getOrInsertTag.get(t.name) as { id: number };
if (t.source === 'ai') linkAi.run(input.id, row.id);
else linkUser.run(input.id, row.id);
}
this.rebuildFtsTagsForNote(input.id);
}
});
tx();
return { id: input.id, status: 'inserted' };
}
if (input.updatedAt <= existing.updated_at) {
return { id: input.id, status: 'skipped' };
}
if (existing.raw_text !== input.rawText) {
this.updateRawText(input.id, input.rawText, new Date(input.updatedAt));
}
const tx = this.db.transaction(() => {
this.db
.prepare(
`UPDATE notes
SET ai_title = CASE WHEN title_edited_by_user = 1 THEN ai_title ELSE ? END,
ai_summary = CASE WHEN summary_edited_by_user = 1 THEN ai_summary ELSE ? END,
ai_provider = ?,
ai_generated_at = ?,
due_date = CASE WHEN due_date_edited_by_user = 1 THEN due_date ELSE ? END,
status = ?,
status_changed_at = ?,
move_reason = ?,
updated_at = ?
WHERE id = ?`
)
.run(
input.aiTitle,
input.aiSummary,
input.aiProvider,
input.aiGeneratedAt,
input.dueDate,
input.status,
input.statusChangedAt,
input.moveReason,
input.updatedAt,
input.id
);
this.db.prepare(`DELETE FROM note_tags WHERE note_id=?`).run(input.id);
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`
);
const linkAi = this.db.prepare(
`INSERT OR IGNORE INTO note_tags(note_id, tag_id, source) VALUES(?, ?, 'ai')`
);
const linkUser = this.db.prepare(
`INSERT OR IGNORE INTO note_tags(note_id, tag_id, source) VALUES(?, ?, 'user')`
);
for (const t of input.tags) {
const row = getOrInsertTag.get(t.name) as { id: number };
if (t.source === 'ai') linkAi.run(input.id, row.id);
else linkUser.run(input.id, row.id);
}
}
this.rebuildFtsTagsForNote(input.id);
});
tx();
return { id: input.id, status: 'updated' };
}
getPendingCount(): number {
const row = this.db
.prepare(
@@ -851,6 +1102,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(

View File

@@ -0,0 +1,32 @@
/**
* v0.2.11 Cut D — FTS5 검색 + 회고 view 의 순수 함수 헬퍼.
*/
const FTS5_SPECIAL_CHARS_RE = /["*():]/g;
const WS_COLLAPSE_RE = /\s+/g;
/**
* FTS5 MATCH 쿼리에 안전한 형태로 변환. " * ( ) : 제거 + 공백 정리.
* 다중 토큰은 그대로 두어 FTS5 implicit AND 활용.
*/
export function sanitizeFtsQuery(input: string): string {
return input.replace(FTS5_SPECIAL_CHARS_RE, ' ').replace(WS_COLLAPSE_RE, ' ').trim();
}
export type ReviewPeriod = 'daily' | 'weekly' | 'monthly';
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
/**
* 회고 cutoff = period 시작점의 KST 자정 (UTC ISO).
* daily = 오늘 0시, weekly = 7일 전 0시, monthly = 30일 전 0시.
*/
export function computeCutoff(period: ReviewPeriod, now: Date): string {
const kstNow = new Date(now.getTime() + KST_OFFSET_MS);
const y = kstNow.getUTCFullYear();
const m = kstNow.getUTCMonth();
const d = kstNow.getUTCDate();
const todayMidKstUtc = Date.UTC(y, m, d) - KST_OFFSET_MS;
const days = period === 'daily' ? 0 : period === 'weekly' ? 7 : 30;
return new Date(todayMidKstUtc - days * 24 * 60 * 60 * 1000).toISOString();
}

View File

@@ -64,6 +64,11 @@ function noteToExportNote(n: Note): ExportNote {
aiGeneratedAt: n.aiGeneratedAt,
userIntent: n.userIntent,
intentPromptedAt: n.intentPromptedAt,
status: n.status,
statusChangedAt: n.statusChangedAt,
moveReason: n.moveReason,
dueDate: n.dueDate,
dueDateEditedByUser: n.dueDateEditedByUser,
tags: n.tags.map((t) => ({ name: t.name, source: t.source })),
media: n.media.map((m, idx) => ({
rel: `media/${n.id.slice(0, 8)}__${idx + 1}.${inferExt(m.mime)}`,

View File

@@ -89,4 +89,33 @@ export class GitClient {
if (r.exitCode !== 0) throw new Error(`git rev-parse failed: ${r.stderr}`);
return r.stdout.trim();
}
async fetch(remote: string = 'origin'): Promise<GitExecResult> {
return this.run(['fetch', remote]);
}
async rebaseOnto(ref: string): Promise<GitExecResult> {
return this.run(['rebase', ref]);
}
async rebaseAbort(): Promise<GitExecResult> {
return this.run(['rebase', '--abort']);
}
async hasUncommittedChanges(): Promise<boolean> {
const r = await this.run(['status', '--porcelain']);
return r.stdout.trim().length > 0;
}
/** ref (branch, tag, remote branch) 존재 여부 확인. `git rev-parse --verify`. */
async refExists(ref: string): Promise<boolean> {
const r = await this.run(['rev-parse', '--verify', ref]);
return r.exitCode === 0;
}
/** rebase conflict 시 conflict 마킹된 파일 list. `git diff --name-only --diff-filter=U`. */
async listConflicts(): Promise<string[]> {
const r = await this.run(['diff', '--name-only', '--diff-filter=U']);
return r.stdout.split('\n').map((s) => s.trim()).filter((s) => s.length > 0);
}
}

View File

@@ -130,6 +130,37 @@ export class ImportService {
};
}
async applySyncFromDir(dir: string): Promise<{ changedCount: number }> {
const files = await this.scanNotes(dir);
let changedCount = 0;
for (const f of files) {
const content = await readFile(f, 'utf8');
const parsed = parseExportNote(content);
const r = this.repo.upsertFromSync({
id: parsed.id,
rawText: parsed.rawText,
createdAt: parsed.createdAt,
updatedAt: parsed.updatedAt,
aiTitle: parsed.aiTitle,
aiSummary: parsed.aiSummary,
titleEditedByUser: parsed.titleEditedByUser,
summaryEditedByUser: parsed.summaryEditedByUser,
aiProvider: parsed.aiProvider,
aiGeneratedAt: parsed.aiGeneratedAt,
userIntent: parsed.userIntent,
intentPromptedAt: parsed.intentPromptedAt,
tags: parsed.tags,
status: parsed.status,
statusChangedAt: parsed.statusChangedAt,
moveReason: parsed.moveReason,
dueDate: parsed.dueDate,
dueDateEditedByUser: parsed.dueDateEditedByUser
});
if (r.status !== 'skipped') changedCount += 1;
}
return { changedCount };
}
private async scanNotes(sourceDir: string): Promise<string[]> {
const notesDir = join(sourceDir, 'notes');
let entries: string[];

View File

@@ -13,7 +13,11 @@ const SettingsSchema = z.object({
// true 로 fallback (기본 enabled). zod default 는 file 이 존재 + 키 부재일 때만 적용 —
// load() 의 file-missing 분기에선 cache={} 라 isAiEnabled() 의 fallback 이 작동.
ai_enabled: z.boolean().optional(),
onboarding_completed: z.boolean().optional()
onboarding_completed: z.boolean().optional(),
// v0.3.0 Cut E — 양방향 git sync 설정. 모두 optional — 미구성 시 sync 비활성.
sync_repo_url: z.string().nullable().optional(),
sync_auto_enabled: z.boolean().optional(),
sync_interval_min: z.number().int().min(5).optional()
}).strict();
export type Settings = z.infer<typeof SettingsSchema>;
@@ -81,6 +85,48 @@ export class SettingsService {
await this.persist(next);
}
/**
* v0.3.0 Cut E — sync 저장소 URL. null/빈 문자열 = sync 비활성. 본 메서드는 값만 저장,
* git init/remote add 는 별도 호출자 (settings:configure-sync IPC) 가 담당.
*/
async getSyncRepoUrl(): Promise<string | null> {
const s = await this.load();
return s.sync_repo_url ?? null;
}
async setSyncRepoUrl(value: string | null): Promise<void> {
const current = await this.load();
const next: Settings = { ...current, sync_repo_url: value };
await this.persist(next);
}
/** v0.3.0 Cut E — 자동 주기 sync 활성. configured 일 때만 의미 있음. 기본 true. */
async isAutoSyncEnabled(): Promise<boolean> {
const s = await this.load();
return s.sync_auto_enabled ?? true;
}
async setAutoSyncEnabled(value: boolean): Promise<void> {
const current = await this.load();
const next: Settings = { ...current, sync_auto_enabled: value };
await this.persist(next);
}
/** v0.3.0 Cut E — 자동 주기 sync interval (분). 기본 30, min 5. */
async getSyncIntervalMin(): Promise<number> {
const s = await this.load();
return s.sync_interval_min ?? 30;
}
async setSyncIntervalMin(value: number): Promise<void> {
if (!Number.isInteger(value) || value < 5) {
throw new Error(`sync_interval_min must be an integer >= 5 (got ${value})`);
}
const current = await this.load();
const next: Settings = { ...current, sync_interval_min: value };
await this.persist(next);
}
private async persist(next: Settings): Promise<void> {
await mkdir(dirname(this.filePath), { recursive: true });
const tmpPath = this.filePath + '.tmp';

View File

@@ -1,22 +1,41 @@
import { join } from 'node:path';
import { mkdir } from 'node:fs/promises';
import type { ExportService } from './ExportService.js';
import type { ImportService } from './ImportService.js';
import { GitClient } from './GitClient.js';
/**
* Cut E final review fix: 'noteId' was misleading — F5 export filenames are
* `<date>-<id8>-<slug>.md` (composeFilename), not `<uuid>.md`. The git checkout /
* resolve operations use the FULL relative path (e.g., `notes/2026-05-09-abc12345-회의.md`).
* `path` matches what we actually pass to `git checkout --ours/theirs`.
*/
export interface SyncConflict {
path: string;
localText: string;
remoteText: string;
}
export interface SyncStatus {
ok: boolean;
reason?: string; // why the sync was skipped or failed
changed?: boolean; // true if a new commit was created
sha?: string | null;
reason?: string;
changed?: boolean;
localSha?: string | null;
pushed?: boolean;
importedCount?: number;
conflicts?: SyncConflict[];
}
export class SyncService {
private syncDir: string;
private lastConflicts: SyncConflict[] = [];
private lastResult: SyncStatus | null = null;
private lastAt: string | null = null;
constructor(
private profileDir: string,
private exportSvc: ExportService,
private importSvc: ImportService,
private now: () => Date = () => new Date()
) {
this.syncDir = join(profileDir, 'sync');
@@ -33,31 +52,151 @@ export class SyncService {
return true;
}
getLastStatus(): { lastAt: string | null; lastResult: SyncStatus | null } {
return { lastAt: this.lastAt, lastResult: this.lastResult };
}
listConflicts(): SyncConflict[] {
return this.lastConflicts;
}
async sync(): Promise<SyncStatus> {
if (!(await this.isConfigured())) {
return { ok: false, reason: 'not_configured' };
const result = await this.runSync();
this.lastResult = result;
this.lastAt = this.now().toISOString();
if (result.reason === 'conflict' && result.conflicts) {
this.lastConflicts = result.conflicts;
} else if (result.ok) {
this.lastConflicts = [];
}
// 1. Re-export the full tree into syncDir (idempotent).
return result;
}
/**
* v0.3.0 Cut E — conflict 해결. local/remote 2 choice (both deferred to v0.3.1+).
* 사용자가 ConflictModal 에서 선택 → IPC → 본 메서드. 각 conflict 의 path 별 호출.
*
* - 'local' = 내 것 사용 (origin 변경 폐기) → git checkout --ours
* - 'remote' = 원격 사용 → git checkout --theirs + applySyncFromDir (local DB 갱신)
*
* 모든 conflict 해결 후 rebase --continue 가 성공 → push.
* UI 가 여러 conflict 를 loop 호출하면 마지막 호출에서 push 까지 완료.
*
* Cut E final review fix: 파라미터를 path 로 변경 (옛 noteId 는 export filename slug,
* UUID 아님 — 혼동 회피).
*/
async resolveConflict(
path: string,
choice: 'local' | 'remote'
): Promise<{ ok: true } | { ok: false; reason: string }> {
const git = new GitClient(this.syncDir);
const flag = choice === 'local' ? '--ours' : '--theirs';
const checkout = await git.run(['checkout', flag, path]);
if (checkout.exitCode !== 0) {
return { ok: false, reason: `checkout failed: ${checkout.stderr}` };
}
await git.addAll();
const cont = await git.run(['rebase', '--continue']);
if (cont.exitCode !== 0) {
// Likely other unresolved files — UI will call resolveConflict for them.
return { ok: false, reason: `rebase --continue failed: ${cont.stderr}` };
}
if (choice === 'remote') {
try {
await this.importSvc.applySyncFromDir(this.syncDir);
} catch (e) {
return { ok: false, reason: `re-import failed: ${(e as Error).message}` };
}
}
try {
await git.push();
} catch (e) {
return { ok: false, reason: `push failed: ${(e as Error).message}` };
}
// Remove this path from cached conflicts list
this.lastConflicts = this.lastConflicts.filter((c) => c.path !== path);
return { ok: true };
}
private async runSync(): Promise<SyncStatus> {
if (!(await this.isConfigured())) return { ok: false, reason: 'not_configured' };
const git = new GitClient(this.syncDir);
// 1. local export
try {
await mkdir(this.syncDir, { recursive: true });
await this.exportSvc.export(this.syncDir, { includeMedia: true });
} catch (e) {
return { ok: false, reason: `export failed: ${(e as Error).message}` };
}
// 2. git add + commit + push
const git = new GitClient(this.syncDir);
// 2. local commit (only if changed)
let localSha: string | null = null;
let localChanged = false;
try {
await git.addAll();
const ts = this.now().toISOString();
const message = `chore(notes): sync ${ts}`;
const commit = await git.commit(message);
if (!commit.changed) {
return { ok: true, changed: false, pushed: false };
localChanged = await git.hasUncommittedChanges();
if (localChanged) {
const c = await git.commit(`chore(notes): sync ${this.now().toISOString()}`);
localSha = c.sha;
}
await git.push();
return { ok: true, changed: true, sha: commit.sha, pushed: true };
} catch (e) {
return { ok: false, reason: (e as Error).message };
return { ok: false, reason: `local commit failed: ${(e as Error).message}` };
}
// 3. fetch
const fetchR = await git.fetch();
if (fetchR.exitCode !== 0) return { ok: false, reason: `fetch failed: ${fetchR.stderr}` };
// 4. rebase — skip if origin/main doesn't exist yet (first-push, empty remote)
const hasOriginMain = await git.refExists('origin/main');
if (hasOriginMain) {
const rebaseR = await git.rebaseOnto('origin/main');
if (rebaseR.exitCode !== 0) {
const files = await git.listConflicts();
// Cut E final review fix — populate localText/remoteText from rebase index
// BEFORE aborting. `git show :2:<path>` = ours (local during rebase),
// `:3:<path>` = theirs (remote being applied). UI shows side-by-side diff.
const conflicts: SyncConflict[] = [];
for (const path of files) {
const ours = await git.run(['show', `:2:${path}`]);
const theirs = await git.run(['show', `:3:${path}`]);
conflicts.push({
path,
localText: ours.exitCode === 0 ? ours.stdout : '',
remoteText: theirs.exitCode === 0 ? theirs.stdout : ''
});
}
await git.rebaseAbort();
return { ok: false, reason: 'conflict', conflicts };
}
}
// 5. re-import
let importedCount = 0;
try {
const r = await this.importSvc.applySyncFromDir(this.syncDir);
importedCount = r.changedCount;
} catch (e) {
return { ok: false, reason: `re-import failed: ${(e as Error).message}` };
}
// 6. push
try {
await git.push();
} catch (e) {
return { ok: false, reason: `push failed: ${(e as Error).message}` };
}
return { ok: true, changed: localChanged || importedCount > 0, localSha, importedCount, pushed: true };
}
}

View File

@@ -0,0 +1,49 @@
import type { SyncService } from './SyncService.js';
import type { SettingsService } from './SettingsService.js';
/**
* v0.3.0 Cut E — 자동 주기 sync timer.
*
* - start: settings 의 auto enabled + repo URL 모두 갖춰져야 시작
* - reconfigure: settings 변경 시 stop + start (새 interval 적용)
* - stop: clearInterval (idempotent)
*
* sync 결과는 무시 (interval mode = silent). conflict 발생 시 다음 manual sync /
* 충돌 UI 진입 시 처리됨 — 사용자가 settings 페이지의 SyncSection 에서 확인 가능.
*/
export class SyncTimer {
private handle: NodeJS.Timeout | null = null;
constructor(
private syncSvc: SyncService,
private settings: SettingsService
) {}
async start(): Promise<void> {
if (this.handle !== null) return; // idempotent
const enabled = await this.settings.isAutoSyncEnabled();
if (!enabled) return;
const url = await this.settings.getSyncRepoUrl();
if (url === null || url.trim().length === 0) return;
const intervalMin = await this.settings.getSyncIntervalMin();
const ms = Math.max(5, intervalMin) * 60 * 1000;
this.handle = setInterval(() => {
void this.syncSvc.sync().catch(() => {
// silent — interval mode 의 실패는 다음 attempt 또는 사용자 manual 호출이 처리
});
}, ms);
}
stop(): void {
if (this.handle !== null) {
clearInterval(this.handle);
this.handle = null;
}
}
/** settings 변경 시 호출 — 현재 interval stop 후 새 값으로 start. */
async reconfigure(): Promise<void> {
this.stop();
await this.start();
}
}

View File

@@ -29,6 +29,13 @@ export interface ExportNote {
aiGeneratedAt: string | null;
userIntent: string | null;
intentPromptedAt: string | null;
// v0.3.0 Cut E — Cut B (status), Cut C (dueDate via m002), and dueDate user-edited flag
// need to round-trip through F5 export and Cut E sync.
status: 'active' | 'completed' | 'archived' | 'trashed';
statusChangedAt: string | null;
moveReason: string | null;
dueDate: string | null;
dueDateEditedByUser: boolean;
tags: ExportNoteTag[];
media: ExportNoteMedia[];
}
@@ -155,6 +162,18 @@ export function composeFrontmatter(note: ExportNote): string {
lines.push(`ai_generated_at: ${note.aiGeneratedAt}`);
}
lines.push(`status: ${note.status}`);
if (note.statusChangedAt !== null) {
lines.push(`status_changed_at: ${note.statusChangedAt}`);
}
if (note.moveReason !== null) {
lines.push(`move_reason: ${formatScalar(note.moveReason)}`);
}
if (note.dueDate !== null) {
lines.push(`due_date: ${note.dueDate}`);
lines.push(`due_date_source: ${note.dueDateEditedByUser ? 'user' : 'ai'}`);
}
if (note.media.length > 0) {
lines.push('images:');
for (const m of note.media) {

View File

@@ -34,6 +34,13 @@ export interface ParsedNote {
userIntent: string | null;
intentPromptedAt: string | null;
deletedAt: string | null; // 신규 v0.2.3 #4
// v0.3.0 Cut E — round-trip status / due_date / move_reason from frontmatter.
// Default to 'active' / null / false when absent (older exports pre-Cut E).
status: 'active' | 'completed' | 'archived' | 'trashed';
statusChangedAt: string | null;
moveReason: string | null;
dueDate: string | null;
dueDateEditedByUser: boolean;
tags: ParsedNoteTag[];
images: ParsedNoteImage[];
exportVersion: number;
@@ -335,6 +342,13 @@ export function parseExportNote(markdown: string): ParsedNote {
const versionRaw = get('inkling_export_version');
const exportVersion = versionRaw === null ? 0 : Number.parseInt(versionRaw, 10) || 0;
const statusRaw = get('status');
const validStatuses = ['active', 'completed', 'archived', 'trashed'] as const;
const status = (validStatuses as readonly string[]).includes(statusRaw ?? 'active')
? ((statusRaw ?? 'active') as ParsedNote['status'])
: 'active';
const dueDateSource = get('due_date_source');
return {
id,
createdAt,
@@ -349,6 +363,11 @@ export function parseExportNote(markdown: string): ParsedNote {
userIntent: get('user_intent'),
intentPromptedAt: get('intent_prompted_at'),
deletedAt: get('deleted_at'),
status,
statusChangedAt: get('status_changed_at'),
moveReason: get('move_reason'),
dueDate: get('due_date'),
dueDateEditedByUser: dueDateSource === 'user',
tags: fm.tags,
images: fm.images,
exportVersion

View File

@@ -85,6 +85,18 @@ const api: InklingApi = {
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),
// v0.3.0 Cut E — 양방향 sync.
configureSync: (url: string | null) => ipcRenderer.invoke('settings:configure-sync', url),
testSyncConnection: () => ipcRenderer.invoke('settings:test-sync-connection'),
listConflicts: () => ipcRenderer.invoke('sync:list-conflicts'),
resolveConflict: (path: string, choice: 'local' | 'remote') =>
ipcRenderer.invoke('sync:resolve-conflict', path, choice),
getSyncStatus: () => ipcRenderer.invoke('sync:get-status'),
setSyncAutoEnabled: (value: boolean) => ipcRenderer.invoke('settings:set-sync-auto-enabled', value),
setSyncIntervalMin: (value: number) => ipcRenderer.invoke('settings:set-sync-interval-min', value),
}
};

View File

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

View File

@@ -0,0 +1,112 @@
import React, { useEffect, useState } from 'react';
import type { SyncConflict } from '@shared/types';
import { inboxApi } from '../api.js';
interface Props {
onClose: () => void;
onResolved: () => 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: 600,
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
};
export function ConflictModal({ onClose, onResolved }: Props): React.ReactElement {
const [conflicts, setConflicts] = useState<SyncConflict[]>([]);
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
void (async () => {
const c = await inboxApi.listConflicts();
if (!cancelled) setConflicts(c);
})();
return () => { cancelled = true; };
}, []);
async function onChoose(path: string, choice: 'local' | 'remote') {
setBusy(path);
setError(null);
const r = await inboxApi.resolveConflict(path, choice);
setBusy(null);
if (!r.ok) {
setError(`해결 실패: ${r.reason}`);
return;
}
const next = conflicts.filter((c) => c.path !== path);
setConflicts(next);
if (next.length === 0) {
onResolved();
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 }}> ({conflicts.length})</h3>
<button onClick={onClose} aria-label="닫기" style={{ background: 'none', border: 'none', fontSize: 18, cursor: 'pointer', color: '#888' }}>×</button>
</div>
{error !== null && <div style={{ marginTop: 10, fontSize: 12, color: '#c93030' }}>{error}</div>}
{conflicts.map((c) => (
<div key={c.path} style={rowStyle}>
<div style={{ fontSize: 12, color: '#888', marginBottom: 6 }}>{c.path}</div>
<div style={{ display: 'flex', gap: 12 }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 11, color: '#666', marginBottom: 4 }}> </div>
<pre style={preStyle()}>{c.localText || '(미리보기 없음)'}</pre>
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 11, color: '#666', marginBottom: 4 }}> </div>
<pre style={preStyle()}>{c.remoteText || '(미리보기 없음)'}</pre>
</div>
</div>
<div style={{ marginTop: 8, display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button
onClick={() => { void onChoose(c.path, 'local'); }}
disabled={busy === c.path}
style={chooseBtnStyle('#0a4b80')}
>
{busy === c.path ? '처리 중…' : '내 것 사용'}
</button>
<button
onClick={() => { void onChoose(c.path, 'remote'); }}
disabled={busy === c.path}
style={chooseBtnStyle('#236b1a')}
>
{busy === c.path ? '처리 중…' : '원격 사용'}
</button>
</div>
</div>
))}
</div>
</div>
);
}
function preStyle(): React.CSSProperties {
return {
margin: 0, whiteSpace: 'pre-wrap', fontSize: 11, color: '#444',
background: '#fafafa', padding: 6, borderRadius: 3, maxHeight: 120, overflow: 'auto'
};
}
function chooseBtnStyle(color: string): React.CSSProperties {
return {
background: 'none', border: `1px solid ${color}`, color, cursor: 'pointer',
fontSize: 12, padding: '4px 10px', borderRadius: 4
};
}

View 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>
);
}

View 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
}}
/>
);
}

View File

@@ -4,6 +4,7 @@ import { AiProviderSection } from './settings/AiProviderSection.js';
import { AutostartSection } from './settings/AutostartSection.js';
import { BackupSection } from './settings/BackupSection.js';
import { InfoSection } from './settings/InfoSection.js';
import { SyncSection } from './settings/SyncSection.js';
export function SettingsPage(): React.ReactElement {
const setShowSettings = useInbox((s) => s.setShowSettings);
@@ -40,6 +41,10 @@ export function SettingsPage(): React.ReactElement {
<h2 style={{ fontSize: 14, marginBottom: 8 }}></h2>
<InfoSection />
</section>
<section style={{ marginBottom: 24 }}>
<h2 style={{ fontSize: 14, marginBottom: 8 }}></h2>
<SyncSection />
</section>
</div>
);
}

View File

@@ -0,0 +1,150 @@
import React, { useEffect, useState } from 'react';
import { inboxApi } from '../../api.js';
import type { SyncStatusSnapshot } from '@shared/types';
import { ConflictModal } from '../ConflictModal.js';
export function SyncSection(): React.ReactElement {
const [url, setUrl] = useState('');
const [draftUrl, setDraftUrl] = useState('');
const [autoEnabled, setAutoEnabled] = useState(true);
const [intervalMin, setIntervalMin] = useState(30);
const [status, setStatus] = useState<SyncStatusSnapshot | null>(null);
const [busy, setBusy] = useState<'save' | 'test' | 'sync' | null>(null);
const [feedback, setFeedback] = useState<string | null>(null);
const [showConflict, setShowConflict] = useState(false);
useEffect(() => {
void (async () => {
const s = await inboxApi.getSettings();
const u = s.sync_repo_url ?? '';
setUrl(u);
setDraftUrl(u);
setAutoEnabled(s.sync_auto_enabled ?? true);
setIntervalMin(s.sync_interval_min ?? 30);
setStatus(await inboxApi.getSyncStatus());
})();
}, []);
async function onSaveUrl() {
setBusy('save');
setFeedback(null);
const r = await inboxApi.configureSync(draftUrl.trim() === '' ? null : draftUrl.trim());
setBusy(null);
if (r.ok) {
setUrl(draftUrl.trim());
setFeedback('저장되었습니다');
} else {
setFeedback(`저장 실패: ${r.reason}`);
}
}
async function onTestConnection() {
setBusy('test');
setFeedback(null);
const r = await inboxApi.testSyncConnection();
setBusy(null);
setFeedback(r.ok ? '연결 성공' : `연결 실패: ${r.reason}`);
}
async function onToggleAuto(next: boolean) {
await inboxApi.setSyncAutoEnabled(next);
setAutoEnabled(next);
}
async function onChangeInterval(value: number) {
if (!Number.isInteger(value) || value < 5) return;
const r = await inboxApi.setSyncIntervalMin(value);
if (r.ok) setIntervalMin(value);
}
const conflictCount = status?.lastResult?.conflicts?.length ?? 0;
return (
<section style={{ marginTop: 24 }}>
<h3 style={{ fontSize: 14, marginBottom: 8 }}> </h3>
<div style={{ display: 'flex', gap: 6, marginBottom: 8 }}>
<input
type="text"
aria-label="저장소 URL"
placeholder="git@host:user/inkling-notes.git"
value={draftUrl}
onChange={(e) => setDraftUrl(e.target.value)}
style={{ flex: 1, fontSize: 12, padding: '4px 8px', border: '1px solid #ccc', borderRadius: 4 }}
/>
<button onClick={() => { void onSaveUrl(); }} disabled={busy !== null} style={btnStyle()}>
{busy === 'save' ? '저장 중…' : '저장'}
</button>
<button onClick={() => { void onTestConnection(); }} disabled={busy !== null || url.trim() === ''} style={btnStyle()}>
{busy === 'test' ? '확인 중…' : '연결 테스트'}
</button>
</div>
{feedback !== null && (
<div style={{ fontSize: 12, color: '#444', marginBottom: 8 }}>{feedback}</div>
)}
{url.trim() !== '' && (
<>
<div style={{ fontSize: 12, color: '#666', marginBottom: 8 }}>
sync: {status?.lastAt ?? '없음'} {status?.lastResult?.ok === false && status?.lastResult?.reason !== 'conflict' && (
<span style={{ color: '#a55' }}> ({status.lastResult.reason})</span>
)}
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, marginBottom: 6 }}>
<input
type="checkbox"
checked={autoEnabled}
onChange={(e) => { void onToggleAuto(e.target.checked); }}
/>
sync
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, marginBottom: 8 }}>
interval:
<input
type="number"
aria-label="sync interval (분)"
min={5}
value={intervalMin}
onChange={(e) => { void onChangeInterval(Number.parseInt(e.target.value, 10)); }}
disabled={!autoEnabled}
style={{ width: 60, fontSize: 12, padding: '2px 4px' }}
/>
</label>
{conflictCount > 0 && (
<div style={{ marginTop: 8 }}>
<button onClick={() => setShowConflict(true)} style={btnStyle()}>
({conflictCount})
</button>
</div>
)}
{showConflict && (
<ConflictModal
onClose={() => setShowConflict(false)}
onResolved={async () => {
setStatus(await inboxApi.getSyncStatus());
}}
/>
)}
</>
)}
</section>
);
}
function btnStyle(): React.CSSProperties {
return {
background: '#0a4b80',
color: '#fff',
border: 'none',
cursor: 'pointer',
fontSize: 12,
padding: '4px 10px',
borderRadius: 4
};
}

View File

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

View File

@@ -31,6 +31,40 @@ export interface NoteRevision {
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 };
}
// v0.3.0 Cut E — 양방향 sync 결과 + conflict.
// `path` = git index 의 conflict 파일 상대경로 (예: 'notes/2026-05-09-abc12345-회의.md').
// F5 export 의 filename 은 date-id8-slug 패턴 — UUID 가 아니라 path 가 맞는 식별자.
export interface SyncConflict {
path: string;
localText: string;
remoteText: string;
}
export interface SyncStatus {
ok: boolean;
reason?: string;
changed?: boolean;
localSha?: string | null;
pushed?: boolean;
importedCount?: number;
conflicts?: SyncConflict[];
}
export interface SyncStatusSnapshot {
lastAt: string | null;
lastResult: SyncStatus | null;
nextAt: string | null;
}
export interface Note {
id: string;
rawText: string;
@@ -160,6 +194,9 @@ export interface InboxApi {
ollama?: { endpoint: string; model: string };
ai_enabled?: boolean;
onboarding_completed?: boolean;
sync_repo_url?: string | null;
sync_auto_enabled?: boolean;
sync_interval_min?: number;
}>;
setAiEnabled(enabled: boolean): Promise<{ ok: true }>;
setOnboardingCompleted(completed: boolean): Promise<{ ok: true }>;
@@ -170,6 +207,17 @@ export interface InboxApi {
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>;
// v0.3.0 Cut E — 양방향 sync.
configureSync(url: string | null): Promise<{ ok: true } | { ok: false; reason: string }>;
testSyncConnection(): Promise<{ ok: true } | { ok: false; reason: string }>;
listConflicts(): Promise<SyncConflict[]>;
resolveConflict(path: string, choice: 'local' | 'remote'): Promise<{ ok: true } | { ok: false; reason: string }>;
getSyncStatus(): Promise<SyncStatusSnapshot>;
setSyncAutoEnabled(enabled: boolean): Promise<{ ok: true }>;
setSyncIntervalMin(value: number): Promise<{ ok: true } | { ok: false; reason: string }>;
}
export interface InklingApi {

View File

@@ -56,7 +56,13 @@ vi.mock('../../src/renderer/inbox/api.js', () => ({
setOnboardingCompleted: vi.fn(async () => ({ ok: true as const })),
// v0.2.9 Cut B Task 16 — AiProviderSection 가 SettingsPage 렌더 시 호출.
getDisabledCount: vi.fn(async () => 0),
enqueueDisabled: vi.fn(async () => ({ count: 0 }))
enqueueDisabled: vi.fn(async () => ({ count: 0 })),
// v0.3.0 Cut E — SyncSection 이 SettingsPage 에 마운트되어 호출.
getSyncStatus: vi.fn(async () => ({ lastAt: null, lastResult: null, nextAt: null })),
setSyncAutoEnabled: vi.fn(async () => ({ ok: true as const })),
setSyncIntervalMin: vi.fn(async () => ({ ok: true as const })),
configureSync: vi.fn(async () => ({ ok: true as const })),
testSyncConnection: vi.fn(async () => ({ ok: true as const }))
}
}));

View File

@@ -0,0 +1,61 @@
// @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 { mockListConflicts, mockResolveConflict } = vi.hoisted(() => ({
mockListConflicts: vi.fn(),
mockResolveConflict: vi.fn()
}));
vi.mock('../../src/renderer/inbox/api.js', () => ({
inboxApi: { listConflicts: mockListConflicts, resolveConflict: mockResolveConflict }
}));
import { ConflictModal } from '../../src/renderer/inbox/components/ConflictModal';
describe('ConflictModal', () => {
beforeEach(() => {
vi.clearAllMocks();
cleanup();
mockListConflicts.mockResolvedValue([
{ path: 'notes/n1.md', localText: 'local A', remoteText: 'remote A' },
{ path: 'notes/n2.md', localText: 'local B', remoteText: 'remote B' }
]);
mockResolveConflict.mockResolvedValue({ ok: true });
});
it('open 시 listConflicts 호출 + 양 conflict preview 표시', async () => {
render(<ConflictModal onClose={() => {}} onResolved={() => {}} />);
await waitFor(() => screen.getByText(/local A/));
expect(screen.getByText(/local A/)).toBeInTheDocument();
expect(screen.getByText(/remote A/)).toBeInTheDocument();
expect(screen.getByText(/local B/)).toBeInTheDocument();
// path 가 표시됨 (Cut E final review fix — noteId → path)
expect(screen.getByText('notes/n1.md')).toBeInTheDocument();
});
it('내 것 사용 클릭 → resolveConflict(path, "local") 호출', async () => {
render(<ConflictModal onClose={() => {}} onResolved={() => {}} />);
await waitFor(() => screen.getByText(/local A/));
const buttons = screen.getAllByRole('button', { name: /내 것 사용/ });
fireEvent.click(buttons[0]!);
await waitFor(() => {
expect(mockResolveConflict).toHaveBeenCalledWith('notes/n1.md', 'local');
});
});
it('마지막 conflict 해결 → onResolved + onClose 호출', async () => {
mockListConflicts.mockResolvedValueOnce([{ path: 'notes/n1.md', localText: 'a', remoteText: 'b' }]);
const onResolved = vi.fn();
const onClose = vi.fn();
render(<ConflictModal onClose={onClose} onResolved={onResolved} />);
await waitFor(() => screen.getByRole('button', { name: /원격 사용/ }));
fireEvent.click(screen.getByRole('button', { name: /원격 사용/ }));
await waitFor(() => {
expect(onResolved).toHaveBeenCalled();
expect(onClose).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,51 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { GitClient } from '../../src/main/services/GitClient.js';
describe('GitClient — fetch / rebase / conflict 메서드', () => {
let client: GitClient;
let runSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
client = new GitClient('/tmp/sync');
runSpy = vi.spyOn(client, 'run');
});
it('fetch — git fetch origin 호출', async () => {
runSpy.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 });
const r = await client.fetch();
expect(runSpy).toHaveBeenCalledWith(['fetch', 'origin']);
expect(r.exitCode).toBe(0);
});
it('rebaseOnto — git rebase origin/main', async () => {
runSpy.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 });
const r = await client.rebaseOnto('origin/main');
expect(runSpy).toHaveBeenCalledWith(['rebase', 'origin/main']);
expect(r.exitCode).toBe(0);
});
it('rebaseAbort — git rebase --abort', async () => {
runSpy.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 });
await client.rebaseAbort();
expect(runSpy).toHaveBeenCalledWith(['rebase', '--abort']);
});
it('hasUncommittedChanges — git status --porcelain 의 출력 있으면 true', async () => {
runSpy.mockResolvedValueOnce({ stdout: ' M notes/abc.md\n', stderr: '', exitCode: 0 });
expect(await client.hasUncommittedChanges()).toBe(true);
runSpy.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 });
expect(await client.hasUncommittedChanges()).toBe(false);
});
it('listConflicts — git diff --name-only --diff-filter=U 결과 파싱', async () => {
runSpy.mockResolvedValueOnce({
stdout: 'notes/aaa.md\nnotes/bbb.md\n',
stderr: '',
exitCode: 0
});
const r = await client.listConflicts();
expect(runSpy).toHaveBeenCalledWith(['diff', '--name-only', '--diff-filter=U']);
expect(r).toEqual(['notes/aaa.md', 'notes/bbb.md']);
});
});

View File

@@ -0,0 +1,106 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import Database from 'better-sqlite3';
import { mkdtemp, writeFile, mkdir, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { runMigrations } from '@main/db/migrations/index.js';
import { NoteRepository } from '@main/repository/NoteRepository.js';
import { ImportService } from '@main/services/ImportService.js';
import { MediaStore } from '@main/services/MediaStore.js';
describe('ImportService.applySyncFromDir', () => {
let db: Database.Database;
let repo: NoteRepository;
let svc: ImportService;
let workDir: string;
beforeEach(async () => {
db = new Database(':memory:');
db.pragma('foreign_keys = ON');
runMigrations(db);
repo = new NoteRepository(db);
workDir = await mkdtemp(join(tmpdir(), 'inkling-sync-'));
const mediaStore = new MediaStore(workDir);
svc = new ImportService(repo, mediaStore);
});
afterEach(async () => {
db.close();
await rm(workDir, { recursive: true, force: true });
});
it('inserts new notes and reports changedCount', async () => {
const notesDir = join(workDir, 'notes');
await mkdir(notesDir, { recursive: true });
await writeFile(
join(notesDir, 'a.md'),
`---\nid: 00000000-0000-0000-0000-000000000001\ncreated_at: 2026-05-09T00:00:00Z\nupdated_at: 2026-05-10T00:00:00Z\ntitle: title\ntitle_source: ai\nsummary: summary\nsummary_source: ai\nstatus: active\ninkling_export_version: 1\n---\n\n# title\n\n> summary\n\nbody\n`
);
const r = await svc.applySyncFromDir(workDir);
expect(r.changedCount).toBe(1);
const note = repo.findById('00000000-0000-0000-0000-000000000001');
expect(note?.rawText).toBe('body');
});
it('skips unchanged notes (no changedCount increment)', async () => {
const created = repo.create({ rawText: 'body' });
db.prepare(`UPDATE notes SET updated_at=? WHERE id=?`).run('2026-05-15T00:00:00Z', created.id);
const notesDir = join(workDir, 'notes');
await mkdir(notesDir, { recursive: true });
await writeFile(
join(notesDir, 'a.md'),
`---\nid: ${created.id}\ncreated_at: 2026-05-09T00:00:00Z\nupdated_at: 2026-05-10T00:00:00Z\ntitle: t\ntitle_source: ai\nsummary: s\nsummary_source: ai\nstatus: active\ninkling_export_version: 1\n---\n\n# t\n\n> s\n\nbody\n`
);
const r = await svc.applySyncFromDir(workDir);
expect(r.changedCount).toBe(0);
});
it('returns changedCount=0 for an empty notes directory', async () => {
const notesDir = join(workDir, 'notes');
await mkdir(notesDir, { recursive: true });
const r = await svc.applySyncFromDir(workDir);
expect(r.changedCount).toBe(0);
});
it('updates a note when source updatedAt is newer', async () => {
const created = repo.create({ rawText: 'old body' });
db.prepare(`UPDATE notes SET updated_at=? WHERE id=?`).run('2026-05-01T00:00:00Z', created.id);
const notesDir = join(workDir, 'notes');
await mkdir(notesDir, { recursive: true });
await writeFile(
join(notesDir, 'a.md'),
`---\nid: ${created.id}\ncreated_at: 2026-05-09T00:00:00Z\nupdated_at: 2026-05-10T00:00:00Z\ntitle: t\ntitle_source: ai\nsummary: s\nsummary_source: ai\nstatus: active\ninkling_export_version: 1\n---\n\n# t\n\n> s\n\nnew body\n`
);
const r = await svc.applySyncFromDir(workDir);
expect(r.changedCount).toBe(1);
const note = repo.findById(created.id);
expect(note?.rawText).toBe('new body');
});
it('preserves status field from frontmatter', async () => {
const notesDir = join(workDir, 'notes');
await mkdir(notesDir, { recursive: true });
await writeFile(
join(notesDir, 'a.md'),
`---\nid: 00000000-0000-0000-0000-000000000002\ncreated_at: 2026-05-09T00:00:00Z\nupdated_at: 2026-05-10T00:00:00Z\ntitle: t\ntitle_source: ai\nsummary: s\nsummary_source: ai\nstatus: archived\nstatus_changed_at: 2026-05-08T00:00:00Z\nmove_reason: done\ninkling_export_version: 1\n---\n\n# t\n\n> s\n\nbody\n`
);
await svc.applySyncFromDir(workDir);
const note = repo.findById('00000000-0000-0000-0000-000000000002');
expect(note?.status).toBe('archived');
expect(note?.statusChangedAt).toBe('2026-05-08T00:00:00Z');
expect(note?.moveReason).toBe('done');
});
it('preserves dueDate from frontmatter', async () => {
const notesDir = join(workDir, 'notes');
await mkdir(notesDir, { recursive: true });
await writeFile(
join(notesDir, 'a.md'),
`---\nid: 00000000-0000-0000-0000-000000000003\ncreated_at: 2026-05-09T00:00:00Z\nupdated_at: 2026-05-10T00:00:00Z\ntitle: t\ntitle_source: ai\nsummary: s\nsummary_source: ai\nstatus: active\ndue_date: 2026-06-01\ndue_date_source: user\ninkling_export_version: 1\n---\n\n# t\n\n> s\n\nbody\n`
);
await svc.applySyncFromDir(workDir);
const note = repo.findById('00000000-0000-0000-0000-000000000003');
expect(note?.dueDate).toBe('2026-06-01');
expect(note?.dueDateEditedByUser).toBe(true);
});
});

View 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 });
});
});

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

View File

@@ -1074,3 +1074,86 @@ describe('NoteRepository — note_revisions', () => {
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('결재');
});
});

View File

@@ -0,0 +1,98 @@
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';
const baseInput = {
id: '00000000-0000-0000-0000-000000000001',
rawText: 'sync 본문',
createdAt: '2026-05-09T00:00:00Z',
updatedAt: '2026-05-10T00:00:00Z',
aiTitle: 'sync 제목',
aiSummary: 'sync 요약',
titleEditedByUser: false,
summaryEditedByUser: false,
aiProvider: 'p',
aiGeneratedAt: '2026-05-10T00:00:00Z',
userIntent: null,
intentPromptedAt: null,
tags: [{ name: '동기', source: 'user' as const }],
status: 'active' as const,
statusChangedAt: null,
moveReason: null,
dueDate: null,
dueDateEditedByUser: false
};
describe('NoteRepository.upsertFromSync', () => {
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('id 없음 → INSERT (status=inserted) + capture revision + tags FTS sync', () => {
const r = repo.upsertFromSync(baseInput);
expect(r.status).toBe('inserted');
expect(r.id).toBe(baseInput.id);
const note = repo.findById(baseInput.id);
expect(note?.rawText).toBe('sync 본문');
expect(note?.aiTitle).toBe('sync 제목');
const revs = repo.listRevisions(baseInput.id);
expect(revs).toHaveLength(1);
expect(revs[0]!.editedBy).toBe('capture');
const fts = db.prepare(`SELECT tags FROM notes_fts WHERE note_id=?`).get(baseInput.id) as { tags: string };
expect(fts.tags).toBe('동기');
});
it('id 있음 + raw_text 동일 + source 더 최신 → metadata 갱신 (status=updated)', () => {
const created = repo.create({ rawText: 'sync 본문' });
repo.updateAiResult(created.id, { title: '옛 제목', summary: '옛 요약', tags: ['old'], provider: 'p' });
db.prepare(`UPDATE notes SET updated_at=? WHERE id=?`).run('2026-05-08T00:00:00Z', created.id);
const r = repo.upsertFromSync({ ...baseInput, id: created.id });
expect(r.status).toBe('updated');
const note = repo.findById(created.id);
expect(note?.aiTitle).toBe('sync 제목');
expect(note?.tags.map((t) => t.name)).toEqual(['동기']);
const fts = db.prepare(`SELECT tags FROM notes_fts WHERE note_id=?`).get(created.id) as { tags: string };
expect(fts.tags).toBe('동기');
});
it('id 있음 + raw_text 동일 + source 더 옛 → skip (status=skipped)', () => {
const created = repo.create({ rawText: 'sync 본문' });
repo.updateAiResult(created.id, { title: '신선한 제목', summary: 'fresh', tags: ['x'], provider: 'p' });
db.prepare(`UPDATE notes SET updated_at=? WHERE id=?`).run('2026-05-12T00:00:00Z', created.id);
const r = repo.upsertFromSync({ ...baseInput, id: created.id, updatedAt: '2026-05-10T00:00:00Z' });
expect(r.status).toBe('skipped');
const note = repo.findById(created.id);
expect(note?.aiTitle).toBe('신선한 제목');
});
it('id 있음 + raw_text 다름 + source 더 최신 → updateRawText (status=updated) + new user revision', () => {
const created = repo.create({ rawText: 'old text' });
db.prepare(`UPDATE notes SET updated_at=? WHERE id=?`).run('2026-05-08T00:00:00Z', created.id);
const r = repo.upsertFromSync({ ...baseInput, id: created.id, rawText: 'new sync text' });
expect(r.status).toBe('updated');
const note = repo.findById(created.id);
expect(note?.rawText).toBe('new sync text');
const revs = repo.listRevisions(created.id);
expect(revs).toHaveLength(2); // capture (old) + user (new)
expect(revs[0]!.editedBy).toBe('user');
expect(revs[0]!.rawText).toBe('new sync text');
});
it('id 있음 + raw_text 다름 + source 더 옛 → skip', () => {
const created = repo.create({ rawText: 'local fresh' });
db.prepare(`UPDATE notes SET updated_at=? WHERE id=?`).run('2026-05-15T00:00:00Z', created.id);
const r = repo.upsertFromSync({ ...baseInput, id: created.id, rawText: 'old sync text', updatedAt: '2026-05-10T00:00:00Z' });
expect(r.status).toBe('skipped');
const note = repo.findById(created.id);
expect(note?.rawText).toBe('local fresh');
});
});

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

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

View File

@@ -46,7 +46,13 @@ vi.mock('../../src/renderer/inbox/api.js', () => ({
setAiEnabled: vi.fn(async () => ({ ok: true as const })),
setOnboardingCompleted: vi.fn(async () => ({ ok: true as const })),
getDisabledCount: vi.fn(async () => 0),
enqueueDisabled: vi.fn(async () => ({ count: 0 }))
enqueueDisabled: vi.fn(async () => ({ count: 0 })),
// v0.3.0 Cut E — SyncSection 이 SettingsPage 에 마운트되어 호출.
getSyncStatus: vi.fn(async () => ({ lastAt: null, lastResult: null, nextAt: null })),
setSyncAutoEnabled: vi.fn(async () => ({ ok: true as const })),
setSyncIntervalMin: vi.fn(async () => ({ ok: true as const })),
configureSync: vi.fn(async () => ({ ok: true as const })),
testSyncConnection: vi.fn(async () => ({ ok: true as const }))
}
}));
@@ -64,12 +70,13 @@ describe('SettingsPage', () => {
expect(screen.getByRole('button', { name: /돌아가기/ })).toBeInTheDocument();
});
it('renders 4 section headings', () => {
it('renders 5 section headings', () => {
render(<SettingsPage />);
expect(screen.getByText('AI 제공자')).toBeInTheDocument();
expect(screen.getByText('자동 실행')).toBeInTheDocument();
expect(screen.getByText('백업 / 복원')).toBeInTheDocument();
expect(screen.getByText('정보')).toBeInTheDocument();
expect(screen.getByText('동기화')).toBeInTheDocument();
});
it('clicking "← 돌아가기" sets showSettings to false', () => {

View File

@@ -54,4 +54,40 @@ describe('SettingsService', () => {
expect(existsSync(join(dir, 'settings.json.tmp'))).toBe(false);
expect(existsSync(join(dir, 'settings.json'))).toBe(true);
});
describe('v0.3.0 Cut E — sync settings', () => {
it('getSyncRepoUrl() defaults to null', async () => {
expect(await svc.getSyncRepoUrl()).toBeNull();
});
it('setSyncRepoUrl() / getSyncRepoUrl() round-trip', async () => {
await svc.setSyncRepoUrl('git@gitea.example:user/notes.git');
expect(await svc.getSyncRepoUrl()).toBe('git@gitea.example:user/notes.git');
// setting null clears
await svc.setSyncRepoUrl(null);
expect(await svc.getSyncRepoUrl()).toBeNull();
});
it('isAutoSyncEnabled() defaults to true', async () => {
expect(await svc.isAutoSyncEnabled()).toBe(true);
});
it('setAutoSyncEnabled() persists', async () => {
await svc.setAutoSyncEnabled(false);
expect(await svc.isAutoSyncEnabled()).toBe(false);
await svc.setAutoSyncEnabled(true);
expect(await svc.isAutoSyncEnabled()).toBe(true);
});
it('getSyncIntervalMin() defaults to 30', async () => {
expect(await svc.getSyncIntervalMin()).toBe(30);
});
it('setSyncIntervalMin() persists + rejects values < 5 / non-integer', async () => {
await svc.setSyncIntervalMin(15);
expect(await svc.getSyncIntervalMin()).toBe(15);
await expect(svc.setSyncIntervalMin(3)).rejects.toThrow();
await expect(svc.setSyncIntervalMin(10.5)).rejects.toThrow();
});
});
});

View File

@@ -0,0 +1,75 @@
// @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 { mockGetSettings, mockConfigureSync, mockTestSyncConnection, mockGetSyncStatus, mockSetAuto, mockSetInterval } = vi.hoisted(() => ({
mockGetSettings: vi.fn(async () => ({ sync_repo_url: '', sync_auto_enabled: true, sync_interval_min: 30 })),
mockConfigureSync: vi.fn(async () => ({ ok: true as const })),
mockTestSyncConnection: vi.fn(async () => ({ ok: true as const })),
mockGetSyncStatus: vi.fn(async () => ({ lastAt: null, lastResult: null, nextAt: null })),
mockSetAuto: vi.fn(async () => ({ ok: true as const })),
mockSetInterval: vi.fn(async () => ({ ok: true as const }))
}));
vi.mock('../../src/renderer/inbox/api.js', () => ({
inboxApi: {
getSettings: mockGetSettings,
configureSync: mockConfigureSync,
testSyncConnection: mockTestSyncConnection,
getSyncStatus: mockGetSyncStatus,
setSyncAutoEnabled: mockSetAuto,
setSyncIntervalMin: mockSetInterval
}
}));
// ConflictModal is imported by SyncSection — mock it to avoid needing listConflicts
vi.mock('../../src/renderer/inbox/components/ConflictModal.js', () => ({
ConflictModal: () => null
}));
import { SyncSection } from '../../src/renderer/inbox/components/settings/SyncSection';
describe('SyncSection', () => {
beforeEach(() => {
vi.clearAllMocks();
cleanup();
mockGetSettings.mockResolvedValue({ sync_repo_url: '', sync_auto_enabled: true, sync_interval_min: 30 });
mockGetSyncStatus.mockResolvedValue({ lastAt: null, lastResult: null, nextAt: null });
});
it('빈 URL — 저장/연결 테스트 버튼 + 자동 sync 옵션 hide', async () => {
render(<SyncSection />);
await waitFor(() => screen.getByRole('button', { name: /저장/ }));
expect(screen.queryByText(/자동 sync/)).not.toBeInTheDocument();
});
it('URL 입력 + 저장 → configureSync 호출 + 자동 sync 옵션 표시', async () => {
mockGetSettings.mockResolvedValueOnce({ sync_repo_url: 'git@host:u/r.git', sync_auto_enabled: true, sync_interval_min: 30 });
render(<SyncSection />);
await waitFor(() => screen.getByText(/자동 sync/));
expect(screen.getByText(/자동 sync/)).toBeInTheDocument();
});
it('연결 테스트 클릭 → testSyncConnection 호출 + 결과 표시', async () => {
mockGetSettings.mockResolvedValueOnce({ sync_repo_url: 'git@host:u/r.git', sync_auto_enabled: true, sync_interval_min: 30 });
render(<SyncSection />);
await waitFor(() => screen.getByRole('button', { name: /연결 테스트/ }));
fireEvent.click(screen.getByRole('button', { name: /연결 테스트/ }));
await waitFor(() => {
expect(mockTestSyncConnection).toHaveBeenCalled();
expect(screen.getByText(/연결 성공/)).toBeInTheDocument();
});
});
it('자동 sync 토글 → setSyncAutoEnabled 호출', async () => {
mockGetSettings.mockResolvedValueOnce({ sync_repo_url: 'git@host:u/r.git', sync_auto_enabled: true, sync_interval_min: 30 });
render(<SyncSection />);
await waitFor(() => screen.getByLabelText(/자동 sync/));
fireEvent.click(screen.getByLabelText(/자동 sync/));
await waitFor(() => {
expect(mockSetAuto).toHaveBeenCalledWith(false);
});
});
});

View File

@@ -0,0 +1,107 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { SyncService } from '../../src/main/services/SyncService.js';
vi.mock('../../src/main/services/GitClient.js');
import { GitClient } from '../../src/main/services/GitClient.js';
describe('SyncService.sync — 양방향', () => {
let svc: SyncService;
let exportSvc: { export: ReturnType<typeof vi.fn> };
let importSvc: { applySyncFromDir: ReturnType<typeof vi.fn> };
let gitInstance: {
isRepo: ReturnType<typeof vi.fn>;
hasRemote: ReturnType<typeof vi.fn>;
addAll: ReturnType<typeof vi.fn>;
hasUncommittedChanges: ReturnType<typeof vi.fn>;
commit: ReturnType<typeof vi.fn>;
fetch: ReturnType<typeof vi.fn>;
refExists: ReturnType<typeof vi.fn>;
rebaseOnto: ReturnType<typeof vi.fn>;
rebaseAbort: ReturnType<typeof vi.fn>;
listConflicts: ReturnType<typeof vi.fn>;
push: ReturnType<typeof vi.fn>;
run: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
exportSvc = { export: vi.fn(async () => {}) };
importSvc = { applySyncFromDir: vi.fn(async () => ({ changedCount: 0 })) };
gitInstance = {
isRepo: vi.fn(async () => true),
hasRemote: vi.fn(async () => true),
addAll: vi.fn(async () => {}),
hasUncommittedChanges: vi.fn(async () => true),
commit: vi.fn(async () => ({ changed: true, sha: 'abc' })),
fetch: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })),
refExists: vi.fn(async () => true),
rebaseOnto: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })),
rebaseAbort: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })),
listConflicts: vi.fn(async () => []),
push: vi.fn(async () => {}),
run: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 }))
};
(GitClient as unknown as ReturnType<typeof vi.fn>).mockImplementation(function () { return gitInstance; });
svc = new SyncService(
'/tmp/profile',
exportSvc as unknown as never,
importSvc as unknown as never
);
});
it('happy path — 6단계 모두 호출, ok:true', async () => {
const r = await svc.sync();
expect(exportSvc.export).toHaveBeenCalled();
expect(gitInstance.addAll).toHaveBeenCalled();
expect(gitInstance.commit).toHaveBeenCalled();
expect(gitInstance.fetch).toHaveBeenCalled();
expect(gitInstance.rebaseOnto).toHaveBeenCalledWith('origin/main');
expect(importSvc.applySyncFromDir).toHaveBeenCalled();
expect(gitInstance.push).toHaveBeenCalled();
expect(r.ok).toBe(true);
expect(r.pushed).toBe(true);
});
it('local 변경 없음 → commit skip + 다음 단계 진행', async () => {
gitInstance.hasUncommittedChanges.mockResolvedValueOnce(false);
const r = await svc.sync();
expect(gitInstance.commit).not.toHaveBeenCalled();
expect(gitInstance.fetch).toHaveBeenCalled();
expect(r.ok).toBe(true);
});
it('rebase 실패 → abort + reason=conflict + conflicts 포함 (path + localText/remoteText)', async () => {
gitInstance.rebaseOnto.mockResolvedValueOnce({ stdout: '', stderr: 'CONFLICT', exitCode: 1 });
gitInstance.listConflicts.mockResolvedValueOnce(['notes/aaa.md', 'notes/bbb.md']);
// Cut E final review fix — runSync calls git.run(['show', ':2:path']) and ':3:path'
// for each conflict. Mock returns ours/theirs text per call.
gitInstance.run
.mockResolvedValueOnce({ stdout: 'aaa local', stderr: '', exitCode: 0 }) // :2:notes/aaa.md
.mockResolvedValueOnce({ stdout: 'aaa remote', stderr: '', exitCode: 0 }) // :3:notes/aaa.md
.mockResolvedValueOnce({ stdout: 'bbb local', stderr: '', exitCode: 0 }) // :2:notes/bbb.md
.mockResolvedValueOnce({ stdout: 'bbb remote', stderr: '', exitCode: 0 }); // :3:notes/bbb.md
const r = await svc.sync();
expect(gitInstance.rebaseAbort).toHaveBeenCalled();
expect(r.ok).toBe(false);
expect(r.reason).toBe('conflict');
expect(r.conflicts).toEqual([
{ path: 'notes/aaa.md', localText: 'aaa local', remoteText: 'aaa remote' },
{ path: 'notes/bbb.md', localText: 'bbb local', remoteText: 'bbb remote' }
]);
expect(gitInstance.push).not.toHaveBeenCalled();
});
it('fetch 실패 → reason 반환', async () => {
gitInstance.fetch.mockResolvedValueOnce({ stdout: '', stderr: 'no network', exitCode: 1 });
const r = await svc.sync();
expect(r.ok).toBe(false);
expect(r.reason).toContain('fetch failed');
expect(gitInstance.rebaseOnto).not.toHaveBeenCalled();
});
it('not configured → ok:false + reason=not_configured', async () => {
gitInstance.isRepo.mockResolvedValueOnce(false);
const r = await svc.sync();
expect(r.ok).toBe(false);
expect(r.reason).toBe('not_configured');
});
});

View File

@@ -0,0 +1,60 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { SyncService } from '../../src/main/services/SyncService.js';
vi.mock('../../src/main/services/GitClient.js');
import { GitClient } from '../../src/main/services/GitClient.js';
describe('SyncService.resolveConflict', () => {
let svc: SyncService;
let importSvc: { applySyncFromDir: ReturnType<typeof vi.fn> };
let gitInstance: {
run: ReturnType<typeof vi.fn>;
addAll: ReturnType<typeof vi.fn>;
push: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
importSvc = { applySyncFromDir: vi.fn(async () => ({ changedCount: 0 })) };
gitInstance = {
run: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })),
addAll: vi.fn(async () => {}),
push: vi.fn(async () => {})
};
(GitClient as unknown as ReturnType<typeof vi.fn>).mockImplementation(function () { return gitInstance; });
svc = new SyncService('/tmp', {} as never, importSvc as never);
});
it('local 선택 → checkout --ours + add + rebase --continue + push', async () => {
const r = await svc.resolveConflict('notes/note-id.md', 'local');
expect(gitInstance.run).toHaveBeenCalledWith(['checkout', '--ours', 'notes/note-id.md']);
expect(gitInstance.run).toHaveBeenCalledWith(['rebase', '--continue']);
expect(gitInstance.push).toHaveBeenCalled();
expect(r.ok).toBe(true);
});
it('remote 선택 → checkout --theirs + add + rebase --continue + applySyncFromDir + push', async () => {
const r = await svc.resolveConflict('notes/note-id.md', 'remote');
expect(gitInstance.run).toHaveBeenCalledWith(['checkout', '--theirs', 'notes/note-id.md']);
expect(importSvc.applySyncFromDir).toHaveBeenCalled();
expect(gitInstance.push).toHaveBeenCalled();
expect(r.ok).toBe(true);
});
it('checkout 실패 → ok:false + reason 반환', async () => {
gitInstance.run.mockResolvedValueOnce({ stdout: '', stderr: 'fail', exitCode: 1 });
const r = await svc.resolveConflict('notes/note-id.md', 'local');
expect(r.ok).toBe(false);
expect((r as { reason: string }).reason).toContain('checkout failed');
expect(gitInstance.push).not.toHaveBeenCalled();
});
it('rebase --continue 실패 (다른 파일 미해결) → ok:false', async () => {
gitInstance.run
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }) // checkout
.mockResolvedValueOnce({ stdout: '', stderr: 'still unresolved', exitCode: 1 }); // rebase --continue
const r = await svc.resolveConflict('notes/note-id.md', 'local');
expect(r.ok).toBe(false);
expect((r as { reason: string }).reason).toContain('rebase --continue failed');
expect(gitInstance.push).not.toHaveBeenCalled();
});
});

View File

@@ -9,6 +9,7 @@ import { runMigrations } from '@main/db/migrations/index.js';
import { NoteRepository } from '@main/repository/NoteRepository.js';
import { MediaStore } from '@main/services/MediaStore.js';
import { ExportService } from '@main/services/ExportService.js';
import { ImportService } from '@main/services/ImportService.js';
import { SyncService } from '@main/services/SyncService.js';
const execFileAsync = promisify(execFile);
@@ -47,6 +48,7 @@ describe('SyncService', () => {
let repo: NoteRepository;
let mediaStore: MediaStore;
let exportSvc: ExportService;
let importSvc: ImportService;
let svc: SyncService;
let remoteDir: string | null = null;
let prevEnv: NodeJS.ProcessEnv;
@@ -73,7 +75,8 @@ describe('SyncService', () => {
repo = new NoteRepository(db);
mediaStore = new MediaStore(profileDir);
exportSvc = new ExportService(repo, mediaStore, () => new Date('2026-04-26T12:00:00Z'));
svc = new SyncService(profileDir, exportSvc, () => new Date('2026-04-26T12:00:00Z'));
importSvc = new ImportService(repo, mediaStore);
svc = new SyncService(profileDir, exportSvc, importSvc, () => new Date('2026-04-26T12:00:00Z'));
});
afterEach(() => {
@@ -110,7 +113,7 @@ describe('SyncService', () => {
expect(r.ok).toBe(true);
expect(r.changed).toBe(true);
expect(r.pushed).toBe(true);
expect(r.sha).toMatch(/^[0-9a-f]{40}$/);
expect(r.localSha).toMatch(/^[0-9a-f]{40}$/);
expect(existsSync(join(svc.getSyncDir(), 'manifest.json'))).toBe(true);
expect(existsSync(join(svc.getSyncDir(), 'notes'))).toBe(true);
expect(existsSync(join(svc.getSyncDir(), 'index.jsonl'))).toBe(true);
@@ -122,10 +125,11 @@ describe('SyncService', () => {
const first = await svc.sync();
expect(first.ok).toBe(true);
expect(first.changed).toBe(true);
// Re-sync without DB change. With fixed now() → identical files → git sees no change.
// Re-sync without DB change. With fixed now() → identical files → git sees no local change.
// New bidirectional flow: always does fetch+rebase+re-import+push.
const second = await svc.sync();
expect(second.ok).toBe(true);
expect(second.changed).toBe(false);
expect(second.pushed).toBe(false);
expect(second.changed).toBe(false); // no local commit + importedCount=0
expect(second.pushed).toBe(true); // push always runs on success
});
});

View File

@@ -0,0 +1,72 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { SyncTimer } from '../../src/main/services/SyncTimer.js';
describe('SyncTimer', () => {
let syncSvc: { sync: ReturnType<typeof vi.fn> };
let settings: {
isAutoSyncEnabled: ReturnType<typeof vi.fn>;
getSyncIntervalMin: ReturnType<typeof vi.fn>;
getSyncRepoUrl: ReturnType<typeof vi.fn>;
};
let timer: SyncTimer;
beforeEach(() => {
vi.useFakeTimers();
syncSvc = { sync: vi.fn(async () => ({ ok: true })) };
settings = {
isAutoSyncEnabled: vi.fn(async () => true),
getSyncIntervalMin: vi.fn(async () => 5),
getSyncRepoUrl: vi.fn(async () => 'git@host:u/r.git')
};
timer = new SyncTimer(syncSvc as never, settings as never);
});
afterEach(() => {
timer.stop();
vi.useRealTimers();
});
it('start — interval 마다 syncSvc.sync 호출', async () => {
await timer.start();
expect(syncSvc.sync).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(5 * 60 * 1000);
expect(syncSvc.sync).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(5 * 60 * 1000);
expect(syncSvc.sync).toHaveBeenCalledTimes(2);
});
it('auto disabled → 시작 안 함 (sync 0회)', async () => {
settings.isAutoSyncEnabled.mockResolvedValueOnce(false);
await timer.start();
await vi.advanceTimersByTimeAsync(60 * 60 * 1000);
expect(syncSvc.sync).not.toHaveBeenCalled();
});
it('repo URL 미설정 → 시작 안 함', async () => {
settings.getSyncRepoUrl.mockResolvedValueOnce(null);
await timer.start();
await vi.advanceTimersByTimeAsync(60 * 60 * 1000);
expect(syncSvc.sync).not.toHaveBeenCalled();
});
it('reconfigure — stop + 새 interval 로 start', async () => {
await timer.start();
await vi.advanceTimersByTimeAsync(5 * 60 * 1000);
expect(syncSvc.sync).toHaveBeenCalledTimes(1);
settings.getSyncIntervalMin.mockResolvedValueOnce(10);
await timer.reconfigure();
await vi.advanceTimersByTimeAsync(5 * 60 * 1000);
// not enough time for new interval — still 1 call
expect(syncSvc.sync).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(5 * 60 * 1000);
expect(syncSvc.sync).toHaveBeenCalledTimes(2);
});
it('stop — 호출 후 더 이상 sync 발생 안 함', async () => {
await timer.start();
timer.stop();
await vi.advanceTimersByTimeAsync(60 * 60 * 1000);
expect(syncSvc.sync).not.toHaveBeenCalled();
});
});

View File

@@ -22,6 +22,11 @@ const baseNote: ExportNote = {
aiGeneratedAt: '2026-04-25T14:23:34.000Z',
userIntent: null,
intentPromptedAt: null,
status: 'active',
statusChangedAt: null,
moveReason: null,
dueDate: null,
dueDateEditedByUser: false,
tags: [{ name: 'pr', source: 'ai' }, { name: 'review', source: 'user' }],
media: []
};
@@ -122,6 +127,54 @@ describe('composeFrontmatter', () => {
expect(fm).toContain('mime: image/png');
expect(fm).toContain('bytes: 1234');
});
it('always emits status: active for a default note', () => {
const fm = composeFrontmatter(baseNote);
expect(fm).toContain('status: active');
});
it('emits due_date and due_date_source together when dueDate present', () => {
const fm = composeFrontmatter({ ...baseNote, dueDate: '2026-06-01', dueDateEditedByUser: true });
expect(fm).toContain('due_date: 2026-06-01');
expect(fm).toContain('due_date_source: user');
});
it('emits due_date_source: ai when dueDateEditedByUser is false', () => {
const fm = composeFrontmatter({ ...baseNote, dueDate: '2026-06-01', dueDateEditedByUser: false });
expect(fm).toContain('due_date: 2026-06-01');
expect(fm).toContain('due_date_source: ai');
});
it('omits due_date and due_date_source when dueDate is null', () => {
const fm = composeFrontmatter(baseNote);
expect(fm).not.toContain('due_date:');
expect(fm).not.toContain('due_date_source:');
});
it('emits move_reason when present', () => {
const fm = composeFrontmatter({ ...baseNote, status: 'archived', moveReason: 'done for now' });
expect(fm).toContain('status: archived');
expect(fm).toContain('move_reason: done for now');
});
it('emits status_changed_at when present', () => {
const fm = composeFrontmatter({ ...baseNote, statusChangedAt: '2026-05-01T00:00:00Z' });
expect(fm).toContain('status_changed_at: 2026-05-01T00:00:00Z');
});
it('status/due_date/move_reason fields appear before images: in frontmatter', () => {
const fm = composeFrontmatter({
...baseNote,
dueDate: '2026-06-01',
dueDateEditedByUser: false,
media: [{ rel: 'media/014a3b9c__1.png', mime: 'image/png', bytes: 1 }]
});
const statusPos = fm.indexOf('status:');
const imagesPos = fm.indexOf('images:');
expect(statusPos).toBeGreaterThan(-1);
expect(imagesPos).toBeGreaterThan(-1);
expect(statusPos).toBeLessThan(imagesPos);
});
});
describe('composeMarkdown', () => {

View 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');
});
});

View File

@@ -18,6 +18,11 @@ const baseNote: ExportNote = {
aiGeneratedAt: '2026-04-25T14:23:34.000Z',
userIntent: null,
intentPromptedAt: null,
status: 'active',
statusChangedAt: null,
moveReason: null,
dueDate: null,
dueDateEditedByUser: false,
tags: [{ name: 'pr', source: 'ai' }, { name: 'review', source: 'user' }],
media: []
};
@@ -180,6 +185,66 @@ describe('parseExportNote — provenance', () => {
});
});
describe('parseExportNote — status/dueDate/moveReason round-trip (v0.3.0 Cut E)', () => {
it('round-trips status=active (default)', () => {
const md = composeMarkdown(baseNote);
const parsed = parseExportNote(md);
expect(parsed.status).toBe('active');
expect(parsed.statusChangedAt).toBeNull();
expect(parsed.moveReason).toBeNull();
expect(parsed.dueDate).toBeNull();
expect(parsed.dueDateEditedByUser).toBe(false);
});
it('round-trips status=archived with statusChangedAt and moveReason', () => {
const note: ExportNote = {
...baseNote,
status: 'archived',
statusChangedAt: '2026-05-01T10:00:00Z',
moveReason: 'project done'
};
const md = composeMarkdown(note);
const parsed = parseExportNote(md);
expect(parsed.status).toBe('archived');
expect(parsed.statusChangedAt).toBe('2026-05-01T10:00:00Z');
expect(parsed.moveReason).toBe('project done');
});
it('round-trips dueDate with dueDateEditedByUser=true', () => {
const note: ExportNote = {
...baseNote,
dueDate: '2026-06-15',
dueDateEditedByUser: true
};
const md = composeMarkdown(note);
const parsed = parseExportNote(md);
expect(parsed.dueDate).toBe('2026-06-15');
expect(parsed.dueDateEditedByUser).toBe(true);
});
it('round-trips dueDate with dueDateEditedByUser=false (ai source)', () => {
const note: ExportNote = {
...baseNote,
dueDate: '2026-07-01',
dueDateEditedByUser: false
};
const md = composeMarkdown(note);
const parsed = parseExportNote(md);
expect(parsed.dueDate).toBe('2026-07-01');
expect(parsed.dueDateEditedByUser).toBe(false);
});
it('defaults to status=active for older exports without status field', () => {
// Simulate a pre-Cut E export that has no status line
const md = `---\nid: 014a3b9c-1234-7890-abcd-000000000001\ncreated_at: 2026-04-25T14:23:11.000Z\nupdated_at: 2026-04-25T14:24:02.000Z\ntitle: t\ntitle_source: ai\nsummary: s\nsummary_source: ai\ninkling_export_version: 1\n---\n\n# t\n\n> s\n\nbody\n`;
const parsed = parseExportNote(md);
expect(parsed.status).toBe('active');
expect(parsed.dueDate).toBeNull();
expect(parsed.moveReason).toBeNull();
expect(parsed.dueDateEditedByUser).toBe(false);
});
});
describe('parseExportNote — edge cases', () => {
it('preserves user_intent when present', () => {
const md = composeMarkdown({

View 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 });
});
});

View 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);
});
});

View File

@@ -51,11 +51,11 @@ describe('migration v3 — soft delete columns', () => {
db.close();
});
it('user_version reaches latest (6)', () => {
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(6);
expect(row.user_version).toBe(7);
db.close();
});

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

250
tests/unit/sync-ipc.test.ts Normal file
View File

@@ -0,0 +1,250 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
vi.mock('electron', () => ({ default: { ipcMain: { handle: vi.fn() }, dialog: {}, shell: {} } }));
vi.mock('../../src/main/services/GitClient.js');
import electron from 'electron';
import { GitClient } from '../../src/main/services/GitClient.js';
import { registerSettingsApi } from '../../src/main/ipc/settingsApi.js';
import type { SettingsIpcDeps } from '../../src/main/ipc/settingsApi.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() {
const gitInstance = {
isRepo: vi.fn(async () => false),
hasRemote: vi.fn(async () => false),
run: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 }))
};
(GitClient as unknown as ReturnType<typeof vi.fn>).mockImplementation(function () { return gitInstance; });
const syncSvc = {
getSyncDir: vi.fn(() => '/tmp/sync'),
listConflicts: vi.fn(() => [] as { path: string; localText: string; remoteText: string }[]),
resolveConflict: vi.fn(async () => ({ ok: true as const })),
getLastStatus: vi.fn(() => ({ lastAt: null as string | null, lastResult: null as { ok: boolean } | null }))
};
const settings = {
getSyncRepoUrl: vi.fn(async () => 'git@host:u/r.git'),
setSyncRepoUrl: vi.fn(async () => {}),
isAutoSyncEnabled: vi.fn(async () => false),
getSyncIntervalMin: vi.fn(async () => 30),
getAll: vi.fn(async () => ({})),
setAiEnabled: vi.fn(async () => {}),
setOnboardingCompleted: vi.fn(async () => {}),
isAiEnabled: vi.fn(async () => true)
};
const deps: Partial<SettingsIpcDeps> = {
backup: { runDaily: vi.fn(async () => ({ snapshotted: false })) } as never,
exportSvc: {} as never,
importSvc: {} as never,
syncSvc: syncSvc as never,
telemetry: { exportTo: vi.fn(async () => ({ eventCount: 0 })) } as never,
settings: settings as never,
getInboxWindow: () => null
};
return { gitInstance, syncSvc, settings, deps };
}
describe('sync IPC channels', () => {
beforeEach(() => {
(electron.ipcMain as unknown as { handle: ReturnType<typeof vi.fn> }).handle.mockClear();
vi.clearAllMocks();
});
it('5 sync channels registered', () => {
const { deps } = makeDeps();
registerSettingsApi(deps as SettingsIpcDeps);
const handle = (electron.ipcMain as unknown as { handle: ReturnType<typeof vi.fn> }).handle;
const channels = handle.mock.calls.map((c) => c[0]);
expect(channels).toContain('settings:configure-sync');
expect(channels).toContain('settings:test-sync-connection');
expect(channels).toContain('sync:list-conflicts');
expect(channels).toContain('sync:resolve-conflict');
expect(channels).toContain('sync:get-status');
});
describe('settings:configure-sync', () => {
it('null URL → setSyncRepoUrl(null), no git init', async () => {
const { deps, settings, gitInstance } = makeDeps();
registerSettingsApi(deps as SettingsIpcDeps);
const h = getHandler('settings:configure-sync');
const r = await h({}, null);
expect(settings.setSyncRepoUrl).toHaveBeenCalledWith(null);
expect(gitInstance.run).not.toHaveBeenCalled();
expect(r).toEqual({ ok: true });
});
it('empty string URL → treated as null', async () => {
const { deps, settings, gitInstance } = makeDeps();
registerSettingsApi(deps as SettingsIpcDeps);
const h = getHandler('settings:configure-sync');
const r = await h({}, ' ');
expect(settings.setSyncRepoUrl).toHaveBeenCalledWith(null);
expect(gitInstance.run).not.toHaveBeenCalled();
expect(r).toEqual({ ok: true });
});
it('valid URL → isRepo=false → git init + remote add', async () => {
const { deps, gitInstance } = makeDeps();
gitInstance.isRepo.mockResolvedValue(false);
gitInstance.hasRemote.mockResolvedValue(false);
registerSettingsApi(deps as SettingsIpcDeps);
const h = getHandler('settings:configure-sync');
const r = await h({}, 'git@github.com:user/repo.git');
expect(gitInstance.run).toHaveBeenCalledWith(['init']);
expect(gitInstance.run).toHaveBeenCalledWith(['remote', 'add', 'origin', 'git@github.com:user/repo.git']);
expect(r).toEqual({ ok: true });
});
it('valid URL → isRepo=true, hasRemote=true → remote set-url', async () => {
const { deps, gitInstance } = makeDeps();
gitInstance.isRepo.mockResolvedValue(true);
gitInstance.hasRemote.mockResolvedValue(true);
registerSettingsApi(deps as SettingsIpcDeps);
const h = getHandler('settings:configure-sync');
const r = await h({}, 'git@github.com:user/new-repo.git');
expect(gitInstance.run).toHaveBeenCalledWith(['remote', 'set-url', 'origin', 'git@github.com:user/new-repo.git']);
expect(r).toEqual({ ok: true });
});
it('git init failure → ok: false', async () => {
const { deps, gitInstance } = makeDeps();
gitInstance.isRepo.mockResolvedValue(false);
gitInstance.run.mockResolvedValue({ stdout: '', stderr: 'permission denied', exitCode: 1 });
registerSettingsApi(deps as SettingsIpcDeps);
const h = getHandler('settings:configure-sync');
const r = await h({}, 'git@github.com:user/repo.git');
expect(r).toMatchObject({ ok: false, reason: expect.stringContaining('git init failed') });
});
it('setSyncRepoUrl throws → ok: false', async () => {
const { deps, settings } = makeDeps();
settings.setSyncRepoUrl.mockRejectedValue(new Error('disk full'));
registerSettingsApi(deps as SettingsIpcDeps);
const h = getHandler('settings:configure-sync');
const r = await h({}, 'git@github.com:user/repo.git');
expect(r).toMatchObject({ ok: false, reason: expect.stringContaining('persist failed') });
});
});
describe('settings:test-sync-connection', () => {
it('not initialized → ok: false, reason: not_initialized', async () => {
const { deps, gitInstance } = makeDeps();
gitInstance.isRepo.mockResolvedValue(false);
registerSettingsApi(deps as SettingsIpcDeps);
const h = getHandler('settings:test-sync-connection');
const r = await h({});
expect(r).toEqual({ ok: false, reason: 'not_initialized' });
});
it('ls-remote success → ok: true', async () => {
const { deps, gitInstance } = makeDeps();
gitInstance.isRepo.mockResolvedValue(true);
gitInstance.run.mockResolvedValue({ stdout: 'abc123\trefs/heads/main', stderr: '', exitCode: 0 });
registerSettingsApi(deps as SettingsIpcDeps);
const h = getHandler('settings:test-sync-connection');
const r = await h({});
expect(gitInstance.run).toHaveBeenCalledWith(['ls-remote', 'origin']);
expect(r).toEqual({ ok: true });
});
it('ls-remote failure → ok: false', async () => {
const { deps, gitInstance } = makeDeps();
gitInstance.isRepo.mockResolvedValue(true);
gitInstance.run.mockResolvedValue({ stdout: '', stderr: 'connection refused', exitCode: 128 });
registerSettingsApi(deps as SettingsIpcDeps);
const h = getHandler('settings:test-sync-connection');
const r = await h({});
expect(r).toMatchObject({ ok: false, reason: 'connection refused' });
});
});
describe('sync:list-conflicts', () => {
it('returns syncSvc.listConflicts() result', () => {
const { deps, syncSvc } = makeDeps();
const conflicts = [{ path: 'notes/abc.md', localText: 'local', remoteText: 'remote' }];
syncSvc.listConflicts.mockReturnValue(conflicts);
registerSettingsApi(deps as SettingsIpcDeps);
const h = getHandler('sync:list-conflicts');
const r = h({});
expect(r).toEqual(conflicts);
});
});
describe('sync:resolve-conflict', () => {
it('valid choice "local" → delegates to syncSvc.resolveConflict (path)', async () => {
const { deps, syncSvc } = makeDeps();
registerSettingsApi(deps as SettingsIpcDeps);
const h = getHandler('sync:resolve-conflict');
const r = await h({}, 'notes/note-1.md', 'local');
expect(syncSvc.resolveConflict).toHaveBeenCalledWith('notes/note-1.md', 'local');
expect(r).toEqual({ ok: true });
});
it('valid choice "remote" → delegates to syncSvc.resolveConflict (path)', async () => {
const { deps, syncSvc } = makeDeps();
registerSettingsApi(deps as SettingsIpcDeps);
const h = getHandler('sync:resolve-conflict');
await h({}, 'notes/note-2.md', 'remote');
expect(syncSvc.resolveConflict).toHaveBeenCalledWith('notes/note-2.md', 'remote');
});
it('invalid choice → ok: false, reason: invalid choice', async () => {
const { deps, syncSvc } = makeDeps();
registerSettingsApi(deps as SettingsIpcDeps);
const h = getHandler('sync:resolve-conflict');
const r = await h({}, 'notes/note-1.md', 'both');
expect(syncSvc.resolveConflict).not.toHaveBeenCalled();
expect(r).toEqual({ ok: false, reason: 'invalid choice' });
});
});
describe('sync:get-status', () => {
it('auto-sync disabled → nextAt: null', async () => {
const { deps, syncSvc, settings } = makeDeps();
settings.isAutoSyncEnabled.mockResolvedValue(false);
syncSvc.getLastStatus.mockReturnValue({ lastAt: '2026-05-09T10:00:00.000Z', lastResult: { ok: true } });
registerSettingsApi(deps as SettingsIpcDeps);
const h = getHandler('sync:get-status');
const r = await h({});
expect(r).toMatchObject({ lastAt: '2026-05-09T10:00:00.000Z', lastResult: { ok: true }, nextAt: null });
});
it('auto-sync enabled → nextAt computed from lastAt + interval', async () => {
const { deps, syncSvc, settings } = makeDeps();
settings.isAutoSyncEnabled.mockResolvedValue(true);
settings.getSyncIntervalMin.mockResolvedValue(30);
syncSvc.getLastStatus.mockReturnValue({ lastAt: '2026-05-09T10:00:00.000Z', lastResult: { ok: true } });
registerSettingsApi(deps as SettingsIpcDeps);
const h = getHandler('sync:get-status');
const r = (await h({})) as { lastAt: string; nextAt: string };
const expectedNextAt = new Date(new Date('2026-05-09T10:00:00.000Z').getTime() + 30 * 60 * 1000).toISOString();
expect(r.nextAt).toBe(expectedNextAt);
});
it('no previous sync + auto-sync enabled → nextAt based on Date.now()', async () => {
const { deps, syncSvc, settings } = makeDeps();
settings.isAutoSyncEnabled.mockResolvedValue(true);
settings.getSyncIntervalMin.mockResolvedValue(15);
syncSvc.getLastStatus.mockReturnValue({ lastAt: null, lastResult: null });
registerSettingsApi(deps as SettingsIpcDeps);
const h = getHandler('sync:get-status');
const before = Date.now();
const r = (await h({})) as { lastAt: null; nextAt: string };
const after = Date.now();
const nextAtMs = new Date(r.nextAt).getTime();
expect(nextAtMs).toBeGreaterThanOrEqual(before + 15 * 60 * 1000);
expect(nextAtMs).toBeLessThanOrEqual(after + 15 * 60 * 1000);
expect(r.lastAt).toBeNull();
});
});
});