195 Commits

Author SHA1 Message Date
b20473a593 Merge pull request 'v0.2.8 Cut A — 이미지 렌더링 + 앱 아이콘 (F22 + chore)' (#26) from worktree-v028-cut-a-image-icon into main
Reviewed-on: #26
2026-05-09 05:57:09 +00:00
altair823
6db449f86d chore(v028): final review minor 3건 cleanup
- inklingMedia.ts:39 no-op replace 제거 + 명료한 host+pathname 결합 코멘트
- inbox:open-media 빈 relPath 명시적 거절 (typeof + length 검사)
- NoteCard <img> alt="" decorative 의도 코멘트

472/472 + typecheck 0 유지.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:27:42 +09:00
altair823
29259eef32 chore(release): v0.2.8 — Cut A (이미지 렌더링 + 앱 아이콘) 2026-05-09 14:23:51 +09:00
altair823
4d4dac5523 chore(v028): 앱 아이콘 (assets/icon.svg → ICO/ICNS/PNG) + electron-builder config
- electron-icon-builder + sharp devDep 추가
- assets/icon.svg → build/icon.{ico,icns,png} 산출 + git 추적
- electron-icon-builder 가 SVG 직접 input 안 받음 (Jimp MIME 에러) — sharp 로 SVG → PNG 1024 변환 후 input
- scripts/svg-to-png.mjs (sharp 사용 SVG→PNG) + scripts/finalize-icons.mjs (build/icons/ → build/ 정규 위치 정리)
- package.json build.{win,mac,linux}.icon 키 추가
- .gitignore: build/icons/ 와 build/icon-source.png (중간 산출물) 무시, build/icon.* 는 추적
- typecheck 0 errors + 472/472 단위 통과 유지 (회귀 없음)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:19:28 +09:00
altair823
9cdea1531c feat(v028): IPC inbox:open-media + path traversal + NoteCard cast 정리 2026-05-09 14:10:57 +09:00
altair823
f6bea623bf feat(v028): NoteCard 이미지 <img> 렌더링 + onClick (openMedia 시그니처는 Task 3)
- 회색 placeholder div → <img src=inkling-media://...> 로 교체
- onClick 으로 inboxApi.openMedia(relPath) 호출 (현재는 InboxApi 인터페이스에 부재 → unknown cast 사용; Task 3 에서 정식 시그니처 추가 후 cast 제거 예정)
- alt='' 로 decorative 처리 (role=presentation), title 에 relPath 유지
- flex-wrap 추가 — 다수 이미지 시 줄바꿈

Tests: tests/unit/NoteCard.test.tsx 신규 2건 (img src 검증, click → openMedia 호출)
회귀: 468 → 470 pass
2026-05-09 14:06:21 +09:00
altair823
470384bf80 feat(v028): inkling-media:// custom protocol + path traversal 검사
- registerSchemesAsPrivileged: inkling-media 스킴을 secure + supportFetchAPI + stream 으로 등록 (whenReady 이전 호출 필수).
- registerInklingMediaProtocol: profileDir/media 하위 파일을 raw URL traversal (.., %2e%2e) 검사 + normalize 후 mediaRoot 봉쇄로 이중 검증 후 readFile.
- inferMime: png/jpg/jpeg/gif/webp → image/*, 그 외 → application/octet-stream.
- src/main/index.ts: 모듈 import 직후 registerSchemesAsPrivileged(), whenReady 안 paths 결정 직후 registerInklingMediaProtocol(paths.profileDir).
- tests/unit/inklingMedia.test.ts: 8 unit (5 inferMime + 3 handler — valid/403/404). vitest 의 new Request() 가 url 을 normalize 하므로 raw url 보존을 위해 minimal mock req 사용.
2026-05-09 14:00:50 +09:00
e8cddc7889 Merge pull request 'v0.2.7 — cross-platform 입구 정상화 (F12 deeper + F14 + F15 빌드 + F16)' (#25) from worktree-v027-cross-platform into main
Reviewed-on: #25
2026-05-07 00:50:17 +00:00
altair823
e19f6a8de7 chore(v027): PR review minor cleanup 3건
- types.ts:119 stale "Task 25 cleanup" comment 제거 (Task 25 이미 완료)
- BackupSection.tsx 의 dead try/catch 제거 + status 단순화 — 모든 IPC 핸들러가 자체 try/catch + Notification 으로 결과 알림. 컴포넌트 status 는 진행 표시 보조용
- index.ts startup #45 autostart 진단 로그를 AutostartDiagnostic.collectAutostartState() 호출로 통합 — single source of truth (SettingsPage 진단 패널과 동일 데이터 소스)

460/460 pass, typecheck 0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 09:49:09 +09:00
altair823
ccfdbce79b chore(release): v0.2.7 — cross-platform 입구 정상화 (F12 deeper + F14 + F15 빌드 + F16) 2026-05-07 02:37:13 +09:00
altair823
cffd1cec90 refactor(v027): OllamaSettingsModal 제거 + onOpenOllamaSettings 채널 cleanup 2026-05-07 02:35:43 +09:00
altair823
c5f2b8337a test(v027): App/SettingsPage 테스트 mock 을 새 AutostartResponse 형태로 갱신 2026-05-07 02:32:06 +09:00
altair823
836828636c feat(v027): AutostartSection 재등록 버튼 2026-05-07 02:30:29 +09:00
altair823
8a8652e87a feat(v027): AutostartSection 진단 패널 + mismatch 경고 2026-05-07 02:29:17 +09:00
altair823
ce6c5ea756 feat(v027): settings:autostart-set 정식 + 채널 이름 통일 2026-05-07 02:28:17 +09:00
altair823
39bbf8f443 feat(v027): settings:autostart-state IPC 핸들러 2026-05-07 02:26:18 +09:00
altair823
5f964aa2f5 feat(v027): AutostartDiagnostic — Windows registry 조회 + silent fallback 2026-05-07 02:25:21 +09:00
altair823
3a8137f334 feat(v027): AutostartDiagnostic — withArgs/noArgs/execPath 수집 2026-05-07 02:23:52 +09:00
altair823
3b53cec663 fix(v027): F14 — macOS dock 클릭 시 hidden inbox 창 show/focus 2026-05-07 02:22:40 +09:00
altair823
9c8ba8ad09 feat(v027): createTray wiring 3-callback + refreshTray 호출부 슬림 2026-05-07 02:18:32 +09:00
altair823
f30fbddd38 feat(v027): tray.ts 의 showAboutDialog + 자동실행 분기 + 미사용 import 제거 2026-05-07 02:16:55 +09:00
altair823
77effb4526 feat(v027): TrayCallbacks/TrayState 슬림 + buildMenu 4 항목 2026-05-07 02:16:29 +09:00
altair823
feb7c62f19 feat(v027): IPC inbox:navigate — 외부에서 설정 페이지 진입 2026-05-07 02:12:45 +09:00
altair823
95ed0fba93 feat(v027): App.tsx 헤더 톱니바퀴 + showSettings 분기 2026-05-07 02:10:01 +09:00
altair823
6ab518410e feat(v027): InfoSection — 버전/데이터 위치/복사 + IPC 2026-05-07 02:07:20 +09:00
altair823
5cd38f2537 feat(v027): BackupSection — 5 버튼 + IPC 핸들러 2026-05-07 02:03:31 +09:00
altair823
fca28fb0c4 feat(v027): AutostartSection 토글 (진단 패널은 후속 task) 2026-05-07 01:56:58 +09:00
altair823
7301f4d73d feat(v027): AiProviderSection — OllamaSettingsModal 흡수 + 지금 재확인 2026-05-07 01:51:53 +09:00
altair823
91bf98f1a2 feat(v027): SettingsPage scaffold — 4 섹션 placeholder + 돌아가기
v027 plan Task 7. zustand store 의 showSettings 를 사용하는 첫 컴포넌트.
4 섹션 (AI 제공자/자동 실행/백업·복원/정보) placeholder 와 헤더 + 돌아가기 버튼만.
실 콘텐츠는 후속 Task 8-11 에서 채움.

테스트 인프라 동시 추가 (v027 의 첫 React 컴포넌트 테스트):
- @testing-library/react + @testing-library/jest-dom + jsdom devDep 추가
- vitest.config: plugin-react 적용, include 에 .test.tsx 포함
- 환경 분리는 per-file `// @vitest-environment jsdom` directive 로 처리
  (vitest 4.x 에서 environmentMatchGlobs 미지원 — 기존 .ts 단위 테스트는 node env 유지)
2026-05-07 01:42:54 +09:00
altair823
5b37529175 feat(v027): inbox store 에 showSettings state + setShowSettings action 2026-05-07 01:36:26 +09:00
altair823
c9d374ade6 docs(v027): dist:linux 1차 빌드 시도 결과 (Windows 호스트) 2026-05-07 00:23:07 +09:00
altair823
b1b7bfee26 feat(v027): electron-builder linux target (AppImage + deb x64) 2026-05-07 00:18:14 +09:00
altair823
66bae5e317 docs(v027): better-sqlite3 linux-x64 prebuild 가용성 검증 2026-05-07 00:15:12 +09:00
altair823
5a605ef98f docs(v027): cross-platform 입구 정상화 implementation plan 작성
27 task / 6 phase. Phase 1 (Linux 빌드 risk-reduction first) → Phase 2
(설정 페이지 + IPC) → Phase 3 (트레이 슬림) → Phase 4 (F14 dock fix) →
Phase 5 (F12 deeper fix) → Phase 6 (cleanup + version bump).

각 task TDD red→green→typecheck→commit 순서. spec coverage / placeholder
/ type consistency self-review 통과.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 00:10:25 +09:00
altair823
c2be135031 docs(v027): cross-platform 입구 정상화 design 작성
F12 deeper fix + F14 + F15 (Linux 빌드만, CLI 제거) + F16 4묶음 —
v0.2.7 brainstorm 결과. dogfood-feedback.md F15 entry promoted/rejected
표시. F12/F14/F16 promoted 마킹은 design 확정 후 일괄 처리.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 23:59:39 +09:00
altair823
9f47c13649 docs(dogfood): v0.2.6 release 후 dogfood 문서 갱신
dogfood-feedback.md (F1~F7 → F1~F13):
- Header: 진척 흐름 요약 표 추가 (v0.2.3 ~ v0.2.6 cuts + 신규 dogfood 발견)
- F8 Windows 11434 reserved → v0.2.3.1/v0.2.4 (PR #21/#22)
- F9 multi-instance spawn → v0.2.5 critical hotfix (PR #23)
- F10 버전 정보 부재 → v0.2.4 트레이 "Inkling 정보..." 추가
- F11 single-instance lock 부재 (F9 흡수)
- F12 autostart 풀림 → v0.2.6 진단 fallback (drafting, dogfood verify 후 v0.2.7)
- F13 PR review 발견 (restoreNote production path dead code) → v0.2.6 round 1 Critical fix

dogfood-strategy.md (Day 0 환경 step 갱신):
- v0.2.6 binary release 기준
- Ollama 설정: in-app UI (트레이 "Ollama 설정...") 가 1차, env var fallback 그 다음
- 11434 reserved 머신 우회 절차 (OLLAMA_HOST=127.0.0.1:11942)
- 데이터 위치 확인: 트레이 "Inkling 정보..." → "데이터 위치 열기"
- autostart 확인 절차 (F12 dogfood verify 영역)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 02:28:37 +09:00
altair823
a51f241b94 docs(backlog): v0.2.6 cut 16건 처리 갱신 — 잔여 24건
처리 이력 표 갱신:
- v0.2.6 정식 cut (PR #24, 머지 8bc33da) 의 16 backlog 항목 모두  표기
- B1 production path Critical fix (a991008) 별도 row 추가
- v0.2.6 final reviewer + round 1 minors (NoteRepository.countToday inline KST,
  BackupService/ContinuityService inline KST, NoteRepository.test.ts as any,
  OllamaSettingsModal #fce4e4 inline, kstDate naming, store trashCount race,
  ExpiryBanner useEffect closure) deferred 표 추가

총 항목 46 / 처리 21 / stale 1 / 잔여 24.

명명 노트 갱신:
- v0.2.6 = 첫 정식 cut
- v0.2.7 = telemetry data-dependent 14건 + #45 deeper fix + deferred
- backlog file 본 파일은 v0.2.7 cut 시점에 prune + rename 검토

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 02:10:04 +09:00
8bc33da954 Merge pull request 'feat(v026): bugs + cleanup — 16 backlog 항목 처리' (#24) from feat/v026-bugs-cleanup into main
Reviewed-on: #24
2026-05-04 17:06:31 +00:00
altair823
a991008689 fix(v026): PR #24 round 1 Critical — B1 production path activation
Round 1 reviewer 발견: B1 (#10) fix 가 dead code. NoteRepository.restoreNote
새 메서드는 unit test 만 호출, production path (CaptureService.restoreNote)
는 옛 repo.restore() 호출 → ai_status reset + pending_jobs INSERT 우회.

Fix:
- CaptureService.restoreNote 가 repo.restoreNote 호출
- before 의 ai_status 가 'failed' or 'pending' 이면 worker.enqueue(id) 도 호출
  (in-memory queue 갱신 — restoreNote 가 DB 만 갱신하면 다음 app start 까지
   처리 안 됨)

Round 1 Important 도 함께 처리.

단위 +2 cases (failed → enqueue, done → skip enqueue).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 01:58:27 +09:00
altair823
54e2f5b10f chore(release): v0.2.6 — bugs + cleanup (16 backlog 항목 처리)
bugs (4):
- #10 restoreNote 가 failed 노트 시 pending_jobs 재생성
- #12 trashCount cap → countTrashed() 정확 N (이미 fix 됨, tests 추가)
- #45 autostart 풀림 — args 비교 정확도 + 진단 로그
- #46 hidden-start race — additionalData 로 두 번째 hidden 구분

cleanup (12 → 9 cluster):
- #3+#19+#34 KST helper 통합 → src/shared/util/kstDate.ts (4 callsite migrate)
- #4+#23+#26+#27 TrayCallbacks 객체화 + state 통합 (10 positional → 1-arg + Partial<TrayState>)
- #5 AiFailedReason union 단일 export (zod z.infer)
- #21 hasNoteId type predicate (TelemetryService.test.ts narrowing 단축)
- #22 NoteRepository hydrate row type 통일 (as Record<string, unknown>[])
- #24+#41 Banner shared component (severity prop, 4 banner migrate)
- #8 stats.md exhaustiveness check (else { _: never })
- #15 IPC channel inbox:delete → inbox:trash
- #29 VOCAB_TOP_N const
- #42 Modal client-side URL pre-check (zod safeParse)
- #9 휴지통 회수율 ratio 의미 코멘트

게이트: typecheck 0 / 단위 424 / e2e 1
잔여 backlog: 14건 (telemetry data-dependent, v0.2.7 brainstorm)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 01:46:25 +09:00
altair823
8b2920fee4 refactor(v026): C9 microfixes — #15 #29 #42 #9
- #15: IPC channel inbox:delete → inbox:trash (semantic = soft delete)
  channel name 만 변경, InboxApi method name (deleteNote) 은 backward compat 유지
- #29: getTopUsedTags(20) → VOCAB_TOP_N const (튜닝 자체는 dogfood telemetry 후)
- #42: OllamaSettingsModal client-side URL validation (zod safeParse pre-check)
  + model 빈 문자열 가드. server-side healthCheck 전에 친화적 에러 메시지.
- #9: 휴지통 회수율 ratio 의미 1줄 코멘트 (event-level, unique-note 아님)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 01:44:58 +09:00
altair823
0447b69b82 refactor(v026): #24+#41 Banner shared component (severity prop)
4 banner inline style 중복 (warning 황색 / error 적색 / info 청색)
→ <Banner severity="warning|error|info"> wrapper. THEMES map 단일 source.

- ExpiryBanner: warning
- OllamaBanner: warning
- FailedBanner: error
- RecallBanner: info

OllamaSettingsModal 은 modal 형식이라 banner 와 분리 (별개 inline style 유지).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 01:42:16 +09:00
altair823
476a519fb5 refactor(v026): #4+#23+#26+#27 TrayCallbacks 객체화 + state 통합
createTray(callbacks: TrayCallbacks) 1-arg signature. 기존 10 positional 폐기.
TrayState 통합 (ollamaOk, todayCount, failedCount) — refreshTray({...partial})
1개 setter 로 일원화.

기존 refreshTrayOllama / refreshTrayFailedCount export 제거 — 호출자 모두
refreshTray({ ollamaOk: ... }) / refreshTray({ failedCount: ... }) 로 migrate.
module-scoped 개별 state 변수 (_failedCount 등) 제거.

backlog 4건 일괄: #4 (positional 폭주) / #23 (8 callbacks) / #26 (10 callbacks) /
#27 (refreshTrayFailedCount singleton). 다음 menu item 추가 시 callback
프로퍼티 추가만 — readability blocker 해소.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 01:38:51 +09:00
altair823
9230ebff9d refactor(v026): #8 telemetryStats.aggregateStats exhaustiveness check
if/else if 체인 끝에 const _exhaustive: never = ev — 새 telemetry kind
추가 시 본 함수 분기 누락을 컴파일 단계에서 catch.

silent fall-through 방지 — kind 추가 → typecheck 실패 → 강제 분기 추가.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 01:35:04 +09:00
altair823
983306e004 refactor(v026): #22 NoteRepository hydrate row type 통일
db.prepare().all() 의 row type cast s any[] / s unknown[] →
s Record<string, unknown>[] 일괄 통일. hydrate() signature 도 동일
(이미 그렇거나 갱신).

- TS strict 환경 친화 (any 보다 narrow)
- 향후 explicit row interface 추가 시 base 형 명확
- runtime 동작 변경 0

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 01:33:30 +09:00
altair823
05c45c1e10 refactor(v026): #21 hasNoteId type predicate helper
기존 4-line narrowing 체인 (e.kind !== 'empty_trash' && ... && ...) 이
union 확장 시 길어짐 → hasNoteId(ev) type predicate 로 통합.

- telemetryEvents.ts: NO_NOTE_ID_KINDS Set + hasNoteId(ev): ev is ... export
- TelemetryService.test.ts: 2 narrowing callsite 단축
- 단위 +2 cases (noteId-bearing / noteId-less)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 01:31:16 +09:00
altair823
a2c17a8b0d refactor(v026): #5 AiFailedReason union 단일 export 통합
기존 'unreachable' | 'schema' | 'timeout' | 'other' literal 이 3곳에 분산:
- telemetryEvents.ts (zod enum AiFailedReason)
- TelemetryService.ts (EmitInput 안 inline literal)
- AiWorker.ts (classifyReason 반환 + AiTelemetryEmitter inline literal)

zod enum z.infer 통해 type 파생, 단일 export AiFailedReason 으로 통합.
- AiFailedReasonSchema (zod enum) + AiFailedReason (type) 둘 다 export
- TelemetryService EmitInput / AiWorker classifyReason / AiTelemetryEmitter
  모두 import type AiFailedReason 사용

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 01:29:11 +09:00
altair823
3cfa60bbba refactor(v026): #3+#19+#34 KST helper 통합 → src/shared/util/kstDate.ts
기존 src/main/util/kstDate.ts (2 함수) 를 shared 로 이동 + kstTodayAsDate 추가.
main + renderer 양쪽 import 가능. 6 callsite 통합:
- NoteRepository.findExpiredCandidates (todayInKstString → kstTodayIso)
- TelemetryService.todayKstIso (inline 제거)
- telemetryStats.kstDate (inline 제거)
- AiWorker.todayKstAsDate / todayKstAsIso (inline 제거)
- store.snoozeExpired + snoozeRecall (inline 제거 → nextKstMidnightMs)

API: kstTodayIso(now) / nextKstMidnightMs(now) / kstTodayAsDate(now)
+ KST_OFFSET_MS, DAY_MS 상수 export.

단위 +4 cases (boundary, format, midnight, asDate). 418 → 422.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 01:27:25 +09:00
altair823
075f395b6d fix(v026): #45 autostart 풀림 — args 비교 정확도 + 진단 로그
추정 원인 (a)/(b)/(c):
- (a) Windows registry path mismatch (NSIS 설치 위치 변경)
- (b) electron path canonicalization
- (c) args 비교 mismatch — getLoginItemSettings 가 args 와 함께 read 해야 매치

Fix:
- tray.ts: getLoginItemSettings({ args: ['--hidden'] }) 명시 — 트레이 checkbox
  의 checked 상태가 실제 LoginItem args 와 정합하게 비교
- index.ts firstRun 후: autostart.state 진단 로그 (withArgs vs noArgs 비교
  + executableWillLaunchAtLogin) — dogfood 에서 실제 동작 확인

Fix 가 충분하지 않으면 dogfood 로그 분석 후 v0.2.7 deeper fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 01:22:34 +09:00
altair823
e485b77888 fix(v026): #46 hidden-start race — additionalData 로 두 번째 hidden 구분
PR #23 single-instance lock 의 second-instance handler 가 무조건 inbox 창
띄움. NSIS installer 직후 사용자 클릭 + autostart --hidden 동시 시도 시
두 번째가 hidden 이어도 창 띄워서 "트레이만" 의도 위반.

Fix: requestSingleInstanceLock 에 additionalData = { hidden: startedHidden }
전달, second-instance 콜백 signature (event, argv, cwd, additionalData) 의
4번째 인자에서 hidden flag 확인 → true 면 early return (창 안 띄움).

PR #23 round 1 reviewer Important deferred 처리.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 01:20:44 +09:00
altair823
e2c53a28dc fix(v026): #12 trashCount cap → countTrashed() 정확 N (silent undercount 해소)
기존 UI 가 listTrash 200 limit 후 length 사용 → 350개 trash 시 dialog
"200개 영구 삭제" 표시되지만 실제 350 모두 삭제. 사용자 혼동 해소.

- NoteRepository.countTrashed() 신규 — SELECT COUNT(*) WHERE deleted_at IS NOT NULL
- IPC inbox:trashCount → countTrashed 사용
- 단위 +2 cases (>200 not capped, empty 0)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 01:18:10 +09:00
altair823
df27a9637e fix(v026): #10 restoreNote 가 failed 노트 시 pending_jobs 재생성
restore 가 deleted_at = NULL 만 했음 → ai_status='failed' 인 노트는
영구 fail 상태로 복구. atomic transaction 안에서 ai_status='pending' reset
+ INSERT OR IGNORE INTO pending_jobs.

- failed → pending + pending_jobs 재처리 path 복구
- done 은 영향 X (이미 결과 있음)
- pending 은 pending_jobs 재생성 (defensive — trash 도중 jobs 미정상 상태 가능)
- 단위 +3 cases (failed/done/pending 각 케이스)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 01:15:23 +09:00
altair823
6fdb72101f docs(v026): plan — 13 task TDD (4 bug + 9 cleanup cluster + closure)
순서: B1 → B2 → B4 → B3 → C1 → C4 → C5 → C6 → C8 → C2+C3 → C7 → C9 → T13.
B3 (autostart) 위험 task 는 cleanup 시작 직전, fail 시 빠른 회피.

각 task 별 file path / 상세 step / commit message 포함.
신규 단위 추정 +14 (413 → ~427).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 01:12:15 +09:00
altair823
341f55505d docs(v026): bugs + cleanup spec — 16 backlog 항목 → 13 task
bugs (4): #10 restore + pending_jobs / #12 trashCount cap / #45 autostart 풀림 / #46 hidden-start race
cleanup (12 → 9 cluster): KST helper / TrayCallbacks 객체 / refreshTrayFailedCount singleton /
  AiFailedReason union / hasNoteId predicate / hydrate as any[] / Banner shared component /
  exhaustiveness check / microfixes (channel rename + VOCAB_TOP_N + Modal URL pre-check + ratio 코멘트)

dogfood telemetry 필요 14건은 v0.2.7 영역. 별도 brainstorm 4건도 v0.2.7+.

게이트 추정: 단위 413 → 427 (+14). version 0.2.5 → 0.2.6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 01:08:05 +09:00
altair823
b3e16ff5bc docs(backlog): v0.2.4/v0.2.5 release 후 status 갱신 + #46 신규
Header / 처리 이력 / next-step 섹션 outdated 반영:
- 최종 갱신 2026-05-05 v0.2.5 critical hotfix 완료
- 처리 이력 표 — v0.2.4 5건 처리 + v0.2.5 single-instance lock (out-of-backlog hotfix)
- #46 신규 추가: PR #23 reviewer Important deferred (hidden-start race)
- #45 우선순위 v0.2.4 → v0.2.6 으로 이동 표기
- post-cut next-step (#38) status 갱신 — v0.2.5 release 완료, 다음 v0.2.6 brainstorm
- "v0.2.4 brainstorm" → "v0.2.6 brainstorm" 표현 통일
- 명명 노트 추가: 파일명 historic, v0.2.6 cut 시 prune + rename 검토

총 항목 46 / 잔여 40건.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 00:57:09 +09:00
8f2b9adb3a Merge pull request 'hotfix(critical): single-instance lock — SQLite race 방지 (v0.2.5)' (#23) from hotfix/single-instance-lock into main
Reviewed-on: #23
2026-05-04 15:48:05 +00:00
altair823
7187aea0a9 hotfix(critical): single-instance lock — multi-process SQLite race 방지
dogfood 발견 — 앱 아이콘 클릭 시마다 새 process 가 떠서 트레이 아이콘 여러 개,
SQLite 동시 접근 + AiWorker 중복 처리 + HealthChecker 중복 polling 등
**데이터 corruption 위험**.

원인: app.requestSingleInstanceLock() 호출 부재. Electron default 가
multi-instance 라 .exe 실행마다 별도 process.

Fix:
- app.requestSingleInstanceLock() 첫 줄에서 호출
- 두 번째 인스턴스 → app.quit() 즉시 종료
- 'second-instance' 이벤트 → 기존 inbox 창 restore + show + focus
  (사용자 의도는 "앱 보기" 라 가정)

게이트: typecheck 0 / 단위 413 / e2e 1
version: 0.2.4 → 0.2.5 (critical hotfix patch)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 00:42:50 +09:00
49c29f34c3 Merge pull request 'chore(release): v0.2.4 — patch cut (backlog 5건 + dogfood unblock)' (#22) from feat/v024-patch-cleanup into main
Reviewed-on: #22
2026-05-04 15:24:46 +00:00
altair823
d213d45f92 fix(v024): About dialog EOL + .catch (round 1 review)
Round 1 review minor + final reviewer minor 일괄:
- About dialog detail/clipboard 의 줄바꿈 → os.EOL (Windows Notepad 등에서 줄바꿈 정상)
- showMessageBox().then().catch(() => {}) — dialog reject (main crash 예외) silent
  (tray.ts 가 logger 미import — minimal swallow 패턴 채택)

skip:
- nit: 트레이 메뉴 ordering ("정보" → "종료" 한 그룹) — 현재 패턴도 흔함, 호불호 영역
- nit: process.versions.electron ?? '?' dead branch — 안전 fallback 유지

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 00:22:00 +09:00
altair823
298d1c6182 chore(release): v0.2.4 — patch cut (backlog 5건 처리 + dogfood unblock)
PR #21 머지 후 v0.2.3.1 binary 빌드 시도 → electron-builder semver 검증
실패 (4-part X.Y.Z.W 비호환). v0.2.4 minor bump 으로 우회.

본 cut 동봉:
- 0.2.3.1 의 in-app Ollama 설정 UI (PR #21 fee982a)
- backlog #2 (DAY_MS 상수)
- backlog #6 (media.gc .catch)
- backlog #13 (NoteCard onDeleted optional)
- backlog #44 (버전 정보 트레이 메뉴)
- backlog #1 stale 표기 (PR #13 시 이미 fix)

게이트: typecheck 0 / 단위 413 / e2e 1
다음: PR + 머지 후 binary 빌드 v0.2.4 + Gitea release
v0.2.5 brainstorm 트리거 시 잔여 backlog 39건 일괄 triage

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 00:15:51 +09:00
altair823
d3dfe1e4e2 feat(v024): "Inkling 정보..." 트레이 메뉴 + native About dialog (backlog #44)
dogfood 발견 #44 fix — 사용자가 설치된 버전 확인 path 부재 해소.

- 트레이 메뉴 마지막 항목 (종료 직전): "Inkling 정보..."
- 클릭 시 native dialog (showMessageBox):
  - title: Inkling 정보
  - message: Inkling {version}
  - detail: 버전, Electron, Node, OS platform/release, 데이터 위치
  - 버튼 3개: 확인 / 데이터 위치 열기 (shell.openPath) / 정보 복사 (clipboard)
- 디버그 정보 노출로 사용자가 issue report 시 첨부 가능

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 00:14:30 +09:00
altair823
c87c248e89 refactor(v024): NoteCard onDeleted optional + trash mode 미전달 (backlog #13)
- onDeleted: () => void → onDeleted?: () => void (inbox mode 전용 명시)
- handleDelete 내부 onDeleted() → onDeleted?.()
- App.tsx 의 trash mode NoteCard 가 onDeleted prop 미전달 (dead-code 제거)
- API 시그니처 정리 — trash mode 는 onPermanentDelete/onRestore 만 의미

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 00:12:56 +09:00
altair823
ef5d3daf4c refactor(v024): TelemetryService DAY_MS 상수 + media.gc .catch (backlog #2 #6)
- #2: 24*60*60*1000 magic number → 모듈 상단 const DAY_MS
  cleanupOldFiles + readAllRecent 두 callsite 통일
- #6: gc.run() 의 .catch 누락 → backup.runDaily 패턴 통일
  실패 시 logger.warn('media.gc.failed', { reason })

Note: backlog #1 (now() 2번 호출) 은 PR #13 round 1 review 시 이미 fix —
backlog 항목 stale. v0.2.5 brainstorm 시 backlog 정리.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 00:11:38 +09:00
altair823
4bde148cdc docs(v024): patch cleanup spec — 5 backlog 항목 + version bump
0.2.3.1 semver 위반 → 0.2.4 minor bump 이용해 backlog risk 낮은 cleanup
5건 + dogfood 가치 #44 묶음 cut. v0.2.4 정식 brainstorm 은 v0.2.5 로 이동.

In: #1 (now() 2번), #2 (DAY_MS), #6 (media.gc .catch), #13 (NoteCard onDeleted),
    #44 (버전 정보 surface), version bump
Out: #45 (autostart bug — 별도 cut), #3/#4/#5/#22/#26 (큰 refactor),
     #39~#43 (PR #21 deferred — v0.2.5 brainstorm)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 00:09:56 +09:00
altair823
8ba43d939e docs(backlog): v0.2.3.1 dogfood 발견 +2건 (#44 버전 정보, #45 자동실행 버그)
PR #21 머지 후 dogfood 중 사용자 발견:
- #44: 버전 / 빌드 정보 표시 surface 부재 (트레이 / Inbox footer / About 모달)
- #45: 윈도우 자동 실행 옵션 재시작 후 풀려있는 버그
  (tray.ts:47-58, app.setLoginItemSettings + getLoginItemSettings 비대칭)

PR review deferred 와 별개의 raw UX/bug 발견. 신설 섹션 "v0.2.3 / v0.2.3.1
dogfood 발견" 으로 분리 — v0.2.4 brainstorm 시 우선순위 결정.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 00:02:40 +09:00
fee982a6e6 Merge pull request 'feat(ollama): v0.2.3.1 — in-app endpoint/model 설정' (#21) from feat/v0231-ollama-settings into main
Reviewed-on: #21
2026-05-04 15:00:40 +00:00
altair823
d974335ee4 docs(backlog): v0.2.3.1 round 1 review m2/i1 + 신규 항목 5건 추가
PR #21 round 1 review 에서 deferred 항목들 backlog 38 → 43:
- #39 (m2): ollama_unreachable.reason 의 endpoint URL PII 우회 노출
- #40 (i1): save vs HealthChecker tick race UX flicker
- #41: OllamaSettingsModal 인라인 스타일 (#24 와 합산)
- #42: Modal client-side URL validation 부재
- #43: createTray 10번째 positional callback (#4/#26 blocker)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 23:54:50 +09:00
altair823
6f95e89456 fix(ollama): PR #21 review round 1 — m1+m3+m4+n1 (v0.2.3.1)
- m1 (Minor): saveOllamaSettings IPC가 setOllama throw 시 try/catch
  → { ok: false, reason: 'persist failed: ...' } 대칭 응답
- m3 (Minor): Modal ESC=close + Enter=save 키 핸들러 + 첫 input autoFocus
- m4 (Minor): handleSave 첫 줄 if (saving) return; — sync double-click 가드
- n1 (Nit): 'gemma4:e4b' / 'http://localhost:11434' magic
  → src/shared/constants.ts 의 DEFAULT_OLLAMA_MODEL / DEFAULT_OLLAMA_ENDPOINT

defer to v0.2.4 backlog:
- m2: ollama_unreachable.reason 에 endpoint URL 노출 (PII 우회) — telemetry masking 정책

skip:
- i1 (race UX): acknowledge only, 정확성 영향 0
- m5 (abort try/catch): 현재 LocalOllamaProvider.abort 는 throw X
- m6 (first-boot blocking): 무시 가능
- n2 (offReplace): 현재 listener callsite 0건

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 23:53:42 +09:00
altair823
3a2ff1a35c chore(release): v0.2.3.1 — Ollama 설정 in-app UI (patch cut)
dogfood unblock 패치. v0.2.3 의 INKLING_OLLAMA_ENDPOINT env var 의존 →
in-app UI (트레이 + 배너) 에서 endpoint + model 변경 가능.

게이트: typecheck 0 / 단위 413 / e2e 1
다음: PR + 머지 후 binary 재빌드 + Gitea release v0.2.3.1

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 23:44:45 +09:00
altair823
0c0327ddb6 feat(ollama): 트레이 메뉴 "Ollama 설정..." (v0.2.3.1)
- createTray 10번째 positional callback runOpenOllamaSettings
- 트레이 → 메뉴 클릭 → main 이 inbox:openOllamaSettings IPC push
- renderer App.tsx 가 구독해 modal open

backlog #4/#26 (TrayCallbacks object refactor) 와 합산 — v0.2.4 시 일괄 정리

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 23:43:00 +09:00
altair823
833a598368 feat(ollama): OllamaSettingsModal + App mount + OllamaBanner 설정 링크 (v0.2.3.1)
- OllamaSettingsModal: endpoint + model freetext 입력, 저장 시 healthCheck → 성공 닫기, 실패 inline 에러
- App.tsx: ollamaSettingsOpen state + onOpenOllamaSettings IPC subscribe
- OllamaBanner: onOpenSettings prop 추가, 우측 "설정" 버튼
- preload + types: onOpenOllamaSettings listener bridge

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 23:40:31 +09:00
altair823
4153284af1 fix(ollama): saveOllamaSettings 가 health.runOnce() 즉시 호출 (T4 review)
T4 fallback comment "60s polling cycle" 대신 HealthChecker 의 기존 public
method runOnce() 사용. 사용자가 settings 저장하자마자 OllamaBanner 갱신.
runOnce 는 이미 inbox:ollamaRecheck IPC 가 사용 중인 패턴.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 23:37:57 +09:00
altair823
cee39a90aa feat(ollama): index 부팅 + IPC + preload + types (v0.2.3.1)
- index.ts: SettingsService.load() 후 endpoint/model 결정 (settings > env > default)
- IPC: inbox:loadOllamaSettings + inbox:saveOllamaSettings
  - save: 임시 provider 로 healthCheck 통과 시에만 영속화 + holder.replace
  - 기존 in-flight generate 는 abort?.() (optional method)
- preload + InboxApi shared types

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 23:36:46 +09:00
altair823
d1f36250e7 fix(ollama): InferenceProvider — abort?: () => void optional 추가 (T3 review)
T3 가 ProviderHolder 를 InferenceProvider 로 추상화. 단 IPC handler 가
holder.get().abort() 호출 예정 — interface 에 method 가 없으면 typecheck 실패.

abort 는 in-flight generate 중단용이라 모든 provider 가 지원할 필요는 없음
→ optional method 로 추가. caller 는 holder.get().abort?.() 패턴 사용.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 23:34:17 +09:00
altair823
9fef2edb6e feat(ollama): ProviderHolder + AiWorker/HealthChecker refactor (v0.2.3.1)
- ProviderHolder: mutable holder + listeners, indirection layer
- AiWorker: constructor InferenceProvider → ProviderHolder
  this.provider.x → this.holder.get().x 전환
- HealthChecker: 동일 패턴
- src/main/index.ts: provider 를 ProviderHolder 로 감싸서 생성
- 기존 AiWorker / HealthChecker 테스트의 constructor 호출에 ProviderHolder wrap
- 단위 +2 cases (ProviderHolder)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 23:32:20 +09:00
altair823
c77c30be83 feat(ollama): LocalOllamaProvider — abort() + AbortController instance field (v0.2.3.1)
- abortController 가 method-local 에서 private instance field 로 이동
- public abort() 메서드 — 외부에서 in-flight generate 강제 중단
- ProviderHolder.replace() 시 호출되어 endpoint 변경 즉시 반영
- 단위 +2 cases (abort cancellation, model 파라미터)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 23:26:48 +09:00
altair823
de895b8fec feat(settings): SettingsService — JSON 영속화 + zod 검증 (v0.2.3.1)
- `<profileDir>/settings.json` atomic write (temp + rename)
- 손상 JSON / 파일 없음 → 빈 객체 fallback (no throw)
- in-memory cache (load 1회 file read)
- zod .strict() schema for ollama { endpoint: URL, model: string }
- 단위 +6 cases

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 23:23:32 +09:00
altair823
71ec79ae19 docs(ollama-settings): v0.2.3.1 plan — 7 tasks TDD + 10 단위 cases
T1 SettingsService (JSON 영속화 + zod, +6 cases)
T2 LocalOllamaProvider abort + model param (+2 cases)
T3 ProviderHolder + AiWorker/HealthChecker refactor (+2 cases)
T4 index 부팅 + IPC + preload + types
T5 OllamaSettingsModal + App.tsx + OllamaBanner 링크
T6 트레이 메뉴 "Ollama 설정..."
T7 Closure (version 0.2.3 → 0.2.3.1 + gates)

총 신규 단위 +10. 단위 403 → 413.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 23:21:00 +09:00
altair823
97ca119b55 docs(ollama-settings): v0.2.3.1 spec — in-app endpoint/model 설정
mini-brainstorm 3개 결정:
- Q1=B: Endpoint + Model 둘 다 포함
- Q2=A: Freetext input (dropdown 은 v0.2.4 영역)
- Q3=B: JSON file (`<profileDir>/settings.json`, migration v4 회피)

자명 결정 (질문 없이 패턴):
- precedence: settings > env > default
- in-flight: AbortController abort + provider re-create
- UI: 트레이 + OllamaBanner 진입점, React modal
- validation: save 전 healthCheck

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 23:17:23 +09:00
altair823
b259734aa0 docs(backlog): v0.2.4 backlog memory → repo 이동
v0.2.3 cut 7항목 동안 final reviewer + PR review 에서 발견된 minor/nit
중 의도적 deferred 38건 누적. 기존엔 user-level memory 에만 있어
사용자가 직접 보거나 편집 어려움 → repo 안으로 lift.

dogfood 1주 soak 동안 user 가 직접 prune / 우선순위 표시 / 새 항목 추가
가능. v0.2.4 brainstorm 진입 시 본 doc 가 1차 backlog reference.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:18:07 +09:00
altair823
5fc694c57b hotfix(build): publish: null 추가 — Mac 빌드 시 updateInfoBuilder crash 회피
PR #20 직후 Mac arm64 dist 시도 중 발견:
- Cannot detect repository by .git/config 경고 (3회)
- ⨯ Cannot read properties of null (reading 'channel')
  at computeChannelNames (updateInfoBuilder.ts:47:74)

원인: electron-builder 가 auto-update 메타파일 (latest-mac.yml) 생성 시
publish config 또는 git remote 에서 채널 정보 추론 실패 → null 접근 crash.
DMG 자체는 빌드 성공 (dist/Inkling-0.2.3-arm64.dmg) — 후처리 단계 crash.

Fix: build.publish = null 명시 — auto-update 메커니즘 미사용 (개인 dogfood)
이라 latest-mac.yml / latest.yml 생성 단계 skip. Windows 빌드도 동일 경고
3회 떴는데 이번 fix 로 함께 사라짐.

검증: npm run dist:dir on Windows → "Cannot detect repository" 경고 사라짐, 정상 빌드.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 16:15:47 +09:00
4e1f60cb7d Merge pull request 'hotfix(build): npm run dist Mac arm64 cross-platform 지원' (#20) from hotfix/arm-mac-build into main
Reviewed-on: #20
2026-05-02 07:06:23 +00:00
altair823
8cdffb2143 hotfix(build): npm run dist 가 Mac arm64 에서도 동작하도록 cross-platform
- dist / dist:dir 에서 --win --x64 제거 → electron-builder host-default
  (Windows 에선 win-x64, Mac 에선 mac-arm64 자동 선택)
- 명시적 강제 variant 추가: dist:win, dist:mac
- build.mac 블록 추가:
  - target: dmg / arch: arm64
  - category: productivity
  - identity: null (개인 dogfood, codesign skip)

검증:
- typecheck 0
- 단위 403/403
- npm run dist:dir on Windows: platform=win32 arch=x64 (회귀 X)

Mac arm64 빌드 시 첫 실행 시 "Apple 이 검증할 수 없음" 경고 → 우클릭 → 열기 (codesign 미적용 의도).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 16:04:20 +09:00
altair823
5d0f87c5fb chore(release): v0.2.3 — package.json + lock 버전 bump
v0.2.3 cut 7/7 완료 (PR #13/#14/#15/#16/#17/#18/#19) 후 binary 빌드.

빌드 결과: dist/Inkling Setup 0.2.3.exe (103.8MB, NSIS x64)
gates: typecheck 0 / 단위 403 / e2e 1

다음: dogfood 머신 핸드오프 → ≥1주 soak → telemetry export → v0.2.4 brainstorm

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 14:03:41 +09:00
cb29ef6f89 Merge pull request 'feat(recall): #6 리마인드 1 spike — RecallBanner + telemetry (v0.2.3 7/7 final)' (#19) from feat/v023-recall-spike into main
Reviewed-on: #19
2026-05-02 04:52:46 +00:00
altair823
61b6fa6c1f fix(recall): PR review round 1 — i1 race + m1~m4 + n2 (#6 v0.2.3)
- i1 (Important): RecallBanner shownIds → useRef (state setState 트리거 race 차단)
  store 의 recallShownIds 필드 제거 (dead — useRef 가 대체)
- m1 (Minor): snoozeRecall candidate-null race 코멘트 (의도적 emit skip 명시)
- m2 (Minor): dismissRecallNote 후 recallSnoozeUntilMs = null clear
- m3 (Minor): CaptureService.markRecallOpened 의 dead local 'before' inline check 로 제거
- m4 (Minor): RecallBanner title 빈 케이스 fallback '(제목 없음)'
- n2 (Nit): NoteCard id load-bearing 의미 1줄 코멘트

skip: n1 (KST 4번째 inline duplicate — 프로젝트 전반 패턴, v0.2.4 nextKstMidnightMs 통합),
      n3 (ipcMain.on vs handle — 다른 IPC 와 패턴 일관)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 13:38:52 +09:00
altair823
348e9ee402 chore(recall): #6 closure — strategy.md 갱신 + roadmap mark + 게이트 검증
- strategy.md §2.3 (오늘 회상 surface) / §4.3 (F4 측정 인프라) / §8 (banner stack) 갱신
- typecheck 0 / 단위 403 / e2e 1
- v0.2.3 7/7 — 모든 cut 완료. 다음: v0.2.3 binary 빌드

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 13:30:49 +09:00
altair823
646fe7a7ab feat(recall): RecallBanner + App.tsx mount + NoteCard id (#6 v0.2.3)
- RecallBanner: 노트 제목 + N일 전 + 3 버튼 (열어보기/다음에/더 이상)
- 첫 렌더 시 emitRecallShown (recallShownIds Set 으로 per-session 1회 제약)
- snoozeUntilMs 만료 setTimeout (ExpiryBanner 패턴)
- 위치: ExpiryBanner 다음 (banner stack 끝)
- NoteCard 외곽 div 에 id="note-${note.id}" — "열어보기" scrollIntoView target
- 컬러 테마: 파랑 (#e8f0fe / #4a7ec0) — 다른 banner (적/황/적) 와 구별

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 13:28:58 +09:00
altair823
f4e1af83fe feat(recall): renderer store — recallCandidate + 4 actions (#6 v0.2.3)
- recallCandidate, recallSnoozeUntilMs, recallShownIds (Set) state
- loadInitial / refreshMeta 가 listRecallCandidate Promise.all 합류
- loadRecallCandidate / openRecall / dismissRecallNote / snoozeRecall actions
- snoozeRecall: KST 다음 자정 (snoozeExpired 패턴 일관) + emitRecallSnoozed
- openRecall / dismissRecallNote: API 호출 후 다음 후보 fetch
- 신규 store.recall.test.ts +3 cases

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 13:25:49 +09:00
altair823
20394bf2a3 feat(recall): IPC + preload + InboxApi — 5 channels (#6 v0.2.3)
- ipcMain.handle: list/markOpened/dismiss/emitShown/emitSnoozed
- preload inboxApi: 5 entries (ipcRenderer.invoke)
- shared/types InboxApi: 5 method signatures

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 13:22:16 +09:00
altair823
0c59ce3715 feat(recall): CaptureService — 5 methods (list/open/dismiss/shown/snoozed) (#6 v0.2.3)
- listRecallCandidate(): repo.findRecallCandidate 위임
- markRecallOpened(id): last_recalled_at 갱신 + recall_opened emit
- dismissRecall(id): recall_dismissed_at 갱신 + recall_dismissed emit
- emitRecallShown(id): ageDays 계산 + recall_shown emit
- emitRecallSnoozed(id): recall_snoozed emit
- private computeAgeDays(note): last_recalled_at ?? created_at 기준 일수
- 단위 +4 cases

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 13:20:44 +09:00
altair823
59cfb711cd feat(recall): telemetryStats + EmitInput — recall 누적 + 열림율 + 평균 ageDays (#6 v0.2.3)
- DailyRow +4 cols (recall_shown/opened/dismissed/snoozed)
- accumulators + 4 branches + recallAgeDaysSum
- table 컬럼 +4
- summary lines: "- 회상 추천: shown N / opened O / dismissed D / snoozed S (열림율 X%)"
                 "- 회상 평균 ageDays: avg"
- TelemetryService.EmitInput union 15 → 19
- 단위 +2 cases

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 13:17:49 +09:00
altair823
b94e68238c feat(recall): telemetryEvents — recall_shown/opened/dismissed/snoozed zod schemas (#6 v0.2.3)
- RecallShownPayload { noteId, ageDays: int>=0 } .strict()
- recall_opened/dismissed/snoozed → NoteIdPayload 재사용
- TelemetryEventSchema union 15 → 19
- 단위 +3 cases (recall_shown valid, extra field 거부, opened/dismissed/snoozed valid)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 13:13:49 +09:00
altair823
0eb2e6282f feat(recall): NoteRepository — findRecallCandidate + markRecallOpened + dismissRecall (#6 v0.2.3)
- findRecallCandidate(): 7일+ 안 본 + 30일+ dismiss 만료 + ai='done' + 마감 안 임박 + LIMIT 1
- markRecallOpened(id, now): last_recalled_at 갱신
- dismissRecall(id, now): recall_dismissed_at 갱신
- KST 보정 SQL date('now','+9 hours')
- 단위 +5 cases (empty/recent/old/dismiss expiry/exclude variants)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 13:11:14 +09:00
altair823
746671059e docs(recall): #6 plan — 8 tasks TDD + 17 단위 cases (v0.2.3)
8 task TDD plan:
T1 NoteRepository (find/markOpened/dismiss, +5 cases)
T2 telemetryEvents (recall_shown 4 union members, +3 cases)
T3 telemetryStats + EmitInput union 19 (+2 cases)
T4 CaptureService (5 methods, +4 cases)
T5 IPC + preload + types (5 channels)
T6 Renderer store (recallCandidate + 4 actions, +3 cases)
T7 RecallBanner + App.tsx + NoteCard id
T8 closure (strategy.md + roadmap + gates)

총 신규 단위 +17. 단위 386 → 403 예상.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 13:08:32 +09:00
altair823
e6494b8778 docs(recall): #6 spec — RecallBanner + 4 telemetry events (v0.2.3)
mini-brainstorm 2개 결정:
- Q1=A: snooze in-memory (KST 다음 자정, ExpiryBanner 패턴 일관)
- Q2=B: ageDays = last_recalled_at ?? created_at 기준

자명 결정:
- Banner 위치: ExpiryBanner 다음 (stack 끝)
- 0건 시 null return
- "열어보기" 동작: scrollIntoView (NoteCard 항상 expanded)
- scroll target: id="note-${id}" (ref 시스템 복잡도 회피)

핵심 invariants 6개 + privacy invariant + tests 17개 약속.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 13:03:58 +09:00
3c9326d6ec Merge pull request 'feat(tag-vocab): #3 태그 vocab — prompt + telemetry (v0.2.3 6/7)' (#18) from feat/v023-tag-vocab into main
Reviewed-on: #18
2026-05-02 03:53:01 +00:00
altair823
d8621d55e0 fix(tag-vocab): PR review round 1 — i1 dedup + m2 test gap (#3 v0.2.3)
- i1 (Important): AiWorker per-tag emit 루프에 res.tags Set dedup
  AI 가 같은 태그 중복 응답 시 hit count 2번 emit 되던 통계 왜곡 수정
  + 테스트 1개 (중복 태그 1 hit + 1 miss 검증)
- m2 (Minor): NoteRepository.getTopUsedTags LIMIT-then-filter 테스트 갭
  + 테스트 1개 (limit=3 + 한글 1 + kebab 2 → 결과 length=2 lock-in)

skip: m1 (per-tag serial await — ai_succeeded 패턴 일관),
      n1 (prompt 빈 줄 cosmetic), n2 (tagId positive — AUTOINCREMENT 1+)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 12:49:36 +09:00
altair823
ff07738b02 chore(tag-vocab): #3 closure — gates verified + roadmap mark complete
- typecheck 0 / 단위 384 / e2e 1
- v0.2.3 6/7 (#3 태그 vocab 머지)
- 다음: #6 리마인드 spike (마지막 항목)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 12:37:30 +09:00
altair823
727eeb1919 fix(tag-vocab): T7 review nit 2건 — test 코드 ergonomics (#3 v0.2.3)
- nit1: tag_vocab_hit/miss 테스트 payload cast dedupe (한 번에 typed 바인딩)
- nit2: { kind: string; payload: unknown } 반복을 EmittedEvent 타입 alias 로 hoist

skip: Minor1 (serial await — ai_succeeded 와 패턴 일관), Nit3 (magic number VOCAB_TOP_N — v0.2.4 backlog), Nit4 (한국어 코멘트 — 기존 코드와 일관)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 12:36:16 +09:00
altair823
3e0f710c70 feat(tag-vocab): AiWorker — vocab fetch + per-tag hit/miss emit (#3 v0.2.3)
- processJob 가 generate 직전 repo.getTopUsedTags(20) fetch
- provider.generate 에 vocab 전달 (LocalOllamaProvider 가 prompt 에 주입)
- ai_succeeded emit 후 per-tag 분류 → tag_vocab_hit/miss emit
  - hit: vocabSet.has + getTagIdByName lookup → { tagId, vocabSize }
  - miss: { vocabSize }
- AiTelemetryEmitter union 4종 (ai_succeeded/ai_failed/tag_vocab_hit/tag_vocab_miss)
- 단위 +4 cases (vocab passthrough, hit+miss, vocab=[] all miss, per-tag emit count)
- collectingTelemetry mock → AiTelemetryEmitter 타입 적용 (typecheck 통과)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 12:33:16 +09:00
altair823
26f1db5626 feat(tag-vocab): TelemetryService EmitInput +tag_vocab_hit/miss + 테스트 narrowing 확장 (#3 v0.2.3)
- EmitInput union 13 → 15
- narrowing guards (noteId 없는 kind 분기) 에 tag_vocab_hit/miss 추가

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 12:29:24 +09:00
altair823
973cb1d08d feat(tag-vocab): telemetryStats — hit/miss 누적 + summary 적중률 (#3 v0.2.3)
- DailyRow +2 cols (tag_vocab_hit, tag_vocab_miss)
- accumulators + branches
- table 컬럼 +2
- summary "- 태그 vocab: hit/miss = N/M (적중률 X%)" 또는 "(데이터 없음)"
- 단위 +2 cases

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 12:27:22 +09:00
altair823
b81fc82621 feat(tag-vocab): telemetryEvents — tag_vocab_hit/miss zod schemas (#3 v0.2.3)
- TagVocabHitPayload { tagId: int>0, vocabSize: int>=0 } .strict()
- TagVocabMissPayload { vocabSize: int>=0 } .strict()
- TelemetryEventSchema union 13 → 15
- 단위 +3 cases (hit accept, miss accept, hit extra field 거부)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 12:23:31 +09:00
altair823
daa8507364 feat(tag-vocab): InferenceProvider.vocab + LocalOllamaProvider 전달 (#3 v0.2.3)
- GenerateInput.vocab?: string[] (optional, 미전달 시 빈 배열 처리)
- LocalOllamaProvider.generate 가 input.vocab ?? [] 를 buildPrompt 4th 인자로
- 단위 +1 case (vocab → prompt body)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 12:21:24 +09:00
altair823
896b374f56 fix(tag-vocab): T2 review minor/nit 2건 (#3 v0.2.3)
- M1: prompt.test.ts test 4 변수명 candidateIdx → headerIdx (실제 anchor 가 'Today's date' 헤더)
- N1: prompt.ts return 직전 self-delimited block 컨벤션 1줄 코멘트

skip: N2 (PROMPT_VERSION 테스트 redundancy nit — harmless guard), N3 (vocab dedup/normalize — Task 1 caller 책임)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 12:19:39 +09:00
altair823
134d59ddb4 feat(tag-vocab): prompt.ts — PROMPT_VERSION 4 + vocab parameter (#3 v0.2.3)
- PROMPT_VERSION 3 → 4 (marker bump, retry 트리거 X)
- buildPrompt 4번째 param vocab: string[] = []
- vocab.length > 0 시 "Existing vocabulary tags" + "Prefer reusing" 라인 추가
- vocab=[] 시 라인 자체 생략 (Q3=B 결정)
- 단위 +4 cases (신규 prompt.test.ts)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 12:17:17 +09:00
altair823
e2b16d44d7 fix(tag-vocab): T1 review minor/nit 4건 일괄 (#3 v0.2.3)
- M1: getTopUsedTags 의 LIMIT-then-filter 의미 docstring 명시
- M2: AI+user source 통합 테스트 강화 — 카운트 차이로 정렬 검증 (toContain 만으론 약함)
  updateUserAiFields 는 tags REPLACE 방식 (DELETE+reinsert) 이므로
  fallback 패턴 사용: 3개 노트 각 1태그, AI/user 혼합으로 design=2 > meeting=1 검증
- N1: SQL "COUNT(*) c" → "COUNT(*) AS c" (countFailed 패턴과 일관)
- N2: kebab-case regex 모듈 상수 KEBAB_CASE_RE 로 hoist

skip: N3 (test 헬퍼 — verbosity 경미), N4 (it 블록 분리 — 코드베이스 패턴 유지)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 12:14:53 +09:00
altair823
df8a53aec1 feat(tag-vocab): NoteRepository — getTopUsedTags + getTagIdByName (#3 v0.2.3)
- getTopUsedTags(limit=20): top-N (count desc, id asc) + kebab-case 필터 + deleted_at 제외
- getTagIdByName(name): COLLATE NOCASE lookup
- AI+user source 통합 카운트 (Q1=C 결정)
- 단위 +7 cases (정렬, 필터, source 통합, deleted 제외, limit, getTagIdByName)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 12:10:36 +09:00
altair823
853ca39c0d docs(tag-vocab): #3 plan — 8 tasks TDD + 21 단위 cases (v0.2.3)
8 task TDD plan:
T1 NoteRepository (getTopUsedTags + getTagIdByName, +7 cases)
T2 prompt.ts (PROMPT_VERSION 4 + vocab param, +4 cases, 신규 prompt.test.ts)
T3 InferenceProvider + LocalOllamaProvider (vocab passthrough, +1 case)
T4 telemetryEvents (zod schemas, +3 cases)
T5 telemetryStats (누적 + summary, +2 cases)
T6 TelemetryService EmitInput + narrowing 확장
T7 AiWorker (vocab fetch + per-tag emit, +4 cases)
T8 closure (gates + roadmap)

총 신규 단위 +21 (spec budget 19 + 2 surplus). 단위 363 → 382 (±5) 예상.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 12:07:28 +09:00
altair823
8206462ee4 docs(tag-vocab): #3 spec — vocab pool/telemetry/prompt 강도/재처리 결정 (v0.2.3)
mini-brainstorm 4개 결정:
- Q1=C: vocab pool = AI+user 통합 + kebab-case 필터
- Q2=A: telemetry emit 단위 = 태그별 (per-tag hit/miss)
- Q3=B: prompt 강도 = "Prefer" (우선, MUST 아님)
- Q4=A: 기존 노트 재처리 = 자연 진화 (X)

핵심 invariant 6개 + privacy invariant + tests ≥19개 약속.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 12:02:06 +09:00
dbbec38079 Merge pull request 'feat(retry): #2 AI retry 수동 trigger (v0.2.3 5/7)' (#17) from feat/v023-ai-retry into main
Reviewed-on: #17
2026-05-02 02:44:42 +00:00
altair823
8f56814186 fix(retry): review round 1 — minor/nit 4건 일괄 (#2 v0.2.3)
m1 — NoteRepository.test.ts 에 retryAllFailed OR IGNORE race-safe 회귀
가드 1 case 추가. failed 노트인데 pending_jobs row 가 이미 존재하는
비정상 race 상태 시뮬레이션 → INSERT OR IGNORE 라 duplicate 안 됨,
기존 attempts/next_run_at 보존.

m2 — store.retryAllFailed 의 r.count 무시 의도 주석 1줄.
단일 process (Electron) 환경 + 모든 ai_status='failed' 가 retry 대상이라
사용자 시점 카운트는 0 reset 가 정확.

n1 — AiWorker unreachableBackoffStep increment 명료화.
Math.min(..., length-1) → 명시적 if 가드 (step < length-1) 로 cap 도달 시
no-op 의도 가시화. 동작 동일.

n2 — AiWorker.processJob 의 max 의미 주석 1줄. unreachable/timeout 분기는
attempt -= 1 로 인덱스 stay 라 max 무관 — future maintainer 위해 명시.

n3 (FailedBanner inline style) 은 v0.2.4 backlog (banner theme cleanup).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 03:47:08 +09:00
altair823
95bbe9cd22 chore(retry): #2 closure — gates verified + roadmap mark complete
- typecheck 0 errors
- 단위 362/362 (T1~T7 누적 18 신규)
- e2e 1/1
- roadmap §3 #2 ✓ 완료 마커

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 03:37:34 +09:00
altair823
e4a0be15ae feat(retry): tray '지금 AI 처리' 9th callback + main wiring (#2 v0.2.3)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 03:36:44 +09:00
altair823
406a5e61f0 feat(retry): FailedBanner + App.tsx mount (#2 v0.2.3) 2026-05-02 03:34:09 +09:00
altair823
3ebd3bc9a5 feat(retry): store retryAllFailed action + failedCount (#2 v0.2.3) 2026-05-02 03:32:01 +09:00
altair823
6e5f3703d7 feat(retry): CaptureService.retryAllFailed + IPC 2 channels (#2 v0.2.3)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 03:28:11 +09:00
altair823
12c267aabd feat(retry): telemetry ai_retry_manual + stats AI 수동 재시도 (#2 v0.2.3)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 03:24:31 +09:00
altair823
449eb76683 feat(retry): AiWorker unreachable/timeout 무한 retry — 15분 cap (#2 v0.2.3) 2026-05-02 03:19:43 +09:00
altair823
2e3f0edffd feat(retry): NoteRepository — findFailedIds/countFailed/retryAllFailed/setNextRunAt (#2 v0.2.3) 2026-05-02 03:15:05 +09:00
altair823
821db4001d docs(plan): v0.2.3 #2 AI retry / 수동 trigger 구현 계획
8 task TDD 분할 + 단위 ≥ 18개 (spec §6 의 17개 충족 + 1 over):
- T1 NoteRepository — findFailedIds/countFailed/retryAllFailed/setNextRunAt
- T2 AiWorker unreachable/timeout 무한 retry (15분 cap)
- T3 telemetry ai_retry_manual + stats
- T4 CaptureService.retryAllFailed + IPC 2채널
- T5 store retryAllFailed action + failedCount
- T6 FailedBanner + App.tsx mount
- T7 tray '지금 AI 처리' 9th callback
- T8 closure

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 03:08:06 +09:00
altair823
f50cabcc62 docs(spec): v0.2.3 #2 AI retry / 수동 trigger design
mini-brainstorm 결정 3개:
- Q1=A unreachable backoff cap 15분 (30s→60s→120s→240s→480s→900s)
- Q2=A timeout 도 unreachable 동일 (무한 retry, attempts 증가 안 함)
- Q3=A retry-all 만 (per-note 버튼 v0.2.4)

AiWorker unreachable/timeout 무한 retry + schema/other max 3 유지
+ retryAllFailed atomic + FailedBanner (Inbox stack 4번째)
+ tray '지금 AI 처리 (실패 N건)' 9th callback
+ ai_retry_manual telemetry.

roadmap §3 #2 deviation 1건 (timeout) 의식적 — v0.2.4 dogfood 데이터로 영구 hang 케이스 식별 후 가다듬기.

T1-T8 작업 순서 + 단위 ≥ 17개.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 03:00:49 +09:00
37292f1a53 Merge pull request 'feat(ollama): #1 Ollama 회복 polling (v0.2.3 4/7)' (#16) from feat/v023-ollama into main
Reviewed-on: #16
2026-05-01 17:08:44 +00:00
altair823
b6c307148d chore: remove accidental review artifacts (.pr-16-*.json) 2026-05-02 02:04:43 +09:00
altair823
a94c7578b7 fix(ollama): review round 1 — minor/nit 7건 일괄 (#1 v0.2.3)
m1 — HealthChecker.last={ok:true} sentinel 의도 주석 (line 17).
  첫 healthy=ok=true 면 transition 으로 인식 안 됨, ok=false 면 unreachable
  transition 으로 정상 인식. telemetry 누락 0.

m2 — runOnce in-flight guard 추가. polling 첫 호출이 늦게 끝나는 동안
  setInterval 가 두 번째 호출 시작하면 같은 promise 반환. healthCheck 가
  idempotent HTTP 라 race 안전하지만, 이중 onUpdate/telemetry emit 회피.

m3 — main.ts before-quit 핸들러 통합. trayInterval cleanup 별도 핸들러
  (line 349) 제거하고 health.stop() 핸들러 안에 흡수. 모든 cleanup 한 곳.

n1 — OllamaBanner 재확인 button 의 onClick 에 .catch 추가.
  recheckOllama Promise rejection 시 console.warn (silent swallow 회피).

n2 — App.tsx useEffect deps array 의도 주석 1줄. onOllamaStatus 콜백이
  useInbox.setState 직접 호출 — store reference 안정적이라 deps 불필요.

n3 — HealthChecker idempotent test 강화. <=2 → ===2 (정확).
  두 timer 등록되면 4 (각 timer 마다 즉시+1s) 가 됨.

n4 — runOnce 의 manual emit 이 healthCheck *전에* fire 인 의도 주석.
  provider 실패 시에도 manual 카운트 1:1 보장.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 02:04:25 +09:00
altair823
d8f4ae5f6b chore(ollama): #1 closure — gates verified + roadmap mark complete
- typecheck 0 errors
- 단위 344/344 (T1~T7 누적 17 신규)
- e2e 1/1
- roadmap §3 #1 ✓ 완료 마커

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 01:47:54 +09:00
altair823
cdf2e4bc47 feat(ollama): OllamaBanner 재확인 button (#1 v0.2.3) 2026-05-02 01:46:18 +09:00
altair823
557960ff5a feat(ollama): tray 'Ollama 재확인' 메뉴 + 8th callback (#1 v0.2.3)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 01:44:11 +09:00
altair823
c78f3af3a6 feat(ollama): InboxApi + preload + store recheckOllama + onOllamaStatus subscriber (#1 v0.2.3)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 01:41:04 +09:00
altair823
410a6f494b feat(ollama): IPC inbox:ollamaRecheck + pushOllamaStatus helper (#1 v0.2.3) 2026-05-02 01:37:47 +09:00
altair823
e30e436051 feat(ollama): main wiring — health.start + before-quit stop (#1 v0.2.3) 2026-05-02 01:34:33 +09:00
altair823
a68ffe0aeb feat(ollama): telemetry 3 events — unreachable/recovered/recheck_manual (#1 v0.2.3)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 01:30:26 +09:00
altair823
12681e431c feat(ollama): HealthChecker.start/stop + delta + onTelemetry hook (#1 v0.2.3) 2026-05-02 01:25:26 +09:00
altair823
f299926f58 docs(plan): v0.2.3 #1 Ollama 회복 polling 구현 계획
8 task TDD 분할 + 단위 ≥ 17개 (spec §6 의 12개 충족 + 5 over):
- T1 HealthChecker.start/stop + delta + onTelemetry hook
- T2 telemetry 3 events + stats.md (downtime 평균 / unreachable 빈도 / recheck 사용량)
- T3 main wiring — health.start + before-quit stop + onUpdate→push
- T4 IPC inbox:ollamaRecheck + pushOllamaStatus helper
- T5 InboxApi + preload + store recheckOllama + onOllamaStatus subscriber
- T6 tray 'Ollama 재확인' 메뉴 + 8th callback
- T7 OllamaBanner 재확인 button
- T8 closure

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 01:22:06 +09:00
altair823
050e7f08f1 docs(spec): #1 ollama — runOnce({manual}) + ollama_recheck_manual via hook
§2.1 / §3.2 / §11 보강 — IPC handler 가 직접 telemetry.emit 안 하고
HealthChecker.runOnce({ manual: true }) 호출 → onTelemetry hook 으로
ollama_recheck_manual 발화. 단위 테스트 가능 (HealthChecker 레이어).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 01:18:28 +09:00
altair823
f36b9ecb5b docs(spec): v0.2.3 #1 Ollama 회복 polling design
mini-brainstorm 결과 3개 결정:
- Q1=A polling 주기 60s
- Q2=A 절대 중단 안 함
- Q3=A constant (no backoff)

HealthChecker.start/stop + delta-only onUpdate + 3 telemetry events
(ollama_unreachable / ollama_recovered / ollama_recheck_manual)
+ main → renderer push (ollama:status) + manual recheck (banner + tray).

T1-T7 작업 순서 + 단위 ≥ 12개.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 01:16:14 +09:00
da7455b25f Merge pull request 'feat(expiry): #5 만료 추천 (v0.2.3 3/7)' (#15) from feat/v023-expiry into main
Reviewed-on: #15
2026-05-01 15:52:37 +00:00
altair823
d672ec3afa fix(expiry): review round 1 — minor/nit 6건 일괄 (#5 v0.2.3)
m1 — spec §5.3 dialog 버튼 순서를 impl 패턴 (`['옮기기','취소'], defaultId=1, cancelId=1`) 으로 보정. project 의 permanentDelete/emptyTrash 와 일관 (위험 액션은 default focus = 취소).

m2 — telemetryEvents.test.ts 에 `expired_batch_trash` 의 extra-field 회귀 가드 추가. `expired_banner_shown` 과 대칭 (privacy invariant).

m3 — ExpiryBanner.InnerProps.candidates 타입을 narrow subset → `Note` 로 통일. v0.2.4 에서 Note 타입 진화 시 silent drift 방지.

m4 — onTrash 의 `void trashExpiredBatch(ids)` → `.catch((e) => console.warn(...))` 로 Promise rejection 가시화. (project-wide error toast 도입은 v0.2.4 backlog 유지)

n1 — 24h+ 앱 켜둔 상태에서 snooze 자동 만료. `setTimeout(snoozeUntilMs - now)` 으로 자정 KST 시점에 force re-render. (refreshMeta trigger 의존 제거)

n2 — CaptureService.listExpired 의 dedup signature reset on empty 의도 주석 1줄. future maintainer 위해.

n3 (`as any[]`) 은 repo 전체 hydrate 패턴 — 단독 fix 시 inconsistency. v0.2.4 backlog #22 로 합산.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 00:47:58 +09:00
altair823
8a96d5279d chore(expiry): #5 closure — gates verified + roadmap mark complete
- typecheck 0 errors
- 단위 326/326 (T1~T7 누적 26 신규)
- e2e 1/1
- spec §3 IPC 채널명 inbox:trashBatch → inbox:trashExpiredBatch 보정 (의미 명확화)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 00:25:15 +09:00
altair823
7cbbd4dc97 feat(expiry): ExpiryBanner component + App.tsx mount (#5 v0.2.3) 2026-05-02 00:22:38 +09:00
altair823
b7205597db feat(expiry): zustand store extension — expiredCandidates + snooze (#5 v0.2.3)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 00:18:11 +09:00
altair823
749235f65d feat(expiry): CaptureService listExpired/trashExpiredBatch + IPC 2 channels (#5 v0.2.3) 2026-05-02 00:13:49 +09:00
altair823
f76ca06d9e feat(expiry): telemetry 2 events — expired_banner_shown / expired_batch_trash (#5 v0.2.3) 2026-05-02 00:08:44 +09:00
altair823
fec80361dd feat(expiry): NoteRepository.trashBatch atomic (#5 v0.2.3) 2026-05-02 00:01:03 +09:00
altair823
00423fb235 feat(expiry): NoteRepository.findExpiredCandidates (#5 v0.2.3) 2026-05-01 23:57:53 +09:00
altair823
0a9dab4a7f feat(expiry): KST util — todayInKstString + nextKstMidnightMs (#5 v0.2.3) 2026-05-01 23:53:20 +09:00
altair823
a5e6859ac9 docs(plan): v0.2.3 #5 만료 추천 구현 계획
8 task TDD 분할 + 단위 26개 (spec §8 의 16개 충족 + 6 over):
- T1 KST util (todayInKstString + nextKstMidnightMs)
- T2 NoteRepository.findExpiredCandidates
- T3 NoteRepository.trashBatch (atomic)
- T4 telemetry 2 events + stats.md 만료 trash ratio
- T5 CaptureService listExpired/trashExpiredBatch + IPC 2채널 + preload
- T6 zustand store 확장
- T7 ExpiryBanner 컴포넌트 + App.tsx mount
- T8 closure

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:30:48 +09:00
altair823
c45e613b31 docs(spec): #5 expiry — move dedup to main, keep IPC at 2 channels
§6.2 의 expired_banner_shown signature dedup 위치를 zustand store(renderer)
→ CaptureService(main) 로 변경. 결과: 신규 IPC 채널 1개 추가 회피.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:25:12 +09:00
altair823
4c2769fd82 docs(spec): v0.2.3 #5 만료 추천 design
mini-brainstorm 결과 5개 결정 박힘:
- Q1=B due_date_edited_by_user 필터 없음 (AI + 수동 모두)
- Q2=A 만료만 (D-7 임박 v0.2.4)
- Q3=C unchecked default + 전체선택 토글 (데이터 안전)
- Q4=B PendingBanner 아래 (system → progress → actionable)
- Q5=A 후보 0건 / snooze 시 collapse (PendingBanner 패턴)

T1-T10 작업 순서 + 단위 ≥ 16개 + IPC 2채널 + telemetry 2이벤트.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:22:38 +09:00
df60c5a5b2 Merge pull request 'feat(trash): #4 휴지통 + migration v3 (v0.2.3 2/7)' (#14) from feat/v023-trash into main
Reviewed-on: #14
2026-05-01 14:06:21 +00:00
altair823
87b6d71628 fix(trash): add repo.countTrashed() — fix UI 200-cap mismatch (review 회차 1)
PR #14 회차 1 review actionable — `inbox:trashCount` 와 `emptyTrash` dialog
가 `listTrashed({limit:200})` 로 카운트를 도출하면서 (a) hot path 에서 N rows
+ tags/media JOIN hydrate 비효율 (b) trash > 200 시 dialog message 가
실제 SQL DELETE 동작과 mismatch ('200개 영구 삭제합니다' 표시 vs 500개
실제 삭제) 발생.

NoteRepository.countTrashed() — `SELECT COUNT(*) FROM notes WHERE deleted_at
IS NOT NULL` 단일 쿼리. hydrate 없이 정확한 카운트만 반환. 두 IPC 핸들러를
이 메서드 호출로 교체.

테스트: 3 신규 단위 테스트 (0 trash / 부분 trash / 200 cap 초과 범위)
292 → 295 (+3). typecheck 0 errors.

deferrable (v0.2.4 backlog 그대로): AiWorker race guard 강화, restore self-guard,
limit 200 매직 넘버 상수화.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 22:45:11 +09:00
altair823
2ac4d648c1 chore(trash): #4 closure — gates verified + roadmap mark complete
v0.2.3 #4 휴지통 (soft delete + migration v3) 종료.

게이트:
- typecheck: 0 errors
- 단위 테스트: 245 → 292 (+47, schema/repo/AiWorker/CaptureService/Continuity/
  ImportService/ExportService/store 전반)
- e2e smoke: 1/1 PASS

기능:
- migration v3 — deleted_at + last_recalled_at + recall_dismissed_at
- NoteRepository: trash/restore/permanentDelete/emptyTrash/listTrashed
- AiWorker.processJob deletedAt 가드
- CaptureService 4 신규 메서드 + idempotency 가드 + 4 telemetry emit
- telemetryStats: 4 신규 컬럼 + 휴지통 회수율 ratio
- ImportService: deletedAt 보존 + skip-merge 정책
- ExportService 회귀 가드 (T5 listAll filter 자동 동작)
- IPC 5 신규 채널 + native dialog confirm
- zustand store: showTrash/trashNotes/trashCount + 5 actions
- App.tsx 헤더 탭 + 휴지통 view + bulk 비우기
- NoteCard mode='trash' read-only

기타 fix (cross-task):
- ContinuityService streak 가 trash 노트 무시
- getPendingCount 가 trash 노트 무시 (drift 방지)
- MediaGc intentional non-filter 주석 (restore 시 media 보존)

deferred (v0.2.4 backlog):
- exhaustiveness check on stats union
- restore 시 pending_jobs 재생성 정책
- inbox:trashCount cap 200 → repo.countTrashed()
- inbox:delete 채널 rename
- 탭 ARIA role="tab" 정정
- per-note 영구 삭제 텔레메트리 기반 retire 검토

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 21:53:26 +09:00
altair823
03bca3ed59 feat(trash): Inbox 탭 toggle + 휴지통 view + NoteCard mode prop (#4 v0.2.3) 2026-05-01 21:48:15 +09:00
altair823
df85b88424 fix(trash): T13 review — trashCount clobber guard + restoreNote test (review I1+I2+M5)
- I1: trashCount 가 upsertNote 안에서 항상 trashNotes.length 로 덮어써져
  server 값 (refreshMeta) 손상. showTrash=true (trashNotes cache-loaded)
  일 때만 local recompute.
- I2: restoreNote 의 "fallback for missed event" 주석 부정확 — main 은
  trash/restore 시 pushNoteUpdated 안 보냄. 자가 갱신이 primary mechanism.
  주석 정정.
- M5: restoreNote 테스트가 IPC 호출만 검증, 노트 이동 미검증. trashNotes
  → notes 라우팅 + deletedAt=null 어설션 추가. + I1 회귀 가드 테스트 신규.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 21:43:59 +09:00
altair823
99cdc346d2 feat(trash): zustand store — showTrash/trashNotes/trashCount + 5 actions (#4 v0.2.3)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 21:38:30 +09:00
altair823
3e4ad6ec91 refactor(trash): emptyTrash IPC dedup query (review T12 nit) 2026-05-01 21:35:31 +09:00
altair823
dd74aec884 feat(trash): IPC 5 channels + native dialog confirm + InboxApi extension (#4 v0.2.3) 2026-05-01 21:32:22 +09:00
altair823
cdceb609e6 test(trash): ExportService excludes trashed notes (regression guard, #4 v0.2.3) 2026-05-01 21:28:12 +09:00
altair823
6f0d032ff1 refactor(trash): import skip-merge reuses trash() for pending_jobs invariant (review T10 minor #1) 2026-05-01 21:26:54 +09:00
altair823
a5f23b925e feat(trash): ImportService deletedAt preservation + skip-merge policy (#4 v0.2.3) 2026-05-01 21:23:23 +09:00
altair823
468ea90d6c fix(trash): idempotency guards on delete/restore/permanent (review T9 important #1+#2)
review T9 flagged 2 service-layer defenses:

#1: deleteNote/restoreNote/permanentDeleteNote 의 idempotency. 이미 trash 인
노트를 trash 하거나, 이미 active 인 노트를 restore 하거나, 존재하지 않는 노트를
permanentDelete 시 telemetry 가 spurious 하게 emit → restore/trash ratio (T8)
오염. findById 가드로 의미 없는 emit skip.

#2: permanentDeleteNote 의 disk cleanup unguarded. store.deleteNoteDirectory
실패 시 (Windows file-lock 등) telemetry 가 영영 emit 안 되고 IPC 호출자가
이미 성공한 작업에 에러 propagate. emptyTrash 와 동일하게 try/catch 로 감싸
best-effort. orphan dir 은 future janitor 가 정리.

Tests: 12/12 still pass. typecheck 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 21:20:03 +09:00
altair823
b19ea6423a feat(trash): CaptureService soft-delete + restore/permanent/empty + 4 emits (#4 v0.2.3)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 21:16:26 +09:00
altair823
e6a945cad4 feat(trash): telemetryStats 4 new counters + 휴지통 회수율 ratio (#4 v0.2.3) 2026-05-01 21:11:15 +09:00
altair823
c5329f1ccc refactor(test): replace as-cast with discriminant narrowing (review T7 I-1) 2026-05-01 21:09:04 +09:00
altair823
284bfcbdd1 feat(trash): telemetry 4 new kinds (trash/restore/permanent_delete/empty_trash) (#4 v0.2.3)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 21:05:06 +09:00
altair823
78c10e8817 feat(trash): AiWorker.processJob deletedAt guard (#4 v0.2.3) 2026-05-01 21:00:09 +09:00
altair823
3c780a7464 fix(trash): close active-query invariant leaks (review T5 important #1+#2)
T5 reviewer identified 2 reads outside NoteRepository that were missing the
'WHERE deleted_at IS NULL' filter, breaking the silent invariant beyond the
3 originally-listed methods.

- ContinuityService.get() now excludes trashed notes from streak / weekCount
  / lastNoteAt / recovery-toast math. A trashed note no longer counts toward
  weekly streak (regression: streak felt fake after trash).
- NoteRepository.getPendingCount() now excludes trashed-but-still-pending
  notes. trash() removes the pending_jobs row but leaves notes.ai_status='pending';
  the count would have drifted upward as users trashed pending notes.
- MediaGc.run() gets an inline comment documenting why it intentionally does
  NOT filter — trashed notes still own their media until permanentDelete /
  emptyTrash. Removing here would defeat restore.

Also: migrations.due_date.test.ts had 2 brittle assertions
(latestVersion()===2, user_version===2) that broke with v3. Migration-system
version assertions belong in migrations.test.ts (already covered there);
m002-specific test keeps the due_date column assertion which is version-stable.

Tests: 245 → 265 (+20). typecheck 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 20:58:18 +09:00
altair823
2203bcf65b feat(trash): active queries exclude deleted_at IS NOT NULL (#4 v0.2.3)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 20:53:03 +09:00
altair823
70a69f0ae3 refactor(trash): emptyTrash uses DELETE...RETURNING (review T4 S1) 2026-05-01 20:51:06 +09:00
altair823
11703b976e feat(trash): NoteRepository.permanentDelete/emptyTrash/listTrashed (#4 v0.2.3)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 20:47:05 +09:00
altair823
bf49b8351e feat(trash): NoteRepository.restore (#4 v0.2.3) 2026-05-01 20:42:42 +09:00
altair823
13da554461 feat(trash): NoteRepository.trash with pending_jobs cleanup (#4 v0.2.3) 2026-05-01 20:38:17 +09:00
altair823
3797e6c4f3 docs(m003): add dormant-columns rationale comment (review T1 minor #1) 2026-05-01 20:36:27 +09:00
altair823
5bcfd26bfd feat(trash): migration v3 + Note type extension (#4 v0.2.3)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 20:32:52 +09:00
altair823
b93185edd5 docs(plan): #4 휴지통 구현 계획 (v0.2.3 2/7)
15 task TDD plan — migration v3, Note type extension, NoteRepository 신규
4메서드 + active query 일괄 변경, AiWorker deletedAt guard, telemetry 4 new
kinds + stats.md 회수율 ratio, CaptureService soft delete + 3 신규 메서드
+ 4 emit, ImportService deletedAt 보존, ExportService 회귀 가드, IPC 5 신규
채널 + native dialog confirm, zustand store + 5 actions, Inbox 탭 toggle +
NoteCard mode prop, 게이트 + closure marker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 20:16:26 +09:00
altair823
61e277f36c docs(spec): #4 휴지통 (soft delete + migration v3) 설계
v0.2.3 두 번째 항목의 mini-brainstorm 결과 lock.

UI=A (Inbox 탭 toggle), 필터=A (명시적 WHERE deleted_at IS NULL),
AiWorker race=C (pending_jobs cleanup + processJob 가드),
액션=B (per-card 영구 삭제 추가 — IPC 4채널 → 5채널, telemetry 3 → 4 events),
confirm/정렬/카드차이 모두 A.

self-review 후 ExportService/ImportService 충돌 정책 ambiguity 명시화.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 20:04:47 +09:00
6f8ae75ff7 Merge pull request 'feat(telemetry): #7 telemetry skeleton (v0.2.3 1/7)' (#13) from feat/v023-telemetry into main
Reviewed-on: #13
2026-05-01 10:37:55 +00:00
altair823
7e8e2b598d fix(telemetry): 회차 1 review 반영 — attempts 의미 통일 + DI 우회 제거 + 매직 슬라이스 제거
PR #13 회차 1 리뷰의 actionable 1건 + suggestion 3건 반영.

- `AiWorker` 의 `attempts` 필드가 success/failure 경로에서 비대칭 의미 (0-index vs count) 였던 문제. 둘 다 `attempt + 1` (실제 시도 횟수, 1-based) 로 통일. stats markdown 의 평균/분포 해석이 일관됨.
- `Date.now()` 직접 호출이 `opts.now` DI 를 우회하던 두 곳을 `this.now().getTime()` 으로 교체. 추후 durationMs 분포 테스트 작성 가능.
- `TelemetryService.emit` 의 `this.now()` 두 번 호출을 한 번 캐시로 통합. KST 자정 경계에서 ts 와 파일명 일자 불일치 가능성 제거.
- `readAllRecent` 의 `n.slice(7, 17)` 매직 슬라이스를 정규식 capture 그룹으로 교체. prefix 변경 시 한 곳만 수정.

테스트: AiWorker 성공 케이스의 `attempts: 0` → `attempts: 1` 갱신.
게이트: typecheck 0 errors, 245/245 unit tests pass.

Deferred (v0.2.4 backlog): 'aborted' user-cancel false-positive, tray menu submenu 분리.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 18:41:26 +09:00
altair823
5c97397cbe chore(telemetry): #7 closure — gate verification + .catch consistency + spec fix
- Add .catch(...) to telemetry.cleanupOldFiles fire-and-forget for consistency
  with backup.runDaily pattern (M1 from T10 code review).
- Mark Roadmap §3 #7 as completed (✓).
- Correct spec: tray:exportTelemetry was never an IPC channel — tray callbacks
  run in main process directly. Replace with "트레이 콜백 (main 내부)".

Closes v0.2.3 task 1 of 7. Next task: #4 휴지통 (migration v3).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 17:37:00 +09:00
altair823
fe24ff577f feat(telemetry): wire TelemetryService + tray export (#7 v0.2.3)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 17:30:54 +09:00
altair823
dca6aed44e docs(tray): restore F4-C identity-signal intent comment
The T9 full-file replacement accidentally dropped the inline comment
documenting why the count label is conditional on _todayCount > 0
(F4-C UX rationale). No behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 17:28:04 +09:00
altair823
4213745dc7 feat(telemetry): tray menu '사용 로그 내보내기...' (#7 v0.2.3) 2026-05-01 17:25:52 +09:00
altair823
01447ddaad feat(telemetry): AiWorker emits ai_succeeded/ai_failed with reason (#7 v0.2.3) 2026-05-01 17:21:08 +09:00
altair823
f0cef95d3f feat(telemetry): CaptureService emits capture event (#7 v0.2.3) 2026-05-01 17:15:24 +09:00
altair823
36a5c67ed6 feat(telemetry): exportTo writes events.jsonl + stats.md (#7 v0.2.3) 2026-05-01 17:08:34 +09:00
altair823
2036c687d2 test(telemetry): add KST regression test for near-midnight UTC bucketing
Original 'counts events per KST day' test used UTC times that bucket
identically under both KST and naive UTC slice — would not catch a regression
where kstDate was replaced with ev.ts.slice(0,10). Add an explicit
near-midnight case (2026-05-01T15:30Z = 2026-05-02 00:30 KST) that fails
under naive UTC and passes under correct KST conversion.

6 tests pass (was 5).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 17:06:29 +09:00
altair823
9a066ed807 feat(telemetry): telemetryStats.aggregateStats (#7 v0.2.3) 2026-05-01 17:03:31 +09:00
altair823
729a3f9c47 feat(telemetry): readAllRecent with malformed-line tolerance (#7 v0.2.3) 2026-05-01 16:58:45 +09:00
altair823
0501bd1762 feat(telemetry): cleanupOldFiles with 14-day KST retention (#7 v0.2.3) 2026-05-01 16:54:36 +09:00
altair823
50b6d05bcb fix(telemetry): silent-fs-error test exercises the actual code path
Earlier test used '/proc/0/...' as the unwritable dir. On Windows this
resolved to 'C:\proc\0\...' and mkdir({recursive: true}) silently created
it — the silent code path was never exercised, plus filesystem side-effect
leaked outside the test tmpdir.

Replace with a path that points to an existing file (mkdir on a file path
fails on every platform). Also add a companion test that confirms silent
is opt-in: without {silent: true}, the same fs failure DOES throw.

7 tests pass (was 6).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 16:52:11 +09:00
altair823
93e278b241 feat(telemetry): TelemetryService.emit with KST rotation (#7 v0.2.3) 2026-05-01 14:18:59 +09:00
altair823
0a0ef11327 feat(telemetry): event schema + privacy invariant (#7 v0.2.3) 2026-05-01 14:14:19 +09:00
altair823
358cada017 docs(plan): #7 telemetry skeleton 구현 계획 (v0.2.3 1/7)
11 task TDD plan — events schema/privacy invariant, JSONL emit/rotation,
14d cleanup, readAllRecent, stats aggregator, exportTo(folder),
CaptureService/AiWorker hooks, tray menu, index.ts wiring, gates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 14:02:48 +09:00
altair823
22a25cc622 docs(spec): v0.2.3 dogfood feedback roadmap (7 items, single cut)
v0.2.2 dogfood 7항목 (#7 telemetry 신설 + #1~#6) 단일 cut 로드맵.
데이터 안전 우선 (C 채택), schema migration v3 3컬럼 한 묶음 (B),
trash↔backup/export B 정책, #6 = 1 spike 흡수.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 13:56:16 +09:00
112 changed files with 30361 additions and 504 deletions

4
.gitignore vendored
View File

@@ -7,3 +7,7 @@ dist/
coverage/
playwright-report/
test-results/
# build/ 산출물 — icon.{ico,icns,png} 만 커밋, 중간 산출물은 무시
build/icons/
build/icon-source.png

24
assets/icon.svg Normal file
View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" role="img" aria-label="Inkling">
<!-- 배경 -->
<rect width="1024" height="1024" rx="192" fill="#1a6b6e"/>
<!-- 화살표 marker -->
<defs>
<marker id="head" markerWidth="14" markerHeight="14" refX="6" refY="7" orient="auto" markerUnits="strokeWidth">
<path d="M 0 0 L 12 7 L 0 14 Z" fill="#5fdbc8"/>
</marker>
</defs>
<!-- sync 호 1개 (270도, 시작점 + 끝 화살표) -->
<path d="M 512 132 A 380 380 0 1 1 132 512"
stroke="#5fdbc8" stroke-width="36" stroke-linecap="round" fill="none"
marker-end="url(#head)"/>
<circle cx="512" cy="132" r="28" fill="#5fdbc8"/>
<!-- 노트 1장 (단일 흰색 paper) -->
<rect x="332" y="332" width="360" height="360" rx="32" fill="#ffffff"/>
<!-- 텍스트 라인 2개 -->
<rect x="376" y="436" width="272" height="28" rx="14" fill="#1a6b6e"/>
<rect x="376" y="510" width="200" height="28" rx="14" fill="#1a6b6e"/>
</svg>

After

Width:  |  Height:  |  Size: 988 B

BIN
build/icon.icns Normal file

Binary file not shown.

BIN
build/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 353 KiB

BIN
build/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,31 @@
# Dogfood 피드백 수집
**작성일:** 2026-04-25 (open)
**작성일:** 2026-04-25 (open, ongoing — v0.4 slice → v0.2.x cuts 까지 누적)
**최종 갱신:** 2026-05-05 (v0.2.6 정식 cut 후, F8~F13 dogfood 발견 추가)
**저자:** 김태현 (dlsrks0734@gmail.com)
**문서 성격:** 슬라이스 v0.4 dogfood 중 발견된 본인 피드백 수집·정제하는 living document. 각 항목은 후속 spec 으로 승격될 후보다. 정식 spec 이 분기될 만큼 성숙하면 별도 파일로 추출하고 여기엔 링크만 남긴다.
**문서 성격:** v0.4 slice → v0.2.x cuts 동안 누적된 본인 dogfood 피드백 수집·정제 living document. 각 항목은 후속 spec/cut 으로 승격될 후보다.
**선행 문서:**
- `docs/superpowers/specs/2026-04-24-inkling-vertical-slice-design.md` v0.4 (슬라이스 본문)
- `docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md` (v0.2.3 7항목 cut 로드맵)
- `docs/superpowers/strategy/strategy.md` (심리학 전략)
- `docs/superpowers/strategy/dogfood-strategy.md` (dogfood 운영안 — 본 문서와 의존 없음)
- `docs/superpowers/strategy/dogfood-strategy.md` (dogfood 운영안)
- `docs/superpowers/v024-backlog.md` (PR review deferred + dogfood 발견 누적 backlog, 잔여 24건)
---
## 진척 흐름 요약 (2026-05-05 기준)
본 문서가 작성된 v0.4 slice 시점 이후 흐름:
| 시점 | Cut | 주요 피드백 / 발견 |
|---|---|---|
| 2026-05-01 | v0.2.3 7항목 roadmap (PR #13~#19) | v0.2.2 dogfood 발견 6건 (`memory/project_v022_feedback.md`) → telemetry skeleton + 휴지통 + 만료 추천 + Ollama 회복 + AI retry + 태그 vocab + RecallBanner |
| 2026-05-04 | v0.2.3.1 (PR #21) → v0.2.4 (PR #22, semver 우회) | **F8 11434 reserved**, **F9 multi-instance**, **F10 버전 정보 부재** |
| 2026-05-05 | v0.2.5 critical hotfix (PR #23) | **F11 single-instance lock 부재** (critical, 해결됨) |
| 2026-05-05 | v0.2.6 정식 cut (PR #24) | **F12 autostart 풀림**, **F13 hidden-start race** (해결됨) |
**v0.2.7 brainstorm 트리거**: dogfood ≥1주 soak 후 telemetry export + 신규 피드백 + 잔여 backlog 24건 일괄 triage.
---
@@ -925,6 +943,441 @@ slice §1.3 종료 조건 ("크래시 0회") 와 별개로, **"데이터 손실
---
## F8. Windows 11434 포트 OS-level reserved (🚀 promoted → v0.2.3.1/v0.2.4)
**진행 상태:** 🚀 promoted → PR #21 (in-app Ollama 설정), v0.2.4 release.
**발견:** 2026-05-04 dogfood 시작 시도 직후, Windows 머신.
### 관찰
v0.2.3 binary 설치 후 첫 실행 시 OllamaBanner 가 "unreachable" 상태 지속. `curl http://localhost:11434/api/tags` 응답 없음. 하지만 `ollama app.exe` 프로세스는 떠있음. 진단 결과:
- Ollama server 가 11434 bind 무한 실패: `Error: listen tcp 127.0.0.1:11434: bind: An attempt was made to access a socket in a way forbidden by its access permissions.`
- `netsh int ipv4 show excludedportrange protocol=tcp` → 1124211941 범위 (8개 100-port 블록) reserved
- 원인: WSL2 (Ubuntu Running) + Hyper-V `hns` (Host Network Service) 가 부팅 시 동적 NAT 용 포트 블록 할당 — 11434 가 포함됨
### 제안 방향
**v0.2.3.1 → v0.2.4 cut**: env var 의존 제거, in-app endpoint/model 설정 UI 추가. 트레이 메뉴 + OllamaBanner "설정" 링크 → modal → JSON 영속화 (`<profileDir>/settings.json`).
추가: 사용자가 빈 포트 (11942 등) 로 Ollama 재시작 후 in-app 에서 endpoint 변경 — 시스템 설정 변경 (excludedportrange 등) 없이 dogfood 즉시 unblock.
### 결정 대기
- (해결됨) endpoint + model 둘 다 in-app 설정 가능해야 하는가 → Q1=B 결정 (v0.2.3.1 spec)
- (해결됨) JSON 영속화 vs SQLite 새 테이블 → Q3=B JSON (migration 회피)
- (해결됨) Settings precedence → settings > env > default
### 가설·측정
| # | 가설 | 측정 |
|---|------|------|
| H8.1 | dogfood 사용자 의 ≥30% 가 비기본 endpoint 사용 (LAN 또는 다른 포트) | telemetry `ollama_settings_changed` (count-only, v0.2.7) |
| H8.2 | 11434 reserved 가 다른 머신에도 발생 | LAN 머신 / 동료 dogfood |
### 범위
- **In:** SettingsService + ProviderHolder + OllamaSettingsModal + IPC + 트레이 메뉴 항목
- **Out:** Multi-provider abstraction (OpenAI 등), Settings dropdown UI (v0.2.4 freetext)
### 영향
- **Spec:** `docs/superpowers/specs/2026-05-04-v0231-ollama-settings-design.md`
- **Backlog 잔여:** #39 (`ollama_unreachable.reason` 의 endpoint URL PII 우회 노출 — telemetry 하드닝 v0.2.7)
---
## F9. 앱 다중 인스턴스 spawn — SQLite race 위험 (🚀 promoted → v0.2.5 critical hotfix)
**진행 상태:** 🚀 promoted → PR #23 critical hotfix, v0.2.5 release.
**발견:** 2026-05-05 dogfood 도중. v0.2.4 설치 후 단축키 / 트레이 / 시작메뉴 클릭 반복 시.
### 관찰
앱 아이콘 클릭 시마다 새 process spawn. 트레이 아이콘 여러 개 누적. 작업관리자 에서 `Inkling.exe` process 다중 확인. 잠재 영향:
- SQLite WAL 동시 write → DB 손상 가능
- AiWorker 중복 polling → 같은 pending_jobs 두 process race
- HealthChecker 중복 polling → Ollama 부하 + telemetry 이중 emit
- `settings.json` atomic write 의 temp/rename race
### 제안 방향
**Critical hotfix**`app.requestSingleInstanceLock()` 호출 + `second-instance` event handler 로 기존 inbox 창 restore + show + focus.
### 결정 대기
(해결됨) — Electron 표준 패턴 직접 적용.
### 범위
- **In:** `src/main/index.ts` 진입점 (whenReady 전 lock 획득). 단일 21줄 변경.
- **Out:** None — cross-platform 자동 동작.
### 영향
- v0.2.5 critical hotfix release (`Inkling Setup 0.2.5.exe`)
- **Backlog 잔여:** #46 hidden-start race (NSIS 직후 사용자 클릭 + autostart `--hidden` 동시 시도) — v0.2.6 (PR #24) 에서 `additionalData` 패턴으로 해결 완료.
### 비고
dogfood 발견 → 4시간 안에 hotfix release. 잠재 데이터 손실급 버그라 patch increment (v0.2.5) 사용. v0.2.4 사용자 즉시 업그레이드 권장.
---
## F10. 버전 / 빌드 정보 표시 surface 부재 (🚀 promoted → v0.2.4)
**진행 상태:** 🚀 promoted → PR #22, v0.2.4 release.
**발견:** 2026-05-04 dogfood 도중. 머신 간 버전 일치 검증 시도 시.
### 관찰
설치된 Inkling 의 버전을 UI 어디서도 확인 못함. 트레이 메뉴 / Inbox 푸터 / About 모달 모두 부재. 핸드오프 후 다른 머신 (Mac vs Windows) 에서 같은 버전인지 검증 path 없음. issue report 시 첨부할 디버그 정보 (Electron / Node / OS / profileDir) 도 노출 안 됨.
### 제안 방향
트레이 메뉴 마지막 항목 (종료 직전) "Inkling 정보..." → native `dialog.showMessageBox`. 표시:
- 버전 (`app.getVersion()`)
- Electron / Node 버전
- OS (`platform()` + `release()`)
- 데이터 위치 (`app.getPath('userData')`)
버튼: 확인 / 데이터 위치 열기 (`shell.openPath`) / 정보 복사 (`clipboard.writeText`).
### 영향
- v0.2.4 cut 동봉. 별도 spec 없음 (단순 추가).
- **Backlog 잔여:** Inbox footer 형태 검토 (v0.2.7 — 항상 보이는 작은 버전 라벨)
---
## F11. Single-instance lock 부재 (🚀 promoted)
**진행 상태:** 🚀 promoted → F9 와 동일 cut (v0.2.5 hotfix).
F9 와 root cause 동일. 원래 별개 발견이었지만 fix 가 같음 — 본 항목은 F9 의 부분으로 흡수.
---
## F12. 윈도우 자동 실행 옵션이 재시작 후 풀려있는 버그 (🚀 promoted — v0.2.7 deeper fix)
**진행 상태:** 🚀 promoted → docs/superpowers/specs/2026-05-06-v027-cross-platform-design.md §9. v0.2.7 진단 노출 (withArgs/noArgs/registry/execPath + mismatch 경고 + 재등록 버튼) 적용 — 설정 페이지 "자동 실행" 섹션에서 사용자가 진단 정보 직접 확인 + 1-클릭 재등록 가능.
**발견:** 2026-05-05 dogfood 도중. autostart 토글 후 재시작.
### 관찰
트레이 메뉴 "윈도우 시작 시 자동 실행" 체크 → 종료 → 재실행 → 체크박스 풀려있음. `app.setLoginItemSettings({ openAtLogin, args: ['--hidden'] })` 호출 후 다음 부팅 시 `app.getLoginItemSettings()``openAtLogin=false` 반환.
### 제안 방향 (현 cut: 진단 fallback)
추정 원인:
- (a) Windows registry path mismatch (NSIS 설치 위치 변경 / 버전 업데이트 시 새 디렉터리)
- (b) Electron `setLoginItemSettings` Windows 구현 의 path canonicalization
- (c) `args: ['--hidden']` 와 actual launch args 비교 mismatch — `getLoginItemSettings()` 가 args 없이 호출되면 mismatch
**v0.2.6 적용**:
- `getLoginItemSettings({ args: ['--hidden'] })` 명시 → 트레이 checkbox checked 상태 가 실제 args 와 정합 비교
- `autostart.state` 진단 로그 (withArgs vs noArgs 비교 + executableWillLaunchAtLogin) — dogfood 에서 실제 동작 로그 수집
### 결정 대기
dogfood 후 `autostart.state` 로그 분석:
- args 명시 fix 만으로 충분 → close
- 여전히 mismatch → registry 직접 inspect (HKCU\Software\Microsoft\Windows\CurrentVersion\Run\inkling) → exe path 확인 → executable path canonicalization 검토
### 영향
- dogfood UX 핵심 마찰 — autostart 가 핸드오프 시 매번 수동 재설정 필요. 자동 실행 의도 자체가 dogfood "잊지 않고 매일 사용" 목적인데 깨짐.
- v0.2.6 진단 fallback 로 일단 시도, **v0.2.7 deeper fix 영역**.
---
## F13. PR review 발견: restoreNote production path dead code (🚀 promoted → v0.2.6 round 1 Critical fix)
**진행 상태:** 🚀 promoted → PR #24 round 1 Critical fix, v0.2.6 release.
**발견:** 2026-05-05 PR #24 round 1 reviewer (Claude code-reviewer subagent).
### 관찰
v0.2.6 cut 의 B1 (#10 restoreNote) 작업이 새 메서드 `NoteRepository.restoreNote(id)` 추가했지만 — **production path 가 옛 메서드 `repo.restore()` 호출 중**. CaptureService.restoreNote (line 93) 가 `this.repo.restore(noteId)` 호출 → `deleted_at = NULL` 만 set, `ai_status='failed'` 그대로 + `pending_jobs` 미재생성.
즉 새 메서드는 unit test 만 검증, 실제 사용자가 trash → AI fail → restore 흐름 시 영구 fail 상태 그대로.
### Fix
**Round 1 Critical fix (commit `a991008`)**:
- `CaptureService.restoreNote``repo.restoreNote` 호출 (production path 활성화)
- `before``ai_status``failed` 또는 `pending` 이면 `worker.enqueue(noteId)` 추가 호출 — in-memory AiWorker queue 갱신 (다음 app start 까지 대기 X)
테스트 +2 (CaptureService 의 enqueue 호출 검증).
### 비고
Round 1 reviewer 의 발견 가치 = **production path 와 unit test 가 갈라진 dead code 패턴**. 비슷한 패턴 다른 곳도 점검 가치. v0.2.7 brainstorm 시 grep 으로 "신 메서드 vs 옛 메서드 둘 다 존재 + 호출자 옛 사용" 패턴 검사.
---
## F14. macOS dock 클릭 시 hidden 창 재현 안 됨 (🚀 promoted → v0.2.7)
**진행 상태:** 🚀 promoted → docs/superpowers/specs/2026-05-06-v027-cross-platform-design.md §8. 2026-05-05 v0.2.6 dogfood 발견.
**발견:** 2026-05-05 김태현 macOS dogfood 도중.
### 관찰
- macOS 에서 inbox 창 닫음 (빨간 신호등) → 창 사라짐.
- Dock 에는 Inkling 아이콘이 "켜져 있음" 표시 (점) 으로 그대로.
- Dock 아이콘 클릭 → **창이 안 뜸**. 클릭이 그냥 무시됨.
- 트레이 메뉴 "보관한 메모 보기" 로는 정상 호출 가능 (별 이슈 없음).
- 앱을 완전히 종료 후 재실행 해야만 창이 다시 뜸.
### 제안 방향
추정 원인 (코드 확인 결과):
- [src/main/windows/inboxWindow.ts:39-44](src/main/windows/inboxWindow.ts#L39-L44) — close 이벤트에서 `app.isQuitting === false``e.preventDefault()` + `inboxWindow.hide()`. 즉 창은 살아있고 단지 hidden.
- [src/main/index.ts:411-413](src/main/index.ts#L411-L413) — `app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createInboxWindow(); })`. 하지만 hidden 창은 destroy 되지 않았으므로 `getAllWindows().length === 1`. 분기 안 타고 no-op.
따라서 fix 방향은 단순:
```ts
app.on('activate', () => {
const win = getInboxWindow();
if (win && !win.isDestroyed()) {
win.show();
win.focus();
} else {
createInboxWindow();
}
});
```
(B4 #46 second-instance 핸들러의 windows-restore 로직과 동일 패턴 — 거기서는 macOS dock 이 아닌 Windows multi-launch 경로지만 의도가 같음.)
### 결정 대기
- macOS 에서 트레이 (menubar extra) 도 사용 중인가? Windows 에서 트레이가 핵심 surface 인 반면 macOS 에서는 dock 클릭이 자연스러운 1차 surface.
- 현재 macOS 빌드는 "기본 동작 유지" 수준 (메모리 우선순위 정책). dogfood 1차 타깃이 Windows 라 macOS 마찰을 어디까지 잡을지 cost-benefit 결정 필요.
### 가설·측정
- Fix 후 macOS dogfood 시 dock click → 창 즉시 등장 (1 클릭, < 100ms 체감).
- regression 측정: Windows 에서 close→tray 메뉴 경로 영향 없음 (activate 이벤트는 macOS 전용 흐름이라 Windows 무관해야 함).
### 범위
- 1-file edit (`src/main/index.ts` activate 핸들러 5줄).
- 단위 테스트 추가 어려움 (BrowserWindow + activate 이벤트 mocking 비용 ↑) — manual dogfood 검증 으로 충분.
- v0.2.7 telemetry export 와 함께 묶어 cut.
### 영향
- macOS dogfood UX 마찰. "앱이 떠 있는데 안 보임 + 클릭 무반응" 은 사용자가 "고장났나?" 의심하게 만드는 첫 신호 — 신뢰 손상 큼.
- Windows 는 트레이 중심이라 동일 증상 없음. macOS 단독 이슈.
- v0.2.7 우선순위: F12 autostart 풀림 (Windows) 와 동급의 "잊지 않고 매일 쓰는" 흐름 마찰.
---
## F15. Linux 앱 빌드 (🚀 promoted → v0.2.7 / CLI 부분 거부)
**진행 상태:** 🚀 promoted (Linux 빌드만) → `docs/superpowers/specs/2026-05-06-v027-cross-platform-design.md` §5. CLI 부분은 ❌ rejected (DB/Ollama 동시접근 race + monorepo 부담 대비 본인 dogfood metric 직접 기여 적음).
**발견:** 2026-05-05 dogfood 외부 피드백 + 본인 후속 결정.
### 관찰
- Linux 터미널 사용자가 "Inkling 을 터미널에서 쓰고 싶다" 요청.
- 본인 macOS 흐름에서도 GUI quick-capture 띄우는 비용 > `ink "한 줄"` 한 방 비용.
- 현재 빌드 타겟: Windows NSIS x64 + macOS DMG arm64. Linux 타겟 자체 없음 → Linux 사용자에게 입구 자체 부재.
### 결정된 방향 (narrow scope)
**범위 (v0.2.7 narrow):**
- CLI **capture-only**: `inkling capture "한 줄"` 만 노출. search/recall/restore/edit 등 v0.3+ 로 deferred.
- 플랫폼: **Linux + macOS** (Windows CLI 제외 — 본인 Windows 는 GUI 흐름).
- Linux 앱 빌드 추가: **AppImage + deb** 1차. rpm 은 외부 demand 후.
**아키텍처 — race-free 설계:**
- CLI = SQLite WAL writer 로 `notes` INSERT (raw_text) + `pending_jobs` INSERT (note_id) 만 수행.
- AI 워커는 **Electron 메인 단일 보유**. CLI 는 워커 안 띄움.
- Electron 떠있으면 → 워커가 즉시 pending_jobs poll → AI 처리.
- Electron 안 떠있으면 → 다음 launch 시 처리 (기존 pending_jobs 큐 모델 그대로).
- 동일 DB 파일 동시 접근은 SQLite WAL 로 안전 (1 writer + N readers). Single-instance lock 은 **Electron 인스턴스 끼리만** 적용 — CLI 는 짧은 transaction 만 수행 후 종료, lock 우회 무관.
**플랫폼별 build 비용:**
| 타겟 | 빌드 비용 | 커버 |
|---|---|---|
| AppImage | 매우 낮음 (Mac/Linux cross-build 가능) | 모든 desktop linux 배포판 (1-file portable) |
| deb | 낮음 (Mac brew `dpkg` `fakeroot`) | Debian/Ubuntu/Mint |
| rpm | 중간 (Linux 호스트 또는 Docker) — **defer** | Fedora/RHEL/openSUSE |
**CLI 패키징:**
- pkg / nexe 로 Node CLI → 단일 바이너리. Linux + macOS 각각 빌드.
- 또는 Electron 앱 안에 `inkling` 심볼릭 링크 노출 (Mac: `/Applications/Inkling.app/Contents/MacOS/inkling`, Linux: AppImage 내 path 노출 / deb postinst hook).
- 결정 대기 — v0.2.7 brainstorm 때 spike.
### 결정 대기 (v0.2.7 brainstorm)
- CLI 패키징: 별도 단일 바이너리 vs Electron 앱 번들 내 노출 — install UX 영향 큼.
- AI 처리 시점 표기: capture 시 즉시 stdout 으로 "queued" 만 회신할지, `--wait` 플래그로 처리 완료 대기할지.
- AppImage + deb 동시 vs 단계적.
### 가설·측정
- 본인 macOS 흐름에서 quick-capture window 호출 vs `ink "..."` time-to-capture 비교. 후자가 의미 있게 빠르면 본인 Aha metric (7일/3일) 에도 기여 가능.
- 외부 Linux 사용자 1주 soak 후 capture 발생 빈도 (telemetry) — 채널 살아있는지 확인.
### 범위
- Linux 앱 빌드 + AppImage + deb: 1~2일.
- CLI capture-only (Mac/Linux 양쪽): 2~3일.
- better-sqlite3 prebuild 매트릭스 확장 (linux-x64): 부수 작업, 0.5일.
- 합 약 4~5일 spike — v0.2.7 cut 1개로 가능.
### 영향
- 본인 macOS dogfood capture 가속 (직접 효용).
- Linux 사용자 입구 제공 (외부 확장 첫걸음).
- v0.4 slice 자기 종료 조건 (본인 2주 완주) 와 병행 가능 — capture 만 노출하므로 v0.4 핵심 흐름 (recall, AI tag, due) 변경 없음.
- Risk: Linux native ABI 첫 진입 — better-sqlite3 prebuild 가 linux-x64 에서 깔끔히 떨어질지 dogfood 검증 필요.
---
## F16. 트레이 의존도 ↓ + 별도 설정 페이지 (🚀 promoted → v0.2.7)
**진행 상태:** 🚀 promoted → docs/superpowers/specs/2026-05-06-v027-cross-platform-design.md §6, §7. 트레이 = quick-capture / 보관함 / 설정 / 종료 4-항목 minimum 으로 강등 + inbox window 안 SettingsPage (AI provider · 자동 실행 · 백업/복원 · 내보내기/가져오기 · 텔레메트리 · 정보 6 섹션) 신설.
**발견:** 2026-05-05 dogfood 후속 결정.
### 관찰
- 현재 [src/main/tray.ts](src/main/tray.ts) 가 **13개 항목** 보유: 보관함 열기, 한 줄 적기, 백업, 내보내기, 복원, 동기화, telemetry export, Ollama 재확인, AI 재처리, **Ollama 설정...**, **자동 실행 토글**, 정보, 종료.
- Windows: SysTray 잘 보임 → 핵심 surface 로 기능했음.
- **macOS**: menubar extra 로 뜨지만 사용자가 메뉴바 가득찼을 때 가려짐. dock 사용자 흐름과 분리되어 발견성 ↓.
- **Linux**: 모던 GNOME 등 일부 DE 는 system tray 자체 없음 (extension 필요). KDE/Cinnamon 은 보임.
- 결과: macOS / Linux 사용자가 **Ollama 설정 / 자동 실행 토글 등 핵심 설정에 접근 불가**.
### 결정된 방향 (정책 변화)
**1. 트레이 = 최소 surface 로 강등.**
- 잔류: 한 줄 적기 (quick capture), 보관함 열기, 종료.
- 이동: Ollama 설정, 자동 실행, 백업/복원/내보내기, telemetry export, AI 재처리, Ollama 재확인, 정보 → **설정 페이지** 또는 inbox window 안 메뉴.
**2. 별도 설정 페이지 신설 (inbox window 안 또는 별도 윈도우).**
- 예상 섹션: AI provider (Ollama endpoint/model — 기존 OllamaSettingsModal 흡수), 자동 실행, 백업/복원, 내보내기/가져오기, 텔레메트리, 정보.
- 현재 OllamaSettingsModal 은 inbox 안 modal — 그대로 활용하거나 페이지화 검토.
**3. 1차 액션 진입점 재설계.**
- macOS: dock 클릭 (F14 fix 와 동시) → inbox 창 → 메뉴 또는 톱니바퀴 → 설정.
- Linux: 앱 launcher 또는 CLI (`inkling settings` 후보) → inbox 창 → 설정.
- Windows: 트레이 우클릭 "한 줄 적기" + "보관함 열기" 만 잔류 + 트레이에서 "설정..." 한 항목 추가 (inbox 안 설정 페이지로 라우팅).
### 결정 대기 (v0.2.7 brainstorm)
- 설정 surface 형태:
- (a) inbox 안 별도 라우트 (e.g., `/settings`) — SPA 내부 페이지
- (b) 별도 BrowserWindow — 독립 윈도우
- (c) 메뉴바 (Application Menu) 사용 — macOS 표준이지만 Win/Linux 와 일관성 깨짐
- 추천 잠정: (a) — 최소 비용, 기존 inbox UI 연속성, OllamaSettingsModal 흡수 자연스러움.
- 트레이 잔류 항목: "설정..." 1줄 잔류 vs 완전 제거 (Windows 사용자 경험 시 trade-off).
- 자동 실행 토글의 실제 동작 — F12 deeper fix 가 설정 페이지 안에서 더 명확한 진단 노출 가능 (registry path 확인, args 미스매치 표시 등).
### 가설·측정
- 새 사용자 (macOS) 가 Ollama endpoint 변경 작업 완료까지 클릭 수: 현재 (트레이 메뉴에 가려짐) vs 설정 페이지 (inbox → 설정) — 후자가 의미 있게 짧으면 채택.
- 트레이 메뉴 항목별 사용 빈도 telemetry — v0.2.7 export 후 실제 어떤 항목이 자주 클릭되는지 보고 잔류 우선순위 결정.
### 범위
- 1~2일: OllamaSettingsModal 을 설정 페이지 섹션으로 흡수.
- 1~2일: 자동 실행 / 백업 / 복원 / 내보내기 등 트레이 click 핸들러를 설정 페이지 버튼으로 이동 (메인 IPC 는 그대로).
- 0.5일: 트레이 메뉴 슬림화 (3~4 항목으로).
- 합 3~5일. CLI (F15) 와 묶으면 v0.2.7 한 cut 가능.
### 영향
- **macOS / Linux 입구 정상화** — F14 (dock 클릭) + F15 (CLI 입구) 와 정합. 외부 Linux 사용자 + 본인 macOS 모두 설정 접근 가능.
- **F12 autostart 영향**: 자동 실행 토글이 설정 페이지로 이동하면 진단 정보 (registry path, args 비교 결과) 노출 가능 → deeper fix 자연스럽게 결합.
- **트레이 코드 단순화**: 13 항목 → 3~4 항목. v0.2.6 의 TrayCallbacks 객체화 (C2) 효과 가시화.
- Risk: Windows 사용자 흐름 변경 — 트레이 한 클릭으로 끝나던 동작이 inbox 열기 → 설정 → 항목 클릭 으로 늘어남. 단, 빈도 낮은 동작 (Ollama 설정 변경, 백업 등) 만 이동하고 자주 쓰는 캡처/보관함 은 트레이 잔류 → 체감 마찰 ↓ 예상.
---
## F22. NoteCard 이미지가 회색 placeholder 만 표시 (🚀 promoted → docs/superpowers/specs/2026-05-09-v028-cut-a-design.md)
**진행 상태:** 🚀 promoted → v0.2.8 Cut A. inkling-media:// custom protocol + NoteCard `<img>` + IPC inbox:open-media + OS viewer 클릭. (commit 470384b + f6bea62 + 9cdea15)
**발견:** 2026-05-09 v0.2.7 release 후 본인 dogfood. 사용자 표현: "이미지 렌더링이 제대로 되지 않는 것 같아".
### 관찰
[src/renderer/inbox/components/NoteCard.tsx:334-340](src/renderer/inbox/components/NoteCard.tsx#L334-L340):
```tsx
{local.media.length > 0 && (
<div style={{ marginTop: 10, display: 'flex', gap: 6 }}>
{local.media.map((m) => (
<div key={m.id} style={{ width: 48, height: 48, background: '#eee', borderRadius: 4 }} title={m.relPath} />
))}
</div>
)}
```
**`<img>` 가 아니라 회색 `<div>`**. 즉 capture 시 첨부한 이미지가 보관함에서 회색 48x48 사각형만 표시 — title attribute (relPath) 만 hover tooltip 으로 보임. 실제 이미지 렌더링 자체 부재.
`MediaStore``<profileDir>/media/<noteId>/<filename>` 절대 경로로 파일 보존. relPath = `media/<noteId>/<filename>` 형태. Electron renderer 에서 직접 `file://` 또는 custom protocol 로 src 매핑 필요.
### 추정 원인 (placeholder 인 이유)
- 초기 v0.4 slice 단계에 thumbnail 렌더는 후순위로 미루고 placeholder 로 둔 채 그대로 잔류.
- Electron renderer 가 raw `file://` 경로 보안 정책상 직접 접근 어려움 — custom protocol (`inkling-media://`) 또는 IPC handle 로 base64 변환 필요.
### 제안 방향
**A. Custom protocol 등록** (권장):
- main process 에서 `protocol.registerFileProtocol('inkling-media', ...)` 등록 — `<profileDir>/media/` 하위 경로를 `inkling-media://<noteId>/<filename>` 으로 매핑
- NoteCard: `<img src={`inkling-media://${m.relPath.slice(6)}`} alt="" />`
- 보안: scheme 별 allowlist + protocol handler 가 path traversal 검사
**B. IPC 로 base64 변환** (작은 이미지에 한정):
- `inboxApi.getMediaDataUrl(relPath)` → main 이 file 읽고 `data:image/png;base64,...` 반환
- renderer 에 `<img src={dataUrl} />`
- 큰 이미지 (수 MB) 시 메모리 부담
**C. file:// 직접** (Electron 특수 설정 필요):
- `webPreferences.webSecurity: false` — 보안 약화 risk. **Reject**.
### 결정 대기
- thumbnail 표시 vs 클릭 시 full-size modal — UX 선택
- 다중 이미지 (현재 capture 가 N개 첨부 가능) 의 grid layout
- 이미지 alt text — capture 시 입력 또는 AI 자동 생성 (옵션)
### 가설·측정
- 본인 dogfood: capture 시 이미지 첨부 빈도 — 현재 추정치 < 일 1건. ≥ 일 1건이면 이미지 흐름 가치 큼.
- 옵션 A 도입 후 NoteCard 클릭 시 modal full-size 사용 빈도 — UX 선택 검증.
### 범위
- A (custom protocol + thumbnail): 1-2일.
- A + click → full-size modal: + 0.5일.
- alt text AI 생성: 별도 cut.
### 영향
- 명확한 bug 수정 — 사용자 마찰 명백.
- F19 (recall) 의 시각적 단서 — 이미지 보일 때 메모 회상 ↑.
- v0.2.8 narrow scope 에 포함 가치 (1-2일 작업).
---
## (다음 항목 자리)
새 피드백 추가 시 `## F8. 짧은 제목 (🌱 raw)` 헤더로 시작. 표준 슬롯 6개 채우거나 비워둔 채 시작 가능.
새 피드백 추가 시 `## F23. 짧은 제목 (🌱 raw)` 헤더로 시작. 표준 슬롯 6개 채우거나 비워둔 채 시작 가능.
v0.2.8 release 후 dogfood ≥1주 soak 동안 새 발견 항목들 여기 누적 → v0.2.9 brainstorm 트리거.

View File

@@ -0,0 +1,352 @@
# v0.2.3 #2 AI retry / 수동 trigger 설계
**작성일:** 2026-05-01
**저자:** 김태현 (dlsrks0734@gmail.com)
**문서 성격:** v0.2.3 cut 7항목 중 5번째 항목 (#2 AI retry) 의 mini-brainstorm 결정 + design. roadmap §3 #2 의 In/Out 위에서 §8 미결정 3항목 (unreachable backoff cap / reason 분류 정밀도 / per-note retry) 결정.
**선행 문서:**
- `docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md` §3 #2, §8
- 선행 cut: #7 telemetry (PR #13), #4 trash (PR #14), #5 expiry (PR #15), #1 ollama 회복 (PR #16)
---
## 1. 결정 요약
| Q | 결정 | 근거 |
|---|------|------|
| Q1 unreachable backoff cap | **A 15분 exponential** | 30s → 60s → 120s → 240s → 480s → 900s cap. 회복 latency 짧으면서 오래 꺼지면 부하 미미. |
| Q2 timeout 분류 | **A unreachable 동일** (무한 retry) | timeout 99% 는 임시 (gemma cold start, 큰 입력). 영구 hang 케이스는 v0.2.4 dogfood 후 식별. roadmap §3 #2 In 과 deviation — 의식적. |
| Q3 per-note retry | **A retry-all 만** | UI 노이즈 회피. NoteCard 단건 버튼은 v0.2.4 dogfood 마찰 발생 시 추가. |
---
## 2. AiWorker.processJob 정책 변경
### 2.1 분기 로직
`classifyReason(err)` 결과로 분기:
```ts
const reason = classifyReason(err);
if (reason === 'unreachable' || reason === 'timeout') {
// 무한 retry 경로: attempts 증가 안 함, in-job loop 안에서 sleep + retry
const sleepMs = nextBackoffMs(this.unreachableBackoffStep);
this.unreachableBackoffStep = Math.min(this.unreachableBackoffStep + 1, 5);
this.repo.setNextRunAt(job.noteId, new Date(Date.now() + sleepMs).toISOString(), msg);
await this.sleep(sleepMs);
// for 루프의 attempt 인덱스 그대로 — 다음 try 도 같은 attempt 번호로 재시도
attempt -= 1; // for 루프의 attempt++ 상쇄
continue;
} else {
// schema / other: 기존 max 3 retry 정책 그대로
this.repo.incrementJobAttempt(job.noteId, nextRunAt, msg);
if (isLast) {
this.repo.markAiFailed(job.noteId, msg);
if (this.telemetry) {
await this.telemetry.emit({ kind: 'ai_failed', payload: { ... } }).catch(() => {});
}
this.emit(job.noteId);
return;
}
await this.sleep(this.backoffsMs[attempt + 1] ?? 0);
}
```
성공 시 `unreachableBackoffStep = 0` 으로 reset.
### 2.2 backoff schedule
```ts
private readonly UNREACHABLE_BACKOFFS_MS = [30_000, 60_000, 120_000, 240_000, 480_000, 900_000];
private nextBackoffMs(step: number): number {
return this.UNREACHABLE_BACKOFFS_MS[Math.min(step, 5)];
}
```
기존 `backoffsMs = [0, 30_000, 120_000]` 은 schema/other 전용 그대로.
### 2.3 invariants
- unreachable/timeout: `markAiFailed` 절대 호출 안 함. `ai_failed` telemetry emit 안 함.
- schema/other: 기존 동작 (max 3 후 markAiFailed + emit).
- 결과: `ai_failed.reason` 통계에는 schema/other 만 누적 (Q2 = A 의 자연 결과).
---
## 3. NoteRepository 확장
```ts
// src/main/repository/NoteRepository.ts
findFailedIds(): string[];
// SELECT id FROM notes WHERE ai_status='failed' AND deleted_at IS NULL ORDER BY updated_at DESC
countFailed(): number;
// SELECT COUNT(*) FROM notes WHERE ai_status='failed' AND deleted_at IS NULL
retryAllFailed(now: string): { ids: string[] };
// 단일 transaction 안에서:
// UPDATE notes SET ai_status='pending', ai_error=NULL, updated_at=now WHERE id IN (...)
// INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, now)
// (이미 pending_jobs row 가 있으면 OR IGNORE — race 가드)
setNextRunAt(noteId: string, nextRunAt: string, lastError: string): void;
// UPDATE pending_jobs SET next_run_at=?, last_error=? WHERE note_id=?
// attempts 변경 없음 — unreachable/timeout 무한 retry 용
```
---
## 4. CaptureService + IPC
### 4.1 CaptureService 메서드
```ts
async retryAllFailed(): Promise<{ count: number }> {
const { ids } = this.repo.retryAllFailed(new Date().toISOString());
for (const id of ids) {
await this.deps.enqueue(id);
}
if (this.deps.telemetry && ids.length > 0) {
await this.deps.telemetry.emit({
kind: 'ai_retry_manual',
payload: { failedCount: ids.length }
}).catch(() => {});
}
return { count: ids.length };
}
```
빈 배열 시 telemetry emit 안 함 — 사용자가 "재시도" 클릭해도 N=0 이면 noise.
### 4.2 IPC 채널 신규 2
| 채널 | 입력 | 출력 |
|------|------|------|
| `inbox:retryAllFailed` | (없음) | `{ count: number }` |
| `inbox:failedCount` | (없음) | `number` |
confirm dialog 불필요 — destructive 아님 (단순 재처리 큐 등록, 데이터 손실 없음).
---
## 5. Tray + Banner UI
### 5.1 Tray 메뉴
기존 (#1 cut 후):
```
- 사용 로그 내보내기...
- Ollama 재확인 (status.ok=false 시 enabled)
```
신규 (본 cut):
```
- 사용 로그 내보내기...
- Ollama 재확인 (status.ok=false 시 enabled)
- 지금 AI 처리 (실패 N건) (failedCount > 0 시 enabled, label dynamic with N)
```
`refreshTrayFailedCount(count: number)` setter — `refreshTrayOllama` 와 동일 패턴. `_failedCount` module-level state + 메뉴 rebuild.
`createTray` 의 9번째 callback `runRetryAllFailed`. 8 → 9 positional. v0.2.4 backlog #4 (TrayCallbacks object refactor) trigger 더 강화.
AiWorker.onUpdate 시점에 `refreshTrayFailedCount(repo.countFailed())` 호출.
### 5.2 FailedBanner
`src/renderer/inbox/components/FailedBanner.tsx` (신규):
```tsx
import React from 'react';
import { useInbox } from '../store.js';
export function FailedBanner(): React.ReactElement | null {
const count = useInbox((s) => s.failedCount);
const retryAllFailed = useInbox((s) => s.retryAllFailed);
if (count === 0) return null;
return (
<div className="banner warn" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ flex: 1 }}> AI <b>{count}</b></span>
<button onClick={() => { retryAllFailed().catch((e) => console.warn('retryAllFailed failed', e)); }}>
</button>
</div>
);
}
```
스타일: warn variant 색상은 PendingBanner 와 다른 차별 (#fff7e6 / #d99500 의 ExpiryBanner 와도 다름) — 본 banner 는 빨강 톤 (#fce4e4 / #a33). 사용자 주의 필요한 영구 실패 신호.
### 5.3 Inbox 상단 stack 갱신
```
1. OllamaBanner (system - down)
2. RecoveryToast (회복 toast)
3. PendingBanner (AI 처리 N건 - 일시)
4. FailedBanner (AI 실패 N건 - 영구 - 신규)
5. ExpiryBanner (만료)
6. tagFilter chip
7. notes
```
위치 근거: system status > 진행 (transient) > 영구 실패 (actionable) > 트리아지 (expiry, also actionable but lower urgency) > filter > content.
---
## 6. zustand store
```ts
// InboxState 확장
failedCount: number;
retryAllFailed: () => Promise<void>;
```
initial: `failedCount: 0`.
`loadInitial` / `refreshMeta``Promise.all``inboxApi.getFailedCount()` 합류 → `set({ failedCount })`.
`retryAllFailed` action:
```ts
async retryAllFailed() {
const r = await inboxApi.retryAllFailed();
// 낙관적 갱신: failedCount = 0 으로 reset (worker 처리 진행 중)
// 실제 카운트는 AiWorker.onUpdate 트리거된 refreshMeta 에서 자연 동기.
// PendingBanner 가 처리 중 N 건 노출.
set({ failedCount: 0 });
// r.count 는 telemetry/log 정보용
}
```
---
## 7. Telemetry
### 7.1 신규 1 event
| event | payload | 발화 |
|-------|---------|------|
| `ai_retry_manual` | `{ failedCount: number }` (≥1) | retryAllFailed 시 ids.length>0 일 때만 |
빈 배열 시 emit 안 함 — sentinel.
### 7.2 zod schema
```ts
const AiRetryManualPayload = z.object({
failedCount: z.number().int().positive() // ≥1 enforced — 0 emit 자체가 invariant violation
}).strict();
```
### 7.3 stats.md 집계
신규 행 (수동 recheck 사용량 다음):
- AI 수동 재시도 사용량: `count` 회 / 누적 `Σfailedcount`
`DailyRow` 에 1 카운터 + sum 누적기 추가.
### 7.4 기존 `ai_failed` 영향
변경 없음. unreachable/timeout 가 markAiFailed 안 부르므로 자연히 reason 분포에서 제외. 결과: `ai_failed.reason` 분포 = schema + other 만. dogfood 통계 의미 명확화.
---
## 8. 테스트
| 영역 | 케이스 | 검증 |
|------|--------|------|
| AiWorker | unreachable 무한 retry | attempts 증가 안 함, markAiFailed 안 호출, ai_failed emit 안 함 |
| AiWorker | timeout 무한 retry (Q2=A) | unreachable 와 동일 경로 |
| AiWorker | schema fail max 3 | attempts 증가, 마지막에 markAiFailed + ai_failed emit |
| AiWorker | other fail max 3 | schema 와 동일 |
| AiWorker | unreachable backoff step | 1차 30s, 2차 60s, ..., 6차 900s cap |
| AiWorker | success 시 unreachableBackoffStep reset | 다음 unreachable 발생 시 30s 부터 |
| Repo | findFailedIds — failed + active 만 | trashed 또는 pending/done 제외 |
| Repo | countFailed | 정확 |
| Repo | retryAllFailed atomic | ai_status reset + pending_jobs 재투입 |
| Repo | retryAllFailed empty | `{ ids: [] }` |
| Repo | retryAllFailed pending_jobs 이미 존재 | OR IGNORE — race 안전 |
| Repo | setNextRunAt | attempts 변경 없이 next_run_at + last_error 만 |
| CaptureService | retryAllFailed — telemetry emit + worker.enqueue 호출 | per-id enqueue + ai_retry_manual emit |
| CaptureService | retryAllFailed 빈 결과 emit 없음 | count=0 sentinel |
| TelemetryEvents | zod parse `ai_retry_manual` | happy + extra field reject + 0 reject (≥1 invariant) |
| TelemetryStats | AI 수동 재시도 집계 | count + sum |
| Store | retryAllFailed action — failedCount=0 reset | 낙관적 갱신 |
총 ≥ 17 단위.
---
## 9. 작업 순서 (writing-plans 시 task 분할 가이드)
T1. Repo: findFailedIds + countFailed + retryAllFailed + setNextRunAt + 단위 5개
T2. AiWorker: unreachable/timeout 무한 retry 로직 + 단위 6개
T3. Telemetry: ai_retry_manual 1 event + stats + 단위 3개
T4. CaptureService.retryAllFailed + IPC 2 채널 + preload + 단위 2개
T5. shared/types InboxApi + store retryAllFailed + failedCount + 단위 1개
T6. FailedBanner 컴포넌트 + App.tsx mount
T7. Tray "지금 AI 처리 (실패 N건)" 메뉴 + 9th callback + refreshTrayFailedCount + main wiring
T8. closure (gates + roadmap mark + memory backlog)
---
## 10. roadmap In/Out 일치
### 10.1 roadmap §3 #2 In 매핑
| roadmap | design |
|---------|--------|
| AiWorker unreachable 무한 retry, attempts 증가 안 함 | §2 ✓ |
| schema fail / invalid response / timeout 만 attempts 증가 (max 3 유지) | §2 — **timeout 은 deviation (Q2=A)**, schema/other 만 attempts 증가 |
| markAiFailed 한 노트 수동 re-enqueue | §3 retryAllFailed |
| 트레이 + Inbox "지금 AI 처리 (실패 N건)" | §5 ✓ |
| FailedBanner | §5.2 ✓ |
| IPC `inbox:retryAllFailed`, `inbox:failedCount` | §4 ✓ |
| Telemetry `ai_retry_manual {failedCount}` | §7 ✓ |
| 단위 테스트 | §8 ≥ 17 |
### 10.2 Out 유지
- per-note retry 버튼 (Q3=A) — Out
- failed reason 별 차등 정책 — Out (모두 동일 max 3, telemetry 통계만 분리)
- retry progress UI — Out (PendingBanner 가 자연 표현)
- retry rate-limit — Out
### 10.3 roadmap deviation
§3 #2 In 의 "timeout 만 attempts 증가" 와 본 design 의 Q2=A "timeout 무한 retry" 가 충돌. 의식적 변경 — `ai_failed.reason='timeout'` 통계가 부족할 수 있음. dogfood 데이터로 검증 후 v0.2.4 에서 hang 케이스 분리 가능.
---
## 11. 위험 / 완화
| 위험 | 완화 |
|------|------|
| unreachable 무한 retry 큐 폭주 | sleep await sequential. 같은 job 안 in-place loop, 새 job 추가 0. cap 15분. |
| retryAllFailed 가 큰 N (예: 100+) | enqueue in-memory queue push. AiWorker 가 sequential 처리 — provider 호출 1개씩. 폭주 0. |
| timeout 분류 잘못 — 영구 hang 노트가 무한 retry | telemetry markAiFailed 시점만 emit → timeout 무한 retry 노트 stats 안 보임. v0.2.4 dogfood 시 ai_status='pending' 의 attempts 분포로 영구 hang 식별. 필요 시 timeout cap 도입. |
| unreachableBackoffStep 이 process restart 시 reset | 의도. next_run_at 가 미래면 sleep, 과거면 즉시 retry — 자연. |
| schema 후 unreachable 발생 — backoff step 이 unreachableBackoffStep 와 별개 인덱스 | unreachableBackoffStep 은 unreachable/timeout 전용. schema 의 attempts 와 독립. 단위 테스트 회귀 가드. |
| retryAllFailed 와 AiWorker 큐의 race (이미 처리 중인 노트 재투입) | retryAllFailed SQL 이 ai_status='failed' 만 → 처리 중 ('pending') 노트는 자연 제외. atomic transaction. |
| pending_jobs 재투입 시 이미 pending_jobs row 존재 (예: race) | INSERT OR IGNORE — duplicate ignored. attempts/next_run_at 그대로 유지. 안전. |
---
## 12. 게이트 (PR 머지 조건)
- `npm run typecheck` 0 errors
- `npm test` — 344 + 17 = 361+
- `npm run test:e2e` 1/1
- main 머지
머지 후:
- roadmap §3 #2 ✓ 완료 마커
- v0.2.4 backlog 누적
---
## 13. 변경 이력
| 일자 | 변경 |
|------|------|
| 2026-05-01 | 초안 — Q1=A (15분 cap), Q2=A (timeout=unreachable), Q3=A (retry-all only). AiWorker unreachable/timeout 무한 retry + retryAllFailed atomic + FailedBanner + tray "지금 AI 처리" + ai_retry_manual telemetry. roadmap §3 #2 deviation 1건 (timeout) 의식적. |

View File

@@ -0,0 +1,294 @@
# v0.2.3 #5 만료 추천 설계
**작성일:** 2026-05-01
**저자:** 김태현 (dlsrks0734@gmail.com)
**문서 성격:** v0.2.3 cut 7항목 중 3번째 항목 (#5 만료 추천) 의 mini-brainstorm 결정 + design. roadmap §3 #5 의 In/Out 위에서 §8 의 미결정 3항목 + UI 위치/0건 처리 추가 결정.
**선행 문서:**
- `docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md` §3 #5 (In/Out), §8 (미결정 항목)
- `docs/superpowers/specs/2026-05-01-v023-trash-design.md` (#4 trash, deleted_at 인프라)
- `docs/superpowers/plans/2026-04-26-f7-ai-primary-due-date.md` (due_date 컬럼 + AI 추출 흐름)
---
## 1. 결정 요약
| Q | 결정 | 근거 |
|---|------|------|
| Q1 `due_date_edited_by_user` 필터 | **B 필터 없음** — AI 자동 + 사용자 수동 모두 후보 | 의도와 무관하게 "지나간 due_date" 는 트리아지 대상. AI 자동 가중치 차등은 v0.2.4 로. |
| Q2 만료 임박 (D-7) | **A 만료만** (`due_date < today`) | roadmap §3 #5 Out 명시. 임박은 의미 (주의 환기 vs trash) 가 달라 분리 surface 필요. v0.2.4. |
| Q3 멀티선택 default | **C unchecked default + "전체 선택" 토글 버튼** | 데이터 안전 우선 (v0.2.1 패턴). 일괄도 토글 한 번. |
| Q4 배너 위치 | **B PendingBanner 아래** | system(Ollama) → progress(Pending) → actionable(Expired) → filter(tagFilter) 순. |
| Q5 후보 0건 / snooze | **A collapse** (렌더링 생략) | PendingBanner `pendingCount===0` → null 패턴 일치. 빈 카피는 노이즈. |
---
## 2. 데이터 / 쿼리
### 2.1 NoteRepository 확장
```ts
// src/main/repository/NoteRepository.ts
findExpiredCandidates({ today }: { today: string }): Note[];
trashBatch(ids: string[], deletedAt: string): { trashedCount: number };
```
- `today`: `'YYYY-MM-DD'` 문자열 (KST 자정 기준 오늘 날짜). caller 가 KST 기준 계산 후 주입 (테스트에서 clock injection 용이).
- `due_date``'YYYY-MM-DD'` 저장 (slice §F1 invariant 일치).
- `findExpiredCandidates` SQL:
```sql
SELECT <note columns + JOIN tags + media> FROM notes
WHERE due_date IS NOT NULL
AND due_date < ?
AND deleted_at IS NULL
AND ai_status = 'done'
ORDER BY created_at DESC
```
- `trashBatch` 는 단일 `db.transaction()` 안에서 `repo.trash(id, deletedAt)` 반복. 이미 trash 된 id 는 silent skip (UPDATE 가 deleted_at 이 이미 set 인 row 에 영향 0건). 반환 `trashedCount` 는 실제 transition (active → trash) 발생 건수. pending_jobs 정리는 `trash()` 가 이미 처리.
### 2.2 KST 자정 today 계산
```ts
// src/main/util/kstDate.ts (재사용 또는 신설)
export function todayInKst(now: Date): string {
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
const kst = new Date(now.getTime() + KST_OFFSET_MS);
return kst.toISOString().slice(0, 10); // 'YYYY-MM-DD'
}
```
- ContinuityService 의 KST_OFFSET_MS 패턴 재사용. 신규 util 또는 ContinuityService 의 helper 추출.
- 단위 테스트: UTC 23:30 (KST 다음날 08:30) 케이스 검증.
---
## 3. IPC
| 채널 | 입력 | 출력 | 설명 |
|------|------|------|------|
| `inbox:listExpired` | (없음) | `Note[]` | candidates 조회. 빈 배열 가능. |
| `inbox:trashExpiredBatch` | `{ ids: string[] }` | `{ trashedCount: number; confirmed: boolean }` | atomic batch trash + native confirm. ids 빈 배열 시 즉시 `{ trashedCount: 0, confirmed: false }`. |
CaptureService 가 진입점. `today` 는 main 에서 `todayInKst(new Date())` 로 계산.
---
## 4. 상태 관리 (zustand)
```ts
// src/renderer/inbox/store.ts
expiredCandidates: Note[];
expiredSnoozeUntilMs: number | null; // KST 자정 epoch ms
loadExpired: () => Promise<void>;
trashExpiredBatch: (ids: string[]) => Promise<void>;
snoozeExpired: () => void;
```
### 4.1 동작 사양
- `loadExpired()`: IPC `inbox:listExpired` 호출 → `expiredCandidates` 갱신.
- `loadInitial()` + `refreshMeta()` 의 `Promise.all` 에 `inboxApi.listExpired()` 합류.
- `trashExpiredBatch(ids)`: IPC `inbox:trashBatch` 호출 → 성공 시 `expiredCandidates` 에서 ids 제거 + `trashCount` 증가 + `notes` 에서도 제거 (낙관적 갱신, restore 와 동일 패턴 — main 은 push 안 함).
- `snoozeExpired()`: KST 자정 epoch ms 계산해 `expiredSnoozeUntilMs` 에 set. 컴포넌트에서 `Date.now() < snoozeUntil` 체크.
- in-memory only. 앱 재시작 시 다시 노출 (roadmap §3 #5 In 의 명시 사양).
### 4.2 KST 자정 epoch 계산
```ts
function nextKstMidnightMs(now: number): number {
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
const kstNow = now + KST_OFFSET_MS;
const kstMidnight = Math.ceil(kstNow / 86_400_000) * 86_400_000;
return kstMidnight - KST_OFFSET_MS;
}
```
단위 테스트: KST 23:00 호출 시 다음날 00:00 KST 반환 (1시간 후), KST 00:01 호출 시 같은 날 자정 24시간 후 (23h59m 후).
---
## 5. UI — `ExpiryBanner`
위치: `<App.tsx>` 의 `<PendingBanner />` 아래 (showTrash=false 분기 안).
### 5.1 collapse 조건 (렌더 null)
```
expiredCandidates.length === 0
|| (expiredSnoozeUntilMs !== null && Date.now() < expiredSnoozeUntilMs)
```
### 5.2 unfolded 구조
```
┌─ ⏰ 오늘 기준 만료 N개 [▼ 펼침/▲ 접힘] [오늘 그만] ─┐
│ │
│ ☐ [전체 선택] │
│ ☐ 노트 제목 1 · due 2026-04-20 · #회의 │
│ ☐ 노트 제목 2 · due 2026-04-18 · #학습 │
│ ... │
│ │
│ [선택 휴지통 (M개)] (M=0 시 disabled) │
└──────────────────────────────────────────────────────────┘
```
**헤더 (1줄)**
- "⏰ 오늘 기준 만료 {N}개"
- 펼침 토글 버튼 (▼/▲)
- "오늘 그만" 버튼 — 클릭 시 `snoozeExpired()` → 배너 즉시 collapse
**펼침 영역**
- 첫 노출 시 default = **펼침** (사용자가 만료 N건 + 어떤 노트인지 동시 노출).
- 한 번 접으면 component-local useState 로 세션 동안 접힘 유지. reload 시 다시 펼침.
- "전체 선택" 체크박스 — 모든 row 동시 toggle. partial 선택 시 indeterminate 상태 표시.
- 노트 row: 체크박스 + 제목(truncate, max 1 line) + due_date + 태그 chip (1개, 없으면 생략).
- row 전체 clickable — 클릭 시 체크박스 toggle (편집/펼침 액션 없음, read-only triage 모드).
- "선택 휴지통 ({M}개)": M=0 시 disabled. 클릭 시 native confirm dialog ("선택한 {M}개를 휴지통으로 옮깁니다.\n\n복구는 휴지통 탭에서 가능합니다.") → 확인 시 `trashExpiredBatch(selectedIds)`.
### 5.3 confirm dialog
`#4` 패턴 재사용 — main 의 `dialog.showMessageBox` 동기 IPC. type='question', `buttons=['옮기기','취소'], defaultId=1, cancelId=1` (project 의 `inbox:permanentDelete` / `inbox:emptyTrash` 와 일관 — 위험 액션은 default focus = 취소). response 0 만 confirm 으로 처리.
---
## 6. Telemetry
### 6.1 신규 events
| event | payload | 발화 |
|-------|---------|------|
| `expired_banner_shown` | `{ candidateCount: number }` | `loadExpired()` 결과 `candidates.length > 0` 시. 같은 세션에 동일 후보 set 중복 emit 회피 (last shown signature 비교). |
| `expired_batch_trash` | `{ count: number }` | `trashBatch` 성공 직후 (count = trashedCount). |
### 6.2 중복 emit 회피 — signature
`signature = candidateCount + ':' + first-3-ids.join('-')` (ids 는 §2.1 의 ORDER BY created_at DESC 정렬 결과의 처음 3개). main 의 `CaptureService.listExpired()` 안에서 `lastExpiredShownSig: string | null` field 와 비교 → 같으면 emit skip, 다르면 emit + sig 갱신. renderer 는 dedup 미관여 (단순 fetch). 결과: IPC 채널 2개 유지 (`inbox:listExpired` 가 자체 dedup-emit 통합).
### 6.3 zod 스키마
```ts
// src/main/services/TelemetryService.ts (TelemetryEvent discriminatedUnion 확장)
z.object({
kind: z.literal('expired_banner_shown'),
payload: z.object({ candidateCount: z.number().int().nonnegative() }).strict()
}).strict(),
z.object({
kind: z.literal('expired_batch_trash'),
payload: z.object({ count: z.number().int().nonnegative() }).strict()
}).strict(),
```
### 6.4 stats.md 집계 추가
| 행 | 산식 |
|----|------|
| 만료 배너 노출 | `expired_banner_shown` count |
| 만료 일괄 trash | `expired_batch_trash` total `count` 합 |
| 만료 trash ratio | `sum(expired_batch_trash.count) / sum(expired_banner_shown.candidateCount)` |
---
## 7. F5 export / F6 import / 백업
영향 0건. #4 가 이미 `deleted_at IS NULL` 을 export/active query 에 적용. 만료 후보는 active 노트의 부분집합이므로 별도 정책 불필요.
**Regression guard**: 단위 테스트로 "사용자 수동 due_date 도 만료 후보" + "trash 된 만료 노트는 후보 제외" 회귀 가드 추가.
---
## 8. 테스트
| 영역 | 단위 | 검증 |
|------|------|------|
| Repo | `findExpiredCandidates` happy path | due_date < today 만 반환, ORDER BY created_at DESC |
| Repo | `findExpiredCandidates` AI + 수동 mix | Q1=B 회귀 가드 — 둘 다 포함 |
| Repo | `findExpiredCandidates` deleted_at | trash 노트 제외 (#4 invariant 회귀 가드) |
| Repo | `findExpiredCandidates` ai_status | pending/failed 제외 |
| Repo | `findExpiredCandidates` due_date NULL | NULL 노트 제외 (NULL < string 평가 가드) |
| Repo | `trashBatch` atomic happy | N개 모두 trash, count=N |
| Repo | `trashBatch` 빈 배열 | count=0, no-op |
| Repo | `trashBatch` 일부 invalid id | valid 만 trash, count = valid 수 |
| Repo | `trashBatch` 이미 trash | 재호출 시 count=0 (idempotent) |
| util | `todayInKst` UTC vs KST 경계 | 23:30 UTC → 다음날 KST 날짜 |
| Service | `nextKstMidnightMs` | 자정 KST 정확 계산 |
| Telemetry | zod parse `expired_banner_shown` | candidateCount int ≥ 0 |
| Telemetry | zod parse `expired_batch_trash` | count int ≥ 0 |
| Telemetry | privacy invariant | payload 에 raw_text/title 포함 시 거부 (기존 invariant 회귀 가드) |
| Store | `loadExpired` integration | candidates set + count |
| Store | `trashExpiredBatch` 낙관적 갱신 | candidates 제거 + trashCount 증가 + notes 제거 |
| Store | `snoozeExpired` | snoozeUntilMs = 다음 KST 자정 epoch |
총 단위 ≥ 16개. e2e smoke 영향 없음 (만료 노트 fixture 추가 없이 기존 1/1 e2e 보존).
---
## 9. 작업 순서 (writing-plans 시 task 분할 가이드)
T1. `findExpiredCandidates` repo + 단위 5개 (TDD)
T2. `trashBatch` repo + 단위 4개 (TDD)
T3. `todayInKst` util + `nextKstMidnightMs` 계산 + 단위 2개
T4. Telemetry 2 events 추가 (zod + stats.md 집계 + 단위 3개)
T5. CaptureService 메소드 + IPC 2 채널 + 단위
T6. zustand store 확장 + 단위 3개
T7. `ExpiryBanner` 컴포넌트 (펼침/접힘/체크박스/전체선택/오늘그만)
T8. App.tsx 통합 (PendingBanner 아래 mount)
T9. confirm dialog + trashBatch 호출 path 통합
T10. typecheck + 전체 단위 + e2e + roadmap §3 #5 ✓ 마커 + closure
---
## 10. roadmap In/Out 일치
### 10.1 roadmap §3 #5 In 처리 매트릭스
| roadmap 항목 | 본 design |
|-------------|----------|
| `findExpiredCandidates({today})` | §2.1 ✓ |
| Inbox 상단 만료 배너 + 펼침 + 멀티선택 + 선택 휴지통 + 오늘 그만 | §5 ✓ |
| IPC `inbox:listExpired`, `inbox:trashBatch` | §3 ✓ |
| Telemetry `expired_banner_shown` `{candidateCount}` | §6.1 ✓ |
| Telemetry `expired_batch_trash` `{count}` | §6.1 ✓ |
| 단위 테스트 | §8 ✓ (16개) |
### 10.2 roadmap §3 #5 Out 유지
- 시스템 알림 surface — Out
- 별 페이지 — Out
- snooze 영속화 — Out (in-memory + 자정 KST 리셋)
- "안 옮김" 가중치 감소 — Out
- 만료 임박 (D-7) 추천 — Out (Q2 confirmed)
---
## 11. 위험 / 완화
| 위험 | 완화 |
|------|------|
| `due_date IS NOT NULL` 누락 시 NULL < string 평가 (SQLite 의 NULL 비교 결과 NULL → falsy) | 명시적 `WHERE due_date IS NOT NULL` + 단위 테스트 회귀 가드 |
| 사용자가 "오늘 그만" 후 다른 만료 노트 추가 시 배너 안 뜸 (자정까지) | 의도된 동작. 자정 KST 리셋 시 다시 노출. roadmap §3 #5 In 명시. |
| 같은 세션에 candidates 가 자주 바뀌면 (capture 등) `expired_banner_shown` 이 과다 emit | signature 비교 (§6.2) 로 회피 |
| `trashBatch` 의 `today` 가 caller 마다 다른 시점이면 race | main 단일 진입점 (CaptureService) 에서 호출 시점 1회 계산. renderer 가 today 주입 안 함. |
| ExpiryBanner 가 PendingBanner 사이에 끼어 layout shift | 양쪽 다 collapse 조건 명확 (count=0 → null) — shift 는 사용자 액션 결과 (예측 가능) |
---
## 12. 게이트 (PR 머지 조건, roadmap §3.1 일치)
- `npm run typecheck` 0 에러
- `npm test` — 기존 295/295 + 신규 16개 = 311/311 (또는 그 이상)
- `npm run test:e2e` 1/1 통과
- main 머지
머지 후:
- roadmap `§3 #5 만료 추천 (3번)` 다음 `✓ 완료` 마커
- `memory/project_v024_backlog.md` 에 deferred 항목 기록 (review 결과)
---
## 13. 변경 이력
| 일자 | 변경 |
|------|------|
| 2026-05-01 | 초안 — Q1=B (필터 없음), Q2=A (만료만), Q3=C (unchecked default + 전체선택 토글), Q4=B (PendingBanner 아래), Q5=A (0건 collapse). |
| 2026-05-01 | §6.2 dedup 위치를 renderer → main (CaptureService) 로 변경. IPC 채널 수 2개 유지. plan 단계 단순화. |

View File

@@ -0,0 +1,342 @@
# v0.2.2 Dogfood 피드백 로드맵 (#7→#6 → v0.2.3) 설계
**작성일:** 2026-05-01
**저자:** 김태현 (dlsrks0734@gmail.com)
**문서 성격:** v0.2.2 dogfood 중 발견된 7개 항목 (`memory/project_v022_feedback.md` #1~#6 + 본 brainstorm 에서 추가된 #7 telemetry) 의 순차 작업 로드맵. 본 문서는 **순서·범위·게이트** 만 정의하며, 각 항목 내부 설계는 항목별 mini-brainstorm + writing-plans 에서 결정.
**선행 문서:**
- `memory/project_v022_feedback.md` (raw 피드백 6건)
- `docs/superpowers/specs/2026-04-26-feedback-roadmap-design.md` (v0.2.1 로드맵, 본 문서의 패턴 원형)
- `docs/superpowers/specs/2026-04-24-inkling-vertical-slice-design.md` (slice §1.1 invariant 4 — 본문 미기록)
- `docs/superpowers/strategy/strategy.md` (#6 에서 §2.3·§4.3·§8 동반 갱신 대상)
---
## 1. 결정 요약
| 결정 | 값 | 근거 |
|------|-----|------|
| Cut 패턴 | **단일 cut v0.2.3** (7항목 한 묶음) | v0.2.1 패턴 반복. 항목 간 결합도 (특히 #7#4~#6 emit) 분리 시 회전 비용. |
| 우선순위 기준 | **데이터 안전 우선** (v0.2.1 패턴) | 측정 인프라 (#7) → schema migration v3 (#4) → 안전망 위에서 기능 진행. |
| 첫 항목 | **#7 Telemetry skeleton** | 다른 6 항목이 emit hook 만 추가. 측정 없는 기능 출시는 다음 cut 까지 1주 본인 라벨링으로 후퇴. |
| 항목당 게이트 | **머지 + 테스트 통과** (typecheck + 205+ 단위 + e2e smoke) | v0.2.2 기준선. |
| 다음 빌드 | **v0.2.3** (7항목 모두 머지 후 단일 cut) | slice §7 strict-pin patch 증분. |
| 신규 dependency | **0 목표** | 모두 stdlib + 기존 better-sqlite3 / electron 으로 충분. |
| Schema 변경 | **migration v3** — 3 컬럼 한 번에: `deleted_at TEXT NULL`, `last_recalled_at TEXT NULL`, `recall_dismissed_at TEXT NULL` | #4 휴지통 + #6 회상 메타 한 묶음. 별 migration 두 번 회피. |
| Trash 와 export/backup | **B 정책** — F6-L1 backup 포함 (byte-for-byte), F5 export 제외, F6-L3 import 시 `deleted_at IS NOT NULL` 우선 (삭제 보존) | 백업은 회복 용도, export 는 외부 노출 형식. |
| Decision-pending 처리 | **항목별 mini-brainstorm** | 본 문서는 순서·In/Out 만, 항목 내부는 per-item. |
---
## 2. 순차 작업 순서
```
v0.2.2 ────────[ dogfood 동결, 병렬 진행 ]────────
개발 트랙 (main 직접 머지 또는 PR): │
① #7 Telemetry skeleton [작음, 인프라 1번] │
② #4 휴지통 + migration v3 [중, schema + 정책] │
③ #5 만료 추천 [작음, #4 destination]│
④ #1 Ollama 회복 polling [작음, 독립] │
⑤ #2 AI retry / 수동 trigger [중, AiWorker 정책] │
⑥ #3 태그 vocab 주입 [작음, 독립] │
⑦ #6 리마인드 1 spike [중, strategy 갱신] │
┌──────────┘
v0.2.3 cut (단일)
dogfood 재설치 + ≥ 1주 soak
telemetry export → 분석 →
v0.2.4 brainstorm
```
### 2.1 순서 결정 근거
1. **#7 (1번)** — 측정 인프라. 다른 항목이 emit hook 추가만 하도록 skeleton 먼저. Cross-cutting privacy invariant 강제도 1번에서 단위 테스트로 고정.
2. **#4 (2번)** — schema migration v3 가 #6 회상 메타 컬럼 동반. 휴지통 invariant (`deleted_at IS NULL` 모든 active 쿼리) 가 다른 항목에 영향. 회복 안전망 (pre-v3 snapshot, v0.2.1 메커니즘) 위에서 진행.
3. **#5 (3번)** — #4 휴지통 destination 직접 소비. 같은 영역 (Inbox 상단 배너).
4. **#1 (4번)** — #2 의 reliable health 의존성. polling 인프라 먼저.
5. **#2 (5번)** — #1 health 위에서 retry/manual trigger 정책 변경. AiWorker 의 unreachable infinite retry 로 #1 polling 결과 활용.
6. **#3 (6번)** — 독립 prompt 변경. PROMPT_VERSION 4. AI 영역 마지막에 묶어서 AiWorker 회귀 위험 격리.
7. **#6 (7번)** — strategy.md 갱신 + RecallBanner 1 spike. last_recalled_at / recall_dismissed_at 사용. 가장 마지막에 두는 이유: 다른 항목 telemetry hook 이 모두 박혀야 #6 측정 가치가 살아남.
---
## 3. 항목당 In (PR 범위) / Out (deferred)
각 항목 PR 범위 라인. 세부 결정 (decision-pending) 은 항목 시작 시 mini-brainstorm.
### #7 Telemetry skeleton (1번) ✓ 완료
**In:**
- `TelemetryService` (`src/main/services/TelemetryService.ts`):
- `emit(kind, payload)` → 비동기 append to `<profileDir>/telemetry/events-YYYY-MM-DD.jsonl`
- 일자별 rotation (KST 자정), 14일 후 rolling 삭제
- write 실패 시 silent log only (앱 동작 영향 없음)
- 이벤트 zod schema: `{ ts: ISO string, kind: enum, payload: object }`. payload shape 는 kind 별 fixed.
- **Privacy invariant** (slice §1.1 invariant 4 강화): payload 에 `raw_text` / `ai_title` / `ai_summary` / `user_intent` / 태그 name 포함 시 zod parser 거부. 단위 테스트로 고정.
- 기본 emit hook 박기:
- `capture` (CaptureService.submit 후): `{ noteId, rawTextLength, hasMedia }`
- `ai_succeeded` (AiWorker.processJob 성공): `{ noteId, durationMs, attempts }`
- `ai_failed` (AiWorker.processJob 실패): `{ noteId, reason: "unreachable"|"schema"|"timeout"|"other", attempts }`
- 트레이 메뉴 "사용 로그 내보내기...":
- 폴더 다이얼로그 → `events.jsonl` (최근 14일 concat) + `stats.md` (집계 마크다운) zip
- `stats.md` 내용: 항목별 일자별 카운트 표 + 핵심 ratio (AI 성공률, ollama uptime%, recall opened/shown, expired batched/shown 등)
- 트레이 콜백 (main 내부 — 별도 IPC 채널 불필요)
- 단위 테스트: emit, rotation, privacy invariant 거부, stats 집계, export zip
**Out:** 자동 업로드 / 원격 telemetry (모두 로컬), 실시간 대시보드 UI, opt-out 토글 (로컬이라 불필요), 14일 보존 기간 사용자 설정
### #4 휴지통 (2번) ✓ 완료
**In:**
- migration v3: `notes.deleted_at TEXT NULL` + `notes.last_recalled_at TEXT NULL` + `notes.recall_dismissed_at TEXT NULL` (3 컬럼 한 번)
- `NoteRepository`: `trash(id)` (`deleted_at = now()`), `restore(id)` (`deleted_at = NULL`), `emptyTrash()` (hard delete + media 정리). 기존 `delete()` 는 deprecate 후 `emptyTrash` 내부에서만 호출.
- **Active 쿼리 일괄 `WHERE deleted_at IS NULL` 추가**: `listNotes`, `countToday`, `findByTag`, search, F5 export, AiWorker `loop()` 진입 시 deleted_at 체크
- 휴지통 UI: Inbox 상단 탭 ("Inbox · 휴지통(N)") — 정밀 위치는 mini-brainstorm
- 휴지통 비우기 confirm dialog ("N개 영구 삭제. 되돌릴 수 없음.")
- F5 export 가 `deleted_at IS NOT NULL` 제외
- F6-L3 import 충돌 정책 추가: source 와 dest 중 `deleted_at IS NOT NULL` 우선 (삭제 보존)
- IPC: `inbox:trash` / `inbox:restore` / `inbox:emptyTrash` / `inbox:listTrash`
- Telemetry emit: `trash` / `restore` / `emptyTrash`
- 단위 테스트: trash/restore/emptyTrash, active query 제외, AiWorker skip, F5 export 제외, F6-L3 import 머지
**Out:** 자동 비우기 정책 (사용자 트리거만), 휴지통 검색, trash 안 노트 편집, 휴지통 UI 정밀 위치 (mini-brainstorm), per-note 영속 보호 플래그
### #5 만료 추천 (3번) ✓ 완료
**In:**
- `NoteRepository.findExpiredCandidates({today})`:
- `WHERE due_date < today AND deleted_at IS NULL AND ai_status = 'done'`
- ORDER BY `created_at DESC`
- Inbox 상단 **만료 배너** (펼침 가능):
- "오늘 기준 만료 N개" 헤더
- 펼치면 노트 카드 리스트 + 체크박스 멀티선택
- "선택 휴지통" 버튼 → 일괄 trash + telemetry emit
- "오늘 그만" → in-memory snooze (자정 KST 리셋)
- IPC: `inbox:listExpired`, `inbox:trashBatch`
- Telemetry emit: `expired_banner_shown` (`{ candidateCount }`), `expired_batch_trash` (`{ count }`)
- 단위 테스트: 후보 query, 멀티선택 batch trash, snooze 동작, deleted_at 제외 확인
**Out:** 시스템 알림 surface, 별 페이지, snooze 영속화, "안 옮김" 가중치 감소, 만료 임박 (D-7) 추천
### #1 Ollama 회복 (4번) ✓ 완료
**In:**
- HealthChecker 주기 polling (기본 60s — mini-brainstorm 에서 주기/backoff 확정):
- `runOnce()` 가 setInterval 로 자동 발화
- 회복 시 `onUpdate` fire → 구독 (renderer OllamaBanner) 자동 갱신
- 실패 N회 후 polling 중단 정책 — mini-brainstorm
- 수동 "재확인" 버튼: `OllamaBanner` + 트레이 컨텍스트 메뉴
- IPC: `inbox:ollamaRecheck`
- Telemetry emit: `ollama_unreachable` (`{ reason }`), `ollama_recovered` (`{ downtimeMs }`), `ollama_recheck_manual` (`{}`)
- 단위 테스트: polling fire, manual recheck, 회복 status 전이 + telemetry emit
**Out:** 사용자 설정 가능 polling 주기, 회복 toast 알림, 모델 정상성 (tags 외) 체크
### #2 AI retry / 수동 trigger (5번) ✓ 완료
**In:**
- `AiWorker.processJob()` 정책 변경:
- **ollama unreachable** 일 때 `attempts` 증가 안 하고 `next_run_at` 만 backoff (무한 retry while unreachable)
- schema fail / invalid response / timeout 만 `attempts` 증가 (기존 max 3 유지)
- reason 분류는 `LocalOllamaProvider` 결과 + zod 결과로 결정
- `markAiFailed` 한 노트 수동 re-enqueue 가능 (hard fail 도 회수 경로)
- 트레이 + Inbox 메뉴 **"지금 AI 처리 (실패 N건)"** → 모든 ai_status='failed' → pending_jobs 재투입
- `FailedBanner` (PendingBanner 형제, 실패 N건 + retry 버튼)
- IPC: `inbox:retryAllFailed`, `inbox:failedCount`
- Telemetry emit: `ai_failed` (#7 의 기본 hook 에 reason 분류 추가), `ai_retry_manual` (`{ failedCount }`)
- 단위 테스트: unreachable infinite retry, retry-all trigger, unreachable vs schema fail 구분, attempts 증가 정책
**Out:** per-note retry 버튼 (NoteCard), failed reason 별 차등 정책, retry progress UI, retry rate-limit
### #3 태그 vocab (6번) ✓ 완료
**In:**
- `NoteRepository.getTopUsedTags(N=20)`:
- `SELECT t.name, COUNT(*) c FROM tags t JOIN note_tags nt ON nt.tag_id=t.id JOIN notes n ON n.id=nt.note_id WHERE n.deleted_at IS NULL GROUP BY t.id ORDER BY c DESC LIMIT 20`
- `buildPrompt()` 에 vocab 주입 라인:
- "기존 태그를 우선 재사용. 새 태그는 vocab 에 없는 의미일 때만 만들기:" + kebab-case 리스트
- vocab 빈 케이스 (신규 사용자) → 라인 자체 생략
- `PROMPT_VERSION` 3 → **4**
- AI 응답 후 vocab hit/miss 분류 → telemetry emit
- Telemetry emit: `tag_vocab_hit` (`{ tagId, vocabSize }`), `tag_vocab_miss` (`{ vocabSize }`)
- 단위 테스트: vocab 합성, 빈 vocab, 길이 cap, prompt version bump, hit/miss 분류
**Out:** 임베딩 유사도 dedup, 사용자 controlled vocabulary 화이트리스트, 자동 normalize ("회의" ↔ "미팅"), top-N 튜닝, vocab cache invalidation 정책
### #6 리마인드 1 spike (7번) ✓ 완료
**In:**
- `strategy.md` §2.3 / §4.3 / §8 갱신: Capitalize 본격 진입, "오늘 회상" surface 정의, F4-A/B/D deferred 항목의 측정 인프라 마련 명시
- Inbox 상단 **`RecallBanner`** — "오늘 회상해볼 노트" 1건 추천:
- algo: `WHERE (last_recalled_at IS NULL OR last_recalled_at < date('now','-7 day')) AND (recall_dismissed_at IS NULL OR recall_dismissed_at < date('now','-30 day')) AND ai_status='done' AND deleted_at IS NULL AND (due_date IS NULL OR due_date >= today) ORDER BY created_at ASC LIMIT 1`
- 사용자 액션 3개:
- "열어보기" → 노트 카드 스크롤 + `last_recalled_at = now()`
- "다음에" → in-memory snooze 1일 (영속화 X)
- "더 이상" → `recall_dismissed_at = now()`
- IPC: `inbox:listRecallCandidate`, `inbox:markRecallOpened`, `inbox:dismissRecall`
- Telemetry emit: `recall_shown` (`{ noteId, ageDays }`), `recall_opened`, `recall_dismissed`, `recall_snoozed`
- 단위 테스트: algo selection, dismiss 만료 (30일 후 재추천), last_recalled 갱신, deleted_at 제외, 후보 0건 케이스
**Out:** 잠금해제 hook (F4-A), 무작위 토스트 (F4-D), ambient if-then (F4-B), 임베딩 유사도 추천 (#3 vocab 후속), spaced repetition (Leitner/SM-2), 다중 후보 추천
### 3.1 공통 게이트 (모든 항목)
각 항목 머지 전 필수:
- `npm run typecheck` 통과 (현재 0 에러)
- `npm test` 통과 (현재 205/205, 항목 신규 단위 추가)
- `npm run test:e2e` 통과 (현재 1/1)
- 항목 신규 단위 테스트 ≥ 1개 (TDD)
- main 머지
---
## 4. 항목당 작업 흐름 + Cross-cutting
```
[항목 N 시작]
├─ mini-brainstorm ← decision-pending 답변
│ - 본 문서 §3 의 "Out" 후보 일부가 In 으로 승격 가능
│ - per-item spec doc → docs/superpowers/specs/2026-MM-DD-v023-<slug>.md
├─ writing-plans ← TDD 구현 계획
├─ 구현 (executing-plans 또는 직접)
│ - 브랜치: feat/v023-<slug> (예: feat/v023-trash, feat/v023-recall)
│ - 게이트 통과 후 main 머지
└─ 다음 항목 시작
```
### 4.1 Cross-cutting 정책
| 영역 | 정책 |
|------|------|
| **버전 관리** | 7개 모두 머지될 때까지 `package.json` `0.2.2` 유지. v0.2.3 cut 은 7번 후 단일. |
| **CHANGELOG** | 기존 `CHANGELOG.md``[0.2.3]` section append (v0.2.2 에서 확립한 패턴). v0.2.3 cut 직전 한 번만 수정. |
| **브랜치 전략** | `feat/v023-<slug>` 단명. main 머지 후 삭제. 작은 항목 (#1, #3, #6 strategy 갱신) 은 main 직접 push 도 허용. |
| **테스트 추가 정책** | 항목당 최소 단위 1개. e2e smoke 영향 시 단언 동기화. AiWorker 변경 (#1, #2, #3) 은 integration (Ollama) 영향 시 검토. |
| **Slice invariant 위반 시** | 본 로드맵 결과로 invariant 변경 — slice §1.1 §7 도 PR 안에 동봉 수정. |
| **신규 dependency** | slice §7 strict-pin 그대로. 0 신규 dep 목표 — 위반 시 PR 안에 §7.2 갱신 + 합리화 동봉. |
| **로깅 정책** | slice §1.1 invariant 4 **강화**: telemetry payload 에 raw_text/title/summary/intent/tag name 포함 절대 금지. 위반 시 silent invariant 위반. #7 단위 테스트로 zod parser 가 거부. |
| **Strategy.md 동반 갱신** | #6 항목 (7번) 에서만. 다른 항목은 strategy.md 미수정. |
| **Schema invariant 추가** | `deleted_at IS NOT NULL` 노트는 모든 active 쿼리 (Inbox 리스트 / 카운트 / 검색 / 태그 필터 / AiWorker 처리 / F5 export) 에서 제외. F6-L1 backup 만 예외. 위반 시 dogfood-feedback 재오픈. |
---
## 5. v0.2.3 Cut 단계
7번 항목 머지 후:
```
[v0.2.2 dogfood 환경에서]
1. 트레이 → "지금 백업" 1회 클릭 ← F6-L1 첫 실증
2. 트레이 → "내보내기..." 1회 ← F5 schema-agnostic 백업
3. 트레이 → "사용 로그 내보내기..." 1회 ← #7 의 첫 실증 (없으면 v0.2.2 raw 데이터 손실)
4. Inkling 종료
[빌드 머신에서]
5. package.json version: 0.2.2 → 0.2.3
6. CHANGELOG.md 에 [0.2.3] section append
7. npm run dist
8. dist/Inkling Setup 0.2.3.exe 검증
[dogfood 머신에서]
9. Setup 0.2.3.exe 실행 → 같은 폴더에 설치
10. 첫 실행 → migration v3 자동 적용 (deleted_at + last_recalled_at + recall_dismissed_at)
11. 트레이 메뉴 "사용 로그 내보내기..." 항목 존재 확인
12. ≥ 1주 soak 시작
```
### 5.1 업그레이드 안전망
| 위험 | 완화 |
|------|------|
| migration v3 결함으로 DB 손상 | 2가지 복원 경로 (v0.2.1 부터): (a) `<dbFile>.pre-v3.bak` 자동 snapshot 으로 SQLite 복원 (v0.2.2 인스톨러 재설치 필요), (b) F5 export → v0.2.3 의 F6-L3 import 로 schema-agnostic 복원 (더 빠름) |
| `deleted_at IS NULL` 누락 — 휴지통 노트가 active 쿼리에 새는 회귀 | 단위 테스트로 모든 active 쿼리 확인. 실수 시 dogfood-feedback 즉시 재오픈. |
| Telemetry payload 에 본문 누출 | `TelemetryService.emit` 의 zod parser 가 거부. CI 단위 테스트로 고정. |
| AiWorker unreachable infinite retry 가 큐 폭주 | next_run_at 의 backoff cap (15분) — mini-brainstorm 에서 확정. |
| 자동시작 토글 / 데이터 디렉터리 손실 | v0.2.1 동일 — `HKCU\...\Run` + `<profileDir>` 보존됨 |
---
## 6. 측정
### 6.1 로드맵 측정
| 메트릭 | 임계값 | 측정 방법 |
|--------|--------|----------|
| 항목 평균 PR 사이즈 | < 800 lines diff | git log 통계 |
| 항목 평균 머지 간격 | < 5일 | git log 시간차 |
| 회귀 테스트 추가 | 항목당 ≥ 1개 단위 | `tests/unit` 카운트 |
| v0.2.3 cut 후 1주 데이터 손실 | 0회 | telemetry + 본인 라벨링 보강 |
| typecheck/test 회귀 | 0회 | CI · 로컬 |
| Telemetry 본문 누출 | 0건 | events.jsonl grep + zod parser |
### 6.2 dogfood soak 측정 (#7 의 본격 사용처)
`stats.md` 가 다음을 답해야 함:
| 질문 | 데이터 |
|------|--------|
| AI 가 실제로 동작 중인가? | `ai_succeeded / (ai_succeeded + ai_failed)` ratio 일자별 |
| Ollama unreachable 빈도? | `ollama_unreachable` count + 평균 `downtimeMs` |
| 수동 trigger 가 쓰이고 있나? | `ai_retry_manual` / `ollama_recheck_manual` count |
| 휴지통이 회수 도구로 동작? | `restore / trash` ratio |
| 만료 추천이 nudging 으로 동작? | `expired_batch_trash / expired_banner_shown` ratio |
| 회상 spike 가 의미 있나? | `recall_opened / recall_shown` ratio + `recall_dismissed` count |
| Tag vocab 재사용? | `tag_vocab_hit / (hit + miss)` ratio (목표: 시간 흐름에 따라 상승) |
### 6.3 silent invariant 후보
본 로드맵 결과로 slice §1.3 종료 조건에 추가 권장:
> **"Telemetry 본문 누출 0회"** — events.jsonl 의 어떤 payload 에도 raw_text/title/summary/intent/tag name 미포함. 발생 시 즉시 silent invariant 위반.
> **"`deleted_at IS NULL` 망각 0회"** — active 쿼리 회귀 시 즉시 dogfood-feedback 재오픈.
이 추가는 #7 / #4 항목 머지 시 slice §1.3 동봉 수정.
---
## 7. 본 로드맵의 종료 조건
**모두 만족해야 종결**:
1. #7·#4·#5·#1·#2·#3·#6 7개 항목 모두 main 머지
2. `CHANGELOG.md [0.2.3]` section + `package.json` 0.2.3 + slice §1.3 silent invariant 2개 추가 동봉 갱신 완료
3. v0.2.3 cut → dogfood 머신 재설치 → migration v3 적용 확인 → 첫 실행 정상 + 트레이 메뉴 신규 항목 ("사용 로그 내보내기...") 동작 확인
4. ≥ 1주 dogfood soak 완료 (데이터 손실 0회 + telemetry 본문 누출 0건 확인)
5. `events.jsonl` + `stats.md` export → 분석 → v0.2.4 brainstorm 진입
5 가 끝나면 본 로드맵 종결.
---
## 8. 미결정 항목 (각 항목 mini-brainstorm 에서 답변)
본 로드맵은 순서·In/Out 만 정의. 다음 결정들은 빨리 마주치게 됨:
- **#7**: events.jsonl rotation 주기 (자정 KST 확정), stats.md 집계 ratio 의 정확한 컬럼 list, payload schema 별 zod 파서 합성 정책, write 실패 시 백오프
- **#4**: 휴지통 UI 정밀 위치 (Inbox 탭 vs 트레이 별 윈도우 vs 별 페이지), 휴지통 비우기 confirm 카피, F5 export 의 trash 옵션 (제외 강제 vs 사용자 토글)
- **#5**: 후보 `due_date_edited_by_user` 필터 여부 (수동 입력만 vs AI 자동 추출 포함), 만료 임박 (D-7) 포함 여부, 멀티선택 default 상태 (전체 선택 vs 비선택)
- **#1**: polling 주기 (10/30/60s), 실패 N회 후 polling 중단, exponential backoff 적용
- **#2**: unreachable backoff cap (15분 후보), reason 분류 정밀도 (timeout vs unreachable 구분), per-note retry 승격 여부
- **#3**: top-N 값 (20 후보), vocab cache invalidation 정책 (write-through vs 매 prompt 시 fresh), 빈 vocab 임계값
- **#6**: dismiss 만료 30일 vs 14일 vs 60일, 후보 0건 시 RecallBanner 숨김 vs 빈 상태 카피
- **#5+#6 coexistence**: 둘 다 Inbox 상단 배너 noting. stack 순서 (만료 위 → 회상 아래 가 자연 — 시간 민감도 우선), 동시 N건 시 우선 표시 정책, 빈 상태 시 영역 collapse 여부. #5#6 순서 머지라 #6 mini-brainstorm 에서 #5 와 통합 layout 결정.
---
## 9. 변경 이력
| 일자 | 변경 |
|------|------|
| 2026-05-01 | 초안 — v0.2.2 dogfood 7항목 (#7 telemetry 신설 포함) 단일 cut 로드맵, 데이터 안전 우선 (C 채택), schema migration v3 3컬럼 한 묶음 (B 채택), trash↔backup/export B 정책, #6 = 1 spike 흡수 (B 채택). |

View File

@@ -0,0 +1,327 @@
# v0.2.3 #1 Ollama 회복 polling 설계
**작성일:** 2026-05-01
**저자:** 김태현 (dlsrks0734@gmail.com)
**문서 성격:** v0.2.3 cut 7항목 중 4번째 항목 (#1 Ollama 회복) 의 mini-brainstorm 결정 + design. roadmap §3 #1 의 In/Out 위에서 §8 미결정 3항목 (polling 주기 / 실패 N회 중단 / backoff) 결정 + 추가 동작 사양 명시.
**선행 문서:**
- `docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md` §3 #1, §8
- 선행 cut: #7 telemetry (PR #13), #4 trash (PR #14), #5 expiry (PR #15)
---
## 1. 결정 요약
| Q | 결정 | 근거 |
|---|------|------|
| Q1 polling 주기 | **A 60s** | 회복 latency ≤ 1분 충분. `/api/tags` 호출 가벼워 부하 미미. dogfood 1인 사용 패턴 (분당 ~1 capture) 과 같은 톤. |
| Q2 실패 N회 후 중단 | **A 절대 중단 안 함** | 부하 무시 가능. 중단 시 사용자 마찰 (재확인 버튼 또는 재시작) 만 남김. |
| Q3 exponential backoff | **A constant 60s** | Q1 결론 + Q2 결론 연결 — backoff 효과 없고 회복 latency 만 늘어남. |
---
## 2. HealthChecker 확장
### 2.1 시그너처
```ts
// src/main/services/HealthChecker.ts
export interface HealthCheckerOptions {
intervalMs?: number; // default 60_000
onUpdate?: (status: HealthResult) => void; // delta only — status 가 변할 때만 fire
onTelemetry?: (event: HealthTelemetryEvent) => void; // emit hook (testability)
now?: () => number; // testability
}
export type HealthTelemetryEvent =
| { kind: 'ollama_unreachable'; reason: string }
| { kind: 'ollama_recovered'; downtimeMs: number }
| { kind: 'ollama_recheck_manual' };
export class HealthChecker {
constructor(private provider: InferenceProvider, private opts: HealthCheckerOptions = {}) {}
/**
* @param opts.manual=true 일 때 결과와 무관하게 onTelemetry({kind:'ollama_recheck_manual'}) 1회 fire.
* IPC `inbox:ollamaRecheck` 가 호출 시 사용 — telemetry 가드를 service 레이어로 끌어 단위 테스트 가능.
*/
async runOnce(opts?: { manual?: boolean }): Promise<HealthResult>;
start(): void; // setInterval 시작 (idempotent — 2회 호출 시 1번만)
stop(): void; // clearInterval (idempotent)
lastStatus(): HealthResult;
}
```
### 2.2 상태 전이 로직
`runOnce()` 안에서 `result = await provider.healthCheck()` 후:
| 전이 | 동작 |
|------|------|
| ok=true → ok=true (변화 없음) | no-op |
| ok=true → ok=false | `unreachableSince = now()`. `onUpdate(result)` 호출. `onTelemetry({kind:'ollama_unreachable', reason})` |
| ok=false → ok=true | `downtimeMs = now() - unreachableSince`. `onUpdate(result)`. `onTelemetry({kind:'ollama_recovered', downtimeMs})`. `unreachableSince = null` |
| ok=false → ok=false (reason 동일) | no-op |
| ok=false → ok=false (reason 다름) | `onUpdate(result)` (UI 갱신). telemetry emit **안 함** (ratio 노이즈 회피) |
### 2.3 start/stop
- `start()`: `runOnce()` 즉시 1회 + `setInterval(runOnce, intervalMs)` 등록. timer 이미 있으면 no-op.
- `stop()`: `clearInterval(timer)`. timer null 로 set.
- App quit hook (`app.on('before-quit')`) 에서 `health.stop()` — leak 방지.
---
## 3. main wiring + IPC
### 3.1 main/index.ts 변경
기존:
```ts
const health = new HealthChecker(provider);
void health.runOnce().then((h) => logger.info('ai.health', { ...h }));
```
신규:
```ts
const health = new HealthChecker(provider, {
onUpdate: (status) => pushOllamaStatus(getInboxWindow, status),
onTelemetry: (ev) => {
if (ev.kind === 'ollama_unreachable') void telemetry.emit({ kind: 'ollama_unreachable', payload: { reason: ev.reason } }).catch(() => {});
else if (ev.kind === 'ollama_recovered') void telemetry.emit({ kind: 'ollama_recovered', payload: { downtimeMs: ev.downtimeMs } }).catch(() => {});
}
});
health.start();
app.on('before-quit', () => health.stop());
```
### 3.2 IPC 채널
| 채널 | 방향 | 용도 |
|------|------|------|
| `inbox:ollamaStatus` | renderer → main | 기존 — `health.lastStatus()` 반환. startup / refreshMeta 시 fetch. |
| `inbox:ollamaRecheck` | renderer → main → renderer | 신규 — main 이 `health.runOnce()` 호출, 결과 status push, telemetry `ollama_recheck_manual` emit. |
| `ollama:status` (push) | main → renderer | 신규 — onUpdate fire 시 main 이 webContents.send. (note:updated 패턴 mirroring) |
`inbox:ollamaStatus` 는 변경 없음 (기존 IPC 호환).
`pushOllamaStatus(getInboxWindow, status)` helper 추가 (`pushNoteUpdated` 의 자매):
```ts
// src/main/ipc/inboxApi.ts
export function pushOllamaStatus(getWin: () => BrowserWindow | null, status: HealthResult): void {
const w = getWin();
if (!w || w.isDestroyed()) return;
w.webContents.send('ollama:status', status);
}
```
`inbox:ollamaRecheck` handler — telemetry emit 은 HealthChecker 의 onTelemetry hook 으로 위임 (testability):
```ts
ipcMain.handle('inbox:ollamaRecheck', async () => {
await deps.health.runOnce({ manual: true }); // status 변경 시 onUpdate + ollama_recheck_manual onTelemetry fire
return deps.health.lastStatus();
});
```
---
## 4. store + UI
### 4.1 store.ts 확장
`InboxState` 에 신규 action + push subscriber:
```ts
recheckOllama: () => Promise<void>;
```
`loadInitial``useEffect` 에서 `inboxApi.onOllamaStatus(cb)` 구독 (note:updated 와 동일 패턴):
```ts
// App.tsx useEffect
const unsubOllama = inboxApi.onOllamaStatus((status) => {
set({ ollamaStatus: status });
});
return () => { unsubNote(); unsubOllama(); window.removeEventListener('focus', onFocus); };
```
`recheckOllama` action:
```ts
async recheckOllama() {
const status = await inboxApi.ollamaRecheck();
set({ ollamaStatus: status });
}
```
### 4.2 InboxApi + preload 확장
```ts
// shared/types.ts InboxApi
ollamaRecheck(): Promise<{ ok: boolean; reason?: string }>;
onOllamaStatus(cb: (status: { ok: boolean; reason?: string }) => void): () => void;
// preload/index.ts
ollamaRecheck: () => ipcRenderer.invoke('inbox:ollamaRecheck'),
onOllamaStatus: (cb) => {
const listener = (_e: unknown, status: { ok: boolean; reason?: string }) => cb(status);
ipcRenderer.on('ollama:status', listener);
return () => ipcRenderer.off('ollama:status', listener);
}
```
### 4.3 OllamaBanner 변경
`status.ok === false` 시 "재확인" 버튼 추가 (기존 메시지 + 진단 줄 옆 또는 아래):
```tsx
<button onClick={() => void recheckOllama()}></button>
```
기존 banner 스타일 유지 (warn variant).
### 4.4 Tray 메뉴
기존 `createTray` 의 컨텍스트 메뉴에 항목 추가:
```ts
{
label: 'Ollama 재확인',
enabled: !health.lastStatus().ok, // dynamic — 정상이면 disabled
click: () => void deps.recheckOllama()
}
```
`createTray` 가 7 positional callbacks 받는 현 구조에 1 callback 추가 — v0.2.4 backlog #4 (TrayCallbacks object refactor) 와 정합. 본 cut 에서는 8번째 callback 추가 + backlog #4 의 trigger 만 강화.
---
## 5. Telemetry
### 5.1 신규 3 events
| event | payload | 발화 |
|-------|---------|------|
| `ollama_unreachable` | `{ reason: string }` (max 500) | ok=true → ok=false 전이 (HealthChecker.onTelemetry) |
| `ollama_recovered` | `{ downtimeMs: number }` (≥0) | ok=false → ok=true 전이 |
| `ollama_recheck_manual` | `{}` (empty) | `inbox:ollamaRecheck` IPC handler |
### 5.2 zod schemas
```ts
// telemetryEvents.ts
const OllamaUnreachablePayload = z.object({
reason: z.string().min(1).max(500)
}).strict();
const OllamaRecoveredPayload = z.object({
downtimeMs: z.number().nonnegative()
}).strict();
const EmptyPayload = z.object({}).strict();
```
`reason` 의 source 는 `LocalOllamaProvider.healthCheck()` 가 반환하는 generic message — `'connection refused'`, `'not installed'`, `'timeout'`, `'http 500'` 등 generic. 본문/PII 누출 0건. max 500 cap 으로 anomaly fence.
### 5.3 stats.md 집계
신규 행 (휴지통 회수율 다음):
```
- Ollama unreachable 빈도: {count}건
- 평균 downtimeMs (recovered): {avg}
- 수동 recheck 사용량: {count}건
```
`DailyRow` 에 3 새 카운터 추가.
---
## 6. 테스트
| 영역 | 단위 | 검증 |
|------|------|------|
| HealthChecker | `start()` idempotent | 2회 호출 → timer 1개. |
| HealthChecker | `start()` 즉시 1회 + 60s 마다 | `vi.useFakeTimers()` advance, runOnce 호출 횟수. |
| HealthChecker | `stop()` cleanup | clearInterval. timer null. |
| HealthChecker | ok=true → ok=false 전이 | onUpdate fire, onTelemetry `ollama_unreachable {reason}` 1회. |
| HealthChecker | ok=false → ok=true 전이 | onUpdate fire, onTelemetry `ollama_recovered {downtimeMs}` 1회. downtimeMs ≈ now-unreachableSince. |
| HealthChecker | reason 변경 (ok=false 유지) | onUpdate fire, onTelemetry 0건. |
| HealthChecker | ok=true → ok=true 변화 없음 | onUpdate 0건. |
| TelemetryEvents | zod 3 신규 parse | happy + extra field reject (privacy invariant 회귀). |
| TelemetryStats | 3 카운터 + downtime 평균 | aggregateStats 검증. |
| IPC handler | `inbox:ollamaRecheck` | runOnce + telemetry.emit recheck_manual + status 반환. |
| Store | `recheckOllama` action | inboxApi.ollamaRecheck → set ollamaStatus. |
| Store | onOllamaStatus subscriber | push 받으면 set ollamaStatus. |
총 ≥ 12 단위. e2e 영향 없음.
---
## 7. 작업 순서 (writing-plans 시 task 분할 가이드)
T1. HealthChecker.start/stop + delta 전이 로직 + 단위 7개 (TDD)
T2. Telemetry 3 events (zod + EmitInput + stats.md 집계 + 단위 4개)
T3. main/index.ts wiring (`onUpdate` + `onTelemetry` + `start()` + `before-quit stop`) + 테스트는 T5 의 IPC 통해
T4. IPC `inbox:ollamaRecheck` + `pushOllamaStatus` helper + `ollama:status` push + 단위 1개
T5. shared/types InboxApi + preload + renderer onOllamaStatus subscriber + recheckOllama action + 단위 2개
T6. OllamaBanner 재확인 버튼 + tray 메뉴 항목 (visual integration)
T7. closure (gates + roadmap mark + memory backlog)
---
## 8. roadmap In/Out 일치
### 8.1 roadmap §3 #1 In 매핑
| roadmap | design |
|---------|--------|
| 60s polling, runOnce setInterval 자동 발화 | §2 ✓ |
| 회복 시 onUpdate → 구독 (renderer OllamaBanner) 자동 갱신 | §3.2 (push) + §4.1 (subscriber) ✓ |
| 실패 N회 후 polling 중단 정책 | Q2=A 절대 중단 안 함 |
| 수동 재확인 버튼 — OllamaBanner + 트레이 | §4.3 + §4.4 ✓ |
| IPC `inbox:ollamaRecheck` | §3.2 ✓ |
| Telemetry `ollama_unreachable {reason}`, `ollama_recovered {downtimeMs}`, `ollama_recheck_manual {}` | §5 ✓ |
| 단위 테스트 | §6 ≥ 12 |
### 8.2 Out 유지
- 사용자 설정 가능 polling 주기 — Out (Q1=A 60s 고정).
- 회복 toast 알림 — Out (banner 자동 사라짐만).
- model 정상성 (tags 외) 체크 — Out (provider 의 healthCheck 만 사용).
---
## 9. 위험 / 완화
| 위험 | 완화 |
|------|------|
| polling 이 app quit 시 leak | `app.on('before-quit')` 에서 `health.stop()`. 단위 테스트로 stop() 동작 가드. |
| onUpdate 가 status 매번 fire 되어 IPC 폭주 | delta only — last 와 비교 후만 fire. 단위 테스트로 ok=ok no-op 가드. |
| reason 문자열에 본문/PII 누출 | LocalOllamaProvider 가 generic message 만 반환. zod max length 500 cap. privacy invariant 단위 테스트. |
| recheck 가 polling 과 동시 발화 race | `runOnce()` async, sequential. provider.healthCheck() 가 자체적으로 동시 호출 안전 (HTTP GET). |
| reason 변경만으로 telemetry 폭주 (예: timeout ↔ refused 반복) | reason 변경 시 onUpdate fire 하지만 telemetry emit 안 함 — ratio 노이즈 회피. |
---
## 10. 게이트 (PR 머지 조건, roadmap §3.1 일치)
- `npm run typecheck` 0 에러
- `npm test` — 327 + 12+ = 339+
- `npm run test:e2e` 1/1
- main 머지
머지 후:
- roadmap `### #1 Ollama 회복 (4번)``✓ 완료`
- `memory/project_v024_backlog.md` review deferred 항목 누적
---
## 11. 변경 이력
| 일자 | 변경 |
|------|------|
| 2026-05-01 | 초안 — Q1=A (60s), Q2=A (절대 중단 안 함), Q3=A (constant). HealthChecker.start/stop + delta-only onUpdate + 3 telemetry events + main → renderer push (`ollama:status`) + manual recheck (banner + tray). |
| 2026-05-01 | §2.1 / §3.2 보강 — `runOnce({ manual?: boolean })` 인자 추가, `ollama_recheck_manual` 도 onTelemetry hook 으로 통합 (IPC handler 가 직접 emit 안 함). 단위 테스트 가능. |

View File

@@ -0,0 +1,385 @@
# #4 휴지통 (soft delete + migration v3) 설계
**작성일:** 2026-05-01
**저자:** 김태현 (dlsrks0734@gmail.com)
**문서 성격:** v0.2.3 로드맵의 두 번째 항목. mini-brainstorm 결과를 잠그고 구현 계획 (`writing-plans`) 으로 넘기는 분기 spec. 본 문서는 **데이터 모델·외부 API·UI 결정** 만 정의. 세부 코드 토폴로지는 plan 단계에서.
**선행 문서:**
- `docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md` §3 #4 — 본 항목의 In/Out 라인 + cross-cutting 정책
- `docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md` §1 — schema migration v3, trash↔backup/export B 정책
- `docs/superpowers/specs/2026-04-26-feedback-roadmap-design.md` §5.1 — pre-v<N>.bak snapshot 메커니즘 (v0.2.1 도입)
- v0.2.3 #7 telemetry skeleton (merged at `6f8ae75`) — 본 항목이 emit hook 대상
---
## 1. 결정 요약
| 영역 | 값 | 근거 |
|------|-----|------|
| Schema | **migration v3**`deleted_at TEXT NULL` + `last_recalled_at TEXT NULL` + `recall_dismissed_at TEXT NULL` (#6 도 같이) | 한 migration 으로 #4+#6 cover. 별 v4 회피. |
| UI 위치 | **Inbox 상단 탭 toggle** (`Inbox(N) · 휴지통(M)`) | 현재 router 없음, single-page 구조 일관. v0.2.1 F2 태그 필터 패턴 (`tagFilter` zustand) 동일 흐름. |
| 쿼리 필터 전략 | **명시적 WHERE** — 모든 active query 에 `WHERE deleted_at IS NULL` 직접 박음 | 기존 SQL prepare 패턴 일관. grep audit 가능. C (silent at hydration) 의 AiWorker race window 회피. |
| AiWorker race | **C — pending_jobs cleanup + processJob 가드** (둘 다) | atomic + 이미 dequeue 한 race window 도 가드. result 적용 직전 재체크는 의도적 skip — restore 시 AI 결과 보존이 UX 유리. |
| 휴지통 액션 | **per-card 복구 + per-card 영구 삭제 + bulk 휴지통 비우기** | per-card 영구 삭제는 fine-grained 삭제 욕구 대응. roadmap §3 #4 의 4채널 → 5채널 (`permanentDelete` 추가) 으로 확장. |
| Confirm UX | **Electron `dialog.showMessageBox`** — F5/F6 패턴 일관 | 신규 React 모달 회피. native = 운영체제 톤. |
| 정렬 | **`deleted_at DESC`** | 회수 의도 매칭 (최근 삭제 먼저). |
| Card 차이 | **휴지통 카드 = read-only** — edit 액션 hidden, raw text 토글은 보존 | roadmap §3 #4 Out (`trash 안 노트 편집`) 일관. |
| F5 export | **`deleted_at IS NOT NULL` 제외** | trash B 정책 (roadmap §1). |
| F6-L1 backup | **byte-for-byte 자동 포함** | SQLite copy. 무수정. |
| F6-L3 import | **`deleted_at` source/dest 중 IS NOT NULL 우선** | 삭제 보존 invariant. |
| Restore 시 AI 결과 | **그대로 살아있음** (race window self-healing) | trash 도중 AI 결과 박힌 경우 restore 시 노트가 결과까지 함께 회수. UX positive. |
### 1.1 v0.2.3 #4 roadmap 와의 차이
| 항목 | roadmap §3 #4 | 본 spec |
|------|---------------|---------|
| 휴지통 액션 | 복구 + bulk emptyTrash (4 IPC 채널) | + per-card 영구 삭제 (5 IPC 채널) |
| Telemetry kinds | `trash` / `restore` / `emptyTrash` (3) | + `permanent_delete` (4) |
**근거:** mini-brainstorm 에서 사용자 결정 (B 옵션 — fine-grained 영구 삭제 추가). 본 spec 의 결정이 roadmap 보다 우선.
---
## 2. Data model
### 2.1 Migration v3 — `m003_soft_delete.ts`
```sql
ALTER TABLE notes ADD COLUMN deleted_at TEXT;
ALTER TABLE notes ADD COLUMN last_recalled_at TEXT;
ALTER TABLE notes ADD COLUMN recall_dismissed_at TEXT;
CREATE INDEX idx_notes_deleted_at ON notes(deleted_at);
```
- `deleted_at`: ISO timestamp (UTC). `NULL` = active, IS NOT NULL = trashed.
- `last_recalled_at`: #6 가 사용. v3 에서 컬럼만 추가, `Note` type 노출 + 사용은 #6.
- `recall_dismissed_at`: #6 가 사용. 위와 동일.
- `idx_notes_deleted_at`: `WHERE deleted_at IS NULL` 쿼리 다수, partial index 효과 기대. SQLite 가 NULL 스파스 인덱스 효율 잘 처리.
m001/m002 와 같이 `version = 3` export 후 `migrations/index.ts` 의 array 에 등록. transaction 내 실행. 실패 시 트랜잭션 롤백 + 사용자에게 보고.
**pre-v3 snapshot:** `<dbFile>.pre-v3.bak` 자동 생성 (v0.2.1 메커니즘 그대로). v0.2.2 → v0.2.3 첫 실행 시 한 번.
### 2.2 `Note` 타입 (`@shared/types`) 확장
```typescript
export interface Note {
// ... 기존 필드 ...
deletedAt: string | null; // #4 가 사용
lastRecalledAt: string | null; // #6 가 사용 (v0.2.3 #4 단계엔 항상 null 으로 hydrate)
recallDismissedAt: string | null; // #6 가 사용 (위와 동일)
}
```
세 필드 모두 v3 부터 schema 에 존재하므로 hydration 코드는 한 번에 추가. 사용은 단계별.
### 2.3 Schema invariant 추가
slice §1.3 silent invariant 후보 (roadmap §6.3 에서 #4 머지 시 동봉 갱신):
> **`deleted_at IS NULL` 망각 0회** — 모든 active query (Inbox / countToday / findByTag / search / F5 export / AiWorker 처리) 가 `WHERE deleted_at IS NULL` 을 빠뜨리지 않는다. 위반 시 dogfood-feedback 즉시 재오픈.
---
## 3. NoteRepository 변경
### 3.1 신규 메서드
| 메서드 | SQL | 부수 효과 |
|--------|-----|-----------|
| `trash(id, deletedAt: string): void` | `UPDATE notes SET deleted_at=?, updated_at=? WHERE id=?` + `DELETE FROM pending_jobs WHERE note_id=?` (한 transaction) | AI 큐 깨끗. atomic. |
| `restore(id): void` | `UPDATE notes SET deleted_at=NULL, updated_at=? WHERE id=?` | 노트 active 복귀. AI 결과 보존됨. |
| `permanentDelete(id): void` | `DELETE FROM notes WHERE id=?` | cascade FK (`note_tags` / `media` / `pending_jobs`) 자동 정리. media 파일 정리는 caller (`CaptureService`) 책임. |
| `emptyTrash(): { noteIds: string[] }` | `SELECT id FROM notes WHERE deleted_at IS NOT NULL` → 각 id `permanentDelete` (한 transaction). 반환된 `noteIds` 로 caller 가 media 정리. |
| `listTrashed(opts: {limit, cursor?}): Note[]` | `WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC` | cursor = `deleted_at` 값 기준. |
### 3.2 기존 메서드 변경
`delete(id)`**deprecate** (호출 site 0건 보장). hard delete 는 `permanentDelete()` 로만. 단계적 cleanup — `delete()` 를 즉시 제거하지 않고 `@deprecated` 로 표시 후 v0.2.4 cut 시 삭제.
### 3.3 Active query 일괄 변경 (`WHERE deleted_at IS NULL` 추가)
| 메서드 | 현재 | 변경 후 |
|--------|------|---------|
| `list(opts)` | `ORDER BY created_at DESC LIMIT ?` | `WHERE deleted_at IS NULL ORDER BY ... LIMIT ?` |
| `listAll()` | `ORDER BY created_at ASC` | `WHERE deleted_at IS NULL ORDER BY ...` |
| `countToday(now?)` | KST today filter | `WHERE deleted_at IS NULL AND ...` |
| `getAllPendingJobs()` | `pending_jobs` 직접 select | **변경 없음**`trash()` 가 atomic 하게 `pending_jobs` row 정리하는 invariant 가 자연 보장. AiWorker `processJob``deletedAt` 가드는 이미 dequeue 한 race 만 처리. |
| `findById(id)` | **변경 없음** — 휴지통 카드도 같은 메서드 사용. `Note.deletedAt` 으로 호출자가 분기. |
NoteRepository 에는 현재 `findByTag` / search 메서드가 없다 — 태그 필터링은 renderer 의 `selectFilteredNotes` 에서 client-side 로 수행 (zustand `tagFilter` state). 따라서 active query 변경은 위 표의 3 메서드 (`list`, `listAll`, `countToday`) + AiWorker 가드 + `getAllPendingJobs` 의 invariant 보존이 전부.
---
## 4. CaptureService 변경
### 4.1 메서드 변경
```typescript
async deleteNote(noteId: string): Promise<void> {
this.repo.trash(noteId, new Date().toISOString());
// media 는 그대로 둔다 (restore 시 필요)
if (this.deps.telemetry) {
await this.deps.telemetry.emit({ kind: 'trash', payload: { noteId } }).catch(() => {});
}
}
```
### 4.2 신규 메서드
```typescript
async restoreNote(noteId: string): Promise<void> {
this.repo.restore(noteId);
if (this.deps.telemetry) await this.deps.telemetry.emit({ kind: 'restore', payload: { noteId } }).catch(() => {});
}
async permanentDeleteNote(noteId: string): Promise<void> {
this.repo.permanentDelete(noteId);
await this.store.deleteNoteDirectory(noteId);
if (this.deps.telemetry) await this.deps.telemetry.emit({ kind: 'permanent_delete', payload: { noteId } }).catch(() => {});
}
async emptyTrash(): Promise<{ count: number }> {
const { noteIds } = this.repo.emptyTrash();
for (const id of noteIds) {
try { await this.store.deleteNoteDirectory(id); }
catch (e) { /* best-effort */ }
}
if (this.deps.telemetry) await this.deps.telemetry.emit({ kind: 'empty_trash', payload: { count: noteIds.length } }).catch(() => {});
return { count: noteIds.length };
}
```
### 4.3 Telemetry interface 확장
`CaptureService.ts``TelemetryEmitter` 인터페이스에 4 union 멤버 추가:
```typescript
export interface TelemetryEmitter {
emit(input:
| { kind: 'capture'; payload: { noteId: string; rawTextLength: number; hasMedia: boolean } }
| { kind: 'trash'; payload: { noteId: string } }
| { kind: 'restore'; payload: { noteId: string } }
| { kind: 'permanent_delete'; payload: { noteId: string } }
| { kind: 'empty_trash'; payload: { count: number } }
): Promise<void>;
}
```
`TelemetryService.ts``EmitInput` union 도 같은 4 추가. `telemetryEvents.ts` 의 zod `discriminatedUnion` 에도 4 새 멤버, 각 payload `.strict()`. **Privacy invariant** 그대로 — payload 에 `noteId` / `count` 만, raw text/title/summary/intent/tag name 절대 미포함. zod 가 거부.
`stats.md` 집계 (`telemetryStats.aggregateStats`) 도 4 신규 카운트 컬럼 추가:
```
| 일자 | capture | ai_succeeded | ai_failed | trash | restore | permanent_delete | empty_trash |
```
핵심 ratio:
- `restore / trash` — 휴지통이 회수 도구로 동작?
---
## 5. AiWorker 가드
`processJob` 진입 시 deletedAt 체크 추가:
```typescript
const note = this.repo.findById(job.noteId);
if (!note || note.deletedAt !== null || note.aiStatus !== 'pending') return;
```
`pending_jobs` 정리는 `trash()` 가 atomic 하게 처리하므로 정상 흐름에서 dead row 미발생. AiWorker 가 이미 dequeue 한 후 trash 된 race 만 본 가드가 cover.
result 적용 (`updateAiResult`) 직전 재체크는 의도적으로 skip — restore 시 AI 결과 살아있어 UX 유리.
---
## 6. IPC
### 6.1 신규 채널 (5개)
`src/main/ipc/inboxApi.ts``registerInboxApi` 에 추가:
| 채널 | 핸들러 | 응답 |
|------|--------|------|
| `inbox:trash` | `(_, id: string) => capture.deleteNote(id)` | `void` |
| `inbox:restore` | `(_, id: string) => capture.restoreNote(id)` | `void` |
| `inbox:permanentDelete` | `(_, id: string) => capture.permanentDeleteNote(id)` | `void` |
| `inbox:emptyTrash` | `() => capture.emptyTrash()` | `{ count: number }` |
| `inbox:listTrash` | `(_, opts) => repo.listTrashed(opts)` | `Note[]` |
confirm dialog (per-card 영구 삭제 / bulk emptyTrash) 는 main 프로세스에서 `dialog.showMessageBox` 호출 (트레이 export/import 와 동일 패턴). 사용자 confirm 후에야 IPC 가 실제 작업 수행.
### 6.2 기존 `inbox:delete` 처리
기존 `inbox:delete` 는 그대로 유지하되 내부적으로 `capture.deleteNote(id)` 가 trash 호출 (변경된 동작). 채널 이름은 유지 — renderer 에서 `inboxApi.deleteNote` 호출하던 곳 (`NoteCard` 의 "🗑 삭제" 버튼) 이 그대로 동작 (의미만 hard → soft 로 변경). 단계적 마이그레이션 — v0.2.4 에서 `inbox:trash` 로 rename 검토.
---
## 7. Renderer (Inbox)
### 7.1 zustand store 확장
```typescript
interface InboxState {
// 기존 ...
showTrash: boolean; // false = Inbox view, true = 휴지통 view
trashNotes: Note[]; // 휴지통 노트 cache
trashCount: number; // 헤더 탭 라벨 (`휴지통(M)`)
toggleShowTrash(): void;
loadTrash(): Promise<void>;
restoreNote(id: string): Promise<void>;
permanentDeleteNote(id: string): Promise<void>;
confirmEmptyTrash(): Promise<void>;
}
```
`toggleShowTrash``showTrash` 토글 + 진입 시 `loadTrash()` 호출.
`confirmEmptyTrash` 는 IPC `inbox:emptyTrash` 호출 (main 이 dialog 띄움). 사용자 cancel 시 `count: 0` 반환.
`upsertNote(note)` / `removeNote(id)``notes``trashNotes` 양쪽 다 갱신 — note 의 `deletedAt` 값으로 어느 list 에 들어갈지 결정.
### 7.2 UI 추가
`App.tsx` 헤더 영역 (h1 + ContinuityBadge 옆):
```tsx
<button onClick={() => setShowTrash(false)} aria-pressed={!showTrash}>
Inbox({notes.length})
</button>
<button onClick={() => setShowTrash(true)} aria-pressed={showTrash}>
({trashCount})
</button>
```
`showTrash === true` 시:
- 상단에 "휴지통 비우기 (M개)" 버튼 (M=0 이면 disabled). 클릭 → `confirmEmptyTrash()`.
- `trashNotes.map(n => <NoteCard note={n} mode="trash" />)`
### 7.3 NoteCard prop `mode`
```tsx
type NoteCardProps = { note: Note; mode?: 'inbox' | 'trash' };
```
`mode === 'trash'` 시:
- DueDateBadge: read-only (날짜 텍스트만 표시, 클릭 무반응)
- IntentBanner: hidden
- 태그 chip: ✕ 버튼 hidden, 클릭 시 필터링 동작 X
- "🗑 삭제" 버튼 → "🔄 복구" + "🗑 영구 삭제" 두 버튼으로 교체
- raw text 토글 (`▸ 원문 보기`): 보존 (read-only 도 본문 확인 필요)
- EditableField (title / summary): read-only 모드 (input 비활성)
빈 휴지통 상태 (`trashNotes.length === 0` AND `showTrash`):
> "휴지통이 비어있습니다."
### 7.4 Confirm dialog 카피
`dialog.showMessageBox` 옵션:
**bulk emptyTrash:**
- type: `question`
- buttons: `['휴지통 비우기', '취소']`
- defaultId: 1, cancelId: 1
- title: `Inkling`
- message: `휴지통의 노트 ${count}개를 영구 삭제합니다`
- detail: `이 작업은 되돌릴 수 없습니다. 첨부된 이미지도 함께 삭제됩니다.`
**per-card 영구 삭제:**
- buttons: `['영구 삭제', '취소']`
- message: `이 노트를 영구 삭제합니다`
- detail: 위와 동일
---
## 8. F5 export / F6-L3 import / F6-L1 backup
### 8.1 ExportService
`repo.listAll()` 자체에 `WHERE deleted_at IS NULL` 추가 (active query exclusion 의 일환, §3.3 표 그대로). ExportService 코드는 무수정 — `repo.listAll()` 호출이 자동으로 trash 제외하게 됨. 휴지통 export 는 본 cut 범위 외 (Out, §10).
### 8.2 ImportService
`ImportNoteInput` interface 에 `deletedAt?: string | null` 추가. INSERT statement 에 컬럼 + 값 추가. fork 케이스 (raw_text 다름) 에서도 `deletedAt` 보존.
충돌 해결 — id 동일 + raw_text 동일 (skip) 또는 raw_text 상이 (fork) 가 기존 정책. `deletedAt` 머지는 그 위에 추가:
- **id 동일 + raw_text 동일** (skip 케이스): source 가 `deletedAt IS NOT NULL` 이고 dest 가 `IS NULL` 이면 dest 의 `deleted_at` 을 source 값으로 **갱신** (삭제 보존). 그 외는 그대로 skip.
- **id 동일 + raw_text 상이** (fork 케이스): source 의 `deletedAt` 을 새 fork 노트에 그대로 넣음 (raw_text invariant 보존이 우선이라 fork 자체는 기존대로).
- **id 신규** (insert 케이스): source 의 `deletedAt` 을 그대로 INSERT.
- **양쪽 IS NOT NULL** (skip 케이스 의 corner case): 단순화 — dest 값 유지 (skip). roadmap §1 의 "IS NOT NULL 우선" 은 한쪽이 NULL 일 때만 결정 영향, 양쪽 IS NOT NULL 시엔 dest 가 이미 trash 라 "삭제 보존" 자체는 만족.
### 8.3 BackupService
무수정. SQLite `db.backup()` 가 byte-for-byte 카피 — `deleted_at IS NOT NULL` 노트도 자동 포함.
---
## 9. 단위 테스트 (TDD 가이드)
### 9.1 Migration v3
- 빈 DB v0 → v3 migrate 후 `deleted_at` / `last_recalled_at` / `recall_dismissed_at` 컬럼 + `idx_notes_deleted_at` 존재 확인
- v2 DB → v3 migrate 시 기존 노트의 새 컬럼 모두 NULL
- migrate idempotent (이미 v3 인 DB 재실행 시 변경 없음)
- pre-v3.bak snapshot 자동 생성 (한 번만)
### 9.2 NoteRepository
- `trash(id, deletedAt)``deleted_at` 설정 + `pending_jobs` row 정리 (atomic — 한 transaction 내 두 쿼리)
- `restore(id)``deleted_at` NULL 복원
- `permanentDelete(id)` 가 cascade FK 통해 `note_tags` / `media` / `pending_jobs` 정리
- `emptyTrash()` 가 IS NOT NULL 노트 모두 hard delete + 반환된 noteIds 정확
- `listTrashed()``deleted_at DESC` 정렬, IS NOT NULL 만 반환
- `list()` / `listAll()` / `countToday()``deleted_at IS NULL` 만 반환 (active query exclusion)
- `findById()` 는 휴지통 노트도 반환 (모든 노트)
- `getAllPendingJobs()` 가 trash 노트 미반환 (join 또는 trash cleanup invariant)
### 9.3 AiWorker
- `processJob``deletedAt IS NOT NULL` 노트 즉시 return (provider.generate 미호출)
- 정상 노트는 그대로 처리 (회귀 없음)
### 9.4 CaptureService
- `deleteNote` 가 trash 호출 + telemetry `trash` emit (media 미삭제)
- `restoreNote` 가 restore 호출 + telemetry `restore` emit
- `permanentDeleteNote` 가 hard delete + media 디렉터리 정리 + telemetry `permanent_delete` emit
- `emptyTrash` 가 모든 trash 노트 hard delete + 각 media 정리 + telemetry `empty_trash` emit (count 정확)
### 9.5 ExportService (F5)
- 활성 노트만 export, trash 노트 제외 (frontmatter 마크다운 파일 미생성)
- `index.jsonl` 도 trash 미포함
### 9.6 ImportService (F6-L3)
- source 의 `deletedAt` 값이 import 후 보존
- 충돌 해결 — source/dest 중 IS NOT NULL 우선 4가지 조합 모두
### 9.7 Telemetry events
- 4 신규 kind (`trash` / `restore` / `permanent_delete` / `empty_trash`) zod 검증 통과
- payload `.strict()``rawText` / `title` / `summary` / `userIntent` / `tagNames` 포함 시 거부 (기존 invariant 유지)
- `aggregateStats` 가 4 신규 컬럼 카운트 정확, `restore / trash` ratio 계산
### 9.8 e2e smoke (Playwright)
- 노트 캡처 → trash 클릭 → Inbox 에서 사라지고 휴지통 탭(1) 표시
- 휴지통 탭 진입 → 노트 보임, 복구 클릭 → 다시 Inbox
- per-card 영구 삭제 confirm → 노트 영구 사라짐, media 디렉터리 정리
---
## 10. Out (deferred to v0.2.4+)
- 자동 비우기 정책 (사용자 트리거만 — 30일 자동 비우기 등은 차후)
- 휴지통 검색 (full-text 또는 태그 필터)
- trash 안 노트 편집 (read-only invariant 깨지면 회귀)
- per-note 영속 보호 플래그 (lock 같은 것)
- restore 시 AI 결과 보존 invariant 명시 — 본 spec 에 짧게 언급, 별 spec 화는 v0.2.4 reason 분포 본 후
- `inbox:delete` 채널 → `inbox:trash` 로 rename (단계적 마이그레이션)
- 휴지통에서 다중 선택 (멀티 복구 / 멀티 영구 삭제)
- `last_recalled_at` / `recall_dismissed_at` 활용 — #6 가 사용
---
## 11. 변경 이력
| 일자 | 변경 |
|------|------|
| 2026-05-01 | 초안 — UI=A (Inbox 탭), 필터=A (명시적 WHERE), AiWorker race=C (cleanup+가드), 액션=B (per-card 영구 삭제 추가, 5 IPC 채널), confirm/정렬/카드차이 모두 A. roadmap §3 #4 의 4채널 → 5채널 확장 명시. |

View File

@@ -0,0 +1,321 @@
# v0.2.3 #6 리마인드 1 spike — Design Spec
> 작성: 2026-05-02 · v0.2.3 dogfood feedback roadmap §3 #6 (7번째 / 마지막 cut)
## 1. Goal
Inbox 상단에 "오늘 회상해볼 노트" 1건 추천 배너 (`RecallBanner`) 도입. 7일 이상 안 본 노트 중 가장 오래된 1건을 제시하여 사용자가 자기 기록을 재방문할 기회 제공. 4종 telemetry (`recall_shown` / `recall_opened` / `recall_dismissed` / `recall_snoozed`) 로 효과 측정 인프라 마련.
## 2. Decisions (mini-brainstorm 합의)
| # | 질문 | 선택 | 이유 |
|---|---|---|---|
| Q1 | 다음에 snooze 영속화 | **A** in-memory | `expiredSnoozeUntilMs` 패턴 일관, schema migration v4 회피, dogfood telemetry 보고 v0.2.4 영속화 결정 |
| Q2 | `ageDays` 의미 | **B** `last_recalled_at ?? created_at` 기준 | algo 의 "7일 안 본 노트" trigger 와 동일 axis, 재추천 분포 측정 가치 |
자명 결정 (질문 없이 패턴 따름):
- Banner 위치: `ExpiryBanner` 다음 (stack 끝, 시간 민감도 가장 낮음)
- 0건 시: `null` return (`ExpiryBanner` 패턴)
- Snooze duration: KST 다음 자정 (`snoozeExpired` 패턴)
- "열어보기" 동작: `scrollIntoView` (NoteCard 항상 expanded — expand 동작 X)
## 3. Architecture & data flow
```
Inbox 마운트 시:
loadInitial() → recallCandidate fetch (별도 fetch, 단일 노트 또는 null)
RecallBanner render (recallCandidate !== null && !snoozed):
┌─ "오늘 회상해볼 노트" + 노트 제목 + (N일 전)
├─ [열어보기] → scrollIntoView(noteCardRef) + markRecallOpened(id)
│ → telemetry: recall_opened
├─ [다음에] → store.snoozeRecall() (KST 다음 자정까지 in-memory)
│ → telemetry: recall_snoozed
└─ [더 이상] → dismissRecall(id) (DB: recall_dismissed_at = now)
→ telemetry: recall_dismissed
Banner 첫 렌더 시 자동 emit: recall_shown { noteId, ageDays }
다음 fetch 트리거:
- markRecallOpened / dismissRecall 후 store 가 자동 다음 후보 fetch
- refreshMeta (focus / inbox:noteUpdated) 도 fetch
```
### 3.1 Invariants
1. **단일 후보 fetch**`LIMIT 1` + `ORDER BY created_at ASC` (가장 오래된 1건)
2. **KST 보정** — SQL 의 `date('now')` 자리 모두 `date('now','+9 hours')`
3. **마감 임박 노트 제외**`due_date < today` 인 노트는 ExpiryBanner 영역 (#5) 이라 회상 후보에서 빠짐
4. **Snooze in-memory**`recallSnoozeUntilMs` store 변수, KST 다음 자정 (ExpiryBanner 패턴)
5. **emit 순서**`recall_shown` (banner 첫 렌더) → `recall_opened/dismissed/snoozed` (사용자 액션)
6. **Snooze 시 `recall_shown` 1회만** — 같은 후보가 다시 보여도 `recall_shown` 재emit 안 함 (notes 1건당 session 1 shown — `recallShownIds: Set<string>` in-memory)
## 4. Components
### 4.1 `NoteRepository`
#### `findRecallCandidate(): Note | null`
```sql
SELECT * FROM notes
WHERE (last_recalled_at IS NULL OR last_recalled_at < date('now','+9 hours','-7 day'))
AND (recall_dismissed_at IS NULL OR recall_dismissed_at < date('now','+9 hours','-30 day'))
AND ai_status = 'done'
AND deleted_at IS NULL
AND (due_date IS NULL OR due_date >= date('now','+9 hours'))
ORDER BY created_at ASC
LIMIT 1
```
기존 `hydrate(row)` 사용 (이미 `last_recalled_at` / `recall_dismissed_at` 매핑 있음).
#### `markRecallOpened(id: string, now: string): void`
```sql
UPDATE notes SET last_recalled_at = ?, updated_at = ? WHERE id = ?
```
#### `dismissRecall(id: string, now: string): void`
```sql
UPDATE notes SET recall_dismissed_at = ?, updated_at = ? WHERE id = ?
```
### 4.2 `CaptureService` (5 신규 메서드)
```typescript
async listRecallCandidate(): Promise<Note | null> {
return this.repo.findRecallCandidate();
}
async markRecallOpened(noteId: string): Promise<{ note: Note }> {
const note = this.repo.findById(noteId);
if (!note) throw new Error(`note not found: ${noteId}`);
this.repo.markRecallOpened(noteId, new Date().toISOString());
if (this.deps.telemetry) {
await this.deps.telemetry.emit({
kind: 'recall_opened',
payload: { noteId }
}).catch(() => {});
}
return { note: this.repo.findById(noteId)! };
}
async dismissRecall(noteId: string): Promise<{ note: Note }> {
this.repo.dismissRecall(noteId, new Date().toISOString());
if (this.deps.telemetry) {
await this.deps.telemetry.emit({
kind: 'recall_dismissed',
payload: { noteId }
}).catch(() => {});
}
return { note: this.repo.findById(noteId)! };
}
async emitRecallShown(noteId: string): Promise<void> {
const note = this.repo.findById(noteId);
if (!note) return;
const ageDays = this.computeAgeDays(note);
if (this.deps.telemetry) {
await this.deps.telemetry.emit({
kind: 'recall_shown',
payload: { noteId, ageDays }
}).catch(() => {});
}
}
async emitRecallSnoozed(noteId: string): Promise<void> {
if (this.deps.telemetry) {
await this.deps.telemetry.emit({
kind: 'recall_snoozed',
payload: { noteId }
}).catch(() => {});
}
}
private computeAgeDays(note: Note): number {
const ref = note.lastRecalledAt ?? note.createdAt;
const refMs = new Date(ref).getTime();
const nowMs = Date.now();
return Math.max(0, Math.floor((nowMs - refMs) / 86_400_000));
}
```
### 4.3 IPC (5 신규 channels)
```typescript
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));
```
### 4.4 Preload + InboxApi shared type
```typescript
// preload/index.ts
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),
```
```typescript
// shared/types.ts InboxApi
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>;
```
### 4.5 `telemetryEvents.ts` zod
```typescript
const RecallShownPayload = z.object({
noteId: z.string().min(1),
ageDays: z.number().int().nonnegative()
}).strict();
// recall_opened / recall_dismissed / recall_snoozed → 기존 NoteIdPayload 재사용
```
union 15 → **19** (recall_shown + recall_opened + recall_dismissed + recall_snoozed).
### 4.6 `telemetryStats.ts`
- DailyRow +4 cols (`recall_shown`, `recall_opened`, `recall_dismissed`, `recall_snoozed`)
- accumulators: `recallShownCount`, `recallOpenedCount`, `recallDismissedCount`, `recallSnoozedCount`, `recallAgeDaysSum`
- summary lines:
```
- 회상 추천: shown {N} / opened {O} / dismissed {D} / snoozed {S} (열림율 {O/N}%)
- 회상 평균 ageDays: {avg}
```
N=0 시 `(데이터 없음)`
### 4.7 `TelemetryService.EmitInput` union 15 → 19
### 4.8 Renderer store (`src/renderer/inbox/store.ts`)
```typescript
interface InboxState {
// ... existing ...
recallCandidate: Note | null;
recallSnoozeUntilMs: number | null;
recallShownIds: Set<string>; // session-local, "1 shown per note per session"
loadRecallCandidate: () => Promise<void>;
openRecall: (id: string) => Promise<void>;
dismissRecallNote: (id: string) => Promise<void>; // store action 명, IPC 와 구별
snoozeRecall: () => Promise<void>;
}
```
`refreshMeta` + `loadInitial` 가 `loadRecallCandidate` 도 호출.
`openRecall(id)`:
- `inboxApi.markRecallOpened(id)` → DB 갱신
- `loadRecallCandidate()` → 다음 후보 fetch
- (스크롤은 RecallBanner 컴포넌트가 자체 처리)
`dismissRecallNote(id)`:
- `inboxApi.dismissRecall(id)` → DB 갱신
- `loadRecallCandidate()` → 다음 후보 fetch
`snoozeRecall()`:
- `recallSnoozeUntilMs = nextKstMidnight()` (`snoozeExpired` 패턴)
- 현재 candidate noteId 기준 `inboxApi.emitRecallSnoozed(id)`
### 4.9 RecallBanner 컴포넌트
**파일**: `src/renderer/inbox/components/RecallBanner.tsx` (신규)
- 위치: `<ExpiryBanner />` 다음 (App.tsx)
- 첫 렌더 시 `useEffect` 가 `recallShownIds` 체크 후 미emit 시 `inboxApi.emitRecallShown(id)` 호출 + Set 에 추가
- Banner UI: 노트 제목 + ageDays + 3개 버튼 (열어보기 / 다음에 / 더 이상)
- `null` return: candidate=null OR snoozed (Date.now < snoozeUntilMs)
- snoozeUntilMs 만료 시 setTimeout re-render 트리거 (ExpiryBanner 패턴)
### 4.10 NoteCard ref 시스템 (scroll target)
App.tsx 가 `noteRefs: Map<noteId, HTMLDivElement | null>` ref store 보유 + RecallBanner 가 store 의 ref 를 lookup 후 `scrollIntoView({ behavior: 'smooth', block: 'center' })` 호출.
구체 구현:
- `App.tsx` 가 `useRef<Map<string, HTMLDivElement | null>>(new Map())` 보유
- 각 `<NoteCard>` 에 `ref={(el) => { noteRefs.current.set(note.id, el); }}` 전달 (NoteCard 가 ref forwardRef 지원 필요)
- RecallBanner 가 `noteRefs` prop 으로 받아 사용
**대안 (단순)**: `document.getElementById(\`note-${id}\`)` — App.tsx 의 NoteCard 가 `id={\`note-${note.id}\`}` 만 추가하면 됨. **이 spike 에선 이 단순 방식 채택** (ref 시스템 복잡도 회피).
## 5. Privacy invariant
- `recall_shown.payload`: `{ noteId, ageDays }` — noteId 기존 패턴, ageDays 정수
- `recall_opened/dismissed/snoozed.payload`: `{ noteId }` — `NoteIdPayload` 재사용
- `.strict()` zod 가드 + extra field 거부 테스트
## 6. Tests (≥17개)
### NoteRepository.test.ts (5)
1. 빈 db → null
2. last_recalled_at 5일 전 노트 제외 (7일 이내)
3. last_recalled_at 8일 전 노트 후보 (7일 초과)
4. recall_dismissed_at 25일 전 제외, 35일 전 후보
5. deleted_at / ai_status='pending' / due_date < today 모두 제외
### CaptureService.test.ts (4)
6. listRecallCandidate → repo.findRecallCandidate
7. markRecallOpened → repo + recall_opened emit + last_recalled_at 갱신 검증
8. dismissRecall → repo + recall_dismissed emit + recall_dismissed_at 갱신 검증
9. emitRecallShown → ageDays 정확 (last_recalled NULL 시 createdAt 기준)
### telemetryEvents.test.ts (3)
10. recall_shown valid parse (noteId + ageDays)
11. recall_shown extra field 거부 (privacy)
12. recall_opened/dismissed/snoozed valid parse (noteId only)
### telemetryStats.test.ts (2)
13. shown/opened/dismissed/snoozed 누적 + 열림율 계산
14. 평균 ageDays 계산
### store.recall.test.ts (신규, 3)
15. snoozeRecall → snoozeUntilMs KST 다음 자정 + emitRecallSnoozed 호출
16. openRecall → API 호출 + recall_shown 한 번만 emit (recallShownIds set)
17. dismissRecallNote → 후보 다시 fetch
총 신규 단위 **17개**. 기존 단위 386 + 17 = **403** 예상.
## 7. Out of scope
(roadmap §3 #6 + 본 cut 결정)
- 잠금해제 hook (F4-A, strategy.md)
- 무작위 토스트 (F4-D)
- ambient if-then (F4-B)
- 임베딩 유사도 추천 (#3 vocab 후속)
- spaced repetition (Leitner / SM-2)
- 다중 후보 추천 (현재 `LIMIT 1` only)
- snooze 영속화 (Q1=A in-memory)
- 사용자 정의 회상 주기 (7일 hardcoded)
- "회상 history" 보기 (last_recalled_at 만 저장, 이전 history X)
- RecallBanner 컴포넌트 단위 테스트 (Inkling 패턴: store 단위만 테스트)
## 8. Gates (roadmap §3.1)
- typecheck 0
- 단위 386 → 403 (+17), 모두 통과
- e2e 1/1
- 새 SQL: 복합 조건 — `idx_notes_ai_status` + `idx_notes_created_at` 활용. 별도 인덱스 불필요.
## 9. `strategy.md` 갱신 (별도 task)
roadmap §3 #6 In 절: §2.3 / §4.3 / §8 갱신:
- Capitalize 본격 진입 (회상 surface 도입)
- "오늘 회상" surface 정의
- F4-A/B/D deferred 항목의 측정 인프라 마련 명시 (recall_* telemetry 가 그 기반)
## 10. Roadmap relation
- v0.2.3 dogfood feedback #6 (7번째 / 마지막 cut)
- 머지 후 v0.2.3 cut 7/7 완료 → v0.2.3 binary 빌드 + 핸드오프
- v0.2.4 후속: dogfood telemetry 분석 (열림율, 평균 ageDays), F4-A/B/D 본격 진행, snooze 영속화 결정

View File

@@ -0,0 +1,255 @@
# v0.2.3 #3 태그 vocab — Design Spec
> 작성: 2026-05-02 · v0.2.3 dogfood feedback roadmap §3 #3 (6번째 cut)
## 1. Goal
기존 `tags` 테이블의 자주 쓰인 태그들을 AI prompt 에 vocabulary 로 주입해, AI 가 의미 일치 시 새 태그 생성 대신 기존 태그를 재사용하도록 유도. 효과는 `tag_vocab_hit` / `tag_vocab_miss` telemetry 로 측정.
## 2. Decisions (mini-brainstorm 합의)
| # | 질문 | 선택 | 이유 |
|---|---|---|---|
| Q1 | vocab pool 범위 | **C** AI+user 통합 + kebab-case 필터 | 사용자가 형식 맞춰 단 태그도 재사용 가치 있음, 단 형식 안 맞는 한글/공백 태그는 prompt 오염 |
| Q2 | telemetry emit 단위 | **A** 태그별 (per-tag hit/miss) | roadmap §3 #3 합의 시그니처 + 기존 누적 카운터 통계 모델과 정합 |
| Q3 | prompt 강제력 강도 | **B** "Prefer" (우선) | "MUST" 는 semantic mismatch 시 false hit, "For reference" 는 효과 미미; "Prefer" 는 우선순위 신호 + escape hatch 보장 |
| Q4 | 기존 노트 재처리 | **A** 자연 진화 (X) | invariant (user-edited 결과 보호) 와 합치, 새 노트만으로 hit/miss 충분 수집, B 는 사용자 결과 변경 |
## 3. Architecture & data flow
```
AiWorker.processJob()
├─ const vocab = repo.getTopUsedTags(20) ← SQL fetch (kebab-case 필터)
├─ provider.generate({ ..., vocab }) ← 새 input 필드
│ └─ LocalOllamaProvider.generate()
│ └─ buildPrompt(rawText, todayKst, candidates, vocab)
│ └─ vocab.length > 0 시 prompt 라인 추가
├─ AI response (tags: ['design', 'meeting', ...])
├─ repo.updateAiResult(...) ← 기존 흐름, tag insert
└─ for tag of res.tags: ← per-tag hit/miss 분류
if vocabSet.has(tag):
tagId = repo.getTagIdByName(tag) ← insert 후 보장
emit tag_vocab_hit { tagId, vocabSize }
else:
emit tag_vocab_miss { vocabSize }
```
### 3.1 Invariants
1. **매 generate 마다 SQL fetch** — vocab 캐싱/invalidation 안 함 (out of scope)
2. **vocab 빈 케이스 (N=0)** → prompt 라인 자체 생략, AI 자유롭게 새 태그 생성
3. **tagId** 는 hit 시 db tag id (`getTagIdByName` lookup, `updateAiResult` 후 호출이라 insert 보장)
4. **PROMPT_VERSION 3 → 4** (marker only, retry 트리거 X)
5. **vocab snapshot 동결** — 같은 generate call 의 `vocab` 배열로 hit/miss 판정. 처리 중 다른 노트가 새 태그 추가해도 이번 노트 분류엔 영향 X
6. **emit 순서**`updateAiResult` 후 emit (tagId 확보 보장)
## 4. Components
### 4.1 `NoteRepository`
#### `getTopUsedTags(limit = 20): string[]`
```sql
SELECT t.name, COUNT(*) c
FROM tags t
JOIN note_tags nt ON nt.tag_id = t.id
JOIN notes n ON n.id = nt.note_id
WHERE n.deleted_at IS NULL
GROUP BY t.id
ORDER BY c DESC, t.id ASC
LIMIT ?
```
JS-side 후처리:
```typescript
return rows
.map((r) => r.name)
.filter((n) => /^[a-z0-9-]+$/.test(n));
```
- `source` 무시 (AI+user 통합 — Q1=C)
- `t.id ASC` tiebreaker (deterministic)
- regex 필터로 한글/공백/대문자 태그 제외
#### `getTagIdByName(name: string): number | null`
```sql
SELECT id FROM tags WHERE name = ? COLLATE NOCASE LIMIT 1
```
대소문자 무시 (tag table `name COLLATE NOCASE` 와 정합).
### 4.2 `prompt.ts`
```typescript
export const PROMPT_VERSION = 4; // bump from 3
export function buildPrompt(
rawText: string,
todayKst: string,
candidates: ParseResult[] = [],
vocab: string[] = []
): string {
const candidateBlock = ...; // 기존 로직 유지
const vocabBlock = vocab.length > 0
? `\nExisting vocabulary tags (most-used first): ${vocab.join(', ')}\nPrefer reusing a vocabulary tag when the meaning matches; create new tags only when the meaning is genuinely new.\n`
: '';
return `... ${candidateBlock} ${vocabBlock} ...`;
}
```
### 4.3 `InferenceProvider` + `LocalOllamaProvider`
```typescript
export interface GenerateInput {
text: string;
todayKst: string;
dueDateCandidates: ParseResult[];
vocab?: string[]; // optional, 미전달 시 buildPrompt 가 빈 배열 처리
}
```
`LocalOllamaProvider.generate()``buildPrompt(text, todayKst, candidates, input.vocab ?? [])` 호출.
### 4.4 `AiWorker.processJob`
generate 호출 직전:
```typescript
const vocab = this.repo.getTopUsedTags(20);
const res = await this.provider.generate({
text: note.rawText,
todayKst: todayIso,
dueDateCandidates: candidates,
vocab
});
```
`updateAiResult` 후 emit 루프:
```typescript
const vocabSet = new Set(vocab);
for (const tagName of res.tags) {
if (vocabSet.has(tagName)) {
const tagId = this.repo.getTagIdByName(tagName);
if (tagId !== null && this.telemetry) {
await this.telemetry.emit({
kind: 'tag_vocab_hit',
payload: { tagId, vocabSize: vocab.length }
}).catch(() => {});
}
} else if (this.telemetry) {
await this.telemetry.emit({
kind: 'tag_vocab_miss',
payload: { vocabSize: vocab.length }
}).catch(() => {});
}
}
```
### 4.5 `telemetryEvents.ts` — zod schema
```typescript
const TagVocabHitPayload = z.object({
tagId: z.number().int().positive(),
vocabSize: z.number().int().nonnegative()
}).strict();
const TagVocabMissPayload = z.object({
vocabSize: z.number().int().nonnegative()
}).strict();
```
`TelemetryEventSchema` discriminatedUnion 13 → **15** entries.
### 4.6 `telemetryStats.ts` — 누적
- `DailyRow``tag_vocab_hit: number`, `tag_vocab_miss: number` 추가
- accumulator 분기 2개
- table 컬럼 2개 추가
- summary 라인:
```
- 태그 vocab: hit/miss = {N}/{M} (적중률 {X}%)
```
N+M=0 시 `(데이터 없음)` 표기
### 4.7 `TelemetryService.EmitInput` union 확장 (15 entries)
### 4.8 `AiWorker.AiTelemetryEmitter` interface 확장
```typescript
export interface AiTelemetryEmitter {
emit(input:
| { kind: 'ai_succeeded'; payload: ... }
| { kind: 'ai_failed'; payload: ... }
| { kind: 'tag_vocab_hit'; payload: { tagId: number; vocabSize: number } }
| { kind: 'tag_vocab_miss'; payload: { vocabSize: number } }
): Promise<void>;
}
```
## 5. Privacy invariant
- `tag_vocab_hit.payload.tagId` — 숫자 id 만, 태그 이름 X
- `tag_vocab_miss.payload` — `vocabSize` 만 (tagId 없음)
- prompt 본문에 vocab 이름 들어가지만 **prompt 는 telemetry 가 아님** (모델 컨텍스트, local Ollama 머신 내부에서만 처리)
- `.strict()` zod 가드 + extra field 거부 테스트로 invariant 보호
## 6. Tests (≥19개)
### NoteRepository.test.ts (7)
1. 빈 db → `[]`
2. 정렬 (count desc, id asc tiebreaker)
3. kebab-case 필터 — 한글/공백/대문자 태그 제외
4. AI+user source 통합 카운트
5. `deleted_at IS NULL` 필터
6. LIMIT 적용 (>20 시 잘림)
7. `getTagIdByName` — 존재 시 id, 없으면 null
### prompt.test.ts (4)
8. `PROMPT_VERSION === 4`
9. vocab=[] → 라인 자체 생략
10. vocab 1+ → "Prefer reusing..." 문구 + comma-separated 리스트
11. vocab 라인 위치 (candidate block 뒤, JSON rules 앞)
### AiWorker.test.ts (4)
12. vocab fetch + provider.generate 에 vocab 전달 + hit emit
13. miss emit (vocab 밖의 tag), vocabSize 정확
14. vocab=[] 시 모든 응답 태그 miss
15. 응답 태그 3개 → 3개 emit (per-tag 검증)
### telemetryEvents.test.ts (3)
16. `tag_vocab_hit` valid parse
17. `tag_vocab_hit` extra field 거부 (privacy)
18. `tag_vocab_miss` valid parse, tagId 필드 없음
### telemetryStats.test.ts (1)
19. hit 5 + miss 3 → daily row + summary "적중률 62.5%"
기존 단위 363 + **19** = **382** 예상. Q3 phrasing 변경으로 LocalOllamaProvider 기존 테스트 일부 string assertion 수정 가능 (±5).
## 7. Out of scope
(roadmap §3 #3 + 본 cut 결정)
- 임베딩 유사도 dedup ("회의" ↔ "meeting" semantic 매핑)
- 사용자 controlled vocabulary 화이트리스트
- 자동 normalize ("회의" ↔ "미팅")
- top-N 튜닝 (N=20 hardcoded)
- vocab cache invalidation 정책 (매번 SQL fetch)
- vocab 시간 범위 필터 (최근 N일 → 전체 사용)
- 기존 `ai_status='done'` 노트 일괄 재처리 (Q4=A 자연 진화)
- 명시적 "AI 결과 재처리" trigger UI (v0.2.4 backlog)
- `promptVersion` 을 telemetry payload 에 포함 (v0.2.4 검토 — 단일 버전 cut 에선 무의미)
- `idx_note_tags_tag_id` 인덱스 추가 (현재 dogfood 규모에선 불필요, v0.2.4 검토)
## 8. Gates (roadmap §3.1 공통)
- typecheck 0
- 단위 363 → 382 (+19), 모두 통과
- e2e 1/1
- 새 SQL: `getTopUsedTags` (3-table JOIN) + `getTagIdByName` (single-table) — 인덱스 영향 dogfood 규모에서 무시
## 9. Roadmap relation
- v0.2.3 dogfood feedback #3 (6번째 cut)
- 다음 cut: #6 리마인드 1 spike (7번째, 마지막)
- v0.2.4 후속: top-N 튜닝, controlled vocabulary, normalize, embeddings dedup

View File

@@ -0,0 +1,334 @@
# v0.2.3.1 Ollama 설정 In-App UI — Design Spec
> 작성: 2026-05-04 · v0.2.3 dogfood unblock 용 patch cut. 환경변수 의존 제거, 사용자 친화 endpoint/model 변경 path.
## 1. Goal
Inkling 사용자가 트레이 메뉴 / OllamaBanner 에서 Ollama endpoint + model 을 직접 변경 가능하도록. 현재는 `INKLING_OLLAMA_ENDPOINT` env var 만 지원 — Windows 의 dynamic port 점유 (Hyper-V/WSL2 NAT) 같은 환경 이슈에 즉시 대응 못함. patch cut 으로 dogfood unblock 후 1주 soak 진입.
## 2. Decisions (mini-brainstorm 합의)
| # | 질문 | 선택 | 이유 |
|---|---|---|---|
| Q1 | Model 설정 포함 여부 | **B** Endpoint + Model 둘 다 | endpoint 만으로는 커버 불충분 (LAN 서버 fallback 시 model 도 다를 수 있음) |
| Q2 | Model input 형태 | **A** Freetext | "급한" patch cut, healthCheck 가 검증, dropdown 은 v0.2.4 영역 |
| Q3 | Settings 영속화 | **B** JSON file (`<profileDir>/settings.json`) | migration v4 회피, 항목 2개 라 transaction 불필요, user 직접 편집 가능 |
자명 결정 (질문 없이 패턴 따름):
- env var precedence: **settings > env > default**
- in-flight job 처리: **AbortController abort + provider re-create**
- UI placement: **트레이 메뉴 "Ollama 설정..." + OllamaBanner 의 "설정" 링크**
- validation: **save 전 healthCheck**
## 3. Architecture & data flow
```
사용자 액션:
1. 트레이 → "Ollama 설정..." 클릭 (또는 OllamaBanner 의 "설정" 링크)
2. Settings modal 열림 — endpoint + model 입력란 + "저장" 버튼
3. 사용자 endpoint/model 입력 → "저장" 클릭
저장 흐름:
├─ Renderer: inboxApi.saveOllamaSettings({ endpoint, model })
├─ Main IPC: 임시 LocalOllamaProvider 생성 → healthCheck()
│ ├─ ok=true → JSON 영속화 + provider/health 교체
│ └─ ok=false → 저장 거부 + reason 반환 (modal 안에 inline 에러)
├─ 기존 in-flight AI job 처리:
│ └─ AbortController abort → 현재 generate 중단 → unreachable 분류 →
│ AiWorker 의 무한 retry 가 새 endpoint 로 재시도 (자동 회복)
└─ HealthChecker.recheck() → OllamaBanner 즉시 갱신
부팅 흐름 (precedence: settings > env > default):
index.ts:
const settings = await settingsSvc.load() // JSON 또는 빈 객체
const endpoint = settings.ollama?.endpoint
?? process.env.INKLING_OLLAMA_ENDPOINT
?? 'http://localhost:11434'
const model = settings.ollama?.model ?? 'gemma4:e4b'
```
### 3.1 핵심 invariants
1. **저장 = 검증 통과 전제** — healthCheck ok=false 면 JSON 안 씀. 사용자 잘못된 값 영속화 방지
2. **Provider mutability via re-create**`setEndpoint()` 메서드 추가 X. `ProviderHolder` 가 새 인스턴스 보유, listeners 알림. `AbortController` 가 in-flight 중단
3. **Settings precedence**: settings.json > env var > hardcoded default. UI 가 source of truth
4. **단일 settings file**`<profileDir>/settings.json`. atomic write (`writeFile` temp → `rename`). 손상 시 빈 객체 fallback (no app crash)
5. **HealthChecker rebind**`ProviderHolder.onReplace` 통해 새 provider 받아 polling endpoint 즉시 갱신
6. **Backward compat** — settings.json 없는 첫 부팅: env var → default 순. 기존 사용자 영향 0
7. **Cross-platform 자동**`app.getPath('userData')` + `node:path.join` + `node:fs/promises` 가 OS 별 경로/separator/UTF-8 자동. 별도 분기 0
## 4. Components
### 4.1 `SettingsService` (신규)
**파일**: `src/main/services/SettingsService.ts`
```typescript
import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
import { join, dirname } from 'node:path';
import { z } from 'zod';
const OllamaSettingsSchema = z.object({
endpoint: z.string().url(),
model: z.string().min(1)
}).strict();
const SettingsSchema = z.object({
ollama: OllamaSettingsSchema.optional()
}).strict();
export type Settings = z.infer<typeof SettingsSchema>;
export type OllamaSettings = z.infer<typeof OllamaSettingsSchema>;
export class SettingsService {
private filePath: string;
private cache: Settings | null = null;
constructor(profileDir: string) {
this.filePath = join(profileDir, 'settings.json');
}
async load(): Promise<Settings> {
if (this.cache !== null) return this.cache;
try {
const raw = await readFile(this.filePath, 'utf8');
const parsed = JSON.parse(raw);
this.cache = SettingsSchema.parse(parsed);
} catch {
this.cache = {}; // 파일 없음 또는 손상 → 빈 객체 fallback
}
return this.cache;
}
async setOllama(value: OllamaSettings): Promise<void> {
const validated = OllamaSettingsSchema.parse(value);
const current = await this.load();
const next: Settings = { ...current, ollama: validated };
await mkdir(dirname(this.filePath), { recursive: true });
const tmpPath = this.filePath + '.tmp';
await writeFile(tmpPath, JSON.stringify(next, null, 2), 'utf8');
await rename(tmpPath, this.filePath);
this.cache = next;
}
}
```
### 4.2 `ProviderHolder` (신규)
**파일**: `src/main/ai/ProviderHolder.ts`
```typescript
import type { LocalOllamaProvider } from './LocalOllamaProvider.js';
export class ProviderHolder {
private current: LocalOllamaProvider;
private listeners: Array<(p: LocalOllamaProvider) => void> = [];
constructor(initial: LocalOllamaProvider) {
this.current = initial;
}
get(): LocalOllamaProvider {
return this.current;
}
replace(next: LocalOllamaProvider): void {
this.current = next;
for (const fn of this.listeners) fn(next);
}
onReplace(fn: (p: LocalOllamaProvider) => void): void {
this.listeners.push(fn);
}
}
```
### 4.3 `LocalOllamaProvider` AbortController + 사용처 변경
**파일**: `src/main/ai/LocalOllamaProvider.ts` (수정)
```typescript
export class LocalOllamaProvider implements InferenceProvider {
private abortController: AbortController | null = null;
async generate(input: GenerateInput): Promise<AiResponse> {
this.abortController = new AbortController();
const timer = setTimeout(() => this.abortController?.abort(), this.timeoutMs);
try {
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 }
}),
signal: this.abortController.signal
});
// ... 기존 응답 처리 ...
} finally {
clearTimeout(timer);
this.abortController = null;
}
}
abort(): void {
this.abortController?.abort();
}
}
```
기존 `signal: controller.signal` 부분에서 `controller` 가 method-local 이었음 → `this.abortController` 로 이동 (외부 abort 가능).
### 4.4 `AiWorker` + `HealthChecker` 가 `ProviderHolder` 사용
`AiWorker` constructor: `provider: InferenceProvider``private holder: ProviderHolder`
- `processJob``this.provider.generate(...)``this.holder.get().generate(...)`
- `provider: this.provider.name``provider: this.holder.get().name`
`HealthChecker` 도 동일 패턴 + `onReplace` listener 등록 → 새 provider 즉시 polling.
### 4.5 IPC + Preload + InboxApi types
```typescript
// src/main/ipc/inboxApi.ts
ipcMain.handle('inbox:loadOllamaSettings', async () => {
const s = await deps.settings.load();
return s.ollama ?? null;
});
ipcMain.handle('inbox:saveOllamaSettings', async (_e, value: { endpoint: string; model: string }) => {
// 검증: 새 인스턴스로 healthCheck
const trial = new LocalOllamaProvider({ endpoint: value.endpoint, model: value.model });
const r = await trial.healthCheck();
if (!r.ok) return { ok: false, reason: r.reason };
await deps.settings.setOllama(value);
// in-flight 중단 후 holder 교체
deps.providerHolder.get().abort();
deps.providerHolder.replace(trial);
await deps.health.recheck();
return { ok: true };
});
```
```typescript
// src/preload/index.ts
loadOllamaSettings: () => ipcRenderer.invoke('inbox:loadOllamaSettings'),
saveOllamaSettings: (v: { endpoint: string; model: string }) => ipcRenderer.invoke('inbox:saveOllamaSettings', v),
```
```typescript
// src/shared/types.ts InboxApi
loadOllamaSettings(): Promise<{ endpoint: string; model: string } | null>;
saveOllamaSettings(v: { endpoint: string; model: string }): Promise<{ ok: true } | { ok: false; reason: string }>;
```
### 4.6 `OllamaSettingsModal` 컴포넌트
**파일**: `src/renderer/inbox/components/OllamaSettingsModal.tsx`
- Props: `open: boolean`, `onClose: () => void`
- 입력란 2개 (endpoint, model) + "저장" / "취소"
- 마운트 시 `inboxApi.loadOllamaSettings()` → 초기값 prefill
- 저장 시 `saveOllamaSettings(...)` → 성공 닫기 + 토스트, 실패 inline 에러
- React `<dialog>` 또는 portal — 별도 BrowserWindow X (단순함)
### 4.7 OllamaBanner "설정" 링크
기존 `OllamaBanner.tsx` 에 endpoint 변경 링크 추가:
```typescript
<button onClick={() => setSettingsOpen(true)}></button>
```
modal state 는 App.tsx 가 보유 + OllamaBanner 와 OllamaSettingsModal 둘 다에 넘김.
### 4.8 트레이 메뉴 + IPC 채널
`tray.ts``createTray` 가 10번째 positional callback 받음 → backlog #4/#26 (TrayCallbacks object refactor) 와 합산 가능. 본 cut 에선 일관성 우선 positional 추가:
```typescript
{ label: 'Ollama 설정...', click: () => runOpenOllamaSettings() }
```
`index.ts``runOpenOllamaSettings = () => mainWindow.webContents.send('inbox:openOllamaSettings')` 푸시. Renderer App.tsx 가 이 channel 구독해 modal 열기.
### 4.9 `index.ts` 부팅 흐름 변경
```typescript
const settingsSvc = new SettingsService(paths.profileDir);
const settings = await settingsSvc.load();
const resolvedEndpoint = settings.ollama?.endpoint
?? process.env.INKLING_OLLAMA_ENDPOINT
?? 'http://localhost:11434';
const resolvedModel = settings.ollama?.model ?? 'gemma4:e4b';
logger.info('ai.endpoint', {
endpoint: resolvedEndpoint,
source: settings.ollama?.endpoint
? 'settings'
: (process.env.INKLING_OLLAMA_ENDPOINT ? 'env' : 'default')
});
const provider = new LocalOllamaProvider({ endpoint: resolvedEndpoint, model: resolvedModel });
const holder = new ProviderHolder(provider);
const health = new HealthChecker(holder, { ... });
const aiWorker = new AiWorker(repo, holder, { ... });
```
## 5. Privacy invariant
- `settings.json` 은 local only, telemetry emit X
- 잠재적 `ollama_settings_changed` event 추가 시 endpoint URL 노출 → privacy 위반 → **emit 안 함** (본 cut)
- 향후 v0.2.4 dogfood telemetry 에서 변경 빈도 측정 필요 시 `{ count: number }` payload (URL 자체 X) 형태로 추가
## 6. Tests (≥10개)
### SettingsService.test.ts (신규, 6)
1. `load()` 파일 없음 → 빈 객체
2. `load()` 손상 JSON (parse 실패) → 빈 객체 fallback (no throw)
3. `load()` 캐시 동작 — 두 번째 호출 시 file read 안 함
4. `setOllama()` zod 검증 실패 (non-URL endpoint) → throw
5. `setOllama()` 정상 저장 → 디스크 file 존재 + 내용 일치
6. `setOllama()` atomic write — temp file 남지 않음 (rename 후 cleanup)
### ProviderHolder.test.ts (신규, 2)
7. `replace()` 시 listener 발화 + `get()` 가 새 인스턴스 반환
8. listener 여러 개 등록 시 모두 발화
### LocalOllamaProvider.test.ts (확장, 2)
9. `abort()` 호출 시 in-flight `generate()` rejects (AbortError)
10. constructor `model` 파라미터 적용 (default `gemma4:e4b` 외 임의 model)
총 신규 단위 **10개**. 기존 403 + 10 = **413**.
(Renderer modal 컴포넌트 단위 테스트 X — Inkling 패턴 따라 store-only. IPC handler 자체도 service-level test 가 logic 보유.)
## 7. Out of scope
- Multi-provider abstraction (OpenAI, Anthropic, etc) — strategy.md local-first 정책 충돌, v0.2.4+
- Settings UI 안에서 다른 기능 (telemetry retention, vocab top-N 등) — 별 cut
- Cross-machine settings sync — 단일 머신 dogfood 패턴
- Model dropdown / 자동 list refresh — Q2=A 결정 (freetext)
- `ollama pull` 자동 안내 — over-scope
- Settings export/import / version migration — over-scope
- Settings 변경 history / undo — over-scope
- Settings UI 안에 model healthCheck 결과 시각화 (loading spinner 등) — minimal toast 만
- `ollama_settings_changed` telemetry — privacy invariant 보호 (v0.2.4 검토 시 count-only)
- Settings 변경 로그 파일 — env-debug 영역, v0.2.4 검토
## 8. Gates (roadmap §3.1)
- typecheck 0
- 단위 403 → 413 (+10)
- e2e 1/1 (smoke 회귀 X)
- backward compat: settings.json 없는 부팅 → env var → default 폴백 정상
- cross-platform: SettingsService.test 가 `app.getPath` mock 으로 Win/macOS/Linux 시뮬레이션 (별도 case 또는 path matrix)
## 9. Roadmap relation
- v0.2.3 dogfood unblock 패치. 정식 v0.2.3 cut (#1~#7) 와 별개 patch
- 머지 후 v0.2.3.1 binary 재빌드 + Gitea release (existing `v0.2.3` tag → `v0.2.3.1` 신규 tag, release 별도)
- ≥1주 dogfood soak 후 telemetry export + 신규 피드백 + v0.2.4 backlog 38건 일괄 triage → v0.2.4 brainstorm

View File

@@ -0,0 +1,54 @@
# v0.2.4 Patch Cleanup — Design Spec (Brief)
> 작성: 2026-05-05 · 0.2.3.1 semver 위반 (`X.Y.Z.W` 4-part) → 0.2.4 minor bump 이용해 backlog 의 simple cleanup 5건 + 사용자 가치 1건 합쳐서 묶음 cut. v0.2.4 정식 brainstorm 은 v0.2.5 로 이동.
## 1. Goal
PR #21 머지 후 0.2.3.1 binary 빌드 시도가 electron-builder 의 semver validation 으로 실패. 0.2.4 minor bump 으로 우회. 이번 cut 에는 dogfood unblock 외 backlog 의 risk 낮은 cleanup + 사용자 가치 항목 동봉.
## 2. Scope (5 backlog 항목 + version bump)
| backlog # | 항목 | 가치 | 작업량 |
|---|---|---|---|
| #1 | `TelemetryService.emit``now()` 2번 호출 → 1번 추출 | cosmetic (KST midnight straddle 이론) | 1줄 |
| #2 | `DAY_MS = 24*60*60*1000` magic number → 모듈 상단 상수 | cosmetic | 1줄 |
| #6 | `media.gc.run()` `.catch` 누락 → backup pattern 통일 | consistency | 1줄 |
| #13 | NoteCard `mode='trash'``onDeleted` dead-code prop 제거 | API 청소 | 작음 |
| #44 | 트레이 메뉴 + Inbox footer 에 "Inkling 0.2.4" 버전 정보 | **사용자 dogfood 가치** | 1 task |
| - | version bump 0.2.3.1 → 0.2.4 | semver 표준 | trivial |
## 3. Out of scope
- **#45 (자동실행 버그)**: Windows registry 디버깅 필요, simple X. 별도 cut.
- **#3/#4/#26 (KST 통합 / TrayCallbacks refactor)**: multi-file, 크다. 별도.
- **#5/#22 (Union 통합 / hydrate cleanup)**: repo-wide.
- **#39~#43 (PR #21 deferred)**: telemetry masking 등 의미 있는 결정 필요. v0.2.5 brainstorm 영역.
- 기타 backlog 39건.
## 4. Architecture changes
본 cut 은 의미 있는 architecture 변경 없음. 기존 pattern 강화만:
- `TelemetryService.emit` 의 atomic timestamp 보장 (now() 1회)
- 모듈 상단 magic number 상수화 패턴 (다른 파일은 이미 그 패턴, TelemetryService 만 예외)
- `.catch` consistency (backup.runDaily / telemetry.cleanupOldFiles 와 동일 wrapper)
- React props 청소 (현재 호출되지 않는 prop 제거)
- 신규 surface: 트레이 메뉴 "Inkling 정보..." → modal 또는 dialog
## 5. Tests
테스트 추가 없음 (모두 cosmetic / refactor). 기존 단위 413/413 회귀 X 확인만.
#44 의 modal 은 컴포넌트 단위 테스트 X (Inkling 패턴 — store-only).
## 6. Gates
- typecheck 0
- 단위 413/413 (회귀 X)
- e2e 1/1
- backward compat: 기존 사용자 영향 0 (cosmetic + 새 surface)
## 7. Roadmap relation
- 0.2.3 cut 7/7 (PR #13~#19) + 0.2.3.1 patch (PR #21) 누적 후 binary 빌드를 위한 v0.2.4 minor bump
- v0.2.5 brainstorm 트리거: dogfood ≥1주 soak + telemetry export + backlog 39건 (=45-5-1) + 신규 피드백 일괄 triage
- backlog 명명 `v024-backlog.md` → 본 cut 후 `v025-backlog.md` 로 rename 검토 (또는 v024-backlog.md 유지하고 내용만 갱신)

View File

@@ -0,0 +1,133 @@
# v0.2.6 Bugs + Cleanup — Design Spec
> 작성: 2026-05-05 · 정식 v0.2.6 cut. backlog 16건 (bug 4 + cleanup 12, 13 task 로 cluster) 통합 처리. dogfood telemetry 미수집 영역 (#7/#16/#18/#25/#33/#35/#36/#39/#40 등 14건) 은 v0.2.7 brainstorm 영역으로 별도.
## 1. Goal
dogfood UX 마찰 (autostart 풀림, trashCount 부정확, restore 시 AI 미재처리) 즉시 해소 + 코드베이스 cleanup (KST helper 통합, TrayCallbacks 객체화, AiFailedReason union 통합 등) 으로 v0.2.7 brainstorm 시 신규 feature 작업 friction 제거.
## 2. Scope (16 backlog 항목 → 13 task)
### Bug fixes (B1~B4)
| Task | 항목 | 작업 요약 |
|---|---|---|
| **B1** | #10 | `NoteRepository.restoreNote(id)``ai_status='failed'` 인 노트 복구 시 `ai_status='pending'` reset + `pending_jobs INSERT` |
| **B2** | #12 | `NoteRepository.countTrashed()` 추가 + IPC `inbox:trashCount` 가 SQL 정확 N 반환 (UI 200 cap 제거) |
| **B3** | #45 | autostart 풀림: `app.getLoginItemSettings({ args: ['--hidden'] })` (args 비교 정확도) + path canonicalization 검토. fallback: 진단 로그만 추가 시 backlog 유지 |
| **B4** | #46 | `app.requestSingleInstanceLock(additionalData)` + `second-instance(event, argv, cwd, additionalData)` 에서 hidden flag 체크 → 두 번째 hidden 이면 inbox 창 안 띄움 |
### Cleanup refactor (C1~C9)
| Task | 항목 (cluster) | 작업 요약 |
|---|---|---|
| **C1** | #3 + #19 + #34 | KST helper 통합 → `src/shared/util/kstDate.ts`. 4 callsite migrate (`TelemetryService.todayKstIso`, `telemetryStats.kstDate`, `AiWorker.todayKstAsDate/Iso`, store `snoozeExpired/snoozeRecall`) |
| **C2** | #4 + #23 + #26 | `interface TrayCallbacks` + `createTray(callbacks: TrayCallbacks)` 1-arg refactor. positional 10개 → object |
| **C3** | #27 | `refreshTrayFailedCount` module-scoped state 제거 → TrayCallbacks 객체 안 reactive 함수 또는 store-driven 패턴 |
| **C4** | #5 | `export type AiFailedReason = 'unreachable' \| 'schema' \| 'timeout' \| 'other'` 단일 export + zod `z.enum``z.infer` 로 type 파생. 3 callsite migrate |
| **C5** | #21 | `hasNoteId(ev: TelemetryEvent): ev is TelemetryEventWithNoteId` type predicate helper → `tests/unit/TelemetryService.test.ts` 의 4-line narrowing 체인 단축 |
| **C6** | #22 | NoteRepository hydrate 의 `as any[]``Record<string, unknown>[]` (또는 explicit row interface) 일괄 cleanup |
| **C7** | #24 + #41 | `<Banner severity="warning"\|"error"\|"info">` shared component → ExpiryBanner / OllamaBanner / FailedBanner / RecallBanner / OllamaSettingsModal 5 callsite migrate |
| **C8** | #8 | `telemetryStats.aggregateStats` if/else if 끝에 `else { const _: never = ev; }` exhaustiveness check |
| **C9** | #15 + #29 + #42 + #9 | microfixes 묶음: `inbox:delete``inbox:trash` rename / `getTopUsedTags(20)``VOCAB_TOP_N` const / `OllamaSettingsModal` zod URL pre-check / 휴지통 회수율 ratio 코멘트 1줄 |
## 3. Out of scope
- Telemetry 데이터 필요 (14건): #7 reason 분포 / #16 permanent_delete 빈도 / #18 loadExpired consumer / #20 telemetry .catch silent / #25 HealthChecker dedup / #28 unreachableBackoffStep / #29 top-N 튜닝값 (extract 만 본 cut, 튜닝은 v0.2.7) / #30 LIMIT-then-filter 정책 / #31 vocabSet COLLATE / #32 per-tag emit 병렬화 / #33 promptVersion payload / #35 recall_shown lifetime / #36 IPC handle vs on / #39 ollama reason PII / #40 Settings race flicker
- 별도 brainstorm 영역 (3건): #11 restoreNote precondition / #14 ARIA 패턴 / #17 dialog 버튼 순서 / #37 NoteCard id ref-forwarding
## 4. Architecture changes
대부분 cosmetic refactor 또는 isolated bug fix. 주목할 architecture-level 변경:
### 4.1 KST helper 통합 (C1)
- 신규 `src/shared/util/kstDate.ts` (main + renderer 양쪽 import 가능)
- 기존 4 callsite 의 inline KST 계산 제거
- API: `kstTodayIso(now?: Date): string`, `nextKstMidnightMs(now?: Date): number`
- KST_OFFSET_MS 상수 단일
### 4.2 TrayCallbacks 객체화 (C2 + C3)
- `interface TrayCallbacks` — 10+ 개 callback + state getter
- `createTray(callbacks: TrayCallbacks): void` — 1-arg signature
- module state (_failedCount, _todayCount, _ollamaOk) 는 TrayCallbacks 의 reactive getter / setter 패턴 또는 explicit refresh 함수 (`refreshTray(state: { todayCount, failedCount, ollamaOk })`)
### 4.3 Banner shared component (C7)
- `<Banner severity="warning"|"error"|"info" icon? title? children>` — wrapping/styling 일원화
- 5 callsite 가 themed inline style 제거 → severity prop
- CSS variables 또는 hardcoded theme map (single source)
### 4.4 NoteRepository.restoreNote behavior change (B1)
- 기존: `UPDATE notes SET deleted_at = NULL WHERE id = ?`
- 변경: 추가로 `ai_status='failed'` 였을 경우 → `ai_status='pending'` reset + `INSERT OR IGNORE INTO pending_jobs`
- atomic transaction
- AiWorker 가 자동으로 다음 loop iteration 에서 처리
## 5. Tests
추정 +17 cases (413 → 430):
| Task | 신규 단위 |
|---|---|
| B1 | +3 (restore failed note re-enqueues, restore done note 영향 X, restore cancelled note 영향 X) |
| B2 | +2 (countTrashed 정확, dialog message 정확 N) |
| B3 | +1-2 (autostart args 비교, 가능하다면 mock electron app) |
| B4 | +1 (additionalData hidden flag 가 second-instance 에 전달, mock test) |
| C1 | +2 (kstTodayIso, nextKstMidnightMs) — 기존 4 callsite test 가 자동 검증 |
| C2 | refactor only, 기존 tray 테스트 유지 |
| C3 | refactor only |
| C4 | refactor only |
| C5 | +2 (hasNoteId predicate) |
| C6 | refactor only |
| C7 | refactor only (UI 컴포넌트 unit test X 패턴) |
| C8 | +1 (exhaustive guard 컴파일 단계) |
| C9 | +1 (Modal URL pre-check), 나머지 refactor only |
총 신규: ~13-15 (보수적). 단위 413 → **~426-428** 예상.
## 6. Privacy invariant
- B1/B2: telemetry 영향 없음
- B3/B4: telemetry emit 없음 (autostart event 미수집)
- C 시리즈: 모두 cosmetic refactor — invariant 영향 0
- 본 cut 에서 신규 telemetry kind 추가 0
## 7. Gates (roadmap §3.1)
- typecheck 0
- 단위 413 → ~427 (+13~15)
- e2e 1/1
- backward compat: 기존 사용자 데이터 + UI 동작 영향 0 (단 B1 은 의도적 동작 추가, B2 는 UI N 표시 정확화)
## 8. Risk + Fallback
### B3 (autostart 풀림) 진단 불확실
가장 risky. Windows registry 디버깅 결과 깨끗한 fix 안 나올 수 있음. **Fallback 정책**:
- 진단 절차 적용해도 fix 안 되면 → 진단 로그만 추가 (`logger.info('autostart.state', { stored, current, mismatch })`) → backlog #45 유지 → 본 cut 에서 task drop
- 다른 task 영향 없음 (각 task 독립적)
### C1 KST helper 의 alias 경계
`src/shared/util/kstDate.ts` 가 main + renderer 양쪽에서 import 되어야. 기존 `@main/util/kstDate.ts` 는 renderer 에서 import 불가 (alias 분리). `src/shared/` 가 양쪽 가능 패턴. 검증 필요.
### C2 TrayCallbacks 객체화 의 backward compat
기존 createTray 호출자 (index.ts 1곳) 한 군데만 변경 → 안전. tray 테스트 영향 최소.
## 9. 작업 순서
순서대로 subagent dispatch. 의존성:
- B1, B2: 독립
- B3: 독립 (Windows-specific, mock 어려움)
- B4: 독립
- C1 → 다른 task 영향 X (shared util 추가)
- C2 → C3 (TrayCallbacks 객체에 refreshTrayFailedCount 흡수)
- C4, C5, C6, C7, C8, C9: 독립
권장 순서: **B1 → B2 → B4 → B3 → C1 → C4 → C5 → C6 → C8 → C2 → C3 → C7 → C9**.
이유: B3 (위험) 을 cleanup 시작 직전에 두어 fail 시 빠르게 회피. C2/C3 cluster 는 묶어서. C7 (Banner shared) 는 isolated UI cleanup, 마지막 그룹.
## 10. Roadmap relation
- v0.2.6 정식 cut (이전 v0.2.4/v0.2.5 는 patch / hotfix)
- 머지 후 binary 빌드 v0.2.6 (Windows + Mac) + Gitea release
- v0.2.7 brainstorm 트리거: dogfood ≥1주 soak + telemetry export 모인 후, 잔여 backlog 14건 (data-dependent) + 신규 피드백 일괄 triage
- backlog file 본 cut 후 prune (16 건 처리 완료 표기) + rename 검토 (`v027-backlog.md` 또는 `feature-backlog.md`)

View File

@@ -0,0 +1,407 @@
# v0.2.7 — Cross-Platform 입구 정상화 (Design)
**작성일:** 2026-05-06
**저자:** 김태현 (dlsrks0734@gmail.com)
**선행 문서:**
- `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` (F12, F14, F15, F16)
- `docs/superpowers/v024-backlog.md` (잔여 24건)
- `docs/superpowers/strategy/dogfood-strategy.md` (운영안)
**Cut 라벨:** v0.2.7 (semver 엄밀히는 MINOR — 새 플랫폼 + 새 surface — 이지만 본 프로젝트 관습상 v0.2.x 를 feature lane 으로 사용 중이므로 v0.2.7 라벨 유지)
---
## 1. Cut 정체성
**"Cross-platform 입구 정상화" cut.** F12 / F14 / F15 / F16 4개 항목을 한 묶음으로 처리. 핵심 동기:
> Windows 트레이 의존을 끊고 macOS / Linux 사용자에게 동등한 입구를 제공한다.
현재 13개 트레이 메뉴 항목이 macOS / Linux (특히 모던 GNOME) 에서 발견 / 접근성이 떨어져 핵심 설정 (Ollama endpoint, 자동 실행 등) 진입이 막히는 구조적 문제. 트레이를 deemphasis 하고 inbox 윈도우 안에 통합 설정 페이지를 둔다. 동시에 macOS dock 동작 정상화 (F14) + Linux 앱 빌드 추가 (F15 축소판) + 자동 실행 진단 노출 (F12 deeper fix) 까지 함께 처리한다.
**의도적으로 빠진 것:**
- ~~CLI (`inkling capture` 등)~~ — DB / Ollama 동시접근 race + monorepo 재구성 부담 대비 본인 dogfood metric 직접 기여 적음. v0.2.7 에서 제외. 외부 demand 누적 시 v0.3+ 재거론.
---
## 2. 범위
| 항목 | 출처 | 작업 |
|---|---|---|
| **F15 (축소판)** | dogfood F15 | Linux 앱 빌드 (AppImage + deb x64) + better-sqlite3 prebuild linux-x64 매트릭스 |
| **F16** | dogfood F16 | 트레이 슬림 (13 → 4) + inbox 안 설정 페이지 (4 섹션) |
| **F14** | dogfood F14 | macOS dock 클릭 시 hidden 창 show/focus (activate 핸들러 5줄 수정) |
| **F12 deeper fix** | dogfood F12 (v0.2.6 진단 fallback 후속) | 설정 페이지 "자동 실행" 섹션 안에 진단 패널 노출 (withArgs vs noArgs / executableWillLaunchAtLogin / registry path) |
---
## 3. Architecture 변화
| 영역 | 현재 (v0.2.6) | v0.2.7 |
|---|---|---|
| 설정 진입 | 트레이 메뉴 13개 항목 | 트레이 4개 + 설정 페이지 (inbox 내부 라우트) |
| Ollama 설정 | OllamaSettingsModal (트레이에서만 진입) | 설정 페이지 안 "AI 제공자" 섹션 (modal 흡수) |
| 자동 실행 | 트레이 checkbox + args 명시 | 설정 페이지 안 섹션 + 진단 패널 |
| macOS dock 클릭 | activate 핸들러 no-op (length===0 분기 못 탐) | `getInboxWindow().show() + focus()` 분기 추가 |
| Linux 배포 | 없음 | AppImage + deb 산출물 |
| 빌드 매트릭스 | win-x64 + mac-arm64 | + linux-x64 |
---
## 4. 구현 순서 (Approach 2: Risk-reduction first)
```
1. Linux 빌드 (가장 unknown — better-sqlite3 prebuild linux-x64 검증)
↓ AppImage + deb 산출 + Linux VM/WSL2 smoke test
2. 설정 페이지 (inbox 내부 라우트 + 4 섹션)
↓ OllamaSettingsModal 흡수
3. 트레이 슬림 (13 → 4)
↓ 제거된 click 핸들러 → 설정 페이지 버튼으로 이동
4. F14 macOS dock 클릭 fix
↓ activate 핸들러 5줄
5. F12 deeper fix (자동 실행 진단 노출)
↓ IPC settings:autostart-state + 진단 panel UI
```
Linux 빌드를 먼저 두는 이유: native ABI 트랩 (메모 `project_inkling_status.md`) 이 linux-x64 에서 재발할 수 있음. 만약 prebuild 가 깔끔히 떨어지지 않으면 v0.2.7 scope 조정 (예: AppImage 만, deb 는 v0.2.8) 여유. 설정 페이지 / 트레이 슬림 / F14 / F12 는 모두 코드 작성 risk 가 낮은 영역이라 후순위로 안전.
---
## 5. Linux 빌드 디테일
### 5-1. electron-builder config 추가
```json
"linux": {
"target": [
{ "target": "AppImage", "arch": ["x64"] },
{ "target": "deb", "arch": ["x64"] }
],
"category": "Utility",
"synopsis": "로컬 메모 캡처 + AI 태그",
"description": "Inkling — 잠깐 스친 생각을 잡아두는 로컬-우선 메모 도구."
}
```
### 5-2. npm scripts 추가
```json
"predist:linux": "npm run rebuild:electron && npm run build",
"dist:linux": "electron-builder --linux --x64"
```
`rebuild:electron``--target=41.3.0` 그대로. `prebuild-install` 이 linux-x64 prebuild 를 npm 레지스트리에서 받아오는지 검증. 없으면 `node-gyp` fallback 으로 로컬 컴파일.
### 5-3. 빌드 호스트 전략
**1차: macOS 호스트** (이미 DMG 빌드 호스트). brew 로 도구 설치:
```bash
brew install dpkg fakeroot
```
electron-builder 가 cross-build 지원. AppImage 는 Mac 에서 직접 빌드 가능 (Linux 유저랜드 도구만 필요한 부분은 electron-builder 내장 + AppImageKit 자동 다운로드). deb 는 dpkg-deb 필요.
**Fallback (1차 실패 시): Docker on Mac/Windows.** `electronuserland/builder` 이미지로 Linux 빌드 환경 격리. v0.2.7 scope 안에서 결정.
### 5-4. Smoke test
`dist/` 산출물:
- `Inkling-0.2.7.AppImage` (x64) — Linux VM 또는 WSL2 에서 chmod +x → 실행 → 마이그레이션 통과 확인 → capture / recall 한 사이클.
- `inkling_0.2.7_amd64.deb` — Ubuntu/Debian VM 또는 WSL2 에서 `sudo dpkg -i``inkling` 실행 → 동일 검증.
검증 항목:
1. better-sqlite3 native module 로드 성공 (마이그레이션 0 → m003 통과)
2. Ollama 연결 시도 (settings.json 의 endpoint 또는 `INKLING_OLLAMA_ENDPOINT` env) — 본인 LAN 서버 `http://192.168.0.47:11434` 사용
3. capture 한 줄 → AI 처리 → tag 표시
4. 트레이 (KDE/Cinnamon DE 가정) 4 항목 표시
5. 트레이 없는 DE (모던 GNOME) — launcher 에서 앱 실행 → inbox 윈도우 → 톱니바퀴 → 설정 페이지 진입
---
## 6. 설정 페이지 디테일
### 6-1. 라우팅 방식
React Router 도입 안 함 (의존성 + 학습 비용). zustand store 의 `view: 'inbox' | 'trash' | 'settings'` state + 조건부 렌더 — 기존 trash view 와 동일 패턴. 새 의존성 0.
### 6-2. 진입점
| 진입 | 동작 |
|---|---|
| 트레이 "설정..." 클릭 | main → IPC `inbox:navigate` 'settings' → renderer store action `setView('settings')` + inbox 윈도우 show/focus |
| inbox 헤더 톱니바퀴 아이콘 | renderer store action `setView('settings')` |
| 설정 페이지 안 "← 돌아가기" 버튼 | `setView('inbox')` |
### 6-3. 섹션 4개
#### 6-3-1. AI 제공자
흡수 대상: OllamaSettingsModal 전체 + 트레이 "Ollama 재확인".
UI 요소:
- Endpoint URL 입력 (zod 검증 — 기존 modal 의 `safeParse` 재활용)
- Model 입력 (빈 값 guard)
- "지금 재확인" 버튼 → ProviderHolder 의 health check trigger
- 마지막 ping 결과 표시 (성공 시각 또는 실패 사유)
- "기본값으로 되돌리기" 버튼
저장: 기존 SettingsService (atomic temp+rename + zod) 그대로.
#### 6-3-2. 자동 실행
흡수 대상: 트레이 "윈도우 시작 시 자동 실행" checkbox.
UI 요소:
- 토글 ("앱 시작 시 자동으로 실행")
- 진단 패널 (펼치기 가능 — 평소엔 접혀 있음)
- "재등록" 버튼 (setLoginItemSettings 강제 재호출)
진단 패널 디테일은 §9 (F12 deeper fix) 참조.
#### 6-3-3. 백업 / 복원 / 내보내기
흡수 대상: 트레이의 5개 항목 — "지금 백업" / "내보내기..." / "백업에서 복원..." / "지금 동기화" / "사용 로그 내보내기...".
UI 요소: 5개 버튼 + 각 작업 마지막 실행 시각 (가능하면) + 결과 toast.
IPC 핸들러는 기존 그대로 — 트레이 click 핸들러였던 함수를 IPC 핸들러로 등록 + renderer 에서 invoke.
#### 6-3-4. 정보
흡수 대상: 트레이 "Inkling 정보..." dialog.
UI 요소: 버전 / Electron / Node / OS / 데이터 위치 텍스트 + "데이터 위치 열기" 버튼 + "정보 복사" 버튼.
기존 `showAboutDialog` 의 detail 문자열 그대로 활용 — clipboard.writeText / shell.openPath 호출도 동일.
### 6-4. 제외 항목
- "지금 AI 처리 (실패 N건)" — 이미 inbox FailedBanner 가 surface. 트레이 / 설정 둘 다 제거.
- "Ollama 재확인" 트레이 메뉴 단독 — OllamaBanner (끊김 시) + 설정 페이지 AI 섹션 "지금 재확인" 버튼이 surface. 트레이 단독 메뉴 제거.
---
## 7. 트레이 슬림 디테일
### 7-1. 잔류 4개 (Win / Mac / Linux 동일)
```ts
items.push({ label: '한 줄 적기', click: cb.showCapture });
items.push({ label: '보관한 메모 보기', click: cb.showInbox });
items.push({ type: 'separator' });
items.push({ label: '설정...', click: cb.showSettings });
items.push({ type: 'separator' });
items.push({ label: '종료', click: () => { app.isQuitting = true; app.quit(); } });
```
todayCount tooltip (`Inkling — 오늘 N`) 잔류. F4-C 의 "오늘 N번 잡아둠" 비활성 라벨도 잔류 (정체성 신호).
### 7-2. TrayCallbacks / TrayState 갱신
```ts
export interface TrayCallbacks {
showInbox: () => void;
showCapture: () => void;
showSettings: () => void; // NEW — IPC 'inbox:navigate' 'settings' 송출
}
// 메뉴 영향 state 슬림
export interface TrayState {
todayCount: number;
}
```
**제거 대상:**
- `runBackup`, `runExport`, `runImport`, `runSync`, `runExportTelemetry` callback (5개) → 설정 페이지 버튼으로 이동
- `runOllamaRecheck`, `runRetryAllFailed`, `runOpenOllamaSettings` callback (3개) → 설정 페이지 또는 banner 로 이동
- `ollamaOk`, `failedCount` state field (2개) → 트레이 메뉴 영향 사라짐 (banner 가 surface)
- `refreshTray({ ollamaOk })`, `refreshTray({ failedCount })` 호출부 (HealthChecker, AiWorker) → 제거. todayCount 만 남음.
v0.2.6 의 Partial<TrayState> 패턴 그대로 활용 — 인터페이스 좁아질 뿐.
### 7-3. 자동 실행 토글 트레이 잔류 X
기존 트레이 안 checkbox (`type: 'checkbox'`) 는 제거. 설정 페이지 "자동 실행" 섹션 토글이 단일 진입점.
이유: 자동 실행 토글은 빈도 낮은 액션 + F12 진단이 같은 자리에 있어야 의미. 트레이 잔류 시 두 surface mismatch 위험.
---
## 8. F14 macOS dock 클릭 fix
[src/main/index.ts:411-413](src/main/index.ts#L411-L413) 수정:
```ts
app.on('activate', () => {
const win = getInboxWindow();
if (win && !win.isDestroyed()) {
if (!win.isVisible()) win.show();
win.focus();
} else {
createInboxWindow();
}
});
```
second-instance 핸들러 (B4 #46) 와 패턴 일치 — 양쪽 모두 "살아있으면 show, 죽었으면 create".
테스트: BrowserWindow + activate 이벤트 mocking 비용 ↑ → manual dogfood 검증으로 충분 (macOS 빨간 신호등 → dock 클릭 → 즉시 창 등장).
---
## 9. F12 deeper fix — 자동 실행 진단 노출
### 9-1. 정보 모델
```ts
// 신규 IPC: settings:autostart-state
interface AutostartState {
withArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean };
noArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean };
execPath: string; // process.execPath
registryPath?: string; // Windows only
registryValue?: string; // Windows only — null 또는 string
}
```
### 9-2. main process 핸들러
```ts
ipcMain.handle('settings:autostart-state', async () => {
const withArgs = app.getLoginItemSettings({ args: ['--hidden'] });
const noArgs = app.getLoginItemSettings();
const state: AutostartState = {
withArgs: {
openAtLogin: withArgs.openAtLogin,
executableWillLaunchAtLogin: withArgs.executableWillLaunchAtLogin
},
noArgs: {
openAtLogin: noArgs.openAtLogin,
executableWillLaunchAtLogin: noArgs.executableWillLaunchAtLogin
},
execPath: process.execPath
};
if (process.platform === 'win32') {
state.registryPath = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run\\Inkling';
state.registryValue = await readRegistryValueSilent(state.registryPath);
}
return state;
});
```
`readRegistryValueSilent`: `child_process.execFile('reg', ['query', path, '/v', 'Inkling'])` 1회. 실패 시 null 반환 (silent fallback — 사용자에 에러 노출 X).
새 dependency 추가 X (`winreg` 등 X) — built-in `child_process` + Windows `reg.exe` 만 활용.
### 9-3. UI
설정 페이지 "자동 실행" 섹션:
```
[ ] 앱 시작 시 자동으로 실행
상태: ✅ 등록됨 / ⚠️ 등록 안 됨 / ⚠️ args 미스매치
▾ 진단 정보 (펼치기)
- 표준 조회 (args 명시): openAtLogin=true, willLaunch=true
- 비교 조회 (args 없이): openAtLogin=false, willLaunch=true ← mismatch ⚠️
- 실행 파일 경로: /Applications/Inkling.app/Contents/MacOS/Inkling
- registry 경로 (Windows): HKCU\...\Run\Inkling
- registry 값: "C:\Users\...\Inkling.exe" --hidden
[ 재등록 ] 버튼
```
### 9-4. dogfood 시나리오
1. 토글 ON → 재시작 → 풀려있으면 진단 패널 펼침.
2. withArgs vs noArgs mismatch 보임 → args canonicalization 문제 확인.
3. registry 값 vs execPath 비교 — 다르면 path canonicalization 문제 (NSIS 재설치 시 path 바뀜).
4. "재등록" 버튼 → setLoginItemSettings 재호출 → 다시 재시작 → 효과 측정.
수집된 데이터로 v0.2.8 root cause fix 작성.
---
## 10. 테스트 전략
| 영역 | 단위 | e2e | Manual dogfood |
|---|---|---|---|
| Linux 빌드 (F15) | - | - | AppImage + deb 산출 + Linux VM 실행 + 마이그레이션/캡처/recall 한 사이클 |
| 설정 페이지 라우팅 | zustand store action `setView('settings')` 단위 | (선택) 트레이 "설정..." → IPC → view 전환 e2e | 실제 클릭 흐름 |
| Ollama 섹션 흡수 | 기존 OllamaSettingsModal 단위 + 흡수 후 회귀 | - | 1회 |
| 자동 실행 진단 IPC | autostart-state 핸들러 단위 (mock electron API + child_process) | - | Win 토글 → 재시작 → 진단 패널 mismatch 검출 |
| 트레이 슬림 | tray.ts buildMenu 단위 (4 항목 검증 + 제거된 항목 부재) | - | - |
| F14 dock fix | (mock 비용 ↑) — manual 만 | - | macOS dock 클릭 |
| F12 진단 UI | mismatch 시 ⚠️ 렌더 단위 | - | F12 시나리오 재현 |
**목표**: 단위 426 → 약 450 (+24), e2e 1 유지 또는 +1.
---
## 11. Risk / Known unknowns
| Risk | 발생 시 대응 |
|---|---|
| linux-x64 prebuild 부재 → node-gyp 빌드 실패 | Docker `electronuserland/builder` fallback. 그래도 실패 시 v0.2.7 scope 조정: AppImage 만, deb 는 v0.2.8. [2026-05-07 검증: ✅ prebuild 존재 — electron-v145 (v41.3.0 ABI) 다운로드 성공, better_sqlite3.node 파일 생성] |
| ELECTRON_RUN_AS_NODE 함정 (메모) 가 Linux 환경에서 재현 | smoke test launch env 에서 strip — 기존 e2e 의 strip 패턴 그대로 |
| AppImage 가 모던 GNOME 에서 트레이 표시 안 됨 | 의도적 — 그래서 dock/launcher → inbox → 설정 페이지 흐름이 안전망. F14 fix 가 이 흐름의 핵심. |
| 설정 페이지 라우팅이 inbox 의 keyboard shortcut / hotkey 와 충돌 | view='settings' 시 inbox-only shortcut 비활성. zustand state 분기. |
| 자동 실행 진단 패널이 Mac/Linux 에선 의미 없는 정보 노출 | 플랫폼 분기 — Mac/Linux 는 registry 행 숨김 + executableWillLaunchAtLogin 만 표시 |
| 트레이 callback 8개 제거 시 import 그래프에서 dead code 잔존 | 제거 후 typecheck + grep 으로 검증 |
---
### v0.2.7 Linux 빌드 1차 시도 결과 (2026-05-07, Windows 호스트)
`npm run dist:linux` 실행 — Windows 11 호스트.
**진행 단계:**
- ✅ predist:linux (electron rebuild + electron-vite build) — 성공
- ✅ electron-builder linux x64 패키징 prep — 성공
- ✅ electron-v41.3.0-linux-x64.zip 다운로드 (117 MB)
-`dist/linux-unpacked/` 스테이징 생성 — 322 MB (electron + native modules + app)
- ✅ appimage-12.0.1.7z 다운로드 (mksquashfs 등 Linux 유저랜드 도구 캐시)
- ⚠️ **AppImage 패키징 실패**`mksquashfs` 실행 불가
- ⏭️ **deb 패키징은 시도조차 못함** (AppImage 실패로 빌드 중단)
**핵심 에러:**
```text
cannot execute cause=exec: "C:\Users\...\electron-builder\Cache\appimage\appimage-12.0.1\linux-x64\mksquashfs": file does not exist
failed to build AppImage error=...app-builder.exe process failed ERR_ELECTRON_BUILDER_CANNOT_EXECUTE
Exit code: 2
```
**진단:** `mksquashfs` 파일은 캐시에 존재하나 (270 KB Linux ELF 바이너리), Windows 가 ELF 를 실행 불가 → electron-builder 가 "file does not exist" 로 보고. AppImage cross-build from Windows 는 **근본적으로 불가능** (WSL/Docker/Linux/Mac 호스트 필요).
**결론:**
- AppImage: ⚠️ 실패 (Windows 호스트는 mksquashfs ELF 실행 불가 — 환경 제약)
- deb: ⚠️ 미시도 (`dpkg-deb` + `fakeroot` 부재 추정. AppImage 실패로 도달 못함)
**Fallback 결정:** Mac (사용자 업무 호스트) 또는 Linux/WSL/Docker 핸드오프 필수. Windows 단독으로는 v0.2.7 Linux 산출물 생성 불가. plan Task 3 은 "시도 + 결과 기록" 이 핵심이었고, **macOS 후속 시도가 본 빌드** — Windows 시도는 환경 한계 확인용.
**권장 후속:**
1. 사용자 macOS 업무 호스트에서 동일 명령 (`npm run dist:linux`) 재시도. brew 로 `dpkg` + `fakeroot` 사전 설치 (`brew install dpkg`). AppImage 는 macOS 에서 정상 cross-build 가능 (mksquashfs Mach-O 바이너리 caching).
2. macOS 에서도 deb 가 실패할 경우 — v0.2.7 scope 를 **AppImage only** 로 축소, deb 는 v0.2.8 또는 Docker `electronuserland/builder` 환경으로 이동. package.json `linux.target` 에서 deb 제거하거나 별도 task 로 분리.
3. 향후 자동화: GitHub Actions / Gitea Actions 에서 ubuntu-latest runner 로 Linux build 자동화 (현 수동 cross-build 환경 의존성 제거).
---
## 12. v0.2.7 후
**잔여 backlog (24건)**`docs/superpowers/v024-backlog.md`:
- v0.2.6 final reviewer minor cleanup 6건 — kstDate 의미 정정 / NoteRepository.test.ts as any / store.ts trashCount race 등
- telemetry data-dependent 14건 — VOCAB_TOP_N 튜닝, recallBanner 임계값 등 — v0.2.8 후보 (v0.2.7 dogfood soak ≥1주 후 telemetry export 누적)
- v0.2.7 본 cut 안 신규 발견 — F12 root cause 가 진단 데이터 누적 후 결정될 가능성
**v0.2.8 트리거**: v0.2.7 release 후 dogfood ≥1주 soak + telemetry export + F12 진단 데이터 → v0.2.8 brainstorm.

View File

@@ -1,9 +1,11 @@
# Inkling — Dogfooding 전략
**작성일:** 2026-04-25
**대상:** 김태현 (저자 본인) — 슬라이스 v0.4 dogfood 단계
**스펙 의존:** `2026-04-24-inkling-vertical-slice-design.md` v0.4 §1.3 (종료 조건)
**최종 갱신:** 2026-05-05 (v0.2.6 release 후 — environment step 갱신, 현재 단계 표기)
**대상:** 김태현 (저자 본인) — 슬라이스 v0.4 → v0.2.6 dogfood 진행 중
**스펙 의존:** `2026-04-24-inkling-vertical-slice-design.md` v0.4 §1.3 (종료 조건) + `2026-05-01-v023-feedback-roadmap-design.md` (v0.2.3 7항목 cut)
**전략 의존:** `strategy.md` §1·§2·§5 (행동 정의, Capture→Clarify→Capitalize, 회복 친화 스트릭)
**현재 binary:** v0.2.6 (`Inkling Setup 0.2.6.exe` — 2026-05-05 release)
---
@@ -27,14 +29,24 @@ dogfood 첫 날 시작 전, 환경을 한 번에 정렬한다.
### 1.1 환경
- [ ] `.nvmrc` 의 Node 버전 (24.15.0) 활성화
- [ ] `INKLING_OLLAMA_ENDPOINT` 가 LAN Ollama (`http://192.168.0.47:11434`) 를 가리킴
- [ ] LAN Ollama 에 `gemma4:e4b` 가 pull 된 상태 확인 (`curl http://192.168.0.47:11434/api/tags`)
- [ ] `npm run build``npm start` 로 정식 실행 (dev 모드 아님 — dogfood 는 프로덕션 빌드)
- [ ] 윈도우 트레이에 Inkling 아이콘 떠 있음
**v0.2.6 release 기준 (2026-05-05 갱신)**:
- [ ] **설치**: Gitea release 페이지 (`https://gitea.altair823.xyz/altair823-org/inkling/releases/tag/v0.2.6`) 에서 `Inkling-Setup-0.2.6.exe` 다운로드 + 설치
- 또는 source 빌드: `npm run dist:win` (Windows) / `npm run dist:mac` (Mac arm64)
- [ ] **Ollama 설정** (v0.2.3.1 PR #21 부터 in-app 가능):
- 트레이 메뉴 → "Ollama 설정..." → endpoint + model 직접 입력
- 또는 env var fallback: `INKLING_OLLAMA_ENDPOINT=http://192.168.0.47:11434` (LAN 서버)
- 또는 default: `http://localhost:11434` (로컬 ollama serve 시)
- **Windows 11434 reserved 머신 (Hyper-V/WSL2 사용 시)**: `OLLAMA_HOST=127.0.0.1:11942` setx + Inkling 설정 endpoint 도 11942 (자세한 내용 F8)
- [ ] LAN Ollama 에 `gemma4:e4b` 가 pull 된 상태 확인 (`curl <endpoint>/api/tags`)
- [ ] 윈도우 트레이에 Inkling 아이콘 떠 있음 (단일 instance — v0.2.5 PR #23 hotfix 로 multi-spawn 차단)
- [ ] `Ctrl+Shift+J` 가 다른 앱(Chrome, Edge DevTools 등)에 충돌 없이 잡힘
- [ ] OS 알림 권한 허용 — 첫 토스트 후 시스템 트레이에서 확인
- [ ] `%APPDATA%\Inkling\default\inkling.db` 가 새로 생성됨 (이전 dogfood 데이터 분리하려면 이 파일을 백업·삭제)
- [ ] **데이터 위치 확인** (v0.2.4 PR #22 트레이 "Inkling 정보..." → "데이터 위치 열기" 로 즉시 확인):
- Windows: `%APPDATA%\Inkling\Inkling\profiles\default\inkling.sqlite`
- macOS: `~/Library/Application Support/Inkling/Inkling/profiles/default/inkling.sqlite`
- 이전 dogfood 데이터 분리하려면 이 디렉터리 백업·삭제
- [ ] **autostart 확인** (v0.2.6 진단 fallback 적용 중, F12 dogfood verify 영역): 트레이 메뉴 "윈도우 시작 시 자동 실행" 체크 → 종료 → 재실행 → 체크박스 유지 여부 확인 (`autostart.state` 로그 같이 확인 = `<userData>/Inkling/logs/main-YYYY-MM-DD.log`)
### 1.2 dogfood 로그 파일 준비

View File

@@ -53,6 +53,8 @@ AI가 제목, 요약, 태그, 프로젝트 후보를 생성합니다. 다만 사
하루 또는 주간 리뷰에서 AI가 메모를 업무 산출물로 바꿔줍니다.
오늘 회상 (RecallBanner, v0.2.3 #6): Inbox 상단의 회상 추천 배너가 7일 이상 안 본 노트 1건을 가장 오래된 순으로 제시합니다. 사용자는 "열어보기"(노트 카드 스크롤 + last_recalled_at 갱신), "다음에"(KST 자정까지 in-memory snooze), "더 이상"(recall_dismissed_at 갱신, 30일 후 재추천) 중 선택합니다. 본 surface 가 Capitalize 단계의 첫 본격 진입점입니다.
예:
“이번 주 결정 근거”
@@ -140,6 +142,8 @@ Confluence 공유 후보 추천
직장에서의 동기와 몰입은 의미 있는 일에서 진전이 보일 때 강해집니다. Amabile와 Kramer의 “Progress Principle”은 지식 근로자의 감정·동기·창의성에 작은 진전 경험이 중요하다는 점을 강조합니다. Inkling의 주간 리포트는 “기록 수”보다 업무 진전의 증거를 보여줘야 합니다.
측정 인프라 (v0.2.3 #6): recall_shown / recall_opened / recall_dismissed / recall_snoozed 4종 telemetry 가 본 cut 으로 자리잡았습니다. 향후 F4-A (잠금해제 hook), F4-B (ambient if-then), F4-D (무작위 토스트) 항목 진입 시 본 telemetry 가 효과 측정 기반으로 확장됩니다.
5. 스트릭은 처벌이 아니라 회복 친화적으로 설계한다
기획서에 스트릭과 뱃지가 포함되어 있는데, 이 장치는 조심해서 써야 합니다. 게임화 연구는 전반적으로 긍정적 효과를 보이지만, 효과 크기와 안정성은 맥락에 따라 다르고, 특히 동기·행동 효과는 고품질 연구만 보면 덜 안정적일 수 있습니다. 따라서 Inkling은 경쟁·압박형 게임화가 아니라 자기효능감 회복형 게임화가 맞습니다.
@@ -280,6 +284,8 @@ AI 자동 정리는 Inkling의 핵심 강점입니다. 다만 사용자가 완
8. 관계성 보상: “내 메모가 동료의 시간을 아껴준다”
Inbox surface stack (v0.2.3 기준): Ollama 회복 → Pending 진행 → Failed 실패 → Expiry 마감 임박 → Recall 회상 추천. 시간 민감도 순으로 위에서 아래. RecallBanner 가 가장 가벼운 surface 로 stack 끝에 놓입니다.
기록 습관은 개인 생산성뿐 아니라 팀 학습과도 연결됩니다. Edmondson의 심리적 안전감 연구는 팀원이 대인관계 위험을 감수하고 질문·실수·학습 행동을 할 수 있는 분위기가 팀 학습과 관련된다는 점을 제시합니다. 업무 메모를 팀 지식으로 공유하게 만들려면 “감시받는다”가 아니라 동료를 돕는다는 감각이 필요합니다.
따라서 Confluence 내보내기 UX는 이렇게 설계합니다.

View File

@@ -0,0 +1,212 @@
# v0.2.x Backlog
> 누적 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)
**총 항목 수:** 46 (#1 stale 포함)
**잔여:** 23건 (=46 처리 22 stale 1)
## 처리 이력 / 진행 흐름
| 항목 | 상태 | Cut |
|---|---|---|
| #1 (`now()` 2번 호출) | ✅ 이미 fix (PR #13 round 1 — backlog stale) | - |
| #2 (`DAY_MS` magic) | ✅ 처리 | v0.2.4 (commit `ef5d3da`) |
| #6 (`media.gc.run()` `.catch`) | ✅ 처리 | v0.2.4 (commit `ef5d3da`) |
| #13 (NoteCard `onDeleted` dead-code) | ✅ 처리 | v0.2.4 (commit `c87c248`) |
| #44 (버전 정보 surface) | ✅ 처리 (트레이 "Inkling 정보..." + native dialog) | v0.2.4 (commit `d3dfe1e`) |
| **out-of-backlog**: multi-instance bug (single-instance lock) | ✅ critical hotfix | v0.2.5 (PR #23, `7187aea`) |
| #10 (restoreNote + pending_jobs) | ✅ 처리 (repo 메서드 + CaptureService production path) | v0.2.6 (commit `df27a96` + `a991008`) |
| #12 (trashCount cap) | ✅ 이미 fix (v0.2.3 #4) — tests +2 추가 | v0.2.6 (commit `e2c53a2`) |
| #45 (자동실행 풀림 버그) | ✅ 처리 (진단 노출 + 재등록 버튼) | v0.2.7 (commit `8a8652e` + `8368286`) |
| #46 (hidden-start race) | ✅ 처리 (`additionalData` + handler hidden flag) | v0.2.6 (commit `e485b77`) |
| #3+#19+#34 (KST helper 통합) | ✅ 처리 → `src/shared/util/kstDate.ts` (4 callsite migrate) | v0.2.6 (commit `3cfa60b`) |
| #5 (AiFailedReason union) | ✅ 처리 (zod z.infer 단일 export) | v0.2.6 (commit `a2c17a8`) |
| #21 (hasNoteId predicate) | ✅ 처리 (NO_NOTE_ID_KINDS Set + type predicate) | v0.2.6 (commit `05c45c1`) |
| #22 (hydrate `as any[]`) | ✅ 처리 (`as Record<string, unknown>[]` 통일) | v0.2.6 (commit `983306e`) |
| #8 (stats exhaustiveness) | ✅ 처리 (`else { _: never = ev }`) | v0.2.6 (commit `9230ebf`) |
| #4+#23+#26+#27 (TrayCallbacks 객체화) | ✅ 처리 (1-arg + `Partial<TrayState>`) | v0.2.6 (commit `476a519`) |
| #24+#41 (Banner shared component) | ✅ 처리 (`Banner severity=...` 4 callsite) | v0.2.6 (commit `0447b69`) |
| #15 (IPC channel rename) | ✅ 처리 (`inbox:delete``inbox:trash`) | v0.2.6 (commit `8b2920f`) |
| #29 (VOCAB_TOP_N const) | ✅ 처리 (튜닝 자체는 telemetry 후) | v0.2.6 (commit `8b2920f`) |
| #42 (Modal URL pre-check) | ✅ 처리 (zod safeParse) | v0.2.6 (commit `8b2920f`) |
| #9 (휴지통 회수율 ratio 코멘트) | ✅ 처리 (1줄 코멘트) | v0.2.6 (commit `8b2920f`) |
### v0.2.6 PR #24 round 1 발견 (Critical fix)
| 항목 | 상태 | Cut |
|---|---|---|
| **B1 production path** (CaptureService.restoreNote 가 옛 `repo.restore` 호출) | ✅ Critical fix (commit `a991008`) | v0.2.6 round 1 |
### v0.2.6 final reviewer + round 1 minors (deferred)
| 항목 | 상태 |
|---|---|
| NoteRepository.countToday inline KST_OFFSET_MS | v0.2.7 cleanup (C1 spec 외) |
| BackupService / ContinuityService inline KST_OFFSET_MS | v0.2.7 cleanup |
| NoteRepository.test.ts:125 `as any` | v0.2.7 (C6 spec 외) |
| OllamaSettingsModal `#fce4e4` inline (C7 spec 5번째) | modal 컨텍스트라 보류 |
| `kstDate(ts)` semantic naming (telemetryStats) | v0.2.7 |
| store.ts:177 trashCount race on `trashExpiredBatch` | pre-existing, v0.2.7 |
| ExpiryBanner useEffect 24h+ closure | edge case, defer |
**잔여 24건** (= 46 처리 21 stale 1). v0.2.7 brainstorm 시 신규 dogfood 피드백 + #45 deeper fix + 위 deferred 항목 일괄 triage.
## 명명 노트
- v0.2.3.1 / v0.2.4 / v0.2.5 는 **dogfood unblock patch** (semver bump 강제 / hotfix)
- v0.2.6 = 첫 정식 cut (16 backlog 항목 처리)
- v0.2.7 = 다음 정식 feature cut (telemetry data-dependent 14건 + 신규 피드백 + 잔여 deferred)
- 본 backlog 파일은 v0.2.7 cut 시점에 prune + rename 검토 (`v027-backlog.md` 또는 stable 한 `feature-backlog.md`)
## Defer 사유 카테고리
각 항목은 머지 전 inline fix 보다 v0.2.4 영역으로 미룬 명시적 사유 가짐:
1. **Cross-cutting refactor** — 한 PR 안에서 부분만 고치면 inconsistency. 일괄 cleanup task 영역. (예: KST helper 4 callsite 통합, `createTray` positional callbacks 전체 객체화)
2. **Data-dependent** — dogfood telemetry 분포 보고 결정해야 의미. (예: top-N 튜닝, recall_shown lifetime dedup 정책)
3. **Cosmetic / style** — 동작 영향 0, 다른 일괄 cleanup task. (예: `now()` 두 번 호출, `as any[]` 통합)
## How to apply
v0.2.6 brainstorm 시 본 리스트를 1차 backlog 로 사용. 항목별로:
- (a) 그대로 cleanup
- (b) #4~#6 영향 받아 변형
- (c) defer-further 결정
- (d) drop (만에 하나 outdated 또는 v0.2.4/v0.2.5 patch 가 우회 처리)
## v0.2.3 #7 Telemetry skeleton 누적 (2026-05-01)
1. **`now()` 두 번 호출** — `TelemetryService.emit` (`src/main/services/TelemetryService.ts:58, :60`) 가 같은 emit 안에서 `this.now()` 두 번. 이론적 midnight straddle 가능 (ts vs filePath 다른 KST 일자), 실제 영향 cosmetic. cleanup: `const nowDate = this.now()` 한 번 추출.
2. **`DAY_MS = 24*60*60*1000` magic number** — `cleanupOldFiles:39` + `readAllRecent:78` (+ `KST_OFFSET_MS` 간접). 모듈 상단에 `const DAY_MS = 24 * 60 * 60 * 1000;` 추출.
3. **KST helper duplication**`TelemetryService.todayKstIso` + `telemetryStats.kstDate` + `AiWorker.todayKstAsDate`/`todayKstAsIso`. 4번째 caller (예: 회상 schedule, 만료 batching) 등장 시 `src/main/util/kst.ts` 로 통합.
4. **`createTray` positional 폭주** — `tray.ts:51` 가 7 positional callbacks. #1 ollama 회복 / #4 휴지통 비우기 등 트레이 메뉴 추가 시 8+ 도달 → readability threshold 넘김. `TrayCallbacks` object 로 refactor.
5. **`AiFailedReason` union 3 곳 중복** — `'unreachable' | 'schema' | 'timeout' | 'other'``telemetryEvents.ts:15` (zod), `TelemetryService.ts:21` (EmitInput), `AiWorker.ts:19, :34` (classifier + emitter) 에 분산. `export type AiFailedReason` 하나로 통합. (단 zod enum + TS literal 의 inherent dual-define 은 어쩔 수 없음 — `z.infer` 통해 type 파생만)
6. **`media.gc.run()``.catch` 누락** — T11 에서 `telemetry.cleanupOldFiles``.catch` 일관성 처리 시 `media.gc` 도 같은 패턴 (`.catch` 없음) 발견. `backup.runDaily()` 와 컨벤션 통일 위해 `.catch((e) => logger.warn('media.gc.failed', { reason: String(e) }))` 추가.
7. **stats.md 의 reason 분포 미포함**`telemetryStats.aggregateStats` 가 AI 성공률만 계산, `ai_failed.payload.reason` 의 분포 (unreachable/schema/timeout/other counts) 는 미집계. roadmap §6.2 의 "Ollama unreachable 빈도?" 질문이 부분적으로만 답해짐. v0.2.3 dogfood 후 실제 reason 분포 보고 결정.
## v0.2.3 #4 휴지통 누적 (2026-05-01)
8. **stats.md exhaustiveness check**`telemetryStats.aggregateStats` 의 7-arm if/else if 가 union 확장 시 silent fall-through. `else { const _: never = ev; }` 추가로 컴파일 단계 가드.
9. **휴지통 회수율 ratio 의미 코멘트**`restore / trash` 가 event-level ratio (한 노트 trash-restore 반복 시 100% 가능). spec §6.2 의 "회수 도구 동작?" 질문에는 충분, 단 unique-note 회수율로 오해할 여지. 코드 옆 1줄 코멘트.
10. **`restore` 시 AI 결과 보존 + pending_jobs 미재생성** — restore 가 `deleted_at = NULL` 만, pending_jobs 안 재생성. 사용자가 trash 도중 AI fail 한 노트를 restore 시 재처리 경로 부재. v0.2.3 dogfood 에서 빈도 보고 결정 — drop / per-note retry 버튼 / 자동 재처리 중.
11. **`restoreNote(id)` precondition 노출** — store 의 낙관적 갱신이 `trashNotes` 에 노트가 있어야 동작. 명령 팔레트 / 프로그래밍 호출 케이스 시 silently no-op. 현재는 trash view 한정이라 OK. main 이 trash/restore 시 `pushNoteUpdated` 보내도록 변경하면 더 견고.
12. **`inbox:trashCount` cap 200 silent undercount** — UI 만 200 cap, `repo.emptyTrash()` SQL 은 unbounded. 350 노트 trash 시 dialog "200개 영구 삭제" 표시되지만 실제 350 모두 삭제. `repo.countTrashed()` 추가로 둘 다 정확히. **(잠재 UX 버그 — pull-forward 후보)**
13. **NoteCard `mode='trash'` 의 `onDeleted` dead-code** — trash 카드는 `onPermanentDelete`/`onRestore` 만 사용. `onDeleted` prop 은 호출되지 않음 (App.tsx 가 pass-through). API 깔끔히 — `onDeleted?` optional + trash 분기 미전달.
14. **탭 ARIA 패턴**`aria-pressed` 로 toggle 버튼 표현. canonical 은 `role="tab"` + `aria-selected`. screen reader 동작 OK 지만 a11y audit 시 정정 후보.
15. **`inbox:delete` 채널 rename** — semantic 이 hard → soft 인데 채널 이름 그대로. v0.2.4 에서 `inbox:trash` 로 rename 검토 (기존 호출 0건 보장 후).
16. **per-note 영구 삭제 telemetry 사용량** — v0.2.3 dogfood 에서 `permanent_delete` event 빈도 확인. 거의 0 이면 v0.2.4 에서 per-card "영구 삭제" 버튼 제거 + bulk emptyTrash 만 (UX 단순화). 빈번하면 유지.
## v0.2.3 #5 만료 추천 누적 (2026-05-01)
17. **dialog 버튼 순서 vs spec §5.3** — spec 은 `['취소','옮기기'], default=0`, 구현은 `['옮기기','취소'], defaultId=1, cancelId=1` (`inboxApi.ts:117`). 효과 동일 (default = cancel). v0.2.4 에서 spec 또는 impl 한쪽 통일.
18. **`loadExpired()` 미사용** — `loadInitial`/`refreshMeta` 가 inline fetch, App.tsx 도 호출 안 함 (test 만 exercise). v0.2.4 dogfood 후에도 consumer 미발생 시 제거 검토.
19. **store `KST_OFFSET_MS` inline duplication**`store.ts:166``snoozeExpired` 가 inline KST 계산. `@main/util/kstDate.ts` 와 동일 알고리즘이지만 alias 경계 (main vs renderer) 로 import 불가. `src/shared/util/kstDate.ts` 로 lift 검토. (#3, #34 와 합산 가능)
20. **telemetry emit `.catch(() => {})` 가 silent**`CaptureService.listExpired`/`trashExpiredBatch` 가 그대로. v0.2.4 telemetry 하드닝 시 debug log path (project pattern 통일) 추가 검토.
21. **TelemetryService.test.ts 의 noteId 가드 widening**`e.kind !== 'empty_trash' && e.kind !== 'expired_banner_shown' && e.kind !== 'expired_batch_trash'` 체인이 #6 추가 시 더 길어짐. `hasNoteId(ev)` type predicate helper 추출 검토.
22. **NoteRepository hydrate 의 `as any[]` 일괄 cleanup**`findExpiredCandidates` round 1 review 의 nit 가 단독 fix 시 다른 hydrate-using methods 와 inconsistency. `db.prepare().all()` 의 row type 을 `Record<string, unknown>[]` 또는 explicit row interface 로 통일하는 repo-wide refactor.
## v0.2.3 #1 ollama 회복 누적 (2026-05-01)
23. **`createTray` 8 positional callbacks** — #1 cut 에서 8개 도달, v0.2.4 backlog #4 와 정합 (TrayCallbacks object refactor 약속). #2 retry 또는 #6 reminder cut 에서 추가 항목 (예: "재시도 N건") 등장 시 9+ 회피 위해 본격 refactor.
24. **Banner CSS 스타일 inline 중복** — ExpiryBanner (`#fff7e6 / #d99500 / #946100` 황색) / OllamaBanner (동) / FailedBanner (`#fce4e4 / #a33` 적색) / RecallBanner (`#e8f0fe / #4a7ec0` 청색) 모두 색상 hardcode. v0.2.4 에서 CSS variables 또는 banner shared component (`<Banner severity="warning|error|info" />`) 추출 검토.
25. **HealthChecker `inFlight` 가드의 manual emit ordering** — manual emit 이 inFlight 체크 전 발생해 user 가 빠르게 N번 클릭하면 N개 manual telemetry. spec 의도 (1:1 보장) 와 정합이지만, 향후 dedup 정책 (예: 1초 윈도우) 으로 변형 가능성. v0.2.4 dogfood soak 결과로 결정.
## v0.2.3 #2 AI retry 누적 (2026-05-02)
26. **`createTray` 9 positional callbacks** — #2 cut 에서 9개 도달 (refreshTrayFailedCount 포함). #4 `TrayCallbacks` object refactor 가 이제 readability blocker. #3 / #6 cut 어느 쪽이든 추가 callback 더 들어오기 전에 우선 처리.
27. **`refreshTrayFailedCount` exported singleton state** — `tray.ts``_failedCount` module-scoped state + setter 패턴. 모듈 캡슐화로 작동하지만 multi-window 또는 multi-tray 시 broken. v0.2.4 refactor 시 TrayController class 또는 store-driven 으로 정리.
28. **`AiWorker.unreachableBackoffStep` 단일 카운터 vs job-level** — 모든 job 이 step counter 공유. 1 job timeout → step↑, 다른 job 정상 처리해도 step reset. 현재는 cross-job correlation 없으니 OK 가정 (Ollama daemon 단일이라 모든 job 이 같은 백엔드 의존). multi-provider 가 들어오면 provider-level step 으로 분리 필요.
## v0.2.3 #3 태그 vocab 누적 (2026-05-02)
29. **`getTopUsedTags(20)` magic number** — `AiWorker.processJob:137``repo.getTopUsedTags(20)` hardcoded. spec §7 Out 에 "top-N 튜닝" 명시. v0.2.4 dogfood telemetry (`tag_vocab_hit/miss` ratio) 보고 `VOCAB_TOP_N` 모듈 상수 추출 + 튜닝 결정.
30. **`getTopUsedTags` LIMIT-then-filter 의미** — SQL 가 limit 만큼 가져온 후 JS regex 가 후처리 → top-20 안에 한글/공백 태그 섞이면 결과 length < limit. dogfood 규모 OK 가정 + 테스트 lock-in (v0.2.3 round 1 m2 fix). v0.2.4 에서 vocab pool 확장 시 SQL `GLOB` 으로 SQL-side 필터 대안 검토 (또는 `LIMIT ?*2` overfetch+slice).
31. **`vocabSet` strict-eq vs DB COLLATE NOCASE 불일치** — `vocabSet = new Set(vocab)` 은 JS 대소문자 strict, `tags.name` 은 COLLATE NOCASE. 현재는 kebab-case 필터로 vocab 이 항상 lowercase + AI prompt 도 lowercase 강제라 충돌 없지만, vocab pool 확장 시 (예: `'Design'` 사용자 직접 추가) `getTagIdByName('Design')` 은 매치하지만 `vocabSet.has('Design')` 은 miss → tagId 없는 hit 가 silently skip. v0.2.4 에서 `vocabSet = new Set(vocab.map(v => v.toLowerCase()))` + `vocabSet.has(tagName.toLowerCase())` 로 normalize 검토.
32. **AiWorker per-tag emit serial await**`for (const tag of new Set(...))` 안의 `await this.telemetry.emit(...)` 가 직렬. 3 태그 시 file-append 3 round-trip. `Promise.all` 로 병렬화 가능, 단 `ai_succeeded` emit 도 serial 이라 패턴 일관성 우선 skip. v0.2.4 telemetry 하드닝 시 일괄 변경 검토.
33. **`PROMPT_VERSION` telemetry payload 미포함** — v0.2.3 cut 에선 단일 버전 (4) 만 굴러가서 무의미. v0.2.4/v0.2.5 prompt 튜닝 후 어느 버전이 어떤 hit-rate 만든지 추적 시 `tag_vocab_hit/miss` payload 에 `promptVersion` 추가 검토. spec §7 Out 명시.
## v0.2.3 #6 RecallBanner 누적 (2026-05-02)
34. **KST midnight inline calc 4번째 복제**`store.ts``snoozeRecall` (#6) + `snoozeExpired` (#5) + `NoteCard.todayKstIso` + 다른 1곳, 그리고 `kstDate.ts` util 도 별도 존재. 4 callsite 모두 동일 알고리즘. v0.2.4 에서 `nextKstMidnightMs()` / `kstTodayIso()` 단일 util 통합 + alias 경계 (main vs renderer) 해결책. backlog #3, #19 와 합산.
35. **`recall_shown` per-banner-lifetime emit 보장** — useState→useRef 로 race 차단했지만 RecallBanner 컴포넌트 unmount/remount 시 reset. 사용자가 페이지 이동 후 돌아오면 같은 노트가 재emit 가능. v0.2.4 dogfood telemetry 에서 동일 noteId 의 `recall_shown` 빈도 보고 결정 (per-noteId 24h dedup 또는 per-noteId 영속 마커).
36. **`emitRecallShown` / `emitRecallSnoozed` 가 fire-and-forget 인데 `ipcMain.handle` 사용** — 더 honest 한 패턴은 `ipcMain.on` (return value 없음). 현재는 다른 IPC 와 패턴 일관성 우선. v0.2.4 IPC 정리 시 `handle` vs `on` 구분 일괄 검토.
37. **NoteCard `id="note-${id}"` load-bearing** — RecallBanner 의 `scrollIntoView` target. 단순 DOM lookup 이라 shadow DOM / portal 미지원. v0.2.4 에서 다른 surface (예: 검색 결과에서 스크롤) 등장 시 ref-forwarding 패턴 검토.
## v0.2.3.1 Ollama Settings 누적 (2026-05-04)
39. **`ollama_unreachable.reason` 에 endpoint URL 노출 (PII 우회)** — `LocalOllamaProvider.healthCheck` 가 catch err 시 `reason: \`unreachable: ${err.message}\`` 로 emit. `err.message` 안에 `http://192.168.x.x:11434/api/tags` 같은 LAN endpoint URL 포함 가능. v0.2.3.1 의 in-app endpoint UI 가 LAN 사용을 흔하게 만들어 PII 우회 노출 경로 확대. v0.2.4 telemetry 하드닝 시: error class only (network/dns/timeout/...) 또는 host 마스킹 (`<host>:11434`) 정책. PR #21 round 1 m2 deferred.
40. **Settings 저장 vs HealthChecker 60s tick race**`saveOllamaSettings` IPC 가 `health.runOnce()` 호출, 동시에 60s 주기 tick 도 `inFlight` 가드 통해 같이 실행 시도. 정확성 영향 0 (가드로 dedup), 단 modal 닫기 직전 banner flicker 가능. PR #21 round 1 i1 acknowledge only. v0.2.4 dogfood 에서 실제 빈도 확인 후 결정 (visible 빈도 낮으면 무시).
41. **`OllamaSettingsModal` 인라인 스타일** — 60+ 줄 inline style. backlog #24 (banner CSS 추출) 와 합산. v0.2.4 에서 CSS module / theme variables 추출 시 함께.
42. **Modal 의 client-side URL validation 부재** — endpoint freetext 가 잘못된 형식 (예: 빈 문자열, 한글) 일 때 server-side healthCheck 만 검증. zod URL error message 가 opaque ("Invalid url"). v0.2.4 에서 client-side z.string().url() pre-check + 친화적 에러 메시지.
43. **`createTray` 10번째 positional callback** — v0.2.3.1 cut 에서 10개 도달 (`runOpenOllamaSettings` 추가). backlog #4/#26 (TrayCallbacks object refactor) blocker 수준. v0.2.4 첫 cleanup 항목 후보.
## v0.2.3 / v0.2.3.1 dogfood 발견 (2026-05-05)
> 본 cut 들의 머지 후 사용자가 dogfood 중 발견한 항목. PR review deferred 와 달리 raw UX/bug 발견.
44. **버전 및 프로그램 정보 표시 방법 부재** — 현재 사용자가 설치된 Inkling 의 버전 (package.json `0.2.3.1`) 을 UI 에서 확인할 path 없음. 트레이 메뉴 / Inbox 푸터 / 별도 "About Inkling" 모달 어느 surface 에도 정보 없음. 핸드오프 후 다른 머신에서 같은 버전인지 사용자가 직접 검증 불가. v0.2.4 에서 트레이 메뉴 "Inkling 0.2.3.1 정보..." 또는 Inbox 우하단 footer 형태로 추가 검토. 곁들여: 빌드 commit SHA, electron/node 버전, OS, profileDir 경로 등 디버그 정보 노출 (사용자가 issue report 시 첨부 가능).
45. **윈도우 자동 실행 옵션이 재시작 후 풀려있는 버그** — 트레이 메뉴 "윈도우 시작 시 자동 실행" 체크 → 종료 → 재실행 시 체크박스가 풀려서 표시됨. 코드 (`src/main/tray.ts:47-58`) 가 `app.setLoginItemSettings({ openAtLogin, args: ['--hidden'] })` 호출 후 다음 부팅 시 `app.getLoginItemSettings().openAtLogin` 이 false 반환. 추정 원인:
- (a) Windows registry 에 쓴 exe path 와 현재 프로세스 path 가 다름 (NSIS 설치 위치 변경 / 버전 업데이트 시 새 디렉터리)
- (b) Electron `setLoginItemSettings` Windows 구현 의 path canonicalization 이슈
- (c) 우리 `args: ['--hidden']` 와 actual launch 시 args 비교 mismatch
- 영향: dogfood UX 핵심 마찰 — autostart 가 핸드오프 시 매번 수동 재설정 필요. 자동 실행 의도 자체가 dogfood "잊지 않고 매일 사용" 목적인데 깨짐.
- v0.2.6 에서 우선순위 높음. 진단 절차: (1) `app.getLoginItemSettings({ args: ['--hidden'] })` 형태로 args 전달해 비교 정확도 올리기, (2) registry 직접 inspect (`HKCU\Software\Microsoft\Windows\CurrentVersion\Run\inkling`) 로 path/args 확인, (3) executable path canonicalization (electron 이 short path 변환 적용 여부).
## v0.2.5 critical hotfix 누적 (2026-05-05)
> v0.2.5 single-instance lock hotfix (PR #23) 의 reviewer deferred 항목.
46. **Hidden-start race (NSIS installer 자동 실행 + 사용자 클릭 충돌)** — NSIS installer 가 설치 직후 사용자가 시작메뉴 / 데스크톱 아이콘 클릭 (`inkling.exe`) + autostart entry (`inkling.exe --hidden`) 을 짧은 간격에 둘 다 시도 시 — 첫 lock 보유자에 따라 visible 여부 race. 본 cut 의 `second-instance` handler 는 무조건 inbox 창 띄움 (사용자 클릭 = 보고 싶다는 강한 시그널 가정). 매우 드문 시나리오 + lock 자체는 정상 동작 (한 쪽만 살아남음).
- 영향: drm-edge 케이스만, 실 사용 거의 X
- v0.2.6 에서: `app.requestSingleInstanceLock(additionalData)``additionalData: { hidden: startedHidden }` 전달 → `second-instance(event, argv, cwd, additionalData)` 에서 두 번째 호출이 hidden 이면 창 안 띄우는 정책. 첫 instance 가 자기 자신의 hidden 상태와 비교해 visible 결정.
- PR #23 round 1 reviewer Important — acknowledge only, defer to v0.2.6.
## post-cut next-step (status, not backlog)
38. **빌드 / release 흐름 (status)** — v0.2.3 cut 7/7 (PR #13~#19) → binary v0.2.3 release → 11434 포트 reserved 발견 → v0.2.3.1 attempt (PR #21) → semver 거부 → v0.2.4 (PR #22, backlog 5건 + Ollama 설정 UI) → release → multi-instance bug 발견 → **v0.2.5 critical hotfix** (PR #23, single-instance lock) → release ✅ (2026-05-05). 다음: dogfood ≥1주 soak → telemetry export + 신규 피드백 → **v0.2.6 brainstorm 트리거** (잔여 backlog 40건 일괄 triage).
## v0.2.3 cut 후 final reviewer 가 칭찬한 부분
- 2-layer privacy invariant (zod outer + payload `.strict()`) 가 강한 defense
- KST 처리 일관성 — 4 callsites 동일 패턴
- backward compat — 기존 13 테스트 (Capture 4 + AiWorker 9) 무수정 통과
- 신규 dep 0 (zip 회피로 폴더 + 2 file 정책)
- TelemetryService surface 가 깔끔한 foundation — 다음 항목들이 (a) zod schema 추가, (b) EmitInput arm 추가, (c) emit 호출만 하면 됨

4185
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "inkling",
"version": "0.2.2",
"version": "0.2.8",
"private": true,
"description": "Inkling — local-first 한 줄 보관 도구",
"author": "altair823 <dlsrks0734@gmail.com>",
@@ -22,13 +22,22 @@
"test:e2e": "playwright test",
"typecheck": "tsc --noEmit",
"predist": "npm run rebuild:electron && npm run build",
"dist": "electron-builder --win --x64",
"dist": "electron-builder",
"predist:dir": "npm run rebuild:electron && npm run build",
"dist:dir": "electron-builder --dir --win --x64"
"dist:dir": "electron-builder --dir",
"predist:win": "npm run rebuild:electron && npm run build",
"dist:win": "electron-builder --win --x64",
"predist:mac": "npm run rebuild:electron && npm run build",
"dist:mac": "electron-builder --mac --arm64",
"predist:linux": "npm run rebuild:electron && npm run build",
"dist:linux": "electron-builder --linux --x64",
"build:icons:png": "node scripts/svg-to-png.mjs assets/icon.svg build/icon-source.png 1024",
"build:icons": "npm run build:icons:png && electron-icon-builder --input=build/icon-source.png --output=build --flatten && node scripts/finalize-icons.mjs"
},
"build": {
"appId": "xyz.altair823.inkling",
"productName": "Inkling",
"publish": null,
"files": [
"out/**/*",
"package.json"
@@ -37,8 +46,14 @@
"**/*.node"
],
"win": {
"icon": "build/icon.ico",
"target": [
{ "target": "nsis", "arch": ["x64"] }
{
"target": "nsis",
"arch": [
"x64"
]
}
]
},
"nsis": {
@@ -47,6 +62,39 @@
"allowToChangeInstallationDirectory": true,
"deleteAppDataOnUninstall": false,
"shortcutName": "Inkling"
},
"mac": {
"icon": "build/icon.icns",
"target": [
{
"target": "dmg",
"arch": [
"arm64"
]
}
],
"category": "public.app-category.productivity",
"identity": null
},
"linux": {
"icon": "build/icon.png",
"target": [
{
"target": "AppImage",
"arch": [
"x64"
]
},
{
"target": "deb",
"arch": [
"x64"
]
}
],
"category": "Utility",
"synopsis": "로컬 메모 캡처 + AI 태그",
"description": "Inkling — 잠깐 스친 생각을 잡아두는 로컬-우선 메모 도구."
}
},
"dependencies": {
@@ -60,6 +108,8 @@
},
"devDependencies": {
"@playwright/test": "1.59.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/better-sqlite3": "7.6.11",
"@types/node": "24.0.0",
"@types/react": "19.0.0",
@@ -67,7 +117,10 @@
"@vitejs/plugin-react": "5.1.4",
"electron": "41.3.0",
"electron-builder": "26.8.1",
"electron-icon-builder": "^2.0.1",
"electron-vite": "5.0.0",
"jsdom": "^29.1.1",
"sharp": "^0.34.5",
"typescript": "6.0.3",
"undici": "8.1.0",
"vite": "7.3.2",

View File

@@ -0,0 +1,35 @@
import { copyFileSync, renameSync, existsSync } from 'node:fs';
import { join } from 'node:path';
// electron-icon-builder --flatten 은 build/icons/ 안에 icon.ico, icon.icns, <size>x<size>.png
// 들을 만든다. electron-builder 는 build/icon.ico, build/icon.icns, build/icon.png 를
// 기대 — 정규 위치로 옮긴다.
const buildDir = 'build';
const iconsDir = join(buildDir, 'icons');
const moves = [
['icon.ico', 'icon.ico'],
['icon.icns', 'icon.icns'],
];
for (const [src, dest] of moves) {
const from = join(iconsDir, src);
const to = join(buildDir, dest);
if (existsSync(from)) {
renameSync(from, to);
console.log(`Moved: ${from} -> ${to}`);
} else {
console.error(`MISSING: ${from}`);
process.exit(1);
}
}
const png1024 = join(iconsDir, '1024x1024.png');
const pngOut = join(buildDir, 'icon.png');
if (existsSync(png1024)) {
copyFileSync(png1024, pngOut);
console.log(`Copied: ${png1024} -> ${pngOut}`);
} else {
console.error(`MISSING: ${png1024}`);
process.exit(1);
}

14
scripts/svg-to-png.mjs Normal file
View File

@@ -0,0 +1,14 @@
import sharp from 'sharp';
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
const [, , input, output, size = '1024'] = process.argv;
if (!input || !output) {
console.error('Usage: svg-to-png.mjs <input.svg> <output.png> [size]');
process.exit(1);
}
mkdirSync(dirname(output), { recursive: true });
const svg = readFileSync(input);
const png = await sharp(svg).resize(Number(size), Number(size)).png().toBuffer();
writeFileSync(output, png);
console.log(`OK: ${output} (${size}x${size})`);

View File

@@ -1,22 +1,38 @@
import type { NoteRepository } from '../repository/NoteRepository.js';
import type { InferenceProvider } from './InferenceProvider.js';
import type { Note } from '@shared/types';
import type { AiFailedReason } from '../services/telemetryEvents.js';
import { ProviderHolder } from './ProviderHolder.js';
import { parseAllCandidates } from '../services/dueDateParser.js';
import { ZodError } from 'zod';
import { kstTodayAsDate, kstTodayIso } from '../../shared/util/kstDate.js';
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
// v0.2.6 #29 — backlog 의 top-N 튜닝은 dogfood telemetry 후 (현재 magic 만 추출).
const VOCAB_TOP_N = 20;
function todayKstAsDate(now: Date): Date {
// Returns a Date object whose UTC year/month/day match KST today
const k = new Date(now.getTime() + KST_OFFSET_MS);
return new Date(Date.UTC(k.getUTCFullYear(), k.getUTCMonth(), k.getUTCDate()));
function classifyReason(err: unknown): AiFailedReason {
if (err instanceof ZodError) return 'schema';
const msg = err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase();
if (msg.includes('econnrefused') || msg.includes('enotfound') || msg.includes('fetch failed') || msg.includes('econnreset') || msg.includes('unreachable')) {
return 'unreachable';
}
if (msg.includes('timeout') || msg.includes('timedout') || msg.includes('aborted')) {
return 'timeout';
}
return 'other';
}
function todayKstAsIso(now: Date): string {
return todayKstAsDate(now).toISOString().slice(0, 10);
export interface AiTelemetryEmitter {
emit(input:
| { kind: 'ai_succeeded'; payload: { noteId: string; durationMs: number; attempts: number } }
| { kind: 'ai_failed'; payload: { noteId: string; reason: AiFailedReason; attempts: number } }
| { kind: 'tag_vocab_hit'; payload: { tagId: number; vocabSize: number } }
| { kind: 'tag_vocab_miss'; payload: { vocabSize: number } }
): Promise<void>;
}
export interface AiWorkerOptions {
backoffsMs?: number[];
unreachableBackoffsMs?: number[];
onUpdate?: (note: Note) => void;
logger?: {
info: (msg: string, meta?: Record<string, unknown>) => void;
@@ -24,6 +40,7 @@ export interface AiWorkerOptions {
error: (msg: string, meta?: Record<string, unknown>) => void;
};
now?: () => Date;
telemetry?: AiTelemetryEmitter;
}
interface Job { noteId: string; attempts: number; }
@@ -33,19 +50,24 @@ export class AiWorker {
private running = false;
private drainResolvers: Array<() => void> = [];
private backoffsMs: number[];
private unreachableBackoffsMs: number[];
private unreachableBackoffStep = 0;
private onUpdate?: (note: Note) => void;
private logger: NonNullable<AiWorkerOptions['logger']>;
private now: () => Date;
private telemetry?: AiTelemetryEmitter;
constructor(
private repo: NoteRepository,
private provider: InferenceProvider,
private holder: ProviderHolder,
opts: AiWorkerOptions = {}
) {
this.backoffsMs = opts.backoffsMs ?? [0, 30_000, 120_000];
this.unreachableBackoffsMs = opts.unreachableBackoffsMs ?? [30_000, 60_000, 120_000, 240_000, 480_000, 900_000];
this.onUpdate = opts.onUpdate;
this.logger = opts.logger ?? { info: () => {}, warn: () => {}, error: () => {} };
this.now = opts.now ?? (() => new Date());
this.telemetry = opts.telemetry;
}
async enqueue(noteId: string): Promise<void> {
@@ -93,45 +115,107 @@ export class AiWorker {
}
private async processJob(job: Job): Promise<void> {
// `max` 는 schema/other 분기 (attempts 증가) 의 cap 이다.
// unreachable/timeout 분기는 `attempt -= 1; continue` 로 인덱스 stay — max 와 무관 무한 retry.
const max = this.backoffsMs.length;
for (let attempt = job.attempts; attempt < max; attempt++) {
const startMs = this.now().getTime();
try {
const note = this.repo.findById(job.noteId);
if (!note || note.aiStatus !== 'pending') return;
if (!note || note.deletedAt !== null || note.aiStatus !== 'pending') return;
const nowDate = this.now();
const todayDate = todayKstAsDate(nowDate);
const todayIso = todayKstAsIso(nowDate);
const todayDate = kstTodayAsDate(nowDate);
const todayIso = kstTodayIso(nowDate);
const candidates = parseAllCandidates(note.rawText, todayDate);
const res = await this.provider.generate({
const vocab = this.repo.getTopUsedTags(VOCAB_TOP_N);
const res = await this.holder.get().generate({
text: note.rawText,
todayKst: todayIso,
dueDateCandidates: candidates
dueDateCandidates: candidates,
vocab
});
// AI primary: AI's dueDate is final (no rule merge)
this.repo.updateAiResult(job.noteId, {
title: res.title,
summary: res.summary,
tags: res.tags,
provider: this.provider.name,
provider: this.holder.get().name,
dueDate: res.dueDate ?? null
});
this.unreachableBackoffStep = 0; // 성공 시 step reset
this.logger.info('ai.done', {
noteId: job.noteId,
attempt,
dueDateSource: res.dueDate !== null ? 'ai' : 'none',
candidatesCount: candidates.length
});
if (this.telemetry) {
await this.telemetry.emit({
kind: 'ai_succeeded',
payload: {
noteId: job.noteId,
durationMs: this.now().getTime() - startMs,
attempts: attempt + 1
}
}).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 }
}).catch(() => {});
}
} else {
await this.telemetry.emit({
kind: 'tag_vocab_miss',
payload: { vocabSize: vocab.length }
}).catch(() => {});
}
}
}
this.emit(job.noteId);
return;
} catch (err) {
const isLast = attempt === max - 1;
const reason = classifyReason(err);
const msg = (err as Error).message;
this.logger.warn('ai.retry', { noteId: job.noteId, attempt, err: msg });
this.logger.warn('ai.retry', { noteId: job.noteId, attempt, err: msg, reason });
if (reason === 'unreachable' || reason === 'timeout') {
// 무한 retry: attempts 증가 안 함, in-place loop + sleep.
// markAiFailed / ai_failed emit 안 함 — ratio 통계는 schema/other 만 누적.
const sleepMs = this.nextBackoffMs(this.unreachableBackoffStep);
// step 이 cap 도달 후엔 인덱스 stay — increment 는 무의미하지만 안전한 no-op.
// (Math.min 가드: cap 넘어가도 length-1 로 묶임.)
if (this.unreachableBackoffStep < this.unreachableBackoffsMs.length - 1) {
this.unreachableBackoffStep += 1;
}
const nextRunAt = new Date(Date.now() + sleepMs).toISOString();
this.repo.setNextRunAt(job.noteId, nextRunAt, msg);
await this.sleep(sleepMs);
attempt -= 1; // for 루프 attempt++ 상쇄 — 같은 attempt 인덱스로 재시도
continue;
}
// schema / other: 기존 max 3 retry 정책
const isLast = attempt === max - 1;
const nextRunAt = new Date(Date.now() + (this.backoffsMs[attempt + 1] ?? 0)).toISOString();
this.repo.incrementJobAttempt(job.noteId, nextRunAt, msg);
if (isLast) {
this.repo.markAiFailed(job.noteId, msg);
this.logger.error('ai.failed', { noteId: job.noteId, err: msg });
if (this.telemetry) {
await this.telemetry.emit({
kind: 'ai_failed',
payload: {
noteId: job.noteId,
reason,
attempts: attempt + 1
}
}).catch(() => {});
}
this.emit(job.noteId);
return;
}
@@ -140,6 +224,11 @@ export class AiWorker {
}
}
private nextBackoffMs(step: number): number {
const idx = Math.min(step, this.unreachableBackoffsMs.length - 1);
return this.unreachableBackoffsMs[idx]!;
}
private emit(noteId: string): void {
if (!this.onUpdate) return;
const note = this.repo.findById(noteId);

View File

@@ -5,6 +5,7 @@ export interface GenerateInput {
text: string;
todayKst: string; // ISO YYYY-MM-DD in KST
dueDateCandidates: ParseResult[];
vocab?: string[]; // v0.2.3 #3 — top-N kebab-case 태그. 미전달 시 빈 배열로 처리.
}
export interface HealthResult { ok: boolean; model?: string; reason?: string; }
@@ -13,4 +14,6 @@ export interface InferenceProvider {
readonly name: string;
generate(input: GenerateInput): Promise<AiResponse>;
healthCheck(): Promise<HealthResult>;
/** v0.2.3.1 — 외부에서 in-flight generate 강제 중단. ProviderHolder.replace 시 사용. */
abort?: () => void;
}

View File

@@ -2,6 +2,7 @@ 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 { DEFAULT_OLLAMA_ENDPOINT, DEFAULT_OLLAMA_MODEL } from '../../shared/constants.js';
export interface LocalOllamaOptions {
endpoint?: string;
@@ -18,10 +19,11 @@ export class LocalOllamaProvider implements InferenceProvider {
private timeoutMs: number;
private temperature: number;
private numPredict: number;
private abortController: AbortController | null = null;
constructor(opts: LocalOllamaOptions = {}) {
this.endpoint = opts.endpoint ?? 'http://localhost:11434';
this.model = opts.model ?? 'gemma4:e4b';
this.endpoint = opts.endpoint ?? DEFAULT_OLLAMA_ENDPOINT;
this.model = opts.model ?? DEFAULT_OLLAMA_MODEL;
this.timeoutMs = opts.timeoutMs ?? 120_000;
this.temperature = opts.temperature ?? 0.2;
this.numPredict = opts.numPredict ?? 512;
@@ -29,20 +31,20 @@ export class LocalOllamaProvider implements InferenceProvider {
}
async generate(input: GenerateInput): Promise<AiResponse> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
this.abortController = new AbortController();
const timer = setTimeout(() => this.abortController?.abort(), this.timeoutMs);
try {
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),
prompt: buildPrompt(input.text, input.todayKst, input.dueDateCandidates, input.vocab ?? []),
format: 'json',
stream: false,
options: { temperature: this.temperature, num_predict: this.numPredict }
}),
signal: controller.signal
signal: this.abortController.signal
});
if (res.statusCode < 200 || res.statusCode >= 300) {
throw new Error(`ollama http ${res.statusCode}`);
@@ -55,9 +57,15 @@ export class LocalOllamaProvider implements InferenceProvider {
return parseAiResponse(parsed);
} finally {
clearTimeout(timer);
this.abortController = null;
}
}
/** v0.2.3.1 — 외부에서 in-flight generate 강제 중단. ProviderHolder.replace 시 사용. */
abort(): void {
this.abortController?.abort();
}
async healthCheck(): Promise<HealthResult> {
try {
const res = await request(`${this.endpoint}/api/tags`, { method: 'GET' });

View File

@@ -0,0 +1,36 @@
import type { InferenceProvider } from './InferenceProvider.js';
/**
* v0.2.3.1 — Mutable provider holder. AiWorker / HealthChecker 가 endpoint 변경 시
* 새 LocalOllamaProvider 인스턴스를 받도록 indirection layer.
*
* 사용 패턴:
* const holder = new ProviderHolder(initialProvider);
* aiWorker = new AiWorker(repo, holder, opts);
* health = new HealthChecker(holder, opts);
*
* // 사용자가 Settings 저장 시:
* holder.get().abort?.(); // in-flight 중단 (LocalOllamaProvider 전용)
* holder.replace(newProvider); // 모든 consumer 가 새 인스턴스 사용
*/
export class ProviderHolder {
private current: InferenceProvider;
private listeners: Array<(p: InferenceProvider) => void> = [];
constructor(initial: InferenceProvider) {
this.current = initial;
}
get(): InferenceProvider {
return this.current;
}
replace(next: InferenceProvider): void {
this.current = next;
for (const fn of this.listeners) fn(next);
}
onReplace(fn: (p: InferenceProvider) => void): void {
this.listeners.push(fn);
}
}

View File

@@ -1,21 +1,27 @@
import type { ParseResult } from '../services/dueDateParser.js';
export const PROMPT_VERSION = 3;
export const PROMPT_VERSION = 4;
export function buildPrompt(
rawText: string,
todayKst: string,
candidates: ParseResult[] = []
candidates: ParseResult[] = [],
vocab: string[] = []
): string {
const candidateBlock = candidates.length > 0
? `\nDate candidates extracted by a Korean rule parser (these are HINTS — you decide which is correct, or pick null):
${candidates.map((c, i) => ` ${i + 1}. ${c.iso ?? '(ambiguous)'} — matched token: "${c.matchedToken ?? '?'}" (confidence: ${c.confidence ?? 'low'})`).join('\n')}\n`
: '';
const vocabBlock = vocab.length > 0
? `\nExisting vocabulary tags (most-used first): ${vocab.join(', ')}\nPrefer reusing a vocabulary tag when the meaning matches; create new tags only when the meaning is genuinely new.\n`
: '';
// candidateBlock & vocabBlock are self-delimited with leading/trailing \n
return `You organize raw personal notes into structured metadata.
Today's date in Korea Standard Time (KST): ${todayKst}
${candidateBlock}
${candidateBlock}${vocabBlock}
Input note (raw text, may be fragmented, any language):
---
${rawText}

View File

@@ -1,8 +1,9 @@
import type Database from 'better-sqlite3';
import * as m001 from './m001_initial.js';
import * as m002 from './m002_due_date.js';
import * as m003 from './m003_soft_delete.js';
const migrations = [m001, m002];
const migrations = [m001, m002, m003];
export function latestVersion(): number {
return migrations[migrations.length - 1]!.version;

View File

@@ -0,0 +1,15 @@
// v3: soft delete (#4) introduces deleted_at.
// last_recalled_at + recall_dismissed_at are pre-allocated for #6 (recall) —
// dormant until then to avoid a v4 migration round-trip.
import type Database from 'better-sqlite3';
export const version = 3;
export function up(db: Database.Database): void {
db.exec(`
ALTER TABLE notes ADD COLUMN deleted_at TEXT;
ALTER TABLE notes ADD COLUMN last_recalled_at TEXT;
ALTER TABLE notes ADD COLUMN recall_dismissed_at TEXT;
CREATE INDEX IF NOT EXISTS idx_notes_deleted_at ON notes(deleted_at);
`);
}

View File

@@ -1,5 +1,5 @@
import electron from 'electron';
const { app, BrowserWindow, Notification, dialog } = electron;
const { app, Notification, dialog } = electron;
import '@shared/types';
import { existsSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
@@ -15,9 +15,11 @@ import { HotkeyService } from './services/HotkeyService.js';
import { IntentService } from './services/IntentService.js';
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 { registerCaptureApi } from './ipc/captureApi.js';
import { registerInboxApi, pushNoteUpdated } from './ipc/inboxApi.js';
import { registerInboxApi, pushNoteUpdated, pushOllamaStatus } from './ipc/inboxApi.js';
import { registerSettingsApi, navigateInbox } from './ipc/settingsApi.js';
import { createInboxWindow, getInboxWindow } from './windows/inboxWindow.js';
import {
createQuickCaptureWindow, showQuickCapture, getQuickCaptureWindow
@@ -28,10 +30,44 @@ 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 { TelemetryService } from './services/TelemetryService.js';
import { SettingsService } from './services/SettingsService.js';
import { collectAutostartState } from './services/AutostartDiagnostic.js';
import { registerSchemesAsPrivileged, registerInklingMediaProtocol } from './protocol/inklingMedia.js';
import { DEFAULT_OLLAMA_ENDPOINT, DEFAULT_OLLAMA_MODEL } from '../shared/constants.js';
const HIDDEN_ARG = '--hidden';
const startedHidden = process.argv.includes(HIDDEN_ARG);
// v0.2.8 Cut A — `inkling-media://` custom protocol 스킴은 app.whenReady() 전에
// privileged 등록 필수 (Electron 표준). 이미지 asset 을 main process 가 직접
// 서빙해 file:// hack 없이 작동.
registerSchemesAsPrivileged();
// CRITICAL — single-instance lock + hidden-flag 전달 (v0.2.6 #46).
// 두 번째 .exe 가 hidden 으로 spawn 됐다면 (autostart) 첫 instance 의 inbox 창
// 띄우지 않음 — 사용자가 명시적으로 클릭한 게 아니므로.
const additionalData = { hidden: startedHidden };
const gotLock = app.requestSingleInstanceLock(additionalData);
if (!gotLock) {
app.quit();
} else {
app.on('second-instance', (_e, _argv, _cwd, secondData) => {
const data = secondData as { hidden?: boolean } | undefined;
// 두 번째가 hidden 으로 spawn (autostart 등) — UI 띄우지 않음
if (data?.hidden === true) return;
// 사용자가 명시적으로 .exe / 단축키 / 트레이로 띄움 → inbox 창 보이게
const win = getInboxWindow();
if (win) {
if (win.isMinimized()) win.restore();
if (!win.isVisible()) win.show();
win.focus();
} else {
createInboxWindow();
}
});
}
app.whenReady().then(async () => {
initLogger();
logger.info('app.start', {
@@ -43,6 +79,14 @@ app.whenReady().then(async () => {
const paths = resolveProfilePaths('default');
// v0.2.8 Cut A — `inkling-media://` request handler 등록 (profileDir 결정 후).
registerInklingMediaProtocol(paths.profileDir);
const telemetry = new TelemetryService(join(paths.profileDir, 'telemetry'), () => new Date(), 14, { silent: true });
void telemetry.cleanupOldFiles()
.then((r) => logger.info('telemetry.cleanup', { removed: r.removed.length }))
.catch((e) => logger.warn('telemetry.cleanup.failed', { reason: String(e) }));
if (app.isPackaged && process.platform === 'win32') {
const initFlag = join(paths.profileDir, '.autostart-init');
if (!existsSync(initFlag)) {
@@ -50,6 +94,8 @@ app.whenReady().then(async () => {
writeFileSync(initFlag, new Date().toISOString());
logger.info('autostart.enabled.firstRun');
}
// v0.2.6 #45 진단 — startup 로그. 같은 정보가 SettingsPage 진단 패널에도 surface (collectAutostartState single source of truth).
void collectAutostartState().then((state) => logger.info('autostart.state', { ...state }));
}
const db = openDb(paths.dbFile);
const repo = new NoteRepository(db);
@@ -57,22 +103,50 @@ app.whenReady().then(async () => {
const continuity = new ContinuityService(db);
const intent = new IntentService(repo);
const resolvedEndpoint = process.env.INKLING_OLLAMA_ENDPOINT ?? 'http://localhost:11434';
const settingsSvc = new SettingsService(paths.profileDir);
const settings = await settingsSvc.load();
const resolvedEndpoint = settings.ollama?.endpoint
?? process.env.INKLING_OLLAMA_ENDPOINT
?? DEFAULT_OLLAMA_ENDPOINT;
const resolvedModel = settings.ollama?.model ?? DEFAULT_OLLAMA_MODEL;
logger.info('ai.endpoint', {
endpoint: resolvedEndpoint,
fromEnv: process.env.INKLING_OLLAMA_ENDPOINT !== undefined
model: resolvedModel,
source: settings.ollama?.endpoint
? 'settings'
: (process.env.INKLING_OLLAMA_ENDPOINT ? 'env' : 'default')
});
const provider = new LocalOllamaProvider({ endpoint: resolvedEndpoint });
const health = new HealthChecker(provider);
void health.runOnce().then((h) => logger.info('ai.health', { ...h } as Record<string, unknown>));
const worker = new AiWorker(repo, provider, {
const provider = new LocalOllamaProvider({ endpoint: resolvedEndpoint, model: resolvedModel });
const providerHolder = new ProviderHolder(provider);
const health = new HealthChecker(providerHolder, {
onUpdate: (status) => {
logger.info('ai.health', { ...status } as Record<string, unknown>);
pushOllamaStatus(getInboxWindow, status);
},
onTelemetry: (ev) => {
if (ev.kind === 'ollama_unreachable') {
void telemetry.emit({ kind: 'ollama_unreachable', payload: { reason: ev.reason } }).catch(() => {});
} else if (ev.kind === 'ollama_recovered') {
void telemetry.emit({ kind: 'ollama_recovered', payload: { downtimeMs: ev.downtimeMs } }).catch(() => {});
} else if (ev.kind === 'ollama_recheck_manual') {
void telemetry.emit({ kind: 'ollama_recheck_manual', payload: {} }).catch(() => {});
}
}
});
health.start();
const worker = new AiWorker(repo, providerHolder, {
onUpdate: (note) => {
pushNoteUpdated(getInboxWindow, note);
// F4-C: AI 처리 완료 = 새 캡처가 inbox 에 합류한 시점, tray 도 즉시 갱신.
refreshTray(repo.countToday());
// v0.2.7 Phase 3 — failedCount 메뉴 항목 제거됨 → todayCount 만 갱신.
refreshTray({ todayCount: repo.countToday() });
},
logger
logger,
telemetry
});
const notify = new NotificationService({
@@ -84,14 +158,18 @@ app.whenReady().then(async () => {
const capture = new CaptureService(repo, store, {
enqueue: (id) => worker.enqueue(id),
celebrate: (id) => notify.celebrate(id)
celebrate: (id) => notify.celebrate(id),
telemetry
});
registerCaptureApi(capture, getQuickCaptureWindow);
registerInboxApi({
repo, continuity, capture, health, intent,
getInboxWindow
getInboxWindow, settings: settingsSvc, providerHolder,
paths: { profileDir: paths.profileDir }
});
// registerSettingsApi 는 backup / exportSvc / importSvc / syncSvc / telemetry 가
// 생성된 뒤에 호출 (Task 10) — 아래 BackupService/ExportService/... 초기화 직후로 이동.
const hotkeys = new HotkeyService();
const reg = hotkeys.register({
@@ -107,7 +185,9 @@ app.whenReady().then(async () => {
await worker.loadFromDb();
const gc = new MediaGc(db, store);
void gc.run().then((r) => logger.info('media.gc', { ...r } as Record<string, unknown>));
void gc.run()
.then((r) => logger.info('media.gc', { ...r } as Record<string, unknown>))
.catch((e) => logger.warn('media.gc.failed', { reason: String(e) }));
const exportSvc = new ExportService(repo, store);
const importSvc = new ImportService(repo, store);
@@ -118,8 +198,21 @@ app.whenReady().then(async () => {
.then((r) => logger.info('backup.daily', { ...r } as Record<string, unknown>))
.catch((e) => logger.warn('backup.daily.failed', { reason: String(e) }));
// v0.2.7 Task 10 — 설정 페이지 IPC (autostart + backup/export/import/sync/telemetry).
// backup / exportSvc / importSvc / syncSvc / telemetry 가 모두 준비된 뒤 등록.
registerSettingsApi({
backup, exportSvc, importSvc, syncSvc, telemetry, getInboxWindow
});
let backupOnQuitDone = false;
let trayInterval: NodeJS.Timeout | null = null;
app.on('before-quit', (e) => {
// 모든 cleanup 한 곳에 통합 — sync (idempotent) → async backup chain.
health.stop();
if (trayInterval !== null) {
clearInterval(trayInterval);
trayInterval = null;
}
if (backupOnQuitDone) return;
e.preventDefault();
backup.runDaily()
@@ -143,158 +236,34 @@ app.whenReady().then(async () => {
});
});
createTray(
() => createInboxWindow(),
() => showQuickCapture(),
async () => {
try {
const r = await backup.runDaily();
new Notification({
title: 'Inkling',
body: r.snapshotted
? `백업 완료 — ${r.removed?.length ?? 0}개 정리`
: `오늘 백업이 이미 있습니다`,
silent: true
}).show();
} catch (e) {
logger.warn('backup.manual.failed', { reason: String(e) });
new Notification({
title: 'Inkling',
body: '백업을 만들지 못했습니다.',
silent: true
}).show();
}
},
async () => {
const win = getInboxWindow();
const dialogOpts: Electron.OpenDialogOptions = {
title: '내보낼 폴더 선택',
message: '선택한 폴더에 노트를 마크다운으로 내보냅니다. 이미지가 함께 포함됩니다. raw_text 가 평문으로 보관되니 비공개 위치를 권장합니다.',
buttonLabel: '여기에 내보내기',
properties: ['openDirectory', 'createDirectory']
};
const result = win
? await dialog.showOpenDialog(win, dialogOpts)
: await dialog.showOpenDialog(dialogOpts);
if (result.canceled || result.filePaths.length === 0) return;
try {
const r = await exportSvc.export(result.filePaths[0]!, { includeMedia: true });
logger.info('export.done', {
outDir: r.outDir,
noteCount: r.noteCount,
mediaCount: r.mediaCount,
bytes: r.bytes
});
new Notification({
title: 'Inkling',
body: `내보내기 완료 — 노트 ${r.noteCount}개, 이미지 ${r.mediaCount}`,
silent: true
}).show();
} catch (e) {
logger.warn('export.failed', { reason: String(e) });
new Notification({
title: 'Inkling',
body: '내보내기를 완료하지 못했습니다.',
silent: true
}).show();
}
},
async () => {
const win = getInboxWindow();
const dirOpts: Electron.OpenDialogOptions = {
title: '복원할 백업 폴더 선택',
message: 'F5 export 형식의 폴더를 선택하세요. notes/ 하위의 마크다운 파일이 적재됩니다.',
buttonLabel: '여기서 복원',
properties: ['openDirectory']
};
const dirResult = win
? await dialog.showOpenDialog(win, dirOpts)
: await dialog.showOpenDialog(dirOpts);
if (dirResult.canceled || dirResult.filePaths.length === 0) return;
const sourceDir = dirResult.filePaths[0]!;
let plan;
try {
plan = await importSvc.preview(sourceDir);
} catch (e) {
logger.warn('import.preview.failed', { reason: String(e) });
new Notification({
title: 'Inkling',
body: '백업 폴더를 읽지 못했습니다.',
silent: true
}).show();
return;
}
const detail = `${plan.total}개 노트\n · 신규 ${plan.newCount}\n · 동일 (스킵) ${plan.unchangedCount}\n · 충돌→새 id (${plan.forkedCount}개, raw_text 보존)\n\n이미지 ${plan.mediaCount}개 복사 예정.`;
const confirmOpts: Electron.MessageBoxOptions = {
type: 'question',
buttons: ['복원', '취소'],
defaultId: 0,
cancelId: 1,
title: 'Inkling 복원',
message: '복원 미리보기',
detail
};
const confirm = win
? await dialog.showMessageBox(win, confirmOpts)
: await dialog.showMessageBox(confirmOpts);
if (confirm.response !== 0) return;
try {
const r = await importSvc.run(sourceDir);
logger.info('import.done', {
total: r.total,
new: r.newCount,
unchanged: r.unchangedCount,
forked: r.forkedCount,
media: r.mediaCount
});
new Notification({
title: 'Inkling',
body: `복원 완료 — 신규 ${r.newCount}개, 스킵 ${r.unchangedCount}개, 충돌 ${r.forkedCount}`,
silent: true
}).show();
} catch (e) {
logger.warn('import.run.failed', { reason: String(e) });
new Notification({
title: 'Inkling',
body: '복원을 완료하지 못했습니다.',
silent: true
}).show();
}
},
async () => {
// runSync — 트레이 "지금 동기화"
try {
const r = await syncSvc.sync();
if (!r.ok) {
logger.warn('sync.failed', { reason: r.reason });
const body = r.reason === 'not_configured'
? `${syncSvc.getSyncDir()} 에서 git init + remote 설정이 필요합니다.`
: '동기화를 완료하지 못했습니다.';
new Notification({ title: 'Inkling', body, silent: true }).show();
return;
}
if (r.changed) {
logger.info('sync.done', { sha: r.sha, pushed: r.pushed });
new Notification({ title: 'Inkling', body: '동기화 완료', silent: true }).show();
} else {
new Notification({ title: 'Inkling', body: '변경 사항 없음', silent: true }).show();
}
} catch (e) {
logger.warn('sync.exception', { reason: String(e) });
new Notification({ title: 'Inkling', body: '동기화를 완료하지 못했습니다.', silent: true }).show();
}
}
);
// v0.2.7 Phase 3 (Task 16) — TrayCallbacks 슬림: 10 → 3.
// 백업/내보내기/복원/동기화/사용 로그/Ollama 재확인/AI 재처리/Ollama 설정/정보 →
// 모두 설정 페이지로 이전 (registerSettingsApi 의 IPC 핸들러가 본문 보유).
createTray({
showInbox: () => createInboxWindow(),
showCapture: () => showQuickCapture(),
showSettings: () => navigateInbox('settings')
});
// F4-C 환경 앵커 — tray tooltip + 메뉴 첫 항목을 오늘 KST 캡처 수로 갱신.
// 초기 1회 + 60s interval. AiWorker.onUpdate 도 별도 갱신 트리거.
refreshTray(repo.countToday());
const trayInterval = setInterval(() => {
refreshTray(repo.countToday());
// cleanup 은 위 통합 before-quit 핸들러에서 처리.
// v0.2.7 Phase 3 — failedCount 메뉴 항목 제거됨 → todayCount 만 갱신.
refreshTray({ todayCount: repo.countToday() });
trayInterval = setInterval(() => {
refreshTray({ todayCount: repo.countToday() });
}, 60_000);
app.on('before-quit', () => { clearInterval(trayInterval); });
// F14 (v0.2.7) — macOS dock 클릭 시 hidden inbox 창 show/focus.
// 기존: BrowserWindow.getAllWindows().length === 0 만 검사 → quickCapture 등이
// 떠 있으면 inbox 창이 hidden 인 채로 남았음.
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createInboxWindow();
const win = getInboxWindow();
if (win && !win.isDestroyed()) {
if (!win.isVisible()) win.show();
win.focus();
} else {
createInboxWindow();
}
});
});

View File

@@ -1,12 +1,17 @@
import electron from 'electron';
import type { BrowserWindow } from 'electron';
const { ipcMain } = electron;
const { ipcMain, dialog, shell } = electron;
import { join, normalize, sep } from 'node:path';
import type { NoteRepository } from '../repository/NoteRepository.js';
import type { ContinuityService } from '../services/ContinuityService.js';
import type { CaptureService } from '../services/CaptureService.js';
import type { HealthChecker } from '../services/HealthChecker.js';
import type { IntentService } from '../services/IntentService.js';
import type { Note } from '@shared/types';
import type { HealthResult } from '../ai/InferenceProvider.js';
import { LocalOllamaProvider } from '../ai/LocalOllamaProvider.js';
import type { SettingsService } from '../services/SettingsService.js';
import type { ProviderHolder } from '../ai/ProviderHolder.js';
export interface InboxIpcDeps {
repo: NoteRepository;
@@ -15,6 +20,10 @@ export interface InboxIpcDeps {
health: HealthChecker;
intent: IntentService;
getInboxWindow: () => BrowserWindow | null;
settings: SettingsService;
providerHolder: ProviderHolder;
// v0.2.8 Cut A — `inbox:open-media` 의 path traversal 검사 baseline.
paths: { profileDir: string };
}
export function registerInboxApi(deps: InboxIpcDeps): void {
@@ -33,7 +42,7 @@ export function registerInboxApi(deps: InboxIpcDeps): void {
deps.repo.setDueDate(arg.noteId, arg.date);
});
ipcMain.handle('inbox:delete', async (_e, noteId: string) => {
ipcMain.handle('inbox:trash', async (_e, noteId: string) => {
await deps.capture.deleteNote(noteId);
});
@@ -52,6 +61,133 @@ export function registerInboxApi(deps: InboxIpcDeps): void {
ipcMain.handle('inbox:pendingCount', () => deps.repo.getPendingCount());
ipcMain.handle('inbox:ollamaStatus', () => deps.health.lastStatus());
ipcMain.handle('inbox:todayCount', () => deps.repo.countToday());
ipcMain.handle('inbox:restore', async (_e, noteId: string) => {
await deps.capture.restoreNote(noteId);
});
ipcMain.handle('inbox:permanentDelete', async (_e, noteId: string) => {
const win = deps.getInboxWindow();
const opts: Electron.MessageBoxOptions = {
type: 'question',
buttons: ['영구 삭제', '취소'],
defaultId: 1,
cancelId: 1,
title: 'Inkling',
message: '이 노트를 영구 삭제합니다',
detail: '이 작업은 되돌릴 수 없습니다. 첨부된 이미지도 함께 삭제됩니다.'
};
const r = win
? await dialog.showMessageBox(win, opts)
: await dialog.showMessageBox(opts);
if (r.response !== 0) return { confirmed: false };
await deps.capture.permanentDeleteNote(noteId);
return { confirmed: true };
});
ipcMain.handle('inbox:emptyTrash', async () => {
const fullCount = deps.repo.countTrashed();
if (fullCount === 0) return { confirmed: true, count: 0 };
const win = deps.getInboxWindow();
const opts: Electron.MessageBoxOptions = {
type: 'question',
buttons: ['휴지통 비우기', '취소'],
defaultId: 1,
cancelId: 1,
title: 'Inkling',
message: `휴지통의 노트 ${fullCount}개를 영구 삭제합니다`,
detail: '이 작업은 되돌릴 수 없습니다. 첨부된 이미지도 함께 삭제됩니다.'
};
const r = win
? await dialog.showMessageBox(win, opts)
: await dialog.showMessageBox(opts);
if (r.response !== 0) return { confirmed: false, count: 0 };
const result = await deps.capture.emptyTrash();
return { confirmed: true, count: result.count };
});
ipcMain.handle('inbox:listTrash', (_e, opts: { limit: number }) =>
deps.repo.listTrashed(opts)
);
ipcMain.handle('inbox:trashCount', () => deps.repo.countTrashed());
ipcMain.handle('inbox:listExpired', async () => deps.capture.listExpired());
ipcMain.handle(
'inbox:trashExpiredBatch',
async (_e, payload: { ids: string[] }) => {
if (payload.ids.length === 0) return { trashedCount: 0, confirmed: false };
const win = deps.getInboxWindow();
const opts: Electron.MessageBoxOptions = {
type: 'question',
buttons: ['옮기기', '취소'],
defaultId: 1,
cancelId: 1,
title: 'Inkling',
message: `선택한 노트 ${payload.ids.length}개를 휴지통으로 옮깁니다`,
detail: '복구는 휴지통 탭에서 가능합니다.'
};
const r = win
? await dialog.showMessageBox(win, opts)
: await dialog.showMessageBox(opts);
if (r.response !== 0) return { trashedCount: 0, confirmed: false };
const result = await deps.capture.trashExpiredBatch(payload.ids);
return { trashedCount: result.trashedCount, confirmed: true };
}
);
ipcMain.handle('inbox:ollamaRecheck', async () => {
await deps.health.runOnce({ manual: true });
return deps.health.lastStatus();
});
ipcMain.handle('inbox:retryAllFailed', async () => deps.capture.retryAllFailed());
ipcMain.handle('inbox:failedCount', () => deps.repo.countFailed());
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.handle('inbox:loadOllamaSettings', async () => {
const s = await deps.settings.load();
return s.ollama ?? null;
});
// v0.2.8 Cut A — 첨부 이미지 클릭 시 OS 기본 뷰어로 열기 (Task 3).
// path traversal 검사는 inkling-media:// protocol handler 와 동일한 패턴 (Task 1).
ipcMain.handle('inbox:open-media', async (_e, relPath: string) => {
if (typeof relPath !== 'string' || relPath.length === 0) {
return { ok: false as const, reason: 'invalid path' as const };
}
const profileDir = deps.paths.profileDir;
const mediaRoot = join(profileDir, 'media');
const target = normalize(join(profileDir, relPath));
if (!target.startsWith(mediaRoot + sep) && target !== mediaRoot) {
return { ok: false as const, reason: 'invalid path' as const };
}
await shell.openPath(target);
return { ok: true as const };
});
ipcMain.handle('inbox:saveOllamaSettings', async (_e, value: { endpoint: string; model: string }) => {
// 검증: 새 인스턴스로 healthCheck
const trial = new LocalOllamaProvider({ endpoint: value.endpoint, model: value.model });
const r = await trial.healthCheck();
if (!r.ok) return { ok: false, reason: r.reason ?? 'unknown' };
try {
await deps.settings.setOllama(value);
} catch (e) {
return { ok: false, reason: `persist failed: ${(e as Error).message}` };
}
deps.providerHolder.get().abort?.();
deps.providerHolder.replace(trial);
// 즉시 health 재확인 → onUpdate callback 통해 OllamaBanner 자동 갱신
await deps.health.runOnce();
return { ok: true };
});
}
export function pushNoteUpdated(getWin: () => BrowserWindow | null, note: Note): void {
@@ -59,3 +195,9 @@ export function pushNoteUpdated(getWin: () => BrowserWindow | null, note: Note):
if (!w || w.isDestroyed()) return;
w.webContents.send('note:updated', note);
}
export function pushOllamaStatus(getWin: () => BrowserWindow | null, status: HealthResult): void {
const w = getWin();
if (!w || w.isDestroyed()) return;
w.webContents.send('ollama:status', status);
}

268
src/main/ipc/settingsApi.ts Normal file
View File

@@ -0,0 +1,268 @@
import electron from 'electron';
import type { BrowserWindow } from 'electron';
import { platform, release, EOL } from 'node:os';
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 type { TelemetryService } from '../services/TelemetryService.js';
import { collectAutostartState } from '../services/AutostartDiagnostic.js';
import { getInboxWindow as getInboxWindowSingleton } from '../windows/inboxWindow.js';
/**
* 외부 (트레이 / second-instance / 기타 main 프로세스 호출자) 에서 inbox 창에 view 전환을
* 요청하는 진입점. 창이 숨겨져 있으면 show + focus 후 'inbox:navigate' IPC 이벤트를
* renderer 로 전달.
*
* Task 13 (v0.2.7) — 트레이 "설정..." 메뉴 wiring 은 Task 16 에서 본 함수 호출.
*/
export function navigateInbox(view: 'inbox' | 'trash' | 'settings'): void {
const win = getInboxWindowSingleton();
if (win && !win.isDestroyed()) {
if (!win.isVisible()) win.show();
win.focus();
win.webContents.send('inbox:navigate', view);
}
}
export interface SettingsIpcDeps {
backup: BackupService;
exportSvc: ExportService;
importSvc: ImportService;
syncSvc: SyncService;
telemetry: TelemetryService;
getInboxWindow: () => BrowserWindow | null;
}
/**
* v0.2.7 설정 페이지 IPC 핸들러.
*
* - 자동 실행 (Task 22 통일): `settings:autostart-state` (조회) / `settings:autostart-set` (변경).
* 둘 다 `{ openAtLogin, diagnostic }` 반환 — diagnostic 은 withArgs/noArgs/execPath/registry 진단.
* args=['--hidden'] 명시 — 자동 실행 시 백그라운드 모드로 시작 (Quick Capture only).
*
* - 백업/내보내기/복원/동기화/사용 로그 (Task 10): 기존 `src/main/index.ts` 트레이 callback
* (runBackup, runExport, runImport, runSync, runExportTelemetry) 본문을 그대로 IPC 핸들러로
* 복사. 트레이 callback 자체 제거는 Task 16 (Phase 3) — 본 task 에선 잔류 (의도적 중복).
*/
export function registerSettingsApi(deps?: SettingsIpcDeps): void {
// v0.2.7 F12 deeper fix (Task 21~22) — 진단 정보 포함된 autostart 상태 조회/변경.
// 옛 'settings:get-autostart' / 'settings:set-autostart' 채널은 본 통일에서 제거됨.
ipcMain.handle('settings:autostart-state', async () => {
const diag = await collectAutostartState();
return { openAtLogin: diag.withArgs.openAtLogin, diagnostic: diag };
});
ipcMain.handle('settings:autostart-set', async (_e, open: boolean) => {
app.setLoginItemSettings({ openAtLogin: open, args: ['--hidden'] });
const diag = await collectAutostartState();
return { openAtLogin: diag.withArgs.openAtLogin, diagnostic: diag };
});
// v0.2.7 정보 섹션 (Task 11) — 트레이 showAboutDialog 의 detail 형식 그대로 (clipboard 일관성).
// 트레이 showAboutDialog 자체 제거는 Task 25 (Phase 6 cleanup) — 본 task 는 추가만.
ipcMain.handle('settings:get-app-info', () => ({
version: app.getVersion(),
electron: process.versions.electron ?? '?',
node: process.versions.node ?? '?',
os: `${platform()} ${release()}`,
profileDir: app.getPath('userData')
}));
ipcMain.handle('settings:open-profile-dir', async () => {
await shell.openPath(app.getPath('userData'));
});
ipcMain.handle('settings:copy-app-info', () => {
const v = app.getVersion();
const detail = [
`버전: ${v}`,
`Electron: ${process.versions.electron ?? '?'}`,
`Node: ${process.versions.node ?? '?'}`,
`OS: ${platform()} ${release()}`,
`데이터 위치: ${app.getPath('userData')}`
].join(EOL);
clipboard.writeText(`Inkling ${v}${EOL}${detail}`);
});
if (!deps) return;
const { backup, exportSvc, importSvc, syncSvc, telemetry, getInboxWindow } = deps;
ipcMain.handle('settings:run-backup', async () => {
try {
const r = await backup.runDaily();
new Notification({
title: 'Inkling',
body: r.snapshotted
? `백업 완료 — ${r.removed?.length ?? 0}개 정리`
: `오늘 백업이 이미 있습니다`,
silent: true
}).show();
} catch (e) {
logger.warn('backup.manual.failed', { reason: String(e) });
new Notification({
title: 'Inkling',
body: '백업을 만들지 못했습니다.',
silent: true
}).show();
}
return { ok: true } as const;
});
ipcMain.handle('settings:run-export', async () => {
const win = getInboxWindow();
const dialogOpts: Electron.OpenDialogOptions = {
title: '내보낼 폴더 선택',
message: '선택한 폴더에 노트를 마크다운으로 내보냅니다. 이미지가 함께 포함됩니다. raw_text 가 평문으로 보관되니 비공개 위치를 권장합니다.',
buttonLabel: '여기에 내보내기',
properties: ['openDirectory', 'createDirectory']
};
const result = win
? await dialog.showOpenDialog(win, dialogOpts)
: await dialog.showOpenDialog(dialogOpts);
if (result.canceled || result.filePaths.length === 0) return { ok: true } as const;
try {
const r = await exportSvc.export(result.filePaths[0]!, { includeMedia: true });
logger.info('export.done', {
outDir: r.outDir,
noteCount: r.noteCount,
mediaCount: r.mediaCount,
bytes: r.bytes
});
new Notification({
title: 'Inkling',
body: `내보내기 완료 — 노트 ${r.noteCount}개, 이미지 ${r.mediaCount}`,
silent: true
}).show();
} catch (e) {
logger.warn('export.failed', { reason: String(e) });
new Notification({
title: 'Inkling',
body: '내보내기를 완료하지 못했습니다.',
silent: true
}).show();
}
return { ok: true } as const;
});
ipcMain.handle('settings:run-import', async () => {
const win = getInboxWindow();
const dirOpts: Electron.OpenDialogOptions = {
title: '복원할 백업 폴더 선택',
message: 'F5 export 형식의 폴더를 선택하세요. notes/ 하위의 마크다운 파일이 적재됩니다.',
buttonLabel: '여기서 복원',
properties: ['openDirectory']
};
const dirResult = win
? await dialog.showOpenDialog(win, dirOpts)
: await dialog.showOpenDialog(dirOpts);
if (dirResult.canceled || dirResult.filePaths.length === 0) return { ok: true } as const;
const sourceDir = dirResult.filePaths[0]!;
let plan;
try {
plan = await importSvc.preview(sourceDir);
} catch (e) {
logger.warn('import.preview.failed', { reason: String(e) });
new Notification({
title: 'Inkling',
body: '백업 폴더를 읽지 못했습니다.',
silent: true
}).show();
return { ok: true } as const;
}
const detail = `${plan.total}개 노트\n · 신규 ${plan.newCount}\n · 동일 (스킵) ${plan.unchangedCount}\n · 충돌→새 id (${plan.forkedCount}개, raw_text 보존)\n\n이미지 ${plan.mediaCount}개 복사 예정.`;
const confirmOpts: Electron.MessageBoxOptions = {
type: 'question',
buttons: ['복원', '취소'],
defaultId: 0,
cancelId: 1,
title: 'Inkling 복원',
message: '복원 미리보기',
detail
};
const confirm = win
? await dialog.showMessageBox(win, confirmOpts)
: await dialog.showMessageBox(confirmOpts);
if (confirm.response !== 0) return { ok: true } as const;
try {
const r = await importSvc.run(sourceDir);
logger.info('import.done', {
total: r.total,
new: r.newCount,
unchanged: r.unchangedCount,
forked: r.forkedCount,
media: r.mediaCount
});
new Notification({
title: 'Inkling',
body: `복원 완료 — 신규 ${r.newCount}개, 스킵 ${r.unchangedCount}개, 충돌 ${r.forkedCount}`,
silent: true
}).show();
} catch (e) {
logger.warn('import.run.failed', { reason: String(e) });
new Notification({
title: 'Inkling',
body: '복원을 완료하지 못했습니다.',
silent: true
}).show();
}
return { ok: true } as const;
});
ipcMain.handle('settings:run-sync', async () => {
try {
const r = await syncSvc.sync();
if (!r.ok) {
logger.warn('sync.failed', { reason: r.reason });
const body = r.reason === 'not_configured'
? `${syncSvc.getSyncDir()} 에서 git init + remote 설정이 필요합니다.`
: '동기화를 완료하지 못했습니다.';
new Notification({ title: 'Inkling', body, silent: true }).show();
return { ok: true } as const;
}
if (r.changed) {
logger.info('sync.done', { sha: r.sha, pushed: r.pushed });
new Notification({ title: 'Inkling', body: '동기화 완료', silent: true }).show();
} else {
new Notification({ title: 'Inkling', body: '변경 사항 없음', silent: true }).show();
}
} catch (e) {
logger.warn('sync.exception', { reason: String(e) });
new Notification({ title: 'Inkling', body: '동기화를 완료하지 못했습니다.', silent: true }).show();
}
return { ok: true } as const;
});
ipcMain.handle('settings:run-export-telemetry', async () => {
const win = getInboxWindow();
const dialogOpts: Electron.OpenDialogOptions = {
title: '사용 로그를 내보낼 폴더 선택',
message: '선택한 폴더에 events.jsonl + stats.md 가 생성됩니다. raw_text/요약/제목/태그 이름은 미포함입니다.',
buttonLabel: '여기로 내보내기',
properties: ['openDirectory', 'createDirectory']
};
const result = win
? await dialog.showOpenDialog(win, dialogOpts)
: await dialog.showOpenDialog(dialogOpts);
if (result.canceled || result.filePaths.length === 0) return { ok: true } as const;
try {
const r = await telemetry.exportTo(result.filePaths[0]!);
logger.info('telemetry.export', { eventCount: r.eventCount, outDir: result.filePaths[0] });
new Notification({
title: 'Inkling',
body: `사용 로그 내보내기 완료 — ${r.eventCount}개 이벤트`,
silent: true
}).show();
} catch (e) {
logger.warn('telemetry.export.failed', { reason: String(e) });
new Notification({
title: 'Inkling',
body: '사용 로그 내보내기를 완료하지 못했습니다.',
silent: true
}).show();
}
return { ok: true } as const;
});
}

View File

@@ -0,0 +1,54 @@
import electron from 'electron';
import { readFile } from 'node:fs/promises';
import { join, normalize, sep, extname } from 'node:path';
const { protocol } = electron;
export function registerSchemesAsPrivileged(): void {
protocol.registerSchemesAsPrivileged([
{ scheme: 'inkling-media', privileges: { secure: true, supportFetchAPI: true, stream: true } }
]);
}
export function inferMime(filename: string): string {
const ext = extname(filename).toLowerCase();
switch (ext) {
case '.png': return 'image/png';
case '.jpg':
case '.jpeg': return 'image/jpeg';
case '.gif': return 'image/gif';
case '.webp': return 'image/webp';
default: return 'application/octet-stream';
}
}
export function registerInklingMediaProtocol(profileDir: string): void {
const mediaRoot = join(profileDir, 'media');
protocol.handle('inkling-media', async (req) => {
// Raw URL 에서 `..` 세그먼트 (URL-encoded 포함) 검출 — `new URL()` 이 normalize 하기 전에 차단.
const rawLower = req.url.toLowerCase();
if (
rawLower.includes('/../') ||
rawLower.endsWith('/..') ||
rawLower.includes('/%2e%2e/') ||
rawLower.endsWith('/%2e%2e')
) {
return new Response(null, { status: 403 });
}
const url = new URL(req.url);
// inkling-media://media/<noteId>/<file> → host='media', pathname='/<noteId>/<file>'
const relPath = decodeURIComponent(`${url.host}${url.pathname}`);
const target = normalize(join(profileDir, relPath));
if (!target.startsWith(mediaRoot + sep) && target !== mediaRoot) {
return new Response(null, { status: 403 });
}
try {
const data = await readFile(target);
return new Response(new Uint8Array(data), {
headers: { 'content-type': inferMime(target) }
});
} catch {
return new Response(null, { status: 404 });
}
});
}

View File

@@ -1,6 +1,7 @@
import type Database from 'better-sqlite3';
import { v7 as uuidv7, v4 as uuidv4 } from 'uuid';
import type { Note, NoteMedia, NoteTag } from '@shared/types';
import { kstTodayIso } from '../../shared/util/kstDate.js';
export interface CreateNoteInput { rawText: string; }
@@ -28,6 +29,7 @@ export interface ImportNoteInput {
userIntent: string | null;
intentPromptedAt: string | null;
tags: { name: string; source: 'ai' | 'user' }[];
deletedAt?: string | null;
}
export type ImportNoteStatus = 'inserted' | 'skipped' | 'forked';
@@ -38,6 +40,8 @@ export interface ImportNoteResult {
status: ImportNoteStatus;
}
const KEBAB_CASE_RE = /^[a-z0-9-]+$/;
export class NoteRepository {
constructor(private db: Database.Database) {}
@@ -74,7 +78,7 @@ export class NoteRepository {
}
findById(id: string): Note | null {
const row = this.db.prepare('SELECT * FROM notes WHERE id=?').get(id) as any;
const row = this.db.prepare('SELECT * FROM notes WHERE id=?').get(id) as Record<string, unknown>;
if (!row) return null;
return this.hydrate(row);
}
@@ -83,18 +87,26 @@ export class NoteRepository {
const limit = Math.max(1, Math.min(200, opts.limit));
const rows = opts.cursor
? (this.db
.prepare(`SELECT * FROM notes WHERE created_at < ? ORDER BY created_at DESC, id DESC LIMIT ?`)
.all(opts.cursor, limit) as any[])
.prepare(
`SELECT * FROM notes
WHERE deleted_at IS NULL AND created_at < ?
ORDER BY created_at DESC, id DESC LIMIT ?`
)
.all(opts.cursor, limit) as Record<string, unknown>[])
: (this.db
.prepare(`SELECT * FROM notes ORDER BY created_at DESC, id DESC LIMIT ?`)
.all(limit) as any[]);
.prepare(
`SELECT * FROM notes
WHERE deleted_at IS NULL
ORDER BY created_at DESC, id DESC LIMIT ?`
)
.all(limit) as Record<string, unknown>[]);
return rows.map((r) => this.hydrate(r));
}
listAll(): Note[] {
const rows = this.db
.prepare(`SELECT * FROM notes ORDER BY created_at ASC, id ASC`)
.all() as any[];
.prepare(`SELECT * FROM notes WHERE deleted_at IS NULL ORDER BY created_at ASC, id ASC`)
.all() as Record<string, unknown>[];
return rows.map((r) => this.hydrate(r));
}
@@ -146,6 +158,140 @@ export class NoteRepository {
tx();
}
findFailedIds(): string[] {
const rows = this.db
.prepare(
`SELECT id FROM notes WHERE ai_status='failed' AND deleted_at IS NULL ORDER BY updated_at DESC, id DESC`
)
.all() as Array<{ id: string }>;
return rows.map((r) => r.id);
}
countFailed(): number {
const row = this.db
.prepare(
`SELECT COUNT(*) AS c FROM notes WHERE ai_status='failed' AND deleted_at IS NULL`
)
.get() as { c: number };
return row.c;
}
/**
* 모든 ai_status='failed' (active) 노트를 'pending' 으로 reset 하고 pending_jobs 재투입.
* 단일 transaction. v0.2.3 #2 retryAllFailed.
*
* INSERT OR IGNORE 로 race 안전 (이미 pending_jobs row 존재 시 skip).
*/
retryAllFailed(now: string): { ids: string[] } {
const ids: string[] = [];
const tx = this.db.transaction(() => {
const rows = this.db
.prepare(`SELECT id FROM notes WHERE ai_status='failed' AND deleted_at IS NULL`)
.all() as Array<{ id: string }>;
if (rows.length === 0) return;
const reset = this.db.prepare(
`UPDATE notes SET ai_status='pending', ai_error=NULL, updated_at=? WHERE id=?`
);
const insert = this.db.prepare(
`INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)`
);
for (const r of rows) {
reset.run(now, r.id);
insert.run(r.id, now);
ids.push(r.id);
}
});
tx();
return { ids };
}
/**
* pending_jobs 의 next_run_at + last_error 만 갱신, attempts 변경 없음.
* v0.2.3 #2 — unreachable/timeout 무한 retry 시 사용 (incrementJobAttempt 와 별도 경로).
*/
setNextRunAt(noteId: string, nextRunAt: string, lastError: string): void {
this.db
.prepare(
`UPDATE pending_jobs SET next_run_at=?, last_error=? WHERE note_id=?`
)
.run(nextRunAt, lastError.slice(0, 500), noteId);
}
/**
* v0.2.3 #6 — 회상 후보 1건. 가장 오래된 후보 (created_at ASC) 우선.
* - 7일 이상 안 본 노트 (last_recalled_at NULL 또는 7일 전 이전)
* - 30일 이상 dismiss 만료 또는 dismiss 안 된 노트
* - ai_status='done' + deleted_at IS NULL + due_date 임박 X (≥ today)
* KST 보정: SQLite date('now') 는 UTC 라 +9 hours 항상 추가.
*/
findRecallCandidate(): Note | null {
const row = this.db
.prepare(
`SELECT * FROM notes
WHERE (last_recalled_at IS NULL OR last_recalled_at < date('now','+9 hours','-7 day'))
AND (recall_dismissed_at IS NULL OR recall_dismissed_at < date('now','+9 hours','-30 day'))
AND ai_status = 'done'
AND deleted_at IS NULL
AND (due_date IS NULL OR due_date >= date('now','+9 hours'))
ORDER BY created_at ASC
LIMIT 1`
)
.get() as Record<string, unknown> | undefined;
return row ? this.hydrate(row) : null;
}
/** v0.2.3 #6 — 회상 "열어보기" 시 last_recalled_at = now. */
markRecallOpened(id: string, now: string): void {
this.db
.prepare(`UPDATE notes SET last_recalled_at = ?, updated_at = ? WHERE id = ?`)
.run(now, now, id);
}
/** v0.2.3 #6 — 회상 "더 이상" 시 recall_dismissed_at = now. 30일 후 재추천. */
dismissRecall(id: string, now: string): void {
this.db
.prepare(`UPDATE notes SET recall_dismissed_at = ?, updated_at = ? WHERE id = ?`)
.run(now, now, id);
}
/**
* v0.2.3 #3 — AI prompt 의 vocabulary 후보. 사용 빈도 높은 태그 top-N.
* source 무시 (AI+user 통합), kebab-case 통과한 것만 (한글/공백/대문자 제외).
* deleted_at IS NULL 만 (휴지통 노트 태그 제외).
*
* Note: LIMIT 가 SQL 단계에서 먼저 적용된 후 regex 필터링이 후처리 됨.
* 따라서 반환 배열 length 가 limit 보다 작을 수 있음 (top-N 안에 비-kebab-case
* 태그가 섞여 있을 때). v0.2.3 dogfood 규모에서는 실용적 영향 없음.
*/
getTopUsedTags(limit = 20): string[] {
const rows = this.db
.prepare(
`SELECT t.name, COUNT(*) AS c
FROM tags t
JOIN note_tags nt ON nt.tag_id = t.id
JOIN notes n ON n.id = nt.note_id
WHERE n.deleted_at IS NULL
GROUP BY t.id
ORDER BY c DESC, t.id ASC
LIMIT ?`
)
.all(limit) as Array<{ name: string; c: number }>;
return rows
.map((r) => r.name)
.filter((n) => KEBAB_CASE_RE.test(n));
}
/**
* v0.2.3 #3 — vocab hit telemetry 의 tagId 확보용. updateAiResult 후 호출 보장.
* tags.name COLLATE NOCASE 라 case-insensitive lookup.
*/
getTagIdByName(name: string): number | null {
const row = this.db
.prepare(`SELECT id FROM tags WHERE name = ? COLLATE NOCASE LIMIT 1`)
.get(name) as { id: number } | undefined;
return row ? row.id : null;
}
updateUserAiFields(
id: string,
fields: { title?: string; summary?: string; tags?: string[] }
@@ -221,6 +367,109 @@ export class NoteRepository {
.run(date, now, id);
}
trash(id: string, deletedAt: string): void {
const tx = this.db.transaction(() => {
this.db
.prepare(`UPDATE notes SET deleted_at = ?, updated_at = ? WHERE id = ?`)
.run(deletedAt, deletedAt, id);
this.db.prepare(`DELETE FROM pending_jobs WHERE note_id = ?`).run(id);
});
tx();
}
/**
* Atomically transition a batch of notes from active → trash.
* Returns the number of notes that actually transitioned (i.e. were active
* before the call). Already-trashed and unknown ids are silent skips —
* counting them would inflate `expired_batch_trash` telemetry.
*
* Reuses `trash(id, deletedAt)` per row to inherit pending_jobs cleanup
* invariant (§9.2 of #4 spec).
*/
trashBatch(ids: string[], deletedAt: string): { trashedCount: number } {
if (ids.length === 0) return { trashedCount: 0 };
let trashedCount = 0;
const tx = this.db.transaction((batch: string[]) => {
for (const id of batch) {
const row = this.db
.prepare(`SELECT deleted_at FROM notes WHERE id = ?`)
.get(id) as { deleted_at: string | null } | undefined;
if (!row || row.deleted_at !== null) continue;
this.trash(id, deletedAt);
trashedCount += 1;
}
});
tx(ids);
return { trashedCount };
}
restore(id: string): void {
const now = new Date().toISOString();
this.db
.prepare(`UPDATE notes SET deleted_at = NULL, updated_at = ? WHERE id = ?`)
.run(now, id);
}
restoreNote(id: string): void {
const tx = this.db.transaction(() => {
const before = this.db.prepare(`SELECT ai_status FROM notes WHERE id = ?`).get(id) as { ai_status: string } | undefined;
const now = new Date().toISOString();
this.db.prepare(`UPDATE notes SET deleted_at = NULL, updated_at = ? WHERE id = ?`).run(now, id);
// v0.2.6 #10 — failed 노트 restore 시 pending 으로 reset + pending_jobs 재생성
if (before?.ai_status === 'failed') {
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);
} else if (before?.ai_status === 'pending') {
// pending 인 채로 trash 됐다면 pending_jobs 도 미정상 상태일 수 있음 — 재생성 (idempotent)
this.db.prepare(
`INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)`
).run(id, now);
}
// done 노트는 재처리 안 함 (이미 결과 있음)
});
tx();
}
permanentDelete(id: string): void {
this.db.prepare('DELETE FROM notes WHERE id=?').run(id);
}
emptyTrash(): { noteIds: string[] } {
// Single DELETE ... RETURNING is atomic by itself (no explicit transaction needed)
// and avoids per-row prepare overhead. RETURNING is house-style elsewhere
// (updateAiResult/updateUserAiFields/getAllPendingJobs).
const rows = this.db
.prepare('DELETE FROM notes WHERE deleted_at IS NOT NULL RETURNING id')
.all() as Array<{ id: string }>;
return { noteIds: rows.map((r) => r.id) };
}
listTrashed(opts: { limit: number }): Note[] {
const limit = Math.max(1, Math.min(200, opts.limit));
const rows = this.db
.prepare(`SELECT * FROM notes WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC, id DESC LIMIT ?`)
.all(limit) as Record<string, unknown>[];
return rows.map((r) => this.hydrate(r));
}
/**
* Cheap COUNT for trash UI badge / bulk-empty dialog. Does not hydrate
* tags/media — used in hot paths (loadInitial / refreshMeta / upsertNote
* follow-ups) where listTrashed() is wasteful.
*/
countTrashed(): number {
const row = this.db
.prepare(`SELECT COUNT(*) AS c FROM notes WHERE deleted_at IS NOT NULL`)
.get() as { c: number };
return row.c;
}
/** @deprecated v0.2.3 #4 부터 hard delete 는 permanentDelete() 사용. soft delete 는 trash(). 본 메서드는 v0.2.4 에서 제거 예정. */
delete(id: string): void {
this.db.prepare('DELETE FROM notes WHERE id=?').run(id);
}
@@ -239,6 +488,10 @@ export class NoteRepository {
* - id present + raw_text identical → no-op (status: 'skipped')
* - id present + raw_text differs → INSERT under fresh uuidv7
* to preserve the raw_text-immutable invariant (status: 'forked')
*
* deletedAt merge (v0.2.3 #4, spec §8.2): source/dest 중 IS NOT NULL 우선
* (삭제 보존). skip 케이스에서 source NN + dest NULL 일 때만 dest 갱신.
* insert/fork 는 source 의 deletedAt 그대로 보존.
*/
importNote(input: ImportNoteInput): ImportNoteResult {
const existing = this.findRawTextById(input.id);
@@ -246,6 +499,16 @@ export class NoteRepository {
let status: ImportNoteStatus = 'inserted';
if (existing !== null) {
if (existing === input.rawText) {
// skip — source 가 deletedAt IS NOT NULL 이고 dest 가 NULL 이면 dest 갱신 (삭제 보존).
// trash() 를 재사용해 pending_jobs cleanup invariant (§9.2) 도 동시에 만족.
if (input.deletedAt != null) {
const destRow = this.db
.prepare('SELECT deleted_at FROM notes WHERE id=?')
.get(input.id) as { deleted_at: string | null } | undefined;
if (destRow && destRow.deleted_at === null) {
this.trash(input.id, input.deletedAt);
}
}
return { id: input.id, status: 'skipped' };
}
finalId = uuidv7();
@@ -257,8 +520,8 @@ export class NoteRepository {
`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)
VALUES (?, ?, ?, ?, 'done', ?, ?, ?, ?, ?, ?, ?, ?)`
user_intent, intent_prompted_at, deleted_at, created_at, updated_at)
VALUES (?, ?, ?, ?, 'done', ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
finalId,
@@ -271,6 +534,7 @@ export class NoteRepository {
input.summaryEditedByUser ? 1 : 0,
input.userIntent,
input.intentPromptedAt,
input.deletedAt ?? null,
input.createdAt,
input.updatedAt
);
@@ -297,7 +561,9 @@ export class NoteRepository {
getPendingCount(): number {
const row = this.db
.prepare(`SELECT COUNT(*) AS c FROM notes WHERE ai_status='pending'`)
.prepare(
`SELECT COUNT(*) AS c FROM notes WHERE ai_status='pending' AND deleted_at IS NULL`
)
.get() as { c: number };
return row.c;
}
@@ -319,19 +585,44 @@ export class NoteRepository {
const startIso = new Date(kstMidnightUtc).toISOString();
const endIso = new Date(nextKstMidnightUtc).toISOString();
const row = this.db
.prepare(`SELECT COUNT(*) AS c FROM notes WHERE created_at >= ? AND created_at < ?`)
.prepare(
`SELECT COUNT(*) AS c FROM notes
WHERE deleted_at IS NULL AND created_at >= ? AND created_at < ?`
)
.get(startIso, endIso) as { c: number };
return row.c;
}
/**
* Notes whose due_date is strictly before today (KST calendar) and that are
* still active (not trashed) and AI-processed. Includes both AI-extracted and
* user-edited due_date (v0.2.3 #5 spec §1 Q1=B).
*
* Caller may inject `now` for testability; defaults to `new Date()`.
*/
findExpiredCandidates(now: Date = new Date()): Note[] {
const today = kstTodayIso(now);
const rows = this.db
.prepare(
`SELECT * FROM notes
WHERE due_date IS NOT NULL
AND due_date < ?
AND deleted_at IS NULL
AND ai_status = 'done'
ORDER BY created_at DESC, id DESC`
)
.all(today) as Record<string, unknown>[];
return rows.map((r) => this.hydrate(r));
}
getAllPendingJobs(): Array<{ noteId: string; attempts: number; nextRunAt: string }> {
const rows = this.db
.prepare(`SELECT note_id, attempts, next_run_at FROM pending_jobs`)
.all() as any[];
.all() as Record<string, unknown>[];
return rows.map((r) => ({
noteId: r.note_id,
attempts: r.attempts,
nextRunAt: r.next_run_at
noteId: r.note_id as string,
attempts: r.attempts as number,
nextRunAt: r.next_run_at as string
}));
}
@@ -347,36 +638,39 @@ export class NoteRepository {
.run(nextRunAt, lastError.slice(0, 500), noteId);
}
private hydrate(row: any): Note {
private hydrate(row: Record<string, unknown>): Note {
const tags = this.db
.prepare(
`SELECT t.name, nt.source
FROM note_tags nt JOIN tags t ON t.id = nt.tag_id
WHERE nt.note_id = ? ORDER BY t.name`
)
.all(row.id) as Array<{ name: string; source: 'ai' | 'user' }>;
.all(row.id as string) as Array<{ name: string; source: 'ai' | 'user' }>;
const media = this.db
.prepare(
`SELECT id, kind, rel_path as relPath, mime, bytes FROM media WHERE note_id=?`
)
.all(row.id) as NoteMedia[];
.all(row.id as string) as NoteMedia[];
return {
id: row.id,
rawText: row.raw_text,
aiTitle: row.ai_title,
aiSummary: row.ai_summary,
aiStatus: row.ai_status,
aiError: row.ai_error,
aiProvider: row.ai_provider,
aiGeneratedAt: row.ai_generated_at,
titleEditedByUser: row.title_edited_by_user === 1,
summaryEditedByUser: row.summary_edited_by_user === 1,
userIntent: row.user_intent,
intentPromptedAt: row.intent_prompted_at,
dueDate: row.due_date ?? null,
dueDateEditedByUser: row.due_date_edited_by_user === 1,
createdAt: row.created_at,
updatedAt: row.updated_at,
id: row.id as string,
rawText: row.raw_text as string,
aiTitle: row.ai_title as string | null,
aiSummary: row.ai_summary as string | null,
aiStatus: row.ai_status as 'pending' | 'done' | 'failed',
aiError: row.ai_error as string | null,
aiProvider: row.ai_provider as string | null,
aiGeneratedAt: row.ai_generated_at as string | null,
titleEditedByUser: (row.title_edited_by_user as number) === 1,
summaryEditedByUser: (row.summary_edited_by_user as number) === 1,
userIntent: row.user_intent as string | null,
intentPromptedAt: row.intent_prompted_at as string | null,
dueDate: (row.due_date as string | null) ?? null,
dueDateEditedByUser: (row.due_date_edited_by_user as number) === 1,
deletedAt: (row.deleted_at as string | null) ?? null,
lastRecalledAt: (row.last_recalled_at as string | null) ?? null,
recallDismissedAt: (row.recall_dismissed_at as string | null) ?? null,
createdAt: row.created_at as string,
updatedAt: row.updated_at as string,
tags: tags as NoteTag[],
media
};

View File

@@ -0,0 +1,56 @@
import electron from 'electron';
import { execFile } from 'node:child_process';
const { app } = electron;
/**
* v0.2.7 F12 deeper fix — 자동 실행 진단 정보 수집.
*
* Electron 의 `app.getLoginItemSettings()` 는 args 가 매칭돼야만 정확한 상태를 반환 →
* `args: ['--hidden']` 으로 등록 vs `args: undefined` 로 조회하면 mismatch 가 발생할 수 있다.
* 그래서 두 호출 결과를 모두 노출 (withArgs / noArgs) + Win 에서는 registry 직접 조회까지.
*/
export interface AutostartState {
withArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean };
noArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean };
execPath: string;
registryPath?: string;
registryValue?: string | null;
}
const WIN_REGISTRY_PATH = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run';
const WIN_REGISTRY_KEY = 'Inkling';
export async function collectAutostartState(): Promise<AutostartState> {
const w = app.getLoginItemSettings({ args: ['--hidden'] });
const n = app.getLoginItemSettings();
const state: AutostartState = {
withArgs: { openAtLogin: w.openAtLogin, executableWillLaunchAtLogin: w.executableWillLaunchAtLogin },
noArgs: { openAtLogin: n.openAtLogin, executableWillLaunchAtLogin: n.executableWillLaunchAtLogin },
execPath: process.execPath
};
if (process.platform === 'win32') {
state.registryPath = `${WIN_REGISTRY_PATH}\\${WIN_REGISTRY_KEY}`;
state.registryValue = await readRegistrySilent();
}
return state;
}
/**
* `reg query` 로 HKCU\\...\\Run\\Inkling 의 값을 조회.
* 키가 없으면 reg.exe 가 exit 1 → silent fallback (null).
*
* promisify(execFile) 대신 직접 Promise 로 wrapping — 테스트에서 vi.mock 이
* `util.promisify.custom` symbol 을 보전하지 못해 stdout 이 undefined 가 되는 이슈 회피.
*/
function readRegistrySilent(): Promise<string | null> {
return new Promise((resolve) => {
execFile('reg', ['query', WIN_REGISTRY_PATH, '/v', WIN_REGISTRY_KEY], (err, stdout) => {
if (err) {
resolve(null);
return;
}
const m = stdout.match(/REG_SZ\s+(.+)/);
resolve(m && m[1] ? m[1].trim() : null);
});
});
}

View File

@@ -1,9 +1,28 @@
import type { NoteRepository } from '../repository/NoteRepository.js';
import type { MediaStore } from './MediaStore.js';
import type { Note } from '@shared/types';
export interface TelemetryEmitter {
emit(input:
| { kind: 'capture'; payload: { noteId: string; rawTextLength: number; hasMedia: boolean } }
| { kind: 'trash'; payload: { noteId: string } }
| { kind: 'restore'; payload: { noteId: string } }
| { kind: 'permanent_delete'; payload: { noteId: string } }
| { kind: 'empty_trash'; payload: { count: number } }
| { kind: 'expired_banner_shown'; payload: { candidateCount: number } }
| { kind: 'expired_batch_trash'; payload: { count: number } }
| { kind: 'ai_retry_manual'; payload: { failedCount: number } }
| { kind: 'recall_opened'; payload: { noteId: string } }
| { kind: 'recall_dismissed'; payload: { noteId: string } }
| { kind: 'recall_shown'; payload: { noteId: string; ageDays: number } }
| { kind: 'recall_snoozed'; payload: { noteId: string } }
): Promise<void>;
}
export interface CaptureDeps {
enqueue: (noteId: string) => Promise<void>;
celebrate: (noteId: string) => void;
telemetry?: TelemetryEmitter;
}
export interface SubmitInput {
@@ -12,6 +31,8 @@ export interface SubmitInput {
}
export class CaptureService {
private lastExpiredShownSig: string | null = null;
constructor(
private repo: NoteRepository,
private store: MediaStore,
@@ -39,13 +60,193 @@ export class CaptureService {
}
this.repo.insertMedia(rows);
}
if (this.deps.telemetry) {
await this.deps.telemetry.emit({
kind: 'capture',
payload: {
noteId: id,
rawTextLength: input.text.length,
hasMedia: input.images.length > 0
}
}).catch(() => {});
}
await this.deps.enqueue(id);
this.deps.celebrate(id);
return { noteId: id };
}
async deleteNote(noteId: string): Promise<void> {
this.repo.delete(noteId);
await this.store.deleteNoteDirectory(noteId);
// v0.2.3 #4: hard delete → soft delete. media 보존 (restore 시 필요).
// 이미 trash 인 노트는 telemetry emit skip — restore/trash ratio 오염 방지.
const note = this.repo.findById(noteId);
if (!note || note.deletedAt !== null) return;
this.repo.trash(noteId, new Date().toISOString());
if (this.deps.telemetry) {
await this.deps.telemetry.emit({ kind: 'trash', payload: { noteId } }).catch(() => {});
}
}
async restoreNote(noteId: string): Promise<void> {
// 이미 active 인 노트는 telemetry emit skip — restore/trash ratio 오염 방지.
const before = this.repo.findById(noteId);
if (!before || before.deletedAt === null) return;
// v0.2.6 #10 — production path: repo.restoreNote (ai_status reset + pending_jobs 재생성)
this.repo.restoreNote(noteId);
// v0.2.6 #10 — in-memory AiWorker queue 갱신: DB 갱신만으로는 다음 앱 실행 시까지 처리 X
if (before.aiStatus === 'failed' || before.aiStatus === 'pending') {
await this.deps.enqueue(noteId);
}
if (this.deps.telemetry) {
await this.deps.telemetry.emit({ kind: 'restore', payload: { noteId } }).catch(() => {});
}
}
async permanentDeleteNote(noteId: string): Promise<void> {
// 존재하지 않는 노트는 emit skip — 메트릭 오염 방지.
const note = this.repo.findById(noteId);
if (!note) return;
this.repo.permanentDelete(noteId);
// best-effort media cleanup — disk 실패해도 telemetry/IPC 흐름은 그대로 (orphan dir
// 은 future janitor 가 정리). emptyTrash 와 동일 패턴.
try { await this.store.deleteNoteDirectory(noteId); }
catch { /* best-effort */ }
if (this.deps.telemetry) {
await this.deps.telemetry.emit({ kind: 'permanent_delete', payload: { noteId } }).catch(() => {});
}
}
async emptyTrash(): Promise<{ count: number }> {
const { noteIds } = this.repo.emptyTrash();
for (const id of noteIds) {
try { await this.store.deleteNoteDirectory(id); }
catch { /* best-effort */ }
}
if (this.deps.telemetry) {
await this.deps.telemetry.emit({ kind: 'empty_trash', payload: { count: noteIds.length } }).catch(() => {});
}
return { count: noteIds.length };
}
/**
* 만료 후보 (due_date < today KST, active, ai_status=done) 조회.
* candidates 가 비지 않고 signature 가 직전과 다르면 expired_banner_shown 자동 emit.
* v0.2.3 #5 spec §6.2 — dedup 위치 main 통합.
*/
async listExpired(now: Date = new Date()): Promise<Note[]> {
const candidates = this.repo.findExpiredCandidates(now);
if (candidates.length === 0) {
// empty → reset sig 으로 의도적: 다시 후보가 차오르면 동일 set 이라도 1회 emit.
// (사용자가 "오늘 그만" 후 새 만료 노트 들어와도 셀렉션 변화로 재인식)
this.lastExpiredShownSig = null;
return candidates;
}
const sig = `${candidates.length}:${candidates.slice(0, 3).map((n) => n.id).join('-')}`;
if (sig !== this.lastExpiredShownSig) {
this.lastExpiredShownSig = sig;
if (this.deps.telemetry) {
await this.deps.telemetry.emit({
kind: 'expired_banner_shown',
payload: { candidateCount: candidates.length }
}).catch(() => {});
}
}
return candidates;
}
/**
* 만료 후보 일괄 trash. 빈 배열은 즉시 no-op.
* 성공 시 expired_batch_trash 1회 emit (per-id trash emit 은 별도 발화 안 함 —
* stats.md 에서 `trash` (단건) vs `expired_batch_trash` (배치) 분리 통계).
*/
async trashExpiredBatch(ids: string[]): Promise<{ trashedCount: number }> {
if (ids.length === 0) return { trashedCount: 0 };
const r = this.repo.trashBatch(ids, new Date().toISOString());
if (this.deps.telemetry) {
await this.deps.telemetry.emit({
kind: 'expired_batch_trash',
payload: { count: r.trashedCount }
}).catch(() => {});
}
return r;
}
/**
* 모든 ai_status='failed' (active) 노트를 'pending' 으로 reset + worker.enqueue 재투입.
* 빈 결과는 telemetry emit 안 함 (failedCount ≥ 1 invariant).
* v0.2.3 #2 retry-all manual trigger.
*/
async retryAllFailed(): Promise<{ count: number }> {
const { ids } = this.repo.retryAllFailed(new Date().toISOString());
for (const id of ids) {
await this.deps.enqueue(id);
}
if (ids.length > 0 && this.deps.telemetry) {
await this.deps.telemetry.emit({
kind: 'ai_retry_manual',
payload: { failedCount: ids.length }
}).catch(() => {});
}
return { count: ids.length };
}
/** v0.2.3 #6 — 회상 후보 1건 fetch. */
async listRecallCandidate(): Promise<Note | null> {
return this.repo.findRecallCandidate();
}
/** v0.2.3 #6 — 회상 "열어보기" 시 last_recalled_at 갱신 + recall_opened emit. */
async markRecallOpened(noteId: string): Promise<{ note: Note }> {
if (!this.repo.findById(noteId)) throw new Error(`note not found: ${noteId}`);
this.repo.markRecallOpened(noteId, new Date().toISOString());
if (this.deps.telemetry) {
await this.deps.telemetry.emit({
kind: 'recall_opened',
payload: { noteId }
}).catch(() => {});
}
return { note: this.repo.findById(noteId)! };
}
/** v0.2.3 #6 — 회상 "더 이상" 시 recall_dismissed_at 갱신 + recall_dismissed emit. */
async dismissRecall(noteId: string): Promise<{ note: Note }> {
this.repo.dismissRecall(noteId, new Date().toISOString());
if (this.deps.telemetry) {
await this.deps.telemetry.emit({
kind: 'recall_dismissed',
payload: { noteId }
}).catch(() => {});
}
return { note: this.repo.findById(noteId)! };
}
/** v0.2.3 #6 — RecallBanner 첫 렌더 시 recall_shown emit (per-note 1회 제약은 renderer 가 보장). */
async emitRecallShown(noteId: string): Promise<void> {
const note = this.repo.findById(noteId);
if (!note) return;
const ageDays = this.computeAgeDays(note);
if (this.deps.telemetry) {
await this.deps.telemetry.emit({
kind: 'recall_shown',
payload: { noteId, ageDays }
}).catch(() => {});
}
}
/** v0.2.3 #6 — 사용자 "다음에" 클릭 시 recall_snoozed emit. */
async emitRecallSnoozed(noteId: string): Promise<void> {
if (this.deps.telemetry) {
await this.deps.telemetry.emit({
kind: 'recall_snoozed',
payload: { noteId }
}).catch(() => {});
}
}
/** ageDays = (now - max(last_recalled_at, created_at)) / 86_400_000, floor. */
private computeAgeDays(note: Note): number {
const ref = note.lastRecalledAt ?? note.createdAt;
const refMs = new Date(ref).getTime();
const nowMs = Date.now();
return Math.max(0, Math.floor((nowMs - refMs) / 86_400_000));
}
}

View File

@@ -32,7 +32,9 @@ export class ContinuityService {
get(): WeeklyContinuity {
const rows = this.db
.prepare(`SELECT created_at FROM notes ORDER BY created_at ASC`)
.prepare(
`SELECT created_at FROM notes WHERE deleted_at IS NULL ORDER BY created_at ASC`
)
.all() as Array<{ created_at: string }>;
const dates = rows.map((r) => new Date(r.created_at));
if (dates.length === 0) {

View File

@@ -1,12 +1,86 @@
import type { InferenceProvider, HealthResult } from '../ai/InferenceProvider.js';
import type { HealthResult } from '../ai/InferenceProvider.js';
import { ProviderHolder } from '../ai/ProviderHolder.js';
export type HealthTelemetryEvent =
| { kind: 'ollama_unreachable'; reason: string }
| { kind: 'ollama_recovered'; downtimeMs: number }
| { kind: 'ollama_recheck_manual' };
export interface HealthCheckerOptions {
intervalMs?: number;
onUpdate?: (status: HealthResult) => void;
onTelemetry?: (event: HealthTelemetryEvent) => void;
now?: () => number;
}
const DEFAULT_INTERVAL_MS = 60_000;
export class HealthChecker {
// sentinel: 첫 healthCheck 가 ok=true 면 transition 으로 인식 안 됨 (no-op),
// ok=false 면 unreachable transition 으로 정상 인식. 즉 첫 호출이 healthy 면 telemetry 0.
private last: HealthResult = { ok: true };
constructor(private provider: InferenceProvider) {}
private timer: NodeJS.Timeout | null = null;
private unreachableSince: number | null = null;
// m2 fix: in-flight guard — 첫 runOnce 가 늦게 끝나는 동안 setInterval 이 두 번째
// runOnce 를 시작하면 같은 promise 반환. healthCheck 가 idempotent HTTP 라 안전 측면에선
// 큰 문제 없지만, telemetry 이중 emit (false→true→false 동시 처리) 회피.
private inFlight: Promise<HealthResult> | null = null;
private intervalMs: number;
private now: () => number;
async runOnce(): Promise<HealthResult> {
this.last = await this.provider.healthCheck();
return this.last;
constructor(
private holder: ProviderHolder,
private opts: HealthCheckerOptions = {}
) {
this.intervalMs = opts.intervalMs ?? DEFAULT_INTERVAL_MS;
this.now = opts.now ?? Date.now;
}
async runOnce(opts?: { manual?: boolean }): Promise<HealthResult> {
// n4 의도: ollama_recheck_manual 은 healthCheck 호출 *전에* fire — provider 가 throw 하거나
// 늦게 응답해도 manual 카운트는 누락 없음. user click → telemetry 1:1 보장.
if (opts?.manual === true) {
this.opts.onTelemetry?.({ kind: 'ollama_recheck_manual' });
}
if (this.inFlight !== null) return this.inFlight;
this.inFlight = this.doRunOnce();
try { return await this.inFlight; }
finally { this.inFlight = null; }
}
private async doRunOnce(): Promise<HealthResult> {
const next = await this.holder.get().healthCheck();
const prev = this.last;
const okChanged = prev.ok !== next.ok;
const reasonChanged = prev.reason !== next.reason;
if (okChanged) {
if (next.ok === false) {
this.unreachableSince = this.now();
this.opts.onTelemetry?.({ kind: 'ollama_unreachable', reason: next.reason ?? 'unknown' });
} else {
const downtimeMs = this.unreachableSince !== null ? this.now() - this.unreachableSince : 0;
this.unreachableSince = null;
this.opts.onTelemetry?.({ kind: 'ollama_recovered', downtimeMs });
}
this.opts.onUpdate?.(next);
} else if (reasonChanged) {
this.opts.onUpdate?.(next);
}
this.last = next;
return next;
}
start(): void {
if (this.timer !== null) return;
void this.runOnce();
this.timer = setInterval(() => { void this.runOnce(); }, this.intervalMs);
}
stop(): void {
if (this.timer !== null) {
clearInterval(this.timer);
this.timer = null;
}
}
lastStatus(): HealthResult { return this.last; }

View File

@@ -39,7 +39,8 @@ function parsedToInput(parsed: ParsedNote): ImportNoteInput {
aiGeneratedAt: parsed.aiGeneratedAt,
userIntent: parsed.userIntent,
intentPromptedAt: parsed.intentPromptedAt,
tags: parsed.tags
tags: parsed.tags,
deletedAt: parsed.deletedAt
};
}

View File

@@ -6,6 +6,9 @@ export class MediaGc {
async run(): Promise<{ removed: number }> {
const dirs = await this.store.listNoteDirs();
// Intentionally does NOT filter `deleted_at IS NULL` — trashed notes still own
// their media until permanentDelete/emptyTrash. Removing dirs of soft-deleted
// notes here would defeat restore.
const rows = this.db.prepare('SELECT id FROM notes').all() as Array<{ id: string }>;
const known = new Set(rows.map((r) => r.id));
let removed = 0;

View File

@@ -0,0 +1,47 @@
import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
import { join, dirname } from 'node:path';
import { z } from 'zod';
const OllamaSettingsSchema = z.object({
endpoint: z.string().url(),
model: z.string().min(1)
}).strict();
const SettingsSchema = z.object({
ollama: OllamaSettingsSchema.optional()
}).strict();
export type Settings = z.infer<typeof SettingsSchema>;
export type OllamaSettings = z.infer<typeof OllamaSettingsSchema>;
export class SettingsService {
private filePath: string;
private cache: Settings | null = null;
constructor(profileDir: string) {
this.filePath = join(profileDir, 'settings.json');
}
async load(): Promise<Settings> {
if (this.cache !== null) return this.cache;
try {
const raw = await readFile(this.filePath, 'utf8');
const parsed = JSON.parse(raw);
this.cache = SettingsSchema.parse(parsed);
} catch {
this.cache = {};
}
return this.cache;
}
async setOllama(value: OllamaSettings): Promise<void> {
const validated = OllamaSettingsSchema.parse(value);
const current = await this.load();
const next: Settings = { ...current, ollama: validated };
await mkdir(dirname(this.filePath), { recursive: true });
const tmpPath = this.filePath + '.tmp';
await writeFile(tmpPath, JSON.stringify(next, null, 2), 'utf8');
await rename(tmpPath, this.filePath);
this.cache = next;
}
}

View File

@@ -0,0 +1,137 @@
import { mkdir, appendFile, readFile, readdir, unlink, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { validateEvent, TelemetryEvent } from './telemetryEvents.js';
import type { AiFailedReason } from './telemetryEvents.js';
import { aggregateStats } from './telemetryStats.js';
import { kstTodayIso, DAY_MS } from '../../shared/util/kstDate.js';
export interface TelemetryServiceOptions {
silent?: boolean;
}
export type EmitInput =
| { kind: 'capture'; payload: { noteId: string; rawTextLength: number; hasMedia: boolean } }
| { kind: 'ai_succeeded'; payload: { noteId: string; durationMs: number; attempts: number } }
| { kind: 'ai_failed'; payload: { noteId: string; reason: AiFailedReason; attempts: number } }
| { kind: 'trash'; payload: { noteId: string } }
| { kind: 'restore'; payload: { noteId: string } }
| { kind: 'permanent_delete'; payload: { noteId: string } }
| { kind: 'empty_trash'; payload: { count: number } }
| { kind: 'expired_banner_shown'; payload: { candidateCount: number } }
| { kind: 'expired_batch_trash'; payload: { count: number } }
| { kind: 'ollama_unreachable'; payload: { reason: string } }
| { kind: 'ollama_recovered'; payload: { downtimeMs: number } }
| { kind: 'ollama_recheck_manual'; payload: Record<string, never> }
| { kind: 'ai_retry_manual'; payload: { failedCount: number } }
| { kind: 'tag_vocab_hit'; payload: { tagId: number; vocabSize: number } }
| { kind: 'tag_vocab_miss'; payload: { vocabSize: number } }
| { kind: 'recall_shown'; payload: { noteId: string; ageDays: number } }
| { kind: 'recall_opened'; payload: { noteId: string } }
| { kind: 'recall_dismissed'; payload: { noteId: string } }
| { kind: 'recall_snoozed'; payload: { noteId: string } };
export class TelemetryService {
constructor(
private dir: string,
private now: () => Date = () => new Date(),
private retentionDays: number = 14,
private opts: TelemetryServiceOptions = {}
) {}
async cleanupOldFiles(): Promise<{ removed: string[] }> {
const removed: string[] = [];
let entries: string[];
try {
entries = await readdir(this.dir);
} catch {
return { removed };
}
const cutoff = new Date(this.now().getTime() - this.retentionDays * DAY_MS);
const cutoffIso = kstTodayIso(cutoff); // KST 일자 비교
for (const name of entries) {
const m = /^events-(\d{4}-\d{2}-\d{2})\.jsonl$/.exec(name);
if (!m) continue;
const fileDate = m[1]!;
if (fileDate < cutoffIso) {
try {
await unlink(join(this.dir, name));
removed.push(name);
} catch {
// ignore — best-effort cleanup
}
}
}
return { removed };
}
async emit(input: EmitInput): Promise<void> {
// 회차 1 review (PR #13) — `now()` 한 번만 호출. KST 자정 경계에서 ts 와 파일명 일자가
// 어긋나는 것을 방지.
const nowDate = this.now();
const ts = nowDate.toISOString();
const event = validateEvent({ ts, kind: input.kind, payload: input.payload });
const filePath = join(this.dir, `events-${kstTodayIso(nowDate)}.jsonl`);
try {
await mkdir(this.dir, { recursive: true });
await appendFile(filePath, JSON.stringify(event) + '\n', 'utf8');
} catch (err) {
if (this.opts.silent) return;
throw err;
}
}
async readAllRecent(): Promise<TelemetryEvent[]> {
const events: TelemetryEvent[] = [];
let entries: string[];
try {
entries = await readdir(this.dir);
} catch {
return events;
}
const cutoffMs = this.now().getTime() - this.retentionDays * DAY_MS;
const cutoffIso = kstTodayIso(new Date(cutoffMs));
// 회차 1 review (PR #13) — 매직 슬라이스 `n.slice(7, 17)` 대신 정규식 capture 그룹으로
// 일자를 추출. prefix 변경 시 정규식 한 곳만 고치면 됨.
const datePattern = /^events-(\d{4}-\d{2}-\d{2})\.jsonl$/;
const fileNames = entries
.filter((n) => {
const m = datePattern.exec(n);
return m !== null && m[1]! >= cutoffIso;
})
.sort();
for (const name of fileNames) {
let raw: string;
try {
raw = await readFile(join(this.dir, name), 'utf8');
} catch {
continue;
}
for (const line of raw.split('\n')) {
const trimmed = line.trim();
if (trimmed.length === 0) continue;
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
continue;
}
try {
events.push(validateEvent(parsed));
} catch {
continue;
}
}
}
return events;
}
async exportTo(outDir: string): Promise<{ eventCount: number }> {
const events = await this.readAllRecent();
await mkdir(outDir, { recursive: true });
const eventsContent = events.map((e) => JSON.stringify(e)).join('\n') + (events.length > 0 ? '\n' : '');
await writeFile(join(outDir, 'events.jsonl'), eventsContent, 'utf8');
const stats = aggregateStats(events, this.now());
await writeFile(join(outDir, 'stats.md'), stats.md, 'utf8');
return { eventCount: stats.eventCount };
}
}

View File

@@ -33,6 +33,7 @@ export interface ParsedNote {
aiGeneratedAt: string | null;
userIntent: string | null;
intentPromptedAt: string | null;
deletedAt: string | null; // 신규 v0.2.3 #4
tags: ParsedNoteTag[];
images: ParsedNoteImage[];
exportVersion: number;
@@ -347,6 +348,7 @@ export function parseExportNote(markdown: string): ParsedNote {
aiGeneratedAt: get('ai_generated_at'),
userIntent: get('user_intent'),
intentPromptedAt: get('intent_prompted_at'),
deletedAt: get('deleted_at'),
tags: fm.tags,
images: fm.images,
exportVersion

View File

@@ -0,0 +1,115 @@
import { z } from 'zod';
const CapturePayload = z.object({
noteId: z.string().min(1),
rawTextLength: z.number().int().nonnegative(),
hasMedia: z.boolean()
}).strict();
const AiSucceededPayload = z.object({
noteId: z.string().min(1),
durationMs: z.number().nonnegative(),
attempts: z.number().int().nonnegative()
}).strict();
export const AiFailedReasonSchema = z.enum(['unreachable', 'schema', 'timeout', 'other']);
export type AiFailedReason = z.infer<typeof AiFailedReasonSchema>;
const AiFailedPayload = z.object({
noteId: z.string().min(1),
reason: AiFailedReasonSchema,
attempts: z.number().int().nonnegative()
}).strict();
const NoteIdPayload = z.object({
noteId: z.string().min(1)
}).strict();
const EmptyTrashPayload = z.object({
count: z.number().int().nonnegative()
}).strict();
const ExpiredBannerShownPayload = z.object({
candidateCount: z.number().int().nonnegative()
}).strict();
const ExpiredBatchTrashPayload = z.object({
count: z.number().int().nonnegative()
}).strict();
const OllamaUnreachablePayload = z.object({
reason: z.string().min(1).max(500)
}).strict();
const OllamaRecoveredPayload = z.object({
downtimeMs: z.number().nonnegative()
}).strict();
const EmptyPayload = z.object({}).strict();
const AiRetryManualPayload = z.object({
failedCount: z.number().int().positive()
}).strict();
const TagVocabHitPayload = z.object({
tagId: z.number().int().positive(),
vocabSize: z.number().int().nonnegative()
}).strict();
const TagVocabMissPayload = z.object({
vocabSize: z.number().int().nonnegative()
}).strict();
const RecallShownPayload = z.object({
noteId: z.string().min(1),
ageDays: z.number().int().nonnegative()
}).strict();
export const TelemetryEventSchema = z.discriminatedUnion('kind', [
z.object({ ts: z.string(), kind: z.literal('capture'), payload: CapturePayload }).strict(),
z.object({ ts: z.string(), kind: z.literal('ai_succeeded'), payload: AiSucceededPayload }).strict(),
z.object({ ts: z.string(), kind: z.literal('ai_failed'), payload: AiFailedPayload }).strict(),
z.object({ ts: z.string(), kind: z.literal('trash'), payload: NoteIdPayload }).strict(),
z.object({ ts: z.string(), kind: z.literal('restore'), payload: NoteIdPayload }).strict(),
z.object({ ts: z.string(), kind: z.literal('permanent_delete'), payload: NoteIdPayload }).strict(),
z.object({ ts: z.string(), kind: z.literal('empty_trash'), payload: EmptyTrashPayload }).strict(),
z.object({ ts: z.string(), kind: z.literal('expired_banner_shown'), payload: ExpiredBannerShownPayload }).strict(),
z.object({ ts: z.string(), kind: z.literal('expired_batch_trash'), payload: ExpiredBatchTrashPayload }).strict(),
z.object({ ts: z.string(), kind: z.literal('ollama_unreachable'), payload: OllamaUnreachablePayload }).strict(),
z.object({ ts: z.string(), kind: z.literal('ollama_recovered'), payload: OllamaRecoveredPayload }).strict(),
z.object({ ts: z.string(), kind: z.literal('ollama_recheck_manual'), payload: EmptyPayload }).strict(),
z.object({ ts: z.string(), kind: z.literal('ai_retry_manual'), payload: AiRetryManualPayload }).strict(),
z.object({ ts: z.string(), kind: z.literal('tag_vocab_hit'), payload: TagVocabHitPayload }).strict(),
z.object({ ts: z.string(), kind: z.literal('tag_vocab_miss'), payload: TagVocabMissPayload }).strict(),
z.object({ ts: z.string(), kind: z.literal('recall_shown'), payload: RecallShownPayload }).strict(),
z.object({ ts: z.string(), kind: z.literal('recall_opened'), payload: NoteIdPayload }).strict(),
z.object({ ts: z.string(), kind: z.literal('recall_dismissed'), payload: NoteIdPayload }).strict(),
z.object({ ts: z.string(), kind: z.literal('recall_snoozed'), payload: NoteIdPayload }).strict()
]);
export type TelemetryEvent = z.infer<typeof TelemetryEventSchema>;
export type TelemetryKind = TelemetryEvent['kind'];
export function validateEvent(raw: unknown): TelemetryEvent {
return TelemetryEventSchema.parse(raw);
}
/**
* v0.2.6 #21 — type predicate helper. payload.noteId 가 있는 event kind 만 narrow.
* union 확장 시 NO_NOTE_ID_KINDS Set 한 곳만 갱신.
*/
const NO_NOTE_ID_KINDS = new Set<TelemetryKind>([
'empty_trash',
'expired_banner_shown',
'expired_batch_trash',
'ollama_unreachable',
'ollama_recovered',
'ollama_recheck_manual',
'ai_retry_manual',
'tag_vocab_hit',
'tag_vocab_miss'
]);
export function hasNoteId(ev: TelemetryEvent): ev is Extract<TelemetryEvent, { payload: { noteId: string } }> {
return !NO_NOTE_ID_KINDS.has(ev.kind);
}

View File

@@ -0,0 +1,193 @@
import type { TelemetryEvent } from './telemetryEvents.js';
import { kstTodayIso } from '../../shared/util/kstDate.js';
function kstDate(ts: string): string {
return kstTodayIso(new Date(ts));
}
interface DailyRow {
date: string;
capture: number;
ai_succeeded: number;
ai_failed: number;
trash: number;
restore: number;
permanent_delete: number;
empty_trash: number;
expired_banner_shown: number;
expired_batch_trash: number;
ollama_unreachable: number;
ollama_recovered: number;
ollama_recheck_manual: number;
ai_retry_manual: number;
tag_vocab_hit: number;
tag_vocab_miss: number;
recall_shown: number;
recall_opened: number;
recall_dismissed: number;
recall_snoozed: number;
}
export interface StatsResult {
md: string;
eventCount: number;
}
export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): StatsResult {
const eventCount = events.length;
const byDay = new Map<string, DailyRow>();
let aiSucceeded = 0;
let aiFailed = 0;
let durationSum = 0;
let durationN = 0;
let trashCount = 0;
let restoreCount = 0;
let expiredBannerShownCandidatesSum = 0;
let expiredBatchTrashCountSum = 0;
let ollamaDowntimeSum = 0;
let ollamaRecoveredCount = 0;
let ollamaRecheckManualCount = 0;
let aiRetryManualCount = 0;
let aiRetryManualFailedSum = 0;
let tagVocabHitCount = 0;
let tagVocabMissCount = 0;
let recallShownCount = 0;
let recallOpenedCount = 0;
let recallDismissedCount = 0;
let recallSnoozedCount = 0;
let recallAgeDaysSum = 0;
for (const ev of events) {
const day = kstDate(ev.ts);
let row = byDay.get(day);
if (!row) {
row = {
date: day,
capture: 0, ai_succeeded: 0, ai_failed: 0,
trash: 0, restore: 0, permanent_delete: 0, empty_trash: 0,
expired_banner_shown: 0, expired_batch_trash: 0,
ollama_unreachable: 0, ollama_recovered: 0, ollama_recheck_manual: 0,
ai_retry_manual: 0,
tag_vocab_hit: 0, tag_vocab_miss: 0,
recall_shown: 0, recall_opened: 0, recall_dismissed: 0, recall_snoozed: 0
};
byDay.set(day, row);
}
if (ev.kind === 'capture') row.capture += 1;
else if (ev.kind === 'ai_succeeded') {
row.ai_succeeded += 1;
aiSucceeded += 1;
durationSum += ev.payload.durationMs;
durationN += 1;
} else if (ev.kind === 'ai_failed') {
row.ai_failed += 1;
aiFailed += 1;
} else if (ev.kind === 'trash') {
row.trash += 1;
trashCount += 1;
} else if (ev.kind === 'restore') {
row.restore += 1;
restoreCount += 1;
} else if (ev.kind === 'permanent_delete') {
row.permanent_delete += 1;
} else if (ev.kind === 'empty_trash') {
row.empty_trash += 1;
} else if (ev.kind === 'expired_banner_shown') {
row.expired_banner_shown += 1;
expiredBannerShownCandidatesSum += ev.payload.candidateCount;
} else if (ev.kind === 'expired_batch_trash') {
row.expired_batch_trash += 1;
expiredBatchTrashCountSum += ev.payload.count;
} else if (ev.kind === 'ollama_unreachable') {
row.ollama_unreachable += 1;
} else if (ev.kind === 'ollama_recovered') {
row.ollama_recovered += 1;
ollamaDowntimeSum += ev.payload.downtimeMs;
ollamaRecoveredCount += 1;
} else if (ev.kind === 'ollama_recheck_manual') {
row.ollama_recheck_manual += 1;
ollamaRecheckManualCount += 1;
} else if (ev.kind === 'ai_retry_manual') {
row.ai_retry_manual += 1;
aiRetryManualCount += 1;
aiRetryManualFailedSum += ev.payload.failedCount;
} else if (ev.kind === 'tag_vocab_hit') {
row.tag_vocab_hit += 1;
tagVocabHitCount += 1;
} else if (ev.kind === 'tag_vocab_miss') {
row.tag_vocab_miss += 1;
tagVocabMissCount += 1;
} else if (ev.kind === 'recall_shown') {
row.recall_shown += 1;
recallShownCount += 1;
recallAgeDaysSum += ev.payload.ageDays;
} else if (ev.kind === 'recall_opened') {
row.recall_opened += 1;
recallOpenedCount += 1;
} else if (ev.kind === 'recall_dismissed') {
row.recall_dismissed += 1;
recallDismissedCount += 1;
} else if (ev.kind === 'recall_snoozed') {
row.recall_snoozed += 1;
recallSnoozedCount += 1;
} else {
// v0.2.6 #8 — 새 telemetry kind 추가 시 본 함수 분기 누락을 컴파일 단계에서 catch.
const _exhaustive: never = ev;
void _exhaustive;
}
}
const days = Array.from(byDay.values()).sort((a, b) => a.date.localeCompare(b.date));
const aiTotal = aiSucceeded + aiFailed;
const successRate = aiTotal === 0 ? 'N/A' : `${(aiSucceeded / aiTotal * 100).toFixed(1)}% (${aiSucceeded}/${aiTotal})`;
const avgDuration = durationN === 0 ? 'N/A' : `${Math.round(durationSum / durationN)}`;
// v0.2.6 #9 — 회수율 = restore / trash event 비율 (event-level — 한 노트 trash-restore 반복 시
// 100% 가능, unique-note 회수율 아님. spec §6.2 "회수 도구 동작?" 질문에 충분).
const trashRecoveryRate = trashCount === 0
? 'N/A'
: `${(restoreCount / trashCount * 100).toFixed(1)}% (${restoreCount}/${trashCount})`;
const expiredTrashRatio = expiredBannerShownCandidatesSum === 0
? 'N/A'
: `${(expiredBatchTrashCountSum / expiredBannerShownCandidatesSum * 100).toFixed(1)}% (${expiredBatchTrashCountSum}/${expiredBannerShownCandidatesSum})`;
const avgDowntime = ollamaRecoveredCount === 0
? 'N/A'
: `${Math.round(ollamaDowntimeSum / ollamaRecoveredCount)}`;
const totalUnreachable = days.reduce((s, r) => s + r.ollama_unreachable, 0);
const tagVocabTotal = tagVocabHitCount + tagVocabMissCount;
const tagVocabSummary = tagVocabTotal === 0
? '(데이터 없음)'
: `hit/miss = ${tagVocabHitCount}/${tagVocabMissCount} (적중률 ${(tagVocabHitCount / tagVocabTotal * 100).toFixed(1)}%)`;
const recallSummary = recallShownCount === 0
? '(데이터 없음)'
: `shown ${recallShownCount} / opened ${recallOpenedCount} / dismissed ${recallDismissedCount} / snoozed ${recallSnoozedCount} (열림율 ${(recallOpenedCount / recallShownCount * 100).toFixed(1)}%)`;
const recallAvgAge = recallShownCount === 0
? '(데이터 없음)'
: `${Math.round(recallAgeDaysSum / recallShownCount)}`;
const lines: string[] = [];
lines.push('# Inkling Telemetry Stats');
lines.push('');
lines.push(`생성: ${generatedAt.toISOString()}`);
lines.push(`총 이벤트: ${eventCount}`);
lines.push('');
lines.push('## 일자별 카운트');
lines.push('');
lines.push('| 일자 | capture | ai_succeeded | ai_failed | trash | restore | permanent_delete | empty_trash | expired_banner_shown | expired_batch_trash | ollama_unreachable | ollama_recovered | ollama_recheck_manual | ai_retry_manual | tag_vocab_hit | tag_vocab_miss | recall_shown | recall_opened | recall_dismissed | recall_snoozed |');
lines.push('|------|---------|--------------|-----------|-------|---------|------------------|-------------|----------------------|---------------------|--------------------|------------------|----------------------|-----------------|---------------|----------------|--------------|---------------|------------------|----------------|');
for (const row of days) {
lines.push(`| ${row.date} | ${row.capture} | ${row.ai_succeeded} | ${row.ai_failed} | ${row.trash} | ${row.restore} | ${row.permanent_delete} | ${row.empty_trash} | ${row.expired_banner_shown} | ${row.expired_batch_trash} | ${row.ollama_unreachable} | ${row.ollama_recovered} | ${row.ollama_recheck_manual} | ${row.ai_retry_manual} | ${row.tag_vocab_hit} | ${row.tag_vocab_miss} | ${row.recall_shown} | ${row.recall_opened} | ${row.recall_dismissed} | ${row.recall_snoozed} |`);
}
lines.push('');
lines.push('## 핵심 ratio');
lines.push('');
lines.push(`- AI 성공률: ${successRate}`);
lines.push(`- 평균 ai_succeeded durationMs: ${avgDuration}`);
lines.push(`- 휴지통 회수율: ${trashRecoveryRate}`);
lines.push(`- 만료 trash ratio: ${expiredTrashRatio}`);
lines.push(`- Ollama unreachable 빈도: ${totalUnreachable}`);
lines.push(`- 평균 downtimeMs (recovered): ${avgDowntime}`);
lines.push(`- 수동 recheck 사용량: ${ollamaRecheckManualCount}`);
lines.push(`- AI 수동 재시도: ${aiRetryManualCount}회 / 누적 ${aiRetryManualFailedSum}`);
lines.push(`- 태그 vocab: ${tagVocabSummary}`);
lines.push(`- 회상 추천: ${recallSummary}`);
lines.push(`- 회상 평균 ageDays: ${recallAvgAge}`);
lines.push('');
return { md: lines.join('\n'), eventCount };
}

View File

@@ -2,79 +2,80 @@ import electron from 'electron';
import type { Tray as TrayType, MenuItemConstructorOptions } from 'electron';
const { app, Tray, Menu, nativeImage } = electron;
let tray: TrayType | null = null;
let _showInbox: () => void = () => {};
let _showCapture: () => void = () => {};
let _runBackup: () => void = () => {};
let _runExport: () => void = () => {};
let _runImport: () => void = () => {};
let _runSync: () => void = () => {};
let _todayCount = 0;
// v0.2.7 Phase 3 (Task 15) — showAboutDialog 제거됨.
// "Inkling 정보..." 트레이 항목이 사라짐 → 동일 기능은 설정 페이지의 InfoSection 이 담당.
// settings:get-app-info / settings:copy-app-info IPC 핸들러 (settingsApi.ts) 가 역할 인계.
function buildMenu() {
/**
* v0.2.7 Phase 3 (Task 14) — 트레이 메뉴 슬림. 13 → 4 항목.
*
* 백업/내보내기/복원/동기화/사용 로그/Ollama 재확인/AI 재처리/Ollama 설정/자동실행/정보 →
* 모두 설정 페이지로 이전. 트레이는 4 항목만 노출:
* 1. 한 줄 적기 (showCapture)
* 2. 보관한 메모 보기 (showInbox)
* 3. 설정... (showSettings — 설정 페이지로 navigate)
* 4. 종료
*/
export interface TrayCallbacks {
showInbox: () => void;
showCapture: () => void;
showSettings: () => void;
}
/**
* v0.2.7 Phase 3 (Task 14) — TrayState 슬림. todayCount 만 잔류 (오늘 N번 잡아둠 라벨).
* ollamaOk / failedCount 메뉴 항목이 사라져 더 이상 필요 없음.
*/
export interface TrayState {
todayCount: number;
}
let tray: TrayType | null = null;
let _callbacks: TrayCallbacks | null = null;
let _state: TrayState = { todayCount: 0 };
function buildMenu(): electron.Menu {
const items: MenuItemConstructorOptions[] = [];
const cb = _callbacks;
if (!cb) {
// createTray 호출 전이면 빈 메뉴 (defensive)
return Menu.buildFromTemplate([{ label: '로딩 중...', enabled: false }]);
}
// F4-C: count > 0 시 비활성 라벨로 정체성 신호 노출. count = 0 시 메뉴를 자연스럽게 시작.
if (_todayCount > 0) {
items.push({ label: `오늘 ${_todayCount}번 잡아둠`, enabled: false });
if (_state.todayCount > 0) {
items.push({ label: `오늘 ${_state.todayCount}번 잡아둠`, enabled: false });
items.push({ type: 'separator' });
}
items.push({ label: '보관한 메모 보기', click: _showInbox });
items.push({ label: '한 줄 적기', click: _showCapture });
items.push({ label: '한 줄 적기', click: cb.showCapture });
items.push({ label: '보관한 메모 보기', click: cb.showInbox });
items.push({ type: 'separator' });
items.push({ label: '설정...', click: cb.showSettings });
items.push({ type: 'separator' });
items.push({ label: '지금 백업', click: _runBackup });
items.push({ label: '내보내기...', click: _runExport });
items.push({ label: '백업에서 복원...', click: _runImport });
items.push({ label: '지금 동기화', click: _runSync });
if (app.isPackaged) {
const { openAtLogin } = app.getLoginItemSettings();
items.push({
label: '윈도우 시작 시 자동 실행',
type: 'checkbox',
checked: openAtLogin,
click: (item) => {
app.setLoginItemSettings({
openAtLogin: item.checked,
args: ['--hidden']
});
}
});
items.push({ type: 'separator' });
} else {
items.push({ type: 'separator' });
}
items.push({ label: '종료', click: () => { app.isQuitting = true; app.quit(); } });
return Menu.buildFromTemplate(items);
}
export function createTray(
showInbox: () => void,
showCapture: () => void,
runBackup: () => void,
runExport: () => void,
runImport: () => void,
runSync: () => void
): TrayType {
_showInbox = showInbox;
_showCapture = showCapture;
_runBackup = runBackup;
_runExport = runExport;
_runImport = runImport;
_runSync = runSync;
/**
* v0.2.6 C2 — 1-arg createTray. 기존 10 positional 폐기.
* v0.2.7 Phase 3 — TrayCallbacks 3-필드로 슬림.
*/
export function createTray(callbacks: TrayCallbacks): TrayType {
_callbacks = callbacks;
const icon = nativeImage.createEmpty();
tray = new Tray(icon);
tray.setToolTip(`Inkling — 오늘 ${_todayCount}`);
tray.setToolTip(`Inkling — 오늘 ${_state.todayCount}`);
tray.setContextMenu(buildMenu());
tray.on('click', showInbox);
tray.on('click', callbacks.showInbox);
return tray;
}
/**
* F4-C 환경 앵커 — tooltip + 메뉴 첫 항목을 오늘 캡처 수로 갱신.
* `src/main/index.ts` 가 60s interval / AiWorker onUpdate 시점에 호출.
* v0.2.6 C3 — 통합 state 갱신. partial 으로 받아 _state merge + 메뉴 재빌드.
* v0.2.7 Phase 3 — TrayState 가 todayCount 만 갖도록 슬림.
*/
export function refreshTray(todayCount: number): void {
_todayCount = todayCount;
export function refreshTray(state: Partial<TrayState>): void {
_state = { ..._state, ...state };
if (tray === null) return;
tray.setToolTip(`Inkling — 오늘 ${todayCount}`);
tray.setToolTip(`Inkling — 오늘 ${_state.todayCount}`);
tray.setContextMenu(buildMenu());
}

View File

@@ -12,18 +12,62 @@ const api: InklingApi = {
updateAiFields: (noteId, fields) =>
ipcRenderer.invoke('inbox:updateAi', { noteId, fields }),
setDueDate: (noteId, date) => ipcRenderer.invoke('inbox:setDueDate', { noteId, date }),
deleteNote: (noteId) => ipcRenderer.invoke('inbox:delete', noteId),
deleteNote: (noteId) => ipcRenderer.invoke('inbox:trash', noteId),
setIntent: (noteId, text) => ipcRenderer.invoke('inbox:setIntent', { noteId, text }),
dismissIntent: (noteId) => ipcRenderer.invoke('inbox:dismissIntent', noteId),
getContinuity: () => ipcRenderer.invoke('inbox:continuity'),
getPendingCount: () => ipcRenderer.invoke('inbox:pendingCount'),
getOllamaStatus: () => ipcRenderer.invoke('inbox:ollamaStatus'),
getTodayCount: () => ipcRenderer.invoke('inbox:todayCount'),
// 신규 v0.2.3 #4:
restoreNote: (noteId) => ipcRenderer.invoke('inbox:restore', noteId),
permanentDeleteNote: (noteId) => ipcRenderer.invoke('inbox:permanentDelete', noteId),
emptyTrash: () => ipcRenderer.invoke('inbox:emptyTrash'),
listTrash: (opts) => ipcRenderer.invoke('inbox:listTrash', opts),
getTrashCount: () => ipcRenderer.invoke('inbox:trashCount'),
listExpired: () => ipcRenderer.invoke('inbox:listExpired'),
trashExpiredBatch: (ids) => ipcRenderer.invoke('inbox:trashExpiredBatch', { ids }),
ollamaRecheck: () => ipcRenderer.invoke('inbox:ollamaRecheck'),
onNoteUpdated: (cb) => {
const listener = (_e: unknown, note: Note) => cb(note);
ipcRenderer.on('note:updated', listener);
return () => ipcRenderer.off('note:updated', listener);
}
},
onOllamaStatus: (cb) => {
const listener = (_e: unknown, status: { ok: boolean; reason?: string }) => cb(status);
ipcRenderer.on('ollama:status', listener);
return () => ipcRenderer.off('ollama:status', listener);
},
retryAllFailed: () => ipcRenderer.invoke('inbox:retryAllFailed'),
getFailedCount: () => ipcRenderer.invoke('inbox:failedCount'),
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),
loadOllamaSettings: () => ipcRenderer.invoke('inbox:loadOllamaSettings'),
saveOllamaSettings: (v: { endpoint: string; model: string }) => ipcRenderer.invoke('inbox:saveOllamaSettings', v),
// v0.2.7 Task 13 — 외부 (트레이) 에서 view 전환 요청 listener.
onNavigate: (cb: (view: 'inbox' | 'trash' | 'settings') => void) => {
const listener = (_e: unknown, view: 'inbox' | 'trash' | 'settings') => cb(view);
ipcRenderer.on('inbox:navigate', listener);
return () => ipcRenderer.off('inbox:navigate', listener);
},
// v0.2.7 자동 실행 (Task 22 통일) — 진단 정보 포함 응답
getAutostart: () => ipcRenderer.invoke('settings:autostart-state'),
setAutostart: (open: boolean) => ipcRenderer.invoke('settings:autostart-set', open),
// v0.2.7 백업/복원/동기화/텔레메트리 (Task 10) — 트레이 callback 의 IPC 대응
runBackup: () => ipcRenderer.invoke('settings:run-backup'),
runExport: () => ipcRenderer.invoke('settings:run-export'),
runImport: () => ipcRenderer.invoke('settings:run-import'),
runSync: () => ipcRenderer.invoke('settings:run-sync'),
runExportTelemetry: () => ipcRenderer.invoke('settings:run-export-telemetry'),
// v0.2.7 정보 섹션 (Task 11) — 트레이 showAboutDialog 의 IPC 대응
getAppInfo: () => ipcRenderer.invoke('settings:get-app-info'),
openProfileDir: () => ipcRenderer.invoke('settings:open-profile-dir'),
copyAppInfo: () => ipcRenderer.invoke('settings:copy-app-info'),
// v0.2.8 Cut A — 첨부 이미지를 OS 기본 뷰어로 열기 (Task 3).
openMedia: (relPath: string) => ipcRenderer.invoke('inbox:open-media', relPath),
}
};

View File

@@ -9,93 +9,178 @@ import { PendingBanner } from './components/PendingBanner.js';
import { OllamaBanner } from './components/OllamaBanner.js';
import { RecoveryToast } from './components/RecoveryToast.js';
import { TagUndoToast } from './components/TagUndoToast.js';
import { ExpiryBanner } from './components/ExpiryBanner.js';
import { FailedBanner } from './components/FailedBanner.js';
import { RecallBanner } from './components/RecallBanner.js';
import { SettingsPage } from './components/SettingsPage.js';
export function App(): React.ReactElement {
const {
notes,
loading,
loadInitial,
refreshMeta,
upsertNote,
removeNote,
continuity,
tagFilter,
setTagFilter
notes, trashNotes, trashCount, showTrash,
loading, loadInitial, refreshMeta, upsertNote, removeNote,
continuity, tagFilter, setTagFilter,
toggleShowTrash, restoreNote, permanentDeleteNote, emptyTrash
} = useInbox();
const showSettings = useInbox((s) => s.showSettings);
const setShowSettings = useInbox((s) => s.setShowSettings);
const [recoveryDismissed, setRecoveryDismissed] = useState(isRecoveryDismissedToday());
useEffect(() => {
void loadInitial();
const unsub = inboxApi.onNoteUpdated((note) => {
const unsubNote = inboxApi.onNoteUpdated((note) => {
upsertNote(note);
void refreshMeta();
});
const unsubOllama = inboxApi.onOllamaStatus((status) => {
useInbox.setState({ ollamaStatus: status });
});
const unsubNav = inboxApi.onNavigate((view) => {
if (view === 'settings') {
useInbox.getState().setShowSettings(true);
} else if (view === 'inbox') {
useInbox.getState().setShowSettings(false);
if (useInbox.getState().showTrash) void useInbox.getState().toggleShowTrash();
} else if (view === 'trash') {
useInbox.getState().setShowSettings(false);
if (!useInbox.getState().showTrash) void useInbox.getState().toggleShowTrash();
}
});
const onFocus = () => { void refreshMeta(); };
window.addEventListener('focus', onFocus);
return () => { unsub(); window.removeEventListener('focus', onFocus); };
return () => { unsubNote(); unsubOllama(); unsubNav(); window.removeEventListener('focus', onFocus); };
// onOllamaStatus 콜백은 useInbox.setState 직접 호출 — store reference 가 안정적이라
// deps array 에 추가 불필요. mount 시 1회 구독 + unmount 시 해제.
}, [loadInitial, refreshMeta, upsertNote]);
if (showSettings) return <SettingsPage />;
const showRecovery = continuity.showRecoveryToast && !recoveryDismissed;
const filtered = selectFilteredNotes({ notes, tagFilter });
const tabBtnStyle = (active: boolean): React.CSSProperties => ({
background: active ? '#0a4b80' : 'transparent',
color: active ? '#fff' : '#0a4b80',
border: '1px solid #0a4b80',
borderRadius: 4,
padding: '4px 10px',
fontSize: 12,
cursor: 'pointer'
});
return (
<>
<div className="header">
<h1 style={{ fontSize: 18, margin: 0 }}>Inkling</h1>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 2 }}>
<div style={{ display: 'flex', gap: 6, marginLeft: 12 }}>
<button
onClick={() => { if (showTrash) void toggleShowTrash(); }}
aria-pressed={!showTrash}
style={tabBtnStyle(!showTrash)}
>
Inbox({notes.length})
</button>
<button
onClick={() => { if (!showTrash) void toggleShowTrash(); }}
aria-pressed={showTrash}
style={tabBtnStyle(showTrash)}
>
({trashCount})
</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 2, marginLeft: 'auto' }}>
<ContinuityBadge />
<IdentityCounter />
</div>
<button
aria-label="설정 열기"
onClick={() => setShowSettings(true)}
style={{
background: 'transparent',
border: 'none',
cursor: 'pointer',
padding: 4,
fontSize: 16,
marginLeft: 8
}}
>
</button>
</div>
<main className="main">
<OllamaBanner />
<RecoveryToast
show={showRecovery}
onDismiss={() => { markRecoveryDismissed(); setRecoveryDismissed(true); }}
/>
<PendingBanner />
{tagFilter !== null && (
<div
style={{
background: '#eaf3ff',
color: '#0a4b80',
padding: '6px 12px',
borderRadius: 6,
margin: '8px 0',
fontSize: 12,
display: 'flex',
alignItems: 'center',
gap: 8
}}
>
<span>🔎 : <strong>#{tagFilter}</strong></span>
<span style={{ color: '#666' }}>({filtered.length})</span>
<button
onClick={() => setTagFilter(null)}
style={{
marginLeft: 'auto',
background: 'none',
border: 'none',
color: '#0a4b80',
cursor: 'pointer',
fontSize: 12
}}
title="필터 해제"
>
</button>
</div>
{!showTrash && (
<>
<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',
borderRadius: 6, margin: '8px 0', fontSize: 12,
display: 'flex', alignItems: 'center', gap: 8
}}>
<span>🔎 : <strong>#{tagFilter}</strong></span>
<span style={{ color: '#666' }}>({filtered.length})</span>
<button
onClick={() => setTagFilter(null)}
style={{ marginLeft: 'auto', background: 'none', border: 'none', color: '#0a4b80', cursor: 'pointer', fontSize: 12 }}
title="필터 해제"
>
</button>
</div>
)}
{loading && notes.length === 0 ? (
<div className="empty"> </div>
) : notes.length === 0 ? (
<div className="empty">릿 . <code>Ctrl+Shift+J</code></div>
) : filtered.length === 0 ? (
<div className="empty"> .</div>
) : (
filtered.map((n) => (
<NoteCard
key={n.id} note={n} mode="inbox"
onDeleted={() => removeNote(n.id)}
onUpdated={(u) => upsertNote(u)}
/>
))
)}
</>
)}
{loading && notes.length === 0 ? (
<div className="empty"> </div>
) : notes.length === 0 ? (
<div className="empty">릿 . <code>Ctrl+Shift+J</code></div>
) : filtered.length === 0 ? (
<div className="empty"> .</div>
) : (
filtered.map((n) => (
<NoteCard key={n.id} note={n} onDeleted={() => removeNote(n.id)} onUpdated={(u) => upsertNote(u)} />
))
{showTrash && (
<>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', margin: '8px 0' }}>
<div style={{ fontSize: 13, color: '#666' }}>
{trashCount === 0 ? '휴지통이 비어있습니다.' : `${trashCount}개 보관 중`}
</div>
<button
onClick={() => void emptyTrash()}
disabled={trashCount === 0}
style={{
background: trashCount === 0 ? '#666' : '#a33', color: '#fff',
border: 'none', borderRadius: 4, padding: '4px 10px',
fontSize: 12, cursor: trashCount === 0 ? 'not-allowed' : 'pointer'
}}
>
({trashCount})
</button>
</div>
{trashNotes.length === 0 ? null : (
trashNotes.map((n) => (
<NoteCard
key={n.id} note={n} mode="trash"
onUpdated={(u) => upsertNote(u)}
onRestore={() => void restoreNote(n.id)}
onPermanentDelete={() => void permanentDeleteNote(n.id)}
/>
))
)}
</>
)}
</main>
<TagUndoToast />

View File

@@ -0,0 +1,27 @@
import React from 'react';
/**
* v0.2.6 #24+#41 — 4 banner 의 inline style 중복 제거. severity 별 theme map.
*/
const THEMES = {
warning: { bg: '#fff7e6', border: '#d99500', text: '#946100' },
error: { bg: '#fce4e4', border: '#a33', text: '#a33' },
info: { bg: '#e8f0fe', border: '#4a7ec0', text: '#234' }
} as const;
interface Props {
severity: 'warning' | 'error' | 'info';
children: React.ReactNode;
}
export function Banner({ severity, children }: Props): React.ReactElement {
const t = THEMES[severity];
return (
<div style={{
background: t.bg, border: `1px solid ${t.border}`, color: t.text,
borderRadius: 6, padding: '8px 12px', margin: '8px 0', fontSize: 13
}}>
{children}
</div>
);
}

View File

@@ -0,0 +1,155 @@
import React, { useEffect, useState } from 'react';
import type { Note } from '@shared/types';
import { useInbox } from '../store.js';
import { Banner } from './Banner.js';
export function ExpiryBanner(): React.ReactElement | null {
const candidates = useInbox((s) => s.expiredCandidates);
const snoozeUntilMs = useInbox((s) => s.expiredSnoozeUntilMs);
const trashExpiredBatch = useInbox((s) => s.trashExpiredBatch);
const snoozeExpired = useInbox((s) => s.snoozeExpired);
// n1 fix — snoozeUntilMs 가 set 되어 있고 아직 미래면 그 시점에 force re-render 트리거.
// 24h+ 켜둔 상태에서 자정 KST 넘어 자동 collapse 해제 보장.
const [, setTick] = useState(0);
useEffect(() => {
if (snoozeUntilMs === null) return;
const remaining = snoozeUntilMs - Date.now();
if (remaining <= 0) return;
const t = setTimeout(() => setTick((n) => n + 1), remaining);
return () => clearTimeout(t);
}, [snoozeUntilMs]);
// Q5=A: 0건 / snooze 활성 시 collapse
if (candidates.length === 0) return null;
if (snoozeUntilMs !== null && Date.now() < snoozeUntilMs) return null;
return <ExpiryBannerInner
candidates={candidates}
onTrash={(ids) => {
trashExpiredBatch(ids).catch((e) => {
// eslint-disable-next-line no-console
console.warn('trashExpiredBatch failed', e);
});
}}
onSnooze={() => snoozeExpired()}
/>;
}
interface InnerProps {
candidates: Note[];
onTrash: (ids: string[]) => void;
onSnooze: () => void;
}
function ExpiryBannerInner({ candidates, onTrash, onSnooze }: InnerProps): React.ReactElement {
const [expanded, setExpanded] = useState<boolean>(true);
const [selected, setSelected] = useState<Set<string>>(new Set());
// candidates 가 변하면 selected 의 stale id 정리
useEffect(() => {
const valid = new Set(candidates.map((c) => c.id));
setSelected((prev) => {
const next = new Set<string>();
for (const id of prev) if (valid.has(id)) next.add(id);
return next;
});
}, [candidates]);
const allSelected = candidates.length > 0 && candidates.every((c) => selected.has(c.id));
const someSelected = selected.size > 0 && !allSelected;
function toggleAll() {
if (allSelected) setSelected(new Set());
else setSelected(new Set(candidates.map((c) => c.id)));
}
function toggle(id: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
return (
<Banner severity="warning">
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span> <b> {candidates.length}</b></span>
<button
onClick={() => setExpanded((e) => !e)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#946100' }}
aria-expanded={expanded}
>
{expanded ? '▲ 접기' : '▼ 펼치기'}
</button>
<button
onClick={onSnooze}
style={{
marginLeft: 'auto',
background: 'transparent', color: '#946100',
border: '1px solid #d99500', borderRadius: 4,
padding: '2px 8px', fontSize: 12, cursor: 'pointer'
}}
>
</button>
</div>
{expanded && (
<>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, margin: '8px 0 4px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={allSelected}
ref={(el) => { if (el) el.indeterminate = someSelected; }}
onChange={toggleAll}
/>
<span style={{ color: '#666' }}> ({selected.size}/{candidates.length})</span>
</label>
<div>
{candidates.map((n) => (
<label
key={n.id}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '4px 0', cursor: 'pointer'
}}
>
<input
type="checkbox"
checked={selected.has(n.id)}
onChange={() => toggle(n.id)}
/>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{n.aiTitle ?? n.rawText.slice(0, 60)}
</span>
<span style={{ color: '#946100', fontSize: 12 }}>due {n.dueDate}</span>
{n.tags[0] && (
<span style={{
background: '#fce8b2', color: '#946100', padding: '0 6px',
borderRadius: 10, fontSize: 11
}}>
#{n.tags[0].name}
</span>
)}
</label>
))}
</div>
<button
onClick={() => onTrash(Array.from(selected))}
disabled={selected.size === 0}
style={{
marginTop: 8,
background: selected.size === 0 ? '#999' : '#a33', color: '#fff',
border: 'none', borderRadius: 4,
padding: '4px 12px', fontSize: 12,
cursor: selected.size === 0 ? 'not-allowed' : 'pointer'
}}
>
({selected.size})
</button>
</>
)}
</Banner>
);
}

View File

@@ -0,0 +1,31 @@
import React from 'react';
import { useInbox } from '../store.js';
import { Banner } from './Banner.js';
export function FailedBanner(): React.ReactElement | null {
const count = useInbox((s) => s.failedCount);
const retryAllFailed = useInbox((s) => s.retryAllFailed);
if (count === 0) return null;
return (
<Banner severity="error">
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ flex: 1 }}> AI <b>{count}</b></span>
<button
onClick={() => {
retryAllFailed().catch((e) => {
// eslint-disable-next-line no-console
console.warn('retryAllFailed failed', e);
});
}}
style={{
background: '#a33', color: '#fff',
border: 'none', borderRadius: 4,
padding: '4px 12px', fontSize: 12, cursor: 'pointer'
}}
>
</button>
</div>
</Banner>
);
}

View File

@@ -8,8 +8,11 @@ import { pushTagUndo } from './TagUndoToast.js';
interface Props {
note: Note;
onDeleted: () => void;
onDeleted?: () => void; // inbox mode 전용 (trash mode 에서 미사용)
onUpdated: (n: Note) => void;
mode?: 'inbox' | 'trash'; // default 'inbox'
onRestore?: () => void;
onPermanentDelete?: () => void;
}
const aiBadgeStyle: React.CSSProperties = {
@@ -104,7 +107,8 @@ function DueDateBadge({
);
}
export function NoteCard({ note, onDeleted, onUpdated }: Props): React.ReactElement {
export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore, onPermanentDelete }: Props): React.ReactElement {
const isTrash = mode === 'trash';
const [rawOpen, setRawOpen] = useState(note.aiStatus !== 'done');
const [local, setLocal] = useState(note);
@@ -115,7 +119,7 @@ export function NoteCard({ note, onDeleted, onUpdated }: Props): React.ReactElem
async function handleDelete() {
if (!window.confirm('이 기억을 버릴까요? 되돌릴 수 없습니다.')) return;
await inboxApi.deleteNote(note.id);
onDeleted();
onDeleted?.();
}
async function saveTitle(next: string) {
@@ -180,10 +184,11 @@ export function NoteCard({ note, onDeleted, onUpdated }: Props): React.ReactElem
const showIntentBanner = local.aiStatus === 'done' && local.intentPromptedAt === null;
return (
<div style={{ background: 'white', padding: 16, marginBottom: 12, borderRadius: 10, boxShadow: '0 1px 2px rgba(0,0,0,0.04)' }}>
// id load-bearing — RecallBanner 의 scrollIntoView target (#6 v0.2.3)
<div id={`note-${note.id}`} style={{ background: 'white', padding: 16, marginBottom: 12, borderRadius: 10, boxShadow: '0 1px 2px rgba(0,0,0,0.04)' }}>
<div style={{ fontSize: 11, color: '#888' }}>{formatted}</div>
{showIntentBanner && (
{!isTrash && showIntentBanner && (
<IntentBanner
noteId={note.id}
onResolved={(intentText) => {
@@ -206,94 +211,145 @@ export function NoteCard({ note, onDeleted, onUpdated }: Props): React.ReactElem
)}
{local.aiStatus === 'done' && (
<>
<div style={{ marginTop: 4 }}>
<EditableField
value={local.aiTitle ?? ''}
onSave={saveTitle}
style={{ display: 'inline-block', fontSize: 16, fontWeight: 600 }}
singleLine
/>
{!local.titleEditedByUser && <span style={aiBadgeStyle} title="AI 제안">AI</span>}
</div>
<div style={{ marginTop: 6 }}>
<EditableField
value={local.aiSummary ?? ''}
onSave={saveSummary}
style={{ fontSize: 13, color: '#333', whiteSpace: 'pre-wrap' }}
singleLine={false}
/>
{!local.summaryEditedByUser && <span style={aiBadgeStyle} title="AI 제안">AI</span>}
</div>
<div style={{ marginTop: 6 }}>
<DueDateBadge
value={local.dueDate}
isEdited={local.dueDateEditedByUser}
today={todayKstIso()}
onSave={saveDueDate}
/>
</div>
{local.tags.length > 0 && (
<div style={{ marginTop: 8, display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{local.tags.map((t) => (
<span
key={t.name}
style={{
background: t.source === 'ai' ? '#eaf3ff' : '#e9f9e4',
color: t.source === 'ai' ? '#0a4b80' : '#236b1a',
padding: '2px 4px 2px 8px',
borderRadius: 12,
fontSize: 12,
display: 'inline-flex',
alignItems: 'center',
gap: 4
}}
>
<span
onClick={() => filterByTag(t.name)}
style={{ cursor: 'pointer' }}
title={`#${t.name} 노트만 보기`}
>
{t.name}{t.source === 'ai' && <sub style={{ marginLeft: 3, fontSize: 9 }}>AI</sub>}
</span>
<button
onClick={(e) => { e.stopPropagation(); void removeTag(t.name); }}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
color: 'inherit',
fontSize: 14,
padding: '0 2px',
lineHeight: 1,
opacity: 0.6
}}
title="태그 제거"
aria-label={`${t.name} 태그 제거`}
>
×
</button>
</span>
))}
</div>
)}
{local.userIntent !== null && (
<div style={{ marginTop: 10, padding: 8, background: '#fffbe9', borderRadius: 6 }}>
<span style={{ fontSize: 12, color: '#7a5a00', marginRight: 6 }}>💡</span>
<EditableField
value={local.userIntent}
onSave={saveIntent}
style={{ display: 'inline-block', fontSize: 13, color: '#444' }}
singleLine
/>
</div>
{isTrash ? (
<>
<div style={{ marginTop: 4 }}>
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 600 }}>{local.aiTitle ?? '(제목 없음)'}</h3>
</div>
<div style={{ marginTop: 6, fontSize: 13, color: '#333', whiteSpace: 'pre-wrap' }}>
{local.aiSummary ?? '(요약 없음)'}
</div>
{local.dueDate !== null && (
<div style={{ marginTop: 6 }}>
<span style={{ fontSize: 11, color: '#666' }}>📅 {local.dueDate}</span>
</div>
)}
{local.tags.length > 0 && (
<div style={{ marginTop: 8, display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{local.tags.map((t) => (
<span
key={t.name}
style={{
background: t.source === 'ai' ? '#eaf3ff' : '#e9f9e4',
color: t.source === 'ai' ? '#0a4b80' : '#236b1a',
padding: '2px 8px',
borderRadius: 12,
fontSize: 12
}}
>
{t.name}{t.source === 'ai' && <sub style={{ marginLeft: 3, fontSize: 9 }}>AI</sub>}
</span>
))}
</div>
)}
</>
) : (
<>
<div style={{ marginTop: 4 }}>
<EditableField
value={local.aiTitle ?? ''}
onSave={saveTitle}
style={{ display: 'inline-block', fontSize: 16, fontWeight: 600 }}
singleLine
/>
{!local.titleEditedByUser && <span style={aiBadgeStyle} title="AI 제안">AI</span>}
</div>
<div style={{ marginTop: 6 }}>
<EditableField
value={local.aiSummary ?? ''}
onSave={saveSummary}
style={{ fontSize: 13, color: '#333', whiteSpace: 'pre-wrap' }}
singleLine={false}
/>
{!local.summaryEditedByUser && <span style={aiBadgeStyle} title="AI 제안">AI</span>}
</div>
<div style={{ marginTop: 6 }}>
<DueDateBadge
value={local.dueDate}
isEdited={local.dueDateEditedByUser}
today={todayKstIso()}
onSave={saveDueDate}
/>
</div>
{local.tags.length > 0 && (
<div style={{ marginTop: 8, display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{local.tags.map((t) => (
<span
key={t.name}
style={{
background: t.source === 'ai' ? '#eaf3ff' : '#e9f9e4',
color: t.source === 'ai' ? '#0a4b80' : '#236b1a',
padding: '2px 4px 2px 8px',
borderRadius: 12,
fontSize: 12,
display: 'inline-flex',
alignItems: 'center',
gap: 4
}}
>
<span
onClick={() => filterByTag(t.name)}
style={{ cursor: 'pointer' }}
title={`#${t.name} 노트만 보기`}
>
{t.name}{t.source === 'ai' && <sub style={{ marginLeft: 3, fontSize: 9 }}>AI</sub>}
</span>
<button
onClick={(e) => { e.stopPropagation(); void removeTag(t.name); }}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
color: 'inherit',
fontSize: 14,
padding: '0 2px',
lineHeight: 1,
opacity: 0.6
}}
title="태그 제거"
aria-label={`${t.name} 태그 제거`}
>
×
</button>
</span>
))}
</div>
)}
{local.userIntent !== null && (
<div style={{ marginTop: 10, padding: 8, background: '#fffbe9', borderRadius: 6 }}>
<span style={{ fontSize: 12, color: '#7a5a00', marginRight: 6 }}>💡</span>
<EditableField
value={local.userIntent}
onSave={saveIntent}
style={{ display: 'inline-block', fontSize: 13, color: '#444' }}
singleLine
/>
</div>
)}
</>
)}
</>
)}
{local.media.length > 0 && (
<div style={{ marginTop: 10, display: 'flex', gap: 6 }}>
<div style={{ marginTop: 10, display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{local.media.map((m) => (
<div key={m.id} style={{ width: 48, height: 48, background: '#eee', borderRadius: 4 }} title={m.relPath} />
// alt="" — decorative (relPath 는 사용자 의미 X). title 이 hover tooltip.
<img
key={m.id}
src={`inkling-media://${m.relPath}`}
alt=""
title={m.relPath}
onClick={() => { void inboxApi.openMedia(m.relPath); }}
style={{
width: 48,
height: 48,
objectFit: 'cover',
borderRadius: 4,
cursor: 'pointer',
border: '1px solid #e0e0e0'
}}
/>
))}
</div>
)}
@@ -310,9 +366,32 @@ export function NoteCard({ note, onDeleted, onUpdated }: Props): React.ReactElem
</div>
<div style={{ marginTop: 10, textAlign: 'right' }}>
<button onClick={() => void handleDelete()} style={{ background: 'none', border: 'none', color: '#c93030', cursor: 'pointer', fontSize: 12 }}>
🗑
</button>
{isTrash ? (
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button
onClick={onRestore}
style={{
background: 'none', border: '1px solid #0a4b80', color: '#0a4b80',
cursor: 'pointer', fontSize: 12, padding: '4px 10px', borderRadius: 4
}}
>
🔄
</button>
<button
onClick={onPermanentDelete}
style={{
background: 'none', border: '1px solid #c93030', color: '#c93030',
cursor: 'pointer', fontSize: 12, padding: '4px 10px', borderRadius: 4
}}
>
🗑
</button>
</div>
) : (
<button onClick={() => void handleDelete()} style={{ background: 'none', border: 'none', color: '#c93030', cursor: 'pointer', fontSize: 12 }}>
🗑
</button>
)}
</div>
</div>
);

View File

@@ -1,21 +1,59 @@
import React from 'react';
import { useInbox } from '../store.js';
import { Banner } from './Banner.js';
export function OllamaBanner(): React.ReactElement | null {
interface OllamaBannerProps {
onOpenSettings?: () => void;
}
export function OllamaBanner({ onOpenSettings }: OllamaBannerProps = {}): React.ReactElement | null {
const status = useInbox((s) => s.ollamaStatus);
const recheckOllama = useInbox((s) => s.recheckOllama);
if (status.ok) return null;
const isMissing = status.reason?.includes('not installed');
const message = isMissing
? '`ollama pull gemma4:e4b` 실행 후 앱을 재시작해주세요.'
: 'Inkling 정리가 잠시 멈췄습니다. Ollama를 실행해주세요.';
return (
<div className="banner warn" style={{ flexDirection: 'column', alignItems: 'flex-start' }}>
<span> {message}</span>
<Banner severity="warning">
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%' }}>
<span style={{ flex: 1 }}> {message}</span>
<button
onClick={() => {
recheckOllama().catch((e) => {
// eslint-disable-next-line no-console
console.warn('recheckOllama failed', e);
});
}}
style={{
background: 'transparent', color: '#946100',
border: '1px solid #d99500', borderRadius: 4,
padding: '2px 8px', fontSize: 12, cursor: 'pointer'
}}
>
</button>
{onOpenSettings && (
<button
onClick={onOpenSettings}
style={{
background: 'transparent', color: 'inherit',
border: '1px solid currentColor', borderRadius: 4,
padding: '2px 8px', fontSize: 12, cursor: 'pointer',
marginLeft: 6
}}
>
</button>
)}
</div>
{status.reason ? (
<span style={{ fontSize: 11, opacity: 0.7, marginTop: 4 }}>
: {status.reason}
</span>
) : null}
</div>
</div>
</Banner>
);
}

View File

@@ -0,0 +1,98 @@
import React, { useEffect, useRef, useState } from 'react';
import { useInbox } from '../store.js';
import { inboxApi } from '../api.js';
import { Banner } from './Banner.js';
export function RecallBanner(): React.ReactElement | null {
const candidate = useInbox((s) => s.recallCandidate);
const snoozeUntilMs = useInbox((s) => s.recallSnoozeUntilMs);
const openRecall = useInbox((s) => s.openRecall);
const dismissRecallNote = useInbox((s) => s.dismissRecallNote);
const snoozeRecall = useInbox((s) => s.snoozeRecall);
// i1 fix — shownIds 를 useRef 로 관리해 race 차단 (setState 트리거 X)
// 같은 RecallBanner 컴포넌트 인스턴스 동안 per-noteId 1회 emit 보장.
// 컴포넌트 언마운트/리마운트 시 reset (session-local 의도).
const shownIdsRef = useRef<Set<string>>(new Set());
// ExpiryBanner 패턴 — snoozeUntilMs 만료 시 force re-render
const [, setTick] = useState(0);
useEffect(() => {
if (snoozeUntilMs === null) return;
const remaining = snoozeUntilMs - Date.now();
if (remaining <= 0) return;
const t = setTimeout(() => setTick((n) => n + 1), remaining);
return () => clearTimeout(t);
}, [snoozeUntilMs]);
// first-render emit recall_shown (per-banner-lifetime 1회 per note)
useEffect(() => {
if (!candidate) return;
if (snoozeUntilMs !== null && Date.now() < snoozeUntilMs) return;
if (shownIdsRef.current.has(candidate.id)) return;
void inboxApi.emitRecallShown(candidate.id);
shownIdsRef.current.add(candidate.id);
}, [candidate, snoozeUntilMs]);
if (candidate === null) return null;
if (snoozeUntilMs !== null && Date.now() < snoozeUntilMs) return null;
const ageDays = computeAgeDays(candidate.lastRecalledAt ?? candidate.createdAt);
// m4 fix — rawText 와 aiTitle 모두 비었을 때 빈 제목 방지
const title = candidate.aiTitle?.trim() || candidate.rawText.trim().slice(0, 60) || '(제목 없음)';
function onOpen() {
void openRecall(candidate!.id);
const el = document.getElementById(`note-${candidate!.id}`);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
return (
<Banner severity="info">
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span>💭 <b> </b></span>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: '#234' }}>
{title}
</span>
<span style={{ color: '#6a7e9a', fontSize: 12 }}>{ageDays} </span>
</div>
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
<button
onClick={onOpen}
style={{
background: '#4a7ec0', color: '#fff',
border: 'none', borderRadius: 4,
padding: '4px 12px', fontSize: 12, cursor: 'pointer'
}}
>
</button>
<button
onClick={() => void snoozeRecall()}
style={{
background: 'transparent', color: '#4a7ec0',
border: '1px solid #4a7ec0', borderRadius: 4,
padding: '4px 12px', fontSize: 12, cursor: 'pointer'
}}
>
</button>
<button
onClick={() => void dismissRecallNote(candidate.id)}
style={{
marginLeft: 'auto',
background: 'transparent', color: '#888',
border: 'none', fontSize: 12, cursor: 'pointer'
}}
>
</button>
</div>
</Banner>
);
}
function computeAgeDays(refIso: string): number {
const refMs = new Date(refIso).getTime();
return Math.max(0, Math.floor((Date.now() - refMs) / 86_400_000));
}

View File

@@ -0,0 +1,45 @@
import React from 'react';
import { useInbox } from '../store.js';
import { AiProviderSection } from './settings/AiProviderSection.js';
import { AutostartSection } from './settings/AutostartSection.js';
import { BackupSection } from './settings/BackupSection.js';
import { InfoSection } from './settings/InfoSection.js';
export function SettingsPage(): React.ReactElement {
const setShowSettings = useInbox((s) => s.setShowSettings);
return (
<div style={{ padding: 16, maxWidth: 720, margin: '0 auto' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16 }}>
<button
onClick={() => setShowSettings(false)}
style={{
background: 'transparent',
border: 'none',
fontSize: 14,
cursor: 'pointer',
color: '#0a4b80'
}}
>
</button>
<h1 style={{ fontSize: 18, margin: 0 }}></h1>
</div>
<section style={{ marginBottom: 24 }}>
<h2 style={{ fontSize: 14, marginBottom: 8 }}>AI </h2>
<AiProviderSection />
</section>
<section style={{ marginBottom: 24 }}>
<h2 style={{ fontSize: 14, marginBottom: 8 }}> </h2>
<AutostartSection />
</section>
<section style={{ marginBottom: 24 }}>
<h2 style={{ fontSize: 14, marginBottom: 8 }}> / </h2>
<BackupSection />
</section>
<section style={{ marginBottom: 24 }}>
<h2 style={{ fontSize: 14, marginBottom: 8 }}></h2>
<InfoSection />
</section>
</div>
);
}

View File

@@ -0,0 +1,131 @@
import React, { useEffect, useState } from 'react';
import { z } from 'zod';
import { inboxApi } from '../../api.js';
const endpointSchema = z.string().url();
export function AiProviderSection(): React.ReactElement {
const [endpoint, setEndpoint] = useState('');
const [model, setModel] = useState('');
const [error, setError] = useState<string | null>(null);
const [saveResult, setSaveResult] = useState<string | null>(null);
const [recheckResult, setRecheckResult] = useState<string | null>(null);
useEffect(() => {
void (async () => {
const s = await inboxApi.loadOllamaSettings();
if (s) {
setEndpoint(s.endpoint);
setModel(s.model);
}
})();
}, []);
async function onSave(): Promise<void> {
const r = endpointSchema.safeParse(endpoint);
if (!r.success) {
setError('올바른 URL 형식이 아닙니다 (예: http://localhost:11434)');
setSaveResult(null);
return;
}
if (model.trim() === '') {
setError('모델 이름을 입력해주세요');
setSaveResult(null);
return;
}
setError(null);
const result = await inboxApi.saveOllamaSettings({ endpoint, model });
if (result.ok) {
setSaveResult('저장됨');
} else {
setSaveResult(null);
setError(`저장 실패: ${result.reason}`);
}
}
async function onRecheck(): Promise<void> {
setRecheckResult('확인 중...');
const r = await inboxApi.ollamaRecheck();
setRecheckResult(r.ok ? '연결됨' : `연결 실패: ${r.reason ?? '알 수 없는 이유'}`);
}
return (
<div>
<label style={{ display: 'block', marginBottom: 8, fontSize: 12, color: '#666' }}>
Endpoint
<input
type="text"
value={endpoint}
onChange={(e) => setEndpoint(e.target.value)}
placeholder="http://localhost:11434"
style={{
display: 'block',
width: '100%',
padding: '6px 8px',
marginTop: 4,
fontSize: 13,
border: '1px solid #ccc',
borderRadius: 4
}}
/>
</label>
<label style={{ display: 'block', marginBottom: 8, fontSize: 12, color: '#666' }}>
Model
<input
type="text"
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder="gemma2:2b"
style={{
display: 'block',
width: '100%',
padding: '6px 8px',
marginTop: 4,
fontSize: 13,
border: '1px solid #ccc',
borderRadius: 4
}}
/>
</label>
{error && (
<div style={{ color: '#c33', fontSize: 12, marginBottom: 8 }}>{error}</div>
)}
{saveResult && (
<div style={{ fontSize: 12, marginBottom: 8, color: '#0a4b80' }}>{saveResult}</div>
)}
<div style={{ display: 'flex', gap: 8 }}>
<button
onClick={() => void onSave()}
style={{
background: '#0a4b80',
color: '#fff',
border: 'none',
borderRadius: 4,
padding: '6px 14px',
fontSize: 12,
cursor: 'pointer'
}}
>
</button>
<button
onClick={() => void onRecheck()}
style={{
background: 'transparent',
color: '#0a4b80',
border: '1px solid #0a4b80',
borderRadius: 4,
padding: '6px 14px',
fontSize: 12,
cursor: 'pointer'
}}
>
</button>
</div>
{recheckResult && (
<div style={{ fontSize: 12, marginTop: 8 }}>{recheckResult}</div>
)}
</div>
);
}

View File

@@ -0,0 +1,95 @@
import React, { useEffect, useState } from 'react';
import type { AutostartResponse } from '@shared/types';
import { inboxApi } from '../../api.js';
export function AutostartSection(): React.ReactElement {
const [data, setData] = useState<AutostartResponse | null>(null);
const [expanded, setExpanded] = useState(false);
useEffect(() => {
void (async () => {
const r = await inboxApi.getAutostart();
setData(r);
})();
}, []);
async function onToggle(e: React.ChangeEvent<HTMLInputElement>): Promise<void> {
const r = await inboxApi.setAutostart(e.target.checked);
setData(r);
}
// Task 24 — 현재 openAtLogin 값으로 다시 setLoginItemSettings 호출 → mismatch 복구.
// (예: registry 누락된 채로 withArgs.openAtLogin=true 인 경우 등.)
async function onReregister(): Promise<void> {
if (!data) return;
const r = await inboxApi.setAutostart(data.openAtLogin);
setData(r);
}
if (data === null) {
return <div style={{ fontSize: 12, color: '#666' }}> ...</div>;
}
const d = data.diagnostic;
// v0.2.7 F12 deeper fix — withArgs vs noArgs 의 openAtLogin 불일치, 또는
// executableWillLaunchAtLogin = false 면 mismatch 로 간주 (등록은 됐지만 실제론
// 로그인 시 실행되지 않을 수 있는 상태).
const mismatch = d.withArgs.openAtLogin !== d.noArgs.openAtLogin
|| (data.openAtLogin && !d.withArgs.executableWillLaunchAtLogin);
return (
<div>
<label style={{ display: 'flex', gap: 8, alignItems: 'center', fontSize: 13 }}>
<input type="checkbox" checked={data.openAtLogin} onChange={onToggle} />
</label>
{mismatch && (
<div style={{ color: '#c33', fontSize: 12, marginTop: 4 }}>
.
</div>
)}
<div style={{ marginTop: 6 }}>
<button
onClick={() => { void onReregister(); }}
style={{ fontSize: 12, padding: '4px 10px' }}
>
</button>
</div>
<button
onClick={() => setExpanded(!expanded)}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
fontSize: 12,
color: '#0a4b80',
marginTop: 4,
padding: 0
}}
>
{expanded ? '▾' : '▸'}
</button>
{expanded && (
<div
style={{
fontSize: 11,
lineHeight: 1.6,
marginTop: 4,
fontFamily: 'monospace',
background: '#f5f5f5',
padding: 8,
borderRadius: 4,
wordBreak: 'break-all'
}}
>
<div> (--hidden ): openAtLogin={String(d.withArgs.openAtLogin)}, willLaunch={String(d.withArgs.executableWillLaunchAtLogin)}</div>
<div> ( ): openAtLogin={String(d.noArgs.openAtLogin)}, willLaunch={String(d.noArgs.executableWillLaunchAtLogin)}</div>
<div> : {d.execPath}</div>
{d.registryPath !== undefined && <div>registry : {d.registryPath}</div>}
{d.registryValue !== undefined && <div>registry : {d.registryValue ?? '(없음)'}</div>}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,25 @@
import React, { useState } from 'react';
import { inboxApi } from '../../api.js';
export function BackupSection(): React.ReactElement {
const [status, setStatus] = useState<string | null>(null);
// IPC 핸들러 (settingsApi.ts) 가 자체 try/catch + Notification 으로 결과를 사용자에게 알림.
// 이 컴포넌트의 status 는 보조 진행 표시 — 결과 (성공/실패) 는 native UX 에 의존.
async function run(label: string, fn: () => Promise<unknown>): Promise<void> {
setStatus(`${label}: 진행 중...`);
await fn();
setStatus(null);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<button onClick={() => run('지금 백업', () => inboxApi.runBackup())}> </button>
<button onClick={() => run('내보내기', () => inboxApi.runExport())}>...</button>
<button onClick={() => run('백업에서 복원', () => inboxApi.runImport())}> ...</button>
<button onClick={() => run('지금 동기화', () => inboxApi.runSync())}> </button>
<button onClick={() => run('사용 로그 내보내기', () => inboxApi.runExportTelemetry())}> ...</button>
{status && <div style={{ fontSize: 12 }}>{status}</div>}
</div>
);
}

View File

@@ -0,0 +1,41 @@
import React, { useEffect, useState } from 'react';
import { inboxApi } from '../../api.js';
interface AppInfo {
version: string;
electron: string;
node: string;
os: string;
profileDir: string;
}
export function InfoSection(): React.ReactElement {
const [info, setInfo] = useState<AppInfo | null>(null);
useEffect(() => {
void (async () => setInfo(await inboxApi.getAppInfo()))();
}, []);
if (!info) return <div style={{ fontSize: 12 }}> ...</div>;
return (
<div>
<dl style={{ fontSize: 12, lineHeight: 1.6 }}>
<dt style={{ fontWeight: 600 }}></dt>
<dd>{info.version}</dd>
<dt style={{ fontWeight: 600 }}>Electron</dt>
<dd>{info.electron}</dd>
<dt style={{ fontWeight: 600 }}>Node</dt>
<dd>{info.node}</dd>
<dt style={{ fontWeight: 600 }}>OS</dt>
<dd>{info.os}</dd>
<dt style={{ fontWeight: 600 }}> </dt>
<dd style={{ wordBreak: 'break-all' }}>{info.profileDir}</dd>
</dl>
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<button onClick={() => void inboxApi.openProfileDir()}> </button>
<button onClick={() => void inboxApi.copyAppInfo()}> </button>
</div>
</div>
);
}

View File

@@ -1,22 +1,47 @@
import { create } from 'zustand';
import type { Note, WeeklyContinuity } from '@shared/types';
import { inboxApi } from './api.js';
import { nextKstMidnightMs } from '@shared/util/kstDate.js';
export { selectFilteredNotes } from './selectFilteredNotes.js';
interface InboxState {
notes: Note[];
trashNotes: Note[];
trashCount: number;
showTrash: boolean;
showSettings: boolean;
continuity: WeeklyContinuity;
pendingCount: number;
ollamaStatus: { ok: boolean; reason?: string };
todayCount: number;
loading: boolean;
tagFilter: string | null;
expiredCandidates: Note[];
expiredSnoozeUntilMs: number | null;
failedCount: number;
recallCandidate: Note | null;
recallSnoozeUntilMs: number | null;
loadInitial: () => Promise<void>;
refreshMeta: () => Promise<void>;
upsertNote: (note: Note) => void;
removeNote: (id: string) => void;
setTagFilter: (tag: string | null) => void;
setShowSettings: (open: boolean) => 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>;
retryAllFailed: () => Promise<void>;
loadRecallCandidate: () => Promise<void>;
openRecall: (id: string) => Promise<void>;
dismissRecallNote: (id: string) => Promise<void>;
snoozeRecall: () => Promise<void>;
}
const emptyContinuity: WeeklyContinuity = {
@@ -26,46 +51,175 @@ const emptyContinuity: WeeklyContinuity = {
export const useInbox = create<InboxState>((set, get) => ({
notes: [],
trashNotes: [],
trashCount: 0,
showTrash: false,
showSettings: false,
continuity: emptyContinuity,
pendingCount: 0,
ollamaStatus: { ok: true },
todayCount: 0,
loading: false,
tagFilter: null,
expiredCandidates: [],
expiredSnoozeUntilMs: null,
failedCount: 0,
recallCandidate: null,
recallSnoozeUntilMs: null,
async loadInitial() {
set({ loading: true });
const [notes, continuity, pendingCount, ollamaStatus, todayCount] = await Promise.all([
const [notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate] = await Promise.all([
inboxApi.listNotes({ limit: 50 }),
inboxApi.getContinuity(),
inboxApi.getPendingCount(),
inboxApi.getOllamaStatus(),
inboxApi.getTodayCount()
inboxApi.getTodayCount(),
inboxApi.getTrashCount(),
inboxApi.listExpired(),
inboxApi.getFailedCount(),
inboxApi.listRecallCandidate()
]);
set({ notes, continuity, pendingCount, ollamaStatus, todayCount, loading: false });
set({ notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, loading: false });
},
async refreshMeta() {
const [continuity, pendingCount, ollamaStatus, todayCount] = await Promise.all([
const [continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate] = await Promise.all([
inboxApi.getContinuity(),
inboxApi.getPendingCount(),
inboxApi.getOllamaStatus(),
inboxApi.getTodayCount()
inboxApi.getTodayCount(),
inboxApi.getTrashCount(),
inboxApi.listExpired(),
inboxApi.getFailedCount(),
inboxApi.listRecallCandidate()
]);
set({ continuity, pendingCount, ollamaStatus, todayCount });
set({ continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate });
},
upsertNote(note) {
const i = get().notes.findIndex((n) => n.id === note.id);
if (i >= 0) {
const next = get().notes.slice();
next[i] = note;
set({ notes: next });
// 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;
else nextTrash.unshift(note);
set({
notes: cleanNotes,
trashNotes: nextTrash,
...(showTrash ? { trashCount: nextTrash.length } : {})
});
} else {
set({ notes: [note, ...get().notes] });
// 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 } : {})
});
}
},
removeNote(id) {
set({ notes: get().notes.filter((n) => n.id !== id) });
const cleanNotes = get().notes.filter((n) => n.id !== id);
const cleanTrash = get().trashNotes.filter((n) => n.id !== id);
const showTrash = get().showTrash;
set({
notes: cleanNotes,
trashNotes: cleanTrash,
...(showTrash ? { trashCount: cleanTrash.length } : {})
});
},
setTagFilter(tag) {
set({ tagFilter: tag });
},
setShowSettings(open) {
set({ showSettings: open });
},
async toggleShowTrash() {
const next = !get().showTrash;
set({ showTrash: next });
if (next) await get().loadTrash();
},
async loadTrash() {
const trashNotes = await inboxApi.listTrash({ limit: 200 });
set({ trashNotes, trashCount: trashNotes.length });
},
async restoreNote(id) {
await inboxApi.restoreNote(id);
// 낙관적 갱신: main 은 trash/restore 시 pushNoteUpdated 를 보내지 않음
// (현재 AiWorker.onUpdate 만 push). 자가 반영이 primary 메커니즘.
// 전제: 호출 시점에 trashNotes 에 노트가 존재 (T14 trash view 한정 호출).
const note = get().trashNotes.find((n) => n.id === id);
if (note) {
get().upsertNote({ ...note, deletedAt: null });
}
},
async permanentDeleteNote(id) {
const r = await inboxApi.permanentDeleteNote(id);
if (r.confirmed) get().removeNote(id);
},
async emptyTrash() {
const r = await inboxApi.emptyTrash();
if (r.confirmed) {
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;
const idSet = new Set(ids);
set({
expiredCandidates: get().expiredCandidates.filter((n) => !idSet.has(n.id)),
notes: get().notes.filter((n) => !idSet.has(n.id)),
trashCount: get().trashCount + r.trashedCount
});
},
snoozeExpired() {
set({ expiredSnoozeUntilMs: nextKstMidnightMs(Date.now()) });
},
async recheckOllama() {
const status = await inboxApi.ollamaRecheck();
set({ ollamaStatus: status });
},
async retryAllFailed() {
await inboxApi.retryAllFailed();
// 낙관적 갱신: failedCount = 0. AiWorker 처리 진행 중에 PendingBanner 가 N건 노출.
// refreshMeta 가 트리거되면 자연 동기 (worker.onUpdate → main → renderer).
// 반환된 r.count 는 의도적으로 무시 — 단일 process 환경 (Electron) 이라 race 무관,
// 모든 ai_status='failed' 가 retry 대상이므로 사용자 시점 카운트는 0 으로 reset 가 정확.
set({ failedCount: 0 });
},
async loadRecallCandidate() {
const recallCandidate = await inboxApi.listRecallCandidate();
set({ recallCandidate });
},
async openRecall(id) {
await inboxApi.markRecallOpened(id);
const recallCandidate = await inboxApi.listRecallCandidate();
set({ recallCandidate });
},
async dismissRecallNote(id) {
await inboxApi.dismissRecall(id);
const recallCandidate = await inboxApi.listRecallCandidate();
// m2 fix — dismiss 후 새 candidate 가 들어와도 이전 snooze 가 적용되지 않도록 clear
set({ recallCandidate, recallSnoozeUntilMs: null });
},
async snoozeRecall() {
set({ recallSnoozeUntilMs: nextKstMidnightMs(Date.now()) });
// m1 fix — candidate=null 인 race 케이스 (사용자가 banner 닫힌 직후 클릭) 시
// snooze 는 적용하되 emit 만 skip. telemetry 누락 받아들임 (의도적).
const candidate = get().recallCandidate;
if (candidate) {
await inboxApi.emitRecallSnoozed(candidate.id);
}
}
}));

2
src/shared/constants.ts Normal file
View File

@@ -0,0 +1,2 @@
export const DEFAULT_OLLAMA_MODEL = 'gemma4:e4b';
export const DEFAULT_OLLAMA_ENDPOINT = 'http://localhost:11434';

View File

@@ -33,6 +33,10 @@ export interface Note {
intentPromptedAt: string | null;
dueDate: string | null;
dueDateEditedByUser: boolean;
// 신규 v3:
deletedAt: string | null;
lastRecalledAt: string | null;
recallDismissedAt: string | null;
createdAt: string;
updatedAt: string;
tags: NoteTag[];
@@ -53,6 +57,20 @@ export interface CaptureApi {
hide(): void;
}
// v0.2.7 F12 deeper fix — 자동 실행 진단 정보 (AutostartDiagnostic.collectAutostartState 결과).
export interface AutostartDiagnostic {
withArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean };
noArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean };
execPath: string;
registryPath?: string;
registryValue?: string | null;
}
export interface AutostartResponse {
openAtLogin: boolean;
diagnostic: AutostartDiagnostic;
}
export interface InboxApi {
listNotes(opts: { limit: number; cursor?: string }): Promise<Note[]>;
updateAiFields(
@@ -67,7 +85,49 @@ export interface InboxApi {
getPendingCount(): Promise<number>;
getOllamaStatus(): Promise<{ ok: boolean; reason?: string }>;
getTodayCount(): Promise<number>;
// 신규 v0.2.3 #4:
restoreNote(noteId: string): Promise<void>;
permanentDeleteNote(noteId: string): Promise<{ confirmed: boolean }>;
emptyTrash(): Promise<{ confirmed: boolean; count: number }>;
listTrash(opts: { limit: number }): Promise<Note[]>;
getTrashCount(): Promise<number>;
listExpired(): Promise<Note[]>;
trashExpiredBatch(ids: string[]): Promise<{ trashedCount: number; confirmed: boolean }>;
ollamaRecheck(): Promise<{ ok: boolean; reason?: string }>;
onNoteUpdated(cb: (note: Note) => void): () => void;
onOllamaStatus(cb: (status: { ok: boolean; reason?: string }) => void): () => void;
retryAllFailed(): Promise<{ count: number }>;
getFailedCount(): Promise<number>;
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>;
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 전환 요청 구독.
onNavigate(cb: (view: 'inbox' | 'trash' | 'settings') => void): () => void;
// v0.2.7 자동 실행 (Task 22 통일) — 진단 정보 포함 응답
getAutostart(): Promise<AutostartResponse>;
setAutostart(open: boolean): Promise<AutostartResponse>;
// v0.2.7 백업 / 복원 / 동기화 / 텔레메트리 — 트레이 callback 의 IPC 대응 (Task 10)
runBackup(): Promise<{ ok: true }>;
runExport(): Promise<{ ok: true }>;
runImport(): Promise<{ ok: true }>;
runSync(): Promise<{ ok: true }>;
runExportTelemetry(): Promise<{ ok: true }>;
// 정보 섹션 — 트레이 showAboutDialog 의 IPC 대응.
getAppInfo(): Promise<{
version: string;
electron: string;
node: string;
os: string;
profileDir: string;
}>;
openProfileDir(): Promise<void>;
copyAppInfo(): Promise<void>;
// v0.2.8 Cut A — 첨부 이미지를 OS 기본 뷰어로 열기 (Task 3).
openMedia(relPath: string): Promise<{ ok: true } | { ok: false; reason: string }>;
}
export interface InklingApi {

View File

@@ -0,0 +1,42 @@
/**
* KST timezone helpers — main + renderer 양쪽에서 import 가능.
* v0.2.6 C1: backlog #3+#19+#34 통합 (기존 src/main/util/kstDate.ts 이동).
*/
export const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
export const DAY_MS = 24 * 60 * 60 * 1000;
/**
* KST 자정 기준 today YYYY-MM-DD.
*
* 기존 todayInKstString (NoteRepository.findExpiredCandidates),
* TelemetryService.todayKstIso, telemetryStats.kstDate, AiWorker.todayKstAsIso
* 4 callsite 통합.
*/
export function kstTodayIso(now: Date = new Date()): string {
const k = new Date(now.getTime() + KST_OFFSET_MS);
return new Date(Date.UTC(k.getUTCFullYear(), k.getUTCMonth(), k.getUTCDate()))
.toISOString().slice(0, 10);
}
/**
* 다음 KST 자정의 epoch ms (UTC).
*
* 기존 nextKstMidnightMs (store.snoozeExpired) + store.snoozeRecall inline 통합.
*/
export function nextKstMidnightMs(now: number = Date.now()): number {
const kstNow = now + KST_OFFSET_MS;
const kstMidnightFloor = Math.floor(kstNow / DAY_MS) * DAY_MS;
const nextKstMidnight = kstMidnightFloor + DAY_MS;
return nextKstMidnight - KST_OFFSET_MS;
}
/**
* KST today (00:00 KST 의 UTC Date 객체). AiWorker 의 dueDateParser 가 candidate 비교용.
*
* 기존 AiWorker.todayKstAsDate 통합.
*/
export function kstTodayAsDate(now: Date = new Date()): Date {
const k = new Date(now.getTime() + KST_OFFSET_MS);
return new Date(Date.UTC(k.getUTCFullYear(), k.getUTCMonth(), k.getUTCDate()));
}

View File

@@ -0,0 +1,44 @@
// @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';
vi.mock('../../src/renderer/inbox/api.js', () => ({
inboxApi: {
loadOllamaSettings: vi.fn(async () => ({ endpoint: 'http://localhost:11434', model: 'gemma2:2b' })),
saveOllamaSettings: vi.fn(async () => ({ ok: true })),
ollamaRecheck: vi.fn(async () => ({ ok: true }))
}
}));
import { AiProviderSection } from '../../src/renderer/inbox/components/settings/AiProviderSection';
describe('AiProviderSection', () => {
beforeEach(() => {
vi.clearAllMocks();
cleanup();
});
it('loads current settings on mount', async () => {
render(<AiProviderSection />);
expect(await screen.findByDisplayValue('http://localhost:11434')).toBeInTheDocument();
expect(screen.getByDisplayValue('gemma2:2b')).toBeInTheDocument();
});
it('rejects invalid endpoint URL', async () => {
render(<AiProviderSection />);
await screen.findByDisplayValue('http://localhost:11434');
const input = screen.getByLabelText(/Endpoint/);
fireEvent.change(input, { target: { value: 'not-a-url' } });
fireEvent.click(screen.getByRole('button', { name: /저장/ }));
expect(await screen.findByText(/올바른 URL/)).toBeInTheDocument();
});
it('"지금 재확인" calls ollamaRecheck and shows result', async () => {
const { inboxApi } = await import('../../src/renderer/inbox/api.js');
render(<AiProviderSection />);
await screen.findByDisplayValue('http://localhost:11434');
fireEvent.click(screen.getByRole('button', { name: /지금 재확인/ }));
expect(inboxApi.ollamaRecheck).toHaveBeenCalled();
});
});

View File

@@ -3,8 +3,12 @@ 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 type { AiTelemetryEmitter } from '@main/ai/AiWorker.js';
import type { InferenceProvider } from '@main/ai/InferenceProvider.js';
import type { AiResponse } from '@main/ai/schema.js';
import { ProviderHolder } from '@main/ai/ProviderHolder.js';
type EmittedEvent = { kind: string; payload: unknown };
function makeProvider(overrides: Partial<InferenceProvider> = {}): InferenceProvider {
return {
@@ -30,7 +34,7 @@ describe('AiWorker', () => {
it('processes a pending job and marks done', async () => {
const { id } = repo.create({ rawText: 'x' });
const updates: string[] = [];
const w = new AiWorker(repo, makeProvider(), {
const w = new AiWorker(repo, new ProviderHolder(makeProvider()), {
backoffsMs: [0, 0, 0],
onUpdate: (note) => updates.push(note.aiStatus)
});
@@ -45,7 +49,7 @@ describe('AiWorker', () => {
const provider = makeProvider({
generate: vi.fn(async () => { throw new Error('boom'); })
});
const w = new AiWorker(repo, provider, { backoffsMs: [0, 0, 0] });
const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0, 0, 0] });
await w.enqueue(id);
await w.drain();
const note = repo.findById(id)!;
@@ -57,7 +61,7 @@ describe('AiWorker', () => {
it('loadFromDb re-queues all pending', async () => {
const a = repo.create({ rawText: 'a' }).id;
const b = repo.create({ rawText: 'b' }).id;
const w = new AiWorker(repo, makeProvider(), { backoffsMs: [0, 0, 0] });
const w = new AiWorker(repo, new ProviderHolder(makeProvider()), { backoffsMs: [0, 0, 0] });
await w.loadFromDb();
await w.drain();
expect(repo.findById(a)?.aiStatus).toBe('done');
@@ -76,7 +80,7 @@ describe('AiWorker', () => {
return { title: '제목', summary: 'a\nb\nc', tags: [], dueDate: null };
})
});
const w = new AiWorker(repo, provider, { backoffsMs: [0, 0, 0] });
const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0, 0, 0] });
for (const id of ids) await w.enqueue(id);
await w.drain();
expect(max).toBe(1);
@@ -93,7 +97,7 @@ describe('AiWorker', () => {
}),
healthCheck: async () => ({ ok: true })
} as any;
const w = new AiWorker(repo, provider, {
const w = new AiWorker(repo, new ProviderHolder(provider), {
backoffsMs: [0],
now: () => new Date('2026-04-26T00:00:00.000Z')
});
@@ -115,7 +119,7 @@ describe('AiWorker', () => {
}),
healthCheck: async () => ({ ok: true })
} as any;
const w = new AiWorker(repo, provider, {
const w = new AiWorker(repo, new ProviderHolder(provider), {
backoffsMs: [0],
now: () => new Date('2026-04-26T00:00:00.000Z')
});
@@ -137,7 +141,7 @@ describe('AiWorker', () => {
}),
healthCheck: async () => ({ ok: true })
} as any;
const w = new AiWorker(repo, provider, {
const w = new AiWorker(repo, new ProviderHolder(provider), {
backoffsMs: [0],
now: () => new Date('2026-04-26T00:00:00.000Z')
});
@@ -159,7 +163,7 @@ describe('AiWorker', () => {
},
healthCheck: async () => ({ ok: true })
} as any;
const w = new AiWorker(repo, provider, {
const w = new AiWorker(repo, new ProviderHolder(provider), {
backoffsMs: [0],
now: () => new Date('2026-04-26T15:00:00.000Z') // 04-27 00:00 KST
});
@@ -181,7 +185,7 @@ describe('AiWorker', () => {
},
healthCheck: async () => ({ ok: true })
} as any;
const w = new AiWorker(repo, provider, {
const w = new AiWorker(repo, new ProviderHolder(provider), {
backoffsMs: [0],
now: () => new Date('2026-04-26T00:00:00.000Z')
});
@@ -193,3 +197,365 @@ describe('AiWorker', () => {
expect(captured.dueDateCandidates.length).toBe(2); // 내일 + 모레
});
});
describe('AiWorker telemetry emit', () => {
let db: Database.Database;
let repo: NoteRepository;
let events: Array<{ kind: string; payload: { noteId?: string; durationMs?: number; reason?: string; attempts?: number; tagId?: number; vocabSize?: number } }>;
const collectingTelemetry: AiTelemetryEmitter = {
emit: async (ev) => {
events.push(ev as typeof events[number]);
}
};
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
events = [];
});
it('emits ai_succeeded with durationMs/attempts on success', async () => {
const { id } = repo.create({ rawText: '수요일 회의 메모' });
const w = new AiWorker(repo, new ProviderHolder(makeProvider()), {
backoffsMs: [0, 0, 0],
telemetry: collectingTelemetry
});
await w.enqueue(id);
await w.drain();
const succeeded = events.find((e) => e.kind === 'ai_succeeded');
expect(succeeded).toBeDefined();
expect(succeeded!.payload.noteId).toBe(id);
// attempts = 시도한 횟수 (count, 1-based). 첫 시도 성공이므로 1.
// 회차 1 review (PR #13) 의 비대칭 의미 통일 결과 — 실패 경로의 `attempt + 1` 과 동일 의미.
expect(succeeded!.payload.attempts).toBe(1);
expect(succeeded!.payload.durationMs).toBeGreaterThanOrEqual(0);
});
it('unreachable error — ai_failed NOT emitted (infinite retry, no markAiFailed)', async () => {
const { id } = repo.create({ rawText: '메모' });
const provider = makeProvider({
generate: vi.fn(async () => { throw new Error('fetch failed: ECONNREFUSED 11434'); })
});
const w = new AiWorker(repo, new ProviderHolder(provider), {
backoffsMs: [0, 0, 0],
unreachableBackoffsMs: [10, 10, 10, 10, 10, 10],
telemetry: collectingTelemetry
});
await w.enqueue(id);
await new Promise((r) => setTimeout(r, 200));
const failed = events.find((e) => e.kind === 'ai_failed');
expect(failed).toBeUndefined();
expect(repo.findById(id)!.aiStatus).toBe('pending');
});
it('emits ai_failed with reason=schema on zod failure', async () => {
const { id } = repo.create({ rawText: '메모' });
const { ZodError } = await import('zod');
const provider = makeProvider({
generate: vi.fn(async () => { throw new ZodError([]); })
});
const w = new AiWorker(repo, new ProviderHolder(provider), {
backoffsMs: [0, 0, 0],
telemetry: collectingTelemetry
});
await w.enqueue(id);
await w.drain();
const failed = events.find((e) => e.kind === 'ai_failed');
expect(failed).toBeDefined();
expect(failed!.payload.reason).toBe('schema');
});
it('emits ai_failed with reason=other on unrecognized error', async () => {
const { id } = repo.create({ rawText: '메모' });
const provider = makeProvider({
generate: vi.fn(async () => { throw new Error('mystery'); })
});
const w = new AiWorker(repo, new ProviderHolder(provider), {
backoffsMs: [0, 0, 0],
telemetry: collectingTelemetry
});
await w.enqueue(id);
await w.drain();
const failed = events.find((e) => e.kind === 'ai_failed');
expect(failed).toBeDefined();
expect(failed!.payload.reason).toBe('other');
});
});
describe('AiWorker — deletedAt guard (v0.2.3 #4)', () => {
let db: Database.Database;
let repo: NoteRepository;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
});
it('skips notes with deleted_at IS NOT NULL — provider.generate not called', async () => {
const { id } = repo.create({ rawText: 'x' });
// 먼저 trash — pending_jobs cleanup 됨
repo.trash(id, '2026-05-01T12:00:00.000Z');
// 강제로 pending_jobs row 다시 삽입 (race 시뮬레이션 — AiWorker 가 이미 dequeue 한 상태 흉내)
db.prepare(`INSERT INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)`).run(id, '2026-05-01T12:00:00.000Z');
const generate = vi.fn();
const provider = makeProvider({ generate: generate as any });
const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0, 0, 0] });
await w.loadFromDb();
await w.drain();
expect(generate).not.toHaveBeenCalled();
expect(repo.findById(id)!.aiStatus).toBe('pending');
});
});
describe('AiWorker — unreachable/timeout infinite retry (v0.2.3 #2)', () => {
let db: Database.Database;
let repo: NoteRepository;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
});
it('unreachable — markAiFailed 안 호출, attempts 증가 안 함', async () => {
const provider = makeProvider({
generate: vi.fn(async () => { throw new Error('ECONNREFUSED'); })
});
const w = new AiWorker(repo, new ProviderHolder(provider), {
backoffsMs: [0, 30_000, 120_000],
unreachableBackoffsMs: [10, 10, 10, 10, 10, 10]
});
const { id } = repo.create({ rawText: 'x' });
await w.enqueue(id);
// 무한 retry — drain() 은 끝나지 않음. 짧게 대기 후 검증.
await new Promise((r) => setTimeout(r, 200));
expect(repo.findById(id)!.aiStatus).toBe('pending');
expect(provider.generate).toHaveBeenCalled();
expect((provider.generate as any).mock.calls.length).toBeGreaterThanOrEqual(2);
const job = repo.getAllPendingJobs().find((j) => j.noteId === id)!;
expect(job.attempts).toBe(0);
});
it('timeout — unreachable 동일 (Q2=A)', async () => {
const provider = makeProvider({
generate: vi.fn(async () => { throw new Error('Request timeout'); })
});
const w = new AiWorker(repo, new ProviderHolder(provider), {
backoffsMs: [0, 30_000, 120_000],
unreachableBackoffsMs: [10, 10, 10, 10, 10, 10]
});
const { id } = repo.create({ rawText: 'x' });
await w.enqueue(id);
await new Promise((r) => setTimeout(r, 200));
expect(repo.findById(id)!.aiStatus).toBe('pending');
expect((provider.generate as any).mock.calls.length).toBeGreaterThanOrEqual(2);
});
it('schema fail max 3 — markAiFailed + ai_failed emit (reason=schema)', async () => {
const { ZodError } = await import('zod');
const provider = makeProvider({
generate: vi.fn(async () => {
throw new ZodError([{ code: 'custom', message: 'bad', path: [] } as any]);
})
});
const events: any[] = [];
const w = new AiWorker(repo, new ProviderHolder(provider), {
backoffsMs: [0, 0, 0],
telemetry: { emit: async (e) => { events.push(e); } }
});
const { id } = repo.create({ rawText: 'x' });
await w.enqueue(id);
await w.drain();
expect(repo.findById(id)!.aiStatus).toBe('failed');
expect((provider.generate as any).mock.calls.length).toBe(3);
const failed = events.find((e) => e.kind === 'ai_failed');
expect(failed).toBeDefined();
expect(failed.payload.reason).toBe('schema');
});
it('other fail max 3 — markAiFailed + ai_failed emit (reason=other)', async () => {
const provider = makeProvider({
generate: vi.fn(async () => { throw new Error('something weird'); })
});
const events: any[] = [];
const w = new AiWorker(repo, new ProviderHolder(provider), {
backoffsMs: [0, 0, 0],
telemetry: { emit: async (e) => { events.push(e); } }
});
const { id } = repo.create({ rawText: 'x' });
await w.enqueue(id);
await w.drain();
expect(repo.findById(id)!.aiStatus).toBe('failed');
const failed = events.find((e) => e.kind === 'ai_failed');
expect(failed.payload.reason).toBe('other');
});
it('unreachable backoff schedule — nextBackoffMs(step) cap at index 5 (15분)', async () => {
const w = new AiWorker(repo, new ProviderHolder(makeProvider()), {
backoffsMs: [0, 30_000, 120_000],
unreachableBackoffsMs: [30_000, 60_000, 120_000, 240_000, 480_000, 900_000]
});
expect((w as any).nextBackoffMs(0)).toBe(30_000);
expect((w as any).nextBackoffMs(2)).toBe(120_000);
expect((w as any).nextBackoffMs(5)).toBe(900_000);
expect((w as any).nextBackoffMs(10)).toBe(900_000); // cap
});
it('success 후 unreachableBackoffStep reset', async () => {
let callCount = 0;
const provider = makeProvider({
generate: vi.fn(async (): Promise<AiResponse> => {
callCount += 1;
if (callCount <= 2) throw new Error('ECONNREFUSED');
return { title: 't', summary: 's', tags: [], dueDate: null };
})
});
const w = new AiWorker(repo, new ProviderHolder(provider), {
backoffsMs: [0, 0, 0],
unreachableBackoffsMs: [10, 10, 10, 10, 10, 10]
});
const { id } = repo.create({ rawText: 'x' });
await w.enqueue(id);
await w.drain();
expect(repo.findById(id)!.aiStatus).toBe('done');
expect(callCount).toBe(3);
expect((w as any).unreachableBackoffStep).toBe(0);
});
});
describe('AiWorker — vocab fetch + per-tag hit/miss (v0.2.3 #3 T7)', () => {
let db: Database.Database;
let repo: NoteRepository;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
});
it('fetches vocab and passes to provider.generate', async () => {
// Pre-seed 1 note with tag 'design' so vocab non-empty
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 generateMock = vi.fn(async () => ({
title: '제목', summary: 'a\nb\nc', tags: ['design'], dueDate: null
}));
const w = new AiWorker(repo, new ProviderHolder(makeProvider({ generate: generateMock })), {
backoffsMs: [0, 0, 0]
});
await w.enqueue(id);
await w.drain();
expect(generateMock).toHaveBeenCalledWith(expect.objectContaining({
vocab: expect.arrayContaining(['design'])
}));
});
it('emits tag_vocab_hit for vocab tags + tag_vocab_miss for new tags', async () => {
// Pre-seed: 'design' in vocab
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', 'newtag'], // 1 hit + 1 miss
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();
const hit = emits.filter((e) => e.kind === 'tag_vocab_hit');
const miss = emits.filter((e) => e.kind === 'tag_vocab_miss');
expect(hit).toHaveLength(1);
expect(miss).toHaveLength(1);
const hitPayload = hit[0]!.payload as { tagId: number; vocabSize: number };
const missPayload = miss[0]!.payload as { vocabSize: number };
expect(hitPayload.tagId).toBeGreaterThan(0);
expect(hitPayload.vocabSize).toBe(1);
expect(missPayload.vocabSize).toBe(1);
});
it('all tags miss when vocab is empty', async () => {
// No seed → vocab=[]
const { id } = repo.create({ rawText: 'x' });
const provider = makeProvider({
generate: vi.fn(async () => ({
title: 't', summary: 'a\nb\nc',
tags: ['design', 'meeting', 'qa'],
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();
const miss = emits.filter((e) => e.kind === 'tag_vocab_miss');
expect(miss).toHaveLength(3);
expect(emits.filter((e) => e.kind === 'tag_vocab_hit')).toHaveLength(0);
});
it('emits one event per tag (3 tags → 3 events)', async () => {
// Pre-seed: all 3 in vocab
const seed = repo.create({ rawText: 'seed' }).id;
repo.updateAiResult(seed, { title: 't', summary: 'a\nb\nc', tags: ['design', 'meeting', 'qa'], provider: 'p' });
const { id } = repo.create({ rawText: 'x' });
const provider = makeProvider({
generate: vi.fn(async () => ({
title: 't', summary: 'a\nb\nc',
tags: ['design', 'meeting', 'qa'],
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();
const hits = emits.filter((e) => e.kind === 'tag_vocab_hit');
expect(hits).toHaveLength(3);
});
it('dedupes duplicate tags in AI response (one emit per unique tag)', async () => {
// Pre-seed: 'design' in vocab
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', 'design', 'meeting'], // 중복 '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();
const hit = emits.filter((e) => e.kind === 'tag_vocab_hit');
const miss = emits.filter((e) => e.kind === 'tag_vocab_miss');
expect(hit).toHaveLength(1); // 'design' 중복 → 1 hit (dedup)
expect(miss).toHaveLength(1); // 'meeting' 1 miss
});
});

91
tests/unit/App.test.tsx Normal file
View File

@@ -0,0 +1,91 @@
// @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';
vi.mock('../../src/renderer/inbox/api.js', () => ({
inboxApi: {
listNotes: vi.fn(async () => []),
getContinuity: vi.fn(async () => ({
weekStart: '', weekCount: 0, weekTarget: 7,
consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null
})),
getPendingCount: vi.fn(async () => 0),
getOllamaStatus: vi.fn(async () => ({ ok: true })),
getTodayCount: vi.fn(async () => 0),
getTrashCount: vi.fn(async () => 0),
listExpired: vi.fn(async () => []),
getFailedCount: vi.fn(async () => 0),
listRecallCandidate: vi.fn(async () => null),
onNoteUpdated: vi.fn(() => () => undefined),
onOllamaStatus: vi.fn(() => () => undefined),
onNavigate: vi.fn(() => () => undefined),
// 4 섹션 mounted 시 호출되는 stub
loadOllamaSettings: vi.fn(async () => ({ endpoint: '', model: '' })),
saveOllamaSettings: vi.fn(async () => ({ ok: true })),
ollamaRecheck: vi.fn(async () => ({ ok: true })),
getAutostart: vi.fn(async () => ({
openAtLogin: false,
diagnostic: {
withArgs: { openAtLogin: false, executableWillLaunchAtLogin: false },
noArgs: { openAtLogin: false, executableWillLaunchAtLogin: false },
execPath: '/p'
}
})),
setAutostart: vi.fn(async () => ({
openAtLogin: false,
diagnostic: {
withArgs: { openAtLogin: false, executableWillLaunchAtLogin: false },
noArgs: { openAtLogin: false, executableWillLaunchAtLogin: false },
execPath: '/p'
}
})),
runBackup: vi.fn(async () => ({ ok: true })),
runExport: vi.fn(async () => ({ ok: true })),
runImport: vi.fn(async () => ({ ok: true })),
runSync: vi.fn(async () => ({ ok: true })),
runExportTelemetry: vi.fn(async () => ({ ok: true })),
getAppInfo: vi.fn(async () => ({ version: '0.2.7', electron: '?', node: '?', os: '?', profileDir: '?' })),
openProfileDir: vi.fn(async () => undefined),
copyAppInfo: vi.fn(async () => undefined)
}
}));
import { App } from '../../src/renderer/inbox/App';
import { useInbox } from '../../src/renderer/inbox/store';
import { inboxApi } from '../../src/renderer/inbox/api.js';
describe('App — settings view', () => {
beforeEach(() => {
cleanup();
useInbox.setState({ showSettings: false, notes: [], trashNotes: [], trashCount: 0 });
});
it('renders SettingsPage when showSettings=true', async () => {
useInbox.setState({ showSettings: true });
render(<App />);
expect(await screen.findByText('설정')).toBeInTheDocument();
expect(screen.getByText('AI 제공자')).toBeInTheDocument();
});
it('header gear icon click sets showSettings=true', async () => {
render(<App />);
fireEvent.click(await screen.findByLabelText('설정 열기'));
expect(useInbox.getState().showSettings).toBe(true);
});
it('inbox:navigate "settings" event sets showSettings=true', async () => {
const navHandlers: Array<(view: 'inbox' | 'trash' | 'settings') => void> = [];
vi.mocked(inboxApi.onNavigate).mockImplementation((cb) => {
navHandlers.push(cb);
return () => {
const i = navHandlers.indexOf(cb);
if (i >= 0) navHandlers.splice(i, 1);
};
});
render(<App />);
await waitFor(() => expect(navHandlers.length).toBeGreaterThan(0));
navHandlers.forEach((h) => h('settings'));
await waitFor(() => expect(useInbox.getState().showSettings).toBe(true));
});
});

View File

@@ -0,0 +1,96 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
const { mockApp, mockExecFile } = vi.hoisted(() => ({
mockApp: { getLoginItemSettings: vi.fn() },
mockExecFile: vi.fn()
}));
vi.mock('electron', () => ({
default: { app: mockApp }
}));
vi.mock('node:child_process', () => ({
execFile: mockExecFile
}));
import { collectAutostartState } from '../../src/main/services/AutostartDiagnostic';
const ORIGINAL_PLATFORM = process.platform;
function setPlatform(p: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', { value: p, configurable: true });
}
describe('AutostartDiagnostic — collectAutostartState', () => {
beforeEach(() => {
mockApp.getLoginItemSettings.mockReset();
mockExecFile.mockReset();
});
afterEach(() => {
setPlatform(ORIGINAL_PLATFORM);
});
it('returns withArgs / noArgs / execPath structure', async () => {
setPlatform('darwin');
mockApp.getLoginItemSettings
.mockReturnValueOnce({ openAtLogin: true, executableWillLaunchAtLogin: true })
.mockReturnValueOnce({ openAtLogin: false, executableWillLaunchAtLogin: true });
const state = await collectAutostartState();
expect(state.withArgs).toEqual({ openAtLogin: true, executableWillLaunchAtLogin: true });
expect(state.noArgs).toEqual({ openAtLogin: false, executableWillLaunchAtLogin: true });
expect(state.execPath).toBe(process.execPath);
});
it('passes args=["--hidden"] for the first call, no args for the second', async () => {
setPlatform('darwin');
mockApp.getLoginItemSettings
.mockReturnValueOnce({ openAtLogin: true, executableWillLaunchAtLogin: true })
.mockReturnValueOnce({ openAtLogin: true, executableWillLaunchAtLogin: true });
await collectAutostartState();
expect(mockApp.getLoginItemSettings).toHaveBeenNthCalledWith(1, { args: ['--hidden'] });
expect(mockApp.getLoginItemSettings).toHaveBeenNthCalledWith(2);
});
it('non-win32: does not set registryPath/registryValue', async () => {
setPlatform('darwin');
mockApp.getLoginItemSettings.mockReturnValue({ openAtLogin: true, executableWillLaunchAtLogin: true });
const state = await collectAutostartState();
expect(state.registryPath).toBeUndefined();
expect(state.registryValue).toBeUndefined();
expect(mockExecFile).not.toHaveBeenCalled();
});
it('Windows: returns registryPath + registryValue when reg.exe succeeds', async () => {
setPlatform('win32');
mockApp.getLoginItemSettings.mockReturnValue({ openAtLogin: true, executableWillLaunchAtLogin: true });
mockExecFile.mockImplementation((_cmd: string, _args: string[], cb: (err: Error | null, stdout: string, stderr: string) => void) => {
cb(null, '\r\nHKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Run\r\n Inkling REG_SZ "C:\\Users\\u\\Inkling.exe" --hidden\r\n', '');
});
const state = await collectAutostartState();
expect(state.registryPath).toBe('HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run\\Inkling');
expect(state.registryValue).toContain('Inkling.exe');
expect(state.registryValue).toContain('--hidden');
});
it('Windows: silent fallback on reg.exe error', async () => {
setPlatform('win32');
mockApp.getLoginItemSettings.mockReturnValue({ openAtLogin: true, executableWillLaunchAtLogin: true });
mockExecFile.mockImplementation((_cmd: string, _args: string[], cb: (err: Error | null, stdout: string, stderr: string) => void) => {
cb(new Error('not found'), '', '');
});
const state = await collectAutostartState();
expect(state.registryPath).toBe('HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run\\Inkling');
expect(state.registryValue).toBeNull();
});
});

View File

@@ -0,0 +1,115 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
function makeDiag(open: boolean): {
withArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean };
noArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean };
execPath: string;
} {
return {
withArgs: { openAtLogin: open, executableWillLaunchAtLogin: open },
noArgs: { openAtLogin: open, executableWillLaunchAtLogin: open },
execPath: '/path/to/exe'
};
}
vi.mock('../../src/renderer/inbox/api.js', () => ({
inboxApi: {
getAutostart: vi.fn(async () => ({ openAtLogin: true, diagnostic: makeDiag(true) })),
setAutostart: vi.fn(async (open: boolean) => ({ openAtLogin: open, diagnostic: makeDiag(open) }))
}
}));
import { AutostartSection } from '../../src/renderer/inbox/components/settings/AutostartSection';
describe('AutostartSection', () => {
beforeEach(() => {
vi.clearAllMocks();
cleanup();
});
it('renders toggle reflecting current state', async () => {
render(<AutostartSection />);
const toggle = await screen.findByRole('checkbox');
expect(toggle).toBeChecked();
});
it('clicking toggle calls setAutostart', async () => {
const { inboxApi } = await import('../../src/renderer/inbox/api.js');
render(<AutostartSection />);
const toggle = await screen.findByRole('checkbox');
fireEvent.click(toggle);
await waitFor(() => expect(inboxApi.setAutostart).toHaveBeenCalledWith(false));
});
it('renders diagnostic panel when expanded, shows mismatch warning + execPath', async () => {
const { inboxApi } = await import('../../src/renderer/inbox/api.js');
vi.mocked(inboxApi.getAutostart).mockResolvedValueOnce({
openAtLogin: true,
diagnostic: {
withArgs: { openAtLogin: true, executableWillLaunchAtLogin: true },
noArgs: { openAtLogin: false, executableWillLaunchAtLogin: true },
execPath: '/path/to/Inkling.exe'
}
});
render(<AutostartSection />);
await screen.findByRole('checkbox');
fireEvent.click(screen.getByRole('button', { name: /진단 정보/ }));
expect(await screen.findByText(/⚠️/)).toBeInTheDocument();
expect(screen.getByText(/path\/to\/Inkling\.exe/)).toBeInTheDocument();
expect(screen.getByText(/표준 \(--hidden 인자\)/)).toBeInTheDocument();
expect(screen.getByText(/비교 \(인자 없이\)/)).toBeInTheDocument();
});
it('shows registry info when present (Win)', async () => {
const { inboxApi } = await import('../../src/renderer/inbox/api.js');
vi.mocked(inboxApi.getAutostart).mockResolvedValueOnce({
openAtLogin: true,
diagnostic: {
withArgs: { openAtLogin: true, executableWillLaunchAtLogin: true },
noArgs: { openAtLogin: true, executableWillLaunchAtLogin: true },
execPath: 'C:\\app.exe',
registryPath: 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run\\Inkling',
registryValue: '"C:\\app.exe" --hidden'
}
});
render(<AutostartSection />);
await screen.findByRole('checkbox');
fireEvent.click(screen.getByRole('button', { name: /진단 정보/ }));
expect(screen.getByText(/registry 경로/)).toBeInTheDocument();
expect(screen.getByText(/registry 값/)).toBeInTheDocument();
});
it('no mismatch warning when withArgs == noArgs and willLaunch=true', async () => {
const { inboxApi } = await import('../../src/renderer/inbox/api.js');
vi.mocked(inboxApi.getAutostart).mockResolvedValueOnce({
openAtLogin: true,
diagnostic: {
withArgs: { openAtLogin: true, executableWillLaunchAtLogin: true },
noArgs: { openAtLogin: true, executableWillLaunchAtLogin: true },
execPath: '/p'
}
});
render(<AutostartSection />);
await screen.findByRole('checkbox');
expect(screen.queryByText(/⚠️/)).not.toBeInTheDocument();
});
it('"재등록" button calls setAutostart with current openAtLogin value', async () => {
const { inboxApi } = await import('../../src/renderer/inbox/api.js');
vi.mocked(inboxApi.getAutostart).mockResolvedValueOnce({
openAtLogin: true,
diagnostic: {
withArgs: { openAtLogin: true, executableWillLaunchAtLogin: true },
noArgs: { openAtLogin: true, executableWillLaunchAtLogin: true },
execPath: '/p'
}
});
render(<AutostartSection />);
await screen.findByRole('checkbox');
fireEvent.click(screen.getByRole('button', { name: /재등록/ }));
await waitFor(() => expect(inboxApi.setAutostart).toHaveBeenCalledWith(true));
});
});

View File

@@ -0,0 +1,39 @@
// @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';
vi.mock('../../src/renderer/inbox/api.js', () => ({
inboxApi: {
runBackup: vi.fn(async () => ({ ok: true })),
runExport: vi.fn(async () => ({ ok: true })),
runImport: vi.fn(async () => ({ ok: true })),
runSync: vi.fn(async () => ({ ok: true })),
runExportTelemetry: vi.fn(async () => ({ ok: true }))
}
}));
import { BackupSection } from '../../src/renderer/inbox/components/settings/BackupSection';
describe('BackupSection', () => {
beforeEach(() => {
vi.clearAllMocks();
cleanup();
});
it('renders 5 buttons', () => {
render(<BackupSection />);
expect(screen.getByRole('button', { name: /지금 백업/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^내보내기/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /백업에서 복원/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /지금 동기화/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /사용 로그/ })).toBeInTheDocument();
});
it('clicking 지금 백업 calls runBackup', async () => {
const { inboxApi } = await import('../../src/renderer/inbox/api.js');
render(<BackupSection />);
fireEvent.click(screen.getByRole('button', { name: /지금 백업/ }));
await waitFor(() => expect(inboxApi.runBackup).toHaveBeenCalled());
});
});

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { mkdtempSync } from 'node:fs';
import { mkdtempSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import Database from 'better-sqlite3';
@@ -51,10 +51,436 @@ describe('CaptureService', () => {
expect(celebrated).toHaveLength(0);
});
it('deleteNote removes db row + media dir', async () => {
it('deleteNote soft-deletes (sets deletedAt, preserves row)', async () => {
const img = new Uint8Array([0, 1, 2, 3]).buffer;
const { noteId } = await svc.submit({ text: 't', images: [img] });
await svc.deleteNote(noteId);
expect(repo.findById(noteId)).toBeNull();
expect(repo.findById(noteId)!.deletedAt).not.toBeNull();
});
});
describe('CaptureService telemetry emit', () => {
let db: Database.Database;
let repo: NoteRepository;
let store: MediaStore;
let tmp: string;
let events: Array<{ kind: string; payload: { noteId: string; rawTextLength: number; hasMedia: boolean } }>;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
tmp = mkdtempSync(join(tmpdir(), 'inkling-capture-'));
store = new MediaStore(tmp);
events = [];
});
it('emits capture event with noteId/rawTextLength/hasMedia', async () => {
const svc = new CaptureService(repo, store, {
enqueue: async () => {},
celebrate: () => {},
telemetry: { emit: async (ev) => { events.push(ev as typeof events[number]); } }
});
await svc.submit({ text: '안녕하세요', images: [] });
expect(events).toHaveLength(1);
expect(events[0]!.kind).toBe('capture');
expect(events[0]!.payload.rawTextLength).toBe('안녕하세요'.length);
expect(events[0]!.payload.hasMedia).toBe(false);
expect(typeof events[0]!.payload.noteId).toBe('string');
});
it('emits hasMedia=true when images present', async () => {
const svc = new CaptureService(repo, store, {
enqueue: async () => {},
celebrate: () => {},
telemetry: { emit: async (ev) => { events.push(ev as typeof events[number]); } }
});
const img = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]).buffer;
await svc.submit({ text: '이미지 메모', images: [img] });
expect(events).toHaveLength(1);
expect(events[0]!.payload.hasMedia).toBe(true);
});
it('does NOT emit when telemetry dep absent (backward compat)', async () => {
const svc = new CaptureService(repo, store, {
enqueue: async () => {},
celebrate: () => {}
});
const result = await svc.submit({ text: 'no telem', images: [] });
expect(typeof result.noteId).toBe('string');
expect(events).toHaveLength(0); // events array stays empty since no telemetry was wired
});
});
describe('CaptureService trash flow (v0.2.3 #4)', () => {
let db: Database.Database;
let repo: NoteRepository;
let store: MediaStore;
let tmp: string;
let events: Array<{ kind: string; payload: any }>;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
tmp = mkdtempSync(join(tmpdir(), 'inkling-trash-'));
store = new MediaStore(tmp);
events = [];
});
it('deleteNote sets deleted_at and emits trash event (no media cleanup)', async () => {
const svc = new CaptureService(repo, store, {
enqueue: async () => {},
celebrate: () => {},
telemetry: { emit: async (ev) => { events.push(ev); } }
});
const { noteId } = await svc.submit({ text: 'hi', images: [new ArrayBuffer(8)] });
events.length = 0; // clear capture event
await svc.deleteNote(noteId);
expect(repo.findById(noteId)!.deletedAt).not.toBeNull();
expect(events).toHaveLength(1);
expect(events[0]!.kind).toBe('trash');
expect(events[0]!.payload.noteId).toBe(noteId);
// media 디렉터리 보존 확인 (restore 시 필요)
expect(existsSync(join(tmp, 'media', noteId))).toBe(true);
});
it('restoreNote clears deleted_at and emits restore event', async () => {
const svc = new CaptureService(repo, store, {
enqueue: async () => {},
celebrate: () => {},
telemetry: { emit: async (ev) => { events.push(ev); } }
});
const { noteId } = await svc.submit({ text: 'hi', images: [] });
events.length = 0;
await svc.deleteNote(noteId);
events.length = 0;
await svc.restoreNote(noteId);
expect(repo.findById(noteId)!.deletedAt).toBeNull();
expect(events).toHaveLength(1);
expect(events[0]!.kind).toBe('restore');
});
it('permanentDeleteNote hard-deletes + cleans media + emits permanent_delete', async () => {
const svc = new CaptureService(repo, store, {
enqueue: async () => {},
celebrate: () => {},
telemetry: { emit: async (ev) => { events.push(ev); } }
});
const { noteId } = await svc.submit({ text: 'hi', images: [new ArrayBuffer(8)] });
events.length = 0;
await svc.permanentDeleteNote(noteId);
expect(repo.findById(noteId)).toBeNull();
expect(existsSync(join(tmp, 'media', noteId))).toBe(false);
expect(events).toHaveLength(1);
expect(events[0]!.kind).toBe('permanent_delete');
});
it('emptyTrash deletes all trashed + cleans each media + emits empty_trash with count', async () => {
const svc = new CaptureService(repo, store, {
enqueue: async () => {},
celebrate: () => {},
telemetry: { emit: async (ev) => { events.push(ev); } }
});
const a = (await svc.submit({ text: 'a', images: [new ArrayBuffer(8)] })).noteId;
const b = (await svc.submit({ text: 'b', images: [new ArrayBuffer(8)] })).noteId;
await svc.submit({ text: 'c (active)', images: [] });
await svc.deleteNote(a);
await svc.deleteNote(b);
events.length = 0;
const r = await svc.emptyTrash();
expect(r.count).toBe(2);
expect(repo.findById(a)).toBeNull();
expect(repo.findById(b)).toBeNull();
expect(existsSync(join(tmp, 'media', a))).toBe(false);
expect(existsSync(join(tmp, 'media', b))).toBe(false);
const empty = events.find((e) => e.kind === 'empty_trash')!;
expect(empty.payload.count).toBe(2);
});
it('emptyTrash returns count=0 when trash empty', async () => {
const svc = new CaptureService(repo, store, {
enqueue: async () => {},
celebrate: () => {},
telemetry: { emit: async (ev) => { events.push(ev); } }
});
const r = await svc.emptyTrash();
expect(r.count).toBe(0);
});
});
describe('CaptureService.listExpired (dedup signature)', () => {
let db: Database.Database;
let repo: NoteRepository;
let store: MediaStore;
let tmp: string;
let calls: Array<{ kind: string; payload: any }>;
let svc: CaptureService;
function addExpired(id: string, dueDate: string, createdAt: string = '2026-04-30T10:00:00Z'): void {
db.prepare(
`INSERT INTO notes
(id, raw_text, ai_status, due_date, created_at, updated_at)
VALUES (?, ?, 'done', ?, ?, ?)`
).run(id, id, dueDate, createdAt, createdAt);
}
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
tmp = mkdtempSync(join(tmpdir(), 'inkling-capture-'));
store = new MediaStore(tmp);
calls = [];
svc = new CaptureService(repo, store, {
enqueue: async () => {},
celebrate: () => {},
telemetry: { emit: async (input) => { calls.push(input as any); } }
});
});
it('emits expired_banner_shown on first call when candidates > 0', async () => {
addExpired('n1', '2026-04-20', '2026-04-30T10:00:00Z');
addExpired('n2', '2026-04-22', '2026-04-30T11:00:00Z');
const r = await svc.listExpired(new Date('2026-05-01T12:00:00Z'));
expect(r).toHaveLength(2);
expect(calls).toContainEqual(
expect.objectContaining({ kind: 'expired_banner_shown', payload: { candidateCount: 2 } })
);
});
it('does NOT re-emit on second call with identical candidate set (dedup)', async () => {
addExpired('n1', '2026-04-20', '2026-04-30T10:00:00Z');
addExpired('n2', '2026-04-22', '2026-04-30T11:00:00Z');
await svc.listExpired(new Date('2026-05-01T12:00:00Z'));
await svc.listExpired(new Date('2026-05-01T12:00:00Z'));
const showns = calls.filter((c) => c.kind === 'expired_banner_shown');
expect(showns).toHaveLength(1);
});
it('re-emits when candidate set changes (count or first-3-ids)', async () => {
addExpired('n1', '2026-04-20', '2026-04-30T10:00:00Z');
addExpired('n2', '2026-04-22', '2026-04-30T11:00:00Z');
await svc.listExpired(new Date('2026-05-01T12:00:00Z'));
addExpired('n3', '2026-04-23', '2026-04-30T12:00:00Z');
await svc.listExpired(new Date('2026-05-01T12:00:00Z'));
const showns = calls.filter((c) => c.kind === 'expired_banner_shown');
expect(showns).toHaveLength(2);
expect(showns[1]!.payload).toMatchObject({ candidateCount: 3 });
});
it('does NOT emit when candidates is empty', async () => {
const r = await svc.listExpired(new Date('2026-05-01T12:00:00Z'));
expect(r).toEqual([]);
expect(calls.filter((c) => c.kind === 'expired_banner_shown')).toEqual([]);
});
});
describe('CaptureService.trashExpiredBatch', () => {
let db: Database.Database;
let repo: NoteRepository;
let store: MediaStore;
let tmp: string;
let calls: Array<{ kind: string; payload: any }>;
let svc: CaptureService;
function addExpired(id: string, dueDate: string): void {
db.prepare(
`INSERT INTO notes
(id, raw_text, ai_status, due_date, created_at, updated_at)
VALUES (?, ?, 'done', ?, ?, ?)`
).run(id, id, dueDate, '2026-04-30T10:00:00Z', '2026-04-30T10:00:00Z');
}
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
tmp = mkdtempSync(join(tmpdir(), 'inkling-capture-'));
store = new MediaStore(tmp);
calls = [];
svc = new CaptureService(repo, store, {
enqueue: async () => {},
celebrate: () => {},
telemetry: { emit: async (input) => { calls.push(input as any); } }
});
});
it('emits expired_batch_trash with trashedCount + no per-id trash emit', async () => {
addExpired('n1', '2026-04-20');
addExpired('n2', '2026-04-22');
const r = await svc.trashExpiredBatch(['n1', 'n2']);
expect(r.trashedCount).toBe(2);
expect(calls.filter((c) => c.kind === 'expired_batch_trash')).toEqual([
expect.objectContaining({ kind: 'expired_batch_trash', payload: { count: 2 } })
]);
expect(calls.filter((c) => c.kind === 'trash')).toEqual([]);
});
it('returns trashedCount=0 for empty array (no emit)', async () => {
const r = await svc.trashExpiredBatch([]);
expect(r.trashedCount).toBe(0);
expect(calls.filter((c) => c.kind === 'expired_batch_trash')).toEqual([]);
});
});
describe('CaptureService.restoreNote — enqueue on failed/pending (#10 production path)', () => {
let db: Database.Database;
let repo: NoteRepository;
let store: MediaStore;
let tmp: string;
let enqueued: string[];
let svc: CaptureService;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
tmp = mkdtempSync(join(tmpdir(), 'inkling-restore-'));
store = new MediaStore(tmp);
enqueued = [];
svc = new CaptureService(repo, store, {
enqueue: async (id) => { enqueued.push(id); },
celebrate: () => {}
});
});
it('restoreNote calls worker.enqueue when restoring failed note', async () => {
const { id } = repo.create({ rawText: 'x' });
repo.markAiFailed(id, 'unreachable');
repo.trash(id, new Date().toISOString());
enqueued.length = 0; // reset
await svc.restoreNote(id);
expect(repo.findById(id)!.aiStatus).toBe('pending');
expect(enqueued).toContain(id);
});
it('restoreNote does not enqueue done note', async () => {
const { id } = repo.create({ rawText: 'x' });
repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
repo.trash(id, new Date().toISOString());
enqueued.length = 0; // reset
await svc.restoreNote(id);
expect(repo.findById(id)!.aiStatus).toBe('done');
expect(enqueued).not.toContain(id);
});
});
describe('CaptureService.retryAllFailed', () => {
let db: Database.Database;
let repo: NoteRepository;
let store: MediaStore;
let tmp: string;
let calls: Array<{ kind: string; payload: any }>;
let enqueued: string[];
let svc: CaptureService;
function makeFailed(rawText: string): string {
const { id } = repo.create({ rawText });
db.prepare(`UPDATE notes SET ai_status='failed', ai_error='boom' WHERE id=?`).run(id);
db.prepare(`DELETE FROM pending_jobs WHERE note_id=?`).run(id);
return id;
}
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
tmp = mkdtempSync(join(tmpdir(), 'inkling-capture-'));
store = new MediaStore(tmp);
calls = [];
enqueued = [];
svc = new CaptureService(repo, store, {
enqueue: async (id) => { enqueued.push(id); },
celebrate: () => {},
telemetry: { emit: async (input) => { calls.push(input as any); } }
});
});
it('retryAllFailed — enqueue per id + ai_retry_manual emit', async () => {
const a = makeFailed('a');
const b = makeFailed('b');
const r = await svc.retryAllFailed();
expect(r.count).toBe(2);
expect(enqueued.sort()).toEqual([a, b].sort());
expect(calls).toContainEqual(
expect.objectContaining({ kind: 'ai_retry_manual', payload: { failedCount: 2 } })
);
});
it('retryAllFailed empty — count=0, no emit', async () => {
const r = await svc.retryAllFailed();
expect(r.count).toBe(0);
expect(enqueued).toEqual([]);
expect(calls.filter((c) => c.kind === 'ai_retry_manual')).toEqual([]);
});
});
describe('CaptureService recall methods (v0.2.3 #6)', () => {
let db: Database.Database;
let repo: NoteRepository;
let store: MediaStore;
let tmp: string;
let emits: Array<{ kind: string; payload: any }>;
let service: CaptureService;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
tmp = mkdtempSync(join(tmpdir(), 'inkling-recall-'));
store = new MediaStore(tmp);
emits = [];
service = new CaptureService(repo, store, {
enqueue: async () => {},
celebrate: () => {},
telemetry: { emit: async (ev) => { emits.push(ev as any); } }
});
});
it('listRecallCandidate delegates to repo.findRecallCandidate', async () => {
const id = repo.create({ rawText: 'old' }).id;
repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
// No last_recalled_at → eligible immediately
const candidate = await service.listRecallCandidate();
expect(candidate?.id).toBe(id);
});
it('markRecallOpened updates last_recalled_at and emits recall_opened', async () => {
const id = repo.create({ rawText: 'x' }).id;
repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
const before = repo.findById(id)!.lastRecalledAt;
expect(before).toBeNull();
await service.markRecallOpened(id);
expect(repo.findById(id)!.lastRecalledAt).not.toBeNull();
expect(emits.find((e) => e.kind === 'recall_opened')).toBeDefined();
});
it('dismissRecall updates recall_dismissed_at and emits recall_dismissed', async () => {
const id = repo.create({ rawText: 'x' }).id;
repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
expect(repo.findById(id)!.recallDismissedAt).toBeNull();
await service.dismissRecall(id);
expect(repo.findById(id)!.recallDismissedAt).not.toBeNull();
expect(emits.find((e) => e.kind === 'recall_dismissed')).toBeDefined();
});
it('emitRecallShown emits with ageDays from createdAt when never recalled', async () => {
const id = repo.create({ rawText: 'x' }).id;
repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
// Backdate created_at to 14 days ago
db.prepare(`UPDATE notes SET created_at = ? WHERE id = ?`)
.run(new Date(Date.now() - 14 * 86_400_000).toISOString(), id);
await service.emitRecallShown(id);
const shown = emits.find((e) => e.kind === 'recall_shown');
expect(shown).toBeDefined();
const payload = shown!.payload as { noteId: string; ageDays: number };
expect(payload.noteId).toBe(id);
expect(payload.ageDays).toBeGreaterThanOrEqual(13);
expect(payload.ageDays).toBeLessThanOrEqual(15);
});
});

View File

@@ -88,4 +88,18 @@ describe('ContinuityService', () => {
const svc = new ContinuityService(db, () => new Date('2026-04-25T12:00:00+09:00'));
expect(svc.get().showRecoveryToast).toBe(false);
});
it('excludes trashed notes from streak/recovery math (v0.2.3 #4)', () => {
const db = dbWithDates([
'2026-04-22T10:00:00+09:00',
'2026-04-25T11:00:00+09:00'
]);
// 22일 노트를 trash → 25일이 마지막. 22일 미만이라 weekCount 1 이지만 lastNoteAt
// 은 25일 (마지막 active) 이어야 함. trashed 노트가 무시되어야 함.
db.prepare(`UPDATE notes SET deleted_at='2026-04-26T00:00:00Z' WHERE id='n0'`).run();
const svc = new ContinuityService(db, () => new Date('2026-04-25T12:00:00+09:00'));
const r = svc.get();
expect(r.weekCount).toBe(1);
expect(r.lastNoteAt).toBe('2026-04-25T02:00:00.000Z'); // KST 11:00 = UTC 02:00
});
});

View File

@@ -138,4 +138,18 @@ describe('ExportService', () => {
expect(readme).toContain('RAG');
expect(readme).toContain('inkling_export_version');
});
it('does NOT export trashed notes (listAll filter — v0.2.3 #4 회귀 가드)', async () => {
const a = repo.create({ rawText: 'active note' }).id;
const t = repo.create({ rawText: 'trashed note' }).id;
repo.updateAiResult(a, { title: '활성', summary: 'a\nb\nc', tags: [], provider: 'p', dueDate: null });
repo.updateAiResult(t, { title: '버려짐', summary: 'a\nb\nc', tags: [], provider: 'p', dueDate: null });
repo.trash(t, '2026-05-01T00:00:00.000Z');
const r = await svc.export(outDir, { includeMedia: false });
expect(r.noteCount).toBe(1);
// index.jsonl 도 trash 미포함
const indexPath = join(outDir, 'index.jsonl');
const lines = readFileSync(indexPath, 'utf8').trim().split('\n');
expect(lines).toHaveLength(1);
});
});

View File

@@ -0,0 +1,119 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { HealthChecker, type HealthTelemetryEvent } from '@main/services/HealthChecker.js';
import type { InferenceProvider, HealthResult, GenerateInput } from '@main/ai/InferenceProvider.js';
import type { AiResponse } from '@main/ai/schema.js';
import { ProviderHolder } from '@main/ai/ProviderHolder.js';
class FakeProvider implements InferenceProvider {
readonly name = 'fake';
results: HealthResult[] = [];
private idx = 0;
async healthCheck(): Promise<HealthResult> {
const r = this.results[Math.min(this.idx, this.results.length - 1)] ?? { ok: true };
this.idx += 1;
return r;
}
async generate(_input: GenerateInput): Promise<AiResponse> {
throw new Error('not used');
}
}
describe('HealthChecker — start/stop polling', () => {
beforeEach(() => { vi.useFakeTimers(); });
afterEach(() => { vi.useRealTimers(); });
it('start() runs runOnce immediately + every intervalMs', async () => {
const provider = new FakeProvider();
provider.results = [{ ok: true }, { ok: true }, { ok: true }];
const hc = new HealthChecker(new ProviderHolder(provider), { intervalMs: 1000 });
hc.start();
await vi.runOnlyPendingTimersAsync();
await vi.advanceTimersByTimeAsync(1000);
await vi.advanceTimersByTimeAsync(1000);
expect((provider as any).idx).toBeGreaterThanOrEqual(3);
hc.stop();
});
it('start() is idempotent — second call does not duplicate timer', async () => {
const provider = new FakeProvider();
provider.results = [{ ok: true }];
const hc = new HealthChecker(new ProviderHolder(provider), { intervalMs: 1000 });
hc.start();
hc.start();
// 즉시 1회 + 1s 후 1회 = 정확히 2. 두 timer 가 잘못 등록됐으면 4 (각 timer 마다 즉시+1s).
await vi.advanceTimersByTimeAsync(1000);
expect((provider as any).idx).toBe(2);
hc.stop();
});
it('stop() clears timer (no further runOnce)', async () => {
const provider = new FakeProvider();
provider.results = [{ ok: true }, { ok: true }];
const hc = new HealthChecker(new ProviderHolder(provider), { intervalMs: 1000 });
hc.start();
await vi.runOnlyPendingTimersAsync();
const before = (provider as any).idx;
hc.stop();
await vi.advanceTimersByTimeAsync(5000);
expect((provider as any).idx).toBe(before);
});
});
describe('HealthChecker — delta transitions + telemetry', () => {
it('ok=true → ok=false 전이 시 onUpdate + ollama_unreachable emit', async () => {
const provider = new FakeProvider();
provider.results = [{ ok: true }, { ok: false, reason: 'connection refused' }];
const updates: HealthResult[] = [];
const events: HealthTelemetryEvent[] = [];
const hc = new HealthChecker(new ProviderHolder(provider), {
onUpdate: (s) => updates.push(s),
onTelemetry: (e) => events.push(e)
});
await hc.runOnce();
await hc.runOnce();
expect(updates).toEqual([{ ok: false, reason: 'connection refused' }]);
expect(events).toEqual([{ kind: 'ollama_unreachable', reason: 'connection refused' }]);
});
it('ok=false → ok=true 전이 시 onUpdate + ollama_recovered emit (downtimeMs 정확)', async () => {
const provider = new FakeProvider();
provider.results = [{ ok: false, reason: 'refused' }, { ok: true }];
const events: HealthTelemetryEvent[] = [];
let nowCounter = 0;
const hc = new HealthChecker(new ProviderHolder(provider), {
onTelemetry: (e) => events.push(e),
now: () => { nowCounter += 1; return nowCounter * 1000; }
});
await hc.runOnce();
await hc.runOnce();
const recovered = events.find((e) => e.kind === 'ollama_recovered');
expect(recovered).toEqual({ kind: 'ollama_recovered', downtimeMs: 1000 });
});
it('reason 변경만 (ok=false 유지) 시 onUpdate fire 하지만 telemetry emit 안 함', async () => {
const provider = new FakeProvider();
provider.results = [
{ ok: false, reason: 'refused' },
{ ok: false, reason: 'timeout' }
];
const updates: HealthResult[] = [];
const events: HealthTelemetryEvent[] = [];
const hc = new HealthChecker(new ProviderHolder(provider), {
onUpdate: (s) => updates.push(s),
onTelemetry: (e) => events.push(e)
});
await hc.runOnce();
await hc.runOnce();
expect(updates).toHaveLength(2);
expect(events).toEqual([{ kind: 'ollama_unreachable', reason: 'refused' }]);
});
it('runOnce({manual:true}) 가 ollama_recheck_manual 1회 fire', async () => {
const provider = new FakeProvider();
provider.results = [{ ok: true }];
const events: HealthTelemetryEvent[] = [];
const hc = new HealthChecker(new ProviderHolder(provider), { onTelemetry: (e) => events.push(e) });
await hc.runOnce({ manual: true });
expect(events).toEqual([{ kind: 'ollama_recheck_manual' }]);
});
});

View File

@@ -233,3 +233,81 @@ describe('ImportService', () => {
expect(dbNote!.media[0]!.bytes).toBe(7);
});
});
describe('ImportService — deletedAt preservation (v0.2.3 #4)', () => {
it('id-collide skip: source deleted_at IS NOT NULL → dest deleted_at 갱신', () => {
const db = new Database(':memory:');
runMigrations(db);
const repo = new NoteRepository(db);
const { id } = repo.create({ rawText: 'identical' });
const r = repo.importNote({
id, rawText: 'identical',
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: [],
deletedAt: '2026-05-01T12:00:00.000Z'
});
expect(r.status).toBe('skipped');
expect(repo.findById(id)!.deletedAt).toBe('2026-05-01T12:00:00.000Z');
});
it('id-collide skip: source deleted_at NULL + dest IS NOT NULL → dest 유지', () => {
const db = new Database(':memory:');
runMigrations(db);
const repo = new NoteRepository(db);
const { id } = repo.create({ rawText: 'identical' });
repo.trash(id, '2026-05-01T00:00:00.000Z');
repo.importNote({
id, rawText: 'identical',
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: [],
deletedAt: null
});
expect(repo.findById(id)!.deletedAt).toBe('2026-05-01T00:00:00.000Z');
});
it('id-new insert: source deletedAt 보존', () => {
const db = new Database(':memory:');
runMigrations(db);
const repo = new NoteRepository(db);
const r = repo.importNote({
id: 'fresh-id', rawText: 'fresh',
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: [],
deletedAt: '2026-05-01T12:00:00.000Z'
});
expect(r.status).toBe('inserted');
expect(repo.findById('fresh-id')!.deletedAt).toBe('2026-05-01T12:00:00.000Z');
});
it('id-collide forked: deletedAt 도 fork 노트에 보존', () => {
const db = new Database(':memory:');
runMigrations(db);
const repo = new NoteRepository(db);
const { id } = repo.create({ rawText: 'original' });
const r = repo.importNote({
id, rawText: 'different',
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: [],
deletedAt: '2026-05-01T12:00:00.000Z'
});
expect(r.status).toBe('forked');
expect(r.id).not.toBe(id);
expect(repo.findById(r.id)!.deletedAt).toBe('2026-05-01T12:00:00.000Z');
});
});

View File

@@ -0,0 +1,49 @@
// @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';
vi.mock('../../src/renderer/inbox/api.js', () => ({
inboxApi: {
getAppInfo: vi.fn(async () => ({
version: '0.2.7',
electron: '41.3.0',
node: '22.x',
os: 'darwin 23.6.0',
profileDir: '/Users/u/Library/Application Support/Inkling'
})),
openProfileDir: vi.fn(async () => undefined),
copyAppInfo: vi.fn(async () => undefined)
}
}));
import { InfoSection } from '../../src/renderer/inbox/components/settings/InfoSection';
describe('InfoSection', () => {
beforeEach(() => { vi.clearAllMocks(); cleanup(); });
it('renders version, electron, node, OS, profileDir', async () => {
render(<InfoSection />);
expect(await screen.findByText(/0\.2\.7/)).toBeInTheDocument();
expect(screen.getByText(/41\.3\.0/)).toBeInTheDocument();
expect(screen.getByText(/22\.x/)).toBeInTheDocument();
expect(screen.getByText(/darwin/)).toBeInTheDocument();
expect(screen.getByText(/Library\/Application Support\/Inkling/)).toBeInTheDocument();
});
it('"데이터 위치 열기" calls openProfileDir', async () => {
const { inboxApi } = await import('../../src/renderer/inbox/api.js');
render(<InfoSection />);
await screen.findByText(/0\.2\.7/);
fireEvent.click(screen.getByRole('button', { name: /데이터 위치 열기/ }));
await waitFor(() => expect(inboxApi.openProfileDir).toHaveBeenCalled());
});
it('"정보 복사" calls copyAppInfo', async () => {
const { inboxApi } = await import('../../src/renderer/inbox/api.js');
render(<InfoSection />);
await screen.findByText(/0\.2\.7/);
fireEvent.click(screen.getByRole('button', { name: /정보 복사/ }));
await waitFor(() => expect(inboxApi.copyAppInfo).toHaveBeenCalled());
});
});

View File

@@ -26,6 +26,25 @@ describe('LocalOllamaProvider', () => {
expect(r.title).toBe('회의');
});
it('generate passes vocab into prompt body', async () => {
let capturedBody: string = '';
mock.get('http://localhost:11434').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: ['design'] })
}) };
});
await new LocalOllamaProvider().generate({
text: 'x', todayKst: '2026-05-02', dueDateCandidates: [],
vocab: ['design', 'meeting']
});
const parsed = JSON.parse(capturedBody) as { prompt: string };
expect(parsed.prompt).toContain('design, meeting');
expect(parsed.prompt).toContain('Prefer reusing');
});
it('generate throws on non-JSON', async () => {
mock.get('http://localhost:11434').intercept({ path: '/api/generate', method: 'POST' }).reply(200, {
response: 'not json'
@@ -70,4 +89,24 @@ describe('LocalOllamaProvider', () => {
expect(h.ok).toBe(false);
expect(h.reason).toMatch(/connect|refused|unreachable/i);
});
it('abort() cancels in-flight generate (rejects with AbortError)', async () => {
mock.get('http://localhost:11434').intercept({
path: '/api/generate', method: 'POST'
}).reply((async () => {
await new Promise<void>((r) => setTimeout(r, 5000)); // long-running
return { statusCode: 200, data: '{}' };
}) as never);
const provider = new LocalOllamaProvider({ timeoutMs: 30_000 });
const generatePromise = provider.generate({
text: 'x', todayKst: '2026-05-04', dueDateCandidates: []
});
setTimeout(() => provider.abort(), 50);
await expect(generatePromise).rejects.toThrow();
});
it('constructor uses provided model param (not just default)', () => {
const provider = new LocalOllamaProvider({ model: 'gemma4:26b' });
expect(provider.name).toBe('local-ollama/gemma4:26b');
});
});

View File

@@ -0,0 +1,81 @@
// @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 type { Note } from '@shared/types';
const { mockOpenMedia } = vi.hoisted(() => ({
mockOpenMedia: vi.fn(async () => ({ ok: true }))
}));
vi.mock('../../src/renderer/inbox/api.js', () => ({
inboxApi: {
openMedia: mockOpenMedia,
deleteNote: vi.fn(),
restoreNote: vi.fn(),
permanentDeleteNote: vi.fn(),
updateAiFields: vi.fn(),
setDueDate: vi.fn(),
setIntent: vi.fn(),
dismissIntent: vi.fn()
}
}));
vi.mock('../../src/renderer/inbox/store.js', () => ({
useInbox: Object.assign(
() => ({}),
{ getState: () => ({ setTagFilter: vi.fn() }) }
)
}));
import { NoteCard } from '../../src/renderer/inbox/components/NoteCard';
const baseNote: Note = {
id: 'n1',
rawText: 'test',
aiTitle: 'T',
aiSummary: 'S',
aiStatus: 'done',
aiError: null,
aiProvider: null,
aiGeneratedAt: '2026-05-09T00:00:00Z',
titleEditedByUser: false,
summaryEditedByUser: false,
userIntent: null,
intentPromptedAt: '2026-05-09T00:00:00Z',
dueDate: null,
dueDateEditedByUser: false,
deletedAt: null,
lastRecalledAt: null,
recallDismissedAt: null,
createdAt: '2026-05-09T00:00:00Z',
updatedAt: '2026-05-09T00:00:00Z',
tags: [],
media: [
{ id: 'm1', kind: 'image', relPath: 'media/n1/img1.png', mime: 'image/png', bytes: 100 },
{ id: 'm2', kind: 'image', relPath: 'media/n1/img2.jpg', mime: 'image/jpeg', bytes: 200 }
]
};
describe('NoteCard — image rendering', () => {
beforeEach(() => {
vi.clearAllMocks();
cleanup();
});
it('renders <img> for each media item', () => {
render(<NoteCard note={baseNote} onUpdated={() => {}} mode="inbox" />);
const imgs = screen.getAllByRole('presentation');
expect(imgs).toHaveLength(2);
expect(imgs[0]?.getAttribute('src')).toBe('inkling-media://media/n1/img1.png');
expect(imgs[1]?.getAttribute('src')).toBe('inkling-media://media/n1/img2.jpg');
});
it('clicking <img> calls inboxApi.openMedia', () => {
render(<NoteCard note={baseNote} onUpdated={() => {}} mode="inbox" />);
const first = screen.getAllByRole('presentation')[0];
if (first === undefined) throw new Error('expected at least one img');
fireEvent.click(first);
expect(mockOpenMedia).toHaveBeenCalledWith('media/n1/img1.png');
});
});

View File

@@ -214,4 +214,641 @@ describe('NoteRepository', () => {
expect(typeof n).toBe('number');
expect(n).toBeGreaterThanOrEqual(0);
});
it('findRecallCandidate returns null for empty db', () => {
expect(repo.findRecallCandidate()).toBeNull();
});
it('findRecallCandidate excludes notes recalled within 7 days', () => {
const id = repo.create({ rawText: 'x' }).id;
repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
// 5일 전 본 노트 → 제외
const fiveDaysAgo = new Date(Date.now() - 5 * 86_400_000).toISOString();
repo.markRecallOpened(id, fiveDaysAgo);
expect(repo.findRecallCandidate()).toBeNull();
});
it('findRecallCandidate includes notes recalled 8+ days ago', () => {
const id = repo.create({ rawText: 'x' }).id;
repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
const eightDaysAgo = new Date(Date.now() - 8 * 86_400_000).toISOString();
repo.markRecallOpened(id, eightDaysAgo);
expect(repo.findRecallCandidate()?.id).toBe(id);
});
it('findRecallCandidate respects dismiss expiry (25일 제외, 35일 후보)', () => {
const a = repo.create({ rawText: 'a' }).id;
const b = repo.create({ rawText: 'b' }).id;
repo.updateAiResult(a, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
repo.updateAiResult(b, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
const twentyFiveDaysAgo = new Date(Date.now() - 25 * 86_400_000).toISOString();
const thirtyFiveDaysAgo = new Date(Date.now() - 35 * 86_400_000).toISOString();
repo.dismissRecall(a, twentyFiveDaysAgo); // 25일 — 아직 dismiss 만료 안 됨
repo.dismissRecall(b, thirtyFiveDaysAgo); // 35일 — dismiss 만료, 재추천 가능
const candidate = repo.findRecallCandidate();
expect(candidate?.id).toBe(b);
});
it('findRecallCandidate excludes deleted/pending/imminent due', () => {
const todayKst = new Date(Date.now() + 9 * 60 * 60 * 1000).toISOString().slice(0, 10);
const yesterdayKst = new Date(Date.now() + 9 * 60 * 60 * 1000 - 86_400_000).toISOString().slice(0, 10);
// (a) deleted
const a = repo.create({ rawText: 'a' }).id;
repo.updateAiResult(a, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
repo.trash(a, new Date().toISOString());
// (b) pending (no AI)
repo.create({ rawText: 'b' });
// (c) due_date 어제
const c = repo.create({ rawText: 'c' }).id;
repo.updateAiResult(c, { title: 't', summary: 'a\nb\nc', tags: ['x'], dueDate: yesterdayKst, provider: 'p' });
expect(repo.findRecallCandidate()).toBeNull();
// (d) due_date today 는 OK (>=today 통과)
const d = repo.create({ rawText: 'd' }).id;
repo.updateAiResult(d, { title: 't', summary: 'a\nb\nc', tags: ['x'], dueDate: todayKst, provider: 'p' });
expect(repo.findRecallCandidate()?.id).toBe(d);
});
it('restoreNote re-enqueues failed note (ai_status reset to pending + pending_jobs INSERT)', () => {
const id = repo.create({ rawText: 'x' }).id;
repo.markAiFailed(id, 'unreachable');
repo.trash(id, new Date().toISOString());
expect(repo.findById(id)!.aiStatus).toBe('failed');
repo.restoreNote(id);
const after = repo.findById(id)!;
expect(after.deletedAt).toBeNull();
expect(after.aiStatus).toBe('pending');
expect(after.aiError).toBeNull();
const job = db.prepare('SELECT * FROM pending_jobs WHERE note_id=?').get(id);
expect(job).toBeDefined();
});
it('restoreNote does not re-enqueue done note', () => {
const id = repo.create({ rawText: 'x' }).id;
repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
repo.trash(id, new Date().toISOString());
expect(repo.findById(id)!.aiStatus).toBe('done');
repo.restoreNote(id);
expect(repo.findById(id)!.aiStatus).toBe('done');
const job = db.prepare('SELECT * FROM pending_jobs WHERE note_id=?').get(id);
expect(job).toBeUndefined();
});
it('restoreNote re-enqueues pending note (defensive)', () => {
const id = repo.create({ rawText: 'x' }).id;
// 인공적으로 pending_jobs 비운 후 trash
db.prepare('DELETE FROM pending_jobs WHERE note_id=?').run(id);
repo.trash(id, new Date().toISOString());
expect(repo.findById(id)!.aiStatus).toBe('pending');
repo.restoreNote(id);
expect(repo.findById(id)!.aiStatus).toBe('pending');
const job = db.prepare('SELECT * FROM pending_jobs WHERE note_id=?').get(id);
expect(job).toBeDefined();
});
});
describe('NoteRepository.trash', () => {
let db: Database.Database;
let repo: NoteRepository;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
});
it('sets deleted_at and removes pending_jobs row atomically', () => {
const { id } = repo.create({ rawText: 'x' });
expect(db.prepare('SELECT COUNT(*) AS c FROM pending_jobs WHERE note_id=?').get(id)).toMatchObject({ c: 1 });
repo.trash(id, '2026-05-01T12:00:00.000Z');
const note = repo.findById(id)!;
expect(note.deletedAt).toBe('2026-05-01T12:00:00.000Z');
expect(db.prepare('SELECT COUNT(*) AS c FROM pending_jobs WHERE note_id=?').get(id)).toMatchObject({ c: 0 });
});
it('updates updated_at to deletedAt timestamp', () => {
const { id } = repo.create({ rawText: 'x' });
repo.trash(id, '2026-05-01T12:00:00.000Z');
const note = repo.findById(id)!;
expect(note.updatedAt).toBe('2026-05-01T12:00:00.000Z');
});
it('is no-op when note does not exist', () => {
expect(() => repo.trash('nonexistent', '2026-05-01T12:00:00.000Z')).not.toThrow();
});
});
describe('NoteRepository.restore', () => {
let db: Database.Database;
let repo: NoteRepository;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
});
it('clears deleted_at on a trashed note', () => {
const { id } = repo.create({ rawText: 'x' });
repo.trash(id, '2026-05-01T12:00:00.000Z');
repo.restore(id);
const note = repo.findById(id)!;
expect(note.deletedAt).toBeNull();
});
it('updates updated_at', () => {
const { id } = repo.create({ rawText: 'x' });
repo.trash(id, '2026-05-01T12:00:00.000Z');
const before = repo.findById(id)!.updatedAt;
repo.restore(id);
const after = repo.findById(id)!.updatedAt;
expect(after).not.toBe(before);
});
it('is no-op on already-active note', () => {
const { id } = repo.create({ rawText: 'x' });
expect(() => repo.restore(id)).not.toThrow();
expect(repo.findById(id)!.deletedAt).toBeNull();
});
});
describe('NoteRepository.permanentDelete', () => {
let db: Database.Database;
let repo: NoteRepository;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
});
it('removes notes row + cascades note_tags / pending_jobs', () => {
const { id } = repo.create({ rawText: 'x' });
repo.updateAiResult(id, { title: 'T', summary: 'a\nb\nc', tags: ['tag-a'], provider: 'p', dueDate: null });
expect(db.prepare('SELECT COUNT(*) AS c FROM note_tags WHERE note_id=?').get(id)).toMatchObject({ c: 1 });
repo.permanentDelete(id);
expect(repo.findById(id)).toBeNull();
expect(db.prepare('SELECT COUNT(*) AS c FROM note_tags WHERE note_id=?').get(id)).toMatchObject({ c: 0 });
expect(db.prepare('SELECT COUNT(*) AS c FROM pending_jobs WHERE note_id=?').get(id)).toMatchObject({ c: 0 });
});
});
describe('NoteRepository.emptyTrash', () => {
let db: Database.Database;
let repo: NoteRepository;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
});
it('hard-deletes all trashed notes and returns their ids', () => {
const a = repo.create({ rawText: 'a' }).id;
const b = repo.create({ rawText: 'b' }).id;
const c = repo.create({ rawText: 'c' }).id;
repo.trash(a, '2026-05-01T00:00:00.000Z');
repo.trash(c, '2026-05-01T01:00:00.000Z');
const r = repo.emptyTrash();
expect(r.noteIds.sort()).toEqual([a, c].sort());
expect(repo.findById(a)).toBeNull();
expect(repo.findById(b)).not.toBeNull();
expect(repo.findById(c)).toBeNull();
});
it('returns empty array when trash is empty', () => {
expect(repo.emptyTrash()).toEqual({ noteIds: [] });
});
});
describe('NoteRepository.listTrashed', () => {
let db: Database.Database;
let repo: NoteRepository;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
});
it('returns trashed notes ordered by deleted_at DESC', () => {
const a = repo.create({ rawText: 'a' }).id;
const b = repo.create({ rawText: 'b' }).id;
const c = repo.create({ rawText: 'c' }).id;
repo.trash(a, '2026-05-01T00:00:00.000Z');
repo.trash(c, '2026-05-01T02:00:00.000Z');
repo.trash(b, '2026-05-01T01:00:00.000Z');
const r = repo.listTrashed({ limit: 50 });
expect(r.map((n) => n.id)).toEqual([c, b, a]);
});
it('excludes active notes', () => {
repo.create({ rawText: 'active' });
const r = repo.listTrashed({ limit: 50 });
expect(r).toEqual([]);
});
it('respects limit', () => {
for (let i = 0; i < 5; i++) {
const id = repo.create({ rawText: `n${i}` }).id;
repo.trash(id, `2026-05-01T0${i}:00:00.000Z`);
}
const r = repo.listTrashed({ limit: 3 });
expect(r).toHaveLength(3);
});
});
describe('NoteRepository.countTrashed', () => {
let db: Database.Database;
let repo: NoteRepository;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
});
it('returns 0 when no trash', () => {
repo.create({ rawText: 'active' });
expect(repo.countTrashed()).toBe(0);
});
it('counts only trashed notes', () => {
const a = repo.create({ rawText: 'a' }).id;
repo.create({ rawText: 'b (active)' });
const c = repo.create({ rawText: 'c' }).id;
repo.trash(a, '2026-05-01T00:00:00.000Z');
repo.trash(c, '2026-05-01T01:00:00.000Z');
expect(repo.countTrashed()).toBe(2);
});
it('returns count beyond listTrashed limit (no 200 cap drift)', () => {
// listTrashed limit cap is 200; countTrashed must reflect actual count.
for (let i = 0; i < 10; i++) {
const id = repo.create({ rawText: `n${i}` }).id;
repo.trash(id, `2026-05-01T${String(i).padStart(2, '0')}:00:00.000Z`);
}
expect(repo.countTrashed()).toBe(10);
expect(repo.listTrashed({ limit: 5 })).toHaveLength(5);
});
it('countTrashed returns accurate count (>200 not capped)', () => {
const now = new Date().toISOString();
for (let i = 0; i < 250; i++) {
const id = repo.create({ rawText: `n${i}` }).id;
repo.trash(id, now);
}
expect(repo.countTrashed()).toBe(250);
});
it('countTrashed returns 0 for empty trash', () => {
expect(repo.countTrashed()).toBe(0);
});
});
describe('Active queries exclude deleted notes', () => {
let db: Database.Database;
let repo: NoteRepository;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
});
it('list() excludes trashed', () => {
const a = repo.create({ rawText: 'a' }).id;
const b = repo.create({ rawText: 'b' }).id;
repo.trash(a, '2026-05-01T00:00:00.000Z');
const r = repo.list({ limit: 50 });
expect(r.map((n) => n.id)).toEqual([b]);
});
it('listAll() excludes trashed', () => {
const a = repo.create({ rawText: 'a' }).id;
repo.create({ rawText: 'b' });
repo.trash(a, '2026-05-01T00:00:00.000Z');
const r = repo.listAll();
expect(r.map((n) => n.rawText)).toEqual(['b']);
});
it('countToday() excludes trashed', () => {
const a = repo.create({ rawText: 'a' }).id;
repo.create({ rawText: 'b' });
repo.trash(a, new Date().toISOString());
expect(repo.countToday(new Date())).toBe(1);
});
it('findById() returns trashed notes (does NOT filter)', () => {
const { id } = repo.create({ rawText: 'a' });
repo.trash(id, '2026-05-01T00:00:00.000Z');
const note = repo.findById(id);
expect(note).not.toBeNull();
expect(note!.deletedAt).toBe('2026-05-01T00:00:00.000Z');
});
it('getPendingCount() excludes trashed pending notes (drift guard)', () => {
const a = repo.create({ rawText: 'a' }).id; // ai_status=pending
repo.create({ rawText: 'b' }); // ai_status=pending
expect(repo.getPendingCount()).toBe(2);
// trash() 가 pending_jobs row 는 정리하지만 notes.ai_status 는 'pending' 그대로.
// getPendingCount 가 deleted_at IS NOT NULL 노트 포함하면 영구 over-count.
repo.trash(a, '2026-05-01T00:00:00.000Z');
expect(repo.getPendingCount()).toBe(1);
});
});
describe('NoteRepository.findExpiredCandidates', () => {
let db: Database.Database;
let repo: NoteRepository;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
});
function makeDone(opts: {
rawText: string;
dueDate: string | null;
edited?: boolean;
deletedAt?: string | null;
aiStatus?: 'pending' | 'done' | 'failed';
}): string {
const { id } = repo.create({ rawText: opts.rawText });
db.prepare(
`UPDATE notes
SET due_date = ?,
due_date_edited_by_user = ?,
ai_status = ?,
deleted_at = ?
WHERE id = ?`
).run(
opts.dueDate,
opts.edited ? 1 : 0,
opts.aiStatus ?? 'done',
opts.deletedAt ?? null,
id
);
return id;
}
it('returns notes with due_date < today (KST), ORDER BY created_at DESC', () => {
const a = makeDone({ rawText: 'a', dueDate: '2026-04-20' });
db.prepare(`UPDATE notes SET created_at = ? WHERE id = ?`).run('2026-04-30T10:00:00Z', a);
const b = makeDone({ rawText: 'b', dueDate: '2026-04-25' });
db.prepare(`UPDATE notes SET created_at = ? WHERE id = ?`).run('2026-04-30T11:00:00Z', b);
const r = repo.findExpiredCandidates(new Date('2026-05-01T12:00:00Z'));
expect(r.map((n) => n.id)).toEqual([b, a]);
});
it('includes both AI-extracted and user-edited due_date (Q1=B 회귀 가드)', () => {
const ai = makeDone({ rawText: 'a', dueDate: '2026-04-20', edited: false });
const manual = makeDone({ rawText: 'b', dueDate: '2026-04-22', edited: true });
const r = repo.findExpiredCandidates(new Date('2026-05-01T12:00:00Z'));
expect(r.map((n) => n.id).sort()).toEqual([ai, manual].sort());
});
it('excludes trashed notes (deleted_at IS NOT NULL)', () => {
const a = makeDone({ rawText: 'a', dueDate: '2026-04-20' });
makeDone({ rawText: 'b', dueDate: '2026-04-21', deletedAt: '2026-04-30T00:00:00Z' });
const r = repo.findExpiredCandidates(new Date('2026-05-01T12:00:00Z'));
expect(r.map((n) => n.id)).toEqual([a]);
});
it('excludes pending / failed notes (ai_status != done)', () => {
const done = makeDone({ rawText: 'a', dueDate: '2026-04-20' });
makeDone({ rawText: 'b', dueDate: '2026-04-20', aiStatus: 'pending' });
makeDone({ rawText: 'c', dueDate: '2026-04-20', aiStatus: 'failed' });
const r = repo.findExpiredCandidates(new Date('2026-05-01T12:00:00Z'));
expect(r.map((n) => n.id)).toEqual([done]);
});
it('excludes notes with NULL due_date (NULL < string 평가 가드)', () => {
const dated = makeDone({ rawText: 'a', dueDate: '2026-04-20' });
makeDone({ rawText: 'b', dueDate: null });
const r = repo.findExpiredCandidates(new Date('2026-05-01T12:00:00Z'));
expect(r.map((n) => n.id)).toEqual([dated]);
});
it('excludes notes with due_date == today (boundary, not expired)', () => {
const past = makeDone({ rawText: 'a', dueDate: '2026-04-30' });
makeDone({ rawText: 'b', dueDate: '2026-05-01' });
const r = repo.findExpiredCandidates(new Date('2026-05-01T12:00:00Z'));
expect(r.map((n) => n.id)).toEqual([past]);
});
});
describe('NoteRepository.trashBatch', () => {
let db: Database.Database;
let repo: NoteRepository;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
});
it('atomically trashes all valid ids and returns trashedCount', () => {
const a = repo.create({ rawText: 'a' }).id;
const b = repo.create({ rawText: 'b' }).id;
const c = repo.create({ rawText: 'c' }).id;
const r = repo.trashBatch([a, b, c], '2026-05-01T12:00:00.000Z');
expect(r.trashedCount).toBe(3);
expect(repo.findById(a)!.deletedAt).toBe('2026-05-01T12:00:00.000Z');
expect(repo.findById(b)!.deletedAt).toBe('2026-05-01T12:00:00.000Z');
expect(repo.findById(c)!.deletedAt).toBe('2026-05-01T12:00:00.000Z');
expect(db.prepare('SELECT COUNT(*) AS c FROM pending_jobs WHERE note_id IN (?,?,?)').get(a, b, c))
.toMatchObject({ c: 0 });
});
it('returns trashedCount=0 for empty array (no-op)', () => {
const r = repo.trashBatch([], '2026-05-01T12:00:00.000Z');
expect(r.trashedCount).toBe(0);
});
it('skips ids that are already trashed (idempotent — count = 0 transitions)', () => {
const a = repo.create({ rawText: 'a' }).id;
repo.trash(a, '2026-04-30T00:00:00.000Z');
const r = repo.trashBatch([a], '2026-05-01T12:00:00.000Z');
expect(r.trashedCount).toBe(0);
expect(repo.findById(a)!.deletedAt).toBe('2026-04-30T00:00:00.000Z');
});
it('counts only the valid active ids (mix of valid + invalid + already-trashed)', () => {
const a = repo.create({ rawText: 'a' }).id;
const b = repo.create({ rawText: 'b' }).id;
repo.trash(b, '2026-04-30T00:00:00.000Z');
const r = repo.trashBatch([a, b, 'nonexistent-id'], '2026-05-01T12:00:00.000Z');
expect(r.trashedCount).toBe(1);
expect(repo.findById(a)!.deletedAt).toBe('2026-05-01T12:00:00.000Z');
});
});
describe('NoteRepository — failed retry helpers', () => {
let db: Database.Database;
let repo: NoteRepository;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
});
function makeFailed(rawText: string, deletedAt: string | null = null): string {
const { id } = repo.create({ rawText });
db.prepare(
`UPDATE notes SET ai_status='failed', ai_error='boom', deleted_at=? WHERE id=?`
).run(deletedAt, id);
db.prepare(`DELETE FROM pending_jobs WHERE note_id=?`).run(id);
return id;
}
it('findFailedIds returns ai_status=failed AND deleted_at IS NULL only', () => {
const a = makeFailed('a');
makeFailed('b', '2026-04-30T00:00:00Z'); // trashed
repo.create({ rawText: 'pending' }); // pending status
expect(repo.findFailedIds().sort()).toEqual([a].sort());
});
it('countFailed counts active failed notes only', () => {
makeFailed('a');
makeFailed('b');
makeFailed('c', '2026-04-30T00:00:00Z');
expect(repo.countFailed()).toBe(2);
});
it('retryAllFailed atomic — ai_status reset + pending_jobs 재투입', () => {
const a = makeFailed('a');
const b = makeFailed('b');
const r = repo.retryAllFailed('2026-05-01T12:00:00.000Z');
expect(r.ids.sort()).toEqual([a, b].sort());
expect(repo.findById(a)!.aiStatus).toBe('pending');
expect(repo.findById(b)!.aiStatus).toBe('pending');
expect(repo.findById(a)!.aiError).toBeNull();
const jobs = repo.getAllPendingJobs();
expect(jobs.map((j) => j.noteId).sort()).toEqual([a, b].sort());
for (const j of jobs) {
expect(j.attempts).toBe(0);
expect(j.nextRunAt).toBe('2026-05-01T12:00:00.000Z');
}
});
it('retryAllFailed empty — { ids: [] }', () => {
expect(repo.retryAllFailed('2026-05-01T12:00:00.000Z')).toEqual({ ids: [] });
});
it('retryAllFailed — pending_jobs 이미 존재 시 OR IGNORE (race 안전)', () => {
// failed 노트인데 pending_jobs row 가 이미 존재하는 비정상 race 상태 시뮬레이션.
// attempts=2, next_run_at=과거 — retryAllFailed 가 INSERT OR IGNORE 라 그대로 보존되어야.
const id = makeFailed('a');
db.prepare(`INSERT INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 2, ?)`)
.run(id, '2026-04-30T00:00:00.000Z');
const r = repo.retryAllFailed('2026-05-01T12:00:00.000Z');
expect(r.ids).toEqual([id]);
const jobs = repo.getAllPendingJobs().filter((j) => j.noteId === id);
expect(jobs).toHaveLength(1); // duplicate 안 됨
// OR IGNORE 라 기존 row 보존 — attempts=2, nextRunAt 그대로
expect(jobs[0]!.attempts).toBe(2);
expect(jobs[0]!.nextRunAt).toBe('2026-04-30T00:00:00.000Z');
});
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');
// attempts 가 1 이 됨
repo.setNextRunAt(id, '2026-05-01T12:00:00.000Z', 'unreachable');
const job = repo.getAllPendingJobs().find((j) => j.noteId === id)!;
expect(job.attempts).toBe(1); // 변화 없음
expect(job.nextRunAt).toBe('2026-05-01T12:00:00.000Z');
});
it('getTopUsedTags returns [] when no notes', () => {
expect(repo.getTopUsedTags()).toEqual([]);
});
it('getTopUsedTags orders by count desc, id asc tiebreaker', () => {
const a = repo.create({ rawText: 'a' }).id;
const b = repo.create({ rawText: 'b' }).id;
const c = repo.create({ rawText: 'c' }).id;
repo.updateAiResult(a, { title: 't', summary: 'a\nb\nc', tags: ['design', 'meeting'], provider: 'p' });
repo.updateAiResult(b, { title: 't', summary: 'a\nb\nc', tags: ['design'], provider: 'p' });
repo.updateAiResult(c, { title: 't', summary: 'a\nb\nc', tags: ['design', 'meeting', 'qa'], provider: 'p' });
// counts: design=3, meeting=2, qa=1
expect(repo.getTopUsedTags()).toEqual(['design', 'meeting', 'qa']);
});
it('getTopUsedTags filters non-kebab-case (한글/대문자/공백)', () => {
const a = repo.create({ rawText: 'a' }).id;
// user route 가 한글/공백 태그 들어올 수 있음 → vocab 에서 제외 검증
repo.updateUserAiFields(a, { tags: ['design', '회의', 'Meeting', 'two words', 'api-timeout'] });
expect(repo.getTopUsedTags()).toEqual(expect.arrayContaining(['design', 'api-timeout']));
expect(repo.getTopUsedTags()).not.toContain('회의');
expect(repo.getTopUsedTags()).not.toContain('Meeting');
expect(repo.getTopUsedTags()).not.toContain('two words');
});
it('getTopUsedTags counts AI + user sources together', () => {
const a = repo.create({ rawText: 'a' }).id;
const b = repo.create({ rawText: 'b' }).id;
const c = repo.create({ rawText: 'c' }).id;
// design: 1 AI (a) + 1 user (b) = 2 total; meeting: 1 AI (c) = 1 total
// → design must rank first (proves source merging, not AI-only count)
// Note: updateUserAiFields REPLACES tags (DELETE+reinsert), so each note
// gets exactly the tags passed in the call.
repo.updateAiResult(a, { title: 't', summary: 'x\ny\nz', tags: ['design'], provider: 'p' });
repo.updateUserAiFields(b, { tags: ['design'] });
repo.updateAiResult(c, { title: 't', summary: 'x\ny\nz', tags: ['meeting'], provider: 'p' });
const top = repo.getTopUsedTags();
expect(top[0]).toBe('design'); // 2 (AI+user) > 1 (AI only)
expect(top.indexOf('meeting')).toBeGreaterThan(0);
});
it('getTopUsedTags excludes tags from deleted notes', () => {
const a = repo.create({ rawText: 'a' }).id;
repo.updateAiResult(a, { title: 't', summary: 'x\ny\nz', tags: ['lonely'], provider: 'p' });
repo.trash(a, new Date().toISOString());
expect(repo.getTopUsedTags()).not.toContain('lonely');
});
it('getTopUsedTags respects LIMIT parameter', () => {
const ids: string[] = [];
for (let i = 0; i < 5; i++) {
const id = repo.create({ rawText: `n${i}` }).id;
ids.push(id);
repo.updateAiResult(id, {
title: 't', summary: 'a\nb\nc',
tags: [`tag-${i}`],
provider: 'p'
});
}
expect(repo.getTopUsedTags(3)).toHaveLength(3);
expect(repo.getTopUsedTags(10)).toHaveLength(5);
});
it('getTopUsedTags result may be shorter than limit when top-N includes non-kebab tags', () => {
// 비-kebab 1개 (한글) + kebab 2개 → top-3 으로 SQL 가져온 후 regex 필터로 한글 제외
// 결과 length = 2 (limit=3 보다 작음)
const a = repo.create({ rawText: 'a' }).id;
const b = repo.create({ rawText: 'b' }).id;
const c = repo.create({ rawText: 'c' }).id;
repo.updateUserAiFields(a, { tags: ['회의'] }); // 한글 — SQL top 에 포함될 수 있지만 regex 통과 X
repo.updateUserAiFields(b, { tags: ['design'] });
repo.updateUserAiFields(c, { tags: ['meeting'] });
const top = repo.getTopUsedTags(3);
expect(top.length).toBeLessThan(3); // SQL 은 3개 가져왔지만 regex 가 1개 제거
expect(top).not.toContain('회의');
expect(top).toEqual(expect.arrayContaining(['design', 'meeting']));
});
it('getTagIdByName returns id when present, null when absent', () => {
const a = repo.create({ rawText: 'a' }).id;
repo.updateAiResult(a, { title: 't', summary: 'a\nb\nc', tags: ['hello'], provider: 'p' });
const id = repo.getTagIdByName('hello');
expect(typeof id).toBe('number');
expect(id).toBeGreaterThan(0);
// case-insensitive
expect(repo.getTagIdByName('HELLO')).toBe(id);
// absent
expect(repo.getTagIdByName('nothere')).toBeNull();
});
});

View File

@@ -0,0 +1,30 @@
import { describe, it, expect, vi } from 'vitest';
import { ProviderHolder } from '@main/ai/ProviderHolder.js';
import { LocalOllamaProvider } from '@main/ai/LocalOllamaProvider.js';
describe('ProviderHolder', () => {
it('replace() fires listener and get() returns new instance', () => {
const a = new LocalOllamaProvider({ endpoint: 'http://a:11434', model: 'm1' });
const b = new LocalOllamaProvider({ endpoint: 'http://b:11434', model: 'm2' });
const holder = new ProviderHolder(a);
const listener = vi.fn();
holder.onReplace(listener);
expect(holder.get()).toBe(a);
holder.replace(b);
expect(holder.get()).toBe(b);
expect(listener).toHaveBeenCalledWith(b);
});
it('multiple listeners all fire on replace()', () => {
const a = new LocalOllamaProvider({ model: 'm1' });
const b = new LocalOllamaProvider({ model: 'm2' });
const holder = new ProviderHolder(a);
const l1 = vi.fn();
const l2 = vi.fn();
holder.onReplace(l1);
holder.onReplace(l2);
holder.replace(b);
expect(l1).toHaveBeenCalledWith(b);
expect(l2).toHaveBeenCalledWith(b);
});
});

View File

@@ -0,0 +1,74 @@
// @vitest-environment jsdom
import { describe, it, expect, beforeEach, vi } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
// inboxApi 는 window.inkling.inbox 를 참조하므로 jsdom 환경에서 import 자체가 throw.
// SettingsPage 가 마운트하는 AiProviderSection 의 useEffect 가 loadOllamaSettings 를 호출하므로
// 빈 객체 대신 필요한 메서드를 stub 한다.
vi.mock('../../src/renderer/inbox/api.js', () => ({
inboxApi: {
loadOllamaSettings: vi.fn(async () => null),
saveOllamaSettings: vi.fn(async () => ({ ok: true })),
ollamaRecheck: vi.fn(async () => ({ ok: true })),
getAutostart: vi.fn(async () => ({
openAtLogin: false,
diagnostic: {
withArgs: { openAtLogin: false, executableWillLaunchAtLogin: false },
noArgs: { openAtLogin: false, executableWillLaunchAtLogin: false },
execPath: '/p'
}
})),
setAutostart: vi.fn(async (open: boolean) => ({
openAtLogin: open,
diagnostic: {
withArgs: { openAtLogin: open, executableWillLaunchAtLogin: open },
noArgs: { openAtLogin: open, executableWillLaunchAtLogin: open },
execPath: '/p'
}
})),
runBackup: vi.fn(async () => ({ ok: true })),
runExport: vi.fn(async () => ({ ok: true })),
runImport: vi.fn(async () => ({ ok: true })),
runSync: vi.fn(async () => ({ ok: true })),
runExportTelemetry: vi.fn(async () => ({ ok: true })),
getAppInfo: vi.fn(async () => ({
version: '0.2.7',
electron: '41.3.0',
node: '22.x',
os: 'darwin 23.6.0',
profileDir: '/tmp/Inkling'
})),
openProfileDir: vi.fn(async () => undefined),
copyAppInfo: vi.fn(async () => undefined)
}
}));
import { SettingsPage } from '../../src/renderer/inbox/components/SettingsPage';
import { useInbox } from '../../src/renderer/inbox/store';
describe('SettingsPage', () => {
beforeEach(() => {
cleanup();
useInbox.setState({ showSettings: true });
});
it('renders header with "← 돌아가기" button', () => {
render(<SettingsPage />);
expect(screen.getByRole('button', { name: /돌아가기/ })).toBeInTheDocument();
});
it('renders 4 section headings', () => {
render(<SettingsPage />);
expect(screen.getByText('AI 제공자')).toBeInTheDocument();
expect(screen.getByText('자동 실행')).toBeInTheDocument();
expect(screen.getByText('백업 / 복원')).toBeInTheDocument();
expect(screen.getByText('정보')).toBeInTheDocument();
});
it('clicking "← 돌아가기" sets showSettings to false', () => {
render(<SettingsPage />);
fireEvent.click(screen.getByRole('button', { name: /돌아가기/ }));
expect(useInbox.getState().showSettings).toBe(false);
});
});

View File

@@ -0,0 +1,57 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { SettingsService } from '@main/services/SettingsService.js';
describe('SettingsService', () => {
let dir: string;
let svc: SettingsService;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'inkling-settings-'));
svc = new SettingsService(dir);
});
afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
it('load() returns empty object when file does not exist', async () => {
const s = await svc.load();
expect(s).toEqual({});
});
it('load() returns empty object on corrupted JSON (no throw)', async () => {
writeFileSync(join(dir, 'settings.json'), '{ this is not json');
const s = await svc.load();
expect(s).toEqual({});
});
it('load() caches result — second call does not re-read file', async () => {
await svc.setOllama({ endpoint: 'http://localhost:11434', model: 'gemma4:e4b' });
const before = await svc.load();
// 외부에서 파일 변경
writeFileSync(join(dir, 'settings.json'), JSON.stringify({ ollama: { endpoint: 'http://lan:11434', model: 'gemma4:26b' } }));
const after = await svc.load();
// 캐시 적용 — 파일 변경 무시
expect(after).toEqual(before);
});
it('setOllama() throws on non-URL endpoint', async () => {
await expect(
svc.setOllama({ endpoint: 'not-a-url', model: 'gemma4:e4b' })
).rejects.toThrow();
});
it('setOllama() persists to disk with valid JSON', async () => {
await svc.setOllama({ endpoint: 'http://localhost:11435', model: 'gemma4:e4b' });
const raw = readFileSync(join(dir, 'settings.json'), 'utf8');
const parsed = JSON.parse(raw);
expect(parsed.ollama.endpoint).toBe('http://localhost:11435');
expect(parsed.ollama.model).toBe('gemma4:e4b');
});
it('setOllama() atomic write — tmp file does not remain', async () => {
await svc.setOllama({ endpoint: 'http://localhost:11434', model: 'gemma4:e4b' });
expect(existsSync(join(dir, 'settings.json.tmp'))).toBe(false);
expect(existsSync(join(dir, 'settings.json'))).toBe(true);
});
});

View File

@@ -0,0 +1,213 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, readFileSync, existsSync, readdirSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { TelemetryService } from '@main/services/TelemetryService.js';
import { hasNoteId } from '@main/services/telemetryEvents.js';
describe('TelemetryService.emit', () => {
let dir: string;
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'inkling-telem-')); });
afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
it('appends a JSONL line to events-YYYY-MM-DD.jsonl (KST date)', async () => {
// 2026-05-01 12:00 UTC → 2026-05-01 21:00 KST
const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z'));
await svc.emit({ kind: 'capture', payload: { noteId: 'n1', rawTextLength: 5, hasMedia: false } });
const file = join(dir, 'events-2026-05-01.jsonl');
expect(existsSync(file)).toBe(true);
const content = readFileSync(file, 'utf8').trim();
const parsed = JSON.parse(content);
expect(parsed.kind).toBe('capture');
expect(parsed.payload.noteId).toBe('n1');
expect(typeof parsed.ts).toBe('string');
});
it('uses KST date even when UTC date differs (around midnight)', async () => {
// 2026-05-01 23:30 UTC → 2026-05-02 08:30 KST
const svc = new TelemetryService(dir, () => new Date('2026-05-01T23:30:00Z'));
await svc.emit({ kind: 'capture', payload: { noteId: 'n2', rawTextLength: 1, hasMedia: false } });
expect(existsSync(join(dir, 'events-2026-05-02.jsonl'))).toBe(true);
});
it('appends multiple events to same-day file', async () => {
const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z'));
await svc.emit({ kind: 'capture', payload: { noteId: 'n1', rawTextLength: 5, hasMedia: false } });
await svc.emit({ kind: 'ai_succeeded', payload: { noteId: 'n1', durationMs: 100, attempts: 0 } });
const lines = readFileSync(join(dir, 'events-2026-05-01.jsonl'), 'utf8').trim().split('\n');
expect(lines).toHaveLength(2);
expect(JSON.parse(lines[0]!).kind).toBe('capture');
expect(JSON.parse(lines[1]!).kind).toBe('ai_succeeded');
});
it('creates telemetry dir if absent', async () => {
const fresh = join(dir, 'nested', 'telem');
const svc = new TelemetryService(fresh, () => new Date('2026-05-01T12:00:00Z'));
await svc.emit({ kind: 'capture', payload: { noteId: 'n1', rawTextLength: 1, hasMedia: false } });
expect(existsSync(fresh)).toBe(true);
});
it('rejects malformed event (privacy invariant) — does NOT write file', async () => {
const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z'));
await expect(svc.emit({
kind: 'capture',
payload: { noteId: 'n1', rawTextLength: 1, hasMedia: false, rawText: 'leak' } as never
})).rejects.toThrow();
// No file should have been created
expect(readdirSync(dir).filter((f) => f.startsWith('events-'))).toEqual([]);
});
it('emit is silent (does not throw) when fs write fails — invariant: telemetry never breaks app', async () => {
// Make the "dir" actually a file so mkdir({recursive:true}) reliably fails on every platform.
// (Earlier draft used /proc/0/... which on Windows resolves to C:\proc\0\... and
// mkdir({recursive:true}) silently *creates* it, leaking filesystem side-effects + the
// silent code path was never exercised.)
const blockingFile = join(dir, 'this-is-a-file-not-a-dir');
writeFileSync(blockingFile, '');
const svc = new TelemetryService(
blockingFile,
() => new Date('2026-05-01T12:00:00Z'),
14,
{ silent: true }
);
await expect(svc.emit({
kind: 'capture',
payload: { noteId: 'n1', rawTextLength: 1, hasMedia: false }
})).resolves.toBeUndefined();
});
it('emit DOES throw when fs write fails AND silent is not set (default)', async () => {
// Companion case — confirms silent is opt-in. Without silent, fs failure surfaces.
const blockingFile = join(dir, 'block-default');
writeFileSync(blockingFile, '');
const svc = new TelemetryService(blockingFile, () => new Date('2026-05-01T12:00:00Z'));
await expect(svc.emit({
kind: 'capture',
payload: { noteId: 'n1', rawTextLength: 1, hasMedia: false }
})).rejects.toThrow();
});
});
describe('TelemetryService.cleanupOldFiles', () => {
let dir: string;
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'inkling-telem-')); });
afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
it('removes events-*.jsonl older than retentionDays', async () => {
// 시드: 오래된 파일 + 최근 파일
writeFileSync(join(dir, 'events-2026-04-01.jsonl'), '{}\n'); // 30일 전
writeFileSync(join(dir, 'events-2026-04-25.jsonl'), '{}\n'); // 6일 전 (retain)
writeFileSync(join(dir, 'events-2026-05-01.jsonl'), '{}\n'); // 오늘 (retain)
const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z'), 14);
const r = await svc.cleanupOldFiles();
expect(r.removed).toEqual(['events-2026-04-01.jsonl']);
expect(existsSync(join(dir, 'events-2026-04-25.jsonl'))).toBe(true);
expect(existsSync(join(dir, 'events-2026-05-01.jsonl'))).toBe(true);
});
it('returns empty when no files match prefix', async () => {
writeFileSync(join(dir, 'unrelated.txt'), 'x');
const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z'), 14);
const r = await svc.cleanupOldFiles();
expect(r.removed).toEqual([]);
expect(existsSync(join(dir, 'unrelated.txt'))).toBe(true);
});
it('handles missing dir gracefully (no throw)', async () => {
const ghost = join(dir, 'ghost');
const svc = new TelemetryService(ghost, () => new Date('2026-05-01T12:00:00Z'), 14);
const r = await svc.cleanupOldFiles();
expect(r.removed).toEqual([]);
});
it('boundary: file exactly retentionDays old is retained', async () => {
// 2026-04-17 = 14일 전 (boundary, retain)
writeFileSync(join(dir, 'events-2026-04-17.jsonl'), '{}\n');
// 2026-04-16 = 15일 전 (delete)
writeFileSync(join(dir, 'events-2026-04-16.jsonl'), '{}\n');
const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z'), 14);
const r = await svc.cleanupOldFiles();
expect(r.removed).toEqual(['events-2026-04-16.jsonl']);
expect(existsSync(join(dir, 'events-2026-04-17.jsonl'))).toBe(true);
});
});
describe('TelemetryService.readAllRecent', () => {
let dir: string;
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'inkling-telem-')); });
afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
it('reads events from all files within retentionDays', async () => {
writeFileSync(join(dir, 'events-2026-04-25.jsonl'),
JSON.stringify({ ts: '2026-04-25T00:00:00.000Z', kind: 'capture', payload: { noteId: 'a', rawTextLength: 1, hasMedia: false } }) + '\n');
writeFileSync(join(dir, 'events-2026-05-01.jsonl'),
JSON.stringify({ ts: '2026-05-01T00:00:00.000Z', kind: 'capture', payload: { noteId: 'b', rawTextLength: 2, hasMedia: false } }) + '\n' +
JSON.stringify({ ts: '2026-05-01T01:00:00.000Z', kind: 'ai_succeeded', payload: { noteId: 'b', durationMs: 100, attempts: 0 } }) + '\n');
const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z'), 14);
const events = await svc.readAllRecent();
expect(events).toHaveLength(3);
// discriminant narrowing — noteId 없는 kind(empty_trash/expired_banner_shown/expired_batch_trash) 가 섞이면 명시적으로 실패
expect(events.map((e) => hasNoteId(e) ? e.payload.noteId : null)).toEqual(['a', 'b', 'b']);
});
it('skips malformed lines (silent — invariant)', async () => {
writeFileSync(join(dir, 'events-2026-05-01.jsonl'),
'not-json\n' +
JSON.stringify({ ts: '2026-05-01T00:00:00.000Z', kind: 'capture', payload: { noteId: 'a', rawTextLength: 1, hasMedia: false } }) + '\n' +
'{}\n'); // valid JSON but invalid event
const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z'), 14);
const events = await svc.readAllRecent();
expect(events).toHaveLength(1);
const ev = events[0]!;
expect(ev.kind).toBe('capture');
if (hasNoteId(ev)) expect(ev.payload.noteId).toBe('a');
});
it('returns [] when dir missing', async () => {
const ghost = join(dir, 'ghost');
const svc = new TelemetryService(ghost, () => new Date('2026-05-01T12:00:00Z'), 14);
const events = await svc.readAllRecent();
expect(events).toEqual([]);
});
it('returns [] when dir empty', async () => {
const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z'), 14);
expect(await svc.readAllRecent()).toEqual([]);
});
});
describe('TelemetryService.exportTo', () => {
let dir: string;
let outDir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'inkling-telem-'));
outDir = mkdtempSync(join(tmpdir(), 'inkling-export-'));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
rmSync(outDir, { recursive: true, force: true });
});
it('writes events.jsonl (concat) + stats.md to folder', async () => {
writeFileSync(join(dir, 'events-2026-05-01.jsonl'),
JSON.stringify({ ts: '2026-05-01T00:00:00.000Z', kind: 'capture', payload: { noteId: 'a', rawTextLength: 1, hasMedia: false } }) + '\n');
const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z'), 14);
const r = await svc.exportTo(outDir);
expect(r.eventCount).toBe(1);
expect(existsSync(join(outDir, 'events.jsonl'))).toBe(true);
expect(existsSync(join(outDir, 'stats.md'))).toBe(true);
const events = readFileSync(join(outDir, 'events.jsonl'), 'utf8').trim().split('\n');
expect(events).toHaveLength(1);
const stats = readFileSync(join(outDir, 'stats.md'), 'utf8');
expect(stats).toContain('총 이벤트: 1');
});
it('handles empty input — writes 0-event stats', async () => {
const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z'), 14);
const r = await svc.exportTo(outDir);
expect(r.eventCount).toBe(0);
expect(readFileSync(join(outDir, 'events.jsonl'), 'utf8')).toBe('');
expect(readFileSync(join(outDir, 'stats.md'), 'utf8')).toContain('총 이벤트: 0');
});
});

View File

@@ -0,0 +1,62 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { join } from 'node:path';
const { handlers, mockOpenPath } = vi.hoisted(() => ({
handlers: {} as Record<string, (...args: unknown[]) => unknown>,
mockOpenPath: vi.fn(async () => '')
}));
vi.mock('electron', () => ({
default: {
ipcMain: {
handle: (ch: string, fn: (...args: unknown[]) => unknown) => {
handlers[ch] = fn;
}
},
dialog: {},
shell: { openPath: mockOpenPath }
}
}));
import { registerInboxApi } from '../../src/main/ipc/inboxApi';
function makeDeps(profileDir: string): Parameters<typeof registerInboxApi>[0] {
// Minimal stub — `inbox:open-media` 핸들러는 deps.paths.profileDir 만 참조.
return {
repo: {} as never,
continuity: {} as never,
capture: {} as never,
health: {} as never,
intent: {} as never,
getInboxWindow: () => null,
settings: {} as never,
providerHolder: {} as never,
paths: { profileDir }
};
}
describe('inbox:open-media IPC', () => {
beforeEach(() => {
Object.keys(handlers).forEach((k) => delete handlers[k]);
mockOpenPath.mockClear();
});
it('opens valid relPath with shell.openPath', async () => {
registerInboxApi(makeDeps('/profile'));
const handler = handlers['inbox:open-media'];
if (handler === undefined) throw new Error('handler not registered');
const r = await handler(null, 'media/note1/img.png');
expect(r).toEqual({ ok: true });
expect(mockOpenPath).toHaveBeenCalledWith(join('/profile', 'media', 'note1', 'img.png'));
});
it('rejects path traversal with reason "invalid path"', async () => {
registerInboxApi(makeDeps('/profile'));
const handler = handlers['inbox:open-media'];
if (handler === undefined) throw new Error('handler not registered');
const r = await handler(null, '../etc/passwd') as { ok: boolean; reason?: string };
expect(r.ok).toBe(false);
expect(r.reason).toBe('invalid path');
expect(mockOpenPath).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,74 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { join, sep } from 'node:path';
const { mockReadFile, mockHandle, mockRegisterSchemes } = vi.hoisted(() => ({
mockReadFile: vi.fn(),
mockHandle: vi.fn(),
mockRegisterSchemes: vi.fn()
}));
vi.mock('node:fs/promises', () => ({
readFile: mockReadFile
}));
vi.mock('electron', () => ({
default: {
protocol: {
registerSchemesAsPrivileged: mockRegisterSchemes,
handle: mockHandle
}
}
}));
import { registerInklingMediaProtocol, inferMime } from '../../src/main/protocol/inklingMedia';
describe('inferMime', () => {
it('returns image/png for .png', () => { expect(inferMime('foo.png')).toBe('image/png'); });
it('returns image/jpeg for .jpg and .jpeg', () => {
expect(inferMime('foo.jpg')).toBe('image/jpeg');
expect(inferMime('foo.jpeg')).toBe('image/jpeg');
});
it('returns image/gif for .gif', () => { expect(inferMime('foo.gif')).toBe('image/gif'); });
it('returns image/webp for .webp', () => { expect(inferMime('foo.webp')).toBe('image/webp'); });
it('returns application/octet-stream for unknown', () => { expect(inferMime('foo.xyz')).toBe('application/octet-stream'); });
});
describe('inkling-media protocol handler', () => {
beforeEach(() => { vi.clearAllMocks(); });
function getHandler(profileDir: string): (req: { url: string }) => Promise<Response> {
registerInklingMediaProtocol(profileDir);
const call = mockHandle.mock.calls[0];
if (!call) throw new Error('protocol.handle not called');
return call[1] as (req: { url: string }) => Promise<Response>;
}
// 실 운영 (Electron protocol.handle) 에서는 req.url 이 raw 문자열로 전달되지만,
// vitest 의 `new Request()` constructor 는 url 을 즉시 normalize (`/../` 제거) 함.
// 따라서 traversal 검사 로직이 raw URL 단계에서 작동하는지 검증하려면
// raw url 을 보존한 minimal mock 을 직접 전달.
function rawReq(url: string): { url: string } { return { url }; }
it('serves valid file with correct mime', async () => {
mockReadFile.mockResolvedValueOnce(Buffer.from([1, 2, 3]));
const handler = getHandler('/profile');
const res = await handler(rawReq('inkling-media://media/note1/img.png'));
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toBe('image/png');
expect(mockReadFile).toHaveBeenCalledWith(join('/profile', 'media', 'note1', 'img.png'));
});
it('returns 403 on path traversal attempt', async () => {
const handler = getHandler('/profile');
const res = await handler(rawReq('inkling-media://media/../etc/passwd'));
expect(res.status).toBe(403);
expect(mockReadFile).not.toHaveBeenCalled();
});
it('returns 404 when file missing', async () => {
mockReadFile.mockRejectedValueOnce(new Error('ENOENT'));
const handler = getHandler('/profile');
const res = await handler(rawReq('inkling-media://media/note1/missing.png'));
expect(res.status).toBe(404);
});
});

View File

@@ -0,0 +1,63 @@
import { describe, it, expect } from 'vitest';
import { kstTodayIso, nextKstMidnightMs, kstTodayAsDate } from '@shared/util/kstDate.js';
describe('kstTodayIso', () => {
it('returns KST calendar date as YYYY-MM-DD', () => {
// 2026-05-01 12:00 UTC = 2026-05-01 21:00 KST
expect(kstTodayIso(new Date('2026-05-01T12:00:00Z'))).toBe('2026-05-01');
});
it('handles UTC→KST date rollover (UTC 23:30 → KST next day 08:30)', () => {
expect(kstTodayIso(new Date('2026-05-01T23:30:00Z'))).toBe('2026-05-02');
});
it('handles KST midnight exactly (UTC 15:00 = KST 00:00 next day)', () => {
expect(kstTodayIso(new Date('2026-05-01T15:00:00Z'))).toBe('2026-05-02');
});
it('boundary — UTC 14:59:59 still KST 23:59:59 same day', () => {
// KST 5/4 23:59:59 = UTC 5/4 14:59:59
const utcDate = new Date('2026-05-04T14:59:59Z');
expect(kstTodayIso(utcDate)).toBe('2026-05-04');
});
it('KST 5/5 00:30 (UTC 5/4 15:30) returns 2026-05-05', () => {
const utcDate = new Date('2026-05-04T15:30:00Z');
expect(kstTodayIso(utcDate)).toBe('2026-05-05');
});
});
describe('nextKstMidnightMs', () => {
it('returns the next KST 00:00 epoch ms (UTC 12:00 → +12h to KST midnight)', () => {
// 2026-05-01 12:00 UTC = 2026-05-01 21:00 KST → 다음 KST 자정 = 2026-05-02 00:00 KST
// = 2026-05-01 15:00 UTC
const now = Date.parse('2026-05-01T12:00:00Z');
const next = nextKstMidnightMs(now);
expect(new Date(next).toISOString()).toBe('2026-05-01T15:00:00.000Z');
});
it('returns 24h-from-now-ish when called shortly after KST midnight', () => {
// 2026-05-01 15:01 UTC = 2026-05-02 00:01 KST → 다음 KST 자정 = 2026-05-03 00:00 KST
// = 2026-05-02 15:00 UTC (≈ 23h59m later)
const now = Date.parse('2026-05-01T15:01:00Z');
const next = nextKstMidnightMs(now);
expect(new Date(next).toISOString()).toBe('2026-05-02T15:00:00.000Z');
expect(next - now).toBeGreaterThan(23 * 60 * 60 * 1000);
expect(next - now).toBeLessThan(24 * 60 * 60 * 1000);
});
it('KST 5/5 00:30 → next KST midnight = 5/6 00:00 KST = 5/5 15:00 UTC', () => {
const utcMs = new Date('2026-05-04T15:30:00Z').getTime();
const next = nextKstMidnightMs(utcMs);
expect(new Date(next).toISOString()).toBe('2026-05-05T15:00:00.000Z');
});
});
describe('kstTodayAsDate', () => {
it('returns UTC Date at KST 00:00', () => {
// KST 5/5 00:30 → KST 5/5 00:00 = UTC 5/4 15:00
const utcDate = new Date('2026-05-04T15:30:00Z');
const result = kstTodayAsDate(utcDate);
expect(result.toISOString()).toBe('2026-05-05T00:00:00.000Z');
});
});

View File

@@ -1,19 +1,11 @@
import { describe, it, expect } from 'vitest';
import Database from 'better-sqlite3';
import { runMigrations, latestVersion } from '@main/db/migrations/index.js';
import { runMigrations } from '@main/db/migrations/index.js';
describe('migrations m002 due_date', () => {
it('latestVersion returns 2', () => {
expect(latestVersion()).toBe(2);
});
it('runMigrations on fresh DB advances user_version to 2', () => {
const db = new Database(':memory:');
runMigrations(db);
const row = db.pragma('user_version', { simple: true });
expect(row).toBe(2);
});
// v3 (m003 soft_delete) lands in v0.2.3 #4 — latest version + user_version
// assertions migrate to migrations.test.ts. Here we keep only the m002-specific
// assertion (due_date column existence) which is version-stable.
it('due_date column exists with NULL default', () => {
const db = new Database(':memory:');
runMigrations(db);

View File

@@ -29,3 +29,47 @@ describe('migrations', () => {
db.close();
});
});
describe('migration v3 — soft delete columns', () => {
it('adds deleted_at, last_recalled_at, recall_dismissed_at to notes', () => {
const db = new Database(':memory:');
runMigrations(db);
const cols = db.prepare(`PRAGMA table_info(notes)`).all().map((r: any) => r.name);
expect(cols).toEqual(
expect.arrayContaining(['deleted_at', 'last_recalled_at', 'recall_dismissed_at'])
);
db.close();
});
it('creates idx_notes_deleted_at index', () => {
const db = new Database(':memory:');
runMigrations(db);
const indexes = db
.prepare(`SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='notes'`)
.all() as Array<{ name: string }>;
expect(indexes.map((i) => i.name)).toContain('idx_notes_deleted_at');
db.close();
});
it('user_version reaches 3', () => {
const db = new Database(':memory:');
runMigrations(db);
const row = db.prepare('PRAGMA user_version').get() as { user_version: number };
expect(row.user_version).toBe(3);
db.close();
});
it('all 3 new columns default to NULL', () => {
const db = new Database(':memory:');
runMigrations(db);
db.prepare(
`INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at)
VALUES ('n1', 't', 'pending', '2026-05-01T00:00:00Z', '2026-05-01T00:00:00Z')`
).run();
const row = db.prepare('SELECT deleted_at, last_recalled_at, recall_dismissed_at FROM notes WHERE id=?').get('n1') as any;
expect(row.deleted_at).toBeNull();
expect(row.last_recalled_at).toBeNull();
expect(row.recall_dismissed_at).toBeNull();
db.close();
});
});

31
tests/unit/prompt.test.ts Normal file
View File

@@ -0,0 +1,31 @@
import { describe, it, expect } from 'vitest';
import { buildPrompt, PROMPT_VERSION } from '@main/ai/prompt.js';
describe('prompt', () => {
it('PROMPT_VERSION is 4', () => {
expect(PROMPT_VERSION).toBe(4);
});
it('buildPrompt with empty vocab omits vocabulary line entirely', () => {
const out = buildPrompt('hello', '2026-05-02', [], []);
expect(out).not.toContain('vocabulary');
expect(out).not.toContain('Prefer reusing');
});
it('buildPrompt with vocab includes Prefer instruction + comma-separated list', () => {
const out = buildPrompt('hello', '2026-05-02', [], ['design', 'meeting', 'qa']);
expect(out).toContain('Existing vocabulary tags');
expect(out).toContain('design, meeting, qa');
expect(out).toContain('Prefer reusing');
});
it('vocab block appears after header and before JSON rules', () => {
const out = buildPrompt('hello', '2026-05-02', [], ['design']);
const headerIdx = out.indexOf("Today's date");
const vocabIdx = out.indexOf('Existing vocabulary');
const jsonRulesIdx = out.indexOf('Return a JSON object');
expect(headerIdx).toBeGreaterThan(-1);
expect(vocabIdx).toBeGreaterThan(headerIdx);
expect(jsonRulesIdx).toBeGreaterThan(vocabIdx);
});
});

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