72 Commits

Author SHA1 Message Date
th-kim0823
713553a038 chore(release): v0.3.12 — vision AI 응답 robust parse
vision model 의 markdown fence / prose 섞인 응답에서 JSON 추출 fallback.
prompt 에 title 한국어 / kebab tags / JSON-only 출력 명시 강화.

- LocalOllamaProvider: parseJsonLoose 헬퍼 (첫 { ~ 마지막 } 추출)
- visionPrompt: 4 규칙 + markdown fence 금지 명시
- 단위 +2 (fence 추출 + prose 추출)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:32:12 +09:00
th-kim0823
d3cf018f62 chore(release): v0.3.11 — CSP img-src hotfix (paste + media render)
양쪽 창의 CSP 가 img-src 부적합:
- quickcapture/index.html: img-src 미지정 → blob: 차단 → paste thumbnail 안 보임
- inbox/index.html: img-src 에 inkling-media: 누락 → 저장된 노트 이미지 안 보임

v0.3.0 이후 잠재적 회귀. 사용자 dogfood 발견.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:26:46 +09:00
th-kim0823
f676c1638e chore(release): v0.3.10 — macOS fullscreen QuickCapture fix
macOS fullscreen Space 위에 QuickCapture 띄우기. 이전엔 핫키 누를 때 강제 Space 전환.

quickCaptureWindow.ts darwin 분기 추가:
- type: 'panel' (fullscreen floating)
- setAlwaysOnTop(true, 'screen-saver') (가장 높은 level)
- setVisibleOnAllWorkspaces(true, visibleOnFullScreen: true)

Windows / Linux 동작 변경 없음.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:22:12 +09:00
th-kim0823
2e69f598bc chore(release): v0.3.9 — AI 흐름 unblock UI + FTS5 escape
audit edge case 3건:
- pending 노트 "건너뛰기" 버튼 (cancelPending: pending → disabled + jobs DELETE)
- failed 노트 per-note "재시도" 버튼 (retryOneFailed: failed → pending + enqueue)
- FTS5 sanitize regex 확장 (backtick/dash/caret 추가)

동시 편집 race 는 EditableField guard 가 이미 처리 (수정 불필요).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 17:43:46 +09:00
th-kim0823
014c06e1f0 chore(release): v0.3.8 — UX hole 일괄 hotfix 8건
전수 audit 후 핵심 root fix 3 + edge cases 5:

ROOT
- inbox:set-status IPC 가 pushNoteUpdated emit (이전엔 stale → 호출처별 refreshMeta 필요)
- upsertNote 가 current view status 인식 (이전엔 잘못된 status 노트 잔류)
- store async 함수 try/catch (이전엔 IPC fail 시 무한 loading)

EDGE
- restoreNote 가 status='active' 도 갱신
- upsertNote trash 판정 deletedAt → status='trashed'
- Modal Escape dismiss 통일 (5개 modal)
- OnboardingWizard IPC fail fallback (try/catch + skip)
- MoveStatusModal overlay 클릭 close

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:50:10 +09:00
th-kim0823
4216d42d7c chore(release): v0.3.7 — 이동 modal currentStatus 필터 (Inbox 복원 path)
MoveStatusModal 이 완료/보관/휴지통 3 button hardcode 라
완료/보관/휴지통 노트가 inbox 로 돌아오는 path 가 없던 버그 fix.
currentStatus prop 으로 4 status 중 current 제외 동적 render.
'활성' label 도 헤더 탭과 일치하도록 'Inbox' 로 통일.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:25:38 +09:00
th-kim0823
e2058cfdbe chore(release): v0.3.6 — 이동 modal 복원 (v0.3.5 의도 정정)
v0.3.5 의 이동 dropdown 단순화가 사용자 의도와 어긋남.
사용자는 dropdown 의 목적지 중복 (modal 도 목적지 묻기) 만 거슬렸지,
사유 입력 + AI 자동 분류 + 수동 status 선택을 한 곳에서 처리하는 modal 은
보존해야 하는 핵심 UX 였음. 단일 "이동" 버튼 → MoveStatusModal path 로 정정.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:16:14 +09:00
th-kim0823
2c6bfebb5b chore(release): v0.3.5 — dogfood UX hotfix 7건
v0.3.4 까지 누적된 dogfood UX 결함 hotfix.
사용자 직접 보고 3건 (inbox 재진입, 회고 탈출, 이동 modal 중복) + 동반 갭 4건
(count stale, 필터 잔류, 초기 로드 불일치, 배너 컨텍스트 누수).
데이터/마이그레이션 변경 없음 (스키마 v8).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 10:47:04 +09:00
altair823
e815289b2a chore: gitignore .claude/worktrees + .claude/settings.local.json
Claude Code 로컬 worktree 와 사용자별 권한 override 파일 무시.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:09:47 +09:00
altair823
b35b644fe8 chore(release): v0.3.4 — sync 도움말 cut
PR #33 머지. SyncHelpModal + ConflictModal inline + README 통합 도움말.
v0.3.0 Cut E dogfood gap fill. 데이터/마이그레이션 변경 없음 (스키마 v8 그대로).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:02:33 +09:00
f2db82b6d6 Merge pull request 'sync 도움말 — SyncHelpModal + ConflictModal inline + README 동기화 섹션 재작성' (#33) from worktree-v034-sync-help into main
Reviewed-on: #33
2026-05-10 14:59:05 +00:00
altair823
9d6f5bfacc refactor(sync-help): ConflictModal — SyncHelpAnchor import 로 literal duplicate 제거
PR #33 회차 1 review 반영. 두 파일 sibling 인데 anchor union 두 곳 정의 →
import type 1줄로 단일 source of truth. 향후 anchor 추가 시 두 파일 동시
수정 누락 risk + TS 가 catch 하지 않던 silent drift risk 제거.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 23:56:26 +09:00
altair823
d686c661ba chore: package-lock.json version 0.3.1 → 0.3.3 catch-up
v0.3.3 hotfix 시 package.json 만 bump 됐고 lockfile 미동기화.
v0.3.4 진입 시 npm install 로 자동 동기화됨. cleanup commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 23:52:05 +09:00
altair823
dca1def87c docs: README 동기화 섹션 Cut E 반영 — 양방향 sync + ConflictModal + Silent risk + Troubleshoot 2026-05-10 23:41:56 +09:00
altair823
8cd6382902 fix(sync-help): SyncSection 도움말 버튼 busy 중 disable 해제
리뷰 minor 반영. busy (저장/테스트/sync 진행) 시 정확히 사용자가
도움말이 가장 필요한 시점이라 disable 회수. read-only 컴포넌트라
race risk 0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 23:40:16 +09:00
altair823
a5e1c1de35 feat(sync-help): SyncSection 도움말 버튼 + SyncHelpModal mount + ConflictModal onOpenHelp wiring 2026-05-10 23:36:07 +09:00
altair823
54ef394bb4 feat(sync-help): ConflictModal inline 설명 + 자세히 보기 링크 (onOpenHelp prop) 2026-05-10 23:29:29 +09:00
altair823
5e55cd3469 feat(sync-help): SyncHelpModal 4 anchor 섹션 (메인 conflict / 자동 / silent / setup) 2026-05-10 23:25:59 +09:00
altair823
976d53ccfc docs(plan): sync 도움말 4-task TDD 구현 계획
Task 1: SyncHelpModal 신규 (4 anchor 섹션)
Task 2: ConflictModal inline 설명 + onOpenHelp optional prop
Task 3: SyncSection 도움말 버튼 + modal mount + ConflictModal wiring
Task 4: README "원격 백업" → "동기화 (Git, Cut E)" 통째 재작성

각 task TDD (test → impl → typecheck → commit), 단위 +11.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:57:43 +09:00
altair823
e8c6b94d2e docs(spec): sync 도움말 v0.3.4 — SyncHelpModal + ConflictModal inline + README
3 표면 (in-app modal + ConflictModal inline + README) 통합 도움말.
4 시나리오 카테고리: 메인 conflict / 자동 처리 / silent risk / setup·인증.
ConflictModal local/remote 각 옵션 inline 설명 + "자세히 보기" 링크 →
SyncHelpModal anchor jump.

다기기 dogfood 의 핵심 가치 검증 (sync) 인데 막힌 순간 도움말 부재 →
v0.3.0 Cut E + v0.3.3 hotfix 기반 공식 도움말.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:47:27 +09:00
altair823
d5143ab1ad chore(release): v0.3.3 — sync configure-sync hotfix
v0.3.0 Cut E (양방향 sync) dogfood 첫 시도 중 발견된 git init ENOENT
hotfix 1건. 데이터/마이그레이션 변경 없음 (스키마 v8 그대로).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:04:59 +09:00
altair823
2221113329 fix(v033): sync configure-sync — git init 전 syncDir mkdir(recursive)
settings:configure-sync IPC 핸들러가 `git -C <syncDir> init` 호출 전에
syncDir 디렉토리를 생성하지 않아, sync 첫 설정 시 git 이 chdir 단계에서
`fatal: cannot change to '<profileDir>/sync': No such file or directory` 로
실패하던 문제. SyncService.runSync() 의 동일 패턴 (mkdir recursive) 을
핸들러에도 추가.

연쇄 증상: SyncSection 의 "연결 테스트" 버튼 disabled 조건이 저장된 url
state 기반이라, 저장 실패로 url 영영 비어 있어 버튼 활성화 불가 (닭/달걀).
mkdir fix 로 자동 해소.

회귀: sync-ipc.test.ts 에 mkdir 호출 순서 검증 1건 추가 (18 pass).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:04:46 +09:00
f37e17dd81 Merge pull request 'v0.3.2 — cleanup cut (잠재 bug 4 + cosmetic 5 + #20 deferred)' (#32) from worktree-v032-cleanup into main
Reviewed-on: #32
2026-05-10 07:15:23 +00:00
altair823
41310dbe6a refactor(v032): KST_OFFSET_MS inline → @shared/util/kstDate import (#19)
5 callsite (NoteRepository, ftsHelpers, BackupService, ContinuityService,
NoteCard) 모두 canonical export 로 정리. 알고리즘 동일 (9 * 60 * 60 * 1000),
회귀 PASS 검증.

v0.2.6 commit 3cfa60b 가 4 callsite migrate 했지만 5 callsite 잔여.
Cut F audit 에서 발견.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:38:58 +09:00
altair823
bb909e44ff chore(release): v0.3.2 — cleanup cut (잠재 bug + cosmetic 9 + #20 deferred)
backlog 잔여 23 → 14 (-9 처리, +1 deferred 잔존, +1 stale):
- 잠재 bug 4: vocabSet COLLATE / time-dep test flake / PII reason / KST inline
- cosmetic 5: 탭 ARIA / loadExpired 제거 / per-tag Promise.all / recall IPC on
  / OllamaSettingsModal 폐기 audit
- deferred: #20 (.catch debug log — CaptureService logger 미주입)

기록 정리: v0.2.2 stale memory 폐기 + v024-backlog 처리 이력 갱신

단위 710 → 724, typecheck 0

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:32:03 +09:00
altair823
83cefccbdd fix(v032): AiWorker Promise.all closure type narrowing 회복
Task 6 의 Promise.all 도입 시 async callback closure 가 this.telemetry?
narrowing 잃어 TS2532 발생. const telemetry = this.telemetry 로 narrowed
reference capture 후 closure 안에서 사용.
2026-05-10 14:25:24 +09:00
altair823
4db7a0bce0 refactor(v032): recall IPC handle→on + fix sibling test mocks (#36)
- inbox:emitRecallShown / emitRecallSnoozed: ipcMain.handle → on
  (fire-and-forget honest pattern, return value 의존자 0)
- preload: ipcRenderer.invoke → send (matching on the main side)
- shared/types: Promise<void> → void on both recall emit methods
- store.ts: drop await on emitRecallSnoozed (now void)
- inboxApi-*.test.ts: add ipcMain.on to electron mock (broken by above)
- tests/unit/recall-ipc.test.ts: new TDD test for handle→on migration

Note: #20 CaptureService telemetry .catch debug log skipped —
CaptureService has no logger field; adding one would require non-trivial
constructor signature change. Reported as CONCERN below.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 14:23:19 +09:00
altair823
aa7eb9d99f perf(v032): AiWorker per-tag emit Promise.all 병렬화 (#32)
기존 serial for-await: 3 태그 → 3 round-trip file-append.
Promise.all: 동일 결과, file-append 동시 실행 (telemetry 파일은
append-only, 순서 의존 단위 0).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:16:58 +09:00
altair823
9073e78169 refactor(v032): 탭 ARIA canonical + loadExpired dead-code 제거 (#14, #18)
- App.tsx 탭 button 의 aria-pressed → role="tab" + aria-selected
  (canonical pattern, a11y audit 정정)
- store.ts loadExpired action + test 제거 (App.tsx 호출 0건,
  loadInitial/refreshMeta 가 inline fetch — dead code)
2026-05-10 14:12:37 +09:00
altair823
302bbd4ce0 fix(v032): healthCheck reason PII 마스킹 (#39)
err.message 안에 LAN endpoint URL (예: 192.168.x.x:11434) 이 포함될 수
있어 telemetry 파일에 PII 우회 노출. v0.2.3.1 in-app endpoint UI 가 LAN
사용을 흔하게 만들어 노출 경로 확대.

classifyFetchError 로 error class 분류 (network/timeout/dns/other) 후
reason: 'unreachable:{class}' 형태만 emit. host/IP 노출 0.
2026-05-10 14:00:33 +09:00
altair823
6985db3505 fix(v032): AiWorker vocabSet COLLATE NOCASE 정합 (#31)
DB tags.name 가 COLLATE NOCASE 인데 vocabSet 은 strict-eq 였음 →
대문자/소문자 vocab 과 AI tag 가 다를 때 silently skip.

vocab.toLowerCase() + tagName.toLowerCase() 양쪽 normalize 로 정합.
2026-05-10 13:55:52 +09:00
altair823
36eafa1ce9 fix(v032): NoteRepository.create now param + time-dep test flake fix
- create(input, now?: Date) signature 추가 (기존 setStatus/updateRawText 패턴 정합)
- NoteRevisions.test.ts 4 testcase v1 capture 시간 명시 주입 (2026-05-09T00:00:00Z)
- upsertFromSync.test.ts 2 testcase v1 capture 시간 명시 주입
- 시스템 시계가 2026-05-10T00:00:00Z 초과 시 DESC ordering 깨지던 회귀 회복

backlog: time-dependent flake (Cut F audit 발견)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 13:45:37 +09:00
altair823
4deb7775f3 docs(plan): v0.3.2 cleanup cut implementation plan (8 tasks)
Spec: 2026-05-10-v032-cleanup-design.md
Tasks: time-dep test fix / vocabSet COLLATE / PII reason / KST migration /
       탭 ARIA + loadExpired / Promise.all / recall IPC + .catch / 기록 정리

목표 단위 710 → 약 720 (+10 신규, -2 제거), typecheck 0
2026-05-10 13:36:05 +09:00
altair823
d0d9461d75 docs(spec): v0.3.2 cleanup cut design — 잠재 bug 4 + cosmetic 6 + 기록 정리 2
backlog 잔여 23건 audit 결과:
- 잠재 bug 4건: vocabSet COLLATE / time-dep test flake / PII reason / KST inline 5 callsite
- cosmetic 6건: 탭 ARIA / loadExpired 제거 / per-tag Promise.all / recall IPC on / OllamaSettingsModal 폐기 audit / .catch debug log
- 기록 정리 2건: v0.2.2 stale memory 폐기 / v024-backlog 갱신
- 보류: data-dependent 9 + future-proof 2 (dogfood 후 재평가)

목표 단위 710 → 약 720 (+10), typecheck 0
2026-05-10 13:20:54 +09:00
0d2896e0cc Merge pull request 'v0.3.1 Cut F — 멀티모달 vision AI (F24)' (#31) from worktree-v031-cut-f-vision into main
Reviewed-on: #31
2026-05-10 03:10:35 +00:00
altair823
2b3c3d727e feat(v031): vision capability hints 에 gemma4 추가 (사용자 요청)
본인 dogfood 환경 = gemma4:e4b (텍스트). vision 변종은 현재 gemma3 (vision-capable)
또는 향후 gemma4 출시 시. 양 family 모두 hint 에 포함 — capability detection 이
future-proof.

- VisionDetect.VISION_FAMILIES + VISION_NAME_HINTS 에 'gemma4' 추가
- isVisionCapable test 2건 추가 (gemma4 family / gemma4 name hint detection)
- spec §1 + §2 의 'gemma3 family default' → 'gemma family — gemma3 / gemma4'

영향: 기존 detection 정확도 무영향 (set 추가만), 사용자가 gemma4 vision 변종을
설치하면 자동 인식.
2026-05-10 11:12:13 +09:00
altair823
81fae12a8c fix(v031): endpoint resolution + 5MB fast-fail (final review fix)
final code review (Opus) 발견 minor issues 중 valuable 2건:

1. settings:refresh-vision-cache 가 settings.ollama.endpoint 만 체크 — env / default
   fallback 누락. dev 환경 (env var only) 사용자가 manual 다시 감지 시 'no_endpoint'
   silent fail. → index.ts 의 resolvedEndpoint 와 동일 fallback 체인 (settings → env →
   DEFAULT_OLLAMA_ENDPOINT).

2. AiWorker 의 5MB cap 이 readFile + base64 변환 후 throw — retry 마다 동일 비용 반복.
   note.media[].bytes 가 DB 에 이미 있으니 readFile 전 fast-fail. 비용 절감 + 동일 회로
   (markAiFailed 도달).

회귀 test 영향 없음 (기존 5MB throw 시나리오 그대로 — fast-fail 도 throw 분기 동일).
2026-05-10 05:07:55 +09:00
altair823
7b536409a8 chore(release): v0.3.1 — Cut F (멀티모달 vision AI)
- F24 promoted ( v0.3.1 Cut F — Ollama vision 모델 capability detection + AiWorker integration)
- version 0.3.0 → 0.3.1 (semver patch — 새 기능, 기존 영향 X)
- 단위 679 → 710 (+31): VisionDetect 9 + SettingsService 4 + visionPrompt 2 + LocalOllamaProvider vision 3 + AiWorker vision 3 + IPC 5 + UI 4 + ImportService helper fix 5 (Cut E gap)
- typecheck 0 errors
- 자동 fallback (caption→text) + 'skipped' enum deferred v0.3.2+
2026-05-10 05:02:10 +09:00
altair823
7468217460 feat(v031): main — refreshVisionCache whenReady fire-and-forget 2026-05-10 05:00:15 +09:00
altair823
72e9b68923 feat(v031): VisionSection UI — dropdown + 다시 감지 + 마지막 감지 시각
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 04:59:19 +09:00
altair823
d03098cfac feat(v031): vision IPC + preload (get-vision-models / set / refresh)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 04:59:12 +09:00
altair823
2179cfbf39 feat(v031): AiWorker vision integration — note.media + visionModel + 5MB cap 2026-05-10 04:53:21 +09:00
altair823
5012b40c14 feat(v031): LocalOllamaProvider vision path (visionModel + images → body.images base64) 2026-05-10 04:53:10 +09:00
altair823
369d418c7e fix(v031): ImportService.test buildExportNote helper 에 Cut E frontmatter 5 필드 추가
Cut E v0.3.0 에서 ExportNote interface 에 status / statusChangedAt / moveReason /
dueDate / dueDateEditedByUser 필드 추가했지만 ImportService.test 의 buildExportNote
helper 갱신 누락 → composeFrontmatter 가 undefined moveReason 로 formatScalar 호출
시 null !== undefined 분기 통과 후 .includes throw.

helper 에 5 필드 default (active / null / null / null / false) 추가. 회귀 fix.
2026-05-10 04:45:43 +09:00
altair823
e2e8b9b921 feat(v031): buildVisionPrompt + GenerateInput.images + GenerateOptions.visionModel
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 04:43:03 +09:00
altair823
3eb0ef1316 feat(v031): VisionDetect — isVisionCapable + refreshVisionCache (fetch 주입)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 04:42:57 +09:00
altair823
463be7cf26 feat(v031): SettingsService.{getVisionModel,setVisionModel,getVisionCapableCache,setVisionCapableCache}
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 04:42:52 +09:00
altair823
7a56184ad2 docs(plan): v0.3.1 Cut F — 멀티모달 vision AI (spec 정정: 단위 679, SettingsService 개별 메서드, 'skipped' enum 미도입, fallback 미구현) 2026-05-10 04:38:45 +09:00
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
104 changed files with 11284 additions and 452 deletions

4
.gitignore vendored
View File

@@ -11,3 +11,7 @@ test-results/
# build/ 산출물 — icon.{ico,icns,png} 만 커밋, 중간 산출물은 무시
build/icons/
build/icon-source.png
# Claude Code 로컬 worktree + 사용자별 settings
.claude/worktrees/
.claude/settings.local.json

View File

@@ -3,6 +3,246 @@
본 파일은 Inkling 의 버전별 사용자 영향 변경 사항을 기록한다.
형식은 [Keep a Changelog](https://keepachangelog.com/) 를 느슨하게 따른다.
## [0.3.12] — 2026-05-12
이미지 AI 처리 실패 fix. vision model 의 응답이 strict JSON 이 아닌 경우 (markdown fence / prose 섞임) 가 흔해 schema parse 단계에서 throw → `ai_status='failed'` 도달.
### 수정
- **Vision model 응답 JSON loose parse (P1).** `LocalOllamaProvider.generate``JSON.parse` 가 strict 라 vision-tuned 모델의 markdown 코드 펜스 / 앞뒤 prose 응답에서 throw. `parseJsonLoose` 헬퍼 추가 — 첫 `{` ~ 마지막 `}` substring 추출 fallback. 실패 시 raw response 200자 snippet 포함한 에러 throw (디버깅 가시화).
- **Vision prompt 강화 (P1).** `buildVisionPrompt` 가 "title 한국어로 요약" 만 명시 → schema 의 KOREAN_REGEX/KEBAB_REGEX 강제와 불일치. 규칙 4건 명시: title 한국어 60자, summary 3줄, tags 영문 kebab-case 3개, due_date ISO 또는 null. "markdown 코드 펜스 금지" 명시로 JSON-only 출력 강제.
알려진 한계:
- `gemma4:26b` 같이 Ollama 에 실제로 release 안 된 모델명 사용 시 healthCheck 통과해도 generate 가 unknown model 로 throw. 모델 설치 여부는 사용자가 `ollama list` 확인 필요.
### 게이트
- 단위 750 → **752 PASS** (+2: markdown fence 추출 + prose 추출)
- typecheck 0 errors
- 신규 npm dependency 0
### 업그레이드
v0.3.11 인스톨러 위에 v0.3.12 인스톨러를 같은 위치에 실행하면 in-place 업그레이드. 데이터/마이그레이션 변경 없음.
## [0.3.11] — 2026-05-12
붙여넣은 이미지 / 저장된 이미지가 양쪽 창에서 표시 안 되던 CSP 누락 hotfix.
### 수정
- **QuickCapture: paste 이미지 thumbnail 렌더 실패.** `quickcapture/index.html` 의 CSP 가 `img-src` 미지정 → `default-src 'self'` fallback → `URL.createObjectURL``blob:` URL 차단. `img-src 'self' data: blob:` 추가.
- **Inbox: 저장된 노트 이미지 렌더 실패.** `inbox/index.html` 의 CSP `img-src 'self' data: blob: file:``inkling-media:` 미허용 → `NoteCard``<img src="inkling-media://media/..." />` 차단 (custom protocol 자체는 main 에서 등록됐지만 renderer CSP 별도). `inkling-media:` 추가.
v0.3.0 (Cut A 이미지 첨부) 이후 양쪽 창에서 paste/render 가 잠재적으로 깨져있던 회귀. 사용자가 dogfood 중 발견.
### 게이트
- 단위 750 PASS (CSP meta tag 만 변경 — 코드 path 영향 없음)
- typecheck 0 errors
- 신규 npm dependency 0
### 업그레이드
v0.3.10 인스톨러 위에 v0.3.11 인스톨러를 같은 위치에 실행하면 in-place 업그레이드. 데이터/마이그레이션 변경 없음.
## [0.3.10] — 2026-05-12
macOS fullscreen 환경에서 QuickCapture 핫키 (Cmd+Shift+J) 가 작동하지만 강제로 홈 데스크탑으로 Space 전환 후 표시되던 버그 fix.
### 수정
- **macOS fullscreen Space 위에 QuickCapture 표시 (P1).** 기본 BrowserWindow 는 첫 생성된 Space (홈 데스크탑) 에만 표시 → 사용자가 다른 앱 fullscreen 중에 핫키 누르면 macOS 가 강제로 Space 전환 → 사용자 흐름 단절. `quickCaptureWindow.ts` 에 darwin 분기 추가:
- `type: 'panel'` — fullscreen Space 위에 floating 가능한 macOS native panel
- `setAlwaysOnTop(true, 'screen-saver')` — fullscreen app 위에 띄울 수 있는 가장 높은 level
- `setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true })` — 현재 Space (fullscreen 포함) 에 함께 표시, Space 전환 차단
Windows / Linux 동작은 변경 없음 (darwin 분기만).
### 게이트
- 단위 750 PASS (변경 없음 — main window 코드는 단위 테스트 대상 아님)
- typecheck 0 errors (src)
- 신규 npm dependency 0
- macOS 사용자 수동 검증 완료
### 업그레이드
v0.3.9 인스톨러 위에 v0.3.10 인스톨러를 같은 위치에 실행하면 in-place 업그레이드. 데이터/마이그레이션 변경 없음.
## [0.3.9] — 2026-05-11
v0.3.8 audit 의 미수정 edge case 3건 완료. AI 처리 흐름의 사용자 unblock path + FTS5 query 안전성.
### 수정
- **`ai_status='pending'` 노트 cancel UI 부재 (P1).** Ollama 끊김 / 무한 pending 상태에서 사용자가 빠져나오는 path 가 없었음. NoteCard 의 pending 표시 옆에 "건너뛰기" 버튼 추가 → `inboxApi.cancelPending(id)``repo.cancelPending`: `ai_status='disabled'` + `pending_jobs` DELETE. raw_text 는 보존. pushNoteUpdated emit 으로 renderer 자동 sync.
- **`ai_status='failed'` 노트 per-note 재시도 UI 부재 (P2).** 이전엔 FailedBanner 의 일괄 재시도만 가능. NoteCard 의 failed 표시 옆에 "재시도" 버튼 추가 → `inboxApi.retryOneFailed(id)``repo.retryOneFailed`: failed → pending + `pending_jobs` INSERT + worker enqueue. pushNoteUpdated emit.
- **FTS5 query escape 불완전 (P2).** `sanitizeFtsQuery` 의 special chars regex 가 `["*():]` 만 처리 → backtick/dash/caret 미escape 로 일부 입력이 FTS5 parser throw 야기. `["*():`^\-]` 로 확장. 한국어 사용자가 의도 없이 입력할 가능성 높은 punctuation 까지 안전 처리.
### 미수정 (의도)
- **동시 편집 race (P2).** EditableField 가 이미 `editing=true` 중 value prop 변경을 무시하는 guard 보유. 사용자 입력은 보존됨 (last-write-wins). 추가 코드 불필요.
### 게이트
- 단위 745 → **750 PASS** (+5: repo retryOneFailed 2 + cancelPending 2 + FTS sanitize 1)
- typecheck 0 errors (src)
- 신규 npm dependency 0
### 업그레이드
v0.3.8 인스톨러 위에 v0.3.9 인스톨러를 같은 위치에 실행하면 in-place 업그레이드. 데이터/마이그레이션 변경 없음.
## [0.3.8] — 2026-05-11
전수 audit 후 발견된 사용자 상호작용 hole 8건 일괄 hotfix. 핵심은 (1) push-based status 동기화 root fix, (2) modal Escape affordance 통일, (3) IPC 실패 resilience.
### 수정
- **`inbox:set-status` IPC 가 `pushNoteUpdated` emit 안 함 (root fix).** 이전엔 setStatus 후 counts/list/search 가 모두 stale → 호출처마다 `refreshMeta()` 명시 호출이 필요했음 (v0.3.5 워크어라운드). 이제 IPC 핸들러가 `repo.findById()``pushNoteUpdated` 호출. renderer 의 `onNoteUpdated` 콜백 1개 path 로 모든 status 전이가 일관 갱신.
- **`upsertNote` 가 current view 무시 → 잘못된 status 노트 잔류.** 이전엔 trashed 외 모든 status 를 `notes` 에 누적 → 사용자가 inbox 에서 완료로 옮긴 노트가 list 에 잔류. v0.3.8 부터 `viewStatus` (inbox→active, completed→completed, archived→archived) 와 매칭되는 status 만 유지. `searchResults` 도 동일 패턴.
- **`upsertNote` 의 trash 판정을 `deletedAt``status='trashed'` 로 전환.** m004 이후 status 가 single source of truth, deletedAt 은 backward-compat mirror. sync conflict 후 두 컬럼 불일치 가능성 대비.
- **`restoreNote` 가 status 도 'active' 로 갱신.** 이전엔 `deletedAt: null` 만 clear → upsertNote 가 status='trashed' 그대로 라 여전히 trashNotes 에 잔류.
- **OnboardingWizard close path 부재.** IPC 실패 시 무한 wizard 잠금 → `try/catch + setBusy/error state + "지금 건너뛰기" 버튼 + Escape` 추가. 첫 launch 사용자 막힘 회피.
- **Modal Escape key dismiss 통일.** `MoveStatusModal` / `RevisionHistoryModal` / `ConflictModal` / `SyncHelpModal` / `OnboardingWizard` 모두 `keydown` listener 추가. MoveStatusModal 은 overlay 클릭 close 도 추가 (다른 modal 들은 이미 외부 클릭 지원).
- **`store.ts` async 함수 error-resilient.** `loadInitial` / `loadByView` / `searchNotes` / `loadReview` / `refreshMeta` 가 IPC throw 시 try/catch 로 감싸 무한 loading / stale data 회피. loadInitial 은 catch 시 `loading: false`, loadByView 는 빈 list, searchNotes 는 빈 결과, loadReview 는 빈 aggregate 로 graceful fallback.
### 갱신
- **NoteCard 의 명시적 `refreshMeta` 호출 보존** — onNoteUpdated path 가 이미 refreshMeta 호출하므로 redundant 지만 backup 으로 유지 (2번 fetch 만 발생, 무해).
### 게이트
- 단위 739 → **745 PASS** (+6: view-aware upsertNote 3 + setStatus push emit 1 + Modal Escape 1 + Modal overlay 클릭 1)
- typecheck 0 errors (src)
- 신규 npm dependency 0
### 업그레이드
v0.3.7 인스톨러 위에 v0.3.8 인스톨러를 같은 위치에 실행하면 in-place 업그레이드. 데이터/마이그레이션 변경 없음.
### 미수정 (낮은 우선순위 / 별도 작업)
- ai_status='pending' 노트 cancel UI (P1, 별도 spec 필요)
- ai_status='failed' 노트 per-note 재시도 UI (P2)
- FTS5 query escape (P2, 확인 필요)
- 동시 편집 race condition (P2)
## [0.3.7] — 2026-05-11
`MoveStatusModal` 의 button hardcode 로 인해 완료/보관/휴지통 노트가 inbox 로 돌아올 수 없던 버그 fix. v0.2.9 Cut B 부터 존재한 잠재 결함 (dropdown 의 `possibleTargets` 필터가 modal 까지 흐르지 못함).
### 수정
- **완료/보관/휴지통 노트의 Inbox 복원 path 부재.** `MoveStatusModal``완료/보관/휴지통` 3 button hardcode 라 currentStatus 외 3 status 만 동적으로 노출해야 한다는 의도가 누락. `currentStatus: NoteStatus` prop 추가 + 4 status 중 current 제외 동적 render. NoteCard 가 `local.status` 전달.
- **status label 일관성** — `statusLabel('active')` 가 '활성' 이었으나 헤더 탭 표기는 'Inbox'. modal button + AI 추천 텍스트 양쪽 모두 'Inbox' 로 통일.
### 게이트
- 단위 736 → **739 PASS** (+3: completed/archived/trashed currentStatus button list 검증)
- typecheck 0 errors (src)
- 신규 npm dependency 0
### 업그레이드
v0.3.6 인스톨러 위에 v0.3.7 인스톨러를 같은 위치에 실행하면 in-place 업그레이드. 데이터/마이그레이션 변경 없음.
## [0.3.6] — 2026-05-11
v0.3.5 의 이동 dropdown 단순화가 사용자 의도와 어긋난 점 정정. 이동 modal (사유 + AI 자동 분류 + 수동 status 선택) 은 보존해야 하는 핵심 UX 였음.
### 수정
- **이동 dropdown → 단일 "이동" 버튼 + `MoveStatusModal` 복원.** v0.3.5 에서 dropdown 항목 클릭 = 즉시 setStatus 로 단순화한 path 를 되돌림. 사용자 의도는 dropdown 의 목적지 중복 (modal 도 목적지 묻기) 제거였지, modal 자체 제거가 아니었음. 단일 "이동" 버튼 → modal → 사유 입력 + AI 자동 분류 + 수동 status 선택 path 로 통일.
- **`MoveStatusModal.tsx` + 테스트 6 case 복원** — v0.3.5 에서 dead code 로 판단해 삭제했으나 다시 mount 됨. statusLabel 헬퍼 위치는 modal 내부로 회귀 (orphan `statusLabel.ts` 제거).
- **이동 후 `refreshMeta()` 호출 유지** — v0.3.5 D1 fix (setStatus IPC 가 pushNoteUpdated emit 안 함 → 헤더 탭 count stale) 는 modal `onMoved` callback path 에서도 동일하게 트리거.
### 게이트
- 단위 734 → **736 PASS** (NoteCard 이동 case 3 → 2 + MoveStatusModal 6 복원)
- typecheck 0 errors (src)
- 신규 npm dependency 0
### 업그레이드
v0.3.5 인스톨러 위에 v0.3.6 인스톨러를 같은 위치에 실행하면 in-place 업그레이드. 데이터/마이그레이션 변경 없음.
## [0.3.5] — 2026-05-11
v0.3.4 까지 누적된 dogfood UX 결함 7건 hotfix. 사용자가 막혔던 inbox/회고/이동 3건 + 그 부류의 동반 갭 4건. 데이터/마이그레이션 변경 없음 (스키마 v8 그대로).
### 수정
- **Inbox 탭 진입 실패: 다른 보관함에서 inbox 로 못 돌아옴.** `setView('inbox')` 가 reload 를 호출 안 해서 `notes` state 가 이전 view 의 status 로 stale. `loadByView` 시그니처에 `'inbox' → 'active'` 매핑 추가 + setView 의 reload 분기에 inbox 포함.
- **회고 view 탈출 불가.** `ReviewView` 가 App 의 헤더를 우회 (early return) 해서 사용자가 뒤로 갈 길이 없던 문제. `← 돌아가기` 버튼 추가 — 클릭 시 `setView('inbox')`.
- **이동 dropdown 의 modal 중복 질문.** dropdown 에서 "완료로 이동" 선택해도 `MoveStatusModal` 이 떠서 목적지를 재확인. dropdown 클릭 = `inboxApi.setStatus(id, target, null)` 즉시 호출로 단순화. modal path 자체 제거 (`MoveStatusModal.tsx` + 동반 테스트 삭제, `statusLabel` 헬퍼는 별도 `statusLabel.ts` 로 분리).
- **이동 후 헤더 탭 count stale.** `setStatus` IPC 가 `pushNoteUpdated` emit 을 안 해서 `refreshMeta` 가 트리거되지 않던 잠재 버그. dropdown 이동 path 끝에 `refreshMeta()` 명시 호출 추가.
- **View 전환 시 검색/태그 필터 잔류.** `setView``searchResults`/`searchQuery`/`tagFilter` 를 reset 안 해서 이전 view 의 필터가 완료/보관/휴지통/회고에 잘못 적용. 한 번에 reset.
- **Inbox 첫 로드와 탭 reload 결과 불일치.** `loadInitial``listNotes()` (= `deleted_at IS NULL` = active+completed+archived 혼재) 사용 → 헤더 inbox count (active 만) 와 list 불일치. `listByStatus('active', limit:50)` 로 통일.
- **AI 배너가 completed/archived 탭에서도 노출.** OllamaBanner/PendingBanner/FailedBanner/ExpiryBanner/RecallBanner/RecoveryToast 가 `!showTrash` 만 체크해서 active 무관 컨텍스트에서도 그림. `view === 'inbox'` 분기로 한정.
### 갱신
- **이동 dropdown UX** — 메뉴 열린 상태에서 외부 클릭 / Escape 로 닫힘 (mousedown + keydown listener, useEffect 로 menuOpen=true 일 때만 활성).
### 게이트
- 단위 738 → **734 PASS** (MoveStatusModal 6 case 삭제 + NoteCard 메뉴 case 1 → 3 (직접 이동/외부 클릭/Escape) 로 재구성)
- typecheck 0 errors (src)
- 신규 npm dependency 0
### 업그레이드
v0.3.4 인스톨러 위에 v0.3.5 인스톨러를 같은 위치에 실행하면 in-place 업그레이드. 데이터/마이그레이션 변경 없음.
## [0.3.4] — 2026-05-11
v0.3.0 Cut E (양방향 sync) dogfood 의 결과로, 사용자가 conflict 시나리오에 막힌 순간 도움받을 곳이 부재한 갭을 메운 cut. 3 표면 (in-app modal + ConflictModal inline + README) 통합 도움말. PR #33 머지.
### 신규
- **`SyncHelpModal` (4 anchor 섹션)** — 설정 → 동기화 저장소 → "도움말" 버튼 또는 ConflictModal 의 "자세히 보기 →" 링크에서 진입. `#main-conflict` (편집/편집·삭제/편집·AI 결과 충돌 결정 트리) / `#auto` (fetch+rebase·첫 sync·push 거부·자동 주기) / `#silent` (NTP·동시 수정·자동 sync 실패 silent) / `#setup` (URL SSH/HTTPS 형식·잘못된 `git@https://` 사례·인증 helper·연결 테스트 실패 troubleshoot·URL 재설정).
### 갱신
- **`ConflictModal` inline 설명** — 각 conflict row 의 "내 것 사용" / "원격 사용" 의미를 1-2 줄 인라인 안내 + (옵션) "자세히 보기 →" 링크 (onOpenHelp callback). 기존 caller backward-compatible (optional prop).
- **`SyncSection` 도움말 버튼** — URL row 마지막에 추가. busy (저장/테스트/sync 진행) 중에도 도움말 reachable (read-only).
- **`README` 동기화 섹션 통째 재작성** — stale "원격 백업 (F6-L2)" (v0.2.1 MVP, 트레이 "지금 동기화" + 수동 `git init` 안내) → "동기화 (Git, F21 Cut E)". 일회 설정 / 일상 사용 / 충돌 해결 (3 케이스) / Silent risk / Troubleshoot. in-app SyncHelpModal 과 동일 정보 산문체.
### 게이트
- 단위 727 → **738 PASS** (+11): SyncHelpModal 7 + ConflictModal 회귀 3 + SyncSection 회귀 1
- typecheck 0 errors
- 신규 npm dependency 0
### 후속 (deferred)
- ESC key handler (현재 SyncHelpModal / ConflictModal 모두 X + overlay 만, 프로젝트 패턴 정합. 도입 시 양쪽 동시).
- 1주 dogfood soak 후 도움말 텍스트 정합성 1차 갱신 (실제 사용자 경험과 어긋난 부분 보강).
### 업그레이드
v0.3.3 인스톨러 위에 v0.3.4 인스톨러를 같은 위치에 실행하면 in-place 업그레이드. 데이터/마이그레이션 변경 없음 (스키마 v8 그대로).
## [0.3.3] — 2026-05-10
v0.3.0 Cut E (양방향 sync) dogfood 첫 시도 중 발견된 sync 설정 ENOENT 버그 hotfix.
### 수정
- **Sync 설정 첫 저장 실패 (git init ENOENT)**: 설정 → 동기화 저장소에서 URL 입력 후 "저장" 클릭 시 `git init failed: fatal: cannot change to '<profileDir>/sync': No such file or directory` 로 실패하던 문제. `settings:configure-sync` IPC 핸들러가 `git -C <syncDir> init` 호출 전에 syncDir 디렉토리를 생성하지 않아 git 이 chdir 단계에서 죽음. `SyncService.runSync()` 의 동일 패턴 (`mkdir(syncDir, { recursive: true })`) 을 핸들러에도 추가. 결과적으로 "연결 테스트" 버튼이 영영 활성화되지 않던 연쇄 증상 (저장 성공 시에만 url state 채워지고 버튼 enable) 도 자동 해소.
### 게이트
- 단위 테스트: `tests/unit/sync-ipc.test.ts` 18 (mkdir 호출 순서 회귀 1 추가)
- typecheck: 0 errors
- 신규 npm dependency: 0
### 업그레이드
v0.3.2 인스톨러 위에 v0.3.3 인스톨러를 같은 위치에 실행하면 in-place 업그레이드. 데이터/마이그레이션 변경 없음 (스키마 v8 그대로).
## [0.2.2] — 2026-04-26
v0.2.1 dogfood 중 발견된 F7 (Due Date 합성 표현) + Quick Capture 스크롤 버그를 묶은 패치.

View File

@@ -190,37 +190,58 @@ inkling.md 원본 제품 브리프 v1.4
---
## 원격 백업 (선택, F6-L2)
## 동기화 (Git, F21 Cut E)
Inkling 데이터를 사적 git 원격에 백업하려면 한 번만 설정하면 된다. 인코딩된 형식이 아니라 평문 마크다운(F5 export 형식)으로 저장되니, **반드시 비공개 repo** 를 사용한다.
Inkling 데이터를 사적 git 원격으로 양방향 동기화 (Mac ↔ Windows 등). 평문 마크다운(F5 export 형식)으로 저장되니 **반드시 비공개 repo** 를 사용한다.
상세 도움말은 앱 내 설정 → 동기화 저장소 → "도움말" 버튼 (4 섹션 modal) 참조. 본 섹션은 setup + 주요 시나리오 요약.
### 일회 설정
```bash
# 1. 빈 사적 repo 생성 (예: gitea, GitHub private)
1. 빈 사적 repo 생성 (Gitea / GitHub private)
2. 앱 → 설정 → 동기화 저장소 → URL 입력 → "저장"
3. "연결 테스트" 클릭해 인증 / 네트워크 확인
4. 자동 sync 사용 토글 + interval (기본 30분) 확인
# 2. 데이터 디렉터리에 git 초기화 + 원격 등록
cd "%APPDATA%\Inkling\Inkling\profiles\default\sync" # Windows
git init
git remote add origin https://your-host/owner/inkling-data.git
git fetch origin || true # 빈 repo 면 무시
URL 형식 (둘 중 하나):
# 3. 자격증명 설정 (Windows Credential Manager 자동 / 또는 token 임베드 URL)
- SSH: `git@host:user/repo.git`
- HTTPS: `https://host/user/repo.git`
# 4. 첫 동기화: 트레이 → "지금 동기화"
```
`git@https://...` 같은 혼합 형식은 거부된다.
처음 sync 시 SyncService 가 `<profileDir>/sync/` 안에 F5 export 트리(notes/, media/, index.jsonl, manifest.json)를 덮어쓰고 `git add -A && git commit && git push -u origin <branch>` 를 자동 수행.
### 일상 사용
### 사용
- 자동 sync: 설정한 interval 마다 + 앱 종료 시 1회
- 수동 sync: 트레이 → "지금 동기화"
- 충돌 발생 시 트레이 토스트 + 설정 페이지의 "충돌 해결…" 버튼 → ConflictModal
- 트레이 → "지금 동기화" 로 수동 트리거
- 앱 종료 시 자동 1회 (sync dir 이 설정된 경우만)
- 변경 없으면 토스트 "변경 사항 없음", 변경 있으면 "동기화 완료"
### 충돌 해결 (ConflictModal)
설정이 안 됐으면 트레이 토스트로 안내. 한 번 설정하면 이후 push 는 OS credential helper 가 자동 처리.
같은 노트를 두 기기에서 동시에 수정하면 path 별 결정 (내 것 / 원격) 을 받는다.
데이터 라이프사이클 측면에서 F6-L1 (로컬 스냅샷, 자동) + F5/F6-L3 (수동 export/import) + F6-L2 (원격 git, 반자동) 3-layer 구조의 마지막 layer.
- **내 것 사용**: 이 기기의 변경을 보존, 원격 변경을 폐기
- **원격 사용**: 원격 변경을 가져오고, 이 기기의 변경을 폐기
3 케이스:
1. 편집/편집 — 양 텍스트 비교 후 더 새롭고 완전한 쪽 선택. 둘 다 보존하려면 한쪽 선택 + 사후 수동 병합 ('both' 미지원)
2. 삭제/편집 — 삭제가 의도였으면 원격 사용 (trash 측), 수정이 더 중요하면 내 것 사용 (편집 측 = trash 취소)
3. AI 결과 충돌 — 한쪽 선택 후 AI 재실행 권장
### Silent risk
- **시계 어긋남 (NTP)**: 양 기기 시계가 다르면 timestamp merge 가 잘못된 결과를 낼 수 있음. NTP 동기화 끄지 말 것
- **두 기기 동시 수정 회피**: 같은 노트를 동시에 수정하면 conflict 가 잦아짐
- **자동 sync 실패 silent**: 주기적 sync 실패 시 토스트 안 뜸. 마지막 sync 시각 / 결과는 설정 페이지에서 확인
### Troubleshoot
- **"연결 테스트" 실패** — 네트워크 (브라우저로 호스트 접속) / 인증 (SSH key 또는 token) / URL 오타 점검
- **인증 실패 (push 안 됨)** — SSH 는 public key 등록 점검, HTTPS 는 OS credential helper (Windows Credential Manager / macOS Keychain) 의 저장 token 점검
- **URL 변경** — 설정 페이지에서 새 URL 입력 → 저장 (`git remote set-url origin` 자동 처리)
데이터 라이프사이클: F6-L1 (로컬 스냅샷, 자동) + F5/F6-L3 (수동 export/import) + F21 Cut E (양방향 git sync) 3-layer 구조.
---

View File

@@ -0,0 +1,720 @@
# Sync 도움말 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** v0.3.0 Cut E 양방향 sync 의 사용자 도움말을 in-app modal + ConflictModal inline + README 3 표면에 도입. 다기기 dogfood 의 conflict 시나리오에 막힌 순간 결정 트리 / 자동 처리 동작 / silent risk / setup 인증 troubleshoot 4 카테고리를 즉시 찾을 수 있게.
**Architecture:** 신규 `SyncHelpModal` 컴포넌트 (4 anchor 섹션, ConflictModal 패턴 재사용) + ConflictModal 의 local/remote 옵션 inline 설명 + "자세히 보기" 링크 (`onOpenHelp` callback) + SyncSection 의 "도움말" 버튼이 modal mount/unmount 관리. README 의 stale "원격 백업 (F6-L2)" 섹션은 "동기화 (Git)" 로 통째 재작성.
**Tech Stack:** React 18 / TypeScript / vitest + @testing-library/react / electron-vite. 기존 ConflictModal 패턴 정합 (overlay + stopPropagation + 인라인 style object).
**Spec:** `docs/superpowers/specs/2026-05-10-sync-help-design.md`
---
## File Structure
**신규**:
- `src/renderer/inbox/components/SyncHelpModal.tsx` — 신규 modal, 4 anchor 섹션 (`#main-conflict`, `#auto`, `#silent`, `#setup`)
- `tests/unit/SyncHelpModal.test.tsx` — 렌더링 + close 회귀
**수정**:
- `src/renderer/inbox/components/ConflictModal.tsx``onOpenHelp` prop 추가, 각 옵션 inline 설명 + "자세히 보기" 링크
- `tests/unit/ConflictModal.test.tsx` — inline 설명 + 링크 회귀 추가
- `src/renderer/inbox/components/settings/SyncSection.tsx` — "도움말" 버튼 + `showHelp` state + `SyncHelpModal` mount + `ConflictModal``onOpenHelp` wiring
- `tests/unit/SyncSection.test.tsx` — 도움말 버튼 → modal open 회귀
- `README.md` line 193-223 — "원격 백업 (F6-L2)" 섹션 통째 재작성
---
## Task 1: SyncHelpModal 컴포넌트 (4 anchor 섹션 + close 동작)
**Files:**
- Create: `src/renderer/inbox/components/SyncHelpModal.tsx`
- Test: `tests/unit/SyncHelpModal.test.tsx`
- [ ] **Step 1: Write the failing test**
`tests/unit/SyncHelpModal.test.tsx` 신규 작성:
```tsx
// @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';
import { SyncHelpModal } from '../../src/renderer/inbox/components/SyncHelpModal';
describe('SyncHelpModal', () => {
beforeEach(() => {
cleanup();
});
it('4 섹션 헤더 렌더링', () => {
render(<SyncHelpModal onClose={() => {}} />);
expect(screen.getByRole('heading', { name: /충돌 해결/ })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: /자동 처리/ })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: /조용히 잘못될 수 있는/ })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: /Setup/ })).toBeInTheDocument();
});
it('각 섹션이 anchor id 보유', () => {
const { container } = render(<SyncHelpModal onClose={() => {}} />);
expect(container.querySelector('#main-conflict')).not.toBeNull();
expect(container.querySelector('#auto')).not.toBeNull();
expect(container.querySelector('#silent')).not.toBeNull();
expect(container.querySelector('#setup')).not.toBeNull();
});
it('초기 anchor prop 으로 해당 섹션 scrollIntoView 호출', () => {
const scrollSpy = vi.fn();
Element.prototype.scrollIntoView = scrollSpy;
render(<SyncHelpModal onClose={() => {}} initialAnchor="main-conflict" />);
expect(scrollSpy).toHaveBeenCalled();
});
it('X 버튼 클릭 → onClose 호출', () => {
const onClose = vi.fn();
render(<SyncHelpModal onClose={onClose} />);
fireEvent.click(screen.getByRole('button', { name: /닫기/ }));
expect(onClose).toHaveBeenCalled();
});
it('overlay 클릭 → onClose 호출', () => {
const onClose = vi.fn();
const { container } = render(<SyncHelpModal onClose={onClose} />);
const overlay = container.firstChild as HTMLElement;
fireEvent.click(overlay);
expect(onClose).toHaveBeenCalled();
});
it('modal body 클릭 → onClose 호출 X (stopPropagation)', () => {
const onClose = vi.fn();
render(<SyncHelpModal onClose={onClose} />);
fireEvent.click(screen.getByRole('heading', { name: /충돌 해결/ }));
expect(onClose).not.toHaveBeenCalled();
});
it('주요 시나리오 키워드 본문 포함 (회귀)', () => {
render(<SyncHelpModal onClose={() => {}} />);
// 메인 conflict 3 케이스
expect(screen.getByText(/편집\/편집/)).toBeInTheDocument();
expect(screen.getByText(/삭제\/편집/)).toBeInTheDocument();
expect(screen.getByText(/AI 결과 충돌/)).toBeInTheDocument();
// setup 의 잘못된 URL 형식 사례
expect(screen.getByText(/git@https:\/\//)).toBeInTheDocument();
});
});
```
- [ ] **Step 2: Run test to verify it fails**
```bash
cd C:\Users\rlaxo\inkling
npx vitest run tests/unit/SyncHelpModal.test.tsx
```
Expected: FAIL (module not found — `SyncHelpModal` 미존재)
- [ ] **Step 3: Implement SyncHelpModal**
`src/renderer/inbox/components/SyncHelpModal.tsx` 신규:
```tsx
import React, { useEffect, useRef } from 'react';
export type SyncHelpAnchor = 'main-conflict' | 'auto' | 'silent' | 'setup';
interface Props {
onClose: () => void;
initialAnchor?: SyncHelpAnchor;
}
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: 110
};
const modalStyle: React.CSSProperties = {
background: '#fff', borderRadius: 8, padding: 20, width: 640,
maxHeight: '80vh', overflow: 'auto', boxShadow: '0 4px 16px rgba(0,0,0,0.2)'
};
const sectionStyle: React.CSSProperties = {
marginTop: 18, paddingTop: 12, borderTop: '1px solid #eee'
};
const h4Style: React.CSSProperties = { fontSize: 14, margin: '0 0 8px 0' };
const pStyle: React.CSSProperties = { fontSize: 12, color: '#444', lineHeight: 1.6, margin: '4px 0' };
const liStyle: React.CSSProperties = { fontSize: 12, color: '#444', lineHeight: 1.6, marginBottom: 4 };
const codeStyle: React.CSSProperties = { background: '#f4f4f4', padding: '1px 4px', borderRadius: 3, fontSize: 11 };
export function SyncHelpModal({ onClose, initialAnchor }: Props): React.ReactElement {
const bodyRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!initialAnchor) return;
const el = bodyRef.current?.querySelector(`#${initialAnchor}`);
if (el) el.scrollIntoView({ behavior: 'auto', block: 'start' });
}, [initialAnchor]);
return (
<div style={overlayStyle} onClick={onClose}>
<div ref={bodyRef} style={modalStyle} onClick={(e) => e.stopPropagation()}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h3 style={{ margin: 0, fontSize: 16 }}> </h3>
<button onClick={onClose} aria-label="닫기" style={{ background: 'none', border: 'none', fontSize: 18, cursor: 'pointer', color: '#888' }}>×</button>
</div>
<section id="main-conflict" style={sectionStyle}>
<h4 style={h4Style}>1. ( )</h4>
<p style={pStyle}> . "충돌 해결…" ConflictModal path ( / ) .</p>
<p style={{ ...pStyle, marginTop: 10, fontWeight: 600 }}>/ </p>
<ul style={{ paddingLeft: 18, margin: '4px 0' }}>
<li style={liStyle}> ConflictModal </li>
<li style={liStyle}> 트리: 어느 </li>
<li style={liStyle}> ? 'both' ( )</li>
</ul>
<p style={{ ...pStyle, marginTop: 10, fontWeight: 600 }}>/</p>
<ul style={{ paddingLeft: 18, margin: '4px 0' }}>
<li style={liStyle}> trash , </li>
<li style={liStyle}>"삭제가 의도였다" (trash )</li>
<li style={liStyle}>"수정이 더 중요" ( = trash )</li>
</ul>
<p style={{ ...pStyle, marginTop: 10, fontWeight: 600 }}>AI </p>
<ul style={{ paddingLeft: 18, margin: '4px 0' }}>
<li style={liStyle}> AI ( / / ) </li>
<li style={liStyle}> AI ( )</li>
</ul>
</section>
<section id="auto" style={sectionStyle}>
<h4 style={h4Style}>2. ( )</h4>
<ul style={{ paddingLeft: 18, margin: '4px 0' }}>
<li style={liStyle}><b>fetch + rebase</b>: sync (linear history). conflict </li>
<li style={liStyle}><b> sync </b>: push . fetch rebase</li>
<li style={liStyle}><b>push (non-fast-forward)</b>: push fetch + rebase + . rebase conflict </li>
<li style={liStyle}><b> sync </b>: 30 ( ). 1 </li>
</ul>
</section>
<section id="silent" style={sectionStyle}>
<h4 style={h4Style}>3. (silent risk)</h4>
<ul style={{ paddingLeft: 18, margin: '4px 0' }}>
<li style={liStyle}><b> (NTP)</b>: timestamp merge . macOS / Windows NTP </li>
<li style={liStyle}><b> </b>: conflict . </li>
<li style={liStyle}><b> sync silent</b>: sync . sync / 1 </li>
</ul>
</section>
<section id="setup" style={sectionStyle}>
<h4 style={h4Style}>4. Setup / (troubleshoot)</h4>
<p style={{ ...pStyle, fontWeight: 600 }}>URL ( )</p>
<ul style={{ paddingLeft: 18, margin: '4px 0' }}>
<li style={liStyle}>SSH: <code style={codeStyle}>git@host:user/repo.git</code></li>
<li style={liStyle}>HTTPS: <code style={codeStyle}>https://host/user/repo.git</code></li>
<li style={liStyle}> : <code style={codeStyle}>git@https://...</code> 같은 혼합 형식 ✗</li>
</ul>
<p style={{ ...pStyle, fontWeight: 600, marginTop: 10 }}></p>
<ul style={{ paddingLeft: 18, margin: '4px 0' }}>
<li style={liStyle}>SSH: 기기에 SSH key + public key </li>
<li style={liStyle}>HTTPS: OS credential helper (Windows Credential Manager / macOS Keychain) push token . push X</li>
</ul>
<p style={{ ...pStyle, fontWeight: 600, marginTop: 10 }}>"연결 테스트" </p>
<ul style={{ paddingLeft: 18, margin: '4px 0' }}>
<li style={liStyle}>네트워크: 원격 host </li>
<li style={liStyle}>인증: </li>
<li style={liStyle}>URL: 형식 (SSH/HTTPS) + </li>
</ul>
<p style={{ ...pStyle, fontWeight: 600, marginTop: 10 }}></p>
<ul style={{ paddingLeft: 18, margin: '4px 0' }}>
<li style={liStyle}>URL URL . <code style={codeStyle}>git remote set-url origin</code> </li>
</ul>
</section>
</div>
</div>
);
}
```
- [ ] **Step 4: Run test to verify it passes**
```bash
npx vitest run tests/unit/SyncHelpModal.test.tsx
```
Expected: 7/7 PASS
- [ ] **Step 5: typecheck**
```bash
npx tsc --noEmit
```
Expected: 0 errors
- [ ] **Step 6: Commit**
```bash
git add src/renderer/inbox/components/SyncHelpModal.tsx tests/unit/SyncHelpModal.test.tsx
git commit -m "feat(sync-help): SyncHelpModal 4 anchor 섹션 (메인 conflict / 자동 / silent / setup)"
```
---
## Task 2: ConflictModal 갱신 — inline 설명 + "자세히 보기" 링크
**Files:**
- Modify: `src/renderer/inbox/components/ConflictModal.tsx`
- Modify: `tests/unit/ConflictModal.test.tsx`
- [ ] **Step 1: Update test**
`tests/unit/ConflictModal.test.tsx` 의 마지막에 두 케이스 추가 (기존 3 케이스 유지). 또한 mock 시그니처에 `onOpenHelp` 추가.
기존 코드:
```tsx
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 });
});
```
기존 3 it 블록은 그대로 (onOpenHelp optional 이라 미수정 호출 type-clean).
신규 2 케이스 추가:
```tsx
it('각 conflict row 에 local/remote inline 설명 표시', async () => {
render(<ConflictModal onClose={() => {}} onResolved={() => {}} onOpenHelp={() => {}} />);
await waitFor(() => screen.getByText(/local A/));
// 두 conflict row → inline 설명 2 회씩
expect(screen.getAllByText(/이 기기의 변경을 보존/).length).toBeGreaterThanOrEqual(2);
expect(screen.getAllByText(/원격의 변경을 가져오고/).length).toBeGreaterThanOrEqual(2);
});
it('"자세히 보기" 클릭 → onOpenHelp("main-conflict") 호출', async () => {
const onOpenHelp = vi.fn();
render(<ConflictModal onClose={() => {}} onResolved={() => {}} onOpenHelp={onOpenHelp} />);
await waitFor(() => screen.getByText(/local A/));
const links = screen.getAllByRole('button', { name: /자세히 보기/ });
fireEvent.click(links[0]!);
expect(onOpenHelp).toHaveBeenCalledWith('main-conflict');
});
it('onOpenHelp 미제공 → "자세히 보기" 링크 미렌더', async () => {
render(<ConflictModal onClose={() => {}} onResolved={() => {}} />);
await waitFor(() => screen.getByText(/local A/));
expect(screen.queryByRole('button', { name: /자세히 보기/ })).toBeNull();
});
});
```
- [ ] **Step 2: Run test to verify it fails**
```bash
npx vitest run tests/unit/ConflictModal.test.tsx
```
Expected: FAIL — `onOpenHelp` prop 미존재 / inline 설명 미표시 / "자세히 보기" 버튼 없음
- [ ] **Step 3: Update ConflictModal**
`src/renderer/inbox/components/ConflictModal.tsx`:
Props interface 갱신 (`onOpenHelp`**optional** — 없으면 "자세히 보기" 링크 미렌더. caller 가 wiring 하지 않은 환경에서 type-clean):
```tsx
interface Props {
onClose: () => void;
onResolved: () => void;
onOpenHelp?: (anchor: 'main-conflict' | 'auto' | 'silent' | 'setup') => void;
}
```
함수 signature:
```tsx
export function ConflictModal({ onClose, onResolved, onOpenHelp }: Props): React.ReactElement {
```
각 conflict row 의 button row 위에 inline 설명 + (조건부) "자세히 보기" 링크 삽입. 기존 button row (`<div style={{ marginTop: 8, ... }}>`) 직전에 추가:
```tsx
<div style={{ marginTop: 8, fontSize: 11, color: '#666', lineHeight: 1.5 }}>
<div><b> </b>: .</div>
<div><b> </b>: .</div>
{onOpenHelp && (
<button
onClick={() => onOpenHelp('main-conflict')}
style={{ background: 'none', border: 'none', color: '#0a4b80', cursor: 'pointer', fontSize: 11, padding: 0, marginTop: 2, textDecoration: 'underline' }}
>
</button>
)}
</div>
```
전체 row 변경 후 모습:
```tsx
{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, fontSize: 11, color: '#666', lineHeight: 1.5 }}>
<div><b> </b>: .</div>
<div><b> </b>: .</div>
<button
onClick={() => onOpenHelp('main-conflict')}
style={{ background: 'none', border: 'none', color: '#0a4b80', cursor: 'pointer', fontSize: 11, padding: 0, marginTop: 2, textDecoration: 'underline' }}
>
</button>
</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>
))}
```
- [ ] **Step 4: Run test to verify it passes**
```bash
npx vitest run tests/unit/ConflictModal.test.tsx
```
Expected: 6/6 PASS (기존 3 + 신규 3)
- [ ] **Step 5: typecheck**
```bash
npx tsc --noEmit
```
Expected: 0 errors (`onOpenHelp` 가 optional 이라 기존 SyncSection.tsx caller 그대로 type-clean. Task 3 에서 wiring).
- [ ] **Step 6: Commit**
```bash
git add src/renderer/inbox/components/ConflictModal.tsx tests/unit/ConflictModal.test.tsx
git commit -m "feat(sync-help): ConflictModal inline 설명 + 자세히 보기 링크 (onOpenHelp prop)"
```
---
## Task 3: SyncSection wiring — 도움말 버튼 + SyncHelpModal mount + ConflictModal onOpenHelp
**Files:**
- Modify: `src/renderer/inbox/components/settings/SyncSection.tsx`
- Modify: `tests/unit/SyncSection.test.tsx`
- [ ] **Step 1: Update test**
`tests/unit/SyncSection.test.tsx` 에 추가 (기존 케이스 유지):
```tsx
it('도움말 버튼 클릭 → SyncHelpModal open', async () => {
render(<SyncSection />);
await waitFor(() => screen.getByRole('button', { name: /저장/ }));
fireEvent.click(screen.getByRole('button', { name: /^도움말$/ }));
await waitFor(() => screen.getByRole('heading', { name: /동기화 도움말/ }));
expect(screen.getByRole('heading', { name: /동기화 도움말/ })).toBeInTheDocument();
});
```
- [ ] **Step 2: Run test to verify it fails**
```bash
npx vitest run tests/unit/SyncSection.test.tsx
```
Expected: FAIL — "도움말" 버튼 없음
- [ ] **Step 3: Update SyncSection**
`src/renderer/inbox/components/settings/SyncSection.tsx`:
상단 import 에 추가:
```tsx
import { SyncHelpModal, type SyncHelpAnchor } from '../SyncHelpModal.js';
```
state 추가 (`showConflict` 옆):
```tsx
const [showHelp, setShowHelp] = useState<{ open: boolean; anchor?: SyncHelpAnchor }>({ open: false });
```
URL row 의 버튼 영역에 "도움말" 버튼 추가 (연결 테스트 버튼 옆):
```tsx
<button onClick={() => setShowHelp({ open: true })} disabled={busy !== null} style={btnStyle()}>
</button>
```
`ConflictModal` 호출에 `onOpenHelp` 추가:
```tsx
{showConflict && (
<ConflictModal
onClose={() => setShowConflict(false)}
onResolved={async () => {
setStatus(await inboxApi.getSyncStatus());
}}
onOpenHelp={(anchor) => setShowHelp({ open: true, anchor })}
/>
)}
```
return 의 마지막 (section close 직전) 에 SyncHelpModal mount 추가:
```tsx
{showHelp.open && (
<SyncHelpModal
onClose={() => setShowHelp({ open: false })}
initialAnchor={showHelp.anchor}
/>
)}
```
- [ ] **Step 4: Run test to verify it passes**
```bash
npx vitest run tests/unit/SyncSection.test.tsx tests/unit/ConflictModal.test.tsx tests/unit/SyncHelpModal.test.tsx
```
Expected: 모두 PASS
- [ ] **Step 5: App.test.tsx / SettingsPage.test.tsx 에서 ConflictModal 깊이 호출 X 회귀 확인**
`tests/unit/App.test.tsx``tests/unit/SettingsPage.test.tsx` 는 SyncSection 을 mount 하지만 `showConflict=false` default 라 ConflictModal 직접 렌더 X. SyncHelpModal 도 default closed. mock 갱신 불필요.
```bash
npx vitest run tests/unit/App.test.tsx tests/unit/SettingsPage.test.tsx
```
Expected: PASS (mock 갱신 없이도 통과)
- [ ] **Step 6: 전체 typecheck + 단위**
```bash
npx tsc --noEmit && npx vitest run
```
Expected: 0 errors, 모두 PASS (총 +11 케이스: SyncHelpModal 7 + ConflictModal 회귀 3 + SyncSection 회귀 1, App/SettingsPage 무영향)
- [ ] **Step 7: Commit**
```bash
git add src/renderer/inbox/components/settings/SyncSection.tsx tests/unit/SyncSection.test.tsx
git commit -m "feat(sync-help): SyncSection 도움말 버튼 + SyncHelpModal mount + ConflictModal onOpenHelp wiring"
```
---
## Task 4: README "원격 백업 (F6-L2)" 섹션 통째 재작성 → "동기화 (Git, Cut E)"
**Files:**
- Modify: `README.md` line 193-223
- [ ] **Step 1: 기존 섹션 확인**
```bash
sed -n '193,223p' README.md
```
기대 출력: 옛 v0.2.1 MVP 안내 (`cd %APPDATA%\Inkling\...\sync` + 수동 `git init` + 트레이 "지금 동기화"). 본 섹션을 통째 교체.
- [ ] **Step 2: 섹션 교체 (Edit tool 사용)**
기존 텍스트 (line 193-223 전부):
```markdown
## 원격 백업 (선택, F6-L2)
Inkling 데이터를 사적 git 원격에 백업하려면 한 번만 설정하면 된다. 인코딩된 형식이 아니라 평문 마크다운(F5 export 형식)으로 저장되니, **반드시 비공개 repo** 를 사용한다.
### 일회 설정
```bash
# 1. 빈 사적 repo 생성 (예: gitea, GitHub private)
# 2. 데이터 디렉터리에 git 초기화 + 원격 등록
cd "%APPDATA%\Inkling\Inkling\profiles\default\sync" # Windows
git init
git remote add origin https://your-host/owner/inkling-data.git
git fetch origin || true # 빈 repo 면 무시
# 3. 자격증명 설정 (Windows Credential Manager 자동 / 또는 token 임베드 URL)
# 4. 첫 동기화: 트레이 → "지금 동기화"
```
처음 sync 시 SyncService 가 `<profileDir>/sync/` 안에 F5 export 트리(notes/, media/, index.jsonl, manifest.json)를 덮어쓰고 `git add -A && git commit && git push -u origin <branch>` 를 자동 수행.
### 사용
- 트레이 → "지금 동기화" 로 수동 트리거
- 앱 종료 시 자동 1회 (sync dir 이 설정된 경우만)
- 변경 없으면 토스트 "변경 사항 없음", 변경 있으면 "동기화 완료"
설정이 안 됐으면 트레이 토스트로 안내. 한 번 설정하면 이후 push 는 OS credential helper 가 자동 처리.
데이터 라이프사이클 측면에서 F6-L1 (로컬 스냅샷, 자동) + F5/F6-L3 (수동 export/import) + F6-L2 (원격 git, 반자동) 3-layer 구조의 마지막 layer.
```
신규 텍스트 (전체):
```markdown
## 동기화 (Git, F21 Cut E)
Inkling 데이터를 사적 git 원격으로 양방향 동기화 (Mac ↔ Windows 등). 평문 마크다운(F5 export 형식)으로 저장되니 **반드시 비공개 repo** 를 사용한다.
상세 도움말은 앱 내 설정 → 동기화 저장소 → "도움말" 버튼 (4 섹션 modal) 참조. 본 섹션은 setup + 주요 시나리오 요약.
### 일회 설정
1. 빈 사적 repo 생성 (Gitea / GitHub private)
2. 앱 → 설정 → 동기화 저장소 → URL 입력 → "저장"
3. "연결 테스트" 클릭해 인증 / 네트워크 확인
4. 자동 sync 사용 토글 + interval (기본 30분) 확인
URL 형식 (둘 중 하나):
- SSH: `git@host:user/repo.git`
- HTTPS: `https://host/user/repo.git`
`git@https://...` 같은 혼합 형식은 거부된다.
### 일상 사용
- 자동 sync: 설정한 interval 마다 + 앱 종료 시 1회
- 수동 sync: 트레이 → "지금 동기화"
- 충돌 발생 시 트레이 토스트 + 설정 페이지의 "충돌 해결…" 버튼 → ConflictModal
### 충돌 해결 (ConflictModal)
같은 노트를 두 기기에서 동시에 수정하면 path 별 결정 (내 것 / 원격) 을 받는다.
- **내 것 사용**: 이 기기의 변경을 보존, 원격 변경을 폐기
- **원격 사용**: 원격 변경을 가져오고, 이 기기의 변경을 폐기
3 케이스:
1. 편집/편집 — 양 텍스트 비교 후 더 새롭고 완전한 쪽 선택. 둘 다 보존하려면 한쪽 선택 + 사후 수동 병합 ('both' 미지원)
2. 삭제/편집 — 삭제가 의도였으면 원격 사용 (trash 측), 수정이 더 중요하면 내 것 사용 (편집 측 = trash 취소)
3. AI 결과 충돌 — 한쪽 선택 후 AI 재실행 권장
### Silent risk
- **시계 어긋남 (NTP)**: 양 기기 시계가 다르면 timestamp merge 가 잘못된 결과를 낼 수 있음. NTP 동기화 끄지 말 것
- **두 기기 동시 수정 회피**: 같은 노트를 동시에 수정하면 conflict 가 잦아짐
- **자동 sync 실패 silent**: 주기적 sync 실패 시 토스트 안 뜸. 마지막 sync 시각 / 결과는 설정 페이지에서 확인
### Troubleshoot
- **"연결 테스트" 실패** — 네트워크 (브라우저로 호스트 접속) / 인증 (SSH key 또는 token) / URL 오타 점검
- **인증 실패 (push 안 됨)** — SSH 는 public key 등록 점검, HTTPS 는 OS credential helper (Windows Credential Manager / macOS Keychain) 의 저장 token 점검
- **URL 변경** — 설정 페이지에서 새 URL 입력 → 저장 (`git remote set-url origin` 자동 처리)
데이터 라이프사이클: F6-L1 (로컬 스냅샷, 자동) + F5/F6-L3 (수동 export/import) + F21 Cut E (양방향 git sync) 3-layer 구조.
```
Edit tool 호출: `old_string` = 기존 텍스트 전체, `new_string` = 신규 텍스트 전체.
- [ ] **Step 3: Commit**
```bash
git add README.md
git commit -m "docs: README 동기화 섹션 Cut E 반영 — 양방향 sync + ConflictModal + Silent risk + Troubleshoot"
```
---
## Final Verification
- [ ] **Step 1: 전체 단위 + typecheck**
```bash
cd C:\Users\rlaxo\inkling
npx tsc --noEmit && npx vitest run
```
Expected: 0 type errors, 모든 테스트 PASS (직전 base 대비 +11: SyncHelpModal 7 + ConflictModal 회귀 3 + SyncSection 회귀 1)
- [ ] **Step 2: 수동 smoke (electron dev)**
```bash
npm run dev
```
확인:
- 설정 → 동기화 저장소 → "도움말" 버튼 클릭 → SyncHelpModal 4 섹션 표시 + ESC/X/overlay close
- 충돌이 있는 상태에서 "충돌 해결…" → ConflictModal → "자세히 보기" 클릭 → SyncHelpModal 이 메인 conflict 섹션 (#main-conflict) 으로 scroll 된 채 open
- README 변경 사항을 GitHub/Gitea 웹에서 렌더링 정상 (헤더 / 코드 펜스 / 리스트)
- [ ] **Step 3: 모든 commit history 확인**
```bash
git log --oneline -6
```
Expected:
```text
xxxxxxx docs: README 동기화 섹션 Cut E 반영 — ...
xxxxxxx feat(sync-help): SyncSection 도움말 버튼 + SyncHelpModal mount + ConflictModal onOpenHelp wiring
xxxxxxx feat(sync-help): ConflictModal inline 설명 + 자세히 보기 링크 (onOpenHelp prop)
xxxxxxx feat(sync-help): SyncHelpModal 4 anchor 섹션 (메인 conflict / 자동 / silent / setup)
xxxxxxx docs(spec): sync 도움말 v0.3.4 — SyncHelpModal + ConflictModal inline + README
...
```

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,841 @@
# v0.3.1 Cut F — 멀티모달 vision AI Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** F24 — Ollama vision 모델 (gemma3 family default) 활용. 이미지 + raw_text 결합 prompt → title/summary/tags 자동 생성. Capability detection (app launch + manual refresh) + InferenceProvider 확장 + AiWorker 통합 + Configure UI dropdown.
**Architecture:** `isVisionCapable(model)` pure 함수 가 family/families/name 기반으로 vision 가능 모델 판정. `refreshVisionCache(deps)``/api/tags` 호출 후 settings 에 cache. AiWorker 가 `note.media.length > 0 && visionModel` 둘 다 충족 시 vision path (5MB cap + base64 변환). Configure UI 가 cache 기반 dropdown + manual refresh.
**Tech Stack:** undici/fetch (Ollama API), Node fs/promises (이미지 base64 변환), Electron IPC, React 19 + zustand 5, vitest 4 + RTL.
**선행 문서:**
- `docs/superpowers/specs/2026-05-09-v031-cut-f-design.md` — source spec (Cut F 정정 반영: 단위 679, 실제 SettingsService API, 'skipped' enum 미도입, fallback 미구현)
- `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` — F24
- `docs/superpowers/strategy/v028plus-roadmap.md` — Cut F 위치
---
## File Structure
**Create:**
- `src/main/services/VisionDetect.ts``isVisionCapable(model)` pure + `refreshVisionCache(deps)` async (Ollama /api/tags)
- `src/main/ai/visionPrompt.ts``buildVisionPrompt(text, todayKst, dueCandidates, vocab)` pure
- `src/renderer/inbox/components/settings/VisionSection.tsx` — AI 제공자 섹션 안 또는 별도 sub-section. dropdown + 다시 감지 버튼
- `tests/unit/VisionDetect.test.ts` — isVisionCapable 5 + refreshVisionCache 4
- `tests/unit/visionPrompt.test.ts` — buildVisionPrompt 2 (text only / image-only fallback)
- `tests/unit/AiWorker.vision.test.ts` — vision path 3 (text-only / vision body / 5MB cap)
- `tests/unit/VisionSection.test.tsx` — UI 1 (dropdown + 다시 감지)
**Modify:**
- `src/main/services/SettingsService.ts` — zod schema vision_model / vision_capable_cache / vision_cache_at + 4 메서드
- `src/main/ai/InferenceProvider.ts``GenerateInput.images?: Array<{ base64: string; mime: string }>` + `generate(input, opts?: { visionModel?: string | null })`
- `src/main/ai/LocalOllamaProvider.ts``generate` body 에 `images` 필드 (vision path) + 모델 분기
- `src/main/ai/AiWorker.ts``note.media + visionModel` vision path + 5MB cap + base64 변환. 생성자에 `settings: SettingsService` 의존성 추가
- `src/main/ipc/settingsApi.ts` — 3 IPC: `settings:get-vision-models` / `settings:set-vision-model` / `settings:refresh-vision-cache`
- `src/preload/index.ts` — 3 bridge
- `src/shared/types.ts``getSettings()` 반환에 vision_* 3 필드 + InboxApi 3 메서드
- `src/main/index.ts``void refreshVisionCache(...)` whenReady 안 + AiWorker 생성자에 settings 주입
- `src/renderer/inbox/components/settings/AiProviderSection.tsx` 또는 SettingsPage — VisionSection 마운트
- `tests/unit/SettingsService.test.ts` — vision 4 메서드 round-trip
- `tests/unit/LocalOllamaProvider.test.ts` — vision body 분기 회귀
- `tests/unit/AiWorker.test.ts` — 기존 mock 에 settings stub 추가 (생성자 변경)
- `package.json` — version 0.3.0 → 0.3.1
- `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` — F24 promoted
---
## 단위 목표
679 (v0.3.0) → 약 701 (+22), typecheck 0.
---
## Task 1: VisionDetect — `isVisionCapable` + `refreshVisionCache`
**Files:**
- Create: `src/main/services/VisionDetect.ts`
- Create: `tests/unit/VisionDetect.test.ts`
`isVisionCapable(model)` pure 함수 — family/families/name hints 기반 판정. `refreshVisionCache(deps)` async — `/api/tags` 호출 후 capable 추출 + settings cache 저장. fetch 주입 가능 (테스트).
- [ ] **Step 1: failing test**`tests/unit/VisionDetect.test.ts`:
```ts
import { describe, it, expect, vi } from 'vitest';
import { isVisionCapable, refreshVisionCache } from '../../src/main/services/VisionDetect.js';
describe('isVisionCapable', () => {
it('family=gemma3 → true', () => {
expect(isVisionCapable({ name: 'gemma3:12b', details: { family: 'gemma3' } })).toBe(true);
});
it('families=[llava] → true', () => {
expect(isVisionCapable({ name: 'llava-13b', details: { families: ['llava'] } })).toBe(true);
});
it('name hint "vision" → true', () => {
expect(isVisionCapable({ name: 'custom-vision-7b' })).toBe(true);
});
it('text-only family=gemma → false', () => {
expect(isVisionCapable({ name: 'gemma4:e4b', details: { family: 'gemma' } })).toBe(false);
});
it('no hints + unknown family → false', () => {
expect(isVisionCapable({ name: 'mistral:7b', details: { family: 'mistral' } })).toBe(false);
});
});
describe('refreshVisionCache', () => {
it('happy path — capable 추출 + settings cache 저장', async () => {
const settings = {
isAiEnabled: vi.fn(async () => true),
setVisionCapableCache: vi.fn(async () => {})
};
const fetchImpl = vi.fn(async () => ({
ok: true,
status: 200,
json: async () => ({
models: [
{ name: 'gemma4:e4b', details: { family: 'gemma' } },
{ name: 'gemma3:12b-vision', details: { family: 'gemma3' } },
{ name: 'llava:13b', details: { families: ['llava'] } }
]
})
})) as unknown as typeof fetch;
const r = await refreshVisionCache({
settings: settings as never,
endpoint: 'http://localhost:11434',
fetchImpl
});
expect(r).toEqual({ ok: true, models: ['gemma3:12b-vision', 'llava:13b'] });
expect(settings.setVisionCapableCache).toHaveBeenCalledWith(['gemma3:12b-vision', 'llava:13b'], expect.any(Date));
});
it('ai_disabled → 스킵', async () => {
const settings = {
isAiEnabled: vi.fn(async () => false),
setVisionCapableCache: vi.fn(async () => {})
};
const r = await refreshVisionCache({ settings: settings as never, endpoint: 'http://x' });
expect(r).toEqual({ ok: false, reason: 'ai_disabled' });
expect(settings.setVisionCapableCache).not.toHaveBeenCalled();
});
it('http error → ok:false', async () => {
const settings = {
isAiEnabled: vi.fn(async () => true),
setVisionCapableCache: vi.fn(async () => {})
};
const fetchImpl = vi.fn(async () => ({
ok: false,
status: 500,
json: async () => ({})
})) as unknown as typeof fetch;
const r = await refreshVisionCache({ settings: settings as never, endpoint: 'http://x', fetchImpl });
expect(r).toMatchObject({ ok: false });
expect(settings.setVisionCapableCache).not.toHaveBeenCalled();
});
it('unreachable → ok:false', async () => {
const settings = {
isAiEnabled: vi.fn(async () => true),
setVisionCapableCache: vi.fn(async () => {})
};
const fetchImpl = vi.fn(async () => { throw new Error('ECONNREFUSED'); }) as unknown as typeof fetch;
const r = await refreshVisionCache({ settings: settings as never, endpoint: 'http://x', fetchImpl });
expect(r).toMatchObject({ ok: false });
});
});
```
- [ ] **Step 2: implementation**`src/main/services/VisionDetect.ts`:
```ts
import type { SettingsService } from './SettingsService.js';
const VISION_FAMILIES = new Set(['gemma3', 'llava', 'llama3.2-vision', 'minicpm-v', 'pixtral']);
const VISION_NAME_HINTS = ['vision', 'vl', 'multimodal', 'gemma3'];
export interface OllamaModel {
name: string;
details?: { family?: string; families?: string[] };
}
export function isVisionCapable(model: OllamaModel): boolean {
if (model.details?.family && VISION_FAMILIES.has(model.details.family)) return true;
if (model.details?.families?.some((f) => VISION_FAMILIES.has(f))) return true;
const lower = model.name.toLowerCase();
return VISION_NAME_HINTS.some((h) => lower.includes(h));
}
export interface RefreshDeps {
settings: SettingsService;
endpoint: string;
now?: () => Date;
fetchImpl?: typeof fetch;
}
export async function refreshVisionCache(
deps: RefreshDeps
): Promise<{ ok: true; models: string[] } | { ok: false; reason: string }> {
if (!(await deps.settings.isAiEnabled())) {
return { ok: false, reason: 'ai_disabled' };
}
const fetchFn = deps.fetchImpl ?? fetch;
let body: { models?: OllamaModel[] };
try {
const r = await fetchFn(`${deps.endpoint}/api/tags`);
if (!r.ok) return { ok: false, reason: `tags http ${r.status}` };
body = (await r.json()) as { models?: OllamaModel[] };
} catch (e) {
return { ok: false, reason: `unreachable: ${(e as Error).message}` };
}
const capable = (body.models ?? []).filter(isVisionCapable).map((m) => m.name);
const now = deps.now ? deps.now() : new Date();
await deps.settings.setVisionCapableCache(capable, now);
return { ok: true, models: capable };
}
```
- [ ] **Step 3: PASS + commit**
```bash
npm run typecheck
npx vitest run tests/unit/VisionDetect.test.ts
git add src/main/services/VisionDetect.ts tests/unit/VisionDetect.test.ts
git commit -m "feat(v031): VisionDetect — isVisionCapable + refreshVisionCache (fetch 주입)"
```
---
## Task 2: SettingsService — vision_model / vision_capable_cache + 4 메서드
**Files:**
- Modify: `src/main/services/SettingsService.ts`
- Modify: `tests/unit/SettingsService.test.ts`
zod schema 확장 + 4 메서드 추가 (Cut E sync_* 패턴).
- [ ] **Step 1: zod schema 확장**`src/main/services/SettingsService.ts`:
```ts
const SettingsSchema = z.object({
ollama: OllamaSettingsSchema.optional(),
ai_enabled: z.boolean().optional(),
onboarding_completed: z.boolean().optional(),
sync_repo_url: z.string().nullable().optional(),
sync_auto_enabled: z.boolean().optional(),
sync_interval_min: z.number().int().min(5).optional(),
// v0.3.1 Cut F — vision 모델 (이미지 분석). null/없음 = 비활성.
vision_model: z.string().nullable().optional(),
vision_capable_cache: z.array(z.string()).optional(),
vision_cache_at: z.string().optional()
}).strict();
```
- [ ] **Step 2: 4 메서드 추가** (`setSyncIntervalMin` 다음):
```ts
async getVisionModel(): Promise<string | null> {
const s = await this.load();
return s.vision_model ?? null;
}
async setVisionModel(value: string | null): Promise<void> {
const current = await this.load();
const next: Settings = { ...current, vision_model: value };
await this.persist(next);
}
async getVisionCapableCache(): Promise<{ models: string[]; at: string | null }> {
const s = await this.load();
return { models: s.vision_capable_cache ?? [], at: s.vision_cache_at ?? null };
}
async setVisionCapableCache(models: string[], now: Date): Promise<void> {
const current = await this.load();
const next: Settings = { ...current, vision_capable_cache: models, vision_cache_at: now.toISOString() };
await this.persist(next);
}
```
- [ ] **Step 3: failing test**`tests/unit/SettingsService.test.ts` 의 마지막 describe (Cut E sync) 다음에 추가:
```ts
describe('v0.3.1 Cut F — vision settings', () => {
it('getVisionModel() 기본 null', async () => {
expect(await svc.getVisionModel()).toBeNull();
});
it('setVisionModel / getVisionModel round-trip + null clear', async () => {
await svc.setVisionModel('gemma3:12b-vision');
expect(await svc.getVisionModel()).toBe('gemma3:12b-vision');
await svc.setVisionModel(null);
expect(await svc.getVisionModel()).toBeNull();
});
it('getVisionCapableCache() 기본 빈 배열 + null at', async () => {
expect(await svc.getVisionCapableCache()).toEqual({ models: [], at: null });
});
it('setVisionCapableCache 저장 + at ISO', async () => {
const at = new Date('2026-05-10T05:00:00Z');
await svc.setVisionCapableCache(['gemma3:12b', 'llava:13b'], at);
const r = await svc.getVisionCapableCache();
expect(r.models).toEqual(['gemma3:12b', 'llava:13b']);
expect(r.at).toBe('2026-05-10T05:00:00.000Z');
});
});
```
- [ ] **Step 4: PASS + commit**
```bash
npm run typecheck
npx vitest run tests/unit/SettingsService.test.ts
git add src/main/services/SettingsService.ts tests/unit/SettingsService.test.ts
git commit -m "feat(v031): SettingsService.{getVisionModel,setVisionModel,getVisionCapableCache,setVisionCapableCache}"
```
---
## Task 3: visionPrompt + InferenceProvider 인터페이스 확장
**Files:**
- Create: `src/main/ai/visionPrompt.ts`
- Modify: `src/main/ai/InferenceProvider.ts`
- Create: `tests/unit/visionPrompt.test.ts`
`buildVisionPrompt(text, todayKst, dueCandidates, vocab)` pure — 이미지 + raw_text 결합 시나리오. 빈 text 도 처리 ("(이미지만 있음)" placeholder).
- [ ] **Step 1: failing test**`tests/unit/visionPrompt.test.ts`:
```ts
import { describe, it, expect } from 'vitest';
import { buildVisionPrompt } from '../../src/main/ai/visionPrompt.js';
describe('buildVisionPrompt', () => {
it('text + 이미지 시 메모 본문 포함', () => {
const r = buildVisionPrompt('회의 메모', '2026-05-10', ['2026-05-10'], ['회의']);
expect(r).toContain('회의 메모');
expect(r).toContain('2026-05-10');
expect(r).toContain('회의');
});
it('빈 text → "(이미지만 있음)" placeholder', () => {
const r = buildVisionPrompt('', '2026-05-10', [], []);
expect(r).toContain('(이미지만 있음)');
});
});
```
- [ ] **Step 2: implementation**`src/main/ai/visionPrompt.ts`:
```ts
/**
* v0.3.1 Cut F — 멀티모달 vision prompt. 이미지 + raw_text 결합 분석 후
* title/summary/tags/due_date JSON 응답 요청. 빈 raw_text 도 처리.
*/
export function buildVisionPrompt(
text: string,
todayKst: string,
dueCandidates: string[],
vocab: string[]
): string {
return `다음 메모와 첨부 이미지를 종합 분석해 한국어로 요약하세요.
메모 본문 (비어 있을 수 있음):
${text || '(이미지만 있음)'}
이미지 분석 시 주요 시각적 정보 (텍스트, 사람, 장면) 도 포함해 요약하세요.
출력 JSON: { "title": "...", "summary": "...", "tags": [...], "due_date": "..." }
오늘: ${todayKst}
가능한 due 후보: ${dueCandidates.join(', ')}
빈출 태그: ${vocab.slice(0, 20).join(', ')}`;
}
```
- [ ] **Step 3: InferenceProvider 인터페이스 확장**`src/main/ai/InferenceProvider.ts`:
```ts
export interface GenerateInput {
text: string;
todayKst: string;
dueDateCandidates: string[];
vocab?: string[];
// v0.3.1 Cut F — 멀티모달 vision (옵션). LocalOllamaProvider 가 visionModel 과 함께 처리.
images?: Array<{ base64: string; mime: string }>;
}
export interface GenerateOptions {
visionModel?: string | null;
}
export interface InferenceProvider {
generate(input: GenerateInput, opts?: GenerateOptions): Promise<AiResponse>;
// ... 기존 abort / generateRaw
}
```
(기존 호출자는 `opts` 미전달이라 호환 — vision path off.)
- [ ] **Step 4: PASS + commit**
```bash
npm run typecheck
npx vitest run tests/unit/visionPrompt.test.ts
git add src/main/ai/visionPrompt.ts src/main/ai/InferenceProvider.ts tests/unit/visionPrompt.test.ts
git commit -m "feat(v031): buildVisionPrompt + GenerateInput.images + GenerateOptions.visionModel"
```
---
## Task 4: LocalOllamaProvider — vision path
**Files:**
- Modify: `src/main/ai/LocalOllamaProvider.ts`
- Modify: `tests/unit/LocalOllamaProvider.test.ts`
`generate(input, opts)``opts.visionModel + input.images` 둘 다 있으면 vision body 생성 (model = visionModel, prompt = buildVisionPrompt, body.images = base64 array). 그 외는 기존 text-only path.
- [ ] **Step 1: failing test** — 기존 `LocalOllamaProvider.test.ts` 의 적절한 describe 안:
```ts
describe('vision path (v0.3.1 Cut F)', () => {
it('opts.visionModel + input.images 둘 다 있으면 vision body', async () => {
let captured: { model?: string; prompt?: string; images?: string[] } = {};
const undici = await import('undici');
const requestSpy = vi.spyOn(undici, 'request').mockImplementation(async (_url, init) => {
captured = JSON.parse(init?.body as string);
return {
statusCode: 200,
body: { json: async () => ({ response: '{"title":"t","summary":"s","tags":[],"due_date":null}' }) }
} as never;
});
const provider = new LocalOllamaProvider({ endpoint: 'http://x', model: 'gemma4:e4b' });
await provider.generate(
{ text: 'hi', todayKst: '2026-05-10', dueDateCandidates: [], images: [{ base64: 'AAAA', mime: 'image/png' }] },
{ visionModel: 'gemma3:12b-vision' }
);
expect(captured.model).toBe('gemma3:12b-vision');
expect(captured.prompt).toContain('이미지');
expect(captured.images).toEqual(['AAAA']);
requestSpy.mockRestore();
});
it('visionModel 있어도 images 없으면 text-only path', async () => {
let captured: { model?: string; images?: unknown } = {};
const undici = await import('undici');
const requestSpy = vi.spyOn(undici, 'request').mockImplementation(async (_url, init) => {
captured = JSON.parse(init?.body as string);
return {
statusCode: 200,
body: { json: async () => ({ response: '{"title":"t","summary":"s","tags":[],"due_date":null}' }) }
} as never;
});
const provider = new LocalOllamaProvider({ endpoint: 'http://x', model: 'gemma4:e4b' });
await provider.generate(
{ text: 'hi', todayKst: '2026-05-10', dueDateCandidates: [] },
{ visionModel: 'gemma3:12b-vision' }
);
expect(captured.model).toBe('gemma4:e4b');
expect(captured.images).toBeUndefined();
requestSpy.mockRestore();
});
it('opts 미전달 → 기존 text-only (회귀)', async () => {
let captured: { model?: string; images?: unknown } = {};
const undici = await import('undici');
const requestSpy = vi.spyOn(undici, 'request').mockImplementation(async (_url, init) => {
captured = JSON.parse(init?.body as string);
return {
statusCode: 200,
body: { json: async () => ({ response: '{"title":"t","summary":"s","tags":[],"due_date":null}' }) }
} as never;
});
const provider = new LocalOllamaProvider({ endpoint: 'http://x', model: 'gemma4:e4b' });
await provider.generate({ text: 'hi', todayKst: '2026-05-10', dueDateCandidates: [] });
expect(captured.model).toBe('gemma4:e4b');
expect(captured.images).toBeUndefined();
requestSpy.mockRestore();
});
});
```
(기존 LocalOllamaProvider.test.ts 의 mock 패턴 따름. test file 의 imports + vi.mock 은 그대로 사용.)
- [ ] **Step 2: implementation**`LocalOllamaProvider.generate` body 분기:
```ts
import { buildVisionPrompt } from './visionPrompt.js';
// ...
async generate(input: GenerateInput, opts?: GenerateOptions): Promise<AiResponse> {
const useVision = !!opts?.visionModel && (input.images?.length ?? 0) > 0;
const model = useVision ? opts!.visionModel! : this.model;
const prompt = useVision
? buildVisionPrompt(input.text, input.todayKst, input.dueDateCandidates, input.vocab ?? [])
: buildPrompt(input.text, input.todayKst, input.dueDateCandidates, input.vocab ?? []);
this.abortController = new AbortController();
const timer = setTimeout(() => this.abortController?.abort(), this.timeoutMs);
try {
const body: Record<string, unknown> = {
model,
prompt,
format: 'json',
stream: false,
options: { temperature: this.temperature, num_predict: this.numPredict }
};
if (useVision) {
body.images = input.images!.map((i) => i.base64);
}
const res = await request(`${this.endpoint}/api/generate`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
signal: this.abortController.signal
});
// ... 기존 parse
} finally {
// ...
}
}
```
- [ ] **Step 3: PASS + commit**
```bash
npm run typecheck
npx vitest run tests/unit/LocalOllamaProvider.test.ts
git add src/main/ai/LocalOllamaProvider.ts tests/unit/LocalOllamaProvider.test.ts
git commit -m "feat(v031): LocalOllamaProvider vision path (visionModel + images → body.images base64)"
```
---
## Task 5: AiWorker — vision integration + 5MB cap + settings 의존성
**Files:**
- Modify: `src/main/ai/AiWorker.ts`
- Modify: `tests/unit/AiWorker.test.ts`
- Create: `tests/unit/AiWorker.vision.test.ts`
AiWorker 가 `note.media + visionModel` 조건에서 base64 변환 (5MB cap) + provider.generate 에 images + visionModel 전달. 생성자에 `settings: SettingsService` 의존성 추가.
- [ ] **Step 1: AiWorker 생성자 변경** — settings 파라미터 추가. `src/main/index.ts` 의 인스턴스 생성도 갱신.
- [ ] **Step 2: AiWorker.processJob 갱신**:
```ts
import { readFile } from 'node:fs/promises';
// 클래스 안 generate 호출 직전:
const visionModel = await this.settings.getVisionModel();
let images: Array<{ base64: string; mime: string }> | undefined;
if (visionModel && note.media.length > 0) {
images = await Promise.all(
note.media.map(async (m) => {
const buf = await readFile(this.mediaStore.absolutePath(m.relPath));
if (buf.byteLength > 5 * 1024 * 1024) {
throw new Error(`image ${m.relPath} exceeds 5MB cap`);
}
return { base64: buf.toString('base64'), mime: m.mime };
})
);
}
const res = await this.holder.get().generate(
{ text: note.rawText, images, todayKst: todayIso, dueDateCandidates: candidates, vocab },
{ visionModel: visionModel ?? undefined }
);
```
`mediaStore: MediaStore` 도 AiWorker 생성자에 신규 파라미터 (현재 없으면 추가; main 에서 주입).
- [ ] **Step 3: failing test**`tests/unit/AiWorker.vision.test.ts`:
```ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { writeFile, mkdtemp, mkdir, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import Database from 'better-sqlite3';
import { runMigrations } from '../../src/main/db/migrations/index.js';
import { NoteRepository } from '../../src/main/repository/NoteRepository.js';
import { AiWorker } from '../../src/main/ai/AiWorker.js';
import { MediaStore } from '../../src/main/services/MediaStore.js';
describe('AiWorker — vision path (v0.3.1 Cut F)', () => {
let db: Database.Database;
let repo: NoteRepository;
let workDir: string;
let mediaStore: MediaStore;
beforeEach(async () => {
db = new Database(':memory:');
db.pragma('foreign_keys = ON');
runMigrations(db);
repo = new NoteRepository(db);
workDir = await mkdtemp(join(tmpdir(), 'inkling-vision-'));
mediaStore = new MediaStore(workDir);
});
afterEach(async () => {
db.close();
await rm(workDir, { recursive: true, force: true });
});
it('visionModel + media 있음 → provider.generate 가 images + opts 받음', async () => {
const { id } = repo.create({ rawText: '이미지 메모' });
const mediaPath = join(workDir, 'media', id, '1.png');
await mkdir(join(workDir, 'media', id), { recursive: true });
await writeFile(mediaPath, Buffer.from([0x89, 0x50, 0x4e, 0x47])); // 4 bytes PNG-ish
repo.insertMedia([{ noteId: id, kind: 'image', relPath: `media/${id}/1.png`, mime: 'image/png', bytes: 4 }]);
const generate = vi.fn(async () => ({ title: 't', summary: 's', tags: [], dueDate: null }));
const provider = { name: 'fake', generate, abort: () => {} };
const settings = {
getVisionModel: vi.fn(async () => 'gemma3:12b-vision'),
isAiEnabled: vi.fn(async () => true)
} as unknown as never;
const worker = new AiWorker(/* ...deps with settings + mediaStore + repo + holder = { get: () => provider } */);
await worker['processJob']({ noteId: id, attempts: 0, nextRunAt: '' });
expect(generate).toHaveBeenCalledWith(
expect.objectContaining({ images: expect.any(Array) }),
expect.objectContaining({ visionModel: 'gemma3:12b-vision' })
);
const callArg = generate.mock.calls[0]![0] as { images: Array<{ base64: string; mime: string }> };
expect(callArg.images).toHaveLength(1);
expect(callArg.images[0]!.mime).toBe('image/png');
});
it('visionModel 없으면 text-only (회귀)', async () => {
const { id } = repo.create({ rawText: 'just text' });
const generate = vi.fn(async () => ({ title: 't', summary: 's', tags: [], dueDate: null }));
const provider = { name: 'fake', generate, abort: () => {} };
const settings = {
getVisionModel: vi.fn(async () => null),
isAiEnabled: vi.fn(async () => true)
} as unknown as never;
const worker = new AiWorker(/* ... */);
await worker['processJob']({ noteId: id, attempts: 0, nextRunAt: '' });
expect(generate).toHaveBeenCalledWith(
expect.not.objectContaining({ images: expect.anything() }),
expect.any(Object)
);
});
it('5MB 초과 이미지 → throw → ai_status=failed', async () => {
const { id } = repo.create({ rawText: 'big image' });
const mediaPath = join(workDir, 'media', id, '1.png');
await mkdir(join(workDir, 'media', id), { recursive: true });
await writeFile(mediaPath, Buffer.alloc(6 * 1024 * 1024)); // 6 MB
repo.insertMedia([{ noteId: id, kind: 'image', relPath: `media/${id}/1.png`, mime: 'image/png', bytes: 6 * 1024 * 1024 }]);
const generate = vi.fn(async () => ({ title: 't', summary: 's', tags: [], dueDate: null }));
const settings = {
getVisionModel: vi.fn(async () => 'gemma3:12b-vision'),
isAiEnabled: vi.fn(async () => true)
} as unknown as never;
const worker = new AiWorker(/* ... */);
await worker['processJob']({ noteId: id, attempts: 0, nextRunAt: '' });
// 5MB cap 초과 throw → AiWorker 의 attempts 증가 분기 → ai_status='failed'
const note = repo.findById(id);
expect(['failed', 'pending']).toContain(note!.aiStatus); // attempts 모두 소진 시 'failed'; 첫 시도 throw 시 'pending' 유지 가능 — 구현 의존
});
});
```
(NOTE: 정확한 AiWorker 생성자 인자 — 기존 test 의 setup 패턴 따라 deps 전체 stub 구성. 위 코드는 outline; 실수행자가 기존 `AiWorker.test.ts` setup 참고하여 정확한 deps 구조 채움.)
- [ ] **Step 4: 기존 AiWorker.test.ts mock 갱신** — 생성자에 `settings` / `mediaStore` 파라미터 추가됨. 모든 기존 test 의 worker 생성 site 에 stub 추가.
- [ ] **Step 5: PASS + commit**
```bash
npm run typecheck
npx vitest run tests/unit/AiWorker.test.ts tests/unit/AiWorker.vision.test.ts
git add src/main/ai/AiWorker.ts \
src/main/index.ts \
tests/unit/AiWorker.test.ts \
tests/unit/AiWorker.vision.test.ts
git commit -m "feat(v031): AiWorker vision integration — note.media + visionModel + 5MB cap"
```
---
## Task 6: types + IPC + preload
**Files:**
- Modify: `src/shared/types.ts``getSettings()` 반환에 vision_model / vision_capable_cache / vision_cache_at + InboxApi 3 메서드
- Modify: `src/main/ipc/settingsApi.ts` — 3 IPC handler
- Modify: `src/preload/index.ts` — 3 bridge
- Create: `tests/unit/vision-ipc.test.ts`
3 채널:
- `settings:get-vision-models``{ models: string[]; at: string | null; selected: string | null }` (cache 결과 + 현재 선택)
- `settings:set-vision-model` (value: string | null) → `{ ok: true }`
- `settings:refresh-vision-cache``{ ok: true; models: string[] } | { ok: false; reason: string }` (refreshVisionCache 호출)
상세 패턴은 Cut E sync IPC 와 동일.
- [ ] **Step 1: types** + **Step 2: failing test** + **Step 3: handlers** + **Step 4: preload bridges** — Cut E sync-ipc 패턴 그대로
- [ ] **Step 5: PASS + commit**
```bash
git add src/shared/types.ts src/main/ipc/settingsApi.ts src/preload/index.ts tests/unit/vision-ipc.test.ts
git commit -m "feat(v031): vision IPC + preload (get-vision-models / set / refresh)"
```
---
## Task 7: VisionSection UI + AI 제공자 섹션 통합
**Files:**
- Create: `src/renderer/inbox/components/settings/VisionSection.tsx`
- Modify: `src/renderer/inbox/components/settings/AiProviderSection.tsx` 또는 SettingsPage — 마운트
- Create: `tests/unit/VisionSection.test.tsx`
dropdown (cache 기반) + 다시 감지 버튼 + 마지막 감지 시각 표시. dropdown 변경 시 `setVisionModel` 호출. 다시 감지 → `refreshVisionCache` IPC + dropdown 갱신.
```tsx
// 핵심 구조 (Cut E SyncSection 패턴)
const [models, setModels] = useState<string[]>([]);
const [at, setAt] = useState<string | null>(null);
const [selected, setSelected] = useState<string | null>(null);
const [busy, setBusy] = useState<'select' | 'refresh' | null>(null);
useEffect(() => {
void (async () => {
const r = await inboxApi.getVisionModels();
setModels(r.models);
setAt(r.at);
setSelected(r.selected);
})();
}, []);
async function onSelect(value: string) {
setBusy('select');
await inboxApi.setVisionModel(value === '' ? null : value);
setSelected(value === '' ? null : value);
setBusy(null);
}
async function onRefresh() {
setBusy('refresh');
const r = await inboxApi.refreshVisionCache();
setBusy(null);
if (r.ok) {
const cache = await inboxApi.getVisionModels();
setModels(cache.models);
setAt(cache.at);
}
}
```
UI:
```tsx
<select value={selected ?? ''} onChange={(e) => void onSelect(e.target.value)} aria-label="이미지 분석 모델">
<option value="">()</option>
{models.map((m) => <option key={m} value={m}>{m}</option>)}
</select>
<button onClick={() => void onRefresh()} disabled={busy === 'refresh'}>
{busy === 'refresh' ? '감지 중…' : '다시 감지'}
</button>
{at !== null && <span> : {new Date(at).toLocaleString('ko-KR')}</span>}
```
- [ ] **Step 1-5: 컴포넌트 + test + 마운트 + commit**
```bash
git add src/renderer/inbox/components/settings/VisionSection.tsx \
src/renderer/inbox/components/settings/AiProviderSection.tsx \
tests/unit/VisionSection.test.tsx
git commit -m "feat(v031): VisionSection — dropdown + 다시 감지 + 마지막 감지 시각"
```
---
## Task 8: main process — refreshVisionCache 자동 호출 + AiWorker settings 주입
**Files:**
- Modify: `src/main/index.ts`
`whenReady` 안 (Ollama provider 준비 후) `void refreshVisionCache(...)` fire-and-forget 호출. AiWorker 생성자에 settings + mediaStore 주입.
- [ ] **Step 1: imports + 호출**`src/main/index.ts`:
```ts
import { refreshVisionCache } from './services/VisionDetect.js';
// whenReady 안, AiWorker.start() 직후 또는 직전
const ollama = providerHolder.get();
void refreshVisionCache({
settings: settingsSvc,
endpoint: (ollama as LocalOllamaProvider).endpoint, // 또는 SettingsService 의 ollama 설정에서 가져옴
}).catch(() => {});
```
(LocalOllamaProvider 의 endpoint 가 private 이면 settings 에서 가져옴 또는 provider 에 getter 추가.)
- [ ] **Step 2: AiWorker 생성자 인자 갱신**
- [ ] **Step 3: typecheck + PASS + commit**
```bash
npm run typecheck
npx vitest run
git add src/main/index.ts
git commit -m "feat(v031): main — refreshVisionCache whenReady + AiWorker settings/mediaStore 주입"
```
---
## Task 9: dogfood promoted + version bump + release commit
- [ ] F24 promoted 마킹 (`docs/superpowers/specs/2026-04-25-dogfood-feedback.md`):
```markdown
## F24. 멀티모달 vision (✅ promoted v0.3.1 Cut F)
**상태:** ✅ promoted v0.3.1 Cut F — Ollama vision 모델 (gemma3 family default) 활용. capability detection (app launch + manual refresh) + Configure UI dropdown + AiWorker vision integration (5MB cap + base64 변환). 자동 fallback (caption → text) deferred v0.3.2+.
```
- [ ] package.json: 0.3.0 → 0.3.1 + package-lock.json
- [ ] full unit + typecheck
```bash
git add docs/superpowers/specs/2026-04-25-dogfood-feedback.md package.json package-lock.json
git commit -m "chore(release): v0.3.1 — Cut F (멀티모달 vision AI)"
```
---
## Self-Review Checklist (수행자: 모든 task 완료 후 1회 점검)
- [ ] **Spec coverage**: §3 Capability Detection (Task 1) / §3-2 SettingsService (Task 2) / §3-3 main wiring (Task 8) / §3-4 UI (Task 7) / §4 Provider (Tasks 3-4) / §5 AiWorker (Task 5) / §6 image-only fallback ('skipped' enum 미도입 → 기존 'failed' 분기 활용)
- [ ] **Single write path 강제 (Cut C/D/E 정책)**: 본 cut 은 새 데이터 path 추가 없음 — `notes_fts` / `note_revisions` / `note_tags` mutation 없음 (vision 결과는 기존 `updateAiResult` path 활용 → 이미 검증됨). 회귀 검사 4-path invariant 유지.
- [ ] **Type 일관성**: `GenerateInput.images``GenerateOptions.visionModel` ↔ AiWorker 호출 ↔ LocalOllamaProvider body 모두 동일 shape
- [ ] **단위 카운트**: VisionDetect 9 (5+4) + SettingsService 4 + visionPrompt 2 + LocalOllamaProvider 3 + AiWorker 3 + IPC 3-5 + UI 1 = 약 25-27 신규. 목표 22 달성
---
## Risk
- **vision 모델 한국어 정확도**: gemma3 family 가 한국어 약하면 다른 family 추천 갱신 (메모리 정책). dogfood 검증 필요
- **Ollama 가 vision images 무시 (모델 misclassify)**: capability detection false-positive — 사용자가 dropdown 에서 다른 모델 선택해 우회. 자동 fallback 미구현 (YAGNI)
- **base64 메모리 폭주**: 5MB cap 적용. 다중 이미지 시 N×5MB = 메모리 누적 — vision 호출 후 image array 즉시 GC. 본 cut 의 dogfood 규모 (메모당 < 3 이미지) 무시
- **capability detection 실패 silent**: 첫 launch 시 network 실패 → cache 빈 채로 진행. 사용자가 설정 페이지에서 "다시 감지" 클릭 → 직접 trigger 가능
- **AiWorker 생성자 변경**: 기존 test 모두 mock 갱신 필요 (typecheck 가 catch). 누락 시 typecheck red
- **F23 OFF (ai_enabled=false) 시 자동 OFF**: refreshVisionCache 가 ai_enabled 체크 → ai_disabled 분기. AiWorker 의 vision path 진입 자체가 ai_enabled=true 가정 — F23 OFF 시 vision path 미도달 (자명)
- **e2e**: Cut C/D/E 와 동일 — 본 cut 미수행, main 머지 후 검증

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 를 쓸 수 있으면 좋겠어".
@@ -1787,9 +1787,9 @@ app.on('activate', () => {
---
## F24. 이미지 멀티모달 AI 분석 (🌱 raw — v0.2.8/v0.3 후보, capability gated)
## F24. 이미지 멀티모달 AI 분석 (✅ promoted v0.3.1 Cut F)
**진행 상태:** 🌱 raw — Ollama vision 모델 (llava / llama3.2-vision / gemma3-multimodal 등) 활용. 사용자 표현: "가능할 경우만 하면 될 것 같다" — capability detection + opt-in 명시.
**진행 상태:** ✅ promoted v0.3.1 Cut F — Ollama vision 모델 (gemma3 family default) 활용. capability detection (app launch + manual refresh) + Configure UI dropdown + AiWorker vision integration (5MB cap + base64 변환). 자동 fallback (caption → text) + 'skipped' enum deferred v0.3.2+. 단위 679 → 710. dogfood: vision 결과 정확도 + 한국어 token 정확도 검증.
**발견:** 2026-05-09 v0.2.7 release 후 본인 dogfood. F22 (이미지 렌더링) + F23 (Ollama-less 모드) 와 강하게 연관.

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.
---

View File

@@ -12,7 +12,7 @@
## 1. Cut 정체성
Ollama vision 모델 (gemma3 family default) 활용 — 이미지 + raw_text 결합 prompt 또는 이미지 단독 분석 → title/summary/tags 자동 생성. F22 prerequisite (Cut A) 이미 완료.
Ollama vision 모델 (gemma family — gemma3 / gemma4 default capable) 활용 — 이미지 + raw_text 결합 prompt 또는 이미지 단독 분석 → title/summary/tags 자동 생성. F22 prerequisite (Cut A) 이미 완료.
---
@@ -20,7 +20,7 @@ Ollama vision 모델 (gemma3 family default) 활용 — 이미지 + raw_text 결
| 항목 | 결정 |
|---|---|
| **F24 default 모델** | gemma3 family (한국어 + 이미지 둘 다 강함, 본인 메모 `gemma4:e4b` 텍스트 모델과 같은 가족) |
| **F24 default 모델** | gemma family — gemma3 / gemma4 둘 다 vision-capable hint (한국어 + 이미지 둘 다 강함, 본인 메모 `gemma4:e4b` 텍스트 모델과 같은 가족) |
| **prompt 모드** | 단일 vision 모델 호출 (vision 모델이 텍스트도 처리). 모델 capability 부족 시 2단계 fallback (자동) |
| **capability detection** | app launch 시 1회 + 설정 페이지 manual refresh 버튼 |
| **F23 OFF 시 자동 OFF** | `ai_enabled=false` → vision 도 자동 OFF (자명) |
@@ -56,36 +56,59 @@ function isVisionCapable(model: { name: string; details?: { family?: string; fam
}
```
### 3-2. Settings storage
### 3-2. Settings storage (실제 SettingsService API)
zod schema 확장 (기존 ai_enabled / sync_* 와 동일 strict 패턴):
```ts
interface SettingsSchema {
// ... 기존
vision_model?: string; // 사용자 명시 모델 (빈 값 = 비활성)
vision_capable_cache?: string[]; // launch 시 detected 결과 cache
vision_cache_at?: string; // ISO timestamp
}
const SettingsSchema = z.object({
// ... 기존 ollama / ai_enabled / onboarding_completed / sync_*
vision_model: z.string().nullable().optional(),
vision_capable_cache: z.array(z.string()).optional(),
vision_cache_at: z.string().optional()
}).strict();
```
신규 SettingsService 메서드 (개별 setter/getter — `get/set` 일반화 X):
```ts
async getVisionModel(): Promise<string | null>;
async setVisionModel(value: string | null): Promise<void>;
async getVisionCapableCache(): Promise<{ models: string[]; at: string | null }>;
async setVisionCapableCache(models: string[], now: Date): Promise<void>;
```
### 3-3. AppLaunchDetect
```ts
// src/main/index.ts whenReady 안 (settings 초기화 후)
async function refreshVisionCache(): Promise<void> {
if (!settingsService.get('ai_enabled', true)) return;
try {
const tags = await fetch(`${endpoint}/api/tags`).then(r => r.json());
const capable = tags.models.filter(isVisionCapable).map((m: any) => m.name);
settingsService.set('vision_capable_cache', capable);
settingsService.set('vision_cache_at', new Date().toISOString());
} catch {
// network fail — silent, cache 유지
}
}
`src/main/services/VisionDetect.ts` 신규 — pure 함수 + 외부 fetch 주입 (테스트 가능):
void refreshVisionCache();
```ts
export async function refreshVisionCache(deps: {
settings: SettingsService;
endpoint: string;
now?: () => Date;
fetchImpl?: typeof fetch;
}): Promise<{ ok: true; models: string[] } | { ok: false; reason: string }> {
if (!(await deps.settings.isAiEnabled())) {
return { ok: false, reason: 'ai_disabled' };
}
const fetchFn = deps.fetchImpl ?? fetch;
let body: { models?: Array<{ name: string; details?: { family?: string; families?: string[] } }> };
try {
const r = await fetchFn(`${deps.endpoint}/api/tags`);
if (!r.ok) return { ok: false, reason: `tags http ${r.status}` };
body = await r.json();
} catch (e) {
return { ok: false, reason: `unreachable: ${(e as Error).message}` };
}
const capable = (body.models ?? []).filter(isVisionCapable).map((m) => m.name);
await deps.settings.setVisionCapableCache(capable, deps.now ? deps.now() : new Date());
return { ok: true, models: capable };
}
```
main process `whenReady` 안에서 fire-and-forget 호출. 실패 silent (cache 유지). settings:refresh-vision-cache IPC 가 동일 함수 호출 (manual "다시 감지" 버튼).
### 3-4. 설정 페이지 UI (AI 제공자 섹션 확장)
```
@@ -166,44 +189,53 @@ ${text || '(이미지만 있음)'}
---
## 5. AiWorker 통합
## 5. AiWorker 통합 (실제 API 정정)
CaptureService 가 capture 시 image 첨부했으면 → notes.media 에 저장 + pending_jobs INSERT. AiWorker 가 job 처리 시:
기존 `AiWorker.processJob``repo.findById(noteId)` 로 hydrate 된 `Note` 받음 — `note.media` 가 이미 join 결과로 채워져 있어 별도 `listMediaByNote` 호출 불필요. `MediaStore.absolutePath(relPath)` 로 디스크 path 추출.
```ts
// src/main/ai/AiWorker.ts
async processJob(noteId: string): Promise<void> {
const note = this.repo.getById(noteId);
const media = this.repo.listMediaByNote(noteId);
const visionModel = this.settings.get('vision_model');
// src/main/ai/AiWorker.ts processJob 흐름
const note = this.repo.findById(job.noteId);
if (!note || ...) return;
const visionModel = await this.settings.getVisionModel();
let images: Array<{ base64: string; mime: string }> | undefined;
if (visionModel && media.length > 0) {
images = await Promise.all(media.map(async (m) => ({
base64: (await fs.readFile(this.mediaStore.absolutePath(m.relPath))).toString('base64'),
mime: m.mime
})));
}
const provider = this.providerHolder.get();
const response = await provider.generate({ text: note.rawText, images, ... }, { visionModel });
// ... 기존 결과 적용
let images: Array<{ base64: string; mime: string }> | undefined;
if (visionModel && note.media.length > 0) {
images = await Promise.all(
note.media.map(async (m) => {
const buf = await readFile(this.mediaStore.absolutePath(m.relPath));
// 이미지당 5MB cap (base64 메모리 폭주 방지)
if (buf.byteLength > 5 * 1024 * 1024) {
throw new Error(`image ${m.relPath} exceeds 5MB cap`);
}
return { base64: buf.toString('base64'), mime: m.mime };
})
);
}
const res = await this.holder.get().generate({
text: note.rawText,
images,
todayKst,
dueDateCandidates: candidates,
vocab
}, { visionModel });
```
`media.length > 0 && visionModel` 둘 다 true 일 때만 vision path. 그 외는 기존 text-only.
`visionModel && note.media.length > 0` 둘 다 true 일 때만 vision path. 그 외는 기존 text-only path 유지 (호환 보존). image 5MB cap 초과 시 throw → 기존 AiWorker 의 attempts 카운트 + ai_status='failed' 분기 활용.
AiWorker 의 `settings: SettingsService` 의존성 추가 — 기존 생성자에 신규 파라미터.
---
## 6. 이미지만 있는 capture
## 6. 이미지만 있는 capture (정정 — 신규 enum 도입 X)
`raw_text` 빈 값 + media 첨부만:
`raw_text` 빈 값 + media 첨부만 케이스:
- 기존 동작: notes INSERT (raw_text=''), AiWorker 가 빈 prompt 로 호출 → ai_status='failed' 또는 무의미 응답
- vision enabled: AiWorker 가 vision prompt + images → 의미 있는 title/summary/tags 응답
- vision disabled (visionModel 빈 값): notes 저장만, ai_status='disabled' 신규 enum 활용 (Cut B 의 ai_enabled false 와 비슷한 의미 — 그러나 부분 disable, 즉 "이미지 only 라 처리 불가" 상태)
- **vision enabled** (`visionModel` 설정 + media 있음): AiWorker 의 vision path → 의미 있는 title/summary/tags 응답
- **vision disabled** (`visionModel` null): 기존 text-only 흐름 그대로 — 빈 prompt → AI 응답이 무의미하면 ai_status='failed' 분기 (재시도 가능). dogfood 시 빈도 측정 후 'skipped' enum 도입 여부 재평가.
추천: vision disabled + image-only capture 시 `ai_status='skipped'` 신규 enum (Cut B 의 'disabled' 와 다름). title fallback = "(이미지 N개)" 또는 첫 이미지 파일명.
**'skipped' 신규 enum 미도입 (YAGNI)**: m008 마이그레이션 (CHECK relax via table recreate) 부담 + 이미지-only capture 가 본 cut 의 main use case 가 아님. 사용자가 vision 활성 후 retry 하거나 raw_text 추가 후 reprocess 하는 우회로 충분. 정책 검토는 dogfood 후 별도 cut.
---
@@ -219,7 +251,7 @@ async processJob(noteId: string): Promise<void> {
| `AiWorker.processJob` vision integration | media + visionModel 있을 때만 base64 변환 |
| 이미지 only capture | raw_text='' + media → vision 결과 정상 또는 'skipped' 분기 |
**목표**: 단위 555 → 약 575 (+20), typecheck 0.
**목표**: 단위 679 → 약 701 (+22, isVisionCapable 5 + refreshVisionCache 4 + SettingsService vision 4 + LocalOllamaProvider vision path 3 + buildVisionPrompt 2 + AiWorker vision integration 3 + UI dropdown 1), typecheck 0.
---
@@ -231,7 +263,7 @@ async processJob(noteId: string): Promise<void> {
| 이미지 base64 메모리 부담 | media 1개당 평균 < 1MB. 다중 이미지 시 N×base64 = 메모리 N배. cap (이미지당 max size 5MB) 적용 |
| capability detection 실패 시 fallback | cache 부재 → vision dropdown 비어있음 표시 + "다시 감지" 안내 |
| vision 모델 한국어 정확도 | dogfood 검증. gemma3 가 한국어 약하면 다른 family 추천 갱신 (메모리 정책 갱신) |
| Ollama 가 vision images 필드 무시 (모델이 multimodal 미지원) | 자동 2단계 fallback — vision 모델로 caption 추출 → 텍스트 모델 종합 (capability 부족 시) |
| Ollama 가 vision images 필드 무시 (모델이 multimodal 미지원) | **본 cut 미구현 (YAGNI)** 자동 2단계 fallback (caption 추출 → 텍스트 모델 종합) 은 v0.3.2+ 검토. dogfood 시 capability detection 정확도 우선 |
---

View File

@@ -0,0 +1,138 @@
# Sync 도움말 — Design
날짜: 2026-05-10
대상 버전: v0.3.4 (또는 v0.4.0 통합 시 Cut G 안에 포함)
선행 의존: v0.3.0 Cut E (양방향 sync), v0.3.3 (configure-sync ENOENT hotfix)
## 배경
v0.3.0 Cut E 가 양방향 sync (configure UI + ConflictModal + auto-sync timer) 를 도입했지만, 사용자에게 노출되는 도움말은 다음 세 곳 모두 부족 또는 부재:
- **SettingsPage > 동기화 저장소**: URL 입력 + 저장/연결 테스트 + 자동 sync 토글만 있음. 무엇이 어떻게 동작하는지 안내 0.
- **ConflictModal**: "내 것 사용 (local)" / "원격 사용 (remote)" 버튼만 노출, 각 옵션의 의미·결과 미설명. 사용자는 추측에 의존.
- **README "원격 백업 (F6-L2)" 섹션**: v0.2.1 MVP 시점 기준 (트레이 "지금 동기화" + 수동 `git init`). Cut E 의 Configure UI / ConflictModal / auto-sync timer 미반영 — 사용자가 따라하면 어긋남.
다기기 (Mac + Windows) sync dogfood 는 본인 + 사내 베타 10인의 핵심 가치 검증인데, conflict 시나리오에 막혔을 때 도움말이 없어 사용자가 직접 git 내부 동작을 추측해야 하는 상태.
## 목표
git 기반 sync 의 정상 동작·이상 시나리오·복구 절차를 사용자가 막힌 순간에 바로 찾을 수 있게 만든다.
비목표:
- 'both' choice (v0.3.1+ deferred) 도움말
- 다국어 (앱 한국어 only)
- 스크린샷·GIF (텍스트만으로 충분)
- README 외 docs/sync-guide.md 별도 파일 (in-app 이 메인, README 가 보조 — 별도 파일 발견성 ↓)
## 표면 (3개)
### 1. SyncHelpModal — 신규 컴포넌트
**위치**: `src/renderer/inbox/components/SyncHelpModal.tsx`
**진입점**:
- `SyncSection.tsx`: URL 입력 row 옆에 "도움말" 버튼 추가 → 클릭 시 modal open
- `ConflictModal.tsx`: 각 옵션 설명 옆 "자세히 보기 →" 링크 → SyncHelpModal open + "메인 conflict" 섹션으로 스크롤
**구조**: `ConflictModal` 패턴 재사용 (overlay + 닫기 버튼 + scrollable body). 4 섹션 (단순 anchor jump — 좌측 nav 미도입, modal 무게 ↓):
1. **메인 conflict** — 편집/편집, 삭제/편집, AI 결과 충돌 3 케이스 + 각 결정 트리
2. **자동 처리** — fetch+rebase, 첫 sync 순서, push 순서 ("내가 안 해도 되는 일")
3. **Silent risk** — 시계 어긋남(NTP), 결합 실패 silent, dogfood 주의
4. **Setup/인증** — URL 형식 (SSH vs HTTPS), 인증 helper, 연결 실패 troubleshoot
**Close**: ESC + 우상단 X + overlay 클릭 (ConflictModal 패턴 일치).
### 2. ConflictModal 갱신
**현재**: 각 conflict path 에 대해 "내 것 사용 (local)" / "원격 사용 (remote)" 버튼만.
**변경**: 각 옵션 라벨 아래 1-2 줄 inline 설명 + "자세히 보기 →" 링크.
```text
내 것 사용 (local)
이 기기의 변경을 보존하고 원격의 같은 노트 변경을 폐기.
자세히 보기 →
원격 사용 (remote)
원격 (다른 기기 또는 백업) 의 변경을 가져오고 내 변경을 폐기.
자세히 보기 →
```
"자세히 보기" 클릭 → SyncHelpModal open (메인 conflict 섹션 anchor).
### 3. README "원격 백업 (F6-L2)" 섹션 통째 재작성
**현재 (line 193-223)**: v0.2.1 MVP 기준 stale.
**신규 헤더**: "## 동기화 (Git, F21 Cut E)"
**하위 절**:
- 일회 설정 — Settings > 동기화 저장소 UI 안내 (트레이 "지금 동기화" 안내 제거 — 현재 UI 와 다름)
- URL 형식 명확화: `git@host:user/repo.git` (SSH) 또는 `https://host/owner/repo.git` (HTTPS). v0.3.3 dogfood 에서 발견된 `git@https://` 혼합 오류 사례 명시
- 일상 사용 — auto-sync 주기 / 수동 트리거 / 충돌 시 ConflictModal 안내
- 충돌 해결 — local/remote 결정 트리 (in-app SyncHelpModal 과 같은 내용)
- Silent risk — 시계 어긋남, 동시 수정 회피
- Troubleshoot — push 실패 / 인증 실패 / 첫 sync 순서
## 콘텐츠 분배
| 시나리오 | SyncHelpModal | ConflictModal inline | README |
|---|---|---|---|
| 편집/편집 conflict | 결정 트리 (어떤 변경이 더 최신인지 / 둘 다 보존하려면 사후 수동 병합) | 1줄 + "자세히" 링크 | 상세 + 예시 |
| 삭제/편집 | 케이스 설명 (삭제 측이 'remote' 면 trash 로 이동, 편집 측이 'local' 이면 trash 취소) | (해당 없음 — path 가 같음) | 케이스 설명 |
| AI 결과 충돌 | "재처리 권장" — local/remote 한쪽 선택 후 AI 재실행 권장 | (해당 없음) | 케이스 설명 |
| fetch+rebase 자동 | "내가 안 해도 되는 일" 단원 | — | 동일 |
| 첫 sync 순서 | "Mac 먼저 push → Windows pull 후 push" | — | 동일 |
| 시계 어긋남 (NTP) | "두 기기 동시 수정 회피", `chrony` / Windows time sync 점검 안내 | — | 동일 |
| Setup/URL 형식 | SSH/HTTPS 예시, `git@https://` 같은 혼합 형식 거부 사례 | — | 동일 + 인증 helper 안내 |
| 인증 실패 | "OS credential helper 점검", token URL embed 우회 옵션 | — | 동일 |
## 변경 파일
**신규**:
- `src/renderer/inbox/components/SyncHelpModal.tsx`
- `tests/unit/SyncHelpModal.test.tsx`
**수정**:
- `src/renderer/inbox/components/settings/SyncSection.tsx` — "도움말" 버튼 추가 (URL row 옆)
- `src/renderer/inbox/components/ConflictModal.tsx` — 각 옵션 inline 설명 + "자세히" 링크
- `tests/unit/ConflictModal.test.tsx` — inline 설명 / 링크 클릭 시 SyncHelpModal open 회귀
- `tests/unit/SyncSection.test.tsx` — 도움말 버튼 클릭 → SyncHelpModal open 회귀
- `README.md` — "원격 백업 (F6-L2)" 섹션 line 193-223 통째 재작성
## 게이트
- `SyncHelpModal.test.tsx` 신규 — 4 섹션 렌더링, close (ESC/X/overlay), anchor jump
- `ConflictModal.test.tsx` 회귀 — inline 설명 표시, "자세히" 링크 → SyncHelpModal open
- `SyncSection.test.tsx` 회귀 — 도움말 버튼 → SyncHelpModal open
- typecheck 0
- 단위 +6~8 (SyncHelpModal 4 + ConflictModal 회귀 1 + SyncSection 회귀 1)
- e2e 미수행 (UI-only, 기존 capture/onboarding flow 무관)
## Risk
- **콘텐츠 정확성**: AI 결과 충돌 / 시계 어긋남 같은 시나리오는 dogfood 미경험 (v0.3.3 까지 1 dogfood 발견). 도움말이 실제 사용자 경험과 어긋날 risk → 1주 dogfood soak 후 도움말 텍스트 1차 갱신 필수
- **README 와 in-app 의 중복 maintain**: 두 곳에 같은 내용. 정합성 깨질 risk → 우선순위는 in-app (사용자가 보는 위치). README 는 보조
- **'both' choice 부재 안내**: v0.3.1+ deferred 인데 사용자가 "왜 둘 다 보존이 없냐" 질문 가능 → 도움말에 "현재 미지원, 사후 수동 병합" 명시
- **콘텐츠 길이**: SyncHelpModal 4 섹션이 길어지면 modal 자체가 무거워짐 → 각 섹션 200 자 이내 + README 가 상세. modal 은 "막힌 순간 결정 트리" 우선
## 비포함 / Deferred
- 'both' choice 도움말 (Cut E 정책 deferred)
- 다국어 (앱 한국어 only)
- 스크린샷 / GIF
- 별도 docs/sync-guide.md (README + in-app 으로 충분)
- ConflictModal 의 diff 시각 개선 (별개 task, 본 도움말 cut 의 scope 외)
- 도움말 검색 기능 (4 섹션, 짧음)
## How to apply
- v0.3.4 patch 또는 Cut G 안에 통합. 단독 cut 으로 갈 경우 v0.3.4 — 데이터/마이그레이션 변경 0
- dogfood 1주 soak 후 도움말 텍스트 정합성 1차 갱신 (실제 사용자 경험과 어긋난 부분 보강)
- ConflictModal 의 inline 설명은 "자세히 보기" 링크 한 번이 SyncHelpModal 메인 conflict 섹션 anchor 로 점프 — anchor id 명명: `#main-conflict`, `#auto`, `#silent`, `#setup`

View File

@@ -0,0 +1,320 @@
# v0.3.2 — Cleanup Cut Design
**작성일:** 2026-05-10
**선행 문서:**
- `docs/superpowers/v024-backlog.md` (잔여 backlog audit)
- `~/.claude/projects/c--Users-rlaxo-inkling/memory/project_v022_feedback.md` (stale memory — 본 cut 에서 폐기)
- `docs/superpowers/strategy/v028plus-roadmap.md`
**Cut 라벨:** v0.3.2 — patch (기능 추가 X, 잠재 bug fix + cosmetic + 기록 정리)
---
## 1. Cut 정체성
기능 추가 X. backlog 잔여 23건 중 **잠재 bug 4건 + cosmetic 6건 + 기록 정리 2건 = 12건** 일괄 처리. data-dependent 항목 (telemetry 분포 의존) 과 cross-cutting refactor (TrayController class / Banner CSS variables) 는 dogfood 후 재평가. Cut F (v0.3.1) + Cut E (v0.3.0) 종합 dogfood ≥1주 soak 진입을 위한 baseline 정리.
---
## 2. 범위
| 항목 | 결정 |
|---|---|
| **포함 카테고리** | 잠재 bug + cosmetic + 기록 정리 |
| **보류 카테고리** | data-dependent (9건) + cross-cutting refactor (4건) |
| **테스트 +** | 단위 710 → 약 720 (+10), typecheck 0 |
| **schema 변경** | 없음 (m007 이후) |
| **단일 PR** | v0.3.2 — 12 항목 = 7~8 commit (카테고리별 묶음) |
---
## 3. 잠재 bug fix (4건)
### 3-1. `vocabSet` COLLATE NOCASE 정합 (#31)
**현재 코드** (`src/main/ai/AiWorker.ts` `processJob`):
```ts
const vocab = await this.repo.getTopUsedTags(VOCAB_TOP_N);
const vocabSet = new Set(vocab);
// ...
if (vocabSet.has(tagName)) { ... } // strict-eq, DB COLLATE NOCASE 와 충돌 가능
```
**문제**: vocab pool 확장 시 (사용자가 `'Design'` 같은 capital case 추가하면) `getTagIdByName('Design')` 은 COLLATE NOCASE 로 매치되지만 `vocabSet.has('Design')` strict-eq 는 lowercase 만 등록된 set 에 miss → tagId 있는데 vocab hit 0 → silently skip.
**수정**:
```ts
const vocab = await this.repo.getTopUsedTags(VOCAB_TOP_N);
const vocabSet = new Set(vocab.map((v) => v.toLowerCase()));
// ...
if (vocabSet.has(tagName.toLowerCase())) { ... }
```
**테스트 (3건 신규)**:
- 대문자 vocab + lowercase AI tag → hit
- lowercase vocab + 대문자 AI tag → hit
- 동일 lowercase → hit (회귀)
### 3-2. Time-dependent test flake fix
**문제**: `NoteRevisions.test.ts` 의 v1 capture 가 `repo.create()` 호출 → `NoteRepository.create``new Date()` (NOW) 로 `created_at` / `edited_at` 박음. v2 는 fixed `2026-05-10T00:00:00Z` 명시 주입. 시스템 시계가 `2026-05-10T00:00:00Z` 초과 시 v1.edited_at > v2.edited_at → DESC ordering 깨짐. `upsertFromSync.test.ts` 도 동일 패턴.
**수정**: `NoteRepository.create(input, now?: Date)` 추가 (기존 `setStatus(id, status, reason, now: Date)` / `updateRawText(id, text, now: Date)` 패턴 정합).
```ts
// src/main/repository/NoteRepository.ts
create(input: CreateNoteInput, now: Date = new Date()): Note {
const ts = now.toISOString();
// 기존 INSERT 의 createdAt / updatedAt / edited_at 모두 ts 사용
// ...
}
```
**기존 호출자 무영향** — production 코드는 `now` 생략 → 기본 `new Date()` 동일 동작.
**테스트 (5건 회복 + 2건 신규)**:
- `NoteRevisions.test.ts` 의 4 testcase v1 capture 도 fixed 시간 (`'2026-05-09T00:00:00Z'`) 주입 → v2 (`'2026-05-10T00:00:00Z'`) 이전 보장
- `upsertFromSync.test.ts` 의 v1 capture 도 fixed 시간 주입
- `NoteRepository.create` default 시간 (`now` 생략 시 `new Date()`) 단위 1
- `NoteRepository.create` 명시 주입 단위 1
### 3-3. PII reason 마스킹 (#39)
**현재 코드** (`src/main/ai/LocalOllamaProvider.ts` `healthCheck`):
```ts
} catch (e) {
this.telemetry?.emit({ kind: 'ollama_unreachable', payload: { reason: `unreachable: ${(e as Error).message}` } });
}
```
**문제**: `err.message` 안에 `http://192.168.x.x:11434/api/tags` 같은 LAN endpoint URL 포함 가능 → telemetry 파일에 PII 우회 노출. v0.2.3.1 in-app endpoint UI 가 LAN 사용 흔하게 만들어 노출 경로 확대.
**수정**: error class 분류 + host 마스킹.
```ts
function classifyFetchError(e: unknown): 'timeout' | 'network' | 'dns' | 'other' {
const msg = (e as Error).message?.toLowerCase() ?? '';
if (msg.includes('aborted') || msg.includes('timeout')) return 'timeout';
if (msg.includes('econnrefused') || msg.includes('econnreset')) return 'network';
if (msg.includes('enotfound') || msg.includes('eai_again')) return 'dns';
return 'other';
}
// emit
const reason = classifyFetchError(e);
this.telemetry?.emit({ kind: 'ollama_unreachable', payload: { reason } });
```
`AiFailedReason` zod enum (`'unreachable' | 'schema' | 'timeout' | 'other'`) 와 별개 — `ollama_unreachable.payload.reason` 만 신규 enum 도입 또는 기존 union 확장. spec 단계 결정: **기존 `'unreachable'` 그대로 유지**, 신규 enum 추가 X (단순화). reason 변환 후 prefix 만 변경:
```ts
const cls = classifyFetchError(e);
this.telemetry?.emit({ kind: 'ollama_unreachable', payload: { reason: `unreachable:${cls}` } });
```
**테스트 (4건 신규)**:
- `ECONNREFUSED``unreachable:network`
- `ETIMEDOUT``unreachable:timeout`
- `ENOTFOUND``unreachable:dns`
- 그 외 → `unreachable:other`
### 3-4. KST_OFFSET_MS inline duplication 5 callsite → import (#19)
**현재**: canonical `src/shared/util/kstDate.ts` 가 있는데 5 callsite inline duplicate.
| 파일 | 라인 | 처리 |
|---|---|---|
| `src/main/repository/NoteRepository.ts:1042` | inline `const KST_OFFSET_MS = ...` | `import { KST_OFFSET_MS } from '@shared/util/kstDate.js'` |
| `src/main/repository/ftsHelpers.ts:18` | 동 | 동 |
| `src/main/services/BackupService.ts:6` | 동 | 동 |
| `src/main/services/ContinuityService.ts:4` | 동 | 동 |
| `src/renderer/inbox/components/NoteCard.tsx:30` | 동 | 동 (renderer alias 경계 X — `@shared/...` 양쪽 import 가능) |
**테스트**: 단위 추가 없음 — 기존 회귀 검사. `kstDate.ts` 의 export 가 동일 값 (9 \* 60 \* 60 \* 1000) 이므로 동작 무변화.
---
## 4. Cosmetic / readability (6건)
### 4-1. 탭 ARIA 패턴 정정 (#14)
`aria-pressed` 는 toggle 버튼용. 본 UI 의 탭 (Inbox / 휴지통 / 회고 등) 은 `role="tab"` + `aria-selected` canonical. screen reader 동작 OK 였지만 a11y audit canonical 정정.
**파일**: `src/renderer/inbox/App.tsx` (탭 컨테이너) — grep 으로 `aria-pressed` 위치 확정 후 수정.
**테스트**: 단위 추가 없음 (기존 RTL 단위가 selectable 검증). 단 `aria-selected="true"` 관련 assertion 1건 추가 검증.
### 4-2. `loadExpired()` 미사용 제거 (#18)
`store.ts``loadExpired()` action 이 `loadInitial`/`refreshMeta` 가 inline fetch 하면서 사용 안 함. App.tsx 호출 0건. test 만 exercise.
**처리**: dead-code 제거. 관련 test 도 제거.
### 4-3. AiWorker per-tag emit `Promise.all` 병렬화 (#32)
**현재**:
```ts
for (const tag of new Set(...)) {
await this.telemetry.emit({ kind: 'tag_vocab_hit' or 'miss', ... }); // serial
}
```
**수정**:
```ts
await Promise.all(
Array.from(new Set(...)).map((tag) => this.telemetry.emit({ ... }))
);
```
**Risk**: emit 순서 변경 — telemetry 파일 라인 순서 의존 단위 없음 확인. `ai_succeeded` emit 도 serial 이지만 본 cut 은 **per-tag emit 만** 변경 (tag_vocab_hit / tag_vocab_miss). `ai_succeeded` 는 serial 유지 (per-tag 와 다른 호출 시점).
**테스트**: 회귀 단위 PASS 확인. 신규 단위 추가 없음 (병렬 동작 자체 검증은 unit 무리).
### 4-4. `emitRecallShown` / `emitRecallSnoozed` `ipcMain.handle → on` (#36)
`fire-and-forget` 정책 호출 측 (RecallBanner) → return value 사용 안 함. canonical pattern: `ipcMain.on` (return value 없음).
**파일**: `src/main/ipc/inboxApi.ts` 또는 telemetry IPC 정의 위치. `emitRecallShown` / `emitRecallSnoozed``handle → on` migration. 호출 측 (`window.api.emitRecallShown` 등) 의 `Promise<void>` 시그니처 그대로 (preload 가 `ipcRenderer.send`).
**Risk**: `ipcMain.handle` 의 return value 의존 호출자 grep 으로 0 확인.
**테스트 (1건 신규)**: `vision-ipc.test.ts` 패턴 정합 — `ipcRenderer.send` 호출 검증.
### 4-5. OllamaSettingsModal 폐기 확인 (#41+#42)
**현재 audit**: `grep "OllamaSettingsModal" src/` → 0건. v0.2.7 cut 에서 이미 폐기됨 (memory: "OllamaSettingsModal 제거 + onOpenOllamaSettings 채널 cleanup").
**처리**: backlog 항목 닫기만. **코드 변경 0**. v024-backlog.md 의 #41/#42 처리 이력 ✅ 추가.
### 4-6. Telemetry `.catch(() => {})` silent → debug log (#20)
**현재**: `CaptureService.listExpired` / `trashExpiredBatch``.catch(() => {})` 로 silent.
**수정**:
```ts
this.telemetry.emit(...).catch((e) => {
this.logger.debug('telemetry.emit.failed', { reason: String(e) });
});
```
`logger.debug` (project pattern) — production noise 0, 디버그 시 reproduce 가능.
**테스트**: 단위 추가 없음. 회귀 PASS.
---
## 5. 기록 정리 (2건)
### 5-1. v0.2.2 stale memory 폐기
`~/.claude/projects/c--Users-rlaxo-inkling/memory/project_v022_feedback.md` — 6건 모두 v0.2.3~v0.2.9 cut 들에서 처리됨 (Ollama 회복 / AI 영속 큐 / 태그 vocab / 휴지통 / 만료 추천 / RecallBanner). 8일 stale.
**처리**:
- 파일 삭제
- `MEMORY.md` 의 line 8 (`- [v0.2.2 feedback]...`) 제거
### 5-2. v024-backlog.md 갱신
처리 이력 table 에 신규 12건 entry 추가:
```markdown
| #14 (탭 ARIA) | ✅ 처리 | v0.3.2 |
| #18 (loadExpired 미사용) | ✅ 처리 | v0.3.2 |
| #19 (KST inline 5 callsite 잔여) | ✅ 처리 | v0.3.2 |
| #20 (.catch silent → debug log) | ✅ 처리 | v0.3.2 |
| #31 (vocabSet COLLATE) | ✅ 처리 | v0.3.2 |
| #32 (per-tag Promise.all) | ✅ 처리 | v0.3.2 |
| #36 (recall IPC handle→on) | ✅ 처리 | v0.3.2 |
| #39 (PII reason 마스킹) | ✅ 처리 | v0.3.2 |
| #41+#42 (OllamaSettingsModal 폐기) | ✅ 자연 소멸 (v0.2.7) | v0.3.2 audit |
| time-dependent test flake | ✅ 처리 | v0.3.2 |
```
총 처리 22 → 32, 잔여 23 → 11 (data-dependent 9 + future-proof 2).
---
## 6. 보류 항목 (dogfood 후 재평가)
### data-dependent (9건)
- #25 HealthChecker `inFlight` manual emit ordering — dogfood soak 결과 결정 (1초 윈도우 dedup 필요 여부)
- #29 `getTopUsedTags(20)` magic number → `VOCAB_TOP_N` 모듈 상수 (이미 #29 처리, 튜닝 자체는 telemetry 후)
- #30 `getTopUsedTags` SQL-side 필터 (overfetch+slice vs `GLOB`) — vocab pool 확장 결정
- #33 `PROMPT_VERSION` telemetry payload 추가 — prompt 튜닝 후 hit-rate 추적 시
- #35 `recall_shown` per-banner-lifetime dedup — telemetry 빈도 보고
- #40 Settings 저장 vs HealthChecker race — visible 빈도 확인
- #16 per-note 영구 삭제 telemetry 빈도 — 거의 0 이면 bulk emptyTrash 만 (UX 단순화)
- #28 `unreachableBackoffStep` job-level — multi-provider 도입 시
- recall_shown lifetime 영속 마커 (data-dependent #35 와 일부 중복)
### future-proof / cross-cutting (4건)
- #27 `refreshTrayFailedCount` exported singleton → TrayController class — multi-window 가설 검증 X
- #24+#41 Banner CSS variables — modal 폐기로 일부 자연 소멸 + 잔여 4 banner 의 hardcode 색상은 단일 dogfood UX 영향 0
- #37 NoteCard `id="note-${id}"` ref-forwarding — search 결과 scroll 등 신규 surface 등장 시
- #28 단일 카운터 vs job-level — multi-provider 진입 시점
---
## 7. 테스트 전략
| 영역 | 단위 |
|---|---|
| `vocabSet` lowercase normalize | 3 (대/소문자 vocab × AI tag matrix) |
| `NoteRepository.create(now)` param | 2 (default / 명시 주입) |
| `NoteRevisions.test.ts` flake fix | 4 testcase 회복 (v1 capture 시간 주입) |
| `upsertFromSync.test.ts` flake fix | 2 testcase 회복 |
| PII reason classification | 4 (network/timeout/dns/other) |
| KST inline → import migration | 회귀 PASS 확인 (단위 +0) |
| 탭 ARIA `aria-selected` | 1 신규 assertion |
| `loadExpired` 제거 | 회귀 (test 같이 제거) |
| AiWorker `Promise.all` 회귀 | 회귀 PASS |
| recall IPC `on` migration | 1 신규 (`ipcRenderer.send` 검증) |
| Telemetry `.catch` debug log | 회귀 PASS |
목표: 단위 710 → **약 720** (+10 신규, -2 제거 [`loadExpired` test], net +8). typecheck 0.
---
## 8. Risk
| Risk | 대응 |
|---|---|
| `NoteRepository.create(now)` signature 변경 호출자 영향 | optional + default = `new Date()`. 기존 호출자 0 무영향. typecheck 가 누락 catch |
| KST inline → import 회귀 (값 다른 정의) | canonical export 값 (9 \* 60 \* 60 \* 1000) 동일 검증. 기존 단위 회귀 PASS = 알고리즘 동일 |
| `ipcMain.handle → on` migration 시 return value 의존 호출자 누락 | grep 으로 호출자 enumerated. preload 시그니처 그대로 (`Promise<void>`) — 호출 측 무수정 |
| AiWorker `Promise.all` 으로 emit 순서 변경 | telemetry 파일 라인 순서 의존 단위 0 확인. file-append round-trip 만 줄어듦 |
| PII 마스킹으로 디버그 어려움 | error class enum + production reproduce 시 dev 환경에서 stack 그대로 노출 (telemetry 만 마스킹) |
| time-dependent fix 시 다른 시간 의존 단위 발견 | grep `new Date()` in `repo` methods → `create` 외 다른 메서드 audit. 발견 시 spec 갱신 X (cleanup cut 외 deferred) |
---
## 9. v0.3.2 후
**Cut G** (v0.3.3 또는 v0.4.0 — F25 사이드바 + notebook_id) 진입 전:
- v0.3.2 release → main → tag → Windows exe + Gitea release
- macOS host 핸드오프: dist:mac dmg + dist:linux AppImage/deb (v0.3.0 + v0.3.1 + v0.3.2 누적 backlog)
- **다기기 종합 dogfood ≥1주 soak**:
- sync (Cut E): 충돌 빈도 / 인증 흐름 / interval 적정성 / NTP 단조 가정
- vision (Cut F): 이미지 capture 빈도 / 한국어 정확도 / capability detection 정확도
- cleanup (Cut 본 v0.3.2): 잠재 bug 회귀 X 확인
- soak 후 신규 발견 + data-dependent 9건 일괄 triage → Cut G brainstorm 진입
**Cut G 가설** (재검증):
1. inbox 단일 view 의 정보 밀도 한계 (현재 노트 누적 N건 = 스크롤 부담)
2. notebook 카테고리 = 분류 hint vs 단일 inbox + 태그 필터로 충분
3. 사이드바 = 새 surface = 기존 정책 (트레이 deemphasis / SettingsPage 우선) 재고

View File

@@ -3,9 +3,9 @@
> 누적 backlog. v0.2.3 cut (7항목 / PR #13~#19) 시점부터 PR review deferred + dogfood 발견 모두 합산. **파일명은 historic** (`v024-backlog.md`) — v0.2.4 ~ v0.2.6 cut 후에도 이어 사용. **v0.2.7 brainstorm 시** 신규 피드백 + 잔여 일괄 triage.
**누적 시작일:** 2026-05-01 (#7 telemetry skeleton 머지 시점)
**최종 갱신:** 2026-05-07 (v0.2.7 cross-platform cut — #45 자동실행 deeper fix)
**최종 갱신:** 2026-05-10 (v0.3.2 cleanup cut — 잠재 bug 4 + cosmetic 5 + #20 deferred)
**총 항목 수:** 46 (#1 stale 포함)
**잔여:** 23건 (=46 처리 22 stale 1)
**잔여:** 14건 (=46 처리 31 stale 1)
## 처리 이력 / 진행 흐름
@@ -39,6 +39,21 @@
|---|---|---|
| **B1 production path** (CaptureService.restoreNote 가 옛 `repo.restore` 호출) | ✅ Critical fix (commit `a991008`) | v0.2.6 round 1 |
### v0.3.2 cleanup cut (2026-05-10)
| 항목 | 상태 | Cut |
|---|---|---|
| #14 (탭 ARIA `aria-pressed``role="tab"`) | ✅ 처리 | v0.3.2 |
| #18 (`loadExpired` 미사용 제거) | ✅ 처리 | v0.3.2 |
| #19 (KST inline 5 callsite 잔여 migrate) | ✅ 처리 | v0.3.2 |
| #20 (telemetry `.catch` silent → debug log) | 🟡 deferred (CaptureService logger 미주입 — constructor 변경 회피) | v0.3.2 audit |
| #31 (vocabSet COLLATE NOCASE 정합) | ✅ 처리 | v0.3.2 |
| #32 (per-tag emit `Promise.all` 병렬화) | ✅ 처리 | v0.3.2 |
| #36 (recall IPC `handle``on`) | ✅ 처리 | v0.3.2 |
| #39 (`ollama_unreachable.reason` PII 마스킹) | ✅ 처리 | v0.3.2 |
| #41+#42 (`OllamaSettingsModal` 폐기 audit) | ✅ 자연 소멸 (v0.2.7) | v0.3.2 audit |
| time-dependent test flake fix | ✅ 처리 | v0.3.2 |
### v0.2.6 final reviewer + round 1 minors (deferred)
| 항목 | 상태 |

6
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "inkling",
"version": "0.2.10",
"version": "0.3.12",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "inkling",
"version": "0.2.10",
"version": "0.3.12",
"dependencies": {
"better-sqlite3": "12.9.0",
"electron-log": "5.2.0",
@@ -3232,7 +3232,7 @@
}
},
"node_modules/@tokenizer/token": {
"version": "0.3.0",
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
"integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
"dev": true,

View File

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

View File

@@ -1,6 +1,9 @@
import { readFile } from 'node:fs/promises';
import type { NoteRepository } from '../repository/NoteRepository.js';
import type { Note } from '@shared/types';
import type { AiFailedReason } from '../services/telemetryEvents.js';
import type { SettingsService } from '../services/SettingsService.js';
import type { MediaStore } from '../services/MediaStore.js';
import { ProviderHolder } from './ProviderHolder.js';
import { parseAllCandidates } from '../services/dueDateParser.js';
import { ZodError } from 'zod';
@@ -41,6 +44,10 @@ export interface AiWorkerOptions {
};
now?: () => Date;
telemetry?: AiTelemetryEmitter;
/** v0.3.1 Cut F — vision 지원. 미전달 시 vision 비활성. */
settings?: Pick<SettingsService, 'getVisionModel'>;
/** v0.3.1 Cut F — 첨부 이미지 절대경로 변환. settings 와 함께 전달 시 vision 활성. */
mediaStore?: Pick<MediaStore, 'absolutePath'>;
}
interface Job { noteId: string; attempts: number; }
@@ -56,6 +63,8 @@ export class AiWorker {
private logger: NonNullable<AiWorkerOptions['logger']>;
private now: () => Date;
private telemetry?: AiTelemetryEmitter;
private settings?: Pick<SettingsService, 'getVisionModel'>;
private mediaStore?: Pick<MediaStore, 'absolutePath'>;
constructor(
private repo: NoteRepository,
@@ -68,6 +77,8 @@ export class AiWorker {
this.logger = opts.logger ?? { info: () => {}, warn: () => {}, error: () => {} };
this.now = opts.now ?? (() => new Date());
this.telemetry = opts.telemetry;
this.settings = opts.settings;
this.mediaStore = opts.mediaStore;
}
async enqueue(noteId: string): Promise<void> {
@@ -128,12 +139,27 @@ export class AiWorker {
const todayIso = kstTodayIso(nowDate);
const candidates = parseAllCandidates(note.rawText, todayDate);
const vocab = this.repo.getTopUsedTags(VOCAB_TOP_N);
const res = await this.holder.get().generate({
text: note.rawText,
todayKst: todayIso,
dueDateCandidates: candidates,
vocab
});
// v0.3.1 Cut F — vision path: visionModel + note.media → base64 images
// final review fix: note.media[].bytes 로 fast-fail (readFile/base64 비용 회피).
// 5MB cap 초과 시 throw → AiWorker 의 'other' 분기 → markAiFailed 도달.
const visionModel = this.settings ? await this.settings.getVisionModel() : null;
let images: Array<{ base64: string; mime: string }> | undefined;
if (visionModel && note.media.length > 0 && this.mediaStore) {
const oversize = note.media.find((m) => m.bytes > 5 * 1024 * 1024);
if (oversize) {
throw new Error(`image ${oversize.relPath} exceeds 5MB cap (${oversize.bytes} bytes)`);
}
images = await Promise.all(
note.media.map(async (m) => {
const buf = await readFile(this.mediaStore!.absolutePath(m.relPath));
return { base64: buf.toString('base64'), mime: m.mime };
})
);
}
const res = await this.holder.get().generate(
{ text: note.rawText, images, todayKst: todayIso, dueDateCandidates: candidates, vocab },
{ visionModel: visionModel ?? undefined }
);
// AI primary: AI's dueDate is final (no rule merge)
this.repo.updateAiResult(job.noteId, {
title: res.title,
@@ -150,7 +176,8 @@ export class AiWorker {
candidatesCount: candidates.length
});
if (this.telemetry) {
await this.telemetry.emit({
const telemetry = this.telemetry;
await telemetry.emit({
kind: 'ai_succeeded',
payload: {
noteId: job.noteId,
@@ -160,23 +187,25 @@ export class AiWorker {
}).catch(() => {});
// v0.2.3 #3 — per-tag vocab hit/miss 분류 (updateAiResult 후 → tagId 보장)
// dedup: AI 응답에 같은 태그 중복 가능 — INSERT OR IGNORE 와 정합한 1-emit/태그 보장
const vocabSet = new Set(vocab);
for (const tagName of new Set(res.tags)) {
if (vocabSet.has(tagName)) {
const tagId = this.repo.getTagIdByName(tagName);
if (tagId !== null) {
await this.telemetry.emit({
kind: 'tag_vocab_hit',
payload: { tagId, vocabSize: vocab.length }
const vocabSet = new Set(vocab.map((v) => v.toLowerCase()));
await Promise.all(
Array.from(new Set(res.tags)).map(async (tagName) => {
if (vocabSet.has(tagName.toLowerCase())) {
const tagId = this.repo.getTagIdByName(tagName);
if (tagId !== null) {
await telemetry.emit({
kind: 'tag_vocab_hit',
payload: { tagId, vocabSize: vocab.length }
}).catch(() => {});
}
} else {
await telemetry.emit({
kind: 'tag_vocab_miss',
payload: { vocabSize: vocab.length }
}).catch(() => {});
}
} else {
await this.telemetry.emit({
kind: 'tag_vocab_miss',
payload: { vocabSize: vocab.length }
}).catch(() => {});
}
}
})
);
}
this.emit(job.noteId);
return;

View File

@@ -6,13 +6,20 @@ export interface GenerateInput {
todayKst: string; // ISO YYYY-MM-DD in KST
dueDateCandidates: ParseResult[];
vocab?: string[]; // v0.2.3 #3 — top-N kebab-case 태그. 미전달 시 빈 배열로 처리.
// v0.3.1 Cut F — 첨부 이미지. 미전달 시 텍스트 전용 처리.
images?: Array<{ base64: string; mime: string }>;
}
export interface GenerateOptions {
/** v0.3.1 Cut F — vision 전용 model 지정. null/미전달 시 기본 model 사용. */
visionModel?: string | null;
}
export interface HealthResult { ok: boolean; model?: string; reason?: string; }
export interface InferenceProvider {
readonly name: string;
generate(input: GenerateInput): Promise<AiResponse>;
generate(input: GenerateInput, opts?: GenerateOptions): Promise<AiResponse>;
healthCheck(): Promise<HealthResult>;
/** v0.2.3.1 — 외부에서 in-flight generate 강제 중단. ProviderHolder.replace 시 사용. */
abort?: () => void;

View File

@@ -1,9 +1,34 @@
import { request } from 'undici';
import { parseAiResponse, type AiResponse } from './schema.js';
import { buildPrompt } from './prompt.js';
import type { GenerateInput, HealthResult, InferenceProvider } from './InferenceProvider.js';
import { buildVisionPrompt } from './visionPrompt.js';
import type { GenerateInput, GenerateOptions, HealthResult, InferenceProvider } from './InferenceProvider.js';
import { DEFAULT_OLLAMA_ENDPOINT, DEFAULT_OLLAMA_MODEL } from '../../shared/constants.js';
function classifyFetchError(e: unknown): 'network' | 'timeout' | 'dns' | 'other' {
const msg = ((e as Error)?.message ?? '').toLowerCase();
if (msg.includes('aborted') || msg.includes('timeout')) return 'timeout';
if (msg.includes('econnrefused') || msg.includes('econnreset')) return 'network';
if (msg.includes('enotfound') || msg.includes('eai_again')) return 'dns';
return 'other';
}
/**
* v0.3.11 — vision model 이 'format:json' constraint 를 부분적으로 따라 markdown 코드
* 펜스 / prose 가 섞인 응답을 반환할 때 fallback. 첫 '{' ~ 마지막 '}' substring 만
* 추출해서 JSON.parse 재시도. 실패하면 raw response 일부 포함한 에러 throw (디버깅용).
*/
function parseJsonLoose(raw: string): unknown {
try { return JSON.parse(raw); } catch { /* fallback below */ }
const first = raw.indexOf('{');
const last = raw.lastIndexOf('}');
if (first >= 0 && last > first) {
const slice = raw.slice(first, last + 1);
try { return JSON.parse(slice); } catch { /* fall through */ }
}
throw new Error(`unparseable response: ${raw.slice(0, 200).replace(/\s+/g, ' ')}`);
}
export interface LocalOllamaOptions {
endpoint?: string;
model?: string;
@@ -30,30 +55,39 @@ export class LocalOllamaProvider implements InferenceProvider {
this.name = `local-ollama/${this.model}`;
}
async generate(input: GenerateInput): Promise<AiResponse> {
async generate(input: GenerateInput, opts?: GenerateOptions): Promise<AiResponse> {
const useVision = !!opts?.visionModel && (input.images?.length ?? 0) > 0;
const model = useVision ? opts!.visionModel! : this.model;
const prompt = useVision
? buildVisionPrompt(input.text, input.todayKst, input.dueDateCandidates.map((c) => c.iso ?? c.matchedToken ?? ''), input.vocab ?? [])
: buildPrompt(input.text, input.todayKst, input.dueDateCandidates, input.vocab ?? []);
this.abortController = new AbortController();
const timer = setTimeout(() => this.abortController?.abort(), this.timeoutMs);
try {
const body: Record<string, unknown> = {
model,
prompt,
format: 'json',
stream: false,
options: { temperature: this.temperature, num_predict: this.numPredict }
};
if (useVision) {
body.images = input.images!.map((i) => i.base64);
}
const res = await request(`${this.endpoint}/api/generate`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
model: this.model,
prompt: buildPrompt(input.text, input.todayKst, input.dueDateCandidates, input.vocab ?? []),
format: 'json',
stream: false,
options: { temperature: this.temperature, num_predict: this.numPredict }
}),
body: JSON.stringify(body),
signal: this.abortController.signal
});
if (res.statusCode < 200 || res.statusCode >= 300) {
throw new Error(`ollama http ${res.statusCode}`);
}
const body = (await res.body.json()) as { response?: string };
if (!body.response) throw new Error('missing response field');
let parsed: unknown;
try { parsed = JSON.parse(body.response); }
catch (err) { throw new Error(`invalid json in response: ${String(err)}`); }
const responseBody = (await res.body.json()) as { response?: string };
if (!responseBody.response) throw new Error('missing response field');
// v0.3.11 — vision model 응답이 markdown fence / prose 섞인 경우 fallback 추출.
const parsed = parseJsonLoose(responseBody.response);
return parseAiResponse(parsed);
} finally {
clearTimeout(timer);
@@ -108,7 +142,8 @@ export class LocalOllamaProvider implements InferenceProvider {
return found ? { ok: true, model: this.model }
: { ok: false, reason: `${this.model} not installed` };
} catch (err) {
return { ok: false, reason: `unreachable: ${(err as Error).message}` };
const cls = classifyFetchError(err);
return { ok: false, reason: `unreachable:${cls}` };
}
}
}

View File

@@ -0,0 +1,27 @@
export function buildVisionPrompt(
text: string,
todayKst: string,
dueCandidates: string[],
vocab: string[]
): string {
// v0.3.11 — vision model 이 'format:json' constraint 를 부분적으로만 따르는 경우가
// 잦음 (특히 gemma3 vision). title 한국어 + JSON only 를 prompt 에서 명시 강조,
// markdown fence 금지 표기로 schema parse 통과율 개선.
return `다음 메모와 첨부 이미지를 종합 분석해 한국어로 요약하세요.
메모 본문 (비어 있을 수 있음):
${text || '(이미지만 있음)'}
규칙:
- "title" 은 반드시 한국어로 (영어 금지). 60자 이내.
- "summary" 는 3줄. 이미지 시각 정보 (텍스트/사람/장면) 포함.
- "tags" 는 영문 kebab-case (예: meeting-notes), 최대 3개. 한국어 태그 금지.
- "due_date" 는 ISO 형식 YYYY-MM-DD 또는 null.
오직 JSON 객체 하나만 출력. markdown 코드 펜스 (\`\`\`) / 설명 prose 금지.
출력 형식: {"title":"...","summary":"...","tags":[],"due_date":null}
오늘: ${todayKst}
가능한 due 후보: ${dueCandidates.join(', ')}
빈출 태그: ${vocab.slice(0, 20).join(', ')}`;
}

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

@@ -17,6 +17,7 @@ import { HealthChecker } from './services/HealthChecker.js';
import { LocalOllamaProvider } from './ai/LocalOllamaProvider.js';
import { ProviderHolder } from './ai/ProviderHolder.js';
import { AiWorker } from './ai/AiWorker.js';
import { refreshVisionCache } from './services/VisionDetect.js';
import { registerCaptureApi } from './ipc/captureApi.js';
import { registerInboxApi, pushNoteUpdated, pushOllamaStatus } from './ipc/inboxApi.js';
import { registerSettingsApi, navigateInbox } from './ipc/settingsApi.js';
@@ -30,6 +31,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';
@@ -121,6 +123,11 @@ app.whenReady().then(async () => {
const provider = new LocalOllamaProvider({ endpoint: resolvedEndpoint, model: resolvedModel });
const providerHolder = new ProviderHolder(provider);
// v0.3.1 Cut F — app launch 시 vision capability cache 갱신 (fire-and-forget).
// 실패 silent (cache 유지). 사용자가 설정 페이지에서 "다시 감지" manual trigger 가능.
void refreshVisionCache({ settings: settingsSvc, endpoint: resolvedEndpoint }).catch(() => {});
const health = new HealthChecker(providerHolder, {
// v0.2.9 Cut B Task 14 — AI 비활성 시 health polling skip (Ollama 미설치 환경 무영향).
isAiEnabled: () => settingsSvc.isAiEnabled(),
@@ -148,7 +155,10 @@ app.whenReady().then(async () => {
refreshTray({ todayCount: repo.countToday() });
},
logger,
telemetry
telemetry,
// v0.3.1 Cut F — vision 지원
settings: settingsSvc,
mediaStore: store
});
const notify = new NotificationService({
@@ -196,7 +206,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 +217,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

@@ -150,11 +150,30 @@ export function registerInboxApi(deps: InboxIpcDeps): void {
ipcMain.handle('inbox:retryAllFailed', async () => deps.capture.retryAllFailed());
ipcMain.handle('inbox:failedCount', () => deps.repo.countFailed());
// v0.3.9 — per-note retry/cancel. failed/pending 사용자 막힘 해소 path.
// status 변경 후 pushNoteUpdated 로 renderer counts/list 자동 sync.
ipcMain.handle('inbox:retry-one-failed', async (_e, id: string) => {
const r = await deps.capture.retryOneFailed(id);
if (r.ok) {
const updated = deps.repo.findById(id);
if (updated !== null) pushNoteUpdated(deps.getInboxWindow, updated);
}
return r;
});
ipcMain.handle('inbox:cancel-pending', (_e, id: string) => {
const r = deps.capture.cancelPending(id);
if (r.ok) {
const updated = deps.repo.findById(id);
if (updated !== null) pushNoteUpdated(deps.getInboxWindow, updated);
}
return r;
});
ipcMain.handle('inbox:listRecallCandidate', () => deps.capture.listRecallCandidate());
ipcMain.handle('inbox:markRecallOpened', (_e, id: string) => deps.capture.markRecallOpened(id));
ipcMain.handle('inbox:dismissRecall', (_e, id: string) => deps.capture.dismissRecall(id));
ipcMain.handle('inbox:emitRecallShown', (_e, id: string) => deps.capture.emitRecallShown(id));
ipcMain.handle('inbox:emitRecallSnoozed', (_e, id: string) => deps.capture.emitRecallSnoozed(id));
ipcMain.on('inbox:emitRecallShown', (_e, id: string) => { void deps.capture.emitRecallShown(id); });
ipcMain.on('inbox:emitRecallSnoozed', (_e, id: string) => { void deps.capture.emitRecallSnoozed(id); });
ipcMain.handle('inbox:loadOllamaSettings', async () => {
const s = await deps.settings.load();
@@ -198,6 +217,9 @@ export function registerInboxApi(deps: InboxIpcDeps): void {
// v0.2.9 Cut B Task 8 — status 4분기 직접 전이 (사유 포함).
// Modal 의 "완료/보관/휴지통" 버튼 path. backward compat 동기화는
// NoteRepository.setStatus 내부에서 처리 (deleted_at sync).
// v0.3.8 — setStatus 도 pushNoteUpdated emit. 이전엔 emit 안 해서 renderer 의 store
// (counts/search/list) 가 stale 되어 NoteCard 호출처마다 명시적 refreshMeta 호출이
// 필요했음. push 화 시 onNoteUpdated 콜백 1개 path 로 일관 갱신.
ipcMain.handle(
'inbox:set-status',
async (_e, id: string, status: NoteStatus, reason: string | null) => {
@@ -206,6 +228,8 @@ export function registerInboxApi(deps: InboxIpcDeps): void {
return { ok: false as const, reason: 'invalid status' as const };
}
deps.repo.setStatus(id, status, reason);
const updated = deps.repo.findById(id);
if (updated !== null) pushNoteUpdated(deps.getInboxWindow, updated);
return { ok: true as const };
}
);
@@ -292,6 +316,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

@@ -1,16 +1,21 @@
import electron from 'electron';
import type { BrowserWindow } from 'electron';
import { platform, release, EOL } from 'node:os';
import { mkdir } from 'node:fs/promises';
const { ipcMain, app, dialog, Notification, shell, clipboard } = electron;
import { logger } from '../logger.js';
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';
import { refreshVisionCache } from '../services/VisionDetect.js';
import { DEFAULT_OLLAMA_ENDPOINT } from '../../shared/constants.js';
/**
* 외부 (트레이 / second-instance / 기타 main 프로세스 호출자) 에서 inbox 창에 view 전환을
@@ -36,6 +41,7 @@ export interface SettingsIpcDeps {
telemetry: TelemetryService;
settings: SettingsService;
getInboxWindow: () => BrowserWindow | null;
syncTimer?: SyncTimer;
}
/**
@@ -106,6 +112,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 +261,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 +303,112 @@ 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();
try {
await mkdir(syncDir, { recursive: true });
} catch (e) {
return { ok: false as const, reason: `mkdir failed: ${(e as Error).message}` };
}
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 };
});
// v0.3.1 Cut F — vision IPC
ipcMain.handle('settings:get-vision-models', async () => {
const cache = await deps.settings.getVisionCapableCache();
const selected = await deps.settings.getVisionModel();
return { models: cache.models, at: cache.at, selected };
});
ipcMain.handle('settings:set-vision-model', async (_e, value: string | null) => {
const sanitized = typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
await deps.settings.setVisionModel(sanitized);
return { ok: true as const };
});
ipcMain.handle('settings:refresh-vision-cache', async () => {
// Cut F final review fix — index.ts 의 resolvedEndpoint (settings → env → default)
// 와 동일한 fallback 체인 사용. settings.ollama 미설정 + env / default 만 있는 dev
// 환경에서도 manual "다시 감지" 가 동작하도록.
const all = await deps.settings.getAll();
const endpoint = all.ollama?.endpoint
?? process.env.INKLING_OLLAMA_ENDPOINT
?? DEFAULT_OLLAMA_ENDPOINT;
return refreshVisionCache({ settings: deps.settings, endpoint });
});
}

View File

@@ -1,7 +1,8 @@
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 { kstTodayIso, KST_OFFSET_MS } from '../../shared/util/kstDate.js';
import { sanitizeFtsQuery, computeCutoff, type ReviewPeriod } from './ftsHelpers.js';
export interface CreateNoteInput {
rawText: string;
@@ -50,30 +51,53 @@ 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 {
constructor(private db: Database.Database) {}
create(input: CreateNoteInput): { id: string } {
create(input: CreateNoteInput, now: Date = new Date()): { id: string } {
const id = uuidv7();
const now = new Date().toISOString();
const ts = now.toISOString();
const aiStatus: AiStatus = input.aiStatus ?? 'pending';
const tx = this.db.transaction(() => {
this.db
.prepare(`INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)`)
.run(id, input.rawText, aiStatus, now, now);
.run(id, input.rawText, aiStatus, ts, ts);
this.db
.prepare(`INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by)
VALUES (?, ?, ?, 'capture')`)
.run(id, input.rawText, now);
.run(id, input.rawText, ts);
// pending_jobs 는 'pending' 일 때만 생성 — 'disabled' 노트는 worker 가 처리 안 함.
if (aiStatus === 'pending') {
this.db
.prepare(`INSERT INTO pending_jobs (note_id, attempts, next_run_at)
VALUES (?, 0, ?)`)
.run(id, now);
.run(id, ts);
}
});
tx();
@@ -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();
}
@@ -223,6 +248,47 @@ export class NoteRepository {
return { ids };
}
/**
* v0.3.9 — 단일 failed 노트 재시도. retryAllFailed 의 per-row 로직 동일.
* NoteCard 의 per-note "재시도" 버튼 path. failed 가 아닌 status 면 no-op.
*/
retryOneFailed(id: string, now: string): { ok: boolean } {
const row = this.db
.prepare(`SELECT ai_status FROM notes WHERE id=? AND deleted_at IS NULL`)
.get(id) as { ai_status: string } | undefined;
if (!row || row.ai_status !== 'failed') return { ok: false };
const tx = this.db.transaction(() => {
this.db
.prepare(`UPDATE notes SET ai_status='pending', ai_error=NULL, updated_at=? WHERE id=?`)
.run(now, id);
this.db
.prepare(`INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)`)
.run(id, now);
});
tx();
return { ok: true };
}
/**
* v0.3.9 — pending 노트의 AI 처리 cancel. ai_status='disabled' 로 전환 + pending_jobs 삭제.
* raw_text 는 보존. 사용자가 무한 pending (Ollama 끊김 등) 에서 빠져나오는 path.
* pending 외 status 면 no-op.
*/
cancelPending(id: string, now: string): { ok: boolean } {
const row = this.db
.prepare(`SELECT ai_status FROM notes WHERE id=? AND deleted_at IS NULL`)
.get(id) as { ai_status: string } | undefined;
if (!row || row.ai_status !== 'pending') return { ok: false };
const tx = this.db.transaction(() => {
this.db
.prepare(`UPDATE notes SET ai_status='disabled', ai_error=NULL, updated_at=? WHERE id=?`)
.run(now, id);
this.db.prepare(`DELETE FROM pending_jobs WHERE note_id=?`).run(id);
});
tx();
return { ok: true };
}
/**
* v0.2.9 Cut B Task 16 — 모든 ai_status='disabled' 노트를 'pending' 으로 reset 하고
* pending_jobs 재투입. 사용자가 settings.ai_enabled OFF→ON 전환 후 "지금 모두 처리"
@@ -390,6 +456,7 @@ export class NoteRepository {
const row = getOrInsert.get(t) as { id: number };
link.run(id, row.id);
}
this.rebuildFtsTagsForNote(id);
}
});
tx();
@@ -598,6 +665,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 +844,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 +919,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(
@@ -788,7 +1080,6 @@ export class NoteRepository {
* and count rows whose UTC ISO `created_at` lies inside.
*/
countToday(now: Date = new Date()): number {
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
const kstNow = new Date(now.getTime() + KST_OFFSET_MS);
const kstYear = kstNow.getUTCFullYear();
const kstMonth = kstNow.getUTCMonth();
@@ -851,6 +1142,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,35 @@
/**
* v0.2.11 Cut D — FTS5 검색 + 회고 view 의 순수 함수 헬퍼.
*/
import { KST_OFFSET_MS } from '../../shared/util/kstDate.js';
// FTS5 special chars: " * ( ) : 외에도 - (NOT 연산자), ^ (column prefix), ` (escape),
// AND/OR/NOT keyword 도 query parser 가 special 처리. v0.3.9 — backtick/dash/caret 추가.
// 한국어 사용자가 의도 없이 입력할 가능성 가장 높은 punctuation 까지 sanitize.
const FTS5_SPECIAL_CHARS_RE = /["*():`^\-]/g;
const WS_COLLAPSE_RE = /\s+/g;
/**
* FTS5 MATCH 쿼리에 안전한 형태로 변환. special chars 공백 치환 + 공백 정리.
* 다중 토큰은 그대로 두어 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';
/**
* 회고 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

@@ -2,8 +2,7 @@ import type Database from 'better-sqlite3';
import { mkdir, rename, stat, readdir, unlink, readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { applyGfsRetention } from './backupRotation.js';
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
import { KST_OFFSET_MS } from '../../shared/util/kstDate.js';
const MARKER_FILENAME = '.last-snapshot';
function toKstDateKey(d: Date): string {

View File

@@ -207,6 +207,24 @@ export class CaptureService {
return { count: ids.length };
}
/**
* v0.3.9 — 단일 failed 노트 재시도. NoteCard 의 per-note "재시도" 버튼 path.
* repo.retryOneFailed 후 worker.enqueue 재투입.
*/
async retryOneFailed(id: string): Promise<{ ok: boolean }> {
const r = this.repo.retryOneFailed(id, new Date().toISOString());
if (r.ok) await this.deps.enqueue(id);
return r;
}
/**
* v0.3.9 — pending 노트 cancel. ai_status='disabled' + pending_jobs 삭제.
* 사용자가 무한 pending (Ollama 끊김 등) 에서 빠져나오는 path.
*/
cancelPending(id: string): { ok: boolean } {
return this.repo.cancelPending(id, new Date().toISOString());
}
/** v0.2.3 #6 — 회상 후보 1건 fetch. */
async listRecallCandidate(): Promise<Note | null> {
return this.repo.findRecallCandidate();

View File

@@ -1,7 +1,6 @@
import type Database from 'better-sqlite3';
import type { WeeklyContinuity } from '@shared/types';
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
import { KST_OFFSET_MS } from '../../shared/util/kstDate.js';
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
const WEEK_TARGET = 7;
const RECOVERY_GAP_DAYS = 7;

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,15 @@ 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(),
// v0.3.1 Cut F
vision_model: z.string().nullable().optional(),
vision_capable_cache: z.array(z.string()).optional(),
vision_cache_at: z.string().optional()
}).strict();
export type Settings = z.infer<typeof SettingsSchema>;
@@ -81,6 +89,72 @@ 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);
}
/** v0.3.1 Cut F — 선택된 vision model. null = 미선택. */
async getVisionModel(): Promise<string | null> {
const s = await this.load();
return s.vision_model ?? null;
}
async setVisionModel(value: string | null): Promise<void> {
const current = await this.load();
const next: Settings = { ...current, vision_model: value };
await this.persist(next);
}
/** v0.3.1 Cut F — /api/tags 조회 결과 캐시. 기본 빈 배열 + null timestamp. */
async getVisionCapableCache(): Promise<{ models: string[]; at: string | null }> {
const s = await this.load();
return { models: s.vision_capable_cache ?? [], at: s.vision_cache_at ?? null };
}
async setVisionCapableCache(models: string[], now: Date): Promise<void> {
const current = await this.load();
const next: Settings = { ...current, vision_capable_cache: models, vision_cache_at: now.toISOString() };
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

@@ -0,0 +1,47 @@
import type { SettingsService } from './SettingsService.js';
// v0.3.1 Cut F final fix — gemma 시리즈 default 정정. 본인 dogfood 환경 = gemma4:e4b
// (텍스트). vision 변종은 gemma3 (현재 vision-capable) 또는 gemma4 (향후 출시 시).
// 양 family 모두 hint 에 포함 — capability detection 이 future-proof.
const VISION_FAMILIES = new Set(['gemma3', 'gemma4', 'llava', 'llama3.2-vision', 'minicpm-v', 'pixtral']);
const VISION_NAME_HINTS = ['vision', 'vl', 'multimodal', 'gemma3', 'gemma4'];
export interface OllamaModel {
name: string;
details?: { family?: string; families?: string[] };
}
export function isVisionCapable(model: OllamaModel): boolean {
if (model.details?.family && VISION_FAMILIES.has(model.details.family)) return true;
if (model.details?.families?.some((f) => VISION_FAMILIES.has(f))) return true;
const lower = model.name.toLowerCase();
return VISION_NAME_HINTS.some((h) => lower.includes(h));
}
export interface RefreshDeps {
settings: SettingsService;
endpoint: string;
now?: () => Date;
fetchImpl?: typeof fetch;
}
export async function refreshVisionCache(
deps: RefreshDeps
): Promise<{ ok: true; models: string[] } | { ok: false; reason: string }> {
if (!(await deps.settings.isAiEnabled())) {
return { ok: false, reason: 'ai_disabled' };
}
const fetchFn = deps.fetchImpl ?? fetch;
let body: { models?: OllamaModel[] };
try {
const r = await fetchFn(`${deps.endpoint}/api/tags`);
if (!r.ok) return { ok: false, reason: `tags http ${r.status}` };
body = (await r.json()) as { models?: OllamaModel[] };
} catch (e) {
return { ok: false, reason: `unreachable: ${(e as Error).message}` };
}
const capable = (body.models ?? []).filter(isVisionCapable).map((m) => m.name);
const now = deps.now ? deps.now() : new Date();
await deps.settings.setVisionCapableCache(capable, now);
return { ok: true, models: capable };
}

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

@@ -16,16 +16,29 @@ export function createQuickCaptureWindow(): BrowserWindowType {
const x = Math.round((primary.workArea.width - W) / 2 + primary.workArea.x);
const y = Math.round((primary.workArea.height - H) / 3 + primary.workArea.y);
// v0.3.10 — macOS fullscreen Space 위에 quick capture 띄우기.
// 기본 BrowserWindow 는 첫 생성된 Space (홈 데스크탑) 에만 표시되므로
// 사용자가 다른 앱 fullscreen 중일 때 macOS 가 강제 Space 전환 → 사용자 경험 깨짐.
// 'panel' 타입 + 'screen-saver' level + visibleOnFullScreen 조합으로 현재 Space 위에 overlay.
const isMac = process.platform === 'darwin';
win = new BrowserWindow({
width: W, height: H, x, y,
frame: false, show: false, alwaysOnTop: true,
skipTaskbar: true, resizable: false,
...(isMac ? { type: 'panel' as const } : {}),
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
contextIsolation: true, nodeIntegration: false, sandbox: false
}
});
if (isMac) {
// 'screen-saver' level — fullscreen app 위에 띄울 수 있는 가장 높은 level.
// visibleOnFullScreen: true — 현재 fullscreen Space 에 함께 표시 (Space 전환 안 함).
win.setAlwaysOnTop(true, 'screen-saver');
win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
}
if (process.env.ELECTRON_RENDERER_URL) {
win.loadURL(`${process.env.ELECTRON_RENDERER_URL}/quickcapture/index.html`);
} else {

View File

@@ -40,11 +40,13 @@ const api: InklingApi = {
},
retryAllFailed: () => ipcRenderer.invoke('inbox:retryAllFailed'),
getFailedCount: () => ipcRenderer.invoke('inbox:failedCount'),
retryOneFailed: (id: string) => ipcRenderer.invoke('inbox:retry-one-failed', id),
cancelPending: (id: string) => ipcRenderer.invoke('inbox:cancel-pending', id),
listRecallCandidate: () => ipcRenderer.invoke('inbox:listRecallCandidate'),
markRecallOpened: (id: string) => ipcRenderer.invoke('inbox:markRecallOpened', id),
dismissRecall: (id: string) => ipcRenderer.invoke('inbox:dismissRecall', id),
emitRecallShown: (id: string) => ipcRenderer.invoke('inbox:emitRecallShown', id),
emitRecallSnoozed: (id: string) => ipcRenderer.invoke('inbox:emitRecallSnoozed', id),
emitRecallShown: (id: string) => { ipcRenderer.send('inbox:emitRecallShown', id); },
emitRecallSnoozed: (id: string) => { ipcRenderer.send('inbox:emitRecallSnoozed', id); },
loadOllamaSettings: () => ipcRenderer.invoke('inbox:loadOllamaSettings'),
saveOllamaSettings: (v: { endpoint: string; model: string }) => ipcRenderer.invoke('inbox:saveOllamaSettings', v),
// v0.2.7 Task 13 — 외부 (트레이) 에서 view 전환 요청 listener.
@@ -85,6 +87,22 @@ 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),
// v0.3.1 Cut F — vision capability + 모델 선택
getVisionModels: () => ipcRenderer.invoke('settings:get-vision-models'),
setVisionModel: (value: string | null) => ipcRenderer.invoke('settings:set-vision-model', value),
refreshVisionCache: () => ipcRenderer.invoke('settings:refresh-vision-cache'),
}
};

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',
@@ -97,14 +106,31 @@ export function App(): React.ReactElement {
).map((t) => (
<button
key={t.key}
type="button"
role="tab"
aria-selected={view === t.key}
onClick={() => setView(t.key)}
aria-pressed={view === t.key}
style={tabBtnStyle(view === t.key)}
>
{t.label}({t.count})
</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 />
@@ -127,15 +153,21 @@ export function App(): React.ReactElement {
<main className="main">
{!showTrash && (
<>
<OllamaBanner onOpenSettings={() => setShowSettings(true)} />
<RecoveryToast
show={showRecovery}
onDismiss={() => { markRecoveryDismissed(); setRecoveryDismissed(true); }}
/>
<PendingBanner />
<FailedBanner />
<ExpiryBanner />
<RecallBanner />
{/* AI/만료/회상 배너는 active 노트 컨텍스트 — inbox 탭에서만 노출.
completed/archived 에서는 무관 컨텐츠라 숨김. */}
{view === 'inbox' && (
<>
<OllamaBanner onOpenSettings={() => setShowSettings(true)} />
<RecoveryToast
show={showRecovery}
onDismiss={() => { markRecoveryDismissed(); setRecoveryDismissed(true); }}
/>
<PendingBanner />
<FailedBanner />
<ExpiryBanner />
<RecallBanner />
</>
)}
{tagFilter !== null && (
<div style={{
background: '#eaf3ff', color: '#0a4b80', padding: '6px 12px',
@@ -155,12 +187,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,135 @@
import React, { useEffect, useState } from 'react';
import type { SyncConflict } from '@shared/types';
import { inboxApi } from '../api.js';
import type { SyncHelpAnchor } from './SyncHelpModal.js';
interface Props {
onClose: () => void;
onResolved: () => void;
onOpenHelp?: (anchor: SyncHelpAnchor) => 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, onOpenHelp }: 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; };
}, []);
// Escape key 로 닫기.
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
}
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [onClose]);
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, fontSize: 11, color: '#666', lineHeight: 1.5 }}>
<div><b> </b>: .</div>
<div><b> </b>: .</div>
{onOpenHelp && (
<button
onClick={() => onOpenHelp('main-conflict')}
style={{ background: 'none', border: 'none', color: '#0a4b80', cursor: 'pointer', fontSize: 11, padding: 0, marginTop: 2, textDecoration: 'underline' }}
>
</button>
)}
</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

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { inboxApi } from '../api.js';
import type { NoteStatus } from '@shared/types';
@@ -6,17 +6,24 @@ interface Props {
noteId: string;
rawText: string;
summary: string;
/** 현재 노트 status. 이 값을 제외한 나머지 status 가 이동 버튼으로 노출. */
currentStatus: NoteStatus;
onClose: () => void;
onMoved: (status: NoteStatus, reason: string | null) => void;
}
/**
* v0.2.9 Cut B Task 7 — 메모 이동 Modal.
* 메모 이동 Modal.
*
* 사유 입력 + 3 status 버튼 (완료/보관/휴지통) + AI 자동 분류.
* 사유 입력 + AI 자동 분류 + 수동 status 선택. 버튼은 currentStatus 를 제외한
* 나머지 status 만 노출 (v0.3.6 까지는 완료/보관/휴지통 hardcode 라 완료/보관 노트가
* inbox 로 못 돌아오던 버그를 v0.3.7 에서 정정).
*/
const ALL_STATUSES: readonly NoteStatus[] = ['active', 'completed', 'archived', 'trashed'];
export function MoveStatusModal({
noteId,
currentStatus,
onClose,
onMoved
}: Props): React.ReactElement {
@@ -27,6 +34,15 @@ export function MoveStatusModal({
} | null>(null);
const [classifying, setClassifying] = useState(false);
// Escape key 로 modal 닫기. mount 동안만 listener 활성.
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
}
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [onClose]);
async function move(status: NoteStatus): Promise<void> {
const trimmedReason = reason.trim() === '' ? null : reason.trim();
await inboxApi.setStatus(noteId, status, trimmedReason);
@@ -48,6 +64,7 @@ export function MoveStatusModal({
<div
role="dialog"
aria-label="이동"
onClick={onClose}
style={{
position: 'fixed',
top: 0,
@@ -62,6 +79,7 @@ export function MoveStatusModal({
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
background: '#fff',
padding: 16,
@@ -90,9 +108,11 @@ export function MoveStatusModal({
<button onClick={() => void classify()} disabled={classifying}>
{classifying ? '분류 중...' : 'AI 자동 분류'}
</button>
<button onClick={() => void move('completed')}></button>
<button onClick={() => void move('archived')}></button>
<button onClick={() => void move('trashed')}></button>
{ALL_STATUSES.filter((s) => s !== currentStatus).map((s) => (
<button key={s} onClick={() => void move(s)}>
{statusLabel(s)}
</button>
))}
<button onClick={onClose} style={{ marginLeft: 'auto' }}>
</button>
@@ -126,7 +146,8 @@ export function MoveStatusModal({
export function statusLabel(s: NoteStatus): string {
switch (s) {
case 'active':
return '활성';
// 헤더 탭 표기 ('Inbox') 와 일치. UI 전반에서 active = Inbox 동의어.
return 'Inbox';
case 'completed':
return '완료';
case 'archived':

View File

@@ -1,11 +1,12 @@
import React, { useState } from 'react';
import type { Note, NoteStatus } from '@shared/types';
import type { Note } from '@shared/types';
import { KST_OFFSET_MS } from '@shared/util/kstDate.js';
import { inboxApi } from '../api.js';
import { useInbox } from '../store.js';
import { EditableField } from './EditableField.js';
import { IntentBanner } from './IntentBanner.js';
import { pushTagUndo } from './TagUndoToast.js';
import { MoveStatusModal, statusLabelWithParticle } from './MoveStatusModal.js';
import { MoveStatusModal } from './MoveStatusModal.js';
import { RevisionHistoryModal } from './RevisionHistoryModal.js';
interface Props {
@@ -27,7 +28,6 @@ function isPastDue(iso: string, today: string): boolean {
}
function todayKstIso(): string {
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
const k = new Date(Date.now() + KST_OFFSET_MS);
return k.toISOString().slice(0, 10);
}
@@ -116,17 +116,12 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
const [local, setLocal] = useState(note);
const isAiDisabled = local.aiStatus === 'disabled';
const fallbackTitle = local.rawText.split('\n')[0]?.slice(0, 60) || '(빈 메모)';
// v0.2.9 Cut B Task 6 — 이동 메뉴 dropdown + MoveStatusModal target.
const [moveTarget, setMoveTarget] = useState<NoteStatus | null>(null);
const [menuOpen, setMenuOpen] = useState(false);
// 이동 modal 열림 여부. 클릭 시 MoveStatusModal 에서 사유 + AI 분류 + 수동 분류 선택.
const [moveOpen, setMoveOpen] = useState(false);
const [editingRaw, setEditingRaw] = useState(false);
const [draftRaw, setDraftRaw] = useState('');
const [showRevisions, setShowRevisions] = useState(false);
const possibleTargets: NoteStatus[] = (
['active', 'completed', 'archived', 'trashed'] as NoteStatus[]
).filter((s) => s !== local.status);
React.useEffect(() => { setLocal(note); }, [note]);
const formatted = new Date(note.createdAt).toLocaleString('ko-KR');
@@ -220,13 +215,44 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
)}
{local.aiStatus === 'pending' && (
<div style={{ fontSize: 16, fontWeight: 600, color: '#666', marginTop: 4 }}>
Inkling이
<div style={{ marginTop: 4, display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ fontSize: 16, fontWeight: 600, color: '#666' }}>
Inkling이
</div>
{/* v0.3.9 — pending cancel UI. Ollama 끊김 / 무한 pending 시 사용자 unblock path. */}
<button
onClick={async () => {
await inboxApi.cancelPending(local.id);
// push 기반 — onNoteUpdated 가 store 자동 갱신.
}}
style={{
background: 'none', border: '1px solid #ccc', color: '#666',
cursor: 'pointer', fontSize: 11, padding: '2px 8px', borderRadius: 4
}}
title="AI 자동 처리를 건너뛰고 원문만 보관"
>
</button>
</div>
)}
{local.aiStatus === 'failed' && (
<div title={local.aiError ?? ''} style={{ fontSize: 16, fontWeight: 600, color: '#a55', marginTop: 4 }}>
<div style={{ marginTop: 4, display: 'flex', alignItems: 'center', gap: 8 }}>
<div title={local.aiError ?? ''} style={{ fontSize: 16, fontWeight: 600, color: '#a55' }}>
</div>
{/* v0.3.9 — per-note 재시도 UI. FailedBanner 의 일괄 재시도와 별개. */}
<button
onClick={async () => {
await inboxApi.retryOneFailed(local.id);
}}
style={{
background: 'none', border: '1px solid #a55', color: '#a55',
cursor: 'pointer', fontSize: 11, padding: '2px 8px', borderRadius: 4
}}
title="이 노트만 AI 처리 재시도"
>
</button>
</div>
)}
{/* v0.2.9 Cut B Task 13 — ai_status='disabled': raw_text 첫 줄 fallback title.
@@ -424,64 +450,23 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
alignItems: 'center'
}}
>
{/* v0.2.9 Cut B Task 6 — 모든 view 공통 "이동 ▾" dropdown.
현재 status 와 다른 3개 목적지만 표시. */}
<div style={{ position: 'relative' }}>
<button
onClick={() => setMenuOpen((o) => !o)}
aria-label="이동"
style={{
background: 'none',
border: '1px solid #ccc',
color: '#444',
cursor: 'pointer',
fontSize: 12,
padding: '4px 10px',
borderRadius: 4
}}
>
</button>
{menuOpen && (
<div
style={{
position: 'absolute',
right: 0,
top: '100%',
marginTop: 2,
background: '#fff',
border: '1px solid #ccc',
borderRadius: 4,
padding: 4,
zIndex: 10,
minWidth: 140,
boxShadow: '0 2px 6px rgba(0,0,0,0.08)'
}}
>
{possibleTargets.map((t) => (
<button
key={t}
onClick={() => {
setMoveTarget(t);
setMenuOpen(false);
}}
style={{
display: 'block',
width: '100%',
textAlign: 'left',
background: 'none',
border: 'none',
padding: '6px 8px',
fontSize: 12,
cursor: 'pointer'
}}
>
{statusLabelWithParticle(t)}
</button>
))}
</div>
)}
</div>
{/* 이동 버튼 — 클릭 시 MoveStatusModal 진입.
사유 입력 + AI 자동 분류 + 수동 status 선택 한 곳에서 처리. */}
<button
onClick={() => setMoveOpen(true)}
aria-label="이동"
style={{
background: 'none',
border: '1px solid #ccc',
color: '#444',
cursor: 'pointer',
fontSize: 12,
padding: '4px 10px',
borderRadius: 4
}}
>
</button>
{/* trash mode 만 영구 삭제 + 복구 보존 (휴지통 단독 액션). */}
{isTrash && (
@@ -509,19 +494,23 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
</div>
</div>
{moveTarget !== null && (
{moveOpen && (
<MoveStatusModal
noteId={local.id}
rawText={local.rawText}
summary={local.aiSummary ?? ''}
onClose={() => setMoveTarget(null)}
currentStatus={local.status}
onClose={() => setMoveOpen(false)}
onMoved={(newStatus, reason) => {
const updated = { ...local, status: newStatus, moveReason: reason };
setLocal(updated);
onUpdated(updated);
// inbox/trash mode 의 list 가 status 별로 필터되므로 onDeleted (제거) 도 호출.
// inbox/완료/보관/휴지통 view 의 list 가 status 별로 필터되므로 status 변경 시 onDeleted 호출.
if (newStatus !== local.status) onDeleted?.();
setMoveTarget(null);
// setStatus IPC 는 pushNoteUpdated emit 안 함 → 헤더 탭 counts 가 stale.
// refreshMeta 로 server-authoritative counts 재로드.
void useInbox.getState().refreshMeta();
setMoveOpen(false);
}}
/>
)}

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import { inboxApi } from '../api.js';
/**
@@ -10,12 +10,42 @@ import { inboxApi } from '../api.js';
* 가 SettingsPage 에서 추후 선택 가능.
*/
export function OnboardingWizard({ onClose }: { onClose: () => void }): React.ReactElement {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function choose(aiEnabled: boolean | null): Promise<void> {
if (aiEnabled !== null) await inboxApi.setAiEnabled(aiEnabled);
await inboxApi.setOnboardingCompleted(true);
setBusy(true);
setError(null);
try {
if (aiEnabled !== null) await inboxApi.setAiEnabled(aiEnabled);
await inboxApi.setOnboardingCompleted(true);
onClose();
} catch (e) {
// IPC 실패 (예: settings 저장 throw) 시 modal stuck 방지. 사용자에게 메시지 표시 +
// "지금 건너뛰기" 로 fallback 길 제공. choose() 가 throw 하지 않고 무한 wizard 잠금
// 회피.
setError((e as Error).message);
} finally {
setBusy(false);
}
}
function skip(): void {
// IPC 자체가 실패하는 상태 → ai_enabled 변경/onboarding flag 저장 모두 포기하고 wizard 만 닫기.
// 다음 launch 에 다시 wizard 가 뜸 (onboarding_completed=false 상태). 그래도 사용자가
// 진입 자체는 가능.
onClose();
}
// Escape key 로 wizard 종료 (skip 동일 — onboarding flag 미저장).
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') skip();
}
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, []);
return (
<div role="dialog" aria-label="시작 안내" style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
@@ -32,10 +62,16 @@ export function OnboardingWizard({ onClose }: { onClose: () => void }): React.Re
<a href="https://ollama.com/download" target="_blank" rel="noopener noreferrer">ollama.com/download</a>
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<button onClick={() => choose(true)}>AI (Ollama )</button>
<button onClick={() => choose(false)}> (AI )</button>
<button onClick={() => choose(null)} style={{ marginTop: 4 }}> </button>
<button onClick={() => choose(true)} disabled={busy}>AI (Ollama )</button>
<button onClick={() => choose(false)} disabled={busy}> (AI )</button>
<button onClick={() => choose(null)} disabled={busy} style={{ marginTop: 4 }}> </button>
</div>
{error !== null && (
<div style={{ marginTop: 12, padding: 8, background: '#fdecea', color: '#a3261c', fontSize: 12, borderRadius: 4 }}>
<div> : {error}</div>
<button onClick={skip} style={{ marginTop: 8 }}> </button>
</div>
)}
</div>
</div>
);

View File

@@ -0,0 +1,80 @@
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);
const setView = useInbox((s) => s.setView);
const backButton = (
<button
onClick={() => setView('inbox')}
style={{
background: 'transparent',
border: 'none',
fontSize: 14,
cursor: 'pointer',
color: '#0a4b80',
padding: 0
}}
>
</button>
);
if (!reviewData) {
return (
<div style={{ padding: 16 }}>
<div style={{ marginBottom: 12 }}>{backButton}</div>
<div style={{ fontSize: 13, color: '#666' }}> </div>
</div>
);
}
const max = reviewData.tagCounts[0]?.count ?? 1;
return (
<div style={{ padding: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
{backButton}
<h2 style={{ fontSize: 18, margin: 0 }}>{periodLabel[period]} </h2>
</div>
<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

@@ -37,6 +37,15 @@ export function RevisionHistoryModal({ noteId, onClose, onRestored }: Props): Re
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Escape key 로 닫기.
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
}
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [onClose]);
useEffect(() => {
let cancelled = false;
void (async () => {

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,130 @@
import React, { useEffect, useRef } from 'react';
export type SyncHelpAnchor = 'main-conflict' | 'auto' | 'silent' | 'setup';
interface Props {
onClose: () => void;
initialAnchor?: SyncHelpAnchor;
}
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: 110
};
const modalStyle: React.CSSProperties = {
background: '#fff', borderRadius: 8, padding: 20, width: 640,
maxHeight: '80vh', overflow: 'auto', boxShadow: '0 4px 16px rgba(0,0,0,0.2)'
};
const sectionStyle: React.CSSProperties = {
marginTop: 18, paddingTop: 12, borderTop: '1px solid #eee'
};
const h4Style: React.CSSProperties = { fontSize: 14, margin: '0 0 8px 0' };
const pStyle: React.CSSProperties = { fontSize: 12, color: '#444', lineHeight: 1.6, margin: '4px 0' };
const liStyle: React.CSSProperties = { fontSize: 12, color: '#444', lineHeight: 1.6, marginBottom: 4 };
const codeStyle: React.CSSProperties = { background: '#f4f4f4', padding: '1px 4px', borderRadius: 3, fontSize: 11 };
export function SyncHelpModal({ onClose, initialAnchor }: Props): React.ReactElement {
const bodyRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!initialAnchor) return;
const el = bodyRef.current?.querySelector(`#${initialAnchor}`);
if (el) el.scrollIntoView({ behavior: 'auto', block: 'start' });
}, [initialAnchor]);
// Escape key 로 닫기.
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
}
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [onClose]);
return (
<div style={overlayStyle} onClick={onClose}>
<div ref={bodyRef} style={modalStyle} onClick={(e) => e.stopPropagation()}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h3 style={{ margin: 0, fontSize: 16 }}> </h3>
<button onClick={onClose} aria-label="닫기" style={{ background: 'none', border: 'none', fontSize: 18, cursor: 'pointer', color: '#888' }}>×</button>
</div>
<section id="main-conflict" style={sectionStyle}>
<h4 style={h4Style}>1. ( )</h4>
<p style={pStyle}> . "충돌 해결…" ConflictModal path ( / ) .</p>
<p style={{ ...pStyle, marginTop: 10, fontWeight: 600 }}>/ </p>
<ul style={{ paddingLeft: 18, margin: '4px 0' }}>
<li style={liStyle}> ConflictModal </li>
<li style={liStyle}> 트리: 어느 </li>
<li style={liStyle}> ? 'both' ( )</li>
</ul>
<p style={{ ...pStyle, marginTop: 10, fontWeight: 600 }}>/</p>
<ul style={{ paddingLeft: 18, margin: '4px 0' }}>
<li style={liStyle}> trash , </li>
<li style={liStyle}>"삭제가 의도였다" (trash )</li>
<li style={liStyle}>"수정이 더 중요" ( = trash )</li>
</ul>
<p style={{ ...pStyle, marginTop: 10, fontWeight: 600 }}>AI </p>
<ul style={{ paddingLeft: 18, margin: '4px 0' }}>
<li style={liStyle}> AI ( / / ) </li>
<li style={liStyle}> AI ( )</li>
</ul>
</section>
<section id="auto" style={sectionStyle}>
<h4 style={h4Style}>2. ( )</h4>
<ul style={{ paddingLeft: 18, margin: '4px 0' }}>
<li style={liStyle}><b>fetch + rebase</b>: sync (linear history). conflict </li>
<li style={liStyle}><b> sync </b>: push . fetch rebase</li>
<li style={liStyle}><b>push (non-fast-forward)</b>: push fetch + rebase + . rebase conflict </li>
<li style={liStyle}><b> sync </b>: 30 ( ). 1 </li>
</ul>
</section>
<section id="silent" style={sectionStyle}>
<h4 style={h4Style}>3. (silent risk)</h4>
<ul style={{ paddingLeft: 18, margin: '4px 0' }}>
<li style={liStyle}><b> (NTP)</b>: timestamp merge . macOS / Windows NTP </li>
<li style={liStyle}><b> </b>: conflict . </li>
<li style={liStyle}><b> sync silent</b>: sync . sync / 1 </li>
</ul>
</section>
<section id="setup" style={sectionStyle}>
<h4 style={h4Style}>4. Setup / (troubleshoot)</h4>
<p style={{ ...pStyle, fontWeight: 600 }}>URL ( )</p>
<ul style={{ paddingLeft: 18, margin: '4px 0' }}>
<li style={liStyle}>SSH: <code style={codeStyle}>git@host:user/repo.git</code></li>
<li style={liStyle}>HTTPS: <code style={codeStyle}>https://host/user/repo.git</code></li>
<li style={liStyle}> : <code style={codeStyle}>git@https://...</code> 같은 혼합 형식 ✗</li>
</ul>
<p style={{ ...pStyle, fontWeight: 600, marginTop: 10 }}></p>
<ul style={{ paddingLeft: 18, margin: '4px 0' }}>
<li style={liStyle}>SSH: 기기에 SSH key + public key </li>
<li style={liStyle}>HTTPS: OS credential helper (Windows Credential Manager / macOS Keychain) push token . push X</li>
</ul>
<p style={{ ...pStyle, fontWeight: 600, marginTop: 10 }}>"연결 테스트" </p>
<ul style={{ paddingLeft: 18, margin: '4px 0' }}>
<li style={liStyle}>네트워크: 원격 host </li>
<li style={liStyle}>인증: </li>
<li style={liStyle}>URL: 형식 (SSH/HTTPS) + </li>
</ul>
<p style={{ ...pStyle, fontWeight: 600, marginTop: 10 }}></p>
<ul style={{ paddingLeft: 18, margin: '4px 0' }}>
<li style={liStyle}>URL URL . <code style={codeStyle}>git remote set-url origin</code> </li>
</ul>
</section>
</div>
</div>
);
}

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react';
import { z } from 'zod';
import { inboxApi } from '../../api.js';
import { VisionSection } from './VisionSection.js';
const endpointSchema = z.string().url();
@@ -192,6 +193,7 @@ export function AiProviderSection(): React.ReactElement {
{recheckResult && (
<div style={{ fontSize: 12, marginTop: 8 }}>{recheckResult}</div>
)}
<VisionSection />
</div>
);
}

View File

@@ -0,0 +1,163 @@
import React, { useEffect, useState } from 'react';
import { inboxApi } from '../../api.js';
import type { SyncStatusSnapshot } from '@shared/types';
import { ConflictModal } from '../ConflictModal.js';
import { SyncHelpModal, type SyncHelpAnchor } from '../SyncHelpModal.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);
const [showHelp, setShowHelp] = useState<{ open: boolean; anchor?: SyncHelpAnchor }>({ open: 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>
<button onClick={() => setShowHelp({ open: true })} style={btnStyle()}>
</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());
}}
onOpenHelp={(anchor) => setShowHelp({ open: true, anchor })}
/>
)}
</>
)}
{showHelp.open && (
<SyncHelpModal
onClose={() => setShowHelp({ open: false })}
initialAnchor={showHelp.anchor}
/>
)}
</section>
);
}
function btnStyle(): React.CSSProperties {
return {
background: '#0a4b80',
color: '#fff',
border: 'none',
cursor: 'pointer',
fontSize: 12,
padding: '4px 10px',
borderRadius: 4
};
}

View File

@@ -0,0 +1,81 @@
import React, { useEffect, useState } from 'react';
import { inboxApi } from '../../api.js';
export function VisionSection(): React.ReactElement {
const [models, setModels] = useState<string[]>([]);
const [at, setAt] = useState<string | null>(null);
const [selected, setSelected] = useState<string | null>(null);
const [busy, setBusy] = useState<'select' | 'refresh' | null>(null);
const [feedback, setFeedback] = useState<string | null>(null);
async function load() {
const r = await inboxApi.getVisionModels();
setModels(r.models);
setAt(r.at);
setSelected(r.selected);
}
useEffect(() => {
void load();
}, []);
async function onSelect(value: string) {
const next = value === '' ? null : value;
setBusy('select');
setFeedback(null);
await inboxApi.setVisionModel(next);
setSelected(next);
setBusy(null);
}
async function onRefresh() {
setBusy('refresh');
setFeedback(null);
const r = await inboxApi.refreshVisionCache();
setBusy(null);
if (r.ok) {
await load();
setFeedback(`감지 완료 (${r.models.length}개)`);
} else {
setFeedback(`감지 실패: ${r.reason}`);
}
}
return (
<section style={{ marginTop: 16 }}>
<h4 style={{ fontSize: 13, marginBottom: 6 }}> ()</h4>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginBottom: 6 }}>
<select
aria-label="이미지 분석 모델"
value={selected ?? ''}
onChange={(e) => { void onSelect(e.target.value); }}
disabled={busy !== null}
style={{ flex: 1, fontSize: 12, padding: '4px 8px', border: '1px solid #ccc', borderRadius: 4 }}
>
<option value="">()</option>
{models.map((m) => <option key={m} value={m}>{m}</option>)}
</select>
<button
onClick={() => { void onRefresh(); }}
disabled={busy !== null}
style={{ background: '#0a4b80', color: '#fff', border: 'none', cursor: 'pointer', fontSize: 12, padding: '4px 10px', borderRadius: 4 }}
>
{busy === 'refresh' ? '감지 중…' : '다시 감지'}
</button>
</div>
{at !== null && (
<div style={{ fontSize: 11, color: '#888' }}>
: {new Date(at).toLocaleString('ko-KR')}
</div>
)}
{feedback !== null && (
<div style={{ fontSize: 11, color: '#444', marginTop: 4 }}>{feedback}</div>
)}
{models.length === 0 && (
<div style={{ fontSize: 11, color: '#aaa', marginTop: 4 }}>
. Ollama vision "다시 감지" .
</div>
)}
</section>
);
}

View File

@@ -2,7 +2,7 @@
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: file:" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: file: inkling-media:" />
<title>Inkling</title>
<style>
body { margin: 0; font-family: system-ui, sans-serif; background: #f5f5f7; color: #111; }

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;
@@ -46,13 +52,12 @@ interface InboxState {
setTagFilter: (tag: string | null) => void;
setShowSettings: (open: boolean) => void;
setView: (view: InboxView) => void;
loadByView: (view: 'completed' | 'archived' | 'trash') => Promise<void>;
loadByView: (view: 'inbox' | 'completed' | 'archived' | 'trash') => Promise<void>;
toggleShowTrash: () => Promise<void>;
loadTrash: () => Promise<void>;
restoreNote: (id: string) => Promise<void>;
permanentDeleteNote: (id: string) => Promise<void>;
emptyTrash: () => Promise<void>;
loadExpired: () => Promise<void>;
trashExpiredBatch: (ids: string[]) => Promise<void>;
snoozeExpired: () => void;
recheckOllama: () => Promise<void>;
@@ -61,6 +66,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,67 +98,121 @@ export const useInbox = create<InboxState>((set, get) => ({
recallCandidate: null,
recallSnoozeUntilMs: null,
ai_enabled: true,
searchQuery: '',
searchResults: null,
reviewData: null,
async loadInitial() {
// v0.3.8 — IPC 실패 시 loading=true 영구 stuck 방지. catch 로 reset.
set({ loading: true });
const [notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, settings] = await Promise.all([
inboxApi.listNotes({ limit: 50 }),
inboxApi.getContinuity(),
inboxApi.getPendingCount(),
inboxApi.getOllamaStatus(),
inboxApi.getTodayCount(),
inboxApi.getTrashCount(),
inboxApi.listExpired(),
inboxApi.getFailedCount(),
inboxApi.listRecallCandidate(),
inboxApi.countsByStatus(),
inboxApi.getSettings()
]);
set({ notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, ai_enabled: settings.ai_enabled ?? true, loading: false });
try {
const [notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, settings] = await Promise.all([
// inbox 탭은 status='active' 만 표시 — loadByView('inbox') 와 동일 path 로 일관성 확보.
// listNotes 는 deleted_at IS NULL 만 필터 (= active+completed+archived 혼재) 이라 부정확.
inboxApi.listByStatus('active', { limit: 50 }),
inboxApi.getContinuity(),
inboxApi.getPendingCount(),
inboxApi.getOllamaStatus(),
inboxApi.getTodayCount(),
inboxApi.getTrashCount(),
inboxApi.listExpired(),
inboxApi.getFailedCount(),
inboxApi.listRecallCandidate(),
inboxApi.countsByStatus(),
inboxApi.getSettings()
]);
set({ notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, ai_enabled: settings.ai_enabled ?? true, loading: false });
} catch (e) {
// 첫 launch 의 IPC 실패 (DB migration 실패 / main process 비정상) 시 무한 loading 회피.
// 빈 데이터로 진입하면 사용자가 캡처 시도 → 실제 fail 이 표면화 → 재시도 가능.
console.error('[inbox] loadInitial failed', e);
set({ loading: false });
}
},
async refreshMeta() {
const [continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, settings] = await Promise.all([
inboxApi.getContinuity(),
inboxApi.getPendingCount(),
inboxApi.getOllamaStatus(),
inboxApi.getTodayCount(),
inboxApi.getTrashCount(),
inboxApi.listExpired(),
inboxApi.getFailedCount(),
inboxApi.listRecallCandidate(),
inboxApi.countsByStatus(),
inboxApi.getSettings()
]);
set({ continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, ai_enabled: settings.ai_enabled ?? true });
try {
const [continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, settings] = await Promise.all([
inboxApi.getContinuity(),
inboxApi.getPendingCount(),
inboxApi.getOllamaStatus(),
inboxApi.getTodayCount(),
inboxApi.getTrashCount(),
inboxApi.listExpired(),
inboxApi.getFailedCount(),
inboxApi.listRecallCandidate(),
inboxApi.countsByStatus(),
inboxApi.getSettings()
]);
set({ continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, ai_enabled: settings.ai_enabled ?? true });
} catch (e) {
// refreshMeta 는 background poll/event 에서 자주 호출 → fail 무시 (다음 호출에 회복).
console.error('[inbox] refreshMeta failed', e);
}
},
upsertNote(note) {
// trashCount 는 server-authoritative. trashNotes 가 cache-loaded (showTrash=true) 일
// 때만 trashCount 를 local recompute. 그 외엔 server 값 (refreshMeta) 보존.
const showTrash = get().showTrash;
if (note.deletedAt !== null) {
// trash 노트: notes 에서 제거 + trashNotes 에 upsert
const cleanNotes = get().notes.filter((n) => n.id !== note.id);
const ti = get().trashNotes.findIndex((n) => n.id === note.id);
const nextTrash = get().trashNotes.slice();
if (ti >= 0) nextTrash[ti] = note;
// v0.3.8 — status 가 current view 와 매칭될 때만 notes 에 유지. 그 외엔 제거.
// 이전 구현은 trashed 외 모든 status 를 notes 에 누적 → 사용자가 inbox view 에서
// 완료/보관 으로 옮긴 노트가 list 에 잔류하는 버그. push-based (setStatus 도 emit) 로
// 모든 status 전이가 upsertNote 를 거치므로 view-aware filter 가 필수.
//
// trashCount/trashNotes 는 server-authoritative. trashNotes 가 cache-loaded
// (view='trash') 일 때만 trashCount 를 local recompute. 그 외엔 server 값
// (refreshMeta) 보존. searchResults 도 별도로 갱신 (status 변경 시 list 에서 제거).
const state = get();
const view = state.view;
const showTrash = state.showTrash;
const viewStatus: 'active' | 'completed' | 'archived' | 'trashed' | null =
view === 'inbox' ? 'active' :
view === 'completed' ? 'completed' :
view === 'archived' ? 'archived' :
view === 'trash' ? 'trashed' : null;
// trashNotes — note.status='trashed' 면 upsert, 아니면 제거.
const cleanTrash = state.trashNotes.filter((n) => n.id !== note.id);
let nextTrash = cleanTrash;
if (note.status === 'trashed') {
const ti = state.trashNotes.findIndex((n) => n.id === note.id);
nextTrash = cleanTrash.slice();
if (ti >= 0) nextTrash.splice(ti, 0, note);
else nextTrash.unshift(note);
set({
notes: cleanNotes,
trashNotes: nextTrash,
...(showTrash ? { trashCount: nextTrash.length } : {})
});
} else {
// active 노트: trashNotes 에서 제거 + notes 에 upsert (restore 케이스 포함)
const cleanTrash = get().trashNotes.filter((n) => n.id !== note.id);
const i = get().notes.findIndex((n) => n.id === note.id);
const nextNotes = get().notes.slice();
if (i >= 0) nextNotes[i] = note;
else nextNotes.unshift(note);
set({
notes: nextNotes,
trashNotes: cleanTrash,
...(showTrash ? { trashCount: cleanTrash.length } : {})
});
}
// notes — current view 의 status 와 매칭되는 경우만 유지/upsert.
// viewStatus=null (review/settings/검색) 이면 notes 직접 렌더 안 함 → 갱신 skip.
const cleanNotes = state.notes.filter((n) => n.id !== note.id);
let nextNotes = state.notes;
if (viewStatus !== null) {
if (note.status === viewStatus) {
const i = state.notes.findIndex((n) => n.id === note.id);
nextNotes = cleanNotes.slice();
if (i >= 0) nextNotes.splice(i, 0, note);
else nextNotes.unshift(note);
} else {
nextNotes = cleanNotes;
}
}
// searchResults — null 아니면 동일 패턴으로 갱신 (status 가 current search status 와
// 안 맞으면 제거, 맞으면 upsert).
let nextSearch = state.searchResults;
if (state.searchResults !== null) {
const cleanSearch = state.searchResults.filter((n) => n.id !== note.id);
if (viewStatus === null || note.status === viewStatus) {
// search 가 active 한 view 가 review/settings 면 status filter 없음 → 모두 keep.
const i = state.searchResults.findIndex((n) => n.id === note.id);
nextSearch = cleanSearch.slice();
if (i >= 0) nextSearch.splice(i, 0, note);
else nextSearch.unshift(note);
} else {
nextSearch = cleanSearch;
}
}
set({
notes: nextNotes,
trashNotes: nextTrash,
searchResults: nextSearch,
...(showTrash ? { trashCount: nextTrash.length } : {})
});
},
removeNote(id) {
const cleanNotes = get().notes.filter((n) => n.id !== id);
@@ -169,23 +233,41 @@ export const useInbox = create<InboxState>((set, get) => ({
else get().setView('inbox');
},
setView(view) {
// view 전환 시 검색/태그 필터 reset — 이전 view 의 필터가 새 view 에 잘못 적용되는 것 방지.
set({
view,
showTrash: view === 'trash',
showSettings: view === 'settings'
showSettings: view === 'settings',
searchResults: null,
searchQuery: '',
tagFilter: null
});
// settings/inbox 외 status view 면 해당 status fetch.
if (view === 'completed' || view === 'archived' || view === 'trash') {
// status view 면 해당 status fetch. inbox 도 포함 — 다른 탭에서 돌아올 때 notes 가
// 이전 status 로 stale 한 상태이므로 재로드 필요.
if (view === 'inbox' || 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;
const notes = await inboxApi.listByStatus(status, { limit: 200 });
if (view === 'trash') {
set({ trashNotes: notes, trashCount: notes.length });
} else {
set({ notes });
// v0.3.8 — IPC 실패 시 stale 한 이전 view 의 notes 가 계속 노출되는 사고 방지.
// fail 시 빈 배열로 reset 해서 사용자에게 "비어있음" 으로 표시 (혼동 < stale).
const status =
view === 'trash' ? 'trashed' : view === 'inbox' ? 'active' : view;
try {
const notes = await inboxApi.listByStatus(status, { limit: 200 });
if (view === 'trash') {
set({ trashNotes: notes, trashCount: notes.length });
} else {
set({ notes });
}
} catch (e) {
console.error('[inbox] loadByView failed', view, e);
if (view === 'trash') set({ trashNotes: [] });
else set({ notes: [] });
}
},
async toggleShowTrash() {
@@ -200,11 +282,12 @@ export const useInbox = create<InboxState>((set, get) => ({
async restoreNote(id) {
await inboxApi.restoreNote(id);
// 낙관적 갱신: main 은 trash/restore 시 pushNoteUpdated 를 보내지 않음
// (현재 AiWorker.onUpdate 만 push). 자가 반영이 primary 메커니즘.
// (현재 AiWorker.onUpdate + setStatus 만 push). 자가 반영이 primary 메커니즘.
// 전제: 호출 시점에 trashNotes 에 노트가 존재 (T14 trash view 한정 호출).
// v0.3.8 — status 도 'active' 로 함께 갱신. upsertNote 가 status='trashed' 만 trash 로 라우팅.
const note = get().trashNotes.find((n) => n.id === id);
if (note) {
get().upsertNote({ ...note, deletedAt: null });
get().upsertNote({ ...note, deletedAt: null, status: 'active' });
}
},
async permanentDeleteNote(id) {
@@ -217,10 +300,6 @@ export const useInbox = create<InboxState>((set, get) => ({
set({ trashNotes: [], trashCount: 0 });
}
},
async loadExpired() {
const expiredCandidates = await inboxApi.listExpired();
set({ expiredCandidates });
},
async trashExpiredBatch(ids: string[]) {
const r = await inboxApi.trashExpiredBatch(ids);
if (!r.confirmed) return;
@@ -267,7 +346,45 @@ export const useInbox = create<InboxState>((set, get) => ({
// snooze 는 적용하되 emit 만 skip. telemetry 누락 받아들임 (의도적).
const candidate = get().recallCandidate;
if (candidate) {
await inboxApi.emitRecallSnoozed(candidate.id);
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;
try {
const r = await inboxApi.search(q, status ? { status } : {});
set({ searchResults: r });
} catch (e) {
// FTS5 query parse error (special char 미escape) / IPC fail → 빈 결과로.
console.error('[inbox] searchNotes failed', e);
set({ searchResults: [] });
}
},
clearSearch() {
set({ searchQuery: '', searchResults: null });
},
async loadReview(period) {
try {
const data = await inboxApi.reviewAggregate(period);
set({ reviewData: data });
} catch (e) {
// review IPC fail 시 reviewData=null → ReviewView 의 "불러오는 중…" 영구 표시 회피.
// 빈 aggregate 로 set 해서 사용자에게 "0건" 표기.
console.error('[inbox] loadReview failed', period, e);
set({ reviewData: { totalCount: 0, tagCounts: [], dueProgress: { total: 0, passed: 0, pending: 0 }, recentNotes: [] } });
}
}
}));

View File

@@ -2,7 +2,7 @@
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:" />
<title>Inkling Capture</title>
<style>
html, body, #root { margin: 0; height: 100%; background: transparent; font-family: system-ui, sans-serif; overflow: hidden; }

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;
@@ -115,11 +149,14 @@ export interface InboxApi {
onOllamaStatus(cb: (status: { ok: boolean; reason?: string }) => void): () => void;
retryAllFailed(): Promise<{ count: number }>;
getFailedCount(): Promise<number>;
// v0.3.9 — per-note retry/cancel. failed/pending 노트의 사용자 unblock path.
retryOneFailed(id: string): Promise<{ ok: boolean }>;
cancelPending(id: string): Promise<{ ok: boolean }>;
listRecallCandidate(): Promise<Note | null>;
markRecallOpened(id: string): Promise<{ note: Note }>;
dismissRecall(id: string): Promise<{ note: Note }>;
emitRecallShown(id: string): Promise<void>;
emitRecallSnoozed(id: string): Promise<void>;
emitRecallShown(id: string): void;
emitRecallSnoozed(id: string): void;
loadOllamaSettings(): Promise<{ endpoint: string; model: string } | null>;
saveOllamaSettings(v: { endpoint: string; model: string }): Promise<{ ok: true } | { ok: false; reason: string }>;
// v0.2.7 Task 13 — 외부 (트레이 등) 에서 view 전환 요청 구독.
@@ -160,6 +197,13 @@ 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;
// v0.3.1 Cut F
vision_model?: string | null;
vision_capable_cache?: string[];
vision_cache_at?: string;
}>;
setAiEnabled(enabled: boolean): Promise<{ ok: true }>;
setOnboardingCompleted(completed: boolean): Promise<{ ok: true }>;
@@ -170,6 +214,21 @@ 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 }>;
// v0.3.1 Cut F — vision capability detection + 모델 선택.
getVisionModels(): Promise<{ models: string[]; at: string | null; selected: string | null }>;
setVisionModel(value: string | null): Promise<{ ok: true }>;
refreshVisionCache(): Promise<{ ok: true; models: string[] } | { ok: false; reason: string }>;
}
export interface InklingApi {

View File

@@ -11,7 +11,11 @@ vi.mock('../../src/renderer/inbox/api.js', () => ({
getSettings: vi.fn(async () => ({ ai_enabled: true })),
setAiEnabled: vi.fn(async () => ({ ok: true })),
getDisabledCount: vi.fn(async () => 0),
enqueueDisabled: vi.fn(async () => ({ count: 0 }))
enqueueDisabled: vi.fn(async () => ({ count: 0 })),
// v0.3.1 Cut F — VisionSection 이 AiProviderSection 에 마운트되어 호출.
getVisionModels: vi.fn(async () => ({ models: [], at: null, selected: null })),
setVisionModel: vi.fn(async () => ({ ok: true as const })),
refreshVisionCache: vi.fn(async () => ({ ok: true as const, models: [] }))
}
}));

View File

@@ -449,9 +449,10 @@ describe('AiWorker — vocab fetch + per-tag hit/miss (v0.2.3 #3 T7)', () => {
});
await w.enqueue(id);
await w.drain();
expect(generateMock).toHaveBeenCalledWith(expect.objectContaining({
vocab: expect.arrayContaining(['design'])
}));
expect(generateMock).toHaveBeenCalledWith(
expect.objectContaining({ vocab: expect.arrayContaining(['design']) }),
expect.anything()
);
});
it('emits tag_vocab_hit for vocab tags + tag_vocab_miss for new tags', async () => {
@@ -559,3 +560,91 @@ describe('AiWorker — vocab fetch + per-tag hit/miss (v0.2.3 #3 T7)', () => {
expect(miss).toHaveLength(1); // 'meeting' 1 miss
});
});
describe('vocab COLLATE NOCASE', () => {
let db: Database.Database;
let repo: NoteRepository;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
});
it('hits when vocab has lowercase and AI returns capital', async () => {
// Pre-seed: 'design' in vocab (lowercase)
const seed = repo.create({ rawText: 'seed' }).id;
repo.updateAiResult(seed, { title: 't', summary: 'a\nb\nc', tags: ['design'], provider: 'p' });
const { id } = repo.create({ rawText: 'x' });
const provider = makeProvider({
generate: vi.fn(async () => ({
title: 't', summary: 'a\nb\nc',
tags: ['Design'], // AI returns capitalized — DB COLLATE NOCASE matches 'design'
dueDate: null
}))
});
const emits: EmittedEvent[] = [];
const w = new AiWorker(repo, new ProviderHolder(provider), {
backoffsMs: [0, 0, 0],
telemetry: { emit: vi.fn(async (input) => { emits.push(input); }) }
});
await w.enqueue(id);
await w.drain();
expect(emits.filter((e) => e.kind === 'tag_vocab_hit')).toHaveLength(1);
expect(emits.filter((e) => e.kind === 'tag_vocab_miss')).toHaveLength(0);
});
it('hits when vocab has capital and AI returns lowercase', async () => {
// Scenario: vocab contains 'Design' (capital), AI returns 'design' (lowercase).
// getTopUsedTags filters via KEBAB_CASE_RE (/^[a-z0-9-]+$/) so 'Design' would be
// stripped in production. We stub getTopUsedTags to inject the capital vocab directly,
// and pre-seed the DB so getTagIdByName (COLLATE NOCASE) can resolve 'design' → tagId.
const seed = repo.create({ rawText: 'seed' }).id;
repo.updateAiResult(seed, { title: 't', summary: 'a\nb\nc', tags: ['Design'], provider: 'p' });
// Inject capital vocab bypassing the kebab filter
vi.spyOn(repo, 'getTopUsedTags').mockReturnValueOnce(['Design']);
const { id } = repo.create({ rawText: 'x' });
const provider = makeProvider({
generate: vi.fn(async () => ({
title: 't', summary: 'a\nb\nc',
tags: ['design'], // AI returns lowercase — DB COLLATE NOCASE matches 'Design'
dueDate: null
}))
});
const emits: EmittedEvent[] = [];
const w = new AiWorker(repo, new ProviderHolder(provider), {
backoffsMs: [0, 0, 0],
telemetry: { emit: vi.fn(async (input) => { emits.push(input); }) }
});
await w.enqueue(id);
await w.drain();
expect(emits.filter((e) => e.kind === 'tag_vocab_hit')).toHaveLength(1);
expect(emits.filter((e) => e.kind === 'tag_vocab_miss')).toHaveLength(0);
});
it('still hits when both vocab and AI tag are same lowercase (regression)', async () => {
// Pre-seed: 'design' in vocab (lowercase)
const seed = repo.create({ rawText: 'seed' }).id;
repo.updateAiResult(seed, { title: 't', summary: 'a\nb\nc', tags: ['design'], provider: 'p' });
const { id } = repo.create({ rawText: 'x' });
const provider = makeProvider({
generate: vi.fn(async () => ({
title: 't', summary: 'a\nb\nc',
tags: ['design'], // same lowercase — should still hit
dueDate: null
}))
});
const emits: EmittedEvent[] = [];
const w = new AiWorker(repo, new ProviderHolder(provider), {
backoffsMs: [0, 0, 0],
telemetry: { emit: vi.fn(async (input) => { emits.push(input); }) }
});
await w.enqueue(id);
await w.drain();
expect(emits.filter((e) => e.kind === 'tag_vocab_hit')).toHaveLength(1);
expect(emits.filter((e) => e.kind === 'tag_vocab_miss')).toHaveLength(0);
});
});

View File

@@ -0,0 +1,125 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { writeFile, mkdtemp, mkdir, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import Database from 'better-sqlite3';
import { runMigrations } from '@main/db/migrations/index.js';
import { NoteRepository } from '@main/repository/NoteRepository.js';
import { AiWorker } from '@main/ai/AiWorker.js';
import { ProviderHolder } from '@main/ai/ProviderHolder.js';
import { MediaStore } from '@main/services/MediaStore.js';
import type { AiResponse } from '@main/ai/schema.js';
import type { InferenceProvider } from '@main/ai/InferenceProvider.js';
describe('AiWorker — vision path (v0.3.1 Cut F)', () => {
let db: Database.Database;
let repo: NoteRepository;
let workDir: string;
let mediaStore: MediaStore;
beforeEach(async () => {
db = new Database(':memory:');
db.pragma('foreign_keys = ON');
runMigrations(db);
repo = new NoteRepository(db);
workDir = await mkdtemp(join(tmpdir(), 'inkling-vision-'));
mediaStore = new MediaStore(workDir);
});
afterEach(async () => {
db.close();
await rm(workDir, { recursive: true, force: true });
});
function makeWorker(
generate: (input: Parameters<InferenceProvider['generate']>[0], opts?: Parameters<InferenceProvider['generate']>[1]) => Promise<AiResponse>,
getVisionModel: () => Promise<string | null>
): AiWorker {
const provider: InferenceProvider = {
name: 'fake',
generate,
abort: () => {},
healthCheck: vi.fn(async () => ({ ok: true }))
};
const holder = new ProviderHolder(provider);
const settings = { getVisionModel };
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
return new AiWorker(repo, holder, {
backoffsMs: [0, 0, 0],
logger,
settings,
mediaStore,
now: () => new Date('2026-05-10T05:00:00Z')
});
}
it('visionModel + media 있음 → provider.generate 가 images + opts 받음', async () => {
const { id } = repo.create({ rawText: '이미지 메모' });
await mkdir(join(workDir, 'media', id), { recursive: true });
await writeFile(join(workDir, 'media', id, '1.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47]));
repo.insertMedia([{ noteId: id, kind: 'image', relPath: `media/${id}/1.png`, mime: 'image/png', bytes: 4 }]);
const calls: Array<Parameters<InferenceProvider['generate']>> = [];
const generate = vi.fn(async (
input: Parameters<InferenceProvider['generate']>[0],
opts?: Parameters<InferenceProvider['generate']>[1]
): Promise<AiResponse> => {
calls.push([input, opts]);
return { title: 't', summary: 'a\nb\nc', tags: [], dueDate: null };
});
const getVisionModel = vi.fn(async (): Promise<string | null> => 'gemma3:12b-vision');
const worker = makeWorker(generate, getVisionModel);
await worker.enqueue(id);
await worker.drain();
expect(calls.length).toBeGreaterThan(0);
const [callInput, callOpts] = calls[0]!;
expect(callInput.images).toHaveLength(1);
expect(callInput.images![0]!.mime).toBe('image/png');
expect(callOpts?.visionModel).toBe('gemma3:12b-vision');
});
it('visionModel null이면 text-only (images undefined)', async () => {
const { id } = repo.create({ rawText: 'just text' });
const calls: Array<Parameters<InferenceProvider['generate']>> = [];
const generate = vi.fn(async (
input: Parameters<InferenceProvider['generate']>[0],
opts?: Parameters<InferenceProvider['generate']>[1]
): Promise<AiResponse> => {
calls.push([input, opts]);
return { title: 't', summary: 'a\nb\nc', tags: [], dueDate: null };
});
const getVisionModel = vi.fn(async (): Promise<string | null> => null);
const worker = makeWorker(generate, getVisionModel);
await worker.enqueue(id);
await worker.drain();
expect(calls.length).toBeGreaterThan(0);
expect(calls[0]![0].images).toBeUndefined();
});
it('5MB 초과 이미지 → throw → AiWorker 의 fail 분기 (generate 미호출)', async () => {
const { id } = repo.create({ rawText: 'big image' });
await mkdir(join(workDir, 'media', id), { recursive: true });
await writeFile(join(workDir, 'media', id, '1.png'), Buffer.alloc(6 * 1024 * 1024));
repo.insertMedia([{ noteId: id, kind: 'image', relPath: `media/${id}/1.png`, mime: 'image/png', bytes: 6 * 1024 * 1024 }]);
const calls: Array<Parameters<InferenceProvider['generate']>> = [];
const generate = vi.fn(async (
input: Parameters<InferenceProvider['generate']>[0],
opts?: Parameters<InferenceProvider['generate']>[1]
): Promise<AiResponse> => {
calls.push([input, opts]);
return { title: 't', summary: 'a\nb\nc', tags: [], dueDate: null };
});
const getVisionModel = vi.fn(async (): Promise<string | null> => 'gemma3:12b-vision');
const worker = makeWorker(generate, getVisionModel);
await worker.enqueue(id);
await worker.drain();
expect(calls.length).toBe(0);
// AiWorker catch 분기가 처리 — note 는 여전히 DB 에 존재
const note = repo.findById(id);
expect(note).toBeTruthy();
});
});

View File

@@ -56,7 +56,17 @@ 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 })),
// v0.3.1 Cut F — VisionSection 이 AiProviderSection 에 마운트되어 호출.
getVisionModels: vi.fn(async () => ({ models: [], at: null, selected: null })),
setVisionModel: vi.fn(async () => ({ ok: true as const })),
refreshVisionCache: vi.fn(async () => ({ ok: true as const, models: [] }))
}
}));
@@ -120,25 +130,31 @@ describe('App header — 4 tabs', () => {
it('renders 4 tabs with counts', async () => {
render(<App />);
expect(await screen.findByRole('button', { name: /Inbox\(5\)/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /완료\(3\)/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /보관\(2\)/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /휴지통\(1\)/ })).toBeInTheDocument();
expect(await screen.findByRole('tab', { name: /Inbox\(5\)/ })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: /완료\(3\)/ })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: /보관\(2\)/ })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: /휴지통\(1\)/ })).toBeInTheDocument();
});
it('clicking 완료 tab sets view=completed', async () => {
render(<App />);
fireEvent.click(await screen.findByRole('button', { name: /완료/ }));
fireEvent.click(await screen.findByRole('tab', { name: /완료/ }));
expect(useInbox.getState().view).toBe('completed');
});
it('aria-pressed reflects current view', async () => {
it('aria-selected reflects current view', async () => {
useInbox.setState({ view: 'archived' });
render(<App />);
const archivedBtn = await screen.findByRole('button', { name: /보관/ });
expect(archivedBtn.getAttribute('aria-pressed')).toBe('true');
const inboxBtn = screen.getByRole('button', { name: /Inbox/ });
expect(inboxBtn.getAttribute('aria-pressed')).toBe('false');
const archivedBtn = await screen.findByRole('tab', { name: /보관/ });
expect(archivedBtn.getAttribute('aria-selected')).toBe('true');
const inboxBtn = screen.getByRole('tab', { name: /Inbox/ });
expect(inboxBtn.getAttribute('aria-selected')).toBe('false');
});
it('inbox tab has aria-selected="true" when active', async () => {
render(<App />);
const inboxTab = await screen.findByRole('tab', { name: /Inbox/ });
expect(inboxTab).toHaveAttribute('aria-selected', 'true');
});
});
@@ -165,7 +181,7 @@ describe('App — onboarding wizard', () => {
it('does not render OnboardingWizard when onboarding_completed=true', async () => {
vi.mocked(inboxApi.getSettings).mockResolvedValue({ onboarding_completed: true });
render(<App />);
await screen.findByRole('button', { name: /Inbox/ });
await screen.findByRole('tab', { name: /Inbox/ });
expect(screen.queryByText(/Inkling 사용 시작/)).toBeNull();
});
});

View File

@@ -0,0 +1,83 @@
// @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();
});
});
it('각 conflict row 에 local/remote inline 설명 표시', async () => {
render(<ConflictModal onClose={() => {}} onResolved={() => {}} onOpenHelp={() => {}} />);
await waitFor(() => screen.getByText(/local A/));
expect(screen.getAllByText(/이 기기의 변경을 보존/).length).toBeGreaterThanOrEqual(2);
expect(screen.getAllByText(/원격의 변경을 가져오고/).length).toBeGreaterThanOrEqual(2);
});
it('"자세히 보기" 클릭 → onOpenHelp("main-conflict") 호출', async () => {
const onOpenHelp = vi.fn();
render(<ConflictModal onClose={() => {}} onResolved={() => {}} onOpenHelp={onOpenHelp} />);
await waitFor(() => screen.getByText(/local A/));
const links = screen.getAllByRole('button', { name: /자세히 보기/ });
fireEvent.click(links[0]!);
expect(onOpenHelp).toHaveBeenCalledWith('main-conflict');
});
it('onOpenHelp 미제공 → "자세히 보기" 링크 미렌더', async () => {
render(<ConflictModal onClose={() => {}} onResolved={() => {}} />);
await waitFor(() => screen.getByText(/local A/));
expect(screen.queryByRole('button', { name: /자세히 보기/ })).toBeNull();
});
});

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

@@ -34,6 +34,12 @@ function buildExportNote(overrides: Partial<ExportNote> = {}): ExportNote {
aiGeneratedAt: '2026-04-25T14:23:34.000Z',
userIntent: null,
intentPromptedAt: null,
// v0.3.0 Cut E — frontmatter round-trip 5 필드 (Cut B status + Cut C dueDate).
status: 'active',
statusChangedAt: null,
moveReason: null,
dueDate: null,
dueDateEditedByUser: false,
tags: [{ name: 'pr', source: 'ai' }],
media: [],
...overrides

View File

@@ -51,7 +51,24 @@ describe('LocalOllamaProvider', () => {
});
await expect(
new LocalOllamaProvider().generate({ text: 'x', todayKst: '2026-04-26', dueDateCandidates: [] })
).rejects.toThrow(/json/i);
).rejects.toThrow(/unparseable/i);
});
it('v0.3.11 — generate extracts JSON from markdown fence', async () => {
// vision model 이 ```json ... ``` 형태로 응답하는 경우 fallback 으로 추출.
mock.get('http://localhost:11434').intercept({ path: '/api/generate', method: 'POST' }).reply(200, {
response: '```json\n{"title":"회의","summary":"a\\nb\\nc","tags":["meet"]}\n```'
});
const r = await new LocalOllamaProvider().generate({ text: 'x', todayKst: '2026-04-26', dueDateCandidates: [] });
expect(r.title).toBe('회의');
});
it('v0.3.11 — generate extracts JSON when prose 가 앞뒤로 섞임', async () => {
mock.get('http://localhost:11434').intercept({ path: '/api/generate', method: 'POST' }).reply(200, {
response: 'Here is the response:\n{"title":"회의","summary":"a\\nb\\nc","tags":[]}\nDone.'
});
const r = await new LocalOllamaProvider().generate({ text: 'x', todayKst: '2026-04-26', dueDateCandidates: [] });
expect(r.title).toBe('회의');
});
it('generate aborts on timeout', async () => {
@@ -109,4 +126,96 @@ describe('LocalOllamaProvider', () => {
const provider = new LocalOllamaProvider({ model: 'gemma4:26b' });
expect(provider.name).toBe('local-ollama/gemma4:26b');
});
describe('healthCheck PII reason masking', () => {
it('classifies ECONNREFUSED as network', async () => {
mock.get('http://192.168.1.5:11434').intercept({ path: '/api/tags', method: 'GET' })
.replyWithError(new Error('connect ECONNREFUSED 192.168.1.5:11434'));
const provider = new LocalOllamaProvider({ endpoint: 'http://192.168.1.5:11434' });
const h = await provider.healthCheck();
expect(h.ok).toBe(false);
expect(h.reason).toBe('unreachable:network');
expect(h.reason).not.toContain('192.168.1.5');
});
it('classifies AbortError/timeout as timeout', async () => {
mock.get('http://localhost:11434').intercept({ path: '/api/tags', method: 'GET' })
.replyWithError(new Error('The operation was aborted due to timeout'));
const h = await new LocalOllamaProvider().healthCheck();
expect(h.ok).toBe(false);
expect(h.reason).toBe('unreachable:timeout');
});
it('classifies ENOTFOUND as dns', async () => {
mock.get('http://nonexistent.local:11434').intercept({ path: '/api/tags', method: 'GET' })
.replyWithError(new Error('getaddrinfo ENOTFOUND nonexistent.local'));
const provider = new LocalOllamaProvider({ endpoint: 'http://nonexistent.local:11434' });
const h = await provider.healthCheck();
expect(h.ok).toBe(false);
expect(h.reason).toBe('unreachable:dns');
expect(h.reason).not.toContain('nonexistent.local');
});
it('falls back to other for unknown errors', async () => {
mock.get('http://localhost:11434').intercept({ path: '/api/tags', method: 'GET' })
.replyWithError(new Error('something weird happened'));
const h = await new LocalOllamaProvider().healthCheck();
expect(h.ok).toBe(false);
expect(h.reason).toBe('unreachable:other');
});
});
describe('vision path (v0.3.1 Cut F)', () => {
it('visionModel + images → body.images + model=visionModel + buildVisionPrompt', async () => {
let capturedBody: string = '';
mock.get('http://x').intercept({ path: '/api/generate', method: 'POST' }).reply((opts) => {
capturedBody = opts.body as string;
return { statusCode: 200, data: JSON.stringify({
response: JSON.stringify({ title: '비전테스트', summary: 'a\nb\nc', tags: [], due_date: null })
}) };
});
const provider = new LocalOllamaProvider({ endpoint: 'http://x', model: 'gemma4:e4b' });
await provider.generate(
{ text: 'hi', todayKst: '2026-05-10', dueDateCandidates: [], images: [{ base64: 'AAAA', mime: 'image/png' }] },
{ visionModel: 'gemma3:12b-vision' }
);
const parsed = JSON.parse(capturedBody) as { model: string; prompt: string; images?: string[] };
expect(parsed.model).toBe('gemma3:12b-vision');
expect(parsed.prompt).toContain('이미지');
expect(parsed.images).toEqual(['AAAA']);
});
it('visionModel 있어도 images 없으면 text-only (model = this.model, no body.images)', async () => {
let capturedBody: string = '';
mock.get('http://x').intercept({ path: '/api/generate', method: 'POST' }).reply((opts) => {
capturedBody = opts.body as string;
return { statusCode: 200, data: JSON.stringify({
response: JSON.stringify({ title: '텍스트전용', summary: 'a\nb\nc', tags: [], due_date: null })
}) };
});
const provider = new LocalOllamaProvider({ endpoint: 'http://x', model: 'gemma4:e4b' });
await provider.generate(
{ text: 'hi', todayKst: '2026-05-10', dueDateCandidates: [] },
{ visionModel: 'gemma3:12b-vision' }
);
const parsed = JSON.parse(capturedBody) as { model: string; images?: string[] };
expect(parsed.model).toBe('gemma4:e4b');
expect(parsed.images).toBeUndefined();
});
it('opts 미전달 → 기존 text-only (회귀)', async () => {
let capturedBody: string = '';
mock.get('http://x').intercept({ path: '/api/generate', method: 'POST' }).reply((opts) => {
capturedBody = opts.body as string;
return { statusCode: 200, data: JSON.stringify({
response: JSON.stringify({ title: '기본텍스트', summary: 'a\nb\nc', tags: [], due_date: null })
}) };
});
const provider = new LocalOllamaProvider({ endpoint: 'http://x', model: 'gemma4:e4b' });
await provider.generate({ text: 'hi', todayKst: '2026-05-10', dueDateCandidates: [] });
const parsed = JSON.parse(capturedBody) as { model: string; images?: string[] };
expect(parsed.model).toBe('gemma4:e4b');
expect(parsed.images).toBeUndefined();
});
});
});

View File

@@ -32,6 +32,7 @@ describe('MoveStatusModal', () => {
noteId="n1"
rawText="t"
summary=""
currentStatus="active"
onClose={vi.fn()}
onMoved={vi.fn()}
/>
@@ -50,6 +51,7 @@ describe('MoveStatusModal', () => {
noteId="n1"
rawText="t"
summary=""
currentStatus="active"
onClose={vi.fn()}
onMoved={onMoved}
/>
@@ -69,6 +71,7 @@ describe('MoveStatusModal', () => {
noteId="n1"
rawText="t"
summary=""
currentStatus="active"
onClose={vi.fn()}
onMoved={onMoved}
/>
@@ -81,6 +84,95 @@ describe('MoveStatusModal', () => {
await waitFor(() => expect(onMoved).toHaveBeenCalledWith('completed', '결재 끝'));
});
it('currentStatus=completed → Inbox/보관/휴지통 노출, 완료 미노출', () => {
render(
<MoveStatusModal
noteId="n1"
rawText="t"
summary=""
currentStatus="completed"
onClose={vi.fn()}
onMoved={vi.fn()}
/>
);
expect(screen.getByRole('button', { name: 'Inbox' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '보관' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '휴지통' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: '완료' })).toBeNull();
});
it('currentStatus=archived → Inbox 버튼 클릭 시 setStatus("active") 호출', async () => {
const onMoved = vi.fn();
render(
<MoveStatusModal
noteId="n1"
rawText="t"
summary=""
currentStatus="archived"
onClose={vi.fn()}
onMoved={onMoved}
/>
);
fireEvent.click(screen.getByRole('button', { name: 'Inbox' }));
await waitFor(() => {
expect(mockSetStatus).toHaveBeenCalledWith('n1', 'active', null);
expect(onMoved).toHaveBeenCalledWith('active', null);
});
});
it('currentStatus=trashed → Inbox/완료/보관 노출, 휴지통 미노출', () => {
render(
<MoveStatusModal
noteId="n1"
rawText="t"
summary=""
currentStatus="trashed"
onClose={vi.fn()}
onMoved={vi.fn()}
/>
);
expect(screen.getByRole('button', { name: 'Inbox' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '완료' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '보관' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: '휴지통' })).toBeNull();
});
it('Escape key → onClose 호출', () => {
const onClose = vi.fn();
render(
<MoveStatusModal
noteId="n1"
rawText="t"
summary=""
currentStatus="active"
onClose={onClose}
onMoved={vi.fn()}
/>
);
fireEvent.keyDown(document, { key: 'Escape' });
expect(onClose).toHaveBeenCalled();
});
it('overlay 클릭 → onClose, modal body 클릭 → 무반응', () => {
const onClose = vi.fn();
render(
<MoveStatusModal
noteId="n1"
rawText="t"
summary=""
currentStatus="active"
onClose={onClose}
onMoved={vi.fn()}
/>
);
// body 클릭 (textarea) → onClose 호출 안 됨
fireEvent.click(screen.getByRole('textbox'));
expect(onClose).not.toHaveBeenCalled();
// overlay (dialog) 클릭 → onClose
fireEvent.click(screen.getByRole('dialog', { name: '이동' }));
expect(onClose).toHaveBeenCalled();
});
it('빈 사유 → null reason 전달', async () => {
const onMoved = vi.fn();
render(
@@ -88,6 +180,7 @@ describe('MoveStatusModal', () => {
noteId="n1"
rawText="t"
summary=""
currentStatus="active"
onClose={vi.fn()}
onMoved={onMoved}
/>

View File

@@ -32,10 +32,11 @@ vi.mock('../../src/renderer/inbox/api.js', () => ({
}
}));
const mockRefreshMeta = vi.fn();
vi.mock('../../src/renderer/inbox/store.js', () => ({
useInbox: Object.assign(
() => ({}),
{ getState: () => ({ setTagFilter: vi.fn() }) }
{ getState: () => ({ setTagFilter: vi.fn(), refreshMeta: mockRefreshMeta }) }
)
}));
@@ -127,34 +128,36 @@ describe('NoteCard — ai_status=disabled fallback (v0.2.9 Cut B Task 13)', () =
});
});
describe('NoteCard — 이동 메뉴 (v0.2.9 Cut B Task 6)', () => {
describe('NoteCard — 이동 버튼', () => {
beforeEach(() => {
vi.clearAllMocks();
cleanup();
});
it('이동 클릭 → 현재 status 외 3개 목적지 메뉴 표시', () => {
// baseNote.status = 'active' → 완료/보관/휴지통 만 표시
it('이동 클릭 → MoveStatusModal 열림', () => {
render(<NoteCard note={baseNote} onUpdated={() => {}} mode="inbox" />);
fireEvent.click(screen.getByRole('button', { name: '이동' }));
expect(screen.getByRole('button', { name: '완료로 이동' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '보관으로 이동' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '휴지통으로 이동' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: '활성으로 이동' })).toBeNull();
expect(screen.getByRole('dialog', { name: '이동' })).toBeInTheDocument();
});
it('메뉴 항목 클릭 → MoveStatusModal 열림 + 확정 시 setStatus 호출', async () => {
it('Modal 내부 "완료" 버튼 → setStatus 호출 + onUpdated + onDeleted + refreshMeta', async () => {
const onUpdated = vi.fn();
render(<NoteCard note={baseNote} onUpdated={onUpdated} mode="inbox" />);
const onDeleted = vi.fn();
render(
<NoteCard
note={baseNote}
onUpdated={onUpdated}
onDeleted={onDeleted}
mode="inbox"
/>
);
fireEvent.click(screen.getByRole('button', { name: '이동' }));
fireEvent.click(screen.getByRole('button', { name: '완료로 이동' }));
// Modal 의 dialog role 등장
expect(screen.getByRole('dialog', { name: '이동' })).toBeInTheDocument();
// Modal 내부의 "완료" 버튼 클릭 → setStatus
fireEvent.click(screen.getByRole('button', { name: '완료' }));
await waitFor(() => {
expect(mockSetStatus).toHaveBeenCalledWith('n1', 'completed', null);
expect(onUpdated).toHaveBeenCalled();
expect(onDeleted).toHaveBeenCalled();
expect(mockRefreshMeta).toHaveBeenCalled();
});
});
});

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

@@ -310,6 +310,24 @@ describe('NoteRepository', () => {
const job = db.prepare('SELECT * FROM pending_jobs WHERE note_id=?').get(id);
expect(job).toBeDefined();
});
it('create accepts explicit now param', () => {
const fixed = new Date('2026-05-09T10:00:00.000Z');
const { id } = repo.create({ rawText: 'hello' }, fixed);
const note = repo.findById(id)!;
expect(note.createdAt).toBe('2026-05-09T10:00:00.000Z');
expect(note.updatedAt).toBe('2026-05-09T10:00:00.000Z');
});
it('create defaults now to new Date when omitted', () => {
const before = Date.now();
const { id } = repo.create({ rawText: 'hello' });
const after = Date.now();
const note = repo.findById(id)!;
const ts = new Date(note.createdAt).getTime();
expect(ts).toBeGreaterThanOrEqual(before);
expect(ts).toBeLessThanOrEqual(after);
});
});
describe('NoteRepository.trash', () => {
@@ -752,6 +770,41 @@ describe('NoteRepository — failed retry helpers', () => {
expect(jobs[0]!.nextRunAt).toBe('2026-04-30T00:00:00.000Z');
});
it('v0.3.9 — retryOneFailed: failed → pending + pending_jobs INSERT', () => {
const a = makeFailed('a');
const b = makeFailed('b');
const r = repo.retryOneFailed(a, '2026-05-01T12:00:00.000Z');
expect(r).toEqual({ ok: true });
expect(repo.findById(a)!.aiStatus).toBe('pending');
expect(repo.findById(a)!.aiError).toBeNull();
expect(repo.findById(b)!.aiStatus).toBe('failed'); // 다른 노트 영향 없음
const jobs = repo.getAllPendingJobs();
expect(jobs.find((j) => j.noteId === a)).toBeDefined();
});
it('v0.3.9 — retryOneFailed: non-failed status 면 no-op', () => {
const { id } = repo.create({ rawText: 'pending note' });
const r = repo.retryOneFailed(id, '2026-05-01T12:00:00.000Z');
expect(r).toEqual({ ok: false });
});
it('v0.3.9 — cancelPending: pending → disabled + pending_jobs DELETE', () => {
const { id } = repo.create({ rawText: 'x' }); // ai_status=pending
expect(repo.findById(id)!.aiStatus).toBe('pending');
const r = repo.cancelPending(id, '2026-05-01T12:00:00.000Z');
expect(r).toEqual({ ok: true });
expect(repo.findById(id)!.aiStatus).toBe('disabled');
const jobs = repo.getAllPendingJobs().filter((j) => j.noteId === id);
expect(jobs).toHaveLength(0);
});
it('v0.3.9 — cancelPending: non-pending status 면 no-op', () => {
const id = makeFailed('a');
const r = repo.cancelPending(id, '2026-05-01T12:00:00.000Z');
expect(r).toEqual({ ok: false });
expect(repo.findById(id)!.aiStatus).toBe('failed'); // 변경 없음
});
it('setNextRunAt — attempts 변경 없이 next_run_at + last_error 갱신', () => {
const { id } = repo.create({ rawText: 'x' });
repo.incrementJobAttempt(id, '2026-05-01T11:00:00.000Z', 'first error');
@@ -1074,3 +1127,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 본문' }, new Date('2026-05-09T00:00:00Z'));
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' }, new Date('2026-05-09T00:00:00Z'));
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

@@ -18,7 +18,8 @@ describe('NoteRepository — note_revisions', () => {
describe('updateRawText', () => {
it('notes.raw_text 갱신 + 새 user revision INSERT (single transaction)', () => {
const { id } = repo.create({ rawText: 'v1' });
const v1At = new Date('2026-05-09T00:00:00Z');
const { id } = repo.create({ rawText: 'v1' }, v1At);
const t = new Date('2026-05-10T00:00:00Z');
repo.updateRawText(id, 'v2', t);
@@ -41,7 +42,8 @@ describe('NoteRepository — note_revisions', () => {
});
it('atomic: 두 번 호출 시 두 revision 모두 누적 (chain history)', () => {
const { id } = repo.create({ rawText: 'v1' });
const v1At = new Date('2026-05-09T00:00:00Z');
const { id } = repo.create({ rawText: 'v1' }, v1At);
repo.updateRawText(id, 'v2', new Date('2026-05-10T00:00:00Z'));
repo.updateRawText(id, 'v3', new Date('2026-05-11T00:00:00Z'));
const revs = db
@@ -53,7 +55,8 @@ describe('NoteRepository — note_revisions', () => {
describe('listRevisions', () => {
it('DESC 순서 + edited_by + camelCase hydrate', () => {
const { id } = repo.create({ rawText: 'v1' });
const v1At = new Date('2026-05-09T00:00:00Z');
const { id } = repo.create({ rawText: 'v1' }, v1At);
repo.updateRawText(id, 'v2', new Date('2026-05-10T00:00:00Z'));
repo.updateRawText(id, 'v3', new Date('2026-05-11T00:00:00Z'));
@@ -73,7 +76,8 @@ describe('NoteRepository — note_revisions', () => {
describe('restoreRevision', () => {
it('옛 raw_text 를 새 user revision 으로 INSERT + notes.raw_text 갱신', () => {
const { id } = repo.create({ rawText: 'v1' });
const v1At = new Date('2026-05-09T00:00:00Z');
const { id } = repo.create({ rawText: 'v1' }, v1At);
repo.updateRawText(id, 'v2', new Date('2026-05-10T00:00:00Z'));
repo.updateRawText(id, 'v3', new Date('2026-05-11T00:00:00Z'));
@@ -101,7 +105,8 @@ describe('NoteRepository — note_revisions', () => {
describe('AiWorker source 회귀', () => {
it('updateRawText 후 findById 가 latest raw_text 반환 (옛 revision 미노출)', () => {
const { id } = repo.create({ rawText: 'v1' });
const v1At = new Date('2026-05-09T00:00:00Z');
const { id } = repo.create({ rawText: 'v1' }, v1At);
repo.updateRawText(id, 'v2 corrected', new Date('2026-05-10T00:00:00Z'));
const note = repo.findById(id);
expect(note?.rawText).toBe('v2 corrected');

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

@@ -8,6 +8,8 @@ import { render, screen, fireEvent, cleanup } from '@testing-library/react';
// 빈 객체 대신 필요한 메서드를 stub 한다.
vi.mock('../../src/renderer/inbox/api.js', () => ({
inboxApi: {
// setShowSettings(false) → setView('inbox') → loadByView('inbox') 가 listByStatus 호출.
listByStatus: vi.fn(async () => []),
loadOllamaSettings: vi.fn(async () => null),
saveOllamaSettings: vi.fn(async () => ({ ok: true })),
ollamaRecheck: vi.fn(async () => ({ ok: true })),
@@ -46,7 +48,17 @@ 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 })),
// v0.3.1 Cut F — VisionSection 이 AiProviderSection 에 마운트되어 호출.
getVisionModels: vi.fn(async () => ({ models: [], at: null, selected: null })),
setVisionModel: vi.fn(async () => ({ ok: true as const })),
refreshVisionCache: vi.fn(async () => ({ ok: true as const, models: [] }))
}
}));
@@ -64,12 +76,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,67 @@ 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();
});
});
describe('v0.3.1 Cut F — vision settings', () => {
it('getVisionModel() defaults to null', async () => {
expect(await svc.getVisionModel()).toBeNull();
});
it('setVisionModel() / getVisionModel() round-trip including null clear', async () => {
await svc.setVisionModel('llava:13b');
expect(await svc.getVisionModel()).toBe('llava:13b');
await svc.setVisionModel(null);
expect(await svc.getVisionModel()).toBeNull();
});
it('getVisionCapableCache() defaults to empty models + null at', async () => {
const cache = await svc.getVisionCapableCache();
expect(cache.models).toEqual([]);
expect(cache.at).toBeNull();
});
it('setVisionCapableCache() persists models + ISO timestamp', async () => {
const now = new Date('2026-05-09T12:00:00.000Z');
await svc.setVisionCapableCache(['llava:13b', 'llava:7b'], now);
const cache = await svc.getVisionCapableCache();
expect(cache.models).toEqual(['llava:13b', 'llava:7b']);
expect(cache.at).toBe('2026-05-09T12:00:00.000Z');
});
});
});

View File

@@ -0,0 +1,65 @@
// @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';
import { SyncHelpModal } from '../../src/renderer/inbox/components/SyncHelpModal';
describe('SyncHelpModal', () => {
beforeEach(() => {
cleanup();
});
it('4 섹션 헤더 렌더링', () => {
render(<SyncHelpModal onClose={() => {}} />);
expect(screen.getByRole('heading', { name: /충돌 해결/ })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: /자동 처리/ })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: /조용히 잘못될 수 있는/ })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: /Setup/ })).toBeInTheDocument();
});
it('각 섹션이 anchor id 보유', () => {
const { container } = render(<SyncHelpModal onClose={() => {}} />);
expect(container.querySelector('#main-conflict')).not.toBeNull();
expect(container.querySelector('#auto')).not.toBeNull();
expect(container.querySelector('#silent')).not.toBeNull();
expect(container.querySelector('#setup')).not.toBeNull();
});
it('초기 anchor prop 으로 해당 섹션 scrollIntoView 호출', () => {
const scrollSpy = vi.fn();
Element.prototype.scrollIntoView = scrollSpy;
render(<SyncHelpModal onClose={() => {}} initialAnchor="main-conflict" />);
expect(scrollSpy).toHaveBeenCalled();
});
it('X 버튼 클릭 → onClose 호출', () => {
const onClose = vi.fn();
render(<SyncHelpModal onClose={onClose} />);
fireEvent.click(screen.getByRole('button', { name: /닫기/ }));
expect(onClose).toHaveBeenCalled();
});
it('overlay 클릭 → onClose 호출', () => {
const onClose = vi.fn();
const { container } = render(<SyncHelpModal onClose={onClose} />);
const overlay = container.firstChild as HTMLElement;
fireEvent.click(overlay);
expect(onClose).toHaveBeenCalled();
});
it('modal body 클릭 → onClose 호출 X (stopPropagation)', () => {
const onClose = vi.fn();
render(<SyncHelpModal onClose={onClose} />);
fireEvent.click(screen.getByRole('heading', { name: /충돌 해결/ }));
expect(onClose).not.toHaveBeenCalled();
});
it('주요 시나리오 키워드 본문 포함 (회귀)', () => {
render(<SyncHelpModal onClose={() => {}} />);
expect(screen.getByText(/편집\/편집/)).toBeInTheDocument();
expect(screen.getByText(/삭제\/편집/)).toBeInTheDocument();
expect(screen.getByText(/AI 결과 충돌/)).toBeInTheDocument();
expect(screen.getByText(/git@https:\/\//)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,83 @@
// @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);
});
});
it('도움말 버튼 클릭 → SyncHelpModal open', async () => {
render(<SyncSection />);
await waitFor(() => screen.getByRole('button', { name: /저장/ }));
fireEvent.click(screen.getByRole('button', { name: /^도움말$/ }));
await waitFor(() => screen.getByRole('heading', { name: /동기화 도움말/ }));
expect(screen.getByRole('heading', { name: /동기화 도움말/ })).toBeInTheDocument();
});
});

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

@@ -0,0 +1,121 @@
import { describe, it, expect, vi } from 'vitest';
import { isVisionCapable, refreshVisionCache } from '@main/services/VisionDetect.js';
import type { OllamaModel } from '@main/services/VisionDetect.js';
// ---------------------------------------------------------------------------
// isVisionCapable
// ---------------------------------------------------------------------------
describe('isVisionCapable', () => {
it('returns true when details.family is in VISION_FAMILIES', () => {
const model: OllamaModel = { name: 'some-model', details: { family: 'llava' } };
expect(isVisionCapable(model)).toBe(true);
});
it('returns true when details.families contains a vision family', () => {
const model: OllamaModel = { name: 'some-model', details: { families: ['text', 'minicpm-v'] } };
expect(isVisionCapable(model)).toBe(true);
});
it('returns true when name contains a vision hint (case-insensitive)', () => {
const model: OllamaModel = { name: 'My-Vision-Model:latest' };
expect(isVisionCapable(model)).toBe(true);
});
it('returns true when name contains "vl" hint', () => {
const model: OllamaModel = { name: 'qwen2-vl:7b' };
expect(isVisionCapable(model)).toBe(true);
});
it('returns false for a plain text model with no vision signals', () => {
const model: OllamaModel = { name: 'gemma2:9b', details: { family: 'gemma', families: ['gemma'] } };
expect(isVisionCapable(model)).toBe(false);
});
// v0.3.1 Cut F final fix — gemma family default 정정. gemma4 도 vision-capable hint.
it('returns true for gemma4 family (future-proof)', () => {
const model: OllamaModel = { name: 'gemma4-vision:e4b', details: { family: 'gemma4' } };
expect(isVisionCapable(model)).toBe(true);
});
it('returns true for gemma4 in name hints (no family)', () => {
const model: OllamaModel = { name: 'custom-gemma4:latest' };
expect(isVisionCapable(model)).toBe(true);
});
});
// ---------------------------------------------------------------------------
// refreshVisionCache
// ---------------------------------------------------------------------------
describe('refreshVisionCache', () => {
function makeSettings(overrides: Partial<{
isAiEnabled: boolean;
setCalled: { models: string[]; at: Date } | null;
}> = {}) {
const setCalled: { models: string[]; at: Date } | null = null;
const settings = {
isAiEnabled: vi.fn().mockResolvedValue(overrides.isAiEnabled ?? true),
setVisionCapableCache: vi.fn().mockImplementation(async () => undefined),
};
return settings;
}
it('returns ok:false with reason "ai_disabled" when AI is off', async () => {
const settings = makeSettings({ isAiEnabled: false });
const result = await refreshVisionCache({
settings: settings as never,
endpoint: 'http://localhost:11434',
});
expect(result).toEqual({ ok: false, reason: 'ai_disabled' });
expect(settings.setVisionCapableCache).not.toHaveBeenCalled();
});
it('returns ok:false with http reason on non-ok response', async () => {
const settings = makeSettings();
const fetchImpl = vi.fn().mockResolvedValue({ ok: false, status: 503 });
const result = await refreshVisionCache({
settings: settings as never,
endpoint: 'http://localhost:11434',
fetchImpl: fetchImpl as never,
});
expect(result).toEqual({ ok: false, reason: 'tags http 503' });
});
it('returns ok:false with unreachable reason on fetch throw', async () => {
const settings = makeSettings();
const fetchImpl = vi.fn().mockRejectedValue(new Error('ECONNREFUSED'));
const result = await refreshVisionCache({
settings: settings as never,
endpoint: 'http://localhost:11434',
fetchImpl: fetchImpl as never,
});
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toMatch(/unreachable/);
});
it('filters vision-capable models, persists cache, returns ok:true + models', async () => {
const settings = makeSettings();
const fixedNow = new Date('2026-05-09T00:00:00.000Z');
const responseBody = {
models: [
{ name: 'llava:13b', details: { family: 'llava' } },
{ name: 'gemma2:9b', details: { family: 'gemma' } },
{ name: 'qwen2-vl:7b' },
],
};
const fetchImpl = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(responseBody),
});
const result = await refreshVisionCache({
settings: settings as never,
endpoint: 'http://localhost:11434',
fetchImpl: fetchImpl as never,
now: () => fixedNow,
});
expect(result).toEqual({ ok: true, models: ['llava:13b', 'qwen2-vl:7b'] });
expect(settings.setVisionCapableCache).toHaveBeenCalledWith(
['llava:13b', 'qwen2-vl:7b'],
fixedNow
);
});
});

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 { mockGet, mockSet, mockRefresh } = vi.hoisted(() => ({
mockGet: vi.fn(),
mockSet: vi.fn(),
mockRefresh: vi.fn()
}));
vi.mock('../../src/renderer/inbox/api.js', () => ({
inboxApi: {
getVisionModels: mockGet,
setVisionModel: mockSet,
refreshVisionCache: mockRefresh
}
}));
import { VisionSection } from '../../src/renderer/inbox/components/settings/VisionSection';
describe('VisionSection', () => {
beforeEach(() => {
vi.clearAllMocks();
cleanup();
mockGet.mockResolvedValue({
models: ['gemma3:12b-vision', 'llava:13b'],
at: '2026-05-10T05:00:00Z',
selected: 'gemma3:12b-vision'
});
mockSet.mockResolvedValue({ ok: true });
mockRefresh.mockResolvedValue({ ok: true, models: ['gemma3:12b-vision', 'llava:13b'] });
});
it('open 시 cache 로드 + dropdown 옵션 표시 + 선택된 모델 default', async () => {
render(<VisionSection />);
await waitFor(() => {
expect(screen.getByLabelText('이미지 분석 모델')).toHaveValue('gemma3:12b-vision');
});
expect(screen.getByText('gemma3:12b-vision')).toBeInTheDocument();
expect(screen.getByText('llava:13b')).toBeInTheDocument();
expect(screen.getByText(/마지막 감지/)).toBeInTheDocument();
});
it('dropdown 변경 → setVisionModel 호출', async () => {
render(<VisionSection />);
await waitFor(() => screen.getByLabelText('이미지 분석 모델'));
fireEvent.change(screen.getByLabelText('이미지 분석 모델'), { target: { value: 'llava:13b' } });
await waitFor(() => {
expect(mockSet).toHaveBeenCalledWith('llava:13b');
});
});
it('비활성 선택 → setVisionModel(null)', async () => {
render(<VisionSection />);
await waitFor(() => screen.getByLabelText('이미지 분석 모델'));
fireEvent.change(screen.getByLabelText('이미지 분석 모델'), { target: { value: '' } });
await waitFor(() => {
expect(mockSet).toHaveBeenCalledWith(null);
});
});
it('다시 감지 클릭 → refreshVisionCache 호출 + 결과 표시', async () => {
render(<VisionSection />);
await waitFor(() => screen.getByRole('button', { name: /다시 감지/ }));
fireEvent.click(screen.getByRole('button', { name: /다시 감지/ }));
await waitFor(() => {
expect(mockRefresh).toHaveBeenCalled();
});
await waitFor(() => {
expect(screen.getByText(/감지 완료/)).toBeInTheDocument();
});
});
});

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,40 @@
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('');
});
it('v0.3.9 — dash/caret/backtick 추가 sanitize', () => {
expect(sanitizeFtsQuery('key-value')).toBe('key value');
expect(sanitizeFtsQuery('^prefix')).toBe('prefix');
expect(sanitizeFtsQuery('back`tick')).toBe('back tick');
expect(sanitizeFtsQuery('-NOT')).toBe('NOT');
});
});
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

@@ -11,7 +11,8 @@ vi.mock('electron', () => ({
ipcMain: {
handle: (ch: string, fn: (...args: unknown[]) => unknown) => {
handlers[ch] = fn;
}
},
on: (_ch: string, _fn: unknown) => {}
},
dialog: {},
shell: { openPath: mockOpenPath }

View File

@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
vi.mock('electron', () => ({
default: {
ipcMain: { handle: vi.fn() }
ipcMain: { handle: vi.fn(), on: vi.fn() }
}
}));

View File

@@ -0,0 +1,84 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
vi.mock('electron', () => ({ default: { ipcMain: { handle: vi.fn(), on: 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

@@ -12,7 +12,8 @@ vi.mock('electron', () => ({
ipcMain: {
handle: (ch: string, fn: (...args: unknown[]) => unknown) => {
handlers[ch] = fn;
}
},
on: (_ch: string, _fn: unknown) => {}
},
dialog: {},
shell: {}
@@ -82,6 +83,20 @@ describe('inbox:set-status IPC', () => {
expect(r.reason).toBe('invalid status');
expect(mockSetStatus).not.toHaveBeenCalled();
});
it('emits note:updated to renderer after setStatus (v0.3.8 push-based)', async () => {
const send = vi.fn();
const win = { webContents: { send }, isDestroyed: () => false } as never;
const deps = makeDeps();
deps.getInboxWindow = () => win;
const updatedNote = { id: 'n1', status: 'completed' };
mockFindById.mockReturnValue(updatedNote);
registerInboxApi(deps);
const handler = handlers['inbox:set-status'];
if (handler === undefined) throw new Error('handler not registered');
await handler(null, 'n1', 'completed', null);
expect(send).toHaveBeenCalledWith('note:updated', updatedNote);
});
});
describe('ai:classify-status IPC', () => {

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,65 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Track ipcMain.handle and ipcMain.on calls
const { handleCalls, onCalls } = vi.hoisted(() => ({
handleCalls: [] as string[],
onCalls: [] as string[]
}));
vi.mock('electron', () => ({
default: {
ipcMain: {
handle: (ch: string, _fn: unknown) => { handleCalls.push(ch); },
on: (ch: string, _fn: unknown) => { onCalls.push(ch); }
},
dialog: {},
shell: {}
}
}));
import { registerInboxApi } from '../../src/main/ipc/inboxApi.js';
import type { InboxIpcDeps } from '../../src/main/ipc/inboxApi.js';
function makeDeps(): InboxIpcDeps {
return {
repo: {} as never,
continuity: {} as never,
capture: {
emitRecallShown: vi.fn(async () => {}),
emitRecallSnoozed: vi.fn(async () => {})
} as never,
health: {} as never,
intent: {} as never,
getInboxWindow: () => null,
settings: {} as never,
providerHolder: {} as never,
paths: { profileDir: '/profile' }
};
}
describe('recall IPC channels (fire-and-forget)', () => {
beforeEach(() => {
handleCalls.length = 0;
onCalls.length = 0;
});
it('inbox:emitRecallShown is registered with ipcMain.on (not handle)', () => {
registerInboxApi(makeDeps());
expect(onCalls).toContain('inbox:emitRecallShown');
expect(handleCalls).not.toContain('inbox:emitRecallShown');
});
it('inbox:emitRecallSnoozed is registered with ipcMain.on (not handle)', () => {
registerInboxApi(makeDeps());
expect(onCalls).toContain('inbox:emitRecallSnoozed');
expect(handleCalls).not.toContain('inbox:emitRecallSnoozed');
});
it('each recall channel is registered exactly once with ipcMain.on', () => {
registerInboxApi(makeDeps());
const shownCount = onCalls.filter((ch) => ch === 'inbox:emitRecallShown').length;
const snoozedCount = onCalls.filter((ch) => ch === 'inbox:emitRecallSnoozed').length;
expect(shownCount).toBe(1);
expect(snoozedCount).toBe(1);
});
});

View File

@@ -52,15 +52,6 @@ describe('useInbox — expired state (v0.2.3 #5)', () => {
afterEach(() => { vi.restoreAllMocks(); });
it('loadExpired sets expiredCandidates from inboxApi', async () => {
mockApi.listExpired.mockResolvedValueOnce([noteStub('n1')]);
const { useInbox } = await import('../../src/renderer/inbox/store.js');
await useInbox.getState().loadExpired();
const s = useInbox.getState();
expect(s.expiredCandidates).toHaveLength(1);
expect(s.expiredCandidates[0]!.id).toBe('n1');
});
it('trashExpiredBatch removes ids and increments trashCount when confirmed', async () => {
mockApi.trashExpiredBatch.mockResolvedValueOnce({ trashedCount: 2, confirmed: true });
const { useInbox } = await import('../../src/renderer/inbox/store.js');

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

View File

@@ -3,6 +3,7 @@ import type { Note } from '@shared/types';
const mockApi = {
listNotes: vi.fn(async () => [] as Note[]),
listByStatus: vi.fn(async () => [] as Note[]),
listTrash: vi.fn(async () => [] as Note[]),
getTrashCount: vi.fn(async () => 0),
getContinuity: vi.fn(async () => ({ weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null })),

Some files were not shown because too many files have changed in this diff Show More