78 Commits

Author SHA1 Message Date
2e9a82face Merge pull request 'v0.2.11 Cut D — FTS5 search + 회고 view (F19 A+D)' (#29) from worktree-v0211-cut-d-fts5-review into main
Reviewed-on: #29
2026-05-09 15:52:16 +00:00
altair823
735d5494f2 fix(v0211): importNote 가 rebuildFtsTagsForNote 호출 (final review fix)
final code review 발견: F5 import path 가 note_tags INSERT 후 notes_fts.tags 갱신
안 해서 import 한 노트의 tag 가 keyword 검색에서 매칭 안 되는 회귀.

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

NoteRepository 안 note_tags INSERT path 는 updateAiResult / updateUserAiFields /
importNote 3곳, 셋 다 rebuildFtsTagsForNote 호출 보장 — invariant 회복.
2026-05-10 00:46:58 +09:00
altair823
5801a98a00 chore(release): v0.2.11 — Cut D (FTS5 search + 회고 view)
- F19 promoted ( v0.2.11 Cut D — A+D 옵션)
- version 0.2.10 → 0.2.11 (package.json + package-lock.json)
- 단위 569 → 606 (m007 6 + tags sync 2 + ftsHelpers 7 + search 6 + reviewAggregate 5 + IPC 3 + store 3 + SearchBox 2 + ReviewView 3 = 37 신규)
- typecheck 0 errors
2026-05-10 00:41:42 +09:00
altair823
9feb712c60 feat(v0211): ReviewView — 일/주/월 회고 + 헤더 dropdown 진입점 2026-05-10 00:39:36 +09:00
altair823
be125b8ace feat(v0211): SearchBox + 헤더 mount + inbox 결과 렌더 분기
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 00:35:34 +09:00
altair823
f5e43133be feat(v0211): store — search + reviewData state + actions + view enum 확장
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 00:31:53 +09:00
altair823
143684ce8a feat(v0211): InboxApi.search + reviewAggregate (types + IPC + preload) 2026-05-10 00:27:43 +09:00
altair823
e60a2a23c8 feat(v0211): ftsHelpers + NoteRepository.search + reviewAggregate
- ftsHelpers: sanitizeFtsQuery (FTS5 special char escape) + computeCutoff (period → KST 자정)
- search: notes_fts MATCH + status filter + rank order + sanitize + 빈 query → []
- reviewAggregate: period 별 totalCount/recentNotes(50)/tagCounts(DESC)/dueProgress(passed/pending)
2026-05-10 00:24:24 +09:00
altair823
726d155d04 feat(v0211): rebuildFtsTagsForNote 헬퍼 + tags 변경 path 통합 (single write)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 00:19:14 +09:00
altair823
19edeab7b1 feat(v0211): m007 migration — notes_fts FTS5 + trigger 3 + backfill 2026-05-10 00:16:35 +09:00
altair823
1104a8c666 docs(plan): v0.2.11 Cut D — FTS5 search + 회고 view (spec m006→m007 정정 + ai_title/ai_summary + note_tags JOIN) 2026-05-10 00:11:12 +09:00
c4e7536086 Merge pull request 'v0.2.10 Cut C — raw_text 가변 + revision history (F20)' (#28) from worktree-v0210-cut-c-raw-text-revisions into main
Reviewed-on: #28
2026-05-09 14:52:26 +00:00
altair823
39b8d1e728 fix(v0210): importNote 가 capture revision 을 함께 INSERT (final review fix)
final code review 발견: F5 import 후 first user edit 시 import 시점 본문이
note_revisions 에 없어 history 에서 사라지는 회귀. importNote transaction 안
INSERT 추가 (createdAt = edited_at).

부수 작업: ImportNoteInput / importNote 의 "raw_text invariant guard" 주석을
v0.2.10 의 'fork-on-id-collision (sync determinism)' 정확한 의미로 갱신.

테스트 +2 — insert path / fork path 모두 capture revision 검증.
2026-05-09 20:59:37 +09:00
altair823
e32223d28c chore(release): v0.2.10 — Cut C (raw_text 가변 + revision history)
- F20 promoted ( v0.2.10 Cut C)
- version 0.2.9 → 0.2.10 (package.json + package-lock.json)
- 단위 548 → 567 (m006 5 + create rev 1 + repo 6 + IPC 4 + NoteCard 1 + Modal 2 + findById 회귀 1)
- typecheck 0 errors
2026-05-09 20:53:18 +09:00
altair823
81fbacb21e feat(v0210): RevisionHistoryModal — 이력 목록 + 회수 confirm + chain 보존
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 20:51:13 +09:00
altair823
ff1a015226 feat(v0210): NoteCard 원문 영역 편집 UI (textarea + 저장/취소 + updateRawText)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 20:47:51 +09:00
altair823
b4c2d85b26 feat(v0210): inbox:{update-raw-text,list-revisions,restore-revision} IPC 2026-05-09 20:44:52 +09:00
altair823
7541d3c9e4 feat(v0210): NoteRepository revision API + NoteRevision type + InboxApi 시그니처
- updateRawText: raw_text 갱신 + user revision INSERT (atomic)
- listRevisions: edited_at DESC 순 hydrate
- restoreRevision: 옛 raw_text 를 새 user revision 으로 복원 (chain 보존)
- shared/types: NoteRevision + InboxApi 3 메서드 (updateRawText/listRevisions/restoreRevision)
- preload: 3 IPC stub 추가 (inbox:update-raw-text / inbox:list-revisions / inbox:restore-revision)
2026-05-09 20:41:17 +09:00
altair823
18deee5900 feat(v0210): NoteRepository.create 가 capture revision 을 함께 INSERT
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 20:36:09 +09:00
altair823
76c23457ee feat(v0210): m006 migration — note_revisions 테이블 + capture backfill
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 20:32:32 +09:00
altair823
88ce78d860 docs(plan): v0.2.10 Cut C plan + spec m005→m006 정정 (Cut B 가 m005 선점) 2026-05-09 20:28:02 +09:00
altair823
07e61bc9e1 docs(plan): v0.2.9 Cut B implementation plan
17 task / 9 phase:
- Phase 1 (T1-2): m004 schema (status/status_changed_at/move_reason) + NoteRepository.setStatus/listByStatus + restoreNote 재구현
- Phase 2 (T3): ai_status 'disabled' enum + CaptureService aiEnabled 분기 (skip pending_jobs)
- Phase 3 (T4-5): useInbox view enum 4탭 + 헤더 4탭 UI + listByStatus IPC
- Phase 4 (T6-8): NoteCard 액션 메뉴 + MoveStatusModal (사유 입력 + 4 status 버튼) + setStatus IPC
- Phase 5 (T9-10): classifyStatus AI prompt + ai:classify-status IPC + AI 추천 UI
- Phase 6 (T11-12): OnboardingWizard 3 옵션 + 설치 가이드 + App.tsx 첫 launch 분기
- Phase 7 (T13-14): NoteCard ai_status='disabled' fallback (raw_text 첫 줄) + Banner ai_enabled=false 비활성 + HealthChecker polling 중단
- Phase 8 (T15-16): AiProviderSection AI 자동 처리 토글 + requeueDisabled (ON 전환 후 처리 버튼)
- Phase 9 (T17): 회귀 + dogfood F17/F18/F23 promoted + version 0.2.9 bump

선행 spec: 2026-05-09-v029-cut-b-design.md.
단위 472 → 약 510 (+38) 목표.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 17:59:00 +09:00
d59e8388b6 Merge pull request 'v0.2.9 Cut B — status 4분기 + 사유 + Ollama-less (F17/F18/F23)' (#27) from worktree-v029-cut-b-status-reason-ailess into main
Reviewed-on: #27
2026-05-09 08:43:10 +00:00
altair823
3fab44b466 chore(v029): final review minor cleanup — statusLabelWithParticle + initialTarget drop
- 한국어 조사 분기: '보관로/휴지통로/활성로' → '보관으로/휴지통으로/활성으로'
  ('완료로' 만 받침 X). 받침 jongseong 검사 helper.
- MoveStatusModal 의 unused initialTarget prop 제거 + caller (NoteCard) 정리

548/548 + typecheck 0 유지.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:46:55 +09:00
altair823
f42d03f70c fix(v029): e2e smoke test 가 OnboardingWizard dismiss 후 inbox 진입
Task 11-12 의 wizard 가 첫 launch e2e 환경 차단 — "나중에 설정" 클릭으로
wizard dismiss 후 inbox 헤더/empty state 검증. 회귀 1/1 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:42:14 +09:00
altair823
ba08190722 chore(release): v0.2.9 — Cut B (status 4분기 + 사유 + Ollama-less) 2026-05-09 16:40:06 +09:00
altair823
6070562358 feat(v029): NoteRepository.requeueDisabled + countByAiStatus + AiProviderSection 처리 버튼 2026-05-09 16:35:53 +09:00
altair823
c21fca57dd feat(v029): AiProviderSection AI 자동 처리 토글 + OFF 시 안내문 2026-05-09 16:31:24 +09:00
altair823
49fbed050a feat(v029): Banner + HealthChecker ai_enabled=false 시 비활성 (store ai_enabled field) 2026-05-09 16:25:24 +09:00
altair823
bc67dea2c8 feat(v029): NoteCard ai_status='disabled' fallback (raw_text 첫 줄 + summary/tags hide) 2026-05-09 16:25:17 +09:00
altair823
c65d6c810e feat(v029): settings:* IPC (ai-enabled/onboarding-completed/get) + App.tsx 첫 launch 분기 2026-05-09 16:18:27 +09:00
altair823
d2c7bf1b39 feat(v029): OnboardingWizard 3 옵션 + 설치 가이드 link 2026-05-09 16:18:19 +09:00
altair823
d3150976d4 feat(v029): classifyStatus AI prompt + ai:classify-status 정식 구현 (Task 8 stub 대체)
- src/main/ai/classifyStatus.ts: prompt + JSON parse + 안전 fallback (archived).
- InferenceProvider.generateRaw 추가 (optional) + LocalOllamaProvider 구현
  (Ollama /api/generate format:'json' 으로 raw JSON 응답 반환).
- inboxApi 의 ai:classify-status 핸들러를 stub 에서 정식 호출로 교체
  (deps.repo.findById + deps.providerHolder.get + classifyStatus()).
- 신규 테스트 7건 (classifyStatus 단위) + IPC 3건 (note 없음 / AI throw / 정상).
- 회귀: 513 → 522 통과.
2026-05-09 16:09:33 +09:00
altair823
495c3d12a2 feat(v029): NoteCard 이동 메뉴 (status 4분기 dropdown)
Cut B Task 6 — 모든 view 공통 "이동 ▾" dropdown.

- 기존 휴지통/삭제 버튼 위치에 dropdown 추가 (모든 mode 공통)
- 현재 status 외 3개 목적지만 표시 (active 노트 → 완료/보관/휴지통)
- 메뉴 항목 클릭 → MoveStatusModal(initialTarget) 열기
- onMoved → local 상태 갱신 + onUpdated + (status 변경 시) onDeleted (list 제거)
- trash mode 의 영구 삭제/복구 버튼은 보존 (휴지통 단독 액션)
- 사용되지 않게 된 handleDelete 제거 (deleteNote 는 capture path 만)
- NoteCard 메뉴 단위 테스트 2건 (메뉴 표시 / 클릭 → modal → setStatus)
2026-05-09 16:03:40 +09:00
altair823
9eb7abc831 feat(v029): MoveStatusModal — 사유 입력 + 4 status 버튼 + AI 자동 분류 placeholder
Cut B Task 7 — NoteCard 메뉴가 여는 modal.

- 사유 textarea (선택) + 활성/완료/보관/휴지통 버튼 (옮기기 즉시 setStatus + onMoved)
- 빈 사유 → null reason 전달 (trim 처리)
- AI 자동 분류 버튼 → classifyStatus(stub) 호출 + 추천 표시 + 확정 버튼
- statusLabel helper export (NoteCard 메뉴에서 재사용)
- 4 단위 테스트 (render / 버튼 클릭 / AI 추천 흐름 / 빈 사유 null)
2026-05-09 16:00:51 +09:00
altair823
d4dce9bf34 feat(v029): inbox:set-status + ai:classify-status (stub) IPC
Cut B Task 8 — Modal/NoteCard 메뉴 path 의 IPC backbone.

- inbox:set-status: id + status + reason → repo.setStatus, invalid status 거부
- ai:classify-status: stub (Task 9 에서 Ollama provider 호출로 정식 구현)
- types.ts InboxApi.setStatus / classifyStatus 시그니처 + preload wire-up
- 4 단위 테스트 (valid/null reason/invalid status/stub shape)
2026-05-09 15:59:43 +09:00
altair823
92375edc31 feat(v029): 헤더 4탭 (Inbox/완료/보관/휴지통) + count badge
- App.tsx: 기존 2탭 (Inbox/휴지통) → 4탭. setView/counts 사용.
- onNavigate 도 setView 로 위임 (mirror state 동기 갱신).
- App.test: 4탭 렌더 + 클릭 → setView('completed') + aria-pressed (3 cases).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 15:51:59 +09:00
altair823
606ac94976 feat(v029): useInbox view enum + counts + setView + listByStatus/countsByStatus IPC
- store.ts: view enum ('inbox'|'completed'|'archived'|'trash'|'settings') + counts +
  setView + loadByView. setShowSettings delegates to setView (mirror).
- types.ts + preload + ipc/inboxApi: listByStatus + countsByStatus IPC.
- NoteRepository.countByStatus 신규.
- store.view.test (5) + NoteRepository.countByStatus test (1).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 15:51:51 +09:00
altair823
fd839f6afe feat(v029): ai_status 'disabled' enum + CaptureService ai_enabled 분기 (skip pending_jobs)
- AiStatus enum 'disabled' 추가 — settings.ai_enabled=false 일 때 새 노트의 초기 status.
- m005 migration: ai_status CHECK 제약을 ('pending','done','failed','disabled') 로 relax.
  SQLite 가 ALTER COLUMN CHECK 미지원 → table recreate (notes_new INSERT SELECT DROP RENAME).
  기존 인덱스 (idx_notes_created_at, idx_notes_ai_status, idx_notes_deleted_at) 재생성.
- SettingsService schema 에 ai_enabled / onboarding_completed (optional) 추가 +
  isAiEnabled / setAiEnabled / isOnboardingCompleted / setOnboardingCompleted accessor.
  기본 fallback (ai_enabled=true, onboarding_completed=false) — 기존 settings.json 무영향.
- NoteRepository.create 가 optional aiStatus 받도록 — 'pending' 외 값일 때 pending_jobs skip.
  기존 caller (rawText 만 전달) 무영향.
- CaptureService deps 에 settings (좁은 AiEnabledSource 인터페이스) 추가.
  submit() 가 ai_enabled 조회 → false 면 ai_status='disabled' insert + enqueue skip.
  settings 미주입 시 기존 동작 (항상 enabled) 보존 — 테스트 케이스 무영향.
- main/index.ts wiring: settings: settingsSvc 주입.

Tests: 489 → 494 (CaptureService ai_enabled 2건 + m005 migration 3건). typecheck 0.
2026-05-09 15:43:01 +09:00
altair823
facbf54025 feat(v029): NoteRepository.setStatus + listByStatus + restoreNote 재구현
- NoteStatus 타입 추가 ('active'/'completed'/'archived'/'trashed')
- Note interface 에 status / statusChangedAt / moveReason 필드 추가
- setStatus(id, status, reason, now?) — 단일 transaction 으로 status + move_reason +
  status_changed_at + updated_at 갱신. status='trashed' ↔ deleted_at 동기화
  (backward compat). 그 외 status 는 deleted_at NULL.
- listByStatus(status, opts) — status 별 필터 + ORDER BY COALESCE(status_changed_at,
  created_at) DESC. limit cap 200.
- hydrate 에 status / statusChangedAt / moveReason 매핑 추가. 미설정 row 는 'active' fallback.
- restoreNote 재구현 — setStatus('active', null) 로 status + deleted_at 동기화 +
  v0.2.6 #10 round 1 fix (ai_status='failed'/'pending' → pending_jobs 재투입) 보존.
- 기존 테스트 fixture 5건에 새 필드 추가 (NoteCard, store.expired/recall/tagFilter/trash).
- 신규 테스트 11건 (setStatus + listByStatus + restoreNote 회귀).
2026-05-09 15:33:49 +09:00
altair823
06a1caf2bd feat(v029): m004 마이그레이션 — status/status_changed_at/move_reason 컬럼
- notes 테이블 ADD COLUMN status (DEFAULT 'active'), status_changed_at, move_reason
- deleted_at != NULL 노트 → status='trashed' + status_changed_at=deleted_at 로 backfill
- index.ts registry 에 m004 추가 (runMigrations 자동 적용)
- migrations.test.ts user_version assertion 4 로 갱신
2026-05-09 15:27:15 +09:00
altair823
7d2b8c95ec docs(v028+): F17~F25 dogfood + roadmap + Cut A~G specs + Cut A plan
v0.2.7 release 후 dogfood 9건 누적 (F17~F25) 정리:
- F17 휴지통 의미 분기 / F18 사유 입력 / F19 recall / F20 raw_text 가변
- F21 다기기 sync / F22 이미지 렌더링 (이미 v0.2.8 promoted) / F23 Ollama-less
- F24 멀티모달 vision / F25 사이드바 + 저장소

추가:
- v0.2.8+ roadmap: 7 cut 분할 (A~G), 12주 시간선, dependency graph
- Cut A~G design specs (각 cut 별 design 결정 + schema + UI + 테스트 전략)
- Cut A implementation plan (이미 v0.2.8 머지로 실행 완료, 참고 보존)

PR #26 머지 후 main 에 doc commits rebase 안 되어 manual merge 진행:
- F22 entry 는 origin/main 의 promoted 형태 우선
- 신규 9 파일 (specs/plan/roadmap) 은 origin/main 에 없는 파일
- "다음 항목 자리" 안내 F23 → F26 갱신

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 15:09:02 +09:00
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
106 changed files with 21291 additions and 547 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

View File

@@ -0,0 +1,705 @@
# v0.2.8 Cut A Implementation Plan — 이미지 렌더링 + 앱 아이콘
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** F22 (NoteCard 의 회색 placeholder → 실제 `<img>` + 클릭 시 OS viewer) + chore (앱 아이콘 SVG → ICO/ICNS/PNG 다중 size + electron-builder 통합).
**Architecture:** Electron renderer 보안 정책 우회를 위해 main process 에 `inkling-media://` custom protocol 등록 — `<profileDir>/media/<noteId>/<filename>` 을 fetch 가능하게 함. NoteCard 가 protocol URL 을 `<img src>` 로 사용. 클릭 시 IPC `inbox:open-media``shell.openPath` 로 OS default viewer 열기. 앱 아이콘은 `assets/icon.svg` (이미 작성) 를 `electron-icon-builder` 로 한 번 빌드 → `build/icon.ico/icns/png` 산출물 git 추적 + electron-builder config 매핑.
**Tech Stack:** Electron 41 protocol API + React 19 + better-sqlite3 + electron-icon-builder + sharp (SVG 변환 fallback)
**선행 spec:** [docs/superpowers/specs/2026-05-09-v028-cut-a-design.md](docs/superpowers/specs/2026-05-09-v028-cut-a-design.md)
---
## File Structure
### 신규 파일
| 경로 | 책임 |
|---|---|
| `src/main/protocol/inklingMedia.ts` | `inkling-media://` scheme 권한 + handler 등록 (path traversal 검사 + inferMime) |
| `tests/unit/inklingMedia.test.ts` | protocol handler 단위 테스트 (path traversal 403 / 정상 200 / 404 / mime) |
| `assets/icon.svg` | (이미 v0.2.7 turn 에서 생성 — Cut A 시작 전 commit 필요 시 재확인) |
### 수정 파일
| 경로 | 변경 |
|---|---|
| `src/main/index.ts` | top-level `protocol.registerSchemesAsPrivileged` + whenReady 안 `registerInklingMediaProtocol(paths.profileDir)` 호출 + `registerInboxApi` 가 신규 IPC 채널 등록 |
| `src/main/ipc/inboxApi.ts` | 신규 IPC `inbox:open-media` 핸들러 (path traversal 검사 + `shell.openPath`) |
| `src/preload/index.ts` | `inbox:open-media` 채널 화이트리스트 |
| `src/shared/types.ts` | `InboxApi``openMedia(relPath: string): Promise<{ ok: boolean; reason?: string }>` 시그니처 추가 |
| `src/renderer/inbox/api.ts` | inboxApi 객체에 `openMedia` wrapper |
| `src/renderer/inbox/components/NoteCard.tsx` | 회색 `<div>` placeholder → `<img src="inkling-media://...">` + onClick |
| `tests/unit/NoteCard.test.tsx` | 신규 또는 추가 — `<img>` 렌더 + 클릭 시 IPC 호출 |
| `package.json` | devDep `electron-icon-builder` + script `build:icons` + `build.win/mac/linux.icon` 경로 |
| `.gitignore` | `build/``icon.ico/icns/png` 만 추적 (whitelist) |
| `build/icon.ico` / `build/icon.icns` / `build/icon.png` | 빌드 산출물 commit |
| `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` | F22 진행 상태 🚀 promoted 마킹 |
| `docs/superpowers/v024-backlog.md` | (해당 없음 — Cut A 의 작업이 backlog # 와 매핑 X) |
---
## Phase 개요
```
Phase 1: F22 inkling-media protocol (Task 1)
Phase 2: F22 NoteCard <img> + 클릭 (Task 2)
Phase 3: F22 IPC inbox:open-media (Task 3)
Phase 4: chore 앱 아이콘 빌드 + config (Task 4)
Phase 5: verification + version bump (Task 5)
```
---
## Phase 1: inkling-media protocol
### Task 1: protocol 등록 + handler + 단위 테스트
**Files:**
- Create: `src/main/protocol/inklingMedia.ts`
- Create: `tests/unit/inklingMedia.test.ts`
- Modify: `src/main/index.ts`
- [ ] **Step 1: failing test 작성**
```ts
// tests/unit/inklingMedia.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { join, sep } from 'node:path';
const mockReadFile = vi.fn();
vi.mock('node:fs/promises', () => ({ readFile: mockReadFile }));
const mockHandle = vi.fn();
vi.mock('electron', () => ({
default: {
protocol: {
registerSchemesAsPrivileged: vi.fn(),
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: Request) => Promise<Response> {
registerInklingMediaProtocol(profileDir);
return mockHandle.mock.calls[0][1];
}
it('serves valid file with correct mime', async () => {
mockReadFile.mockResolvedValueOnce(Buffer.from([1, 2, 3]));
const handler = getHandler('/profile');
const res = await handler(new Request('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(new Request('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(new Request('inkling-media://media/note1/missing.png'));
expect(res.status).toBe(404);
});
});
```
- [ ] **Step 2: 테스트 실행 → fail**
```bash
npm run rebuild:node
npx vitest run tests/unit/inklingMedia.test.ts
```
Expected: `Cannot find module '../../src/main/protocol/inklingMedia'`.
- [ ] **Step 3: protocol handler 작성**
```ts
// src/main/protocol/inklingMedia.ts
import electron from 'electron';
import { promises as fs } from 'node:fs';
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) => {
const url = new URL(req.url);
// host + pathname 합쳐서 relPath 구성. inkling-media://media/<noteId>/<file> 형식.
// URL parse 시 host = 'media', pathname = '/<noteId>/<file>'
const relPath = decodeURIComponent((url.host + url.pathname).replace(/^media\//, 'media/'));
const target = normalize(join(profileDir, relPath));
if (!target.startsWith(mediaRoot + sep) && target !== mediaRoot) {
return new Response(null, { status: 403 });
}
try {
const data = await fs.readFile(target);
return new Response(new Uint8Array(data), {
headers: { 'content-type': inferMime(target) }
});
} catch {
return new Response(null, { status: 404 });
}
});
}
```
- [ ] **Step 4: 테스트 통과 확인**
```bash
npx vitest run tests/unit/inklingMedia.test.ts
```
Expected: 모든 test pass.
- [ ] **Step 5: src/main/index.ts 통합**
`src/main/index.ts` 의 import 추가:
```ts
import { registerSchemesAsPrivileged, registerInklingMediaProtocol } from './protocol/inklingMedia.js';
```
top-level (whenReady 이전, app 생성 직후) 에서:
```ts
registerSchemesAsPrivileged();
```
`whenReady` 안에서 (paths 초기화 후):
```ts
registerInklingMediaProtocol(paths.profileDir);
```
- [ ] **Step 6: typecheck + 전체 회귀**
```bash
npm run typecheck
npx vitest run
```
Expected: 0 errors + 460 → 466 (+6 inferMime 5 + handler 3).
- [ ] **Step 7: commit**
```bash
git add src/main/protocol/inklingMedia.ts tests/unit/inklingMedia.test.ts src/main/index.ts
git commit -m "feat(v028): inkling-media:// custom protocol + path traversal 검사"
```
---
## Phase 2: NoteCard `<img>` + 클릭
### Task 2: NoteCard placeholder → `<img>` 교체
**Files:**
- Modify: `src/renderer/inbox/components/NoteCard.tsx`
- Create: `tests/unit/NoteCard.test.tsx` (없으면 신규)
- [ ] **Step 1: failing test 작성**
```tsx
// tests/unit/NoteCard.test.tsx
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
import type { Note } from '@shared/types';
const mockOpenMedia = vi.fn(async () => ({ ok: true }));
vi.mock('../../src/renderer/inbox/api.js', () => ({
inboxApi: {
openMedia: mockOpenMedia,
// 다른 inboxApi 메서드 stub (NoteCard 가 사용할 수 있음)
permanentDeleteNote: vi.fn(),
restoreNote: vi.fn()
}
}));
import { NoteCard } from '../../src/renderer/inbox/components/NoteCard';
const baseNote: Note = {
id: 'n1',
rawText: 'test',
title: 'T',
summary: '',
tags: [],
aiStatus: 'complete',
createdAt: '2026-05-09T00:00:00Z',
updatedAt: '2026-05-09T00:00:00Z',
deletedAt: null,
media: [
{ id: 'm1', relPath: 'media/n1/img1.png', mime: 'image/png' },
{ id: 'm2', relPath: 'media/n1/img2.jpg', mime: 'image/jpeg' }
]
} as unknown as Note;
describe('NoteCard — image rendering', () => {
beforeEach(() => { vi.clearAllMocks(); cleanup(); });
it('renders <img> for each media item', () => {
render(<NoteCard note={baseNote} isTrash={false} />);
const imgs = screen.getAllByRole('img');
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} isTrash={false} />);
fireEvent.click(screen.getAllByRole('img')[0]);
expect(mockOpenMedia).toHaveBeenCalledWith('media/n1/img1.png');
});
});
```
- [ ] **Step 2: 테스트 실행 → fail**
```bash
npx vitest run tests/unit/NoteCard.test.tsx
```
Expected: `getAllByRole('img')` 미존재 (현재 회색 div 만).
- [ ] **Step 3: NoteCard 갱신**
[src/renderer/inbox/components/NoteCard.tsx:334-340](src/renderer/inbox/components/NoteCard.tsx#L334-L340) 의 회색 placeholder div 부분을 다음으로 교체:
```tsx
{local.media.length > 0 && (
<div style={{ marginTop: 10, display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{local.media.map((m) => (
<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>
)}
```
`inboxApi` import 가 이미 있는지 확인 — 있으면 그대로. 없으면 추가:
```tsx
import { inboxApi } from '../api.js';
```
- [ ] **Step 4: 테스트 통과 + typecheck**
```bash
npx vitest run tests/unit/NoteCard.test.tsx
npm run typecheck
```
Expected: 2 test pass (`openMedia` 가 types.ts 에 아직 없으니 typecheck error 가능 — Task 3 에서 해결). 일단 임시로 NoteCard.test.tsx 의 mock 만 동작.
만약 typecheck error 발생: `inboxApi.openMedia` 가 InboxApi 인터페이스에 없음 → 일시 `(inboxApi as any).openMedia(m.relPath)` 로 cast. **Task 3 에서 정식 시그니처 추가 후 cast 제거.**
- [ ] **Step 5: commit (with TODO note for Task 3)**
```bash
git add src/renderer/inbox/components/NoteCard.tsx tests/unit/NoteCard.test.tsx
git commit -m "feat(v028): NoteCard 이미지 <img> 렌더링 + onClick (openMedia 시그니처는 Task 3)"
```
---
## Phase 3: IPC `inbox:open-media`
### Task 3: main IPC 핸들러 + api.ts wrapper + types
**Files:**
- Modify: `src/main/ipc/inboxApi.ts`
- Modify: `src/preload/index.ts`
- Modify: `src/shared/types.ts`
- Modify: `src/renderer/inbox/api.ts`
- Modify: `src/renderer/inbox/components/NoteCard.tsx` (Task 2 cast 제거)
- [ ] **Step 1: failing test 작성 (IPC handler 단위)**
```ts
// tests/unit/inboxApi-openMedia.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { join, sep } from 'node:path';
const handlers: Record<string, Function> = {};
const mockOpenPath = vi.fn(async () => '');
vi.mock('electron', () => ({
default: {
ipcMain: { handle: (ch: string, fn: Function) => { handlers[ch] = fn; } },
shell: { openPath: mockOpenPath }
}
}));
import { registerInboxApi } from '../../src/main/ipc/inboxApi';
describe('inbox:open-media IPC', () => {
beforeEach(() => { vi.clearAllMocks(); for (const k of Object.keys(handlers)) delete handlers[k]; });
it('opens valid relPath with shell.openPath', async () => {
// 기존 registerInboxApi 가 deps 받으므로 minimal stub 만 — paths 만 사용
registerInboxApi({ paths: { profileDir: '/profile' } } as any);
const r = await handlers['inbox:open-media'](null, 'media/note1/img.png');
expect(r).toEqual({ ok: true });
expect(mockOpenPath).toHaveBeenCalledWith(join('/profile', 'media/note1/img.png'));
});
it('rejects path traversal', async () => {
registerInboxApi({ paths: { profileDir: '/profile' } } as any);
const r = await handlers['inbox:open-media'](null, '../etc/passwd');
expect(r.ok).toBe(false);
expect(r.reason).toBe('invalid path');
expect(mockOpenPath).not.toHaveBeenCalled();
});
});
```
- [ ] **Step 2: 테스트 실행 → fail**
```bash
npx vitest run tests/unit/inboxApi-openMedia.test.ts
```
Expected: handler 미등록.
- [ ] **Step 3: src/main/ipc/inboxApi.ts 핸들러 추가**
기존 `registerInboxApi(deps)` 함수 안에 다음 추가 (다른 IPC 핸들러와 같은 패턴):
```ts
import { join, normalize, sep } from 'node:path';
import electron from 'electron';
const { ipcMain, shell } = electron;
// registerInboxApi 안:
ipcMain.handle('inbox:open-media', async (_e, relPath: string) => {
const mediaRoot = join(deps.paths.profileDir, 'media');
const target = normalize(join(deps.paths.profileDir, relPath));
if (!target.startsWith(mediaRoot + sep) && target !== mediaRoot) {
return { ok: false, reason: 'invalid path' };
}
await shell.openPath(target);
return { ok: true };
});
```
(기존 deps 타입에 `paths.profileDir` 가 있는지 확인. 없으면 SettingsIpcDeps 와 비슷한 형태로 추가.)
- [ ] **Step 4: src/shared/types.ts InboxApi 갱신**
```ts
// 기존 InboxApi 인터페이스 안:
openMedia(relPath: string): Promise<{ ok: true } | { ok: false; reason: string }>;
```
- [ ] **Step 5: preload + api.ts wrapper**
`src/preload/index.ts` 의 invoke whitelist 또는 API expose 객체 안에 `'inbox:open-media'` 추가 (기존 `'inbox:*'` 채널 패턴 따름).
`src/renderer/inbox/api.ts` 의 inboxApi 객체에 추가:
```ts
async openMedia(relPath: string) {
return await window.inkling.invoke('inbox:open-media', relPath);
}
```
(또는 기존 wildcard re-export 사용 시 자동 노출 — `feb7c62` commit 의 `onNavigate` 처럼.)
- [ ] **Step 6: NoteCard.tsx cast 제거**
Task 2 에서 임시 `(inboxApi as any).openMedia` cast 했다면 → `inboxApi.openMedia` 정상 사용으로 변경. typecheck 통과 확인.
- [ ] **Step 7: 테스트 + typecheck + 회귀**
```bash
npx vitest run tests/unit/inboxApi-openMedia.test.ts
npm run typecheck
npx vitest run
```
Expected: IPC 2 test pass + 466 → 468 (+2) + 0 typecheck.
- [ ] **Step 8: commit**
```bash
git add -A src/main/ipc/ src/preload/ src/shared/ src/renderer/ tests/unit/inboxApi-openMedia.test.ts
git commit -m "feat(v028): IPC inbox:open-media + path traversal + NoteCard cast 정리"
```
---
## Phase 4: 앱 아이콘 빌드 + config
### Task 4: electron-icon-builder + 산출물 + builder config
**Files:**
- Modify: `package.json`
- Modify: `.gitignore`
- Create: `build/icon.ico`, `build/icon.icns`, `build/icon.png` (빌드 산출물)
- (조건부) Create: `scripts/svg-to-png.mjs` (SVG → PNG fallback if needed)
- [ ] **Step 1: devDep 설치**
```bash
npm install --save-dev electron-icon-builder
```
확인: `package.json` 의 devDependencies 에 `"electron-icon-builder": "^2.x.x"` 추가됨.
- [ ] **Step 2: package.json scripts + builder config**
`package.json` 의 scripts 블록에 추가:
```json
"build:icons": "electron-icon-builder --input=assets/icon.svg --output=build --flatten"
```
(만약 SVG 직접 input 안 되면 — Step 3 에서 sharp fallback 으로 분기.)
`build` 블록 갱신 (기존 win/mac/linux 에 icon 키 추가):
```json
"win": { "icon": "build/icon.ico", ... ... },
"mac": { "icon": "build/icon.icns", ... ... },
"linux": { "icon": "build/icon.png", ... ... }
```
- [ ] **Step 3: 빌드 실행**
```bash
npm run build:icons
```
Expected:
- `build/icon.ico` (Win)
- `build/icon.icns` (macOS)
- `build/icon.png` (1024x1024, Linux)
만약 SVG 직접 안 되면 (electron-icon-builder 가 PNG 만 input 받음):
1. `scripts/svg-to-png.mjs` 작성:
```js
import sharp from 'sharp';
import { readFileSync, writeFileSync } from 'node:fs';
const [,, input, output, size = '1024'] = process.argv;
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})`);
```
2. `package.json` scripts 갱신:
```json
"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"
```
3. `npm install --save-dev sharp` (이미 있으면 skip).
4. 다시 `npm run build:icons` 실행.
- [ ] **Step 4: .gitignore 갱신 (build/ 안 산출물 whitelist)**
`.gitignore``build/` 또는 `dist/` 항목 확인. 만약 `build/` 가 ignore 되어 있다면:
```
build/
!build/icon.ico
!build/icon.icns
!build/icon.png
```
(만약 `build/` 가 ignore 안 되어 있다면 — 모두 추적 가능 — Step 4 skip.)
- [ ] **Step 5: 산출물 확인 + commit**
```bash
ls -la build/icon.ico build/icon.icns build/icon.png
```
Expected: 3 파일 모두 size 양수 (수십 KB 이상).
```bash
git add package.json package-lock.json .gitignore scripts/svg-to-png.mjs build/icon.ico build/icon.icns build/icon.png
git commit -m "chore(v028): 앱 아이콘 (assets/icon.svg → ICO/ICNS/PNG) + electron-builder config"
```
(scripts/svg-to-png.mjs 는 sharp fallback 사용 시만 추가.)
- [ ] **Step 6: typecheck + 전체 회귀**
```bash
npm run typecheck
npx vitest run
```
Expected: 0 errors + 468 pass (이전 + Task 1-3 변경 포함). 아이콘 변경은 테스트 영향 X.
---
## Phase 5: verification + version bump
### Task 5: 회귀 + dogfood-feedback 마킹 + version bump
**Files:**
- Modify: `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` (F22 promoted 마킹)
- Modify: `package.json` (version 0.2.7 → 0.2.8)
- [ ] **Step 1: 단위 + e2e + typecheck 일괄**
```bash
npm run rebuild:node
npm test
npm run typecheck
npm run rebuild:electron
npm run test:e2e
```
Expected: 모두 pass. 단위 460 → 약 468 (+8: inferMime 5 + protocol handler 3 + NoteCard img 2 + IPC 2 = 12, 일부 mock 충돌 가능 — 실제 카운트 ±). e2e 1/1.
- [ ] **Step 2: 수동 launch 검증 (Win + macOS 가능 시)**
```bash
npm run start
```
체크리스트:
- inbox 의 capture-with-image 노트의 thumbnail = 실제 이미지 (회색 사각형 X)
- thumbnail 클릭 → OS default viewer (예: Win Photos / macOS Preview) 열림
- 새 아이콘이 트레이 / Windows taskbar / dock 정확 표시
- 다중 이미지 노트의 grid layout (flex-wrap) 자연스러움
- [ ] **Step 3: dogfood-feedback.md F22 promoted 마킹**
`docs/superpowers/specs/2026-04-25-dogfood-feedback.md` 의 F22 entry 헤더 갱신:
```markdown
## F22. NoteCard 이미지가 회색 placeholder 만 표시 (🚀 promoted → docs/superpowers/specs/2026-05-09-v028-cut-a-design.md)
```
진행 상태 line 도 `🚀 promoted` 로 갱신.
- [ ] **Step 4: package.json version bump**
```json
"version": "0.2.8"
```
- [ ] **Step 5: commit**
```bash
git add docs/superpowers/specs/2026-04-25-dogfood-feedback.md package.json
git commit -m "chore(release): v0.2.8 — Cut A (이미지 렌더링 + 앱 아이콘)"
```
---
## Self-Review
**Spec coverage:**
| Spec 섹션 | task |
|---|---|
| §3-1 protocol 등록 | Task 1 |
| §3-2 NoteCard `<img>` | Task 2 |
| §3-3 IPC inbox:open-media | Task 3 |
| §3-4 보안 (path traversal) | Task 1 + Task 3 |
| §4-1 devDep + scripts | Task 4 |
| §4-2 builder config | Task 4 |
| §4-3 산출물 git 추적 | Task 4 |
| §4-4 SVG → PNG fallback | Task 4 (조건부) |
| §5 테스트 | 각 task 안 단위 + Task 5 회귀 |
| §6 Risk | Task 4 fallback 분기 |
모든 spec 요구가 task 매핑됨.
**Placeholder scan**: "TBD" / "TODO" / "implement later" 없음. 각 step 의 코드/명령 실행 가능 형태.
**Type consistency**:
- `InboxApi.openMedia(relPath: string): Promise<{ ok: true } | { ok: false; reason: string }>` — Task 3 정의, Task 2 cast → Task 3 cast 제거 일관.
- `inferMime(filename: string): string` — Task 1 정의, Task 1 안 사용.
- `registerInklingMediaProtocol(profileDir: string): void` / `registerSchemesAsPrivileged(): void` — Task 1 export, src/main/index.ts import 일관.
이슈 없음.
---
## Execution Handoff
Plan 작성 완료, `docs/superpowers/plans/2026-05-09-v028-cut-a.md` 저장.
두 가지 실행 옵션:
**1. Subagent-Driven (recommended)** — fresh subagent per task, two-stage review (spec compliance + code quality), 빠른 iteration
**2. Inline Execution** — 본 세션에서 task 일괄 실행 + checkpoint 마다 review
어느 쪽?

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

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

@@ -0,0 +1,206 @@
# v0.2.10 — Cut C Design (raw_text 수정 + revision history)
**작성일:** 2026-05-09
**선행 문서:**
- `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` (F20)
- `docs/superpowers/strategy/v028plus-roadmap.md` Cut C
**Cut 라벨:** v0.2.10 — load-bearing invariant 변경 (raw_text 불변 폐기 + revision history). semver 엄밀히 minor 이지만 v0.2.x 관습.
---
## 1. Cut 정체성
메모리 정책 `raw_text 불변` invariant 폐기 + 변경 이력 (revision) 보존. 사용자가 raw_text 자유 수정 + 옛 버전 회수 가능.
**load-bearing 정책 변경**:
- 옛: `raw_text 불변` (capture 시점 원본 영구 보존)
- 새: `raw_text 가변` + `note_revisions 테이블` (옛 버전 모두 보존, rollback 가능)
이는 F1 / F4 / F17 / F19 의 raw_text 가정에 영향 — 모두 current latest raw_text 기준으로 동작 (시간 경과 시 정정된 값 사용).
---
## 2. 범위
| 항목 | 결정 |
|---|---|
| **F20** | C 옵션 — raw_text 수정 허용 + `note_revisions` 테이블 + 옛 버전 회수 UI. AI 재실행 input = current latest raw_text (B 옵션). |
---
## 3. Schema 마이그레이션 (m006)
> 메모: 본 스펙 작성 시점에는 m005 로 예상했으나 Cut B (v0.2.9) 에서 m005 (ai_disabled CHECK relax) 가 선점됨 → 실제 번호는 **m006**.
```sql
CREATE TABLE note_revisions (
rev_id INTEGER PRIMARY KEY AUTOINCREMENT,
note_id TEXT NOT NULL,
raw_text TEXT NOT NULL,
edited_at TEXT NOT NULL,
edited_by TEXT NOT NULL DEFAULT 'user', -- 'user' or 'capture' (최초 캡처)
FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE
);
CREATE INDEX idx_note_revisions_note_id ON note_revisions(note_id, edited_at DESC);
-- 기존 notes 의 모든 raw_text 를 첫 revision 으로 backfill
INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by)
SELECT id, raw_text, created_at, 'capture' FROM notes;
```
`note_revisions.rev_id = AUTOINCREMENT` — chronological 순서 보장. `edited_by` = 'user' (사용자 정정) 또는 'capture' (최초).
`notes.raw_text` 컬럼 그대로 — current latest 값. 검색 인덱스 (F19 FTS5) 가 이걸 source 로 사용 → revision 검색 X (latest only). YAGNI.
---
## 4. NoteRepository 메서드
```ts
class NoteRepository {
// 기존
insert(input: ...): Note; // 내부에서 note_revisions INSERT (edited_by='capture')
// 신규
updateRawText(id: string, newText: string, now: Date): void {
const tx = this.db.transaction(() => {
this.db.prepare(`UPDATE notes SET raw_text=?, updated_at=? WHERE id=?`).run(newText, now.toISOString(), id);
this.db.prepare(`INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by) VALUES (?, ?, ?, 'user')`).run(id, newText, now.toISOString());
});
tx();
}
listRevisions(id: string): NoteRevision[] {
return this.db.prepare(`SELECT * FROM note_revisions WHERE note_id=? ORDER BY edited_at DESC`).all(id) as NoteRevision[];
}
restoreRevision(id: string, revId: number, now: Date): void {
const rev = this.db.prepare(`SELECT raw_text FROM note_revisions WHERE rev_id=? AND note_id=?`).get(revId, id) as { raw_text: string } | undefined;
if (!rev) throw new Error(`revision ${revId} not found`);
this.updateRawText(id, rev.raw_text, now); // 새 revision 으로 복원 (linear history 유지)
}
}
```
`restoreRevision` 은 옛 raw_text 를 **새 revision** 으로 INSERT — chain 끊지 않고 latest = restored. timestamp/순서 명확.
---
## 5. UI — NoteCard 수정 흐름
### 5-1. raw_text 편집 UI
기존 NoteCard 의 "원문 보기" 펼침 → 추가 "편집" 버튼:
```tsx
{rawOpen && (
<div>
{editingRaw ? (
<>
<textarea value={draftRaw} onChange={e => setDraftRaw(e.target.value)} />
<button onClick={onSaveRaw}></button>
<button onClick={() => setEditingRaw(false)}></button>
</>
) : (
<>
<pre>{local.rawText}</pre>
<button onClick={() => { setDraftRaw(local.rawText); setEditingRaw(true); }}></button>
<button onClick={() => setShowRevisions(true)}></button>
</>
)}
</div>
)}
```
### 5-2. Revision 회수 UI
"이력" 클릭 → modal 또는 확장 panel:
```
이력 (3 buah)
[2026-05-12 14:30 사용자] 본문... [회수]
[2026-05-10 09:15 사용자] 옛 본문... [회수]
[2026-05-09 11:00 캡처] 최초 캡처 본문... [회수]
```
회수 클릭 → confirm dialog ("이 버전으로 되돌릴까요? 현재 본문도 이력에 보존됩니다.") → `restoreRevision()` 호출.
---
## 6. AI 재실행 정책
**입력 = current notes.raw_text (latest)**. 옛 revision 은 AI 재실행 input X. 정책 일관 (사용자 정정 의도 반영).
`AiWorker` 의 input 추출 코드는 변경 없음 — `notes.raw_text` 그대로 사용.
---
## 7. F1 (Due Date) / F4 (Aha Moment) / F17 / F19 영향
| 영역 | 영향 |
|---|---|
| F1 Due Date 파서 | input = current raw_text. 사용자 정정 후 due 갱신 가능 — 정책 충실 (수정 시 의도 반영) |
| F4 Aha Moment | capture 카운트 = notes 갯수. revision 갯수 무관 |
| F17 status | 영향 X (raw_text 수정과 status 분기 독립) |
| F19 search FTS5 | 인덱스 source = notes.raw_text (latest). revision 검색 미지원. 향후 cut 에서 옵션 |
---
## 8. IPC + types
```ts
// 신규
'inbox:update-raw-text': (id: string, newText: string) => Promise<{ ok: true }>
'inbox:list-revisions': (id: string) => Promise<NoteRevision[]>
'inbox:restore-revision': (id: string, revId: number) => Promise<{ ok: true }>
interface NoteRevision {
revId: number;
noteId: string;
rawText: string;
editedAt: string;
editedBy: 'user' | 'capture';
}
```
---
## 9. 테스트 전략
| 영역 | 단위 |
|---|---|
| m006 마이그레이션 | 기존 notes → revision backfill (edited_by='capture') |
| `updateRawText` | notes.raw_text 갱신 + 새 revision INSERT atomic |
| `listRevisions` | DESC 순 + edited_by 정확 |
| `restoreRevision` | 옛 raw_text 가 새 revision 으로 INSERT + notes.raw_text 갱신 |
| 편집 UI | textarea 입력 + 저장 → IPC 호출 + store 갱신 |
| 이력 modal | revision 목록 표시 + 회수 클릭 → confirm + IPC |
| AiWorker input | current notes.raw_text 사용 (revision X) 회귀 |
**목표**: 단위 548 → 약 567 (+19, m006 5 + create rev 1 + updateRawText 2 + listRevisions 1 + restoreRevision 2 + IPC 4 + NoteCard 편집 1 + RevisionHistoryModal 2 + findById 회귀 1), typecheck 0.
---
## 10. Risk
| Risk | 대응 |
|---|---|
| revision 무한 누적 (메모 1개당 100+ revision 시 DB bloat) | 향후 cut 에서 N개 cap 정책 (예: 최근 50개만 보존). 본 cut 은 unlimited |
| 사용자가 실수로 옛 revision 회수 | confirm dialog 강제 |
| F1 Due Date 가 raw_text 변경 시 재추출 안 함 | 별도 cut. 본 cut 은 raw_text 갱신 + 기존 due 잔류 (사용자 의도 보존) |
| 메모리 정책 갱신 필수 | `project_inkling_status.md` 의 load-bearing invariant 갱신 |
---
## 11. 메모리 정책 갱신 (Cut C 머지 후 필수)
`raw_text 불변``raw_text 가변 + revision 보존`. 메모 갱신:
```
- ~~raw_text 불변~~ → raw_text 가변 (사용자 편집 가능, note_revisions 테이블에 변경 이력 보존)
- AI 재실행 input = current latest raw_text (옛 revision X)
```

View File

@@ -0,0 +1,297 @@
# v0.2.11 — Cut D Design (FTS5 search + 회고 view)
**작성일:** 2026-05-09
**선행 문서:**
- `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` (F19)
- `docs/superpowers/strategy/v028plus-roadmap.md` Cut D
**Cut 라벨:** v0.2.11
---
## 1. Cut 정체성
recall 핵심 가치 도달 — search + 회고 view. F19 의 6 옵션 중 **A (FTS5 search) + D (회고 view)** 2개. B/C/E/F 는 v0.3+ deferred.
---
## 2. 범위
| 항목 | 결정 |
|---|---|
| **F19-A** | SQLite FTS5 인덱스 + inbox 헤더 search box |
| **F19-D** | 일/주/월 회고 라우트 — aggregate query + N건 list + tag distribution + due 진행 |
---
## 3. F19-A 디테일 (FTS5)
### 3-1. Schema 마이그레이션 (m007)
> 메모: 본 스펙 작성 시점에는 m006 로 예상했으나 Cut C (v0.2.10) 에서 m006 (note_revisions) 가 선점됨 → 실제 번호는 **m007**.
실제 schema 정정:
- `notes.title`/`notes.summary` 컬럼 없음 → 실제 `notes.ai_title`/`notes.ai_summary` 사용
- `notes.tags_csv` 컬럼 없음 → tags 는 `note_tags` join (note_tags.note_id ↔ tags.id)
- `notes.status` (Cut B m004 도입) 사용 가능 — `status != 'trashed'` 필터
```sql
CREATE VIRTUAL TABLE notes_fts USING fts5(
note_id UNINDEXED,
raw_text,
ai_title,
ai_summary,
tags,
tokenize='unicode61'
);
-- 기존 notes (active/completed/archived 만 — trashed 제외) 모두 인덱스.
-- tags 는 note_tags+tags JOIN 후 GROUP_CONCAT 으로 csv 구성.
INSERT INTO notes_fts (note_id, raw_text, ai_title, ai_summary, tags)
SELECT
n.id,
n.raw_text,
COALESCE(n.ai_title, ''),
COALESCE(n.ai_summary, ''),
COALESCE((SELECT GROUP_CONCAT(t.name, ' ')
FROM note_tags nt JOIN tags t ON t.id = nt.tag_id
WHERE nt.note_id = n.id), '')
FROM notes n
WHERE n.status != 'trashed';
```
`tokenize='unicode61'` — 한국어 partial tokenize 가능 (단어 boundary). 향후 `tokenize='porter unicode61'` 또는 한국어 전용 tokenizer (예: `mecab-ko-fts5`) 검토 가능 — Cut D 는 unicode61 default.
`tags` 컬럼 = note_tags JOIN 결과 csv (예: `"기획 회의 결재"`). `note_tags` 변경 시 NoteRepository 에서 명시적 헬퍼 (`rebuildFtsTagsForNote(noteId)`) 호출 — trigger 로 sync 어려움 (`note_tags` INSERT/DELETE 가 다른 노트 row 재계산 트리거하기 부담). 단일 write path 패턴 (Cut C 확립) 으로 강제.
### 3-2. Trigger — auto-sync (notes 컬럼 한정)
`notes` INSERT/UPDATE/DELETE 시 `notes_fts` 자동 sync (raw_text/ai_title/ai_summary 만; tags 는 별도 헬퍼):
```sql
CREATE TRIGGER notes_ai AFTER INSERT ON notes BEGIN
INSERT INTO notes_fts (note_id, raw_text, ai_title, ai_summary, tags)
VALUES (NEW.id, NEW.raw_text, COALESCE(NEW.ai_title, ''), COALESCE(NEW.ai_summary, ''), '');
END;
CREATE TRIGGER notes_ad AFTER DELETE ON notes BEGIN
DELETE FROM notes_fts WHERE note_id = OLD.id;
END;
CREATE TRIGGER notes_au AFTER UPDATE ON notes BEGIN
UPDATE notes_fts
SET raw_text = NEW.raw_text,
ai_title = COALESCE(NEW.ai_title, ''),
ai_summary = COALESCE(NEW.ai_summary, '')
WHERE note_id = NEW.id;
END;
```
Cut C 의 `updateRawText``notes.raw_text` UPDATE → trigger 자동 발동 → FTS5 갱신.
`tags` 갱신 path:
- `NoteRepository.updateAiResult` (AI tags) / `updateUserAiFields` (사용자 tags) 모두 `note_tags` 변경 후 동일 transaction 안에서 `rebuildFtsTagsForNote(noteId)` 호출.
trashed 노트 처리 — `setStatus(id, 'trashed', ...)` 시 trigger AFTER UPDATE 발동되어 FTS row 가 그대로 유지됨. 검색 시 query 단계에서 `n.status != 'trashed'` 필터로 제외 (별도 FTS row cleanup 안 함 — YAGNI).
### 3-3. NoteRepository.search
```ts
search(query: string, opts: { limit?: number; status?: NoteStatus } = {}): Note[] {
if (query.trim().length === 0) return [];
const limit = Math.max(1, Math.min(200, opts.limit ?? 50));
const ftsQuery = sanitizeFtsQuery(query); // FTS5 special char escape
const statusClause = opts.status ? `AND n.status = ?` : `AND n.status != 'trashed'`;
const sql = `
SELECT n.* FROM notes n
JOIN notes_fts f ON n.id = f.note_id
WHERE notes_fts MATCH ? ${statusClause}
ORDER BY rank LIMIT ?
`;
const args = opts.status ? [ftsQuery, opts.status, limit] : [ftsQuery, limit];
const rows = this.db.prepare(sql).all(...args) as Record<string, unknown>[];
return rows.map((r) => this.hydrate(r));
}
```
`hydrate` — 기존 패턴 (tags + media join). `sanitizeFtsQuery` — FTS5 special chars (`"`, `*`, `(`, `)`, `:`) 이스케이프 및 multi-word AND 결합 (예: `기획 회의``"기획" AND "회의"` 또는 `기획 회의` 그대로 수용). YAGNI: 다중 토큰을 그대로 FTS5 implicit AND 로 보냄 + 따옴표 제거.
`status` 미지정 시 default = trashed 제외.
`MATCH` 쿼리 syntax — FTS5 standard (`"기획 회의"`, `회의 OR 결재`, `기획*` 등).
### 3-4. UI — inbox 헤더 search box
기존 헤더 (Inbox/완료/보관/휴지통 탭) 옆에 search input:
```tsx
<input
type="search"
placeholder="검색..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
style={{ ... }}
/>
```
debounce 200ms → store action `searchNotes(query)``inboxApi.search(query, { status: currentView })` → result list 갱신.
빈 query → 기본 inbox list 복귀.
### 3-5. IPC
```ts
'inbox:search': (query: string, opts: { status?: NoteStatus; limit?: number }) => Promise<Note[]>
```
---
## 4. F19-D 디테일 (회고 view)
### 4-1. 라우트 추가
`useInbox.view` enum 에 `'review-daily' | 'review-weekly' | 'review-monthly'` 추가. 진입점:
- 헤더 메뉴: "📅 회고" 버튼 → 드롭다운 (일/주/월)
- 또는 별도 라우트 (Settings 옆)
### 4-2. 회고 view 컴포넌트
```tsx
// src/renderer/inbox/components/ReviewView.tsx
export function ReviewView({ period }: { period: 'daily' | 'weekly' | 'monthly' }): ReactElement {
const data = useReviewData(period); // store action — aggregate query 결과
return (
<div>
<h2>{periodLabel(period)} </h2>
<div> N건 N건 N건</div>
<TagDistributionChart tags={data.tagCounts} />
<DueProgressChart due={data.dueProgress} />
<NoteList notes={data.recentNotes} />
</div>
);
}
```
### 4-3. Aggregate query
NoteRepository:
```ts
reviewAggregate(period: 'daily' | 'weekly' | 'monthly', now: Date = new Date()): {
totalCount: number;
recentNotes: Note[];
tagCounts: Array<{ tag: string; count: number }>;
dueProgress: { total: number; passed: number; pending: number };
} {
const cutoff = computeCutoff(period, now); // ISO string — KST 자정 / 7일전 / 30일전
const todayIso = kstTodayIso(now); // YYYY-MM-DD
const totalCount = (this.db
.prepare(`SELECT COUNT(*) as c FROM notes WHERE created_at >= ? AND status != 'trashed'`)
.get(cutoff) as { c: number }).c;
const recentRows = this.db
.prepare(`SELECT * FROM notes WHERE created_at >= ? AND status != 'trashed'
ORDER BY created_at DESC, id DESC LIMIT 50`)
.all(cutoff) as Record<string, unknown>[];
const recentNotes = recentRows.map((r) => this.hydrate(r));
// tag counts via note_tags JOIN — period 안 노트의 태그만 집계
const tagCounts = this.db
.prepare(`SELECT t.name AS tag, COUNT(*) AS count
FROM note_tags nt
JOIN notes n ON n.id = nt.note_id
JOIN tags t ON t.id = nt.tag_id
WHERE n.created_at >= ? AND n.status != 'trashed'
GROUP BY t.id
ORDER BY count DESC, t.name ASC`)
.all(cutoff) as Array<{ tag: string; count: number }>;
// due progress — period 안 created 노트 중 due_date 가 있는 것
const dueRow = this.db
.prepare(`SELECT
COUNT(*) AS total,
SUM(CASE WHEN due_date < ? THEN 1 ELSE 0 END) AS passed,
SUM(CASE WHEN due_date >= ? THEN 1 ELSE 0 END) AS pending
FROM notes
WHERE created_at >= ?
AND status != 'trashed'
AND due_date IS NOT NULL`)
.get(todayIso, todayIso, cutoff) as { total: number; passed: number | null; pending: number | null };
const dueProgress = {
total: dueRow.total,
passed: dueRow.passed ?? 0,
pending: dueRow.pending ?? 0
};
return { totalCount, recentNotes, tagCounts, dueProgress };
}
```
`computeCutoff('daily', now)` = KST 자정 (오늘 시작) ISO. `'weekly'` = 7일 전 KST 자정 ISO. `'monthly'` = 30일 전 KST 자정 ISO. `kstTodayIso``src/shared/util/kstDate.ts` 에 이미 존재 (Cut B 활용).
period 별 query 는 동일 transaction 으로 wrap 해도 되나, read-only + 단일 호출이라 단순 sequential 호출로 충분 (better-sqlite3 동기 API).
### 4-4. Tag distribution chart
간단한 bar list (CSS — chart 라이브러리 X):
```tsx
{data.tagCounts.slice(0, 10).map(t => (
<div key={t.tag}>
<span>{t.tag}</span>
<div style={{ width: `${(t.count / max) * 100}%`, background: '#4ec5b8', height: 8 }} />
<span>{t.count}</span>
</div>
))}
```
### 4-5. Due progress
```
완료 (passed): 12 / 25
대기 (pending): 13
이번 주 due: 3건
```
---
## 5. 테스트 전략
| 영역 | 단위 |
|---|---|
| m007 마이그레이션 | FTS5 virtual table + trigger 3개 + 기존 notes backfill (status != 'trashed' + tags JOIN) |
| Trigger sync | INSERT/UPDATE/DELETE → notes_fts 자동 sync (raw_text/ai_title/ai_summary) |
| `rebuildFtsTagsForNote` 헬퍼 | note_tags 변경 후 FTS tags 컬럼 재구성 |
| `updateAiResult` / `updateUserAiFields` | tags 변경 path 가 헬퍼 호출하여 FTS sync (회귀) |
| `updateRawText` (Cut C) FTS sync 회귀 | trigger 자동 발동 검증 |
| `search` | 한국어 token 매칭 + status filter + trashed 기본 제외 + 빈 query → [] |
| `sanitizeFtsQuery` | FTS5 special char 이스케이프 + multi-word 통과 |
| inbox header search box | debounce + 빈 값 → 기본 list 복귀 |
| ReviewView 단위 | aggregate query 결과 렌더 + period 라벨 |
| `reviewAggregate` | period 별 cutoff 정확 + tag count + due progress (passed/pending KST 비교) |
| `computeCutoff` | daily/weekly/monthly KST 자정 ISO |
**목표**: 단위 569 → 약 595 (+26), typecheck 0.
---
## 6. Risk
| Risk | 대응 |
|---|---|
| FTS5 한국어 token 정확도 (unicode61 가 word boundary 부정확) | dogfood 검증. 부족 시 v0.3+ 에서 mecab-ko 또는 trigram tokenize 검토 |
| FTS5 인덱스 size (notes 수만건 시 DB 크기 ↑) | 수만건 도달 전엔 무시. v0.3+ 에서 prune 또는 partial 인덱스 |
| 회고 aggregate query latency | LIMIT 50 + index 활용 (`created_at DESC`). 수만건도 sub-second 예상 |
| Cut C revision 추가 시 FTS 영향 | revision 은 인덱스 X (latest only). `notes` AFTER UPDATE trigger 가 raw_text 변경 자동 반영 |
| `note_tags` 변경 누락 시 FTS tags stale | NoteRepository 의 tags 변경 path 모두에서 `rebuildFtsTagsForNote` 명시 호출 — single write path 패턴 강제 |
| FTS5 special char crash | `sanitizeFtsQuery` 에서 `"`/`*`/`(`/`)`/`:` 이스케이프 또는 제거 |
---
## 7. v0.2.11 후
**Cut E** (v0.3.0) — F21 양방향 sync.
**dogfood verify**:
1. search 일 사용 빈도 (가설: ≥ 일 1회면 가치 있음)
2. 회고 view 사용 빈도 (월요일 자동 prompt 추가 검토 — v0.3+)
3. FTS5 한국어 token 정확도 (사용자 query 결과 만족도)

View File

@@ -0,0 +1,226 @@
# v0.2.8 — Cut A Design (이미지 렌더링 + 앱 아이콘)
**작성일:** 2026-05-09
**저자:** 김태현 (dlsrks0734@gmail.com)
**선행 문서:**
- `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` (F22)
- `docs/superpowers/strategy/v028plus-roadmap.md` (Cut A 분할 + 우선순위)
**Cut 라벨:** v0.2.8 (semver patch — bug fix + asset 추가)
---
## 1. Cut 정체성
**"이미지 렌더링 + 앱 아이콘 polish" cut.** 두 작은 항목 묶음:
- **F22 (이미지 렌더링)**: NoteCard 의 회색 placeholder div 를 실제 `<img>` 로 교체. Electron renderer 가 raw `file://` 직접 접근 어려운 보안 정책 우회 — `inkling-media://` custom protocol 등록.
- **chore (앱 아이콘)**: 사용자 첨부 SVG (이미 `assets/icon.svg` 작성·검토 완료) → ICO/ICNS/PNG 다중 size 자동 생성 + electron-builder config 통합.
명확/작은 작업, 의사결정 거의 없음. 빠른 release polish.
---
## 2. 범위
| 항목 | 출처 | 작업 |
|---|---|---|
| **F22** | dogfood F22 | `inkling-media://` protocol + NoteCard `<img>` + 클릭 시 OS viewer (`shell.openPath`) |
| **chore** | roadmap | `electron-icon-builder` devDep + `npm run build:icons` + electron-builder config (`build.win.icon` / `build.mac.icon` / `build.linux.icon`) |
---
## 3. F22 디테일
### 3-1. Custom protocol 등록
`src/main/index.ts``whenReady` **이전** (top-level) 에 scheme 권한 등록:
```ts
import { protocol } from 'electron';
protocol.registerSchemesAsPrivileged([
{ scheme: 'inkling-media', privileges: { secure: true, supportFetchAPI: true, stream: true } }
]);
```
`whenReady` 안에서 handler 등록:
```ts
import { promises as fs } from 'node:fs';
import { join, normalize, sep } from 'node:path';
protocol.handle('inkling-media', async (req) => {
const url = new URL(req.url);
const relPath = decodeURIComponent(url.pathname).replace(/^\//, '');
const mediaRoot = join(paths.profileDir, 'media');
const target = normalize(join(mediaRoot, relPath));
if (!target.startsWith(mediaRoot + sep)) {
return new Response(null, { status: 403 });
}
try {
const data = await fs.readFile(target);
return new Response(data, { headers: { 'content-type': inferMime(target) } });
} catch {
return new Response(null, { status: 404 });
}
});
```
`inferMime()` — 파일 확장자 → MIME (png/jpg/jpeg/gif/webp). 작은 함수 (별도 util 또는 inline).
### 3-2. NoteCard 갱신
[src/renderer/inbox/components/NoteCard.tsx:336-338](src/renderer/inbox/components/NoteCard.tsx#L336-L338) 의 회색 div 를 `<img>` 로 교체:
```tsx
{local.media.map((m) => (
<img
key={m.id}
src={`inkling-media://${m.relPath}`}
alt=""
title={m.relPath}
onClick={() => inboxApi.openMedia(m.relPath)}
style={{
width: 48,
height: 48,
objectFit: 'cover',
borderRadius: 4,
cursor: 'pointer',
border: '1px solid #e0e0e0'
}}
/>
))}
```
`m.relPath` 형식 = `media/<noteId>/<filename>`. URL 형식: `inkling-media://media/<noteId>/<filename>`. handler 가 prefix 제거 후 `<profileDir>/media/<noteId>/<filename>` 으로 resolve.
### 3-3. IPC `inbox:open-media`
`src/main/ipc/inboxApi.ts` 에 신규 핸들러:
```ts
ipcMain.handle('inbox:open-media', async (_e, relPath: string) => {
const mediaRoot = join(paths.profileDir, 'media');
const target = normalize(join(mediaRoot, relPath));
if (!target.startsWith(mediaRoot + sep)) return { ok: false, reason: 'invalid path' };
await shell.openPath(target);
return { ok: true };
});
```
preload 화이트리스트 + `src/shared/types.ts` `InboxApi.openMedia(relPath: string)` 시그니처 + `src/renderer/inbox/api.ts` wrapper.
### 3-4. 보안 검토
- **Path traversal**: protocol handler + IPC 핸들러 모두 `target.startsWith(mediaRoot + sep)` 검사. 통과 못 하면 403/실패.
- **Schemes privileges**: `secure: true` 로 https 동등 권한 — webContents 가 페이지 안에서 `<img src="inkling-media://...">` 정상 로드.
- **CORS**: same-origin 정책 영향 X (custom protocol 이라 별도). webContents 안 동일 origin 으로 인식.
- **인증**: 단일 사용자 desktop app — 추가 인증 X.
---
## 4. chore 디테일
### 4-1. 의존성 + scripts
`package.json`:
```json
"devDependencies": {
"electron-icon-builder": "^2.0.1"
},
"scripts": {
"build:icons": "electron-icon-builder --input=assets/icon.svg --output=build --flatten"
}
```
`--flatten` 옵션 = output 을 `build/icon.ico`, `build/icon.icns`, `build/icon.png` (1024x1024) 평면 배치. nested `build/icons/png/<size>.png` 도 함께.
### 4-2. electron-builder config
`package.json``build` 블록 갱신:
```json
"win": { "icon": "build/icon.ico", ... },
"mac": { "icon": "build/icon.icns", ... },
"linux": { "icon": "build/icon.png", ..., "target": [ ... ] }
```
기존 win/mac/linux 블록에 `"icon"` 키만 추가 (다른 설정 그대로).
### 4-3. 산출물 git 추적
`build/``.gitignore` 에 있다면 — 두 옵션:
- (a) **`build/icon.*` 만 ignore 풀고 commit** (size 약 200KB-1MB 작음 — 바이너리 commit 일반적). SVG 갱신 시 `npm run build:icons` 후 commit.
- (b) **모두 ignore 유지** + `prebuild` script 등으로 빌드 시 매번 재생성. dist 빌드 시 자동 — 그러나 dev 환경 (npm start) 에서 아이콘 미생성 시 fallback 필요.
추천: **(a)** — 단순, 빌드 시간 ↓, dev 환경 문제 X.
`.gitignore` 갱신 예:
```
build/
!build/icon.ico
!build/icon.icns
!build/icon.png
```
### 4-4. SVG 가 input 으로 바로 가능?
`electron-icon-builder` v2.0.1 docs 검토 — PNG 1024x1024 입력 권장, SVG 는 `librsvg` 등 의존. SVG 직접 안 되면 `sharp` 로 SVG → PNG 1024 변환 후 input.
대안 (SVG 직접 안 될 시):
```json
"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"
```
`scripts/svg-to-png.mjs``sharp` 활용 ~10줄 스크립트.
---
## 5. 테스트 전략
| 영역 | 단위 | 수동 |
|---|---|---|
| protocol handler — path traversal | mock fs + URL 입력 (`../etc/passwd` 형태) → 403 | - |
| protocol handler — 정상 200 | mock fs.readFile → bytes + content-type 검증 | - |
| protocol handler — 404 | fs.readFile reject → 404 | - |
| `inferMime` | 확장자별 정확 mapping | - |
| NoteCard `<img>` 렌더 | media 배열 길이 N → `<img>` N 개 (jsdom mock) | - |
| `<img>` 클릭 → IPC | onClick stub → `inboxApi.openMedia` 호출 | - |
| IPC `inbox:open-media` | path traversal mock → 'invalid path' 반환 | - |
| 아이콘 빌드 | - | `npm run build:icons``build/icon.ico` `build/icon.icns` `build/icon.png` 존재 확인 |
| Win exe 아이콘 | - | `npm run dist:win``Inkling Setup 0.2.8.exe` 우클릭 → properties → 아이콘 = 새 디자인 |
| dogfood image flow | - | inbox 의 thumbnail 클릭 → OS viewer 열림 (Win + macOS) |
**목표**: 단위 460 → 약 467 (+7), typecheck 0.
---
## 6. Risk + Known unknowns
| Risk | 발생 시 대응 |
|---|---|
| `electron-icon-builder` SVG 직접 미지원 | `sharp` 로 SVG → PNG 1024 변환 (4-4 대안 적용) |
| `protocol.handle` 가 Electron 41 미지원 (deprecated `protocol.registerFileProtocol` 만 있는 경우) | Electron 41 docs 확인 후 deprecated API 사용 또는 newer API |
| `<img>` 가 inkling-media:// 로드 실패 (CSP 차단 등) | webContents 의 contentSecurityPolicy 검토. v0.2.5/6 의 single-instance lock + B4 #46 hidden flag 와 무관 |
| Win/Mac dogfood 시 OS viewer 가 default 미설정 | 사용자 OS settings — Inkling 책임 외 (그러나 안내 메시지 가능) |
---
## 7. v0.2.8 후
**다음**: Cut B (v0.2.9) — F17 status 분기 + F18 사유 + F23 Ollama-less. 데이터 모델 정비 cut.
**Cut A 머지 후 dogfood verify 항목**:
1. inbox 의 capture-with-image 흐름 — 캡처 → 이미지 thumbnail 표시 → 클릭 → OS viewer
2. 새 아이콘이 트레이 / Windows taskbar / dock 모두 정확 표시
3. 다중 이미지 (capture 가 N개 첨부) 의 grid layout — flex-wrap 적용 시 N row 자연스러운지
이슈 발견 시 dogfood-feedback.md F26 부터 누적.

View File

@@ -0,0 +1,269 @@
# v0.2.9 — Cut B Design (status 4분기 + 사유 + Ollama-less)
**작성일:** 2026-05-09
**선행 문서:**
- `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` (F17, F18, F23)
- `docs/superpowers/strategy/v028plus-roadmap.md` Cut B
**Cut 라벨:** v0.2.9 — semver 엄밀히 minor (새 status enum + onboarding wizard) 이지만 v0.2.x feature lane 관습 유지.
---
## 1. Cut 정체성
데이터 모델 정비 cut. 메모의 의미 분기 (active / completed / archived / trashed) + 이동 시 사유 입력 + Ollama-less 모드 onboarding. 세 항목이 같은 schema 영역 (notes 테이블 + ai_status enum) 영향이라 한 cut.
---
## 2. 범위
| 항목 | 결정 |
|---|---|
| **F17** | status 4분기 (`active`/`completed`/`archived`/`trashed`) + AI 자동 분류 버튼 (사유 입력 후 클릭 → AI 가 reason+raw_text 분석 → status 추천 → 사용자 confirm/dismiss) |
| **F18** | 자유 텍스트 사유 (preset X — friction 최소). notes.move_reason 컬럼 또는 별도 trash_log 테이블 |
| **F23** | 첫 launch wizard (Y/N) + Ollama 최적화 안내 + 설치 가이드 페이지 링크. ai_status='disabled' 신규 enum + capture skip + raw-only NoteCard fallback |
---
## 3. F17 디테일
### 3-1. Schema 마이그레이션 (m004)
```sql
ALTER TABLE notes ADD COLUMN status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'completed', 'archived', 'trashed'));
ALTER TABLE notes ADD COLUMN status_changed_at TEXT;
ALTER TABLE notes ADD COLUMN move_reason TEXT;
-- 기존 deleted_at != NULL 노트 → status='trashed' migrate
UPDATE notes SET status='trashed', status_changed_at=deleted_at
WHERE deleted_at IS NOT NULL;
```
`deleted_at` 컬럼 — backward compat 위해 잔류 (status='trashed' 와 동기화). 향후 cut 에서 제거 가능.
### 3-2. 인터페이스
```ts
type NoteStatus = 'active' | 'completed' | 'archived' | 'trashed';
interface Note {
// ... 기존 필드
status: NoteStatus;
statusChangedAt: string | null;
moveReason: string | null;
}
```
NoteRepository 메서드:
- `setStatus(id: string, status: NoteStatus, reason: string | null): void`
- `listByStatus(status: NoteStatus, limit?: number): Note[]`
- 기존 `restoreNote()``setStatus(id, 'active', null)` 으로 재구현
### 3-3. UI — inbox 헤더 탭 4개
기존 Inbox / 휴지통 2탭 → **Inbox / 완료 / 보관 / 휴지통** 4탭. 헤더 폭 좁아질 수 있어 short label + count badge.
```tsx
<button>Inbox(N)</button> <button>(N)</button> <button>(N)</button> <button>(N)</button>
```
`useInbox` store 의 `view: 'inbox' | 'completed' | 'archived' | 'trash' | 'settings'` enum 확장 (기존 `showTrash` boolean + `showSettings` boolean → enum 통합 권장 — 또는 boolean 3개 유지). enum 통합이 깔끔.
### 3-4. NoteCard 액션 메뉴
기존 휴지통 버튼 1개 → 메뉴 (설정 페이지 내부의 dropdown 또는 inline 버튼 group):
- "완료로 이동"
- "보관함으로 이동"
- "휴지통으로 이동"
각 클릭 → 사유 입력 modal (한 줄 textarea, 빈 값 허용) → 확인 → `setStatus(id, target, reason)`.
### 3-5. AI 자동 분류 버튼
사유 입력 modal 안 옵션:
```
사유: [____________________________]
[ AI 자동 분류 ] [ 완료 ] [ 보관 ] [ 휴지통 ] [ 취소 ]
```
"AI 자동 분류" 클릭 → main 의 `ai:classify-status` IPC → AiWorker 가 prompt:
```
다음 메모와 사용자 사유를 보고 어디로 이동해야 할지 판단:
- 메모 본문: <raw_text>
- 메모 요약: <summary>
- 사용자 사유: <reason>
- 가능한 status: completed (작업 끝), archived (장기 보관, 회수 가능), trashed (불필요)
JSON 출력: { "recommended": "completed|archived|trashed", "rationale": "..." }
```
응답 → 사용자에게 추천 + rationale 표시:
```
AI 추천: 완료
이유: "처리됨" 표현 + 사용자 사유 "결재 끝" 일치
[ 확정 ] [ 다른 status 선택 ]
```
확정 → setStatus 적용. 다른 선택 → preset 버튼 노출.
### 3-6. IPC
```ts
// 신규
'inbox:set-status': (id: string, status: NoteStatus, reason: string | null) => Promise<{ ok: true }>
'ai:classify-status': (id: string, reason: string) => Promise<{ recommended: NoteStatus; rationale: string }>
```
---
## 4. F18 디테일
자유 텍스트 사유 입력 — F17 의 modal 안에 그대로 포함. 별도 컬럼 `notes.move_reason TEXT` (가장 마지막 사유 보존). 변경 이력 보존 시 별도 테이블 (`note_status_log`) 가능 but YAGNI — 마지막 사유만으로 충분.
빈 값 허용 (preset X 정책 따라). 검색/필터 — 향후 cut (F19 search) 에서 검색 인덱스 포함 가능.
---
## 5. F23 디테일
### 5-1. Schema
ai_status enum 확장: `pending | processing | complete | failed | disabled`. 마이그레이션 m004 동일 commit:
```sql
-- ai_status 가 enum text 라 그대로 새 값 INSERT 가능. CHECK constraint 갱신:
-- (SQLite 는 CHECK ALTER 직접 안 됨 → table 재생성 또는 trigger 추가)
```
SQLite CHECK 갱신 어려움 — 옵션:
- (a) 기존 CHECK 제거 + 새 CHECK 추가 (table 재생성)
- (b) CHECK 부재 + application-level 검증
추천: (b) — application 검증 (zod schema). 마이그레이션 비용 ↓.
### 5-2. Onboarding wizard
첫 launch (settings.json 의 `onboarding_completed` flag 부재) 시 모달:
```
Inkling 사용 시작
Inkling 은 로컬 LLM (Ollama) 으로 메모를 자동 정리합니다.
Ollama 가 설치되어 있고 한국어 지원 모델 (gemma3, gemma2 등) 이 pull 되어 있어야 최적의 경험이 가능합니다.
설치 가이드: https://ollama.com/download
[ AI 자동 처리 사용 (Ollama 필요) ]
[ 원문만 저장 (AI 처리 안 함) ]
[ 나중에 설정 ]
```
3 옵션:
- (1) AI 사용 → settings.ai_enabled=true + onboarding_completed=true
- (2) 원문만 → ai_enabled=false + onboarding_completed=true
- (3) 나중에 → onboarding_completed=false (다음 launch 다시 prompt — 하지만 capture 가능, ai_enabled=null=undefined → default true)
추천: 3 옵션 모두 onboarding_completed=true 로 설정 + 사용자가 설정 페이지에서 언제든 변경. (3) 만 다시 prompt 면 friction.
수정: 3 옵션 모두 close 시 onboarding_completed=true. (3) 은 default ai_enabled=true (LAN Ollama 가정 본인 dogfood).
### 5-3. AI off 시 capture path
CaptureService.create():
```ts
const aiEnabled = await settingsService.get('ai_enabled', true);
if (!aiEnabled) {
// notes INSERT with ai_status='disabled' + skip pending_jobs enqueue
this.repo.insert({ ...input, ai_status: 'disabled' });
return { id, ... };
}
// 기존 path
```
### 5-4. NoteCard fallback
ai_status='disabled' 노트 → title fallback = raw_text 첫 60자 (또는 첫 줄).
```tsx
const displayTitle = note.title?.trim() || note.rawText.split('\n')[0].slice(0, 60) || '(빈 메모)';
```
summary/tags hide. raw_text 그대로.
### 5-5. Banner 비활성
ai_enabled=false → OllamaBanner / FailedBanner 자동 비활성 (state 가 false 면 render skip). HealthChecker 도 ai_enabled=false 시 polling 중단.
### 5-6. 설정 페이지 토글
AI 제공자 섹션 상단 추가:
```
[ ] AI 자동 처리 사용
↳ AI 처리를 사용하면 메모의 제목/요약/태그가 자동 생성됩니다.
Ollama 로컬 LLM 이 필요합니다. 설치 가이드: ollama.com/download
```
토글 OFF → ON 전환 시: onboarding wizard 와 동일 prompt 재노출 (간소화 — 그냥 endpoint 검증 후 결과 표시).
### 5-7. 옛 노트 처리 (ON ↔ OFF 전환)
**B1 정책 채택** (roadmap):
- ON → OFF: 기존 pending 잔재 그대로 (드레인 후 enqueue stop)
- OFF → ON: 기존 disabled 잔류 (사용자 명시 trigger 만 처리)
설정 페이지 안 "기존 disabled 메모 N건 — 지금 모두 처리" 버튼 (ON 전환 후 disabled count > 0 시 노출).
---
## 6. 테스트 전략
| 영역 | 단위 | 수동 |
|---|---|---|
| m004 마이그레이션 | mock db → status 컬럼 + 기존 deleted_at != NULL → trashed | - |
| `setStatus` repo | 4 status 전환 + reason 저장 + statusChangedAt | - |
| `listByStatus` | 각 status filter | - |
| 4탭 UI 렌더 | view enum 4값 분기 + count badge | - |
| 사유 입력 modal | 자유 텍스트 입력 + 빈 값 허용 + 4 status 버튼 | - |
| `ai:classify-status` IPC | mock provider 응답 → recommended + rationale 반환 | - |
| AI 자동 분류 UI | recommended 표시 + 확정 클릭 시 setStatus 호출 | - |
| ai_status='disabled' enum | application zod 검증 + capture path skip pending_jobs | - |
| Onboarding wizard | 첫 launch 시 표시 (settings 부재) + 3 옵션 결과 | 첫 launch 시 표시 확인 |
| AI off 시 NoteCard | title fallback (raw 첫 줄), summary/tags hide | - |
| AI off 시 Banner | render skip 회귀 | - |
**목표**: 단위 467 → 약 490 (+23), typecheck 0.
---
## 7. Risk
| Risk | 대응 |
|---|---|
| AI 자동 분류 정확도 낮음 | 추천만 표시, 사용자 confirm 강제. fallback = preset 4 status 버튼 |
| status 4분기 + tag + reason layer 가 사용자 정신 부담 | dogfood 1주 측정. 사용 빈도 낮은 status 는 v0.2.10+ 에서 hide 옵션 |
| Onboarding wizard 가 첫 launch 흐름 차단 | "나중에 설정" 옵션 제공 + close 가능 |
| ai_enabled false 시 회귀 (기존 pending → 영원히 잔류) | 설정 페이지의 "기존 disabled 메모 N건 처리" 버튼 |
---
## 8. v0.2.9 후
**Cut C** (v0.2.10) — F20 raw_text revision history. AI 재실행 input = current raw_text (latest revision).
**dogfood verify**:
1. 4탭 사용 빈도 (active/completed/archived/trashed) — 사용 안 되는 status 발견 시 cut B+1 에서 hide
2. AI 자동 분류 정확도 (사용자 confirm 비율)
3. Onboarding wizard 의 3 옵션 비율

View File

@@ -0,0 +1,219 @@
# v0.3.0 — Cut E Design (다기기 git-based 양방향 sync)
**작성일:** 2026-05-09
**선행 문서:**
- `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` (F21)
- `docs/superpowers/strategy/v028plus-roadmap.md` Cut E
**Cut 라벨:** v0.3.0 — semver MINOR (새 인프라 — 양방향 sync + Configure UI). Major 영역 진입.
---
## 1. Cut 정체성
기존 push-only `SyncService` → 양방향 (pull + import + conflict resolution + Configure UI). 다기기 (Mac 업무 + Windows 개인) dogfood 가능.
---
## 2. 범위
| 항목 | 결정 |
|---|---|
| **F21 옵션 A** | `git fetch && rebase` 후 markdown → SQLite re-import. 자동 rebase default (충돌 시 fail + 사용자 prompt) |
| **F21 옵션 B** | 설정 페이지 안 "동기화 저장소" sub-section — URL 입력 + 인증 안내 + 마지막 sync 결과 |
| **F21 옵션 C** | conflict UI — 자동 rebase 실패 시 양쪽 비교 + 사용자 선택 |
| **pull 시점** | 양쪽 — manual ("지금 동기화") + 자동 주기 (사용자 설정 가능 interval, default 30분) |
| **revision 결합 (Cut C)** | note_revisions 가 sync 대상 — 양 기기 rev 가 다른 chain 에 있으면 timestamp linear merge (옛 rev 가 sync source 로 inserted) |
---
## 3. SyncService 양방향화
### 3-1. 갱신된 sync() 흐름
```ts
async sync(opts: { interval?: boolean } = {}): Promise<SyncStatus> {
if (!(await this.isConfigured())) return { ok: false, reason: 'not_configured' };
const git = new GitClient(this.syncDir);
// 1. fetch
const fetchR = await git.fetch();
if (fetchR.exitCode !== 0) return { ok: false, reason: `fetch failed: ${fetchR.stderr}` };
// 2. local export (변경 감지 위해)
await this.exportSvc.export(this.syncDir, { includeMedia: true });
await git.addAll();
const localChanged = await git.hasUncommittedChanges();
// 3. local commit (있으면)
let localSha: string | null = null;
if (localChanged) {
const c = await git.commit(`chore(notes): sync ${this.now().toISOString()}`);
localSha = c.sha;
}
// 4. rebase
const rebaseR = await git.rebaseOnto('origin/main');
if (rebaseR.exitCode !== 0) {
// conflict — abort + 사용자에게 conflict UI 안내
await git.rebaseAbort();
return { ok: false, reason: 'conflict', conflicts: await this.listConflicts() };
}
// 5. re-import (rebase 후 markdown 변경 → SQLite 적용)
const imported = await this.importSvc.importAll(this.syncDir);
// 6. push
const pushR = await git.push();
if (pushR.exitCode !== 0) return { ok: false, reason: `push failed: ${pushR.stderr}` };
return { ok: true, changed: localChanged || imported.changedCount > 0, localSha, importedCount: imported.changedCount, pushed: true };
}
```
### 3-2. ImportService 활용
기존 ImportService (백업 복원 흐름) 가 markdown → SQLite 적재. sync 의 re-import 도 같은 service 활용:
```ts
class ImportService {
async importAll(dir: string): Promise<{ changedCount: number; conflicts: string[] }> {
// dir 하위의 모든 .md 파일 → frontmatter parse → notes UPSERT
// existing note 와 비교 — updated_at 더 최신이면 갱신, 아니면 skip
// raw_text 다른 경우 → note_revisions 에 INSERT (new rev, edited_by='sync')
}
}
```
**revision linear merge 정책**:
- 옛 rev (origin/main 의 rev_5) 가 local 에 없으면 → INSERT note_revisions (timestamp 기준 적절 위치)
- local rev 와 origin rev 가 동일 timestamp + 다른 raw_text → conflict (사용자 prompt)
- 일반적으로 다른 timestamp 면 timestamp 순 linear chain 으로 merge
### 3-3. GitClient 확장
```ts
class GitClient {
// 기존: run, isRepo, hasRemote, addAll, commit, push
// 신규
async fetch(): Promise<GitExecResult>;
async rebaseOnto(ref: string): Promise<GitExecResult>;
async rebaseAbort(): Promise<GitExecResult>;
async hasUncommittedChanges(): Promise<boolean>;
async listConflicts(): Promise<string[]>; // git diff --name-only --diff-filter=U
}
```
---
## 4. Configure UI (옵션 B)
설정 페이지 → 신규 sub-section "동기화 저장소":
```
[동기화 저장소]
저장소 URL: [git@gitea.example.com:user/inkling-notes.git]
[ 저장 ] [ 연결 테스트 ]
마지막 sync: 2026-05-09 14:32 (성공, 3건 가져옴, 2건 보냄)
다음 자동 sync: 2026-05-09 15:02
[ 자동 sync 사용 ]
interval: [30] 분
[ 지금 동기화 ] [ 충돌 해결... ]
```
저장소 URL 변경 → main 의 `settings:configure-sync` IPC 호출 → SyncService 가 `<profileDir>/sync/` 에 git init + remote add origin (없으면). 인증 (SSH key / token) 은 사용자 OS 설정 (`~/.ssh/` 또는 git credential helper) — Inkling 자체 인증 X, 안내 메시지만.
---
## 5. Conflict UI (옵션 C)
자동 rebase 실패 시 SyncService 가 `{ ok: false, reason: 'conflict', conflicts: [...] }` 반환. 설정 페이지 의 "충돌 해결..." 버튼 활성화.
클릭 → modal:
```
충돌 N건
[note-id-1.md]
< 내 기기 > | < 다른 기기 >
본문 A | 본문 B
|
[ 내 것 사용 ] [ 원격 사용 ] [ 양쪽 보존 (옛 revision 으로) ]
```
선택:
- **내 것 사용**: local 채택 (origin 변경 폐기)
- **원격 사용**: origin 채택 (local 변경 → note_revisions 에 보존)
- **양쪽 보존**: local + origin 모두 note_revisions 에 INSERT, latest = 사용자 선택 (또는 timestamp 더 최신)
확정 → SyncService.resolveConflict(noteId, choice) → git rebase --continue → push.
---
## 6. 자동 주기 sync
main process 가 settings.sync_interval_min (default 30) 마다 `SyncService.sync({ interval: true })` 호출. interval=true 시 conflict 발생해도 silent (notification 만, 사용자가 다음 manual 또는 conflict UI 진입 시 처리).
settings: `sync_auto_enabled: boolean` (default true 단, configured 일 때만), `sync_interval_min: number` (default 30, min 5).
---
## 7. IPC
```ts
// 신규
'settings:configure-sync': (url: string) => Promise<{ ok: true } | { ok: false; reason: string }>
'settings:test-sync-connection': () => Promise<{ ok: true } | { ok: false; reason: string }> // git ls-remote
'sync:list-conflicts': () => Promise<Array<{ noteId: string; localText: string; remoteText: string }>>
'sync:resolve-conflict': (noteId: string, choice: 'local' | 'remote' | 'both') => Promise<{ ok: true }>
'sync:get-status': () => Promise<{ lastAt: string | null; lastResult: SyncStatus | null; nextAt: string | null }>
```
---
## 8. 테스트 전략
| 영역 | 단위 |
|---|---|
| `GitClient.fetch / rebaseOnto / rebaseAbort` | mock execFile + 결과 검증 |
| `SyncService.sync` 양방향 | mock GitClient + ImportService → 6 단계 흐름 |
| 자동 rebase 성공 | conflict 없는 시나리오 |
| 자동 rebase 실패 → abort | conflict 시 rebaseAbort + reason 반환 |
| ImportService.importAll | markdown → notes UPSERT + revision INSERT |
| revision merge | 양 chain → timestamp 순 linear |
| Configure UI | URL 입력 → IPC → git init/remote add |
| Conflict UI | 3 choice 별 sync 동작 |
| 자동 주기 sync | timer + interval=true mode |
**목표**: 단위 528 → 약 555 (+27), typecheck 0.
---
## 9. Risk
| Risk | 대응 |
|---|---|
| 인증 설정 실패 (사용자 SSH key 부재) | Configure UI 의 "연결 테스트" 버튼 — git ls-remote 결과 사용자에게 표시 |
| revision linear merge 정확도 | timestamp 단조 증가 가정 (양 기기 시계 동기화). NTP 부재 시 충돌 risk → 사용자 prompt |
| 자동 주기 sync 의 silent 충돌 누적 | interval mode 충돌 시 notification + 충돌 UI 자동 popup option |
| Cut C revision history 와 sync 결합 시 chain 분기 | 본 cut 의 정책: timestamp linear, branch 분기 미지원 (사용자 manual 결정으로 처리) |
---
## 10. v0.3.0 후
**Cut F** (v0.3.1) — F24 멀티모달 vision.
**dogfood verify**:
1. Mac + Windows 양 기기 sync 1주 — 충돌 빈도 측정
2. 자동 주기 sync 의 timing — battery / network 영향
3. revision merge 정확도 (사용자 confirm 비율)

View File

@@ -0,0 +1,246 @@
# v0.3.1 — Cut F Design (멀티모달 vision AI)
**작성일:** 2026-05-09
**선행 문서:**
- `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` (F24)
- `docs/superpowers/strategy/v028plus-roadmap.md` Cut F
**Cut 라벨:** v0.3.1 — patch (vision 추가, 기존 기능 영향 X)
---
## 1. Cut 정체성
Ollama vision 모델 (gemma3 family default) 활용 — 이미지 + raw_text 결합 prompt 또는 이미지 단독 분석 → title/summary/tags 자동 생성. F22 prerequisite (Cut A) 이미 완료.
---
## 2. 범위
| 항목 | 결정 |
|---|---|
| **F24 default 모델** | gemma3 family (한국어 + 이미지 둘 다 강함, 본인 메모 `gemma4:e4b` 텍스트 모델과 같은 가족) |
| **prompt 모드** | 단일 vision 모델 호출 (vision 모델이 텍스트도 처리). 모델 capability 부족 시 2단계 fallback (자동) |
| **capability detection** | app launch 시 1회 + 설정 페이지 manual refresh 버튼 |
| **F23 OFF 시 자동 OFF** | `ai_enabled=false` → vision 도 자동 OFF (자명) |
---
## 3. Capability Detection
### 3-1. Ollama API 활용
`GET /api/tags` → 사용자 Ollama instance 의 모델 목록. response:
```json
{
"models": [
{ "name": "gemma4:e4b", "details": { "family": "gemma" } },
{ "name": "gemma3:12b-vision", "details": { "family": "gemma3", "families": ["gemma3"] } },
{ "name": "llava:13b", "details": { "family": "llava" } }
]
}
```
vision capable 판정 — 모델 이름 또는 family 기반:
```ts
const VISION_FAMILIES = new Set(['gemma3', 'llava', 'llama3.2-vision', 'minicpm-v', 'pixtral']);
const VISION_NAME_HINTS = ['vision', 'vl', 'multimodal', 'gemma3'];
function isVisionCapable(model: { name: string; details?: { family?: string; families?: string[] } }): boolean {
if (model.details?.family && VISION_FAMILIES.has(model.details.family)) return true;
if (model.details?.families?.some(f => VISION_FAMILIES.has(f))) return true;
return VISION_NAME_HINTS.some(h => model.name.toLowerCase().includes(h));
}
```
### 3-2. Settings storage
```ts
interface SettingsSchema {
// ... 기존
vision_model?: string; // 사용자 명시 모델 (빈 값 = 비활성)
vision_capable_cache?: string[]; // launch 시 detected 결과 cache
vision_cache_at?: string; // ISO timestamp
}
```
### 3-3. AppLaunchDetect
```ts
// src/main/index.ts whenReady 안 (settings 초기화 후)
async function refreshVisionCache(): Promise<void> {
if (!settingsService.get('ai_enabled', true)) return;
try {
const tags = await fetch(`${endpoint}/api/tags`).then(r => r.json());
const capable = tags.models.filter(isVisionCapable).map((m: any) => m.name);
settingsService.set('vision_capable_cache', capable);
settingsService.set('vision_cache_at', new Date().toISOString());
} catch {
// network fail — silent, cache 유지
}
}
void refreshVisionCache();
```
### 3-4. 설정 페이지 UI (AI 제공자 섹션 확장)
```
[AI 제공자]
Endpoint: [http://localhost:11434]
모델: [gemma4:e4b]
[이미지 분석 모델 (선택사항)]
[gemma3:12b-vision ▾] ← dropdown, 비어 있으면 비활성
가능한 모델: gemma3:12b-vision, llava:13b, ...
[ 다시 감지 ] 마지막 감지: 2026-05-09 14:30
```
dropdown — `vision_capable_cache` 결과 + 빈 옵션. "다시 감지" → `refreshVisionCache()` + UI 갱신.
---
## 4. InferenceProvider 확장
### 4-1. 인터페이스
```ts
// src/main/ai/InferenceProvider.ts
interface GenerateInput {
text: string;
images?: Array<{ base64: string; mime: string }>; // NEW
todayKst: string;
dueDateCandidates: string[];
vocab?: string[];
}
interface InferenceProvider {
generate(input: GenerateInput, opts?: { visionModel?: string }): Promise<AiResponse>;
abort?(): void;
}
```
### 4-2. LocalOllamaProvider 갱신
```ts
async generate(input: GenerateInput, opts?: { visionModel?: string }): Promise<AiResponse> {
const useVision = !!opts?.visionModel && (input.images?.length ?? 0) > 0;
const model = useVision ? opts.visionModel : this.textModel;
const body: any = {
model,
prompt: useVision
? buildVisionPrompt(input.text, input.todayKst, input.dueDateCandidates, input.vocab ?? [])
: buildPrompt(input.text, input.todayKst, input.dueDateCandidates, input.vocab ?? []),
stream: false,
format: 'json'
};
if (useVision) {
body.images = input.images!.map(i => i.base64);
}
const res = await request(`${this.endpoint}/api/generate`, body);
// ... 기존 parse
}
```
### 4-3. buildVisionPrompt
```ts
function buildVisionPrompt(text: string, todayKst: string, dueCandidates: string[], vocab: string[]): string {
return `다음 메모와 첨부 이미지를 종합 분석해 한국어로 요약하세요.
메모 본문 (비어 있을 수 있음):
${text || '(이미지만 있음)'}
이미지 분석 시 주요 시각적 정보 (텍스트, 사람, 장면) 도 포함해 요약하세요.
출력 JSON: { "title": "...", "summary": "...", "tags": [...], "due_date": "..." }
오늘: ${todayKst}
가능한 due 후보: ${dueCandidates.join(', ')}
빈출 태그: ${vocab.slice(0, 20).join(', ')}`;
}
```
---
## 5. AiWorker 통합
CaptureService 가 capture 시 image 첨부했으면 → notes.media 에 저장 + pending_jobs INSERT. AiWorker 가 job 처리 시:
```ts
// src/main/ai/AiWorker.ts
async processJob(noteId: string): Promise<void> {
const note = this.repo.getById(noteId);
const media = this.repo.listMediaByNote(noteId);
const visionModel = this.settings.get('vision_model');
let images: Array<{ base64: string; mime: string }> | undefined;
if (visionModel && media.length > 0) {
images = await Promise.all(media.map(async (m) => ({
base64: (await fs.readFile(this.mediaStore.absolutePath(m.relPath))).toString('base64'),
mime: m.mime
})));
}
const provider = this.providerHolder.get();
const response = await provider.generate({ text: note.rawText, images, ... }, { visionModel });
// ... 기존 결과 적용
}
```
`media.length > 0 && visionModel` 둘 다 true 일 때만 vision path. 그 외는 기존 text-only.
---
## 6. 이미지만 있는 capture
`raw_text` 빈 값 + media 첨부만:
- 기존 동작: notes INSERT (raw_text=''), AiWorker 가 빈 prompt 로 호출 → ai_status='failed' 또는 무의미 응답
- vision enabled: AiWorker 가 vision prompt + images → 의미 있는 title/summary/tags 응답
- vision disabled (visionModel 빈 값): notes 저장만, ai_status='disabled' 신규 enum 활용 (Cut B 의 ai_enabled false 와 비슷한 의미 — 그러나 부분 disable, 즉 "이미지 only 라 처리 불가" 상태)
추천: vision disabled + image-only capture 시 `ai_status='skipped'` 신규 enum (Cut B 의 'disabled' 와 다름). title fallback = "(이미지 N개)" 또는 첫 이미지 파일명.
---
## 7. 테스트 전략
| 영역 | 단위 |
|---|---|
| `isVisionCapable` | family / families / name hint 별 판정 |
| `refreshVisionCache` | mock /api/tags → capable 추출 + settings 저장 |
| 설정 페이지 dropdown | cache 기반 옵션 + "다시 감지" 클릭 → IPC |
| `LocalOllamaProvider.generate` vision path | images 비어있음 → text-only / images 있음 + visionModel → vision body |
| `buildVisionPrompt` | 빈 text + images 만 케이스 정확 prompt |
| `AiWorker.processJob` vision integration | media + visionModel 있을 때만 base64 변환 |
| 이미지 only capture | raw_text='' + media → vision 결과 정상 또는 'skipped' 분기 |
**목표**: 단위 555 → 약 575 (+20), typecheck 0.
---
## 8. Risk
| Risk | 대응 |
|---|---|
| vision 모델 추론 latency 큼 (수 초~분) | AiWorker backend 처리 — 사용자 대기 X. NoteCard 가 ai_status='processing' 표시 |
| 이미지 base64 메모리 부담 | media 1개당 평균 < 1MB. 다중 이미지 시 N×base64 = 메모리 N배. cap (이미지당 max size 5MB) 적용 |
| capability detection 실패 시 fallback | cache 부재 → vision dropdown 비어있음 표시 + "다시 감지" 안내 |
| vision 모델 한국어 정확도 | dogfood 검증. gemma3 가 한국어 약하면 다른 family 추천 갱신 (메모리 정책 갱신) |
| Ollama 가 vision images 필드 무시 (모델이 multimodal 미지원) | 자동 2단계 fallback — vision 모델로 caption 추출 → 텍스트 모델로 종합 (capability 부족 시) |
---
## 9. v0.3.1 후
**Cut G** (v0.3.2) — F25 사이드바 + notebook_id.
**dogfood verify**:
1. 이미지 capture 빈도 (가설: 일 ≥ 1건 = vision 가치)
2. vision 결과 사용자 수정 비율 (정확도 측정)
3. capability detection 정확도 (false-positive / false-negative)

View File

@@ -0,0 +1,227 @@
# v0.3.2 — Cut G Design (사이드바 + notebook 카테고리)
**작성일:** 2026-05-09
**선행 문서:**
- `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` (F25)
- `docs/superpowers/strategy/v028plus-roadmap.md` Cut G
**Cut 라벨:** v0.3.2
---
## 1. Cut 정체성
inbox layout 재구성 — 사이드바 + 메모 카테고리 (notebook). single-pane → two-pane. 단일 DB 안 `notebook_id` 컬럼 (옵션 B — 1주 scope, 다중 profile 옵션 A 는 v0.4+ 후보).
---
## 2. 범위
| 항목 | 결정 |
|---|---|
| **F25 저장소 정의** | B — 카테고리/폴더 (notebook_id, 단일 DB 안 그룹화) |
| **사이드바 가시성** | 사용자 토글 + last state 보존 (settings) |
| **사이드바 내용** | 상단 notebook 목록 + 하단 메모 list (compact view) |
---
## 3. Schema 마이그레이션 (m007)
```sql
CREATE TABLE notebooks (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
color TEXT, -- accent color for UI (옵션)
created_at TEXT NOT NULL,
position INTEGER NOT NULL DEFAULT 0
);
INSERT INTO notebooks (id, name, created_at, position)
VALUES ('default', '기본', '2026-05-09T00:00:00Z', 0);
ALTER TABLE notes ADD COLUMN notebook_id TEXT NOT NULL DEFAULT 'default'
REFERENCES notebooks(id) ON DELETE RESTRICT;
CREATE INDEX idx_notes_notebook_status ON notes(notebook_id, status, created_at DESC);
```
기존 모든 notes → notebook_id='default'. 사용자가 새 notebook 생성 후 메모 이동 가능.
`ON DELETE RESTRICT` — notebook 삭제 시 노트 잔류해야 함. notebook 삭제 흐름은 사용자가 명시 (메모 이동 후 삭제).
---
## 4. NotebookRepository
```ts
class NotebookRepository {
list(): Notebook[];
get(id: string): Notebook | undefined;
create(name: string, color?: string): Notebook;
rename(id: string, name: string): void;
delete(id: string): void; // notebook 안 메모 0건일 때만 (RESTRICT 위반 시 throw)
reorder(ids: string[]): void; // position 갱신
countNotes(id: string, opts?: { status?: NoteStatus }): number;
}
```
NoteRepository 의 모든 query 에 `notebook_id` filter 추가:
```ts
listByStatus(status: NoteStatus, opts: { notebookId?: string; limit?: number }): Note[];
moveToNotebook(noteId: string, notebookId: string): void;
```
---
## 5. UI — 사이드바
### 5-1. layout
```
┌──────────────┬───────────────────────────────────┐
│ [≡] Inkling │ [Inbox(N) 완료(N) 보관(N) 휴지통(N)] [🔍 search] [⚙] │
├──────────────┼───────────────────────────────────┤
│ 노트북 │ │
│ • 기본 (12) │ NoteCard list (current view) │
│ • 회사 (5) │ │
│ • 학습 (3) │ │
│ + 새 노트북 │ │
├──────────────┤ │
│ 메모 빠른 list│ │
│ - title 1 │ │
│ - title 2 │ │
│ - title 3 │ │
│ ... │ │
└──────────────┴───────────────────────────────────┘
```
폭: 240px (settings 의 `sidebar_width` 사용자 조정 가능, default 240, min 180, max 400).
### 5-2. 토글
헤더 좌측 햄버거 (`≡`) 버튼 → `useInbox.sidebarVisible` toggle. last state 저장 (`settings.sidebar_visible`).
키보드 shortcut: `Ctrl+B` (또는 `Cmd+B` macOS) — 빠른 토글.
### 5-3. Notebook 목록
상단 panel — `NotebookRepository.list()` + 각 notebook 의 active 메모 count.
- 클릭 → `useInbox.selectedNotebookId` 갱신 → main pane 의 NoteCard list 가 해당 notebook 만 표시.
- 우클릭 → context menu: 이름 변경 / 색 변경 / 삭제 (메모 0건일 때만).
- "+ 새 노트북" 버튼 → modal: name 입력 + color picker (선택사항) → create.
### 5-4. 메모 빠른 list
하단 panel — selected notebook + selected status (Inbox/완료/보관/휴지통 탭) 의 NoteCard 들의 compact view.
- title + tag chip 1-2 개 + 시간 (relative — "2시간 전")
- 클릭 → main pane 가 해당 NoteCard 위치로 scroll (또는 강조)
main pane 의 NoteCard grid 와 사이드 빠른 list 는 동일 데이터 — 단지 view 다름. 사이드는 navigation, main 은 detail.
### 5-5. NoteCard 갱신 — notebook 이동
NoteCard 액션 메뉴 (Cut B 의 status 메뉴 옆):
- "다른 노트북으로 이동" → notebook 목록 dropdown → 선택 → `moveToNotebook` IPC
---
## 6. store 갱신
```ts
interface InboxState {
// 기존
view: 'inbox' | 'completed' | 'archived' | 'trash' | 'review-daily' | 'review-weekly' | 'review-monthly' | 'settings';
// 신규 (Cut G)
notebooks: Notebook[];
selectedNotebookId: string;
sidebarVisible: boolean;
loadNotebooks: () => Promise<void>;
selectNotebook: (id: string) => void;
createNotebook: (name: string, color?: string) => Promise<void>;
renameNotebook: (id: string, name: string) => Promise<void>;
deleteNotebook: (id: string) => Promise<void>;
toggleSidebar: () => void;
}
```
`refreshMeta` / `loadInitial` 가 notebooks 도 함께 fetch.
---
## 7. IPC
```ts
'inbox:list-notebooks': () => Promise<Notebook[]>
'inbox:create-notebook': (name: string, color?: string) => Promise<Notebook>
'inbox:rename-notebook': (id: string, name: string) => Promise<{ ok: true }>
'inbox:delete-notebook': (id: string) => Promise<{ ok: true } | { ok: false; reason: string }>
'inbox:move-to-notebook': (noteId: string, notebookId: string) => Promise<{ ok: true }>
'inbox:reorder-notebooks': (ids: string[]) => Promise<{ ok: true }>
```
---
## 8. F19 search 와 결합 (Cut D 후)
search box — 사이드바 도입 후 위치 검토:
- (a) **inbox 헤더 잔류** (Cut D 결정) — 단순. 사이드바 토글 무관.
- (b) **사이드바 안 상단** — 사이드바 visible 일 때만 search. hidden 시 inbox 헤더 fallback.
추천: (a) — Cut D 결정 보존, 사이드바 토글 무관. UX 일관.
search 결과 — current selectedNotebookId 안만 또는 모든 notebook? settings 토글 또는 search options dropdown. 추천: 기본 current notebook 안 검색 + "모든 노트북에서 검색" 옵션.
---
## 9. 테스트 전략
| 영역 | 단위 |
|---|---|
| m007 마이그레이션 | notebooks 테이블 + 'default' INSERT + notes.notebook_id backfill |
| `NotebookRepository.list/create/rename/delete/reorder` | 각 메서드 |
| `delete` RESTRICT | 메모 잔류 시 throw |
| `moveToNotebook` | notebook_id 갱신 + 카운트 영향 |
| 사이드바 토글 | store action + settings 저장 |
| Notebook 목록 렌더 | count badge + 클릭 → selectedNotebookId 갱신 |
| 메모 빠른 list | selectedNotebook + selectedView 필터 |
| Notebook 생성 modal | name 입력 + color picker → create |
| Notebook 삭제 | 메모 잔류 시 error 표시 |
| search + notebook scope | 'current notebook' / 'all' 옵션별 필터 |
**목표**: 단위 575 → 약 600 (+25), typecheck 0.
---
## 10. Risk
| Risk | 대응 |
|---|---|
| 사이드바 폭이 좁은 화면 (1280×720) 에서 너무 큼 | default hidden 옵션? settings 의 width 조정 + 좁은 화면 시 자동 hide |
| Notebook 삭제 시 RESTRICT error UX | error message + "메모 N건 이동 후 다시 시도" 안내 |
| 다중 notebook 시 search default scope 혼란 | search box 옆 'current/all' 토글 + 기본 current |
| F21 sync (Cut E) 와 결합 시 notebook 정합성 | sync markdown export 가 notebook_id 도 frontmatter 에 포함 — Cut E ImportService 갱신 (미리 spec 잔류 — Cut G 머지 시 ImportService 갱신 commit 포함) |
| 다중 profile 옵션 A 로 진화 시 notebook → profile 마이그레이션 | v0.4+ 영역. 본 cut 은 단일 profile + notebook 다 |
---
## 11. v0.3.2 후
**v0.4 후보** (사용자 dogfood metric 충족 후 외부 확장):
- F25 옵션 A (다중 profile 분리 DB) — 외부 user 확장 시
- F19 옵션 B (context-based recall — 시간/태그/요일)
- F19 옵션 E (spaced repetition)
- F25 옵션 C (다중 sync remote)
**dogfood verify**:
1. 사이드바 사용 빈도 (열린 채로 유지 / 토글 자주)
2. notebook 갯수 (본인 dogfood — 1개 vs N개)
3. notebook 간 메모 이동 빈도 (분류 욕구 측정)

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

@@ -0,0 +1,211 @@
# v0.2.8+ Roadmap — F17~F25 cut 분할 + 우선순위
**작성일:** 2026-05-09
**저자:** 김태현
**선행 문서:**
- `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` (F17~F25 raw + chore 아이콘)
- `docs/superpowers/v024-backlog.md` (잔여 23건 — v0.2.6 cut 후 deferred)
- `docs/superpowers/strategy/strategy.md` (심리학 전략)
**목적:** v0.2.7 release 후 dogfood 9건 누적 + chore 1건 의 cut sequencing + 우선순위 + dependency 결정. v0.2.8 brainstorm 진입 직전 alignment 문서.
---
## 1. 항목 요약
| ID | 제목 | scope | 분류 |
|---|---|---|---|
| F17 | 휴지통 의미 분기 (완료/보관/버림) | 1주 (옵션 C 보관함만 별도) | 데이터 모델 |
| F18 | 메모 이동 시 사유 입력 | 1일 (F17 묶음) | 데이터 모델 |
| F19 | 획기적 recall (search/context/AI/회고/spaced/자연어) | A 단독 3-4일 / 묶음 1-2주 | UX 본질 |
| F20 | 기존 메모 raw_text 수정 (load-bearing invariant 재검토) | 옵션 B 3-4일 | 데이터 모델 |
| F21 | 다기기 git-based sync (양방향 + Configure + conflict) | 1-2주 | 인프라 |
| F22 | NoteCard 이미지 회색 placeholder bug | 1-2일 | 명확한 bug |
| F23 | 로컬 LLM 활성화 옵션 (Ollama-less 모드) | 3-4일 | 환경 대응 |
| F24 | 이미지 멀티모달 vision AI | 1주 (F22 prerequisite) | AI 확장 |
| F25 | 사이드바 + 메모 저장소 리스트 | 옵션 결정 후 1-3주 | UI 큰 변화 |
| chore | 앱 아이콘 SVG → ICO/ICNS/PNG + builder 통합 | 0.5일 | release polish |
---
## 2. Dependency Graph
```dot
digraph G {
rankdir=LR;
F22 -> F24 [label="prerequisite (이미지 렌더 → vision 결과 surface)"];
F17 -> F18 [label="conceptual 강한 결합 (status + reason)"];
F17 -> F19 [label="status 분기 데이터가 recall 입력"];
F20 -> F21 [label="user_edited_text 가 sync 충돌 정책 입력"];
F23 -> F19 [label="Ollama-less 시 recall 단순화 (tag 부재)"];
F23 -> F17 [label="raw-only 모드에서 status 자동 분류 무력"];
F25 -> F17 [label="저장소 + status + tag 분기 layer 정합 필요"];
chore [shape=box, style=filled];
F22 [shape=box, style=filled];
chore -> "v0.2.8";
F22 -> "v0.2.8";
}
```
**핵심 prerequisite chain:**
- F22 → F24 (이미지 보여야 vision 결과 surface 의미)
- F20 → F21 (sync 충돌 정책 = `user_edited_text` 우선순위)
- F17 + F23 → F19 (recall 알고리즘 입력은 status / Ollama-less 영향)
**독립 항목 (다른 항목 영향 받지 않음):**
- F22 (bug fix)
- chore (icon)
---
## 3. Cut 분할 + 버전 매핑
### Cut A — v0.2.8 (1주 미만, 빠른 polish)
**테마:** dogfood UX 마찰 + release polish
| 항목 | scope |
|---|---|
| F22 (이미지 렌더링 fix) | 1-2일 — `inkling-media://` custom protocol + `<img>` |
| chore (앱 아이콘) | 0.5일 — SVG → ICO/ICNS/PNG 다중 size + electron-builder config |
**합 2-3일.** 명확한 작업, 빠른 release. 의사결정 X (기술 detail 만).
### Cut B — v0.2.9 (2주, 데이터 모델 정비 1차)
**테마:** 휴지통의 의미 분기 + 사유 + Ollama-less
| 항목 | scope |
|---|---|
| F17 (status — 옵션 C 보관함만 별도) | 1주 — `archived_at` 컬럼 + UI 탭 + 마이그레이션 |
| F18 (사유 입력 — preset + 자유 텍스트) | 1일 (F17 묶음) |
| F23 (Ollama-less 토글) | 3-4일 — ai_status='disabled' enum + capture skip + UI fallback |
**합 1.5-2주.** F17/F18 같은 데이터 모델 변경 cut 안에 함께. F23 의 raw-only 모드가 F17 status 와 같은 schema 영역이라 효율.
**의사결정 필요 (brainstorm 단계)**:
- F17 옵션 A/B/C 중 — C 추천 (보관함만 별도) 가 가장 균형
- F18 preset 항목 명세 ("완료" / "급하지 않음" / "잘못 적음" / "기타")
- F23 ON↔OFF 전환 정책 (B1 추천 — 잔류)
### Cut C — v0.2.10 (1주, raw_text invariant)
**테마:** F20 단독 — load-bearing invariant 재검토
| 항목 | scope |
|---|---|
| F20 (raw_text 수정 — 옵션 B `user_edited_text`) | 3-4일 |
**합 1주.** Cut C 단독 cut 인 이유 = invariant 정책 변경 자체가 의사결정 큰 작업. 별도 PR 로 review focus 보장. 후속 Cut D (sync) 의 prerequisite.
**의사결정 필요**:
- 옵션 A (raw_text 직접 수정 + 원본 lost) vs B (`user_edited_text` 분기) — B 추천
- AI 재실행 시 input — raw_text vs user_edited_text 우선순위
### Cut D — v0.2.11 (1.5-2주, recall 1차)
**테마:** F19 — search 진입 + 회고 view
| 항목 | scope |
|---|---|
| F19 옵션 A (FTS5 free text search) | 3-4일 |
| F19 옵션 D (회고 view) | 1주 |
**합 1.5-2주.** F19 의 6 옵션 중 가장 작은 + 가치 큰 둘 (search + 회고). B/C/E/F 는 v0.3+ deferred.
**의사결정 필요**:
- search box 위치 (header / 사이드바 — F25 결정 영향)
- 회고 view 트리거 (수동 라우트 / 월요일 자동 banner)
### Cut E — v0.3.0 (2주, 다기기 sync)
**테마:** F21 — 양방향 sync + Configure UI
| 항목 | scope |
|---|---|
| F21 옵션 A (양방향 sync — fetch+rebase+import) | 1주 |
| F21 옵션 B (Configure UI) | 3-4일 |
| F21 옵션 C (conflict UI) | 0.5주 |
**합 2주.** F20 의 user_edited_text 가 conflict 정책 입력 — 따라서 Cut C 후. v0.3.0 = MINOR bump (semver 엄밀히도 minor — 새 feature 큰 영역).
### Cut F — v0.3.1 (1-1.5주, 멀티모달 vision)
**테마:** F24 — Ollama vision 모델 활용
| 항목 | scope |
|---|---|
| F24 (capability detection + 멀티모달 prompt + InferenceProvider 확장) | 1주 |
**합 1주.** F22 prerequisite 충족 (Cut A) 이므로 진행 가능. F23 (Ollama-less) OFF 시 자동 OFF.
### Cut G — v0.3.2 (1-3주, 사이드바 + 저장소)
**테마:** F25 — UI 큰 변화
| 항목 | scope |
|---|---|
| F25 옵션 A (다중 profile) | 2-3주 — 큰 refactor |
| F25 옵션 B (notebook_id) | 1주 |
| F25 옵션 C (다중 sync remote) | 0.5주 |
**의사결정 필요 (직접 사용자 의도 확인)**:
- "메모 저장소" = 다중 DB 분리 (A) / 카테고리 폴더 (B) / sync remote (C) 어느 의미인가
---
## 4. 우선순위 + 시간선 추정
```
2026-05-09 ~ 2026-05-15 Cut A (v0.2.8) ✦ 빠른 polish
2026-05-15 ~ 2026-05-29 Cut B (v0.2.9) ✦ 데이터 모델 정비
2026-05-29 ~ 2026-06-05 Cut C (v0.2.10) ✦ invariant 변경
2026-06-05 ~ 2026-06-19 Cut D (v0.2.11) ✦ recall 1차
2026-06-19 ~ 2026-07-03 Cut E (v0.3.0) ✦ 다기기 sync
2026-07-03 ~ 2026-07-10 Cut F (v0.3.1) ✦ 멀티모달
2026-07-10 ~ 2026-07-31 Cut G (v0.3.2) ✦ 사이드바 + 저장소
```
**총 약 12주.** 본인 dogfood 2주 완주 종료 조건 (v0.4 slice §1.3) 은 Cut B 종료 시점 도달. 그 후 Cut C-G 는 외부 확장 영역.
---
## 5. Risk + Open Questions
| ID | 질문 |
|---|---|
| F17 | A/B/C 중 결정 — dogfood 1주 측정 후? |
| F18 | preset 항목 정확 명세 |
| F19 | recall 6 옵션 중 cut D 에 A+D 외 추가 여부 |
| F20 | invariant 폐기 (옵션 A) 충분 vs B (`user_edited_text`) 분기 — B 균형 추천 |
| F21 | conflict 처리 default (rebase / merge / 사용자 prompt) |
| F23 | default ON / OFF — 본인 LAN Ollama 가정 시 ON, 외부 user 첫 실행 OFF? |
| F24 | vision 모델 default 추천 (한국어 + 이미지) — dogfood 검증 필요 |
| F25 | "메모 저장소" 정의 (A/B/C) — 직접 사용자 확인 |
---
## 6. v0.2.8 brainstorm 진입 시 결정 사항
Cut A (v0.2.8) 는 의사결정 거의 없는 작업이라 brainstorm 가벼움. 그러나 절차상 진입.
**Cut A brainstorm focus:**
1. F22 — `inkling-media://` custom protocol 디테일 (path traversal 검사 / fallback / thumbnail vs full-size)
2. chore — 아이콘 size 매트릭스 (16/32/64/128/256/512/1024) + electron-builder config (`build.win.icon`/`build.mac.icon`/`build.linux.icon`)
3. v0.2.8 release notes 초안
이후 Cut B brainstorm 은 F17 옵션 결정 + F18 preset + F23 정책 등 의사결정 多. 별도 brainstorm 세션.
---
## 7. 변경 이력
- 2026-05-09: 작성. F17~F25 + chore 9+1 entry triage. Cut A~G 분할.

View File

@@ -3,9 +3,9 @@
> 누적 backlog. v0.2.3 cut (7항목 / PR #13~#19) 시점부터 PR review deferred + dogfood 발견 모두 합산. **파일명은 historic** (`v024-backlog.md`) — v0.2.4 ~ v0.2.6 cut 후에도 이어 사용. **v0.2.7 brainstorm 시** 신규 피드백 + 잔여 일괄 triage.
**누적 시작일:** 2026-05-01 (#7 telemetry skeleton 머지 시점)
**최종 갱신:** 2026-05-05 (v0.2.6 정식 cut 16건 처리 완료, PR #24 머지 `8bc33da`)
**최종 갱신:** 2026-05-07 (v0.2.7 cross-platform cut — #45 자동실행 deeper fix)
**총 항목 수:** 46 (#1 stale 포함)
**잔여:** 24건 (=46 처리 21 stale 1)
**잔여:** 23건 (=46 처리 22 stale 1)
## 처리 이력 / 진행 흐름
@@ -19,7 +19,7 @@
| **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 (자동실행 풀림 버그) | 진단 fallback (args 명시 + 진단 로그). dogfood verify 후 v0.2.7 deeper fix | v0.2.6 부분처리 (commit `075f395`), 잔여 v0.2.7 |
| #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`) |

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.6",
"version": "0.2.11",
"private": true,
"description": "Inkling — local-first 한 줄 보관 도구",
"author": "altair823 <dlsrks0734@gmail.com>",
@@ -28,7 +28,11 @@
"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"
"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",
@@ -42,8 +46,14 @@
"**/*.node"
],
"win": {
"icon": "build/icon.ico",
"target": [
{ "target": "nsis", "arch": ["x64"] }
{
"target": "nsis",
"arch": [
"x64"
]
}
]
},
"nsis": {
@@ -54,11 +64,37 @@
"shortcutName": "Inkling"
},
"mac": {
"icon": "build/icon.icns",
"target": [
{ "target": "dmg", "arch": ["arm64"] }
{
"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": {
@@ -72,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",
@@ -79,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

@@ -16,4 +16,10 @@ export interface InferenceProvider {
healthCheck(): Promise<HealthResult>;
/** v0.2.3.1 — 외부에서 in-flight generate 강제 중단. ProviderHolder.replace 시 사용. */
abort?: () => void;
/**
* v0.2.9 Cut B Task 9 — raw JSON 응답 호출. classifyStatus 같은 자체 prompt 호출용.
* Ollama `/api/generate` 의 raw `response` 문자열을 그대로 반환한다 (보통 JSON 문자열).
* 미구현 provider 는 undefined; classifyStatus 는 그 경우 안전 fallback 으로 동작.
*/
generateRaw?: (prompt: string) => Promise<string>;
}

View File

@@ -66,6 +66,39 @@ export class LocalOllamaProvider implements InferenceProvider {
this.abortController?.abort();
}
/**
* v0.2.9 Cut B Task 9 — raw JSON 호출 (classifyStatus 등 자체 prompt 용).
* `format: 'json'` + `stream: false` 로 Ollama 가 valid JSON 문자열을 반환하도록 강제.
* abortController / timeout 은 generate() 와 동일 패턴.
*/
async generateRaw(prompt: string): Promise<string> {
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,
format: 'json',
stream: false,
options: { temperature: this.temperature, num_predict: this.numPredict }
}),
signal: this.abortController.signal
});
if (res.statusCode < 200 || res.statusCode >= 300) {
throw new Error(`ollama http ${res.statusCode}`);
}
const body = (await res.body.json()) as { response?: string };
if (!body.response) throw new Error('missing response field');
return body.response;
} finally {
clearTimeout(timer);
this.abortController = null;
}
}
async healthCheck(): Promise<HealthResult> {
try {
const res = await request(`${this.endpoint}/api/tags`, { method: 'GET' });

View File

@@ -0,0 +1,83 @@
import type { InferenceProvider } from './InferenceProvider.js';
import type { NoteStatus } from '@shared/types';
export interface ClassifyStatusInput {
provider: InferenceProvider;
rawText: string;
summary: string;
reason: string;
}
export interface ClassifyStatusOutput {
recommended: NoteStatus;
rationale: string;
}
const VALID: readonly NoteStatus[] = ['completed', 'archived', 'trashed'];
const PROMPT_TEMPLATE = `다음 메모를 분류하세요.
가능한 status:
- completed: 작업이 끝났고 더 이상 행동 불필요
- archived: 장기 보관 — 회수 가능, 지금은 보지 않음
- trashed: 불필요, 의미 없는 메모
JSON 출력만 하세요: { "recommended": "completed|archived|trashed", "rationale": "<한 문장 한국어>" }
메모 본문:
{{rawText}}
메모 요약:
{{summary}}
사용자 이동 사유:
{{reason}}`;
const FALLBACK: ClassifyStatusOutput = {
recommended: 'archived',
rationale: '판단 실패 — 안전하게 보관 추천'
};
/**
* v0.2.9 Cut B Task 9 — AI 자동 분류 (status 추천).
*
* provider.generateRaw 가 있으면 raw JSON 응답 사용, 없으면 generate() 재사용 시도
* (그 경우 응답 형태 불일치로 보통 fallback). 에러/parse 실패 시 'archived' 안전 default
* (사용자 데이터 보존 우선).
*/
export async function classifyStatus(
input: ClassifyStatusInput
): Promise<ClassifyStatusOutput> {
const prompt = PROMPT_TEMPLATE
.replace('{{rawText}}', input.rawText.length > 0 ? input.rawText : '(빈 메모)')
.replace('{{summary}}', input.summary.length > 0 ? input.summary : '(요약 없음)')
.replace('{{reason}}', input.reason.length > 0 ? input.reason : '(사유 없음)');
let rawJson: string;
try {
if (typeof input.provider.generateRaw === 'function') {
rawJson = await input.provider.generateRaw(prompt);
} else {
// 호환 경로 — provider.generate 가 raw 응답을 노출하지 않으므로 안전 fallback.
return FALLBACK;
}
} catch {
return FALLBACK;
}
let parsed: unknown;
try {
parsed = JSON.parse(rawJson);
} catch {
return FALLBACK;
}
if (typeof parsed !== 'object' || parsed === null) return FALLBACK;
const obj = parsed as { recommended?: unknown; rationale?: unknown };
if (typeof obj.recommended !== 'string' || !VALID.includes(obj.recommended as NoteStatus)) {
return FALLBACK;
}
return {
recommended: obj.recommended as NoteStatus,
rationale: typeof obj.rationale === 'string' ? obj.rationale : ''
};
}

View File

@@ -2,8 +2,12 @@ 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';
import * as m004 from './m004_status.js';
import * as m005 from './m005_ai_disabled.js';
import * as m006 from './m006_revisions.js';
import * as m007 from './m007_fts.js';
const migrations = [m001, m002, m003];
const migrations = [m001, m002, m003, m004, m005, m006, m007];
export function latestVersion(): number {
return migrations[migrations.length - 1]!.version;

View File

@@ -0,0 +1,18 @@
// v4: status 4분기 (active/completed/archived/trashed) + 사유 + status_changed_at.
// 기존 deleted_at != NULL 노트는 status='trashed' 로 migrate. deleted_at 컬럼은
// backward compat 위해 잔류 (status='trashed' 와 동기화). 향후 cut 에서 제거 가능.
import type Database from 'better-sqlite3';
export const version = 4;
export function up(db: Database.Database): void {
db.exec(`
ALTER TABLE notes ADD COLUMN status TEXT NOT NULL DEFAULT 'active';
ALTER TABLE notes ADD COLUMN status_changed_at TEXT;
ALTER TABLE notes ADD COLUMN move_reason TEXT;
`);
db.prepare(
`UPDATE notes SET status='trashed', status_changed_at=deleted_at
WHERE deleted_at IS NOT NULL`
).run();
}

View File

@@ -0,0 +1,65 @@
// v5: ai_status enum 에 'disabled' 추가 (v0.2.9 Cut B). settings.ai_enabled=false 일 때
// CaptureService 가 새 노트를 ai_status='disabled' 로 insert + pending_jobs enqueue skip.
//
// SQLite 는 ALTER COLUMN ... CHECK 미지원 → table recreate 패턴.
// 외래키 (note_tags / media / pending_jobs) 는 notes.id 를 참조 + ON DELETE CASCADE 라
// FK off + DROP/RENAME 시 데이터 보존 위해 새 테이블 생성 → INSERT SELECT → DROP old → RENAME new.
// PRAGMA foreign_keys=OFF 안에서 single transaction (runMigrations 가 transaction 으로 감쌈).
import type Database from 'better-sqlite3';
export const version = 5;
export function up(db: Database.Database): void {
// 기존 인덱스/CHECK 제약을 그대로 유지하되 ai_status 만 'disabled' 추가.
db.exec(`
PRAGMA foreign_keys=OFF;
CREATE TABLE notes_new (
id TEXT PRIMARY KEY,
raw_text TEXT NOT NULL,
ai_title TEXT,
ai_summary TEXT,
ai_status TEXT NOT NULL
CHECK (ai_status IN ('pending','done','failed','disabled')),
ai_error TEXT,
ai_provider TEXT,
ai_generated_at TEXT,
title_edited_by_user INTEGER NOT NULL DEFAULT 0
CHECK (title_edited_by_user IN (0,1)),
summary_edited_by_user INTEGER NOT NULL DEFAULT 0
CHECK (summary_edited_by_user IN (0,1)),
user_intent TEXT,
intent_prompted_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
due_date TEXT,
due_date_edited_by_user INTEGER NOT NULL DEFAULT 0
CHECK (due_date_edited_by_user IN (0,1)),
deleted_at TEXT,
last_recalled_at TEXT,
recall_dismissed_at TEXT,
status TEXT NOT NULL DEFAULT 'active',
status_changed_at TEXT,
move_reason TEXT
);
INSERT INTO notes_new (
id, raw_text, ai_title, ai_summary, ai_status, ai_error, ai_provider, ai_generated_at,
title_edited_by_user, summary_edited_by_user, user_intent, intent_prompted_at,
created_at, updated_at, due_date, due_date_edited_by_user,
deleted_at, last_recalled_at, recall_dismissed_at,
status, status_changed_at, move_reason
)
SELECT
id, raw_text, ai_title, ai_summary, ai_status, ai_error, ai_provider, ai_generated_at,
title_edited_by_user, summary_edited_by_user, user_intent, intent_prompted_at,
created_at, updated_at, due_date, due_date_edited_by_user,
deleted_at, last_recalled_at, recall_dismissed_at,
status, status_changed_at, move_reason
FROM notes;
DROP TABLE notes;
ALTER TABLE notes_new RENAME TO notes;
CREATE INDEX idx_notes_created_at ON notes(created_at DESC);
CREATE INDEX idx_notes_ai_status ON notes(ai_status);
CREATE INDEX IF NOT EXISTS idx_notes_deleted_at ON notes(deleted_at);
PRAGMA foreign_keys=ON;
`);
}

View File

@@ -0,0 +1,23 @@
// v6: note_revisions 테이블 + 기존 notes 의 raw_text 를 edited_by='capture' revision 으로 backfill.
// FK ON DELETE CASCADE — notes 영구 삭제 시 revision 도 함께 삭제.
import type Database from 'better-sqlite3';
export const version = 6;
export function up(db: Database.Database): void {
db.exec(`
CREATE TABLE note_revisions (
rev_id INTEGER PRIMARY KEY AUTOINCREMENT,
note_id TEXT NOT NULL,
raw_text TEXT NOT NULL,
edited_at TEXT NOT NULL,
edited_by TEXT NOT NULL DEFAULT 'user'
CHECK (edited_by IN ('user','capture')),
FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE
);
CREATE INDEX idx_note_revisions_note_id ON note_revisions(note_id, edited_at DESC);
INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by)
SELECT id, raw_text, created_at, 'capture' FROM notes;
`);
}

View File

@@ -0,0 +1,48 @@
// v7: notes_fts FTS5 virtual table + trigger 3개 + 기존 notes (status != 'trashed') backfill.
// raw_text/ai_title/ai_summary 는 trigger 자동 sync. tags 는 note_tags JOIN 결과를
// NoteRepository 의 명시 헬퍼 (rebuildFtsTagsForNote) 로 갱신 — Cut D 의 single write path.
import type Database from 'better-sqlite3';
export const version = 7;
export function up(db: Database.Database): void {
db.exec(`
CREATE VIRTUAL TABLE notes_fts USING fts5(
note_id UNINDEXED,
raw_text,
ai_title,
ai_summary,
tags,
tokenize='unicode61'
);
CREATE TRIGGER notes_fts_ai AFTER INSERT ON notes BEGIN
INSERT INTO notes_fts (note_id, raw_text, ai_title, ai_summary, tags)
VALUES (NEW.id, NEW.raw_text, COALESCE(NEW.ai_title, ''), COALESCE(NEW.ai_summary, ''), '');
END;
CREATE TRIGGER notes_fts_ad AFTER DELETE ON notes BEGIN
DELETE FROM notes_fts WHERE note_id = OLD.id;
END;
CREATE TRIGGER notes_fts_au AFTER UPDATE ON notes BEGIN
UPDATE notes_fts
SET raw_text = NEW.raw_text,
ai_title = COALESCE(NEW.ai_title, ''),
ai_summary = COALESCE(NEW.ai_summary, '')
WHERE note_id = NEW.id;
END;
INSERT INTO notes_fts (note_id, raw_text, ai_title, ai_summary, tags)
SELECT
n.id,
n.raw_text,
COALESCE(n.ai_title, ''),
COALESCE(n.ai_summary, ''),
COALESCE((SELECT GROUP_CONCAT(t.name, ' ')
FROM note_tags nt JOIN tags t ON t.id = nt.tag_id
WHERE nt.note_id = n.id), '')
FROM notes n
WHERE n.status != 'trashed';
`);
}

View File

@@ -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';
@@ -19,6 +19,7 @@ import { ProviderHolder } from './ai/ProviderHolder.js';
import { AiWorker } from './ai/AiWorker.js';
import { registerCaptureApi } from './ipc/captureApi.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
@@ -31,11 +32,18 @@ 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 창
// 띄우지 않음 — 사용자가 명시적으로 클릭한 게 아니므로.
@@ -71,6 +79,9 @@ 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 }))
@@ -83,14 +94,8 @@ app.whenReady().then(async () => {
writeFileSync(initFlag, new Date().toISOString());
logger.info('autostart.enabled.firstRun');
}
// v0.2.6 #45 진단 — 실제 LoginItem 상태 확인 (args 전달 vs 미전달 차이)
const withArgs = app.getLoginItemSettings({ args: [HIDDEN_ARG] });
const noArgs = app.getLoginItemSettings();
logger.info('autostart.state', {
withArgs: { openAtLogin: withArgs.openAtLogin, executableWillLaunchAtLogin: withArgs.executableWillLaunchAtLogin },
noArgs: { openAtLogin: noArgs.openAtLogin, executableWillLaunchAtLogin: noArgs.executableWillLaunchAtLogin },
expectedArgs: [HIDDEN_ARG]
});
// 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);
@@ -117,10 +122,11 @@ app.whenReady().then(async () => {
const provider = new LocalOllamaProvider({ endpoint: resolvedEndpoint, model: resolvedModel });
const providerHolder = new ProviderHolder(provider);
const health = new HealthChecker(providerHolder, {
// v0.2.9 Cut B Task 14 — AI 비활성 시 health polling skip (Ollama 미설치 환경 무영향).
isAiEnabled: () => settingsSvc.isAiEnabled(),
onUpdate: (status) => {
logger.info('ai.health', { ...status } as Record<string, unknown>);
pushOllamaStatus(getInboxWindow, status);
refreshTray({ ollamaOk: status.ok });
},
onTelemetry: (ev) => {
if (ev.kind === 'ollama_unreachable') {
@@ -138,7 +144,8 @@ app.whenReady().then(async () => {
onUpdate: (note) => {
pushNoteUpdated(getInboxWindow, note);
// F4-C: AI 처리 완료 = 새 캡처가 inbox 에 합류한 시점, tray 도 즉시 갱신.
refreshTray({ todayCount: repo.countToday(), failedCount: repo.countFailed() });
// v0.2.7 Phase 3 — failedCount 메뉴 항목 제거됨 → todayCount 만 갱신.
refreshTray({ todayCount: repo.countToday() });
},
logger,
telemetry
@@ -154,14 +161,20 @@ app.whenReady().then(async () => {
const capture = new CaptureService(repo, store, {
enqueue: (id) => worker.enqueue(id),
celebrate: (id) => notify.celebrate(id),
telemetry
telemetry,
settings: settingsSvc
});
registerCaptureApi(capture, getQuickCaptureWindow);
registerInboxApi({
repo, continuity, capture, health, intent,
getInboxWindow, settings: settingsSvc, providerHolder
getInboxWindow, settings: settingsSvc, providerHolder,
paths: { profileDir: paths.profileDir },
// v0.2.9 Cut B Task 16 — disabled 메모 일괄 재투입 시 in-memory queue 갱신.
enqueue: (id) => worker.enqueue(id)
});
// registerSettingsApi 는 backup / exportSvc / importSvc / syncSvc / telemetry 가
// 생성된 뒤에 호출 (Task 10) — 아래 BackupService/ExportService/... 초기화 직후로 이동.
const hotkeys = new HotkeyService();
const reg = hotkeys.register({
@@ -190,6 +203,12 @@ 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, settings: settingsSvc, getInboxWindow
});
let backupOnQuitDone = false;
let trayInterval: NodeJS.Timeout | null = null;
app.on('before-quit', (e) => {
@@ -222,193 +241,34 @@ app.whenReady().then(async () => {
});
});
// v0.2.7 Phase 3 (Task 16) — TrayCallbacks 슬림: 10 → 3.
// 백업/내보내기/복원/동기화/사용 로그/Ollama 재확인/AI 재처리/Ollama 설정/정보 →
// 모두 설정 페이지로 이전 (registerSettingsApi 의 IPC 핸들러가 본문 보유).
createTray({
showInbox: () => createInboxWindow(),
showCapture: () => showQuickCapture(),
runBackup: 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();
}
},
runExport: 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();
}
},
runImport: 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();
}
},
runSync: 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();
}
},
runExportTelemetry: 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;
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();
}
},
runOllamaRecheck: () => { void health.runOnce({ manual: true }); },
runRetryAllFailed: () => { void capture.retryAllFailed(); },
runOpenOllamaSettings: () => {
const win = getInboxWindow();
if (win) win.webContents.send('inbox:openOllamaSettings');
}
showSettings: () => navigateInbox('settings')
});
// F4-C 환경 앵커 — tray tooltip + 메뉴 첫 항목을 오늘 KST 캡처 수로 갱신.
// 초기 1회 + 60s interval. AiWorker.onUpdate 도 별도 갱신 트리거.
// cleanup 은 위 통합 before-quit 핸들러에서 처리.
refreshTray({ todayCount: repo.countToday(), failedCount: repo.countFailed() });
// v0.2.7 Phase 3 — failedCount 메뉴 항목 제거됨 → todayCount 만 갱신.
refreshTray({ todayCount: repo.countToday() });
trayInterval = setInterval(() => {
refreshTray({ todayCount: repo.countToday() });
}, 60_000);
// 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,14 +1,16 @@
import electron from 'electron';
import type { BrowserWindow } from 'electron';
const { ipcMain, dialog } = 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 { Note, NoteStatus } from '@shared/types';
import type { HealthResult } from '../ai/InferenceProvider.js';
import { LocalOllamaProvider } from '../ai/LocalOllamaProvider.js';
import { classifyStatus } from '../ai/classifyStatus.js';
import type { SettingsService } from '../services/SettingsService.js';
import type { ProviderHolder } from '../ai/ProviderHolder.js';
@@ -21,6 +23,12 @@ export interface InboxIpcDeps {
getInboxWindow: () => BrowserWindow | null;
settings: SettingsService;
providerHolder: ProviderHolder;
// v0.2.8 Cut A — `inbox:open-media` 의 path traversal 검사 baseline.
paths: { profileDir: string };
// v0.2.9 Cut B Task 16 — disabled 메모 일괄 처리 시 in-memory worker queue 갱신.
// 미주입 시 fire-and-forget skip (다음 launch 의 loadFromDb 가 처리). 본 hook 은
// AiWorker 인스턴스 직접 주입을 피해 IPC 모듈이 worker import 를 갖지 않도록 분리.
enqueue?: (noteId: string) => Promise<void>;
}
export function registerInboxApi(deps: InboxIpcDeps): void {
@@ -153,6 +161,98 @@ export function registerInboxApi(deps: InboxIpcDeps): void {
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 };
});
// v0.2.9 Cut B Task 4 — status 별 노트 목록.
ipcMain.handle(
'inbox:list-by-status',
(_e, status: NoteStatus, opts: { limit?: number } = {}) => {
const VALID: readonly NoteStatus[] = ['active', 'completed', 'archived', 'trashed'];
if (!VALID.includes(status)) return [] as Note[];
return deps.repo.listByStatus(status, opts);
}
);
// v0.2.9 Cut B Task 4 — 4 status counts (헤더 4탭 badge).
ipcMain.handle('inbox:counts-by-status', () => ({
active: deps.repo.countByStatus('active'),
completed: deps.repo.countByStatus('completed'),
archived: deps.repo.countByStatus('archived'),
trashed: deps.repo.countByStatus('trashed')
}));
// v0.2.9 Cut B Task 8 — status 4분기 직접 전이 (사유 포함).
// Modal 의 "완료/보관/휴지통" 버튼 path. backward compat 동기화는
// NoteRepository.setStatus 내부에서 처리 (deleted_at sync).
ipcMain.handle(
'inbox:set-status',
async (_e, id: string, status: NoteStatus, reason: string | null) => {
const VALID: readonly NoteStatus[] = ['active', 'completed', 'archived', 'trashed'];
if (!VALID.includes(status)) {
return { ok: false as const, reason: 'invalid status' as const };
}
deps.repo.setStatus(id, status, reason);
return { ok: true as const };
}
);
// v0.2.9 Cut B Task 9 — AI 자동 분류 (status 추천).
// Ollama provider.generateRaw 호출 + JSON 응답 파싱. 에러/실패 시 archived fallback
// (사용자 데이터 보존 우선). 자세한 prompt + parse 로직은 src/main/ai/classifyStatus.ts.
ipcMain.handle('ai:classify-status', async (_e, id: string, reason: string) => {
const note = deps.repo.findById(id);
if (note === null) {
return {
recommended: 'archived' as const,
rationale: '메모를 찾을 수 없음 — 안전하게 보관 추천'
};
}
const provider = deps.providerHolder.get();
return classifyStatus({
provider,
rawText: note.rawText,
summary: note.aiSummary ?? '',
reason: typeof reason === 'string' ? reason : ''
});
});
// v0.2.9 Cut B Task 16 — disabled 메모 (ai_enabled OFF 시기 캡처) 일괄 재투입.
// OFF→ON 전환 후 사용자가 "지금 모두 처리" 버튼 클릭 path. repo.requeueDisabled 가
// ai_status='pending' + pending_jobs row 보장, worker.enqueue 가 in-memory queue 갱신.
ipcMain.handle('inbox:enqueue-disabled', async () => {
// requeue 전 대상 id 수집 — UPDATE 가 status 바꾸므로 select 후 update 필요 없이
// requeueDisabled 가 처리한 다음 pending_jobs 에서 다시 가져와 enqueue.
const targets = deps.repo.getAllPendingJobs().map((j) => j.noteId);
const before = new Set(targets);
const count = deps.repo.requeueDisabled();
if (count > 0 && deps.enqueue) {
const after = deps.repo.getAllPendingJobs();
// requeue 직후 새로 들어온 pending_jobs row 만 enqueue (기존 row 는 이미 in-memory queue 에).
for (const j of after) {
if (!before.has(j.noteId)) {
await deps.enqueue(j.noteId);
}
}
}
return { count };
});
ipcMain.handle('inbox:get-disabled-count', () => deps.repo.countByAiStatus('disabled'));
ipcMain.handle('inbox:saveOllamaSettings', async (_e, value: { endpoint: string; model: string }) => {
// 검증: 새 인스턴스로 healthCheck
const trial = new LocalOllamaProvider({ endpoint: value.endpoint, model: value.model });
@@ -169,6 +269,49 @@ export function registerInboxApi(deps: InboxIpcDeps): void {
await deps.health.runOnce();
return { ok: true };
});
// v0.2.10 Cut C — raw_text 가변 + revision 보존.
// updateRawText: 빈 문자열 reject (trim 후 length===0). 그 외엔 그대로 (newline/space 보존).
// listRevisions: 그대로 반환 (camelCase 이미 hydrate 됨).
// restoreRevision: repo throw → { ok: false } (UI 가 에러 표시).
ipcMain.handle('inbox:update-raw-text', async (_e, id: string, newText: string) => {
if (typeof newText !== 'string' || newText.trim().length === 0) {
return { ok: false as const, reason: 'empty' as const };
}
deps.repo.updateRawText(id, newText);
return { ok: true as const };
});
ipcMain.handle('inbox:list-revisions', (_e, id: string) => deps.repo.listRevisions(id));
ipcMain.handle('inbox:restore-revision', async (_e, id: string, revId: number) => {
try {
deps.repo.restoreRevision(id, revId);
return { ok: true as const };
} catch (e) {
return { ok: false as const, reason: (e as Error).message };
}
});
// v0.2.11 Cut D — FTS5 검색 + 회고 aggregate.
ipcMain.handle(
'inbox:search',
(_e, query: string, opts: { limit?: number; status?: NoteStatus } = {}) =>
deps.repo.search(query, opts)
);
ipcMain.handle('inbox:review-aggregate', (_e, period: 'daily' | 'weekly' | 'monthly') => {
const VALID = ['daily', 'weekly', 'monthly'] as const;
if (!(VALID as readonly string[]).includes(period)) {
return {
totalCount: 0,
recentNotes: [],
tagCounts: [],
dueProgress: { total: 0, passed: 0, pending: 0 }
};
}
return deps.repo.reviewAggregate(period);
});
}
export function pushNoteUpdated(getWin: () => BrowserWindow | null, note: Note): void {

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

@@ -0,0 +1,284 @@
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 type { SettingsService } from '../services/SettingsService.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;
settings: SettingsService;
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, settings, getInboxWindow } = deps;
// v0.2.9 Cut B Task 12 — settings read + AI/onboarding 토글.
// 첫 launch 시 OnboardingWizard 분기 (App.tsx) 와 SettingsPage 의 ai_enabled 토글 통합.
ipcMain.handle('settings:get', async () => settings.getAll());
ipcMain.handle('settings:set-ai-enabled', async (_e, enabled: boolean) => {
await settings.setAiEnabled(enabled);
return { ok: true as const };
});
ipcMain.handle('settings:set-onboarding-completed', async (_e, completed: boolean) => {
await settings.setOnboardingCompleted(completed);
return { ok: true as const };
});
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,9 +1,17 @@
import type Database from 'better-sqlite3';
import { v7 as uuidv7, v4 as uuidv4 } from 'uuid';
import type { Note, NoteMedia, NoteTag } from '@shared/types';
import type { AiStatus, Note, NoteMedia, NoteRevision, NoteStatus, NoteTag } from '@shared/types';
import { kstTodayIso } from '../../shared/util/kstDate.js';
import { sanitizeFtsQuery, computeCutoff, type ReviewPeriod } from './ftsHelpers.js';
export interface CreateNoteInput { rawText: string; }
export interface CreateNoteInput {
rawText: string;
/**
* v0.2.9 Cut B — settings.ai_enabled=false 일 때 'disabled' 로 insert + pending_jobs skip.
* 미지정 시 기존 'pending' default + pending_jobs enqueue (backward compat).
*/
aiStatus?: AiStatus;
}
export interface NewMediaRow {
noteId: string;
@@ -15,7 +23,10 @@ export interface NewMediaRow {
export interface ImportNoteInput {
/** Proposed id from the export file. May be replaced if it collides with
* an existing row whose `raw_text` differs (raw_text invariant guard). */
* an existing row whose `raw_text` differs — fork-on-conflict so a single
* id never resolves to two distinct historical baselines (v0.2.10 Cut C
* changed `raw_text 불변` policy → `raw_text 가변` + revision history; the
* baseline distinction is now preserved per-id, edit history per-note). */
id: string;
rawText: string;
createdAt: string;
@@ -48,15 +59,23 @@ export class NoteRepository {
create(input: CreateNoteInput): { id: string } {
const id = uuidv7();
const now = new Date().toISOString();
const aiStatus: AiStatus = input.aiStatus ?? 'pending';
const tx = this.db.transaction(() => {
this.db
.prepare(`INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at)
VALUES (?, ?, 'pending', ?, ?)`)
.run(id, input.rawText, now, now);
VALUES (?, ?, ?, ?, ?)`)
.run(id, input.rawText, aiStatus, now, now);
this.db
.prepare(`INSERT INTO pending_jobs (note_id, attempts, next_run_at)
VALUES (?, 0, ?)`)
.run(id, now);
.prepare(`INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by)
VALUES (?, ?, ?, 'capture')`)
.run(id, input.rawText, now);
// pending_jobs 는 'pending' 일 때만 생성 — 'disabled' 노트는 worker 가 처리 안 함.
if (aiStatus === 'pending') {
this.db
.prepare(`INSERT INTO pending_jobs (note_id, attempts, next_run_at)
VALUES (?, 0, ?)`)
.run(id, now);
}
});
tx();
return { id };
@@ -143,6 +162,7 @@ export class NoteRepository {
linkTag.run(id, tagRow.id);
}
this.db.prepare(`DELETE FROM pending_jobs WHERE note_id=?`).run(id);
this.rebuildFtsTagsForNote(id);
});
tx();
}
@@ -205,6 +225,50 @@ export class NoteRepository {
return { ids };
}
/**
* v0.2.9 Cut B Task 16 — 모든 ai_status='disabled' 노트를 'pending' 으로 reset 하고
* pending_jobs 재투입. 사용자가 settings.ai_enabled OFF→ON 전환 후 "지금 모두 처리"
* 버튼을 누른 path. 단일 transaction. 호출자가 `now` 주입 가능 (테스트성).
*
* INSERT OR IGNORE — race 안전 (이미 pending_jobs row 존재 시 skip).
* 반환값 = 처리된 노트 수 (UI 가 "N건 처리됨" 토스트 등 표시용).
*/
requeueDisabled(now: Date = new Date()): number {
const tx = this.db.transaction(() => {
const ts = now.toISOString();
const targets = this.db
.prepare(`SELECT id FROM notes WHERE ai_status='disabled'`)
.all() as Array<{ id: string }>;
for (const { id } of targets) {
this.db
.prepare(`UPDATE notes SET ai_status='pending', updated_at=? WHERE id=?`)
.run(ts, id);
this.db
.prepare(
`INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)`
)
.run(id, ts);
}
return targets.length;
});
return tx();
}
/**
* v0.2.9 Cut B Task 16 — ai_status 별 row count.
* 설정 페이지의 "원문만 저장된 메모 N건" 표기용 (status='disabled' 카운트).
* deleted_at 필터 없음 — disabled 메모도 trash 갈 수 있는데 사용자 의도는
* "AI 처리할 게 얼마나 남았나?" 라 trashed 까지 포함되면 안 됨. → deleted_at IS NULL 추가.
*/
countByAiStatus(status: AiStatus): number {
const row = this.db
.prepare(
`SELECT COUNT(*) AS c FROM notes WHERE ai_status=? AND deleted_at IS NULL`
)
.get(status) as { c: number };
return row.c;
}
/**
* pending_jobs 의 next_run_at + last_error 만 갱신, attempts 변경 없음.
* v0.2.3 #2 — unreachable/timeout 무한 retry 시 사용 (incrementJobAttempt 와 별도 경로).
@@ -328,6 +392,7 @@ export class NoteRepository {
const row = getOrInsert.get(t) as { id: number };
link.run(id, row.id);
}
this.rebuildFtsTagsForNote(id);
}
});
tx();
@@ -410,25 +475,245 @@ export class NoteRepository {
.run(now, id);
}
/**
* v0.2.10 Cut C — 사용자가 raw_text 정정. notes.raw_text 갱신 + note_revisions 에
* edited_by='user' 새 row INSERT. 단일 transaction. 호출자 `now` 주입 가능 (테스트성).
*
* 옛 raw_text 는 backfill (m006) 으로 capture revision 에 이미 보존됨.
*/
updateRawText(id: string, newText: string, now: Date = new Date()): void {
const ts = now.toISOString();
const tx = this.db.transaction(() => {
this.db
.prepare(`UPDATE notes SET raw_text=?, updated_at=? WHERE id=?`)
.run(newText, ts, id);
this.db
.prepare(
`INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by)
VALUES (?, ?, ?, 'user')`
)
.run(id, newText, ts);
});
tx();
}
/**
* v0.2.10 Cut C — 노트의 모든 revision (capture + user) 을 최신순 반환.
* NoteCard 의 "이력" modal 에서 사용. edited_at DESC + rev_id DESC tiebreak.
*/
listRevisions(id: string): NoteRevision[] {
const rows = this.db
.prepare(
`SELECT rev_id, note_id, raw_text, edited_at, edited_by
FROM note_revisions
WHERE note_id = ?
ORDER BY edited_at DESC, rev_id DESC`
)
.all(id) as Array<{
rev_id: number;
note_id: string;
raw_text: string;
edited_at: string;
edited_by: 'user' | 'capture';
}>;
return rows.map((r) => ({
revId: r.rev_id,
noteId: r.note_id,
rawText: r.raw_text,
editedAt: r.edited_at,
editedBy: r.edited_by
}));
}
/**
* v0.2.10 Cut C — 옛 revision 의 raw_text 를 latest 로 복원. chain 끊지 않고
* 새 user revision 으로 INSERT (linear history 유지). revId 가 해당 note 의 것이
* 아니면 throw — restore 대상 잘못 매칭 방지.
*/
restoreRevision(id: string, revId: number, now: Date = new Date()): void {
const rev = this.db
.prepare(`SELECT raw_text FROM note_revisions WHERE rev_id=? AND note_id=?`)
.get(revId, id) as { raw_text: string } | undefined;
if (!rev) throw new Error(`revision ${revId} not found for note ${id}`);
this.updateRawText(id, rev.raw_text, now);
}
/**
* v0.2.9 Cut B — 노트 status 4분기 전이 (active/completed/archived/trashed).
* status + status_changed_at + move_reason + updated_at 갱신 + deleted_at
* backward-compat 동기화 (status='trashed' → deleted_at=ts, 그 외 → NULL).
*
* 단일 transaction. 호출자가 `now` 주입 가능 (테스트성).
*/
setStatus(
id: string,
status: NoteStatus,
reason: string | null,
now: Date = new Date()
): void {
const tx = this.db.transaction(() => {
const ts = now.toISOString();
this.db
.prepare(
`UPDATE notes
SET status = ?,
move_reason = ?,
status_changed_at = ?,
updated_at = ?
WHERE id = ?`
)
.run(status, reason, ts, ts, id);
// backward compat: deleted_at 컬럼은 m004 이후로도 status='trashed' 와 동기화.
if (status === 'trashed') {
this.db.prepare(`UPDATE notes SET deleted_at = ? WHERE id = ?`).run(ts, id);
} else {
this.db.prepare(`UPDATE notes SET deleted_at = NULL WHERE id = ?`).run(id);
}
});
tx();
}
/**
* v0.2.9 Cut B Task 4 — status 별 row count. 4탭 헤더 badge 용.
* tags/media hydrate 없음 (cheap path, listByStatus 와 별도).
*/
countByStatus(status: NoteStatus): number {
const row = this.db
.prepare(`SELECT COUNT(*) AS c FROM notes WHERE status = ?`)
.get(status) as { c: number };
return row.c;
}
/**
* v0.2.9 Cut B — status 별 노트 목록. status_changed_at DESC (최근 전이 우선),
* NULL 은 created_at fallback. limit cap 200 (list/listTrashed 와 동일).
*/
listByStatus(status: NoteStatus, opts: { limit?: number } = {}): Note[] {
const limit = Math.max(1, Math.min(200, opts.limit ?? 200));
const rows = this.db
.prepare(
`SELECT * FROM notes
WHERE status = ?
ORDER BY COALESCE(status_changed_at, created_at) DESC, id DESC
LIMIT ?`
)
.all(status, limit) as Record<string, unknown>[];
return rows.map((r) => this.hydrate(r));
}
/**
* v0.2.11 Cut D — FTS5 검색. notes_fts MATCH + rank 정렬 + 기본 trashed 제외.
* 빈/공백 query → []. multi-token 은 implicit AND. FTS5 special chars 는 sanitize.
*/
search(query: string, opts: { limit?: number; status?: NoteStatus } = {}): Note[] {
const sanitized = sanitizeFtsQuery(query);
if (sanitized.length === 0) return [];
const limit = Math.max(1, Math.min(200, opts.limit ?? 50));
const statusClause = opts.status ? `AND n.status = ?` : `AND n.status != 'trashed'`;
const sql = `
SELECT n.* FROM notes n
JOIN notes_fts f ON n.id = f.note_id
WHERE notes_fts MATCH ? ${statusClause}
ORDER BY rank
LIMIT ?
`;
const args: unknown[] = opts.status ? [sanitized, opts.status, limit] : [sanitized, limit];
const rows = this.db.prepare(sql).all(...args) as Record<string, unknown>[];
return rows.map((r) => this.hydrate(r));
}
/**
* v0.2.11 Cut D — 회고 view aggregate. period 별 KST 자정 cutoff 이후 노트
* (status != 'trashed') 의 totalCount / recentNotes(50) / tagCounts(DESC) /
* dueProgress(passed/pending KST today 기준).
*/
reviewAggregate(period: ReviewPeriod, now: Date = new Date()): {
totalCount: number;
recentNotes: Note[];
tagCounts: Array<{ tag: string; count: number }>;
dueProgress: { total: number; passed: number; pending: number };
} {
const cutoff = computeCutoff(period, now);
const todayIso = kstTodayIso(now);
const totalCount = (this.db
.prepare(`SELECT COUNT(*) AS c FROM notes WHERE created_at >= ? AND status != 'trashed'`)
.get(cutoff) as { c: number }).c;
const recentRows = this.db
.prepare(
`SELECT * FROM notes
WHERE created_at >= ? AND status != 'trashed'
ORDER BY created_at DESC, id DESC LIMIT 50`
)
.all(cutoff) as Record<string, unknown>[];
const recentNotes = recentRows.map((r) => this.hydrate(r));
const tagCounts = this.db
.prepare(
`SELECT t.name AS tag, COUNT(*) AS count
FROM note_tags nt
JOIN notes n ON n.id = nt.note_id
JOIN tags t ON t.id = nt.tag_id
WHERE n.created_at >= ? AND n.status != 'trashed'
GROUP BY t.id
ORDER BY count DESC, t.name ASC`
)
.all(cutoff) as Array<{ tag: string; count: number }>;
const dueRow = this.db
.prepare(
`SELECT
COUNT(*) AS total,
SUM(CASE WHEN due_date < ? THEN 1 ELSE 0 END) AS passed,
SUM(CASE WHEN due_date >= ? THEN 1 ELSE 0 END) AS pending
FROM notes
WHERE created_at >= ?
AND status != 'trashed'
AND due_date IS NOT NULL`
)
.get(todayIso, todayIso, cutoff) as { total: number; passed: number | null; pending: number | null };
const dueProgress = {
total: dueRow.total,
passed: dueRow.passed ?? 0,
pending: dueRow.pending ?? 0
};
return { totalCount, recentNotes, tagCounts, dueProgress };
}
/**
* 휴지통에서 active 로 복원. setStatus('active') 로 status + deleted_at 동기화 +
* v0.2.6 #10 round 1 fix 보존 (ai_status='failed' / 'pending' 시 pending_jobs 재투입).
*/
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 before = this.db
.prepare(`SELECT ai_status FROM notes WHERE id = ?`)
.get(id) as { ai_status: string } | undefined;
// setStatus('active', null) — reason clear + deleted_at NULL + updated_at 갱신.
this.setStatus(id, 'active', null);
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);
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);
this.db
.prepare(
`INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)`
)
.run(id, now);
}
// done 노트는 재처리 안 함 (이미 결과 있음)
});
@@ -483,11 +768,22 @@ export class NoteRepository {
/**
* Import a note from an external source (F5 export tree).
* Conflict policy:
* Conflict policy (fork-on-id-collision):
* - id missing in DB → INSERT (status: 'inserted')
* - id present + raw_text identical → no-op (status: 'skipped')
* - id present + raw_text differs → INSERT under fresh uuidv7
* to preserve the raw_text-immutable invariant (status: 'forked')
* - id present + raw_text differs → INSERT under fresh uuidv7 so the same id
* never points at two different baselines (status: 'forked'). v0.2.10 Cut C
* relaxed the `raw_text 불변` policy → `raw_text 가변 + note_revisions 보존`,
* but per-id baseline distinction is still required for sync determinism.
*
* v0.2.10 Cut C — INSERT/fork 시 동일 transaction 안에서 note_revisions 에
* 'capture' 첫 revision INSERT (createdAt = edited_at). 미수행 시 first user
* edit 직후 import 시점 본문이 history 에서 사라지는 회귀 (final review 발견).
*
* v0.2.11 Cut D — INSERT/fork 시 tags 추가 후 rebuildFtsTagsForNote(finalId)
* 호출 — m007 trigger 가 빈 tags='' 로 FTS row 만들고, note_tags INSERT 만으로는
* notes_fts.tags 갱신 안 됨. 미수행 시 import 한 노트가 tag keyword 검색에서
* 매칭 안 되는 회귀 (final review 발견).
*
* deletedAt merge (v0.2.3 #4, spec §8.2): source/dest 중 IS NOT NULL 우선
* (삭제 보존). skip 케이스에서 source NN + dest NULL 일 때만 dest 갱신.
@@ -538,6 +834,12 @@ export class NoteRepository {
input.createdAt,
input.updatedAt
);
this.db
.prepare(
`INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by)
VALUES (?, ?, ?, 'capture')`
)
.run(finalId, input.rawText, input.createdAt);
if (input.tags.length > 0) {
const getOrInsertTag = this.db.prepare(
`INSERT INTO tags(name) VALUES(?) ON CONFLICT(name) DO UPDATE SET name=name RETURNING id`
@@ -553,6 +855,8 @@ export class NoteRepository {
if (t.source === 'ai') linkAi.run(finalId, row.id);
else linkUser.run(finalId, row.id);
}
// v0.2.11 Cut D — note_tags 변경 후 notes_fts.tags 동기화 (single write path).
this.rebuildFtsTagsForNote(finalId);
}
});
tx();
@@ -638,6 +942,23 @@ export class NoteRepository {
.run(nextRunAt, lastError.slice(0, 500), noteId);
}
/**
* v0.2.11 Cut D — note_tags 변경 후 notes_fts.tags 컬럼 (csv) 재구성.
* 단일 write path 패턴: tags 변경하는 모든 메서드가 같은 transaction 끝에서 호출.
*/
private rebuildFtsTagsForNote(noteId: string): void {
const row = this.db
.prepare(
`SELECT COALESCE(GROUP_CONCAT(t.name, ' '), '') AS csv
FROM note_tags nt JOIN tags t ON t.id = nt.tag_id
WHERE nt.note_id = ?`
)
.get(noteId) as { csv: string };
this.db
.prepare(`UPDATE notes_fts SET tags = ? WHERE note_id = ?`)
.run(row.csv, noteId);
}
private hydrate(row: Record<string, unknown>): Note {
const tags = this.db
.prepare(
@@ -656,7 +977,7 @@ export class NoteRepository {
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',
aiStatus: row.ai_status as AiStatus,
aiError: row.ai_error as string | null,
aiProvider: row.ai_provider as string | null,
aiGeneratedAt: row.ai_generated_at as string | null,
@@ -669,6 +990,9 @@ export class NoteRepository {
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,
status: ((row.status as NoteStatus | undefined) ?? 'active'),
statusChangedAt: (row.status_changed_at as string | null) ?? null,
moveReason: (row.move_reason as string | null) ?? null,
createdAt: row.created_at as string,
updatedAt: row.updated_at as string,
tags: tags as NoteTag[],

View File

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

View File

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

@@ -2,6 +2,14 @@ import type { NoteRepository } from '../repository/NoteRepository.js';
import type { MediaStore } from './MediaStore.js';
import type { Note } from '@shared/types';
/**
* v0.2.9 Cut B — CaptureService 가 ai_enabled 를 조회할 때만 의존하는 좁은 인터페이스.
* SettingsService 직접 의존을 피해 테스트 mock 이 단순해짐 (entire SettingsService 면 불필요).
*/
export interface AiEnabledSource {
isAiEnabled(): Promise<boolean>;
}
export interface TelemetryEmitter {
emit(input:
| { kind: 'capture'; payload: { noteId: string; rawTextLength: number; hasMedia: boolean } }
@@ -23,6 +31,9 @@ export interface CaptureDeps {
enqueue: (noteId: string) => Promise<void>;
celebrate: (noteId: string) => void;
telemetry?: TelemetryEmitter;
// v0.2.9 Cut B — settings.ai_enabled=false 면 새 노트는 ai_status='disabled' + enqueue skip.
// 미주입 시 기존 동작 (항상 enabled) 보존 — 기존 caller 무영향.
settings?: AiEnabledSource;
}
export interface SubmitInput {
@@ -44,7 +55,12 @@ export class CaptureService {
if (trimmed.length === 0 && input.images.length === 0) {
throw new Error('empty submission');
}
const { id } = this.repo.create({ rawText: input.text });
// v0.2.9 Cut B — settings 미주입 시 기본 enabled (backward compat).
const aiEnabled = this.deps.settings ? await this.deps.settings.isAiEnabled() : true;
const { id } = this.repo.create({
rawText: input.text,
aiStatus: aiEnabled ? 'pending' : 'disabled'
});
if (input.images.length > 0) {
const rows = [];
for (const img of input.images) {
@@ -70,7 +86,9 @@ export class CaptureService {
}
}).catch(() => {});
}
await this.deps.enqueue(id);
if (aiEnabled) {
await this.deps.enqueue(id);
}
this.deps.celebrate(id);
return { noteId: id };
}

View File

@@ -11,6 +11,9 @@ export interface HealthCheckerOptions {
onUpdate?: (status: HealthResult) => void;
onTelemetry?: (event: HealthTelemetryEvent) => void;
now?: () => number;
// v0.2.9 Cut B Task 14 — settings.ai_enabled=false 면 polling skip.
// 미설정 시 항상 enabled (backward-compat).
isAiEnabled?: () => Promise<boolean>;
}
const DEFAULT_INTERVAL_MS = 60_000;
@@ -72,8 +75,22 @@ export class HealthChecker {
start(): void {
if (this.timer !== null) return;
void this.runOnce();
this.timer = setInterval(() => { void this.runOnce(); }, this.intervalMs);
void this.tickIfEnabled();
this.timer = setInterval(() => { void this.tickIfEnabled(); }, this.intervalMs);
}
// v0.2.9 Cut B Task 14 — polling tick. settings.ai_enabled=false 면 skip.
// 수동 runOnce({ manual: true }) 는 이 게이트와 무관하게 항상 실행 (사용자 의도).
private async tickIfEnabled(): Promise<void> {
if (this.opts.isAiEnabled !== undefined) {
try {
const enabled = await this.opts.isAiEnabled();
if (!enabled) return;
} catch {
// settings 로드 실패 시 안전 측면 — polling 진행 (기존 동작 유지).
}
}
await this.runOnce();
}
stop(): void {

View File

@@ -8,7 +8,12 @@ const OllamaSettingsSchema = z.object({
}).strict();
const SettingsSchema = z.object({
ollama: OllamaSettingsSchema.optional()
ollama: OllamaSettingsSchema.optional(),
// v0.2.9 Cut B — AI-less mode toggle. 기존 settings 파일에 없으면 isAiEnabled() 가
// true 로 fallback (기본 enabled). zod default 는 file 이 존재 + 키 부재일 때만 적용 —
// load() 의 file-missing 분기에선 cache={} 라 isAiEnabled() 의 fallback 이 작동.
ai_enabled: z.boolean().optional(),
onboarding_completed: z.boolean().optional()
}).strict();
export type Settings = z.infer<typeof SettingsSchema>;
@@ -34,10 +39,49 @@ export class SettingsService {
return this.cache;
}
/**
* v0.2.9 Cut B Task 12 — settings:get IPC 핸들러용 read-only accessor.
* 첫 launch onboarding 분기에서 onboarding_completed 키 확인.
*/
async getAll(): Promise<Settings> {
return this.load();
}
async setOllama(value: OllamaSettings): Promise<void> {
const validated = OllamaSettingsSchema.parse(value);
const current = await this.load();
const next: Settings = { ...current, ollama: validated };
await this.persist(next);
}
/**
* v0.2.9 Cut B — AI-less mode 의 기본값은 enabled (true). 기존 settings 파일을
* 가진 사용자 (ai_enabled 키 부재) 도 무영향.
*/
async isAiEnabled(): Promise<boolean> {
const s = await this.load();
return s.ai_enabled ?? true;
}
async setAiEnabled(value: boolean): Promise<void> {
const current = await this.load();
const next: Settings = { ...current, ai_enabled: value };
await this.persist(next);
}
/** v0.2.9 Cut B — 첫 실행 onboarding completion 표지. 기본 false. */
async isOnboardingCompleted(): Promise<boolean> {
const s = await this.load();
return s.onboarding_completed ?? false;
}
async setOnboardingCompleted(value: boolean): Promise<void> {
const current = await this.load();
const next: Settings = { ...current, onboarding_completed: value };
await this.persist(next);
}
private async persist(next: Settings): Promise<void> {
await mkdir(dirname(this.filePath), { recursive: true });
const tmpPath = this.filePath + '.tmp';
await writeFile(tmpPath, JSON.stringify(next, null, 2), 'utf8');

View File

@@ -1,65 +1,38 @@
import electron from 'electron';
import type { Tray as TrayType, MenuItemConstructorOptions } from 'electron';
import { platform, release, EOL } from 'node:os';
const { app, Tray, Menu, nativeImage, dialog, shell, clipboard } = electron;
const { app, Tray, Menu, nativeImage } = electron;
function showAboutDialog(): void {
const version = app.getVersion();
const electronVersion = process.versions.electron ?? '?';
const nodeVersion = process.versions.node ?? '?';
const profileDir = app.getPath('userData');
// OS EOL 사용 — 클립보드 → Notepad 등에서 줄바꿈 정상.
const detail = [
`버전: ${version}`,
`Electron: ${electronVersion}`,
`Node: ${nodeVersion}`,
`OS: ${platform()} ${release()}`,
`데이터 위치: ${profileDir}`
].join(EOL);
void dialog.showMessageBox({
type: 'info',
title: 'Inkling 정보',
message: `Inkling ${version}`,
detail,
buttons: ['확인', '데이터 위치 열기', '정보 복사'],
defaultId: 0,
cancelId: 0
}).then((r) => {
if (r.response === 1) void shell.openPath(profileDir);
if (r.response === 2) clipboard.writeText(`Inkling ${version}${EOL}${detail}`);
}).catch(() => {
// dialog reject 는 일반 사용에서 발생 X — main process crash 등 예외 케이스 silent.
});
}
// v0.2.7 Phase 3 (Task 15) — showAboutDialog 제거됨.
// "Inkling 정보..." 트레이 항목이 사라짐 → 동일 기능은 설정 페이지의 InfoSection 이 담당.
// settings:get-app-info / settings:copy-app-info IPC 핸들러 (settingsApi.ts) 가 역할 인계.
/**
* v0.2.6 C2 — 트레이 메뉴 콜백 묶음. createTray 가 1-arg 로 받음.
* 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;
runBackup: () => void;
runExport: () => void;
runImport: () => void;
runSync: () => void;
runExportTelemetry: () => void;
runOllamaRecheck: () => void;
runRetryAllFailed: () => void;
runOpenOllamaSettings: () => void;
showSettings: () => void;
}
/**
* v0.2.6 C3 — 메뉴 라벨/활성화에 영향 주는 reactive state. refreshTray() 로 partial 갱신.
* v0.2.7 Phase 3 (Task 14) — TrayState 슬림. todayCount 만 잔류 (오늘 N번 잡아둠 라벨).
* ollamaOk / failedCount 메뉴 항목이 사라져 더 이상 필요 없음.
*/
export interface TrayState {
ollamaOk: boolean;
todayCount: number;
failedCount: number;
}
let tray: TrayType | null = null;
let _callbacks: TrayCallbacks | null = null;
let _state: TrayState = { ollamaOk: true, todayCount: 0, failedCount: 0 };
let _state: TrayState = { todayCount: 0 };
function buildMenu(): electron.Menu {
const items: MenuItemConstructorOptions[] = [];
@@ -73,51 +46,18 @@ function buildMenu(): electron.Menu {
items.push({ label: `오늘 ${_state.todayCount}번 잡아둠`, enabled: false });
items.push({ type: 'separator' });
}
items.push({ label: '보관한 메모 보기', click: cb.showInbox });
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: cb.runBackup });
items.push({ label: '내보내기...', click: cb.runExport });
items.push({ label: '백업에서 복원...', click: cb.runImport });
items.push({ label: '지금 동기화', click: cb.runSync });
items.push({ label: '사용 로그 내보내기...', click: cb.runExportTelemetry });
items.push({
label: 'Ollama 재확인',
enabled: !_state.ollamaOk,
click: cb.runOllamaRecheck
});
items.push({
label: `지금 AI 처리 (실패 ${_state.failedCount}건)`,
enabled: _state.failedCount > 0,
click: cb.runRetryAllFailed
});
items.push({ label: 'Ollama 설정...', click: cb.runOpenOllamaSettings });
if (app.isPackaged) {
// v0.2.6 #45 — args 명시 전달로 openAtLogin 비교 정확도. setLoginItemSettings 가
// args 와 함께 LoginItem 등록하므로 read 시도 같은 args 로 비교해야 매치됨.
const { openAtLogin } = app.getLoginItemSettings({ args: ['--hidden'] });
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: 'Inkling 정보...', click: showAboutDialog });
items.push({ label: '종료', click: () => { app.isQuitting = true; app.quit(); } });
return Menu.buildFromTemplate(items);
}
/**
* v0.2.6 C2 — 1-arg createTray. 기존 10 positional 폐기.
* v0.2.7 Phase 3 — TrayCallbacks 3-필드로 슬림.
*/
export function createTray(callbacks: TrayCallbacks): TrayType {
_callbacks = callbacks;
@@ -131,13 +71,7 @@ export function createTray(callbacks: TrayCallbacks): TrayType {
/**
* v0.2.6 C3 — 통합 state 갱신. partial 으로 받아 _state merge + 메뉴 재빌드.
*
* Replaces: refreshTrayOllama(ok), refreshTrayFailedCount(count), 기존 refreshTray(todayCount).
*
* 호출 예:
* refreshTray({ todayCount: 5 });
* refreshTray({ ollamaOk: false });
* refreshTray({ failedCount: 2 });
* v0.2.7 Phase 3 — TrayState 가 todayCount 만 갖도록 슬림.
*/
export function refreshTray(state: Partial<TrayState>): void {
_state = { ..._state, ...state };

View File

@@ -47,11 +47,47 @@ const api: InklingApi = {
emitRecallSnoozed: (id: string) => ipcRenderer.invoke('inbox:emitRecallSnoozed', id),
loadOllamaSettings: () => ipcRenderer.invoke('inbox:loadOllamaSettings'),
saveOllamaSettings: (v: { endpoint: string; model: string }) => ipcRenderer.invoke('inbox:saveOllamaSettings', v),
onOpenOllamaSettings: (cb: () => void) => {
const handler = () => cb();
ipcRenderer.on('inbox:openOllamaSettings', handler);
return () => ipcRenderer.removeListener('inbox:openOllamaSettings', handler);
// 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),
// v0.2.9 Cut B Task 4 — status 별 list + counts.
listByStatus: (status, opts) => ipcRenderer.invoke('inbox:list-by-status', status, opts ?? {}),
countsByStatus: () => ipcRenderer.invoke('inbox:counts-by-status'),
// v0.2.9 Cut B Task 8 — 4분기 status 전이 + AI 자동 분류 추천.
setStatus: (id, status, reason) => ipcRenderer.invoke('inbox:set-status', id, status, reason),
classifyStatus: (id, reason) => ipcRenderer.invoke('ai:classify-status', id, reason),
// v0.2.9 Cut B Task 12 — settings read + AI/onboarding 토글 (첫 launch wizard 분기 포함).
getSettings: () => ipcRenderer.invoke('settings:get'),
setAiEnabled: (enabled: boolean) => ipcRenderer.invoke('settings:set-ai-enabled', enabled),
setOnboardingCompleted: (completed: boolean) => ipcRenderer.invoke('settings:set-onboarding-completed', completed),
// v0.2.9 Cut B Task 16 — disabled 메모 재투입 + count.
enqueueDisabled: () => ipcRenderer.invoke('inbox:enqueue-disabled'),
getDisabledCount: () => ipcRenderer.invoke('inbox:get-disabled-count'),
// v0.2.10 Cut C — raw_text 가변 + revision 보존.
updateRawText: (noteId: string, newText: string) => ipcRenderer.invoke('inbox:update-raw-text', noteId, newText),
listRevisions: (noteId: string) => ipcRenderer.invoke('inbox:list-revisions', noteId),
restoreRevision: (noteId: string, revId: number) => ipcRenderer.invoke('inbox:restore-revision', noteId, revId),
// v0.2.11 Cut D — search + 회고 aggregate.
search: (query, opts) => ipcRenderer.invoke('inbox:search', query, opts ?? {}),
reviewAggregate: (period) => ipcRenderer.invoke('inbox:review-aggregate', period),
}
};

View File

@@ -12,7 +12,11 @@ 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 { OllamaSettingsModal } from './components/OllamaSettingsModal.js';
import { SettingsPage } from './components/SettingsPage.js';
import { OnboardingWizard } from './components/OnboardingWizard.js';
import { SearchBox } from './components/SearchBox.js';
import { ReviewView } from './components/ReviewView.js';
import type { InboxView } from './store.js';
export function App(): React.ReactElement {
const {
@@ -21,8 +25,28 @@ export function App(): React.ReactElement {
continuity, tagFilter, setTagFilter,
toggleShowTrash, restoreNote, permanentDeleteNote, emptyTrash
} = useInbox();
const showSettings = useInbox((s) => s.showSettings);
const setShowSettings = useInbox((s) => s.setShowSettings);
// v0.2.9 Cut B Task 5 — 4탭 (Inbox/완료/보관/휴지통).
const view = useInbox((s) => s.view);
const counts = useInbox((s) => s.counts);
const setView = useInbox((s) => s.setView);
const searchResults = useInbox((s) => s.searchResults);
const [recoveryDismissed, setRecoveryDismissed] = useState(isRecoveryDismissedToday());
const [ollamaSettingsOpen, setOllamaSettingsOpen] = useState(false);
// v0.2.9 Cut B Task 12 — 첫 launch onboarding 분기. null = 로딩, true = 표시, false = 미표시.
const [showOnboarding, setShowOnboarding] = useState<boolean | null>(null);
useEffect(() => {
void (async () => {
try {
const settings = await inboxApi.getSettings();
setShowOnboarding(!settings.onboarding_completed);
} catch {
// 안전한 fallback — settings 읽기 실패 시 wizard 미표시 (기존 사용자 무영향).
setShowOnboarding(false);
}
})();
}, []);
useEffect(() => {
void loadInitial();
@@ -33,16 +57,29 @@ export function App(): React.ReactElement {
const unsubOllama = inboxApi.onOllamaStatus((status) => {
useInbox.setState({ ollamaStatus: status });
});
const unsubOllamaSettings = inboxApi.onOpenOllamaSettings(() => setOllamaSettingsOpen(true));
const unsubNav = inboxApi.onNavigate((view) => {
// v0.2.9 Cut B Task 4 — setView 가 mirror state (showTrash/showSettings) 동기 갱신.
useInbox.getState().setView(view);
});
const onFocus = () => { void refreshMeta(); };
window.addEventListener('focus', onFocus);
return () => { unsubNote(); unsubOllama(); unsubOllamaSettings(); 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 (showOnboarding === null) return <></>;
if (showOnboarding) return <OnboardingWizard onClose={() => setShowOnboarding(false)} />;
if (view === 'review-daily') return <ReviewView period="daily" />;
if (view === 'review-weekly') return <ReviewView period="weekly" />;
if (view === 'review-monthly') return <ReviewView period="monthly" />;
if (showSettings) return <SettingsPage />;
const showRecovery = continuity.showRecoveryToast && !recoveryDismissed;
const filtered = selectFilteredNotes({ notes, tagFilter });
const displayed = searchResults !== null ? searchResults : filtered;
const tabBtnStyle = (active: boolean): React.CSSProperties => ({
background: active ? '#0a4b80' : 'transparent',
@@ -59,30 +96,62 @@ export function App(): React.ReactElement {
<div className="header">
<h1 style={{ fontSize: 18, margin: 0 }}>Inkling</h1>
<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>
{(
[
{ key: 'inbox', label: 'Inbox', count: counts.active },
{ key: 'completed', label: '완료', count: counts.completed },
{ key: 'archived', label: '보관', count: counts.archived },
{ key: 'trash', label: '휴지통', count: counts.trashed }
] as const
).map((t) => (
<button
key={t.key}
onClick={() => setView(t.key)}
aria-pressed={view === t.key}
style={tabBtnStyle(view === t.key)}
>
{t.label}({t.count})
</button>
))}
</div>
<select
aria-label="회고 기간"
value={view.startsWith('review-') ? view.replace('review-', '') : ''}
onChange={(e) => {
const v = e.target.value;
if (v === 'daily' || v === 'weekly' || v === 'monthly') setView(`review-${v}` as InboxView);
}}
style={{ marginLeft: 8, fontSize: 12, padding: '4px 6px', border: '1px solid #0a4b80', borderRadius: 4, color: '#0a4b80', background: 'transparent' }}
>
<option value="">📅 </option>
<option value="daily"></option>
<option value="weekly"></option>
<option value="monthly"></option>
</select>
<SearchBox />
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 2, marginLeft: 'auto' }}>
<ContinuityBadge />
<IdentityCounter />
</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">
{!showTrash && (
<>
<OllamaBanner onOpenSettings={() => setOllamaSettingsOpen(true)} />
<OllamaBanner onOpenSettings={() => setShowSettings(true)} />
<RecoveryToast
show={showRecovery}
onDismiss={() => { markRecoveryDismissed(); setRecoveryDismissed(true); }}
@@ -110,12 +179,14 @@ export function App(): React.ReactElement {
)}
{loading && notes.length === 0 ? (
<div className="empty"> </div>
) : searchResults !== null && displayed.length === 0 ? (
<div className="empty"> .</div>
) : notes.length === 0 ? (
<div className="empty">릿 . <code>Ctrl+Shift+J</code></div>
) : filtered.length === 0 ? (
) : displayed.length === 0 ? (
<div className="empty"> .</div>
) : (
filtered.map((n) => (
displayed.map((n) => (
<NoteCard
key={n.id} note={n} mode="inbox"
onDeleted={() => removeNote(n.id)}
@@ -157,10 +228,6 @@ export function App(): React.ReactElement {
)}
</main>
<TagUndoToast />
<OllamaSettingsModal
open={ollamaSettingsOpen}
onClose={() => setOllamaSettingsOpen(false)}
/>
</>
);
}

View File

@@ -3,8 +3,11 @@ import { useInbox } from '../store.js';
import { Banner } from './Banner.js';
export function FailedBanner(): React.ReactElement | null {
const aiEnabled = useInbox((s) => s.ai_enabled);
const count = useInbox((s) => s.failedCount);
const retryAllFailed = useInbox((s) => s.retryAllFailed);
// v0.2.9 Cut B Task 14 — AI-less mode 에서는 banner 자체 비활성.
if (!aiEnabled) return null;
if (count === 0) return null;
return (
<Banner severity="error">

View File

@@ -0,0 +1,150 @@
import React, { useState } from 'react';
import { inboxApi } from '../api.js';
import type { NoteStatus } from '@shared/types';
interface Props {
noteId: string;
rawText: string;
summary: string;
onClose: () => void;
onMoved: (status: NoteStatus, reason: string | null) => void;
}
/**
* v0.2.9 Cut B Task 7 — 메모 이동 Modal.
*
* 사유 입력 + 3 status 버튼 (완료/보관/휴지통) + AI 자동 분류.
*/
export function MoveStatusModal({
noteId,
onClose,
onMoved
}: Props): React.ReactElement {
const [reason, setReason] = useState('');
const [recommendation, setRecommendation] = useState<{
status: NoteStatus;
rationale: string;
} | null>(null);
const [classifying, setClassifying] = useState(false);
async function move(status: NoteStatus): Promise<void> {
const trimmedReason = reason.trim() === '' ? null : reason.trim();
await inboxApi.setStatus(noteId, status, trimmedReason);
onMoved(status, trimmedReason);
}
async function classify(): Promise<void> {
setClassifying(true);
setRecommendation(null);
try {
const r = await inboxApi.classifyStatus(noteId, reason);
setRecommendation({ status: r.recommended, rationale: r.rationale });
} finally {
setClassifying(false);
}
}
return (
<div
role="dialog"
aria-label="이동"
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0,0,0,0.4)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 100
}}
>
<div
style={{
background: '#fff',
padding: 16,
borderRadius: 8,
minWidth: 400,
maxWidth: 520
}}
>
<h2 style={{ fontSize: 16, margin: '0 0 12px' }}> </h2>
<textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="이동 사유 (선택사항)"
rows={2}
style={{ width: '100%', padding: 6, fontSize: 13, boxSizing: 'border-box' }}
/>
<div
style={{
display: 'flex',
gap: 8,
marginTop: 8,
flexWrap: 'wrap',
alignItems: 'center'
}}
>
<button onClick={() => void classify()} disabled={classifying}>
{classifying ? '분류 중...' : 'AI 자동 분류'}
</button>
<button onClick={() => void move('completed')}></button>
<button onClick={() => void move('archived')}></button>
<button onClick={() => void move('trashed')}></button>
<button onClick={onClose} style={{ marginLeft: 'auto' }}>
</button>
</div>
{recommendation !== null && (
<div
style={{
marginTop: 12,
padding: 8,
background: '#f0f8ff',
borderRadius: 4,
fontSize: 12
}}
>
<div>
AI : <strong>{statusLabel(recommendation.status)}</strong>
</div>
<div style={{ marginTop: 4 }}>: {recommendation.rationale}</div>
<div style={{ marginTop: 8 }}>
<button onClick={() => void move(recommendation.status)}>
({statusLabel(recommendation.status)})
</button>
</div>
</div>
)}
</div>
</div>
);
}
export function statusLabel(s: NoteStatus): string {
switch (s) {
case 'active':
return '활성';
case 'completed':
return '완료';
case 'archived':
return '보관';
case 'trashed':
return '휴지통';
}
}
/**
* status 의 한글 라벨에 적절한 조사를 붙여 반환. 받침 있으면 "으로", 없으면 "로".
* 예: '완료로' / '보관으로' / '휴지통으로' / '활성으로'.
*/
export function statusLabelWithParticle(s: NoteStatus): string {
const label = statusLabel(s);
const last = label.charCodeAt(label.length - 1);
// 한글 syllable block 외 → "로" default
if (last < 0xAC00 || last > 0xD7A3) return `${label}`;
const jongseong = (last - 0xAC00) % 28;
return jongseong === 0 ? `${label}` : `${label}으로`;
}

View File

@@ -1,10 +1,12 @@
import React, { useState } from 'react';
import type { Note } from '@shared/types';
import type { Note, NoteStatus } from '@shared/types';
import { inboxApi } from '../api.js';
import { useInbox } from '../store.js';
import { EditableField } from './EditableField.js';
import { IntentBanner } from './IntentBanner.js';
import { pushTagUndo } from './TagUndoToast.js';
import { MoveStatusModal, statusLabelWithParticle } from './MoveStatusModal.js';
import { RevisionHistoryModal } from './RevisionHistoryModal.js';
interface Props {
note: Note;
@@ -109,19 +111,26 @@ function DueDateBadge({
export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore, onPermanentDelete }: Props): React.ReactElement {
const isTrash = mode === 'trash';
// v0.2.9 Cut B Task 13 — ai_status='disabled' 노트는 raw_text 가 1차 정보. 원문 펼침 default 켬.
const [rawOpen, setRawOpen] = useState(note.aiStatus !== 'done');
const [local, setLocal] = useState(note);
const isAiDisabled = local.aiStatus === 'disabled';
const fallbackTitle = local.rawText.split('\n')[0]?.slice(0, 60) || '(빈 메모)';
// v0.2.9 Cut B Task 6 — 이동 메뉴 dropdown + MoveStatusModal target.
const [moveTarget, setMoveTarget] = useState<NoteStatus | null>(null);
const [menuOpen, setMenuOpen] = useState(false);
const [editingRaw, setEditingRaw] = useState(false);
const [draftRaw, setDraftRaw] = useState('');
const [showRevisions, setShowRevisions] = useState(false);
const possibleTargets: NoteStatus[] = (
['active', 'completed', 'archived', 'trashed'] as NoteStatus[]
).filter((s) => s !== local.status);
React.useEffect(() => { setLocal(note); }, [note]);
const formatted = new Date(note.createdAt).toLocaleString('ko-KR');
async function handleDelete() {
if (!window.confirm('이 기억을 버릴까요? 되돌릴 수 없습니다.')) return;
await inboxApi.deleteNote(note.id);
onDeleted?.();
}
async function saveTitle(next: string) {
await inboxApi.updateAiFields(note.id, { title: next });
const updated = { ...local, aiTitle: next, titleEditedByUser: true };
@@ -145,6 +154,17 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
setLocal(updated); onUpdated(updated);
}
async function saveRaw() {
const next = draftRaw;
if (next.trim().length === 0) return;
const r = await inboxApi.updateRawText(note.id, next);
if (!r.ok) return;
const updated = { ...local, rawText: next, updatedAt: new Date().toISOString() };
setLocal(updated);
onUpdated(updated);
setEditingRaw(false);
}
async function removeTag(tagName: string) {
const removed = local.tags.find((t) => t.name === tagName);
const nextTagNames = local.tags.filter((t) => t.name !== tagName).map((t) => t.name);
@@ -209,6 +229,13 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
</div>
)}
{/* v0.2.9 Cut B Task 13 — ai_status='disabled': raw_text 첫 줄 fallback title.
summary/tags 는 hide. 원문은 아래 "원문 보기" 영역에서 항상 표시. */}
{isAiDisabled && (
<div style={{ marginTop: 4 }}>
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 600 }}>{fallbackTitle}</h3>
</div>
)}
{local.aiStatus === 'done' && (
<>
{isTrash ? (
@@ -332,9 +359,24 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
)}
{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>
)}
@@ -344,40 +386,156 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
{rawOpen ? '▾ 원문 접기' : '▸ 원문 보기'}
</button>
{rawOpen && (
<pre style={{ marginTop: 6, whiteSpace: 'pre-wrap', fontSize: 12, color: '#555', background: '#fafafa', padding: 8, borderRadius: 4 }}>
{local.rawText}
</pre>
<div style={{ marginTop: 6 }}>
{editingRaw ? (
<div>
<textarea
aria-label="원문 편집"
value={draftRaw}
onChange={(e) => setDraftRaw(e.target.value)}
style={{ width: '100%', minHeight: 80, fontSize: 12, fontFamily: 'inherit', padding: 8, border: '1px solid #ddd', borderRadius: 4, boxSizing: 'border-box' }}
/>
<div style={{ marginTop: 4, display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
<button onClick={() => setEditingRaw(false)} style={{ background: 'none', border: '1px solid #ccc', color: '#444', cursor: 'pointer', fontSize: 12, padding: '3px 10px', borderRadius: 4 }}></button>
<button onClick={() => { void saveRaw(); }} style={{ background: '#0a4b80', border: 'none', color: '#fff', cursor: 'pointer', fontSize: 12, padding: '3px 10px', borderRadius: 4 }}></button>
</div>
</div>
) : (
<>
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', fontSize: 12, color: '#555', background: '#fafafa', padding: 8, borderRadius: 4 }}>
{local.rawText}
</pre>
<div style={{ marginTop: 4, display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
<button onClick={() => setShowRevisions(true)} style={{ background: 'none', border: 'none', color: '#0a4b80', cursor: 'pointer', fontSize: 12, padding: 0 }}></button>
<button onClick={() => { setDraftRaw(local.rawText); setEditingRaw(true); }} style={{ background: 'none', border: '1px solid #ccc', color: '#444', cursor: 'pointer', fontSize: 12, padding: '3px 10px', borderRadius: 4 }}></button>
</div>
</>
)}
</div>
)}
</div>
<div style={{ marginTop: 10, textAlign: 'right' }}>
{isTrash ? (
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<div
style={{
display: 'flex',
gap: 8,
justifyContent: 'flex-end',
alignItems: 'center'
}}
>
{/* v0.2.9 Cut B Task 6 — 모든 view 공통 "이동 ▾" dropdown.
현재 status 와 다른 3개 목적지만 표시. */}
<div style={{ position: 'relative' }}>
<button
onClick={onRestore}
onClick={() => setMenuOpen((o) => !o)}
aria-label="이동"
style={{
background: 'none', border: '1px solid #0a4b80', color: '#0a4b80',
cursor: 'pointer', fontSize: 12, padding: '4px 10px', borderRadius: 4
background: 'none',
border: '1px solid #ccc',
color: '#444',
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>
{menuOpen && (
<div
style={{
position: 'absolute',
right: 0,
top: '100%',
marginTop: 2,
background: '#fff',
border: '1px solid #ccc',
borderRadius: 4,
padding: 4,
zIndex: 10,
minWidth: 140,
boxShadow: '0 2px 6px rgba(0,0,0,0.08)'
}}
>
{possibleTargets.map((t) => (
<button
key={t}
onClick={() => {
setMoveTarget(t);
setMenuOpen(false);
}}
style={{
display: 'block',
width: '100%',
textAlign: 'left',
background: 'none',
border: 'none',
padding: '6px 8px',
fontSize: 12,
cursor: 'pointer'
}}
>
{statusLabelWithParticle(t)}
</button>
))}
</div>
)}
</div>
) : (
<button onClick={() => void handleDelete()} style={{ background: 'none', border: 'none', color: '#c93030', cursor: 'pointer', fontSize: 12 }}>
🗑
</button>
)}
{/* trash mode 만 영구 삭제 + 복구 보존 (휴지통 단독 액션). */}
{isTrash && (
<>
<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>
</div>
{moveTarget !== null && (
<MoveStatusModal
noteId={local.id}
rawText={local.rawText}
summary={local.aiSummary ?? ''}
onClose={() => setMoveTarget(null)}
onMoved={(newStatus, reason) => {
const updated = { ...local, status: newStatus, moveReason: reason };
setLocal(updated);
onUpdated(updated);
// inbox/trash mode 의 list 가 status 별로 필터되므로 onDeleted (제거) 도 호출.
if (newStatus !== local.status) onDeleted?.();
setMoveTarget(null);
}}
/>
)}
{showRevisions && (
<RevisionHistoryModal
noteId={local.id}
onClose={() => setShowRevisions(false)}
onRestored={(newRawText) => {
const updated = { ...local, rawText: newRawText, updatedAt: new Date().toISOString() };
setLocal(updated);
onUpdated(updated);
}}
/>
)}
</div>
);
}

View File

@@ -7,8 +7,11 @@ interface OllamaBannerProps {
}
export function OllamaBanner({ onOpenSettings }: OllamaBannerProps = {}): React.ReactElement | null {
const aiEnabled = useInbox((s) => s.ai_enabled);
const status = useInbox((s) => s.ollamaStatus);
const recheckOllama = useInbox((s) => s.recheckOllama);
// v0.2.9 Cut B Task 14 — AI-less mode 에서는 banner 자체 비활성.
if (!aiEnabled) return null;
if (status.ok) return null;
const isMissing = status.reason?.includes('not installed');
const message = isMissing

View File

@@ -1,140 +0,0 @@
import React, { useEffect, useState } from 'react';
import { z } from 'zod';
import { inboxApi } from '../api.js';
import { DEFAULT_OLLAMA_ENDPOINT, DEFAULT_OLLAMA_MODEL } from '../../../shared/constants.js';
const EndpointSchema = z.string().url();
interface Props {
open: boolean;
onClose: () => void;
}
export function OllamaSettingsModal({ open, onClose }: Props): React.ReactElement | null {
const [endpoint, setEndpoint] = useState(DEFAULT_OLLAMA_ENDPOINT);
const [model, setModel] = useState(DEFAULT_OLLAMA_MODEL);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
// 마운트/open 시 현재 설정 fetch
useEffect(() => {
if (!open) return;
void inboxApi.loadOllamaSettings().then((s) => {
if (s) {
setEndpoint(s.endpoint);
setModel(s.model);
}
setError(null);
});
}, [open]);
if (!open) return null;
async function handleSave() {
if (saving) return; // m4 fix: synchronous double-click 가드
setSaving(true);
setError(null);
try {
// v0.2.6 #42 — client-side URL validation, server-side healthCheck 전에 명확한 메시지
const parseResult = EndpointSchema.safeParse(endpoint);
if (!parseResult.success) {
setError('유효한 URL 형식이 아닙니다 (예: http://localhost:11434)');
return;
}
if (model.trim().length === 0) {
setError('모델명을 입력하세요');
return;
}
const r = await inboxApi.saveOllamaSettings({ endpoint, model });
if (r.ok) {
onClose();
} else {
setError(r.reason);
}
} catch (e) {
setError(String(e));
} finally {
setSaving(false);
}
}
return (
<div
onKeyDown={(e) => {
if (e.key === 'Escape' && !saving) onClose();
if (e.key === 'Enter' && !saving) void handleSave();
}}
tabIndex={-1}
style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)',
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000
}}
>
<div style={{
background: '#fff', borderRadius: 8, padding: 20, minWidth: 400, maxWidth: 500,
boxShadow: '0 4px 16px rgba(0,0,0,0.2)'
}}>
<h2 style={{ margin: '0 0 12px 0', fontSize: 16 }}>Ollama </h2>
<div style={{ marginBottom: 12 }}>
<label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>
Endpoint URL
</label>
<input
type="text"
value={endpoint}
onChange={(e) => setEndpoint(e.target.value)}
placeholder="http://localhost:11434"
autoFocus
style={{ width: '100%', padding: '6px 8px', fontSize: 13, border: '1px solid #ccc', borderRadius: 4 }}
disabled={saving}
/>
</div>
<div style={{ marginBottom: 12 }}>
<label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>
Model
</label>
<input
type="text"
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder="gemma4:e4b"
style={{ width: '100%', padding: '6px 8px', fontSize: 13, border: '1px solid #ccc', borderRadius: 4 }}
disabled={saving}
/>
</div>
{error && (
<div style={{
background: '#fce4e4', color: '#a33', padding: '6px 10px', borderRadius: 4,
fontSize: 12, marginBottom: 12
}}>
: {error}
</div>
)}
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button
onClick={onClose}
disabled={saving}
style={{
background: 'transparent', color: '#666',
border: '1px solid #ccc', borderRadius: 4,
padding: '6px 14px', fontSize: 12, cursor: saving ? 'not-allowed' : 'pointer'
}}
>
</button>
<button
onClick={() => void handleSave()}
disabled={saving}
style={{
background: saving ? '#999' : '#0a4b80', color: '#fff',
border: 'none', borderRadius: 4,
padding: '6px 14px', fontSize: 12, cursor: saving ? 'not-allowed' : 'pointer'
}}
>
{saving ? '검증 중...' : '저장'}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,42 @@
import React from 'react';
import { inboxApi } from '../api.js';
/**
* v0.2.9 Cut B Task 11 — 첫 launch onboarding 위저드.
*
* 3 옵션 (AI 사용 / 원문만 / 나중에) 중 하나를 선택. AI 옵션 (true/false) 은
* setAiEnabled 로 settings 에 저장, 모든 옵션은 setOnboardingCompleted(true) 로
* 두 번째 launch 부터 미노출. "나중에" 는 ai_enabled 기본값 (true) 유지 — 사용자
* 가 SettingsPage 에서 추후 선택 가능.
*/
export function OnboardingWizard({ onClose }: { onClose: () => void }): React.ReactElement {
async function choose(aiEnabled: boolean | null): Promise<void> {
if (aiEnabled !== null) await inboxApi.setAiEnabled(aiEnabled);
await inboxApi.setOnboardingCompleted(true);
onClose();
}
return (
<div role="dialog" aria-label="시작 안내" style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000
}}>
<div style={{ background: '#fff', padding: 24, borderRadius: 8, maxWidth: 520 }}>
<h2 style={{ margin: '0 0 12px' }}>Inkling </h2>
<p style={{ fontSize: 14, lineHeight: 1.6, marginBottom: 12 }}>
Inkling LLM (Ollama) .
Ollama (gemma3, gemma2 ) pull .
</p>
<p style={{ fontSize: 13, marginBottom: 16 }}>
:&nbsp;
<a href="https://ollama.com/download" target="_blank" rel="noopener noreferrer">ollama.com/download</a>
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<button onClick={() => choose(true)}>AI (Ollama )</button>
<button onClick={() => choose(false)}> (AI )</button>
<button onClick={() => choose(null)} style={{ marginTop: 4 }}> </button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,56 @@
import React from 'react';
import { useInbox } from '../store.js';
import { NoteCard } from './NoteCard.js';
interface Props {
period: 'daily' | 'weekly' | 'monthly';
}
const periodLabel: Record<Props['period'], string> = {
daily: '일간',
weekly: '주간',
monthly: '월간'
};
export function ReviewView({ period }: Props): React.ReactElement {
const reviewData = useInbox((s) => s.reviewData);
if (!reviewData) {
return <div style={{ padding: 16, fontSize: 13, color: '#666' }}> </div>;
}
const max = reviewData.tagCounts[0]?.count ?? 1;
return (
<div style={{ padding: 16 }}>
<h2 style={{ fontSize: 18, margin: 0 }}>{periodLabel[period]} </h2>
<div style={{ marginTop: 8, fontSize: 13, color: '#444' }}>
{reviewData.totalCount}
</div>
<section style={{ marginTop: 16 }}>
<h3 style={{ fontSize: 14, marginBottom: 4 }}> </h3>
{reviewData.tagCounts.length === 0 && (
<div style={{ fontSize: 12, color: '#888' }}> </div>
)}
{reviewData.tagCounts.slice(0, 10).map((t) => (
<div key={t.tag} style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 2 }}>
<span style={{ fontSize: 12, width: 80 }}>{t.tag}</span>
<div style={{ flex: 1, background: '#eee', height: 8, borderRadius: 2 }}>
<div style={{ width: `${(t.count / max) * 100}%`, background: '#4ec5b8', height: 8, borderRadius: 2 }} />
</div>
<span style={{ fontSize: 12, color: '#666', width: 30, textAlign: 'right' }}>{t.count}</span>
</div>
))}
</section>
<section style={{ marginTop: 16 }}>
<h3 style={{ fontSize: 14, marginBottom: 4 }}> </h3>
<div style={{ fontSize: 13, color: '#444' }}>
{reviewData.dueProgress.passed} / {reviewData.dueProgress.total} · {reviewData.dueProgress.pending}
</div>
</section>
<section style={{ marginTop: 16 }}>
<h3 style={{ fontSize: 14, marginBottom: 4 }}> ({reviewData.recentNotes.length})</h3>
{reviewData.recentNotes.map((n) => (
<NoteCard key={n.id} note={n} mode="inbox" onUpdated={() => {}} />
))}
</section>
</div>
);
}

View File

@@ -0,0 +1,95 @@
import React, { useEffect, useState } from 'react';
import type { NoteRevision } from '@shared/types';
import { inboxApi } from '../api.js';
interface Props {
noteId: string;
onClose: () => void;
/** 회수 성공 후 부모 (NoteCard) 가 local rawText 를 갱신하도록 통지. */
onRestored: (newRawText: string) => void;
}
const overlayStyle: React.CSSProperties = {
position: 'fixed', top: 0, left: 0, width: '100vw', height: '100vh',
background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center',
justifyContent: 'center', zIndex: 100
};
const modalStyle: React.CSSProperties = {
background: '#fff', borderRadius: 8, padding: 20, width: 520,
maxHeight: '70vh', overflow: 'auto', boxShadow: '0 4px 16px rgba(0,0,0,0.2)'
};
const rowStyle: React.CSSProperties = {
border: '1px solid #eee', borderRadius: 6, padding: 10, marginTop: 8
};
function formatDate(iso: string): string {
return new Date(iso).toLocaleString('ko-KR');
}
function editedByLabel(by: 'user' | 'capture'): string {
return by === 'capture' ? '캡처' : '사용자';
}
export function RevisionHistoryModal({ noteId, onClose, onRestored }: Props): React.ReactElement {
const [revs, setRevs] = useState<NoteRevision[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const r = await inboxApi.listRevisions(noteId);
if (!cancelled) setRevs(r);
} catch (e) {
if (!cancelled) setError((e as Error).message);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, [noteId]);
async function onRestore(rev: NoteRevision) {
if (!window.confirm('이 버전으로 되돌릴까요? 현재 본문도 이력에 보존됩니다.')) return;
const r = await inboxApi.restoreRevision(noteId, rev.revId);
if (!r.ok) {
setError(r.reason ?? '복원 실패');
return;
}
onRestored(rev.rawText);
onClose();
}
return (
<div style={overlayStyle} onClick={onClose}>
<div style={modalStyle} onClick={(e) => e.stopPropagation()}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h3 style={{ margin: 0, fontSize: 16 }}> ({revs.length})</h3>
<button onClick={onClose} aria-label="닫기" style={{ background: 'none', border: 'none', fontSize: 18, cursor: 'pointer', color: '#888' }}>×</button>
</div>
{loading && <div style={{ marginTop: 10, fontSize: 12, color: '#888' }}> </div>}
{error !== null && <div style={{ marginTop: 10, fontSize: 12, color: '#c93030' }}>{error}</div>}
{!loading && revs.map((rev) => (
<div key={rev.revId} style={rowStyle}>
<div style={{ fontSize: 11, color: '#888', display: 'flex', justifyContent: 'space-between' }}>
<span>{formatDate(rev.editedAt)} · {editedByLabel(rev.editedBy)}</span>
<button
onClick={() => { void onRestore(rev); }}
aria-label="회수"
style={{ background: 'none', border: '1px solid #0a4b80', color: '#0a4b80', cursor: 'pointer', fontSize: 11, padding: '2px 8px', borderRadius: 3 }}
>
</button>
</div>
<pre style={{ margin: '6px 0 0 0', whiteSpace: 'pre-wrap', fontSize: 12, color: '#444', background: '#fafafa', padding: 6, borderRadius: 3 }}>
{rev.rawText}
</pre>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,34 @@
import React, { useEffect, useState } from 'react';
import { useInbox } from '../store.js';
export function SearchBox(): React.ReactElement {
const [draft, setDraft] = useState('');
useEffect(() => {
const handle = setTimeout(() => {
const trimmed = draft.trim();
if (trimmed.length === 0) useInbox.getState().clearSearch();
else void useInbox.getState().searchNotes(trimmed);
}, 200);
return () => clearTimeout(handle);
}, [draft]);
return (
<input
type="search"
role="searchbox"
placeholder="검색…"
value={draft}
onChange={(e) => setDraft(e.target.value)}
aria-label="노트 검색"
style={{
marginLeft: 12,
padding: '4px 8px',
fontSize: 12,
border: '1px solid #bbb',
borderRadius: 4,
width: 200
}}
/>
);
}

View File

@@ -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,197 @@
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);
// v0.2.9 Cut B Task 15-16: AI 자동 처리 토글 + disabled 메모 일괄 처리.
const [aiEnabled, setAiEnabledState] = useState<boolean | null>(null);
const [disabledCount, setDisabledCount] = useState(0);
useEffect(() => {
void (async () => {
const s = await inboxApi.loadOllamaSettings();
if (s) {
setEndpoint(s.endpoint);
setModel(s.model);
}
const settings = await inboxApi.getSettings();
const enabled = settings.ai_enabled ?? true;
setAiEnabledState(enabled);
if (enabled) {
const c = await inboxApi.getDisabledCount();
setDisabledCount(c);
}
})();
}, []);
async function onToggleAi(checked: boolean): Promise<void> {
await inboxApi.setAiEnabled(checked);
setAiEnabledState(checked);
if (checked) {
const c = await inboxApi.getDisabledCount();
setDisabledCount(c);
} else {
setDisabledCount(0);
}
}
async function onProcessDisabled(): Promise<void> {
await inboxApi.enqueueDisabled();
setDisabledCount(0);
}
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>
{/* v0.2.9 Cut B Task 15 — AI 자동 처리 토글 (가장 위, 스위치 의미가 가장 큰 결정) */}
{aiEnabled !== null && (
<label style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 12, fontSize: 13 }}>
<input
type="checkbox"
checked={aiEnabled}
onChange={(e) => void onToggleAi(e.target.checked)}
/>
AI
</label>
)}
{aiEnabled === false && (
<p style={{ fontSize: 12, color: '#666', marginBottom: 12 }}>
. // .<br />
<a href="https://ollama.com/download" target="_blank" rel="noopener noreferrer">
Ollama
</a>
</p>
)}
{/* v0.2.9 Cut B Task 16 — ON 전환 후 disabled 메모 일괄 처리 prompt */}
{aiEnabled === true && disabledCount > 0 && (
<div style={{ padding: 8, background: '#fffbe5', borderRadius: 4, marginBottom: 12, fontSize: 13 }}>
{disabledCount} .
<button
onClick={() => void onProcessDisabled()}
style={{
marginLeft: 8,
background: '#0a4b80',
color: '#fff',
border: 'none',
borderRadius: 4,
padding: '4px 10px',
fontSize: 12,
cursor: 'pointer'
}}
>
</button>
</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,15 +1,32 @@
import { create } from 'zustand';
import type { Note, WeeklyContinuity } from '@shared/types';
import type { Note, ReviewAggregate, WeeklyContinuity } from '@shared/types';
import { inboxApi } from './api.js';
import { nextKstMidnightMs } from '@shared/util/kstDate.js';
export { selectFilteredNotes } from './selectFilteredNotes.js';
// v0.2.9 Cut B Task 4 — 4탭 view enum + settings.
// 'inbox' = active, 'completed'/'archived' = NoteStatus 그대로, 'trash' = trashed (mirror), 'settings' = SettingsPage.
export type InboxView =
| 'inbox' | 'completed' | 'archived' | 'trash' | 'settings'
| 'review-daily' | 'review-weekly' | 'review-monthly';
export interface InboxCounts {
active: number;
completed: number;
archived: number;
trashed: number;
}
interface InboxState {
notes: Note[];
trashNotes: Note[];
trashCount: number;
showTrash: boolean;
showSettings: boolean;
// v0.2.9 Cut B Task 4 — view enum + counts. showTrash/showSettings 는 mirror 로 잠시 잔류.
view: InboxView;
counts: InboxCounts;
continuity: WeeklyContinuity;
pendingCount: number;
ollamaStatus: { ok: boolean; reason?: string };
@@ -21,11 +38,21 @@ interface InboxState {
failedCount: number;
recallCandidate: Note | null;
recallSnoozeUntilMs: number | null;
// v0.2.9 Cut B Task 14 — AI 비활성 모드에서는 OllamaBanner/FailedBanner render skip.
// 기본 true (기존 사용자 무영향). loadInitial / refreshMeta 가 settings 로드.
ai_enabled: boolean;
// v0.2.11 Cut D — FTS5 search + review aggregate state.
searchQuery: string;
searchResults: Note[] | null; // null = 검색 안 한 상태
reviewData: ReviewAggregate | null;
loadInitial: () => Promise<void>;
refreshMeta: () => Promise<void>;
upsertNote: (note: Note) => void;
removeNote: (id: string) => void;
setTagFilter: (tag: string | null) => void;
setShowSettings: (open: boolean) => void;
setView: (view: InboxView) => void;
loadByView: (view: 'completed' | 'archived' | 'trash') => Promise<void>;
toggleShowTrash: () => Promise<void>;
loadTrash: () => Promise<void>;
restoreNote: (id: string) => Promise<void>;
@@ -40,6 +67,11 @@ interface InboxState {
openRecall: (id: string) => Promise<void>;
dismissRecallNote: (id: string) => Promise<void>;
snoozeRecall: () => Promise<void>;
// v0.2.11 Cut D — search + review actions.
setSearchQuery: (q: string) => void;
searchNotes: (q: string) => Promise<void>;
clearSearch: () => void;
loadReview: (period: 'daily' | 'weekly' | 'monthly') => Promise<void>;
}
const emptyContinuity: WeeklyContinuity = {
@@ -52,6 +84,9 @@ export const useInbox = create<InboxState>((set, get) => ({
trashNotes: [],
trashCount: 0,
showTrash: false,
showSettings: false,
view: 'inbox',
counts: { active: 0, completed: 0, archived: 0, trashed: 0 },
continuity: emptyContinuity,
pendingCount: 0,
ollamaStatus: { ok: true },
@@ -63,9 +98,13 @@ export const useInbox = create<InboxState>((set, get) => ({
failedCount: 0,
recallCandidate: null,
recallSnoozeUntilMs: null,
ai_enabled: true,
searchQuery: '',
searchResults: null,
reviewData: null,
async loadInitial() {
set({ loading: true });
const [notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate] = await Promise.all([
const [notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, settings] = await Promise.all([
inboxApi.listNotes({ limit: 50 }),
inboxApi.getContinuity(),
inboxApi.getPendingCount(),
@@ -74,12 +113,14 @@ export const useInbox = create<InboxState>((set, get) => ({
inboxApi.getTrashCount(),
inboxApi.listExpired(),
inboxApi.getFailedCount(),
inboxApi.listRecallCandidate()
inboxApi.listRecallCandidate(),
inboxApi.countsByStatus(),
inboxApi.getSettings()
]);
set({ notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, loading: false });
set({ notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, ai_enabled: settings.ai_enabled ?? true, loading: false });
},
async refreshMeta() {
const [continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate] = await Promise.all([
const [continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, settings] = await Promise.all([
inboxApi.getContinuity(),
inboxApi.getPendingCount(),
inboxApi.getOllamaStatus(),
@@ -87,9 +128,11 @@ export const useInbox = create<InboxState>((set, get) => ({
inboxApi.getTrashCount(),
inboxApi.listExpired(),
inboxApi.getFailedCount(),
inboxApi.listRecallCandidate()
inboxApi.listRecallCandidate(),
inboxApi.countsByStatus(),
inboxApi.getSettings()
]);
set({ continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate });
set({ continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, ai_enabled: settings.ai_enabled ?? true });
},
upsertNote(note) {
// trashCount 는 server-authoritative. trashNotes 가 cache-loaded (showTrash=true) 일
@@ -134,6 +177,35 @@ export const useInbox = create<InboxState>((set, get) => ({
setTagFilter(tag) {
set({ tagFilter: tag });
},
setShowSettings(open) {
// backward-compat — setView 로 위임. mirror state (view, showTrash, showSettings) 동기 갱신.
if (open) get().setView('settings');
else get().setView('inbox');
},
setView(view) {
set({
view,
showTrash: view === 'trash',
showSettings: view === 'settings'
});
// settings/inbox 외 status view 면 해당 status fetch.
if (view === 'completed' || view === 'archived' || view === 'trash') {
void get().loadByView(view);
}
// v0.2.11 Cut D — review-* view 진입 시 aggregate 로드.
if (view === 'review-daily') void get().loadReview('daily');
if (view === 'review-weekly') void get().loadReview('weekly');
if (view === 'review-monthly') void get().loadReview('monthly');
},
async loadByView(view) {
const status = view === 'trash' ? 'trashed' : view;
const notes = await inboxApi.listByStatus(status, { limit: 200 });
if (view === 'trash') {
set({ trashNotes: notes, trashCount: notes.length });
} else {
set({ notes });
}
},
async toggleShowTrash() {
const next = !get().showTrash;
set({ showTrash: next });
@@ -215,5 +287,30 @@ export const useInbox = create<InboxState>((set, get) => ({
if (candidate) {
await inboxApi.emitRecallSnoozed(candidate.id);
}
},
// v0.2.11 Cut D — FTS5 search + review aggregate actions.
setSearchQuery(q) {
set({ searchQuery: q });
if (q.trim().length === 0) set({ searchResults: null });
},
async searchNotes(q) {
if (q.trim().length === 0) {
set({ searchResults: null });
return;
}
const view = get().view;
// 회고/설정 view 일 때는 status filter 무의미 → 그대로 전체 검색
const status = view === 'completed' || view === 'archived' || view === 'trash'
? (view === 'trash' ? 'trashed' : view)
: view === 'inbox' ? 'active' : undefined;
const r = await inboxApi.search(q, status ? { status } : {});
set({ searchResults: r });
},
clearSearch() {
set({ searchQuery: '', searchResults: null });
},
async loadReview(period) {
const data = await inboxApi.reviewAggregate(period);
set({ reviewData: data });
}
}));

View File

@@ -11,13 +11,35 @@ export interface NoteMedia {
bytes: number;
}
export type AiStatus = 'pending' | 'done' | 'failed';
export type AiStatus = 'pending' | 'done' | 'failed' | 'disabled';
// v0.2.9 Cut B — 노트 status 4분기 (사용자 액션). m004 마이그레이션 + setStatus.
export type NoteStatus = 'active' | 'completed' | 'archived' | 'trashed';
export interface NoteTag {
name: string;
source: 'ai' | 'user';
}
// v0.2.10 Cut C — note_revisions 테이블 row.
// 'capture' = 최초 캡처 시점, 'user' = 사용자가 raw_text 정정한 시점.
export interface NoteRevision {
revId: number;
noteId: string;
rawText: string;
editedAt: string;
editedBy: 'user' | 'capture';
}
// v0.2.11 Cut D — 회고 view aggregate.
export type ReviewPeriod = 'daily' | 'weekly' | 'monthly';
export interface ReviewAggregate {
totalCount: number;
recentNotes: Note[];
tagCounts: Array<{ tag: string; count: number }>;
dueProgress: { total: number; passed: number; pending: number };
}
export interface Note {
id: string;
rawText: string;
@@ -37,6 +59,10 @@ export interface Note {
deletedAt: string | null;
lastRecalledAt: string | null;
recallDismissedAt: string | null;
// 신규 v4 (v0.2.9 Cut B):
status: NoteStatus;
statusChangedAt: string | null;
moveReason: string | null;
createdAt: string;
updatedAt: string;
tags: NoteTag[];
@@ -57,6 +83,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(
@@ -91,7 +131,57 @@ export interface InboxApi {
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 }>;
onOpenOllamaSettings(cb: () => void): () => void;
// 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 }>;
// v0.2.9 Cut B Task 4 — status 별 노트 목록 + status 별 count.
listByStatus(status: NoteStatus, opts?: { limit?: number }): Promise<Note[]>;
countsByStatus(): Promise<{ active: number; completed: number; archived: number; trashed: number }>;
// v0.2.9 Cut B Task 8 — 4분기 status 전이 + AI 자동 분류 추천.
setStatus(
id: string,
status: NoteStatus,
reason: string | null
): Promise<{ ok: true } | { ok: false; reason: string }>;
classifyStatus(id: string, reason: string): Promise<{ recommended: NoteStatus; rationale: string }>;
// v0.2.9 Cut B Task 12 — settings read + AI/onboarding 토글.
getSettings(): Promise<{
ollama?: { endpoint: string; model: string };
ai_enabled?: boolean;
onboarding_completed?: boolean;
}>;
setAiEnabled(enabled: boolean): Promise<{ ok: true }>;
setOnboardingCompleted(completed: boolean): Promise<{ ok: true }>;
// v0.2.9 Cut B Task 16 — ai_status='disabled' 메모 재투입 (사용자가 ai_enabled OFF→ON 전환 시).
enqueueDisabled(): Promise<{ count: number }>;
getDisabledCount(): Promise<number>;
// v0.2.10 Cut C — raw_text 가변 + revision 보존.
updateRawText(noteId: string, newText: string): Promise<{ ok: true } | { ok: false; reason: string }>;
listRevisions(noteId: string): Promise<NoteRevision[]>;
restoreRevision(noteId: string, revId: number): Promise<{ ok: true } | { ok: false; reason: string }>;
// v0.2.11 Cut D — FTS5 search + 회고 aggregate.
search(query: string, opts?: { limit?: number; status?: NoteStatus }): Promise<Note[]>;
reviewAggregate(period: ReviewPeriod): Promise<ReviewAggregate>;
}
export interface InklingApi {

View File

@@ -25,6 +25,11 @@ test('inbox shell shows v0.2 empty state', async () => {
if ((await w.title()) === 'Inkling') { inbox = w; break; }
}
await inbox.waitForLoadState('load');
// v0.2.9 Cut B: 첫 launch 시 OnboardingWizard 표시 — "나중에 설정" 으로 dismiss 후 inbox 진입.
const dismissOnboarding = inbox.getByRole('button', { name: /나중에 설정/ });
if (await dismissOnboarding.isVisible({ timeout: 2000 }).catch(() => false)) {
await dismissOnboarding.click();
}
await expect(inbox.getByRole('heading', { name: 'Inkling' })).toBeVisible();
await expect(inbox.getByText('머릿속에 떠다니는 한 줄을 적어보세요.')).toBeVisible();
await app.close();

View File

@@ -0,0 +1,97 @@
// @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: {
loadOllamaSettings: vi.fn(async () => ({ endpoint: 'http://localhost:11434', model: 'gemma2:2b' })),
saveOllamaSettings: vi.fn(async () => ({ ok: true })),
ollamaRecheck: vi.fn(async () => ({ ok: true })),
getSettings: vi.fn(async () => ({ ai_enabled: true })),
setAiEnabled: vi.fn(async () => ({ ok: true })),
getDisabledCount: vi.fn(async () => 0),
enqueueDisabled: vi.fn(async () => ({ count: 0 }))
}
}));
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();
});
// v0.2.9 Cut B Task 15 — AI 자동 처리 토글 + OFF 안내문.
it('renders AI 자동 처리 toggle (default true)', async () => {
const { inboxApi } = await import('../../src/renderer/inbox/api.js');
vi.mocked(inboxApi.getSettings).mockResolvedValue({ ai_enabled: true } as never);
render(<AiProviderSection />);
const toggle = await screen.findByLabelText(/AI 자동 처리 사용/);
expect((toggle as HTMLInputElement).checked).toBe(true);
});
it('toggling calls setAiEnabled', async () => {
const { inboxApi } = await import('../../src/renderer/inbox/api.js');
vi.mocked(inboxApi.getSettings).mockResolvedValue({ ai_enabled: true } as never);
vi.mocked(inboxApi.setAiEnabled).mockResolvedValue({ ok: true } as never);
render(<AiProviderSection />);
const toggle = await screen.findByLabelText(/AI 자동 처리 사용/);
fireEvent.click(toggle);
await waitFor(() => expect(inboxApi.setAiEnabled).toHaveBeenCalledWith(false));
});
it('shows OFF state explanation when ai_enabled=false', async () => {
const { inboxApi } = await import('../../src/renderer/inbox/api.js');
vi.mocked(inboxApi.getSettings).mockResolvedValue({ ai_enabled: false } as never);
render(<AiProviderSection />);
await screen.findByLabelText(/AI 자동 처리 사용/);
expect(screen.getByText(/원문만 저장 모드/)).toBeInTheDocument();
expect(screen.getByRole('link', { name: /ollama\.com|설치/ })).toBeInTheDocument();
});
// v0.2.9 Cut B Task 16 — ON 전환 후 disabled 메모 처리 prompt + 버튼.
it('shows disabled count + 처리 버튼 when ai_enabled=true and disabledCount > 0', async () => {
const { inboxApi } = await import('../../src/renderer/inbox/api.js');
vi.mocked(inboxApi.getSettings).mockResolvedValue({ ai_enabled: true } as never);
vi.mocked(inboxApi.getDisabledCount).mockResolvedValue(5);
render(<AiProviderSection />);
await screen.findByText(/5건/);
expect(screen.getByRole('button', { name: /지금 모두 처리/ })).toBeInTheDocument();
});
it('clicking 처리 버튼 calls enqueueDisabled', async () => {
const { inboxApi } = await import('../../src/renderer/inbox/api.js');
vi.mocked(inboxApi.getSettings).mockResolvedValue({ ai_enabled: true } as never);
vi.mocked(inboxApi.getDisabledCount).mockResolvedValue(3);
vi.mocked(inboxApi.enqueueDisabled).mockResolvedValue({ count: 3 } as never);
render(<AiProviderSection />);
await screen.findByText(/3건/);
fireEvent.click(screen.getByRole('button', { name: /지금 모두 처리/ }));
await waitFor(() => expect(inboxApi.enqueueDisabled).toHaveBeenCalled());
});
});

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

@@ -0,0 +1,171 @@
// @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 () => []),
listByStatus: vi.fn(async () => []),
countsByStatus: vi.fn(async () => ({ active: 0, completed: 0, archived: 0, trashed: 0 })),
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),
// v0.2.9 Cut B Task 12 — onboarding wizard 분기. default 는 onboarding_completed=true 라 wizard 미표시.
getSettings: vi.fn(async () => ({ onboarding_completed: true })),
setAiEnabled: vi.fn(async () => ({ ok: true as const })),
setOnboardingCompleted: vi.fn(async () => ({ ok: true as const })),
// v0.2.9 Cut B Task 16 — AiProviderSection 가 SettingsPage 렌더 시 호출.
getDisabledCount: vi.fn(async () => 0),
enqueueDisabled: vi.fn(async () => ({ count: 0 }))
}
}));
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({
view: 'inbox',
counts: { active: 0, completed: 0, archived: 0, trashed: 0 },
showSettings: false, showTrash: 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));
});
});
describe('App header — 4 tabs', () => {
beforeEach(() => {
cleanup();
useInbox.setState({
view: 'inbox',
counts: { active: 5, completed: 3, archived: 2, trashed: 1 },
notes: [], trashNotes: [], trashCount: 0,
showTrash: false, showSettings: false
});
// loadInitial 이 비동기로 counts 를 덮어씀 — onboarding wizard async gate (Task 12) 도입
// 후 render 가 await 후 발생하므로 mock 의 countsByStatus 가 테스트 기대값을 반환하도록 갱신.
vi.mocked(inboxApi.countsByStatus).mockResolvedValue({ active: 5, completed: 3, archived: 2, trashed: 1 });
});
it('renders 4 tabs with counts', async () => {
render(<App />);
expect(await screen.findByRole('button', { name: /Inbox\(5\)/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /완료\(3\)/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /보관\(2\)/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /휴지통\(1\)/ })).toBeInTheDocument();
});
it('clicking 완료 tab sets view=completed', async () => {
render(<App />);
fireEvent.click(await screen.findByRole('button', { name: /완료/ }));
expect(useInbox.getState().view).toBe('completed');
});
it('aria-pressed reflects current view', async () => {
useInbox.setState({ view: 'archived' });
render(<App />);
const archivedBtn = await screen.findByRole('button', { name: /보관/ });
expect(archivedBtn.getAttribute('aria-pressed')).toBe('true');
const inboxBtn = screen.getByRole('button', { name: /Inbox/ });
expect(inboxBtn.getAttribute('aria-pressed')).toBe('false');
});
});
describe('App — onboarding wizard', () => {
beforeEach(() => {
cleanup();
useInbox.setState({
view: 'inbox',
counts: { active: 0, completed: 0, archived: 0, trashed: 0 },
showSettings: false, showTrash: false,
notes: [], trashNotes: [], trashCount: 0
});
// 각 테스트가 getSettings 의 default mock 을 직접 override.
vi.mocked(inboxApi.getSettings).mockResolvedValue({ onboarding_completed: true });
});
it('renders OnboardingWizard when onboarding_completed=false', async () => {
vi.mocked(inboxApi.getSettings).mockResolvedValue({ onboarding_completed: false });
render(<App />);
await screen.findByText(/Inkling 사용 시작/);
expect(screen.getByRole('dialog', { name: /시작 안내/ })).toBeInTheDocument();
});
it('does not render OnboardingWizard when onboarding_completed=true', async () => {
vi.mocked(inboxApi.getSettings).mockResolvedValue({ onboarding_completed: true });
render(<App />);
await screen.findByRole('button', { name: /Inbox/ });
expect(screen.queryByText(/Inkling 사용 시작/)).toBeNull();
});
});

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

@@ -420,6 +420,51 @@ describe('CaptureService.retryAllFailed', () => {
});
});
describe('CaptureService ai_enabled toggle (v0.2.9 Cut B)', () => {
let db: Database.Database;
let repo: NoteRepository;
let store: MediaStore;
let tmp: string;
let enqueued: string[];
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
tmp = mkdtempSync(join(tmpdir(), 'inkling-aitoggle-'));
store = new MediaStore(tmp);
enqueued = [];
});
it('ai_enabled=false → ai_status=disabled, no enqueue, no pending_jobs row', async () => {
const settings = { isAiEnabled: async () => false };
const svc = new CaptureService(repo, store, {
enqueue: async (id) => { enqueued.push(id); },
celebrate: () => {},
settings
});
const { noteId } = await svc.submit({ text: 'no-ai', images: [] });
expect(repo.findById(noteId)?.aiStatus).toBe('disabled');
expect(enqueued).toEqual([]);
const row = db.prepare('SELECT note_id FROM pending_jobs WHERE note_id=?').get(noteId);
expect(row).toBeUndefined();
});
it('ai_enabled=true → default pending + enqueue (parity with no settings dep)', async () => {
const settings = { isAiEnabled: async () => true };
const svc = new CaptureService(repo, store, {
enqueue: async (id) => { enqueued.push(id); },
celebrate: () => {},
settings
});
const { noteId } = await svc.submit({ text: 'with-ai', images: [] });
expect(repo.findById(noteId)?.aiStatus).toBe('pending');
expect(enqueued).toEqual([noteId]);
const row = db.prepare('SELECT note_id FROM pending_jobs WHERE note_id=?').get(noteId);
expect(row).toBeDefined();
});
});
describe('CaptureService recall methods (v0.2.3 #6)', () => {
let db: Database.Database;
let repo: NoteRepository;

View File

@@ -0,0 +1,46 @@
// @vitest-environment jsdom
import { describe, it, expect, beforeEach, vi } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { render, cleanup } from '@testing-library/react';
vi.mock('../../src/renderer/inbox/api.js', () => ({
inboxApi: {
retryAllFailed: vi.fn(async () => {})
}
}));
import { FailedBanner } from '../../src/renderer/inbox/components/FailedBanner';
import { useInbox } from '../../src/renderer/inbox/store';
describe('FailedBanner — ai_enabled (v0.2.9 Cut B Task 14)', () => {
beforeEach(() => {
cleanup();
});
it('renders nothing when ai_enabled=false (even with failedCount > 0)', () => {
useInbox.setState({
ai_enabled: false,
failedCount: 3
} as Partial<ReturnType<typeof useInbox.getState>>);
const { container } = render(<FailedBanner />);
expect(container).toBeEmptyDOMElement();
});
it('renders nothing when ai_enabled=true and failedCount=0', () => {
useInbox.setState({
ai_enabled: true,
failedCount: 0
} as Partial<ReturnType<typeof useInbox.getState>>);
const { container } = render(<FailedBanner />);
expect(container).toBeEmptyDOMElement();
});
it('renders banner when ai_enabled=true and failedCount > 0', () => {
useInbox.setState({
ai_enabled: true,
failedCount: 5
} as Partial<ReturnType<typeof useInbox.getState>>);
const { container } = render(<FailedBanner />);
expect(container).not.toBeEmptyDOMElement();
});
});

View File

@@ -117,3 +117,48 @@ describe('HealthChecker — delta transitions + telemetry', () => {
expect(events).toEqual([{ kind: 'ollama_recheck_manual' }]);
});
});
describe('HealthChecker — ai_enabled gate (v0.2.9 Cut B Task 14)', () => {
beforeEach(() => { vi.useFakeTimers(); });
afterEach(() => { vi.useRealTimers(); });
it('isAiEnabled=false 면 start() polling 이 healthCheck 호출 skip', async () => {
const provider = new FakeProvider();
provider.results = [{ ok: true }];
const hc = new HealthChecker(new ProviderHolder(provider), {
intervalMs: 1000,
isAiEnabled: async () => false
});
hc.start();
await vi.advanceTimersByTimeAsync(1000);
await vi.advanceTimersByTimeAsync(1000);
// 즉시 + 2 tick = 0회 — AI 비활성으로 모든 polling skip.
expect((provider as any).idx).toBe(0);
hc.stop();
});
it('isAiEnabled=true 면 polling 정상 (gate 통과)', async () => {
const provider = new FakeProvider();
provider.results = [{ ok: true }, { ok: true }];
const hc = new HealthChecker(new ProviderHolder(provider), {
intervalMs: 1000,
isAiEnabled: async () => true
});
hc.start();
// start() 의 즉시 tick 이 microtask 에서 isAiEnabled 를 await 함 → flush 필요.
await vi.runOnlyPendingTimersAsync();
await vi.advanceTimersByTimeAsync(1000);
expect((provider as any).idx).toBeGreaterThanOrEqual(2);
hc.stop();
});
it('isAiEnabled=false 여도 manual runOnce 는 항상 실행 (사용자 의도)', async () => {
const provider = new FakeProvider();
provider.results = [{ ok: true }];
const hc = new HealthChecker(new ProviderHolder(provider), {
isAiEnabled: async () => false
});
await hc.runOnce({ manual: true });
expect((provider as any).idx).toBe(1);
});
});

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

@@ -0,0 +1,98 @@
// @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';
const { mockSetStatus, mockClassify } = vi.hoisted(() => ({
mockSetStatus: vi.fn(async () => ({ ok: true as const })),
mockClassify: vi.fn(async () => ({
recommended: 'completed' as const,
rationale: '결재 끝'
}))
}));
vi.mock('../../src/renderer/inbox/api.js', () => ({
inboxApi: {
setStatus: mockSetStatus,
classifyStatus: mockClassify
}
}));
import { MoveStatusModal } from '../../src/renderer/inbox/components/MoveStatusModal';
describe('MoveStatusModal', () => {
beforeEach(() => {
vi.clearAllMocks();
cleanup();
});
it('renders reason textarea + 4 buttons + AI classify button', () => {
render(
<MoveStatusModal
noteId="n1"
rawText="t"
summary=""
onClose={vi.fn()}
onMoved={vi.fn()}
/>
);
expect(screen.getByRole('textbox')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '완료' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '보관' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '휴지통' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /AI 자동 분류/ })).toBeInTheDocument();
});
it('clicking 완료 calls setStatus with reason', async () => {
const onMoved = vi.fn();
render(
<MoveStatusModal
noteId="n1"
rawText="t"
summary=""
onClose={vi.fn()}
onMoved={onMoved}
/>
);
fireEvent.change(screen.getByRole('textbox'), { target: { value: '결재 끝' } });
fireEvent.click(screen.getByRole('button', { name: '완료' }));
await waitFor(() => {
expect(mockSetStatus).toHaveBeenCalledWith('n1', 'completed', '결재 끝');
expect(onMoved).toHaveBeenCalledWith('completed', '결재 끝');
});
});
it('AI 자동 분류 → recommendation 표시 + 확정 → setStatus', async () => {
const onMoved = vi.fn();
render(
<MoveStatusModal
noteId="n1"
rawText="t"
summary=""
onClose={vi.fn()}
onMoved={onMoved}
/>
);
fireEvent.change(screen.getByRole('textbox'), { target: { value: '결재 끝' } });
fireEvent.click(screen.getByRole('button', { name: /AI 자동 분류/ }));
await screen.findByText(/AI 추천/);
expect(screen.getByText(/이유: 결재 끝/)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /확정/ }));
await waitFor(() => expect(onMoved).toHaveBeenCalledWith('completed', '결재 끝'));
});
it('빈 사유 → null reason 전달', async () => {
const onMoved = vi.fn();
render(
<MoveStatusModal
noteId="n1"
rawText="t"
summary=""
onClose={vi.fn()}
onMoved={onMoved}
/>
);
fireEvent.click(screen.getByRole('button', { name: '보관' }));
await waitFor(() => expect(mockSetStatus).toHaveBeenCalledWith('n1', 'archived', null));
});
});

View File

@@ -0,0 +1,188 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
import type { Note } from '@shared/types';
const { mockOpenMedia, mockSetStatus, mockClassify, mockUpdateRawText } = vi.hoisted(() => ({
mockOpenMedia: vi.fn(async () => ({ ok: true })),
mockSetStatus: vi.fn(async () => ({ ok: true as const })),
mockClassify: vi.fn(async () => ({
recommended: 'archived' as const,
rationale: 'stub'
})),
mockUpdateRawText: vi.fn(async () => ({ ok: true as const }))
}));
vi.mock('../../src/renderer/inbox/api.js', () => ({
inboxApi: {
openMedia: mockOpenMedia,
deleteNote: vi.fn(),
restoreNote: vi.fn(),
permanentDeleteNote: vi.fn(),
updateAiFields: vi.fn(),
setDueDate: vi.fn(),
setIntent: vi.fn(),
dismissIntent: vi.fn(),
setStatus: mockSetStatus,
classifyStatus: mockClassify,
updateRawText: mockUpdateRawText,
listRevisions: vi.fn(async () => []),
restoreRevision: vi.fn(async () => ({ ok: true as const }))
}
}));
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,
status: 'active',
statusChangedAt: null,
moveReason: 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');
});
});
describe('NoteCard — ai_status=disabled fallback (v0.2.9 Cut B Task 13)', () => {
beforeEach(() => {
vi.clearAllMocks();
cleanup();
});
it('ai_status=disabled: title fallback to raw_text first line, hide summary/tags', () => {
const disabledNote: Note = {
...baseNote,
aiStatus: 'disabled',
aiTitle: null,
aiSummary: 'should-not-show',
tags: [{ name: 't1', source: 'user' }],
rawText: '첫 줄 본문\n둘째 줄 본문'
};
render(<NoteCard note={disabledNote} mode="inbox" onUpdated={vi.fn()} />);
expect(screen.getByText('첫 줄 본문')).toBeInTheDocument();
expect(screen.queryByText('should-not-show')).toBeNull();
expect(screen.queryByText('t1')).toBeNull();
});
it('ai_status=disabled: empty raw → "(빈 메모)" fallback', () => {
const disabledNote: Note = {
...baseNote,
aiStatus: 'disabled',
aiTitle: null,
rawText: ''
};
render(<NoteCard note={disabledNote} mode="inbox" onUpdated={vi.fn()} />);
expect(screen.getByText('(빈 메모)')).toBeInTheDocument();
});
});
describe('NoteCard — 이동 메뉴 (v0.2.9 Cut B Task 6)', () => {
beforeEach(() => {
vi.clearAllMocks();
cleanup();
});
it('이동 ▾ 클릭 → 현재 status 외 3개 목적지 메뉴 표시', () => {
// baseNote.status = 'active' → 완료/보관/휴지통 만 표시
render(<NoteCard note={baseNote} onUpdated={() => {}} mode="inbox" />);
fireEvent.click(screen.getByRole('button', { name: '이동' }));
expect(screen.getByRole('button', { name: '완료로 이동' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '보관으로 이동' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '휴지통으로 이동' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: '활성으로 이동' })).toBeNull();
});
it('메뉴 항목 클릭 → MoveStatusModal 열림 + 확정 시 setStatus 호출', async () => {
const onUpdated = vi.fn();
render(<NoteCard note={baseNote} onUpdated={onUpdated} mode="inbox" />);
fireEvent.click(screen.getByRole('button', { name: '이동' }));
fireEvent.click(screen.getByRole('button', { name: '완료로 이동' }));
// Modal 의 dialog role 등장
expect(screen.getByRole('dialog', { name: '이동' })).toBeInTheDocument();
// Modal 내부의 "완료" 버튼 클릭 → setStatus
fireEvent.click(screen.getByRole('button', { name: '완료' }));
await waitFor(() => {
expect(mockSetStatus).toHaveBeenCalledWith('n1', 'completed', null);
expect(onUpdated).toHaveBeenCalled();
});
});
});
describe('NoteCard — raw_text editing', () => {
beforeEach(() => {
vi.clearAllMocks();
cleanup();
});
it('원문 편집: textarea 저장 → updateRawText 호출 + 로컬 raw 갱신', async () => {
const onUpdated = vi.fn();
render(<NoteCard note={{ ...baseNote, rawText: 'old' }} onUpdated={onUpdated} mode="inbox" />);
// 원문 펼침
fireEvent.click(screen.getByRole('button', { name: /원문/ }));
// 편집 진입
fireEvent.click(screen.getByRole('button', { name: '편집' }));
const ta = screen.getByRole('textbox', { name: /원문 편집/ }) as HTMLTextAreaElement;
fireEvent.change(ta, { target: { value: 'new' } });
fireEvent.click(screen.getByRole('button', { name: '저장' }));
await waitFor(() => {
expect(mockUpdateRawText).toHaveBeenCalledWith('n1', 'new');
});
await waitFor(() => {
expect(onUpdated).toHaveBeenCalled();
});
const last = onUpdated.mock.calls.at(-1)![0] as { rawText: string };
expect(last.rawText).toBe('new');
});
});

View File

@@ -0,0 +1,72 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import Database from 'better-sqlite3';
import { runMigrations } from '../../src/main/db/migrations/index.js';
import { NoteRepository } from '../../src/main/repository/NoteRepository.js';
describe('NoteRepository.reviewAggregate', () => {
let db: Database.Database;
let repo: NoteRepository;
beforeEach(() => {
db = new Database(':memory:');
db.pragma('foreign_keys = ON');
runMigrations(db);
repo = new NoteRepository(db);
});
afterEach(() => { db.close(); });
it('daily — 오늘 KST 자정 이후 노트만 카운트', () => {
const now = new Date('2026-05-10T05:00:00Z'); // KST 14:00
db.prepare(`INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at, status)
VALUES (?, ?, 'done', ?, ?, 'active')`).run('today', '오늘 메모', '2026-05-10T00:30:00Z', '2026-05-10T00:30:00Z');
db.prepare(`INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at, status)
VALUES (?, ?, 'done', ?, ?, 'active')`).run('yesterday', '어제 메모', '2026-05-09T10:00:00Z', '2026-05-09T10:00:00Z');
const r = repo.reviewAggregate('daily', now);
expect(r.totalCount).toBe(1);
expect(r.recentNotes).toHaveLength(1);
expect(r.recentNotes[0]!.id).toBe('today');
});
it('weekly — 7일 전 KST 자정 이후', () => {
const now = new Date('2026-05-10T05:00:00Z');
db.prepare(`INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at, status)
VALUES (?, ?, 'done', ?, ?, 'active')`).run('5dago', '5일 전', '2026-05-05T00:00:00Z', '2026-05-05T00:00:00Z');
db.prepare(`INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at, status)
VALUES (?, ?, 'done', ?, ?, 'active')`).run('10dago', '10일 전', '2026-04-30T00:00:00Z', '2026-04-30T00:00:00Z');
const r = repo.reviewAggregate('weekly', now);
expect(r.totalCount).toBe(1);
});
it('trashed 제외', () => {
const now = new Date('2026-05-10T05:00:00Z');
const a = repo.create({ rawText: '활성' });
const b = repo.create({ rawText: '버린' });
repo.setStatus(b.id, 'trashed', null);
const r = repo.reviewAggregate('monthly', now);
expect(r.recentNotes.map((n) => n.id)).toContain(a.id);
expect(r.recentNotes.map((n) => n.id)).not.toContain(b.id);
});
it('tagCounts — period 안 노트의 태그만 DESC', () => {
const now = new Date('2026-05-10T05:00:00Z');
const a = repo.create({ rawText: 'a' });
const b = repo.create({ rawText: 'b' });
repo.updateAiResult(a.id, { title: 't', summary: 's', tags: ['x', 'y'], provider: 'p' });
repo.updateAiResult(b.id, { title: 't', summary: 's', tags: ['x'], provider: 'p' });
const r = repo.reviewAggregate('monthly', now);
expect(r.tagCounts[0]).toEqual({ tag: 'x', count: 2 });
expect(r.tagCounts[1]).toEqual({ tag: 'y', count: 1 });
});
it('dueProgress — passed / pending KST today 기준', () => {
const now = new Date('2026-05-10T05:00:00Z');
const a = repo.create({ rawText: 'a' });
const b = repo.create({ rawText: 'b' });
repo.create({ rawText: 'c' }); // due 없음 → 카운트 X
repo.setDueDate(a.id, '2026-05-01'); // passed
repo.setDueDate(b.id, '2026-05-15'); // pending
const r = repo.reviewAggregate('monthly', now);
expect(r.dueProgress).toEqual({ total: 2, passed: 1, pending: 1 });
});
});

View File

@@ -0,0 +1,57 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import Database from 'better-sqlite3';
import { runMigrations } from '../../src/main/db/migrations/index.js';
import { NoteRepository } from '../../src/main/repository/NoteRepository.js';
describe('NoteRepository.search — FTS5', () => {
let db: Database.Database;
let repo: NoteRepository;
beforeEach(() => {
db = new Database(':memory:');
db.pragma('foreign_keys = ON');
runMigrations(db);
repo = new NoteRepository(db);
const a = repo.create({ rawText: '오늘 월요일 회의 정리' });
repo.updateAiResult(a.id, { title: '회의록', summary: '월요일', tags: ['기획', '회의'], provider: 'p' });
const b = repo.create({ rawText: '결재 요청 본문' });
repo.updateAiResult(b.id, { title: '결재', summary: '요청서', tags: ['결재'], provider: 'p' });
const c = repo.create({ rawText: '버려진 메모' });
repo.setStatus(c.id, 'trashed', null);
});
afterEach(() => { db.close(); });
it('빈 query → 빈 배열', () => {
expect(repo.search('')).toEqual([]);
expect(repo.search(' ')).toEqual([]);
});
it('keyword 매칭 → hydrated Note', () => {
const r = repo.search('월요일');
expect(r.length).toBeGreaterThan(0);
const titles = r.map((n) => n.aiTitle);
expect(titles).toContain('회의록');
});
it('multi-token implicit AND', () => {
const r1 = repo.search('회의 월요일');
expect(r1.length).toBeGreaterThan(0);
const r2 = repo.search('회의 결재'); // 동시 매칭 노트 없음
expect(r2).toEqual([]);
});
it('default 는 trashed 제외', () => {
const r = repo.search('버려진');
expect(r).toEqual([]);
});
it('status filter 명시 시 해당 status 만', () => {
const r = repo.search('버려진', { status: 'trashed' });
expect(r.length).toBe(1);
});
it('FTS5 special char 안전 처리', () => {
expect(() => repo.search('"회의*" (월요일):')).not.toThrow();
});
});

View File

@@ -852,3 +852,308 @@ describe('NoteRepository — failed retry helpers', () => {
expect(repo.getTagIdByName('nothere')).toBeNull();
});
});
describe('NoteRepository — setStatus + listByStatus', () => {
let db: Database.Database;
let repo: NoteRepository;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
});
it('setStatus updates status + reason + status_changed_at + updated_at', () => {
const { id } = repo.create({ rawText: 'test' });
repo.setStatus(id, 'completed', '결재 끝', new Date('2026-05-10T00:00:00.000Z'));
const note = repo.findById(id)!;
expect(note.status).toBe('completed');
expect(note.moveReason).toBe('결재 끝');
expect(note.statusChangedAt).toBe('2026-05-10T00:00:00.000Z');
expect(note.updatedAt).toBe('2026-05-10T00:00:00.000Z');
});
it('setStatus accepts null reason', () => {
const { id } = repo.create({ rawText: 'test' });
repo.setStatus(id, 'archived', null, new Date('2026-05-10T00:00:00.000Z'));
const note = repo.findById(id)!;
expect(note.status).toBe('archived');
expect(note.moveReason).toBeNull();
});
it('setStatus default now uses Date.now()', () => {
const { id } = repo.create({ rawText: 'test' });
const before = Date.now();
repo.setStatus(id, 'completed', null);
const after = Date.now();
const note = repo.findById(id)!;
const ts = new Date(note.statusChangedAt!).getTime();
expect(ts).toBeGreaterThanOrEqual(before);
expect(ts).toBeLessThanOrEqual(after);
});
it('listByStatus filters correctly', () => {
const idA = repo.create({ rawText: 'a' }).id;
const idB = repo.create({ rawText: 'b' }).id;
repo.setStatus(idB, 'archived', null, new Date('2026-05-10T00:00:00.000Z'));
const active = repo.listByStatus('active', { limit: 10 });
const archived = repo.listByStatus('archived', { limit: 10 });
expect(active.map((n) => n.id)).toContain(idA);
expect(active.map((n) => n.id)).not.toContain(idB);
expect(archived.map((n) => n.id)).toContain(idB);
expect(archived.map((n) => n.id)).not.toContain(idA);
});
it('listByStatus orders by status_changed_at DESC (NULL falls back to created_at)', () => {
const a = repo.create({ rawText: 'a' }).id;
const b = repo.create({ rawText: 'b' }).id;
const c = repo.create({ rawText: 'c' }).id;
repo.setStatus(a, 'completed', null, new Date('2026-05-10T00:00:00.000Z'));
repo.setStatus(b, 'completed', null, new Date('2026-05-12T00:00:00.000Z'));
repo.setStatus(c, 'completed', null, new Date('2026-05-11T00:00:00.000Z'));
const r = repo.listByStatus('completed', { limit: 10 });
expect(r.map((n) => n.id)).toEqual([b, c, a]);
});
it('listByStatus respects limit (cap 200)', () => {
for (let i = 0; i < 5; i++) {
const id = repo.create({ rawText: `n${i}` }).id;
repo.setStatus(id, 'archived', null, new Date(`2026-05-${10 + i}T00:00:00.000Z`));
}
expect(repo.listByStatus('archived', { limit: 3 })).toHaveLength(3);
expect(repo.listByStatus('archived', { limit: 100 })).toHaveLength(5);
});
it('listByStatus default limit 200', () => {
repo.create({ rawText: 'a' });
expect(repo.listByStatus('active')).toHaveLength(1);
});
it('setStatus("trashed") syncs deleted_at (backward compat)', () => {
const { id } = repo.create({ rawText: 't' });
repo.setStatus(id, 'trashed', null, new Date('2026-05-15T00:00:00.000Z'));
const row = db.prepare(`SELECT deleted_at FROM notes WHERE id=?`).get(id) as {
deleted_at: string;
};
expect(row.deleted_at).toBe('2026-05-15T00:00:00.000Z');
expect(repo.findById(id)!.deletedAt).toBe('2026-05-15T00:00:00.000Z');
});
it('setStatus("active") clears deleted_at (restore from trash)', () => {
const { id } = repo.create({ rawText: 'r' });
repo.setStatus(id, 'trashed', null, new Date('2026-05-15T00:00:00.000Z'));
repo.setStatus(id, 'active', null, new Date('2026-05-16T00:00:00.000Z'));
const row = db.prepare(`SELECT deleted_at FROM notes WHERE id=?`).get(id) as {
deleted_at: string | null;
};
expect(row.deleted_at).toBeNull();
expect(repo.findById(id)!.deletedAt).toBeNull();
});
it('setStatus("completed"/"archived") also clears deleted_at', () => {
const { id } = repo.create({ rawText: 'r' });
repo.setStatus(id, 'trashed', null, new Date('2026-05-15T00:00:00.000Z'));
repo.setStatus(id, 'completed', null, new Date('2026-05-16T00:00:00.000Z'));
expect(repo.findById(id)!.deletedAt).toBeNull();
});
it('newly created note hydrates as status=active', () => {
const { id } = repo.create({ rawText: 'fresh' });
const note = repo.findById(id)!;
expect(note.status).toBe('active');
expect(note.statusChangedAt).toBeNull();
expect(note.moveReason).toBeNull();
});
it('countByStatus returns accurate count per status', () => {
const a = repo.create({ rawText: 'a' }).id; // active
repo.create({ rawText: 'b' }); // active
const c = repo.create({ rawText: 'c' }).id;
repo.setStatus(c, 'completed', null, new Date('2026-05-10T00:00:00.000Z'));
const d = repo.create({ rawText: 'd' }).id;
repo.setStatus(d, 'archived', null, new Date('2026-05-10T00:00:00.000Z'));
const e = repo.create({ rawText: 'e' }).id;
repo.setStatus(e, 'trashed', null, new Date('2026-05-10T00:00:00.000Z'));
expect(repo.countByStatus('active')).toBe(2);
expect(repo.countByStatus('completed')).toBe(1);
expect(repo.countByStatus('archived')).toBe(1);
expect(repo.countByStatus('trashed')).toBe(1);
// sanity — a 가 여전히 active.
expect(repo.findById(a)!.status).toBe('active');
});
it('restoreNote sets status=active + clears moveReason', () => {
const { id } = repo.create({ rawText: 'r' });
repo.setStatus(id, 'trashed', '실수', new Date('2026-05-15T00:00:00.000Z'));
expect(repo.findById(id)!.status).toBe('trashed');
expect(repo.findById(id)!.moveReason).toBe('실수');
repo.restoreNote(id);
const after = repo.findById(id)!;
expect(after.status).toBe('active');
expect(after.moveReason).toBeNull();
expect(after.deletedAt).toBeNull();
});
});
// v0.2.9 Cut B Task 16 — settings.ai_enabled OFF→ON 전환 시 disabled 메모 일괄 재투입.
describe('NoteRepository.requeueDisabled', () => {
let db: Database.Database;
let repo: NoteRepository;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
});
it('changes ai_status="disabled" → "pending" + INSERT pending_jobs', () => {
const { id } = repo.create({ rawText: 't', aiStatus: 'disabled' });
const count = repo.requeueDisabled(new Date('2026-05-09T00:00:00Z'));
expect(count).toBe(1);
const note = repo.findById(id);
expect(note?.aiStatus).toBe('pending');
const job = db.prepare(`SELECT * FROM pending_jobs WHERE note_id=?`).get(id);
expect(job).toBeDefined();
});
it('does not affect non-disabled notes', () => {
const idP = repo.create({ rawText: 'p', aiStatus: 'pending' }).id;
const idC = repo.create({ rawText: 'c' }).id;
repo.updateAiResult(idC, { title: 't', summary: 'a\nb\nc', tags: [], provider: 'p' });
repo.requeueDisabled(new Date());
expect(repo.findById(idP)?.aiStatus).toBe('pending');
expect(repo.findById(idC)?.aiStatus).toBe('done');
});
it('returns 0 when no disabled notes', () => {
const count = repo.requeueDisabled(new Date());
expect(count).toBe(0);
});
});
describe('NoteRepository.countByAiStatus', () => {
let db: Database.Database;
let repo: NoteRepository;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
});
it('returns count per ai_status', () => {
repo.create({ rawText: 'a', aiStatus: 'disabled' });
repo.create({ rawText: 'b', aiStatus: 'disabled' });
repo.create({ rawText: 'c', aiStatus: 'pending' });
expect(repo.countByAiStatus('disabled')).toBe(2);
expect(repo.countByAiStatus('pending')).toBe(1);
expect(repo.countByAiStatus('done')).toBe(0);
});
});
describe('NoteRepository — note_revisions', () => {
let db: Database.Database;
let repo: NoteRepository;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
});
it('create() 가 첫 revision (edited_by=capture) 을 INSERT 한다', () => {
const { id } = repo.create({ rawText: 'hello' });
const rows = db
.prepare(`SELECT raw_text, edited_by FROM note_revisions WHERE note_id=?`)
.all(id) as Array<{ raw_text: string; edited_by: string }>;
expect(rows).toHaveLength(1);
expect(rows[0]).toEqual({ raw_text: 'hello', edited_by: 'capture' });
});
});
describe('NoteRepository — notes_fts tags sync (v0.2.11 Cut D)', () => {
let db: Database.Database;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
});
it('updateAiResult 후 notes_fts.tags 가 csv 로 sync', () => {
const repo = new NoteRepository(db);
const { id } = repo.create({ rawText: '회의 본문' });
repo.updateAiResult(id, { title: '제목', summary: '요약', tags: ['기획', '회의'], provider: 'p' });
const row = db
.prepare(`SELECT tags FROM notes_fts WHERE note_id=?`)
.get(id) as { tags: string };
expect(row.tags.split(' ').sort()).toEqual(['기획', '회의']);
});
it('updateUserAiFields tags 갱신 후 notes_fts.tags 동기', () => {
const repo = new NoteRepository(db);
const { id } = repo.create({ rawText: '본문' });
repo.updateAiResult(id, { title: 't', summary: 's', tags: ['old'], provider: 'p' });
repo.updateUserAiFields(id, { tags: ['new1', 'new2'] });
const row = db
.prepare(`SELECT tags FROM notes_fts WHERE note_id=?`)
.get(id) as { tags: string };
expect(row.tags.split(' ').sort()).toEqual(['new1', 'new2']);
});
it('importNote insert path: notes_fts.tags 가 csv 로 sync (final review fix)', () => {
const repo = new NoteRepository(db);
const r = repo.importNote({
id: '00000000-0000-0000-0000-000000000010',
rawText: 'imported with tags',
createdAt: '2026-04-01T00:00:00Z',
updatedAt: '2026-04-01T00:00:00Z',
aiTitle: 'imported title',
aiSummary: 'imported summary',
titleEditedByUser: false,
summaryEditedByUser: false,
aiProvider: 'p',
aiGeneratedAt: '2026-04-01T00:00:00Z',
userIntent: null,
intentPromptedAt: null,
tags: [
{ name: '기획', source: 'ai' },
{ name: '회의', source: 'user' }
]
});
expect(r.status).toBe('inserted');
const row = db
.prepare(`SELECT tags FROM notes_fts WHERE note_id=?`)
.get(r.id) as { tags: string };
expect(row.tags.split(' ').sort()).toEqual(['기획', '회의']);
});
it('importNote fork path: forked 노트의 notes_fts.tags 동기 (final review fix)', () => {
const repo = new NoteRepository(db);
const existing = repo.create({ rawText: 'v1' });
const r = repo.importNote({
id: existing.id,
rawText: 'imported v2 with tags',
createdAt: '2026-04-01T00:00:00Z',
updatedAt: '2026-04-01T00:00:00Z',
aiTitle: null,
aiSummary: null,
titleEditedByUser: false,
summaryEditedByUser: false,
aiProvider: null,
aiGeneratedAt: null,
userIntent: null,
intentPromptedAt: null,
tags: [{ name: '결재', source: 'user' }]
});
expect(r.status).toBe('forked');
expect(r.id).not.toBe(existing.id);
const row = db
.prepare(`SELECT tags FROM notes_fts WHERE note_id=?`)
.get(r.id) as { tags: string };
expect(row.tags).toBe('결재');
});
});

View File

@@ -0,0 +1,171 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import Database from 'better-sqlite3';
import { runMigrations } from '../../src/main/db/migrations/index.js';
import { NoteRepository } from '../../src/main/repository/NoteRepository.js';
describe('NoteRepository — note_revisions', () => {
let db: Database.Database;
let repo: NoteRepository;
beforeEach(() => {
db = new Database(':memory:');
db.pragma('foreign_keys = ON');
runMigrations(db);
repo = new NoteRepository(db);
});
afterEach(() => { db.close(); });
describe('updateRawText', () => {
it('notes.raw_text 갱신 + 새 user revision INSERT (single transaction)', () => {
const { id } = repo.create({ rawText: 'v1' });
const t = new Date('2026-05-10T00:00:00Z');
repo.updateRawText(id, 'v2', t);
const note = db.prepare(`SELECT raw_text, updated_at FROM notes WHERE id=?`).get(id) as {
raw_text: string;
updated_at: string;
};
expect(note.raw_text).toBe('v2');
expect(note.updated_at).toBe('2026-05-10T00:00:00.000Z');
const revs = db
.prepare(`SELECT raw_text, edited_by, edited_at FROM note_revisions WHERE note_id=? ORDER BY rev_id ASC`)
.all(id) as Array<{ raw_text: string; edited_by: string; edited_at: string }>;
expect(revs).toHaveLength(2); // capture + user
expect(revs.at(0)!.edited_by).toBe('capture');
expect(revs.at(0)!.raw_text).toBe('v1');
expect(revs.at(1)!.edited_by).toBe('user');
expect(revs.at(1)!.raw_text).toBe('v2');
expect(revs.at(1)!.edited_at).toBe('2026-05-10T00:00:00.000Z');
});
it('atomic: 두 번 호출 시 두 revision 모두 누적 (chain history)', () => {
const { id } = repo.create({ rawText: 'v1' });
repo.updateRawText(id, 'v2', new Date('2026-05-10T00:00:00Z'));
repo.updateRawText(id, 'v3', new Date('2026-05-11T00:00:00Z'));
const revs = db
.prepare(`SELECT raw_text FROM note_revisions WHERE note_id=? ORDER BY rev_id ASC`)
.all(id) as Array<{ raw_text: string }>;
expect(revs.map((r) => r.raw_text)).toEqual(['v1', 'v2', 'v3']);
});
});
describe('listRevisions', () => {
it('DESC 순서 + edited_by + camelCase hydrate', () => {
const { id } = repo.create({ rawText: 'v1' });
repo.updateRawText(id, 'v2', new Date('2026-05-10T00:00:00Z'));
repo.updateRawText(id, 'v3', new Date('2026-05-11T00:00:00Z'));
const revs = repo.listRevisions(id);
expect(revs).toHaveLength(3);
expect(revs.at(0)!.rawText).toBe('v3');
expect(revs.at(0)!.editedBy).toBe('user');
expect(revs.at(1)!.rawText).toBe('v2');
expect(revs.at(1)!.editedBy).toBe('user');
expect(revs.at(2)!.rawText).toBe('v1');
expect(revs.at(2)!.editedBy).toBe('capture');
expect(typeof revs.at(0)!.revId).toBe('number');
expect(revs.at(0)!.noteId).toBe(id);
expect(revs.at(0)!.editedAt).toBe('2026-05-11T00:00:00.000Z');
});
});
describe('restoreRevision', () => {
it('옛 raw_text 를 새 user revision 으로 INSERT + notes.raw_text 갱신', () => {
const { id } = repo.create({ rawText: 'v1' });
repo.updateRawText(id, 'v2', new Date('2026-05-10T00:00:00Z'));
repo.updateRawText(id, 'v3', new Date('2026-05-11T00:00:00Z'));
const revs = repo.listRevisions(id);
const v1 = revs.find((r) => r.rawText === 'v1');
expect(v1).toBeDefined();
repo.restoreRevision(id, v1!.revId, new Date('2026-05-12T00:00:00Z'));
const note = db.prepare(`SELECT raw_text FROM notes WHERE id=?`).get(id) as { raw_text: string };
expect(note.raw_text).toBe('v1');
const after = repo.listRevisions(id);
expect(after).toHaveLength(4); // v1(capture) + v2 + v3 + v1 restored (user)
expect(after.at(0)!.rawText).toBe('v1');
expect(after.at(0)!.editedBy).toBe('user');
expect(after.at(0)!.editedAt).toBe('2026-05-12T00:00:00.000Z');
});
it('존재하지 않는 revId 는 throw', () => {
const { id } = repo.create({ rawText: 'v1' });
expect(() => repo.restoreRevision(id, 999_999, new Date())).toThrow(/not found/);
});
});
describe('AiWorker source 회귀', () => {
it('updateRawText 후 findById 가 latest raw_text 반환 (옛 revision 미노출)', () => {
const { id } = repo.create({ rawText: 'v1' });
repo.updateRawText(id, 'v2 corrected', new Date('2026-05-10T00:00:00Z'));
const note = repo.findById(id);
expect(note?.rawText).toBe('v2 corrected');
});
});
describe('importNote — capture revision 생성 (final review 보강)', () => {
it('insert path: imported note 가 capture revision (createdAt = edited_at) 을 함께 갖는다', () => {
const r = repo.importNote({
id: '00000000-0000-0000-0000-000000000001',
rawText: 'imported text',
createdAt: '2026-04-01T00:00:00Z',
updatedAt: '2026-04-02T00:00:00Z',
aiTitle: 't',
aiSummary: 's',
titleEditedByUser: false,
summaryEditedByUser: false,
aiProvider: 'p',
aiGeneratedAt: '2026-04-02T00:00:00Z',
userIntent: null,
intentPromptedAt: null,
tags: []
});
expect(r.status).toBe('inserted');
const revs = repo.listRevisions(r.id);
expect(revs).toHaveLength(1);
expect(revs[0]!.rawText).toBe('imported text');
expect(revs[0]!.editedBy).toBe('capture');
expect(revs[0]!.editedAt).toBe('2026-04-01T00:00:00Z');
});
it('fork path: id 충돌 시 fresh uuidv7 + 새 capture revision (옛 노트 revision 보존)', () => {
// 기존 노트 (capture 'v1' revision 자동 생성됨)
const existing = repo.create({ rawText: 'v1' });
// 동일 id 로 다른 raw_text 를 import → fork
const r = repo.importNote({
id: existing.id,
rawText: 'imported v2',
createdAt: '2026-04-01T00:00:00Z',
updatedAt: '2026-04-02T00:00:00Z',
aiTitle: null,
aiSummary: null,
titleEditedByUser: false,
summaryEditedByUser: false,
aiProvider: null,
aiGeneratedAt: null,
userIntent: null,
intentPromptedAt: null,
tags: []
});
expect(r.status).toBe('forked');
expect(r.id).not.toBe(existing.id);
// forked 노트에 capture revision
const forkRevs = repo.listRevisions(r.id);
expect(forkRevs).toHaveLength(1);
expect(forkRevs[0]!.rawText).toBe('imported v2');
expect(forkRevs[0]!.editedBy).toBe('capture');
// 기존 노트의 revision 은 그대로 보존
const existingRevs = repo.listRevisions(existing.id);
expect(existingRevs).toHaveLength(1);
expect(existingRevs[0]!.rawText).toBe('v1');
});
});
});

View File

@@ -0,0 +1,46 @@
// @vitest-environment jsdom
import { describe, it, expect, beforeEach, vi } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { render, cleanup } from '@testing-library/react';
vi.mock('../../src/renderer/inbox/api.js', () => ({
inboxApi: {
ollamaRecheck: vi.fn(async () => ({ ok: true }))
}
}));
import { OllamaBanner } from '../../src/renderer/inbox/components/OllamaBanner';
import { useInbox } from '../../src/renderer/inbox/store';
describe('OllamaBanner — ai_enabled (v0.2.9 Cut B Task 14)', () => {
beforeEach(() => {
cleanup();
});
it('renders nothing when ai_enabled=false (even if ollama unreachable)', () => {
useInbox.setState({
ai_enabled: false,
ollamaStatus: { ok: false, reason: 'unreachable' }
} as Partial<ReturnType<typeof useInbox.getState>>);
const { container } = render(<OllamaBanner />);
expect(container).toBeEmptyDOMElement();
});
it('renders nothing when ai_enabled=true and ollama ok', () => {
useInbox.setState({
ai_enabled: true,
ollamaStatus: { ok: true }
} as Partial<ReturnType<typeof useInbox.getState>>);
const { container } = render(<OllamaBanner />);
expect(container).toBeEmptyDOMElement();
});
it('renders banner when ai_enabled=true and ollama not ok', () => {
useInbox.setState({
ai_enabled: true,
ollamaStatus: { ok: false, reason: 'unreachable' }
} as Partial<ReturnType<typeof useInbox.getState>>);
const { container } = render(<OllamaBanner />);
expect(container).not.toBeEmptyDOMElement();
});
});

View File

@@ -0,0 +1,58 @@
// @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';
const { mockSetAi, mockSetCompleted } = vi.hoisted(() => ({
mockSetAi: vi.fn(async () => ({ ok: true as const })),
mockSetCompleted: vi.fn(async () => ({ ok: true as const }))
}));
vi.mock('../../src/renderer/inbox/api.js', () => ({
inboxApi: { setAiEnabled: mockSetAi, setOnboardingCompleted: mockSetCompleted }
}));
import { OnboardingWizard } from '../../src/renderer/inbox/components/OnboardingWizard';
describe('OnboardingWizard', () => {
beforeEach(() => {
vi.clearAllMocks();
cleanup();
});
it('renders 3 buttons + 설치 가이드 link', () => {
render(<OnboardingWizard onClose={vi.fn()} />);
expect(screen.getByRole('button', { name: /AI 자동 처리 사용/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /원문만 저장/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /나중에 설정/ })).toBeInTheDocument();
expect(screen.getByRole('link', { name: /ollama\.com/ })).toBeInTheDocument();
});
it('"AI 사용" → setAiEnabled(true) + setOnboardingCompleted(true) + onClose', async () => {
const onClose = vi.fn();
render(<OnboardingWizard onClose={onClose} />);
fireEvent.click(screen.getByRole('button', { name: /AI 자동 처리 사용/ }));
await waitFor(() => {
expect(mockSetAi).toHaveBeenCalledWith(true);
expect(mockSetCompleted).toHaveBeenCalledWith(true);
expect(onClose).toHaveBeenCalled();
});
});
it('"원문만" → setAiEnabled(false) + setOnboardingCompleted(true)', async () => {
render(<OnboardingWizard onClose={vi.fn()} />);
fireEvent.click(screen.getByRole('button', { name: /원문만 저장/ }));
await waitFor(() => {
expect(mockSetAi).toHaveBeenCalledWith(false);
expect(mockSetCompleted).toHaveBeenCalledWith(true);
});
});
it('"나중에" → setOnboardingCompleted(true) only (no setAiEnabled)', async () => {
render(<OnboardingWizard onClose={vi.fn()} />);
fireEvent.click(screen.getByRole('button', { name: /나중에 설정/ }));
await waitFor(() => {
expect(mockSetCompleted).toHaveBeenCalledWith(true);
expect(mockSetAi).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,64 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { render, screen, cleanup } from '@testing-library/react';
import React from 'react';
const baseState = {
reviewData: {
totalCount: 12,
recentNotes: [],
tagCounts: [{ tag: '회의', count: 5 }, { tag: '결재', count: 3 }],
dueProgress: { total: 10, passed: 4, pending: 6 }
}
};
vi.mock('../../src/renderer/inbox/api.js', () => ({
inboxApi: {
openMedia: vi.fn(),
deleteNote: vi.fn(),
restoreNote: vi.fn(),
permanentDeleteNote: vi.fn(),
updateAiFields: vi.fn(),
setDueDate: vi.fn(),
setIntent: vi.fn(),
dismissIntent: vi.fn(),
setStatus: vi.fn(async () => ({ ok: true as const })),
classifyStatus: vi.fn(async () => ({ recommended: 'archived' as const, rationale: 'stub' })),
updateRawText: vi.fn(async () => ({ ok: true as const })),
listRevisions: vi.fn(async () => []),
getRevision: vi.fn()
}
}));
vi.mock('../../src/renderer/inbox/store.js', () => ({
useInbox: Object.assign(
(selector?: (s: typeof baseState) => unknown) => (selector ? selector(baseState) : baseState),
{ getState: () => baseState }
)
}));
import { ReviewView } from '../../src/renderer/inbox/components/ReviewView';
describe('ReviewView', () => {
beforeEach(() => { cleanup(); });
it('daily — 라벨 + totalCount + tagBar + dueProgress 렌더', () => {
render(<ReviewView period="daily" />);
expect(screen.getByText(/일간/)).toBeInTheDocument();
expect(screen.getByText(/총.*12건/)).toBeInTheDocument();
expect(screen.getByText('회의')).toBeInTheDocument();
expect(screen.getByText('결재')).toBeInTheDocument();
expect(screen.getByText(/4.*\/.*10/)).toBeInTheDocument();
});
it('weekly — 라벨 weekly', () => {
render(<ReviewView period="weekly" />);
expect(screen.getByText(/주간/)).toBeInTheDocument();
});
it('monthly — 라벨 monthly', () => {
render(<ReviewView period="monthly" />);
expect(screen.getByText(/월간/)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,64 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
import React from 'react';
const { mockListRevisions, mockRestoreRevision } = vi.hoisted(() => ({
mockListRevisions: vi.fn(),
mockRestoreRevision: vi.fn()
}));
vi.mock('../../src/renderer/inbox/api.js', () => ({
inboxApi: {
listRevisions: mockListRevisions,
restoreRevision: mockRestoreRevision
}
}));
import { RevisionHistoryModal } from '../../src/renderer/inbox/components/RevisionHistoryModal';
describe('RevisionHistoryModal', () => {
beforeEach(() => {
vi.clearAllMocks();
cleanup();
mockListRevisions.mockResolvedValue([
{ revId: 3, noteId: 'a', rawText: 'v3', editedAt: '2026-05-11T00:00:00Z', editedBy: 'user' },
{ revId: 2, noteId: 'a', rawText: 'v2', editedAt: '2026-05-10T00:00:00Z', editedBy: 'user' },
{ revId: 1, noteId: 'a', rawText: 'v1', editedAt: '2026-05-01T00:00:00Z', editedBy: 'capture' }
]);
mockRestoreRevision.mockResolvedValue({ ok: true });
});
it('open 시 listRevisions 호출 + 목록 표시 (capture/user 라벨)', async () => {
render(<RevisionHistoryModal noteId="a" onClose={() => {}} onRestored={() => {}} />);
await waitFor(() => {
expect(screen.getByText('v3')).toBeInTheDocument();
expect(screen.getByText('v2')).toBeInTheDocument();
expect(screen.getByText('v1')).toBeInTheDocument();
});
expect(screen.getByText(/캡처/)).toBeInTheDocument();
expect(screen.getAllByText(/사용자/).length).toBeGreaterThanOrEqual(1);
});
it('회수 클릭 → confirm OK → restoreRevision + onRestored 호출 + onClose', async () => {
const onRestored = vi.fn();
const onClose = vi.fn();
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
render(<RevisionHistoryModal noteId="a" onClose={onClose} onRestored={onRestored} />);
await waitFor(() => screen.getByText('v1'));
const buttons = screen.getAllByRole('button', { name: /회수/ });
// last button = oldest (v1)
const lastButton = buttons[buttons.length - 1];
if (lastButton === undefined) throw new Error('no 회수 button');
fireEvent.click(lastButton);
await waitFor(() => {
expect(mockRestoreRevision).toHaveBeenCalledWith('a', 1);
});
expect(onRestored).toHaveBeenCalledWith('v1');
expect(onClose).toHaveBeenCalled();
confirmSpy.mockRestore();
});
});

View File

@@ -0,0 +1,47 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
import React from 'react';
const { mockSearchNotes, mockClearSearch } = vi.hoisted(() => ({
mockSearchNotes: vi.fn(),
mockClearSearch: vi.fn()
}));
vi.mock('../../src/renderer/inbox/store.js', () => ({
useInbox: Object.assign(
(selector?: (s: { searchQuery: string }) => unknown) => {
const state = { searchQuery: '' };
return selector ? selector(state) : state;
},
{ getState: () => ({ searchNotes: mockSearchNotes, clearSearch: mockClearSearch }) }
)
}));
import { SearchBox } from '../../src/renderer/inbox/components/SearchBox';
describe('SearchBox', () => {
beforeEach(() => {
vi.clearAllMocks();
cleanup();
vi.useFakeTimers();
});
it('타이핑 → 200ms debounce 후 searchNotes 호출', () => {
render(<SearchBox />);
const input = screen.getByRole('searchbox');
fireEvent.change(input, { target: { value: '회의' } });
expect(mockSearchNotes).not.toHaveBeenCalled();
vi.advanceTimersByTime(200);
expect(mockSearchNotes).toHaveBeenCalledWith('회의');
});
it('빈 값 → clearSearch 호출', () => {
render(<SearchBox />);
const input = screen.getByRole('searchbox');
fireEvent.change(input, { target: { value: '' } });
vi.advanceTimersByTime(200);
expect(mockClearSearch).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,80 @@
// @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),
// v0.2.9 Cut B Task 15-16 — AiProviderSection 의 토글 + disabled 메모 prompt.
getSettings: vi.fn(async () => ({ ai_enabled: true, onboarding_completed: true })),
setAiEnabled: vi.fn(async () => ({ ok: true as const })),
setOnboardingCompleted: vi.fn(async () => ({ ok: true as const })),
getDisabledCount: vi.fn(async () => 0),
enqueueDisabled: vi.fn(async () => ({ count: 0 }))
}
}));
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,109 @@
import { describe, it, expect, vi } from 'vitest';
import { classifyStatus } from '../../src/main/ai/classifyStatus';
import type { InferenceProvider } from '../../src/main/ai/InferenceProvider';
function makeProvider(generateRaw?: (p: string) => Promise<string>): InferenceProvider {
return {
name: 'mock',
generate: vi.fn(async () => {
throw new Error('not used');
}),
healthCheck: vi.fn(async () => ({ ok: true })),
...(generateRaw !== undefined ? { generateRaw } : {})
} as InferenceProvider;
}
describe('classifyStatus', () => {
it('parses recommended status and rationale from valid AI response', async () => {
const provider = makeProvider(
vi.fn(async () => '{"recommended":"completed","rationale":"처리됨"}')
);
const r = await classifyStatus({
provider,
rawText: 't',
summary: '',
reason: '결재 끝'
});
expect(r.recommended).toBe('completed');
expect(r.rationale).toBe('처리됨');
});
it('falls back to archived on parse failure (invalid JSON)', async () => {
const provider = makeProvider(vi.fn(async () => 'not json'));
const r = await classifyStatus({
provider,
rawText: 't',
summary: '',
reason: 'r'
});
expect(r.recommended).toBe('archived');
expect(r.rationale).toMatch(/판단 실패|보관/);
});
it('falls back to archived on invalid status value', async () => {
const provider = makeProvider(
vi.fn(async () => '{"recommended":"unknown","rationale":"x"}')
);
const r = await classifyStatus({
provider,
rawText: 't',
summary: '',
reason: 'r'
});
expect(r.recommended).toBe('archived');
});
it('handles provider throw', async () => {
const provider = makeProvider(
vi.fn(async () => {
throw new Error('network');
})
);
const r = await classifyStatus({
provider,
rawText: 't',
summary: '',
reason: 'r'
});
expect(r.recommended).toBe('archived');
expect(r.rationale).toMatch(/판단 실패|보관/);
});
it('falls back when provider lacks generateRaw method', async () => {
const provider = makeProvider();
const r = await classifyStatus({
provider,
rawText: 't',
summary: '',
reason: 'r'
});
expect(r.recommended).toBe('archived');
expect(r.rationale).toMatch(/판단 실패|보관/);
});
it('substitutes empty inputs with placeholder text in prompt', async () => {
const generateRaw = vi.fn(
async (_p: string) => '{"recommended":"archived","rationale":"ok"}'
);
const provider = makeProvider(generateRaw);
await classifyStatus({ provider, rawText: '', summary: '', reason: '' });
const prompt = generateRaw.mock.calls[0]?.[0] ?? '';
expect(prompt).toContain('(빈 메모)');
expect(prompt).toContain('(요약 없음)');
expect(prompt).toContain('(사유 없음)');
});
it('rationale defaults to empty string when missing/non-string', async () => {
const provider = makeProvider(
vi.fn(async () => '{"recommended":"completed"}')
);
const r = await classifyStatus({
provider,
rawText: 't',
summary: '',
reason: 'r'
});
expect(r.recommended).toBe('completed');
expect(r.rationale).toBe('');
});
});

View File

@@ -0,0 +1,34 @@
import { describe, it, expect } from 'vitest';
import { sanitizeFtsQuery, computeCutoff } from '../../src/main/repository/ftsHelpers.js';
describe('sanitizeFtsQuery', () => {
it('strips FTS5 special chars', () => {
expect(sanitizeFtsQuery('"기획" *회의*')).toBe('기획 회의');
expect(sanitizeFtsQuery('foo: (bar)')).toBe('foo bar');
});
it('keeps Korean + alphanumeric tokens', () => {
expect(sanitizeFtsQuery('회의 결재 v2')).toBe('회의 결재 v2');
});
it('collapses whitespace', () => {
expect(sanitizeFtsQuery(' 회의 ')).toBe('회의');
});
it('returns empty string for whitespace-only', () => {
expect(sanitizeFtsQuery(' ')).toBe('');
});
});
describe('computeCutoff', () => {
// KST = UTC+9. KST 자정 = UTC 전날 15:00.
it('daily — KST 오늘 자정 ISO', () => {
const now = new Date('2026-05-10T05:30:00Z'); // KST 14:30
expect(computeCutoff('daily', now)).toBe('2026-05-09T15:00:00.000Z');
});
it('weekly — 7일 전 KST 자정', () => {
const now = new Date('2026-05-10T05:30:00Z');
expect(computeCutoff('weekly', now)).toBe('2026-05-02T15:00:00.000Z');
});
it('monthly — 30일 전 KST 자정', () => {
const now = new Date('2026-05-10T05:30:00Z');
expect(computeCutoff('monthly', now)).toBe('2026-04-09T15:00:00.000Z');
});
});

View File

@@ -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,99 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
vi.mock('electron', () => ({
default: {
ipcMain: { handle: vi.fn() }
}
}));
import electron from 'electron';
import { registerInboxApi } from '../../src/main/ipc/inboxApi.js';
import type { InboxIpcDeps } from '../../src/main/ipc/inboxApi.js';
function getHandler(channel: string): (...args: unknown[]) => unknown {
const handle = (electron.ipcMain as unknown as { handle: ReturnType<typeof vi.fn> }).handle;
const call = handle.mock.calls.find((c) => c[0] === channel);
if (!call) throw new Error(`channel ${channel} not registered`);
return call[1] as (...args: unknown[]) => unknown;
}
function makeDeps(overrides: Partial<InboxIpcDeps> = {}): InboxIpcDeps {
const repo = {
updateRawText: vi.fn(),
listRevisions: vi.fn(() => []),
restoreRevision: vi.fn(),
findById: vi.fn(),
list: vi.fn(),
listByStatus: vi.fn(),
countByStatus: vi.fn(() => 0),
countByAiStatus: vi.fn(() => 0),
countTrashed: vi.fn(() => 0),
countFailed: vi.fn(() => 0),
listTrashed: vi.fn(() => []),
setStatus: vi.fn(),
requeueDisabled: vi.fn(() => 0),
getAllPendingJobs: vi.fn(() => []),
getPendingCount: vi.fn(() => 0),
countToday: vi.fn(() => 0)
} as unknown as InboxIpcDeps['repo'];
return {
repo,
continuity: { get: vi.fn() } as unknown as InboxIpcDeps['continuity'],
capture: {} as InboxIpcDeps['capture'],
health: {} as InboxIpcDeps['health'],
intent: {} as InboxIpcDeps['intent'],
getInboxWindow: () => null,
settings: {} as InboxIpcDeps['settings'],
providerHolder: {} as InboxIpcDeps['providerHolder'],
paths: { profileDir: '/tmp' },
...overrides
};
}
describe('inboxApi revisions IPC', () => {
beforeEach(() => {
(electron.ipcMain as unknown as { handle: ReturnType<typeof vi.fn> }).handle.mockClear();
});
it('inbox:update-raw-text — repo.updateRawText 호출 + ok:true', async () => {
const deps = makeDeps();
registerInboxApi(deps);
const h = getHandler('inbox:update-raw-text');
const r = await h({}, 'note-1', 'new text');
expect(deps.repo.updateRawText).toHaveBeenCalledWith('note-1', 'new text');
expect(r).toEqual({ ok: true });
});
it('inbox:update-raw-text — 빈 문자열 reject', async () => {
const deps = makeDeps();
registerInboxApi(deps);
const h = getHandler('inbox:update-raw-text');
const r = await h({}, 'note-1', ' ');
expect(deps.repo.updateRawText).not.toHaveBeenCalled();
expect(r).toEqual({ ok: false, reason: 'empty' });
});
it('inbox:list-revisions — repo.listRevisions 결과 반환', async () => {
const deps = makeDeps();
(deps.repo.listRevisions as ReturnType<typeof vi.fn>).mockReturnValue([
{ revId: 1, noteId: 'a', rawText: 'v1', editedAt: 't1', editedBy: 'capture' }
]);
registerInboxApi(deps);
const h = getHandler('inbox:list-revisions');
const r = await h({}, 'a');
expect(r).toEqual([
{ revId: 1, noteId: 'a', rawText: 'v1', editedAt: 't1', editedBy: 'capture' }
]);
});
it('inbox:restore-revision — repo throw 시 ok:false', async () => {
const deps = makeDeps();
(deps.repo.restoreRevision as ReturnType<typeof vi.fn>).mockImplementation(() => {
throw new Error('revision 99 not found for note a');
});
registerInboxApi(deps);
const h = getHandler('inbox:restore-revision');
const r = await h({}, 'a', 99);
expect(r).toEqual({ ok: false, reason: 'revision 99 not found for note a' });
});
});

View File

@@ -0,0 +1,84 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
vi.mock('electron', () => ({ default: { ipcMain: { handle: vi.fn() } } }));
import electron from 'electron';
import { registerInboxApi } from '../../src/main/ipc/inboxApi.js';
import type { InboxIpcDeps } from '../../src/main/ipc/inboxApi.js';
function getHandler(channel: string): (...args: unknown[]) => unknown {
const handle = (electron.ipcMain as unknown as { handle: ReturnType<typeof vi.fn> }).handle;
const call = handle.mock.calls.find((c) => c[0] === channel);
if (!call) throw new Error(`channel ${channel} not registered`);
return call[1] as (...args: unknown[]) => unknown;
}
function makeDeps(overrides: Partial<InboxIpcDeps> = {}): InboxIpcDeps {
const repo = {
search: vi.fn(() => []),
reviewAggregate: vi.fn(() => ({ totalCount: 0, recentNotes: [], tagCounts: [], dueProgress: { total: 0, passed: 0, pending: 0 } })),
list: vi.fn(),
listByStatus: vi.fn(),
countByStatus: vi.fn(() => 0),
countByAiStatus: vi.fn(() => 0),
countTrashed: vi.fn(() => 0),
countFailed: vi.fn(() => 0),
listTrashed: vi.fn(() => []),
setStatus: vi.fn(),
requeueDisabled: vi.fn(() => 0),
getAllPendingJobs: vi.fn(() => []),
getPendingCount: vi.fn(() => 0),
countToday: vi.fn(() => 0),
findById: vi.fn(),
listRevisions: vi.fn(() => []),
restoreRevision: vi.fn(),
updateRawText: vi.fn()
} as unknown as InboxIpcDeps['repo'];
return {
repo,
continuity: { get: vi.fn() } as unknown as InboxIpcDeps['continuity'],
capture: {} as InboxIpcDeps['capture'],
health: {} as InboxIpcDeps['health'],
intent: {} as InboxIpcDeps['intent'],
getInboxWindow: () => null,
settings: {} as InboxIpcDeps['settings'],
providerHolder: {} as InboxIpcDeps['providerHolder'],
paths: { profileDir: '/tmp' },
...overrides
};
}
describe('inboxApi search/review IPC', () => {
beforeEach(() => {
(electron.ipcMain as unknown as { handle: ReturnType<typeof vi.fn> }).handle.mockClear();
});
it('inbox:search — repo.search 호출 결과 반환', async () => {
const deps = makeDeps();
(deps.repo.search as ReturnType<typeof vi.fn>).mockReturnValue([{ id: 'a' }]);
registerInboxApi(deps);
const h = getHandler('inbox:search');
const r = await h({}, '회의', { status: 'active', limit: 10 });
expect(deps.repo.search).toHaveBeenCalledWith('회의', { status: 'active', limit: 10 });
expect(r).toEqual([{ id: 'a' }]);
});
it('inbox:review-aggregate — repo.reviewAggregate 호출 결과 반환', async () => {
const deps = makeDeps();
const fake = { totalCount: 5, recentNotes: [], tagCounts: [{ tag: 'x', count: 2 }], dueProgress: { total: 1, passed: 1, pending: 0 } };
(deps.repo.reviewAggregate as ReturnType<typeof vi.fn>).mockReturnValue(fake);
registerInboxApi(deps);
const h = getHandler('inbox:review-aggregate');
const r = await h({}, 'weekly');
expect(deps.repo.reviewAggregate).toHaveBeenCalledWith('weekly');
expect(r).toEqual(fake);
});
it('inbox:review-aggregate — 잘못된 period reject', async () => {
const deps = makeDeps();
registerInboxApi(deps);
const h = getHandler('inbox:review-aggregate');
const r = await h({}, 'yearly');
expect(deps.repo.reviewAggregate).not.toHaveBeenCalled();
expect(r).toMatchObject({ totalCount: 0 });
});
});

View File

@@ -0,0 +1,148 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { handlers, mockSetStatus, mockFindById, mockGenerateRaw } = vi.hoisted(() => ({
handlers: {} as Record<string, (...args: unknown[]) => unknown>,
mockSetStatus: vi.fn(),
mockFindById: vi.fn(),
mockGenerateRaw: vi.fn()
}));
vi.mock('electron', () => ({
default: {
ipcMain: {
handle: (ch: string, fn: (...args: unknown[]) => unknown) => {
handlers[ch] = fn;
}
},
dialog: {},
shell: {}
}
}));
import { registerInboxApi } from '../../src/main/ipc/inboxApi';
function makeDeps(): Parameters<typeof registerInboxApi>[0] {
// Minimal stub — `inbox:set-status` 핸들러는 deps.repo.setStatus 만 참조.
// `ai:classify-status` 는 deps.repo.findById + deps.providerHolder.get() 사용.
const provider = {
name: 'mock',
generate: vi.fn(),
healthCheck: vi.fn(async () => ({ ok: true })),
generateRaw: mockGenerateRaw
};
return {
repo: {
setStatus: mockSetStatus,
findById: mockFindById,
list: vi.fn(),
listByStatus: vi.fn(),
countByStatus: vi.fn(() => 0)
} as never,
continuity: {} as never,
capture: {} as never,
health: {} as never,
intent: {} as never,
getInboxWindow: () => null,
settings: {} as never,
providerHolder: { get: () => provider } as never,
paths: { profileDir: '/profile' }
};
}
describe('inbox:set-status IPC', () => {
beforeEach(() => {
Object.keys(handlers).forEach((k) => delete handlers[k]);
mockSetStatus.mockReset();
});
it('forwards valid status + reason to repo.setStatus', async () => {
registerInboxApi(makeDeps());
const handler = handlers['inbox:set-status'];
if (handler === undefined) throw new Error('handler not registered');
const r = await handler(null, 'n1', 'completed', '결재 끝');
expect(r).toEqual({ ok: true });
expect(mockSetStatus).toHaveBeenCalledWith('n1', 'completed', '결재 끝');
});
it('forwards null reason as-is', async () => {
registerInboxApi(makeDeps());
const handler = handlers['inbox:set-status'];
if (handler === undefined) throw new Error('handler not registered');
const r = await handler(null, 'n1', 'archived', null);
expect(r).toEqual({ ok: true });
expect(mockSetStatus).toHaveBeenCalledWith('n1', 'archived', null);
});
it('rejects invalid status without calling repo', async () => {
registerInboxApi(makeDeps());
const handler = handlers['inbox:set-status'];
if (handler === undefined) throw new Error('handler not registered');
const r = (await handler(null, 'n1', 'invalid', null)) as { ok: boolean; reason?: string };
expect(r.ok).toBe(false);
expect(r.reason).toBe('invalid status');
expect(mockSetStatus).not.toHaveBeenCalled();
});
});
describe('ai:classify-status IPC', () => {
beforeEach(() => {
Object.keys(handlers).forEach((k) => delete handlers[k]);
mockFindById.mockReset();
mockGenerateRaw.mockReset();
});
it('uses classifyStatus with note rawText/summary', async () => {
mockFindById.mockReturnValue({
id: 'n1',
rawText: 'meeting notes',
aiSummary: 's'
});
mockGenerateRaw.mockResolvedValue(
'{"recommended":"completed","rationale":"끝남"}'
);
registerInboxApi(makeDeps());
const handler = handlers['ai:classify-status'];
if (handler === undefined) throw new Error('handler not registered');
const r = (await handler(null, 'n1', '결재')) as {
recommended: string;
rationale: string;
};
expect(r.recommended).toBe('completed');
expect(r.rationale).toBe('끝남');
// prompt 에 rawText / summary / reason 포함
const prompt = mockGenerateRaw.mock.calls[0]?.[0] as string;
expect(prompt).toContain('meeting notes');
expect(prompt).toContain('결재');
});
it('returns archived fallback when note not found', async () => {
mockFindById.mockReturnValue(null);
registerInboxApi(makeDeps());
const handler = handlers['ai:classify-status'];
if (handler === undefined) throw new Error('handler not registered');
const r = (await handler(null, 'missing', '결재')) as {
recommended: string;
rationale: string;
};
expect(r.recommended).toBe('archived');
expect(r.rationale.length).toBeGreaterThan(0);
expect(mockGenerateRaw).not.toHaveBeenCalled();
});
it('returns archived fallback when AI throws', async () => {
mockFindById.mockReturnValue({
id: 'n1',
rawText: 't',
aiSummary: null
});
mockGenerateRaw.mockRejectedValue(new Error('network'));
registerInboxApi(makeDeps());
const handler = handlers['ai:classify-status'];
if (handler === undefined) throw new Error('handler not registered');
const r = (await handler(null, 'n1', 'r')) as {
recommended: string;
rationale: string;
};
expect(r.recommended).toBe('archived');
});
});

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,80 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import Database from 'better-sqlite3';
import { up } from '../../src/main/db/migrations/m004_status.js';
describe('m004 migration — status column', () => {
let db: Database.Database;
beforeEach(() => {
db = new Database(':memory:');
// m003 baseline (notes 테이블 with deleted_at, real schema 따름)
db.exec(`
CREATE TABLE notes (
id TEXT PRIMARY KEY,
raw_text TEXT NOT NULL,
ai_title TEXT,
ai_summary TEXT,
ai_status TEXT NOT NULL
CHECK (ai_status IN ('pending','done','failed')),
ai_error TEXT,
ai_provider TEXT,
ai_generated_at TEXT,
title_edited_by_user INTEGER NOT NULL DEFAULT 0,
summary_edited_by_user INTEGER NOT NULL DEFAULT 0,
user_intent TEXT,
intent_prompted_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
due_date TEXT,
due_date_edited_by_user INTEGER NOT NULL DEFAULT 0,
deleted_at TEXT,
last_recalled_at TEXT,
recall_dismissed_at TEXT
);
INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at, deleted_at)
VALUES ('a', 't1', 'done', '2026-05-01T00:00:00Z', '2026-05-01T00:00:00Z', NULL),
('b', 't2', 'done', '2026-05-01T00:00:00Z', '2026-05-01T00:00:00Z', '2026-05-08T00:00:00Z');
`);
});
afterEach(() => {
db.close();
});
it('adds status / status_changed_at / move_reason columns', () => {
up(db);
const cols = db.prepare(`PRAGMA table_info(notes)`).all() as Array<{ name: string }>;
const names = cols.map((c) => c.name);
expect(names).toContain('status');
expect(names).toContain('status_changed_at');
expect(names).toContain('move_reason');
});
it('default status="active" for non-deleted notes', () => {
up(db);
const a = db.prepare(`SELECT status FROM notes WHERE id=?`).get('a') as { status: string };
expect(a.status).toBe('active');
});
it('migrates deleted_at != NULL to status="trashed" + status_changed_at', () => {
up(db);
const b = db
.prepare(`SELECT status, status_changed_at FROM notes WHERE id=?`)
.get('b') as { status: string; status_changed_at: string };
expect(b.status).toBe('trashed');
expect(b.status_changed_at).toBe('2026-05-08T00:00:00Z');
});
it('move_reason NULL by default', () => {
up(db);
const a = db.prepare(`SELECT move_reason FROM notes WHERE id=?`).get('a') as {
move_reason: string | null;
};
expect(a.move_reason).toBeNull();
});
it('version exported as 4', async () => {
const mod = await import('../../src/main/db/migrations/m004_status.js');
expect(mod.version).toBe(4);
});
});

View File

@@ -0,0 +1,95 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import Database from 'better-sqlite3';
import { up } from '../../src/main/db/migrations/m006_revisions.js';
describe('m006 migration — note_revisions table', () => {
let db: Database.Database;
beforeEach(() => {
db = new Database(':memory:');
db.pragma('foreign_keys = ON');
db.exec(`
CREATE TABLE notes (
id TEXT PRIMARY KEY,
raw_text TEXT NOT NULL,
ai_title TEXT,
ai_summary TEXT,
ai_status TEXT NOT NULL
CHECK (ai_status IN ('pending','done','failed','disabled')),
ai_error TEXT,
ai_provider TEXT,
ai_generated_at TEXT,
title_edited_by_user INTEGER NOT NULL DEFAULT 0,
summary_edited_by_user INTEGER NOT NULL DEFAULT 0,
user_intent TEXT,
intent_prompted_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
due_date TEXT,
due_date_edited_by_user INTEGER NOT NULL DEFAULT 0,
deleted_at TEXT,
last_recalled_at TEXT,
recall_dismissed_at TEXT,
status TEXT NOT NULL DEFAULT 'active',
status_changed_at TEXT,
move_reason TEXT
);
INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at)
VALUES ('a', 'first text', 'done', '2026-05-01T00:00:00Z', '2026-05-01T00:00:00Z'),
('b', 'second text', 'done', '2026-05-02T00:00:00Z', '2026-05-02T00:00:00Z');
`);
});
afterEach(() => { db.close(); });
it('creates note_revisions table with required columns', () => {
up(db);
const cols = db.prepare(`PRAGMA table_info(note_revisions)`).all() as Array<{ name: string }>;
const names = cols.map((c) => c.name);
expect(names).toEqual(
expect.arrayContaining(['rev_id', 'note_id', 'raw_text', 'edited_at', 'edited_by'])
);
});
it('creates idx_note_revisions_note_id index', () => {
up(db);
const idx = db.prepare(`PRAGMA index_list(note_revisions)`).all() as Array<{ name: string }>;
expect(idx.map((i) => i.name)).toContain('idx_note_revisions_note_id');
});
it('cascades on note delete (FK ON DELETE CASCADE)', () => {
up(db);
db.prepare(
`INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by)
VALUES ('a', 'manual rev', '2026-05-03T00:00:00Z', 'user')`
).run();
db.prepare(`DELETE FROM notes WHERE id=?`).run('a');
const rows = db.prepare(`SELECT * FROM note_revisions WHERE note_id=?`).all('a');
expect(rows).toHaveLength(0);
});
it("backfills existing notes as edited_by='capture' revisions", () => {
up(db);
const rows = db
.prepare(`SELECT note_id, raw_text, edited_at, edited_by FROM note_revisions ORDER BY note_id`)
.all() as Array<{ note_id: string; raw_text: string; edited_at: string; edited_by: string }>;
expect(rows).toHaveLength(2);
expect(rows[0]).toEqual({
note_id: 'a',
raw_text: 'first text',
edited_at: '2026-05-01T00:00:00Z',
edited_by: 'capture'
});
expect(rows[1]).toEqual({
note_id: 'b',
raw_text: 'second text',
edited_at: '2026-05-02T00:00:00Z',
edited_by: 'capture'
});
});
it('exports version=6', async () => {
const mod = await import('../../src/main/db/migrations/m006_revisions.js');
expect(mod.version).toBe(6);
});
});

View File

@@ -0,0 +1,96 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import Database from 'better-sqlite3';
import { up } from '../../src/main/db/migrations/m007_fts.js';
describe('m007 migration — notes_fts virtual table + triggers', () => {
let db: Database.Database;
beforeEach(() => {
db = new Database(':memory:');
db.pragma('foreign_keys = ON');
db.exec(`
CREATE TABLE notes (
id TEXT PRIMARY KEY, raw_text TEXT NOT NULL,
ai_title TEXT, ai_summary TEXT,
ai_status TEXT NOT NULL CHECK (ai_status IN ('pending','done','failed','disabled')),
ai_error TEXT, ai_provider TEXT, ai_generated_at TEXT,
title_edited_by_user INTEGER NOT NULL DEFAULT 0,
summary_edited_by_user INTEGER NOT NULL DEFAULT 0,
user_intent TEXT, intent_prompted_at TEXT,
created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
due_date TEXT, due_date_edited_by_user INTEGER NOT NULL DEFAULT 0,
deleted_at TEXT, last_recalled_at TEXT, recall_dismissed_at TEXT,
status TEXT NOT NULL DEFAULT 'active', status_changed_at TEXT, move_reason TEXT
);
CREATE TABLE tags (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE COLLATE NOCASE);
CREATE TABLE note_tags (
note_id TEXT NOT NULL, tag_id INTEGER NOT NULL, source TEXT NOT NULL,
PRIMARY KEY(note_id, tag_id),
FOREIGN KEY(note_id) REFERENCES notes(id) ON DELETE CASCADE,
FOREIGN KEY(tag_id) REFERENCES tags(id) ON DELETE CASCADE
);
INSERT INTO notes (id, raw_text, ai_title, ai_summary, ai_status, created_at, updated_at, status)
VALUES
('a', '오늘 회의 정리', '회의록', '월요일 회의', 'done', '2026-05-01T00:00:00Z', '2026-05-01T00:00:00Z', 'active'),
('b', '예전 메모', '예전 제목', '예전 요약', 'done', '2026-04-01T00:00:00Z', '2026-04-01T00:00:00Z', 'completed'),
('c', '버려진 메모', '버린 제목', '버린 요약', 'done', '2026-03-01T00:00:00Z', '2026-03-01T00:00:00Z', 'trashed');
INSERT INTO tags (id, name) VALUES (1, '기획'), (2, '회의');
INSERT INTO note_tags (note_id, tag_id, source) VALUES ('a', 1, 'ai'), ('a', 2, 'user');
`);
});
afterEach(() => { db.close(); });
it('creates notes_fts virtual table with FTS5 columns', () => {
up(db);
const rows = db.prepare(`SELECT sql FROM sqlite_master WHERE name='notes_fts'`).all() as Array<{ sql: string }>;
expect(rows).toHaveLength(1);
expect(rows[0]!.sql.toLowerCase()).toContain('using fts5');
});
it('backfills active/completed notes; excludes trashed', () => {
up(db);
const rows = db
.prepare(`SELECT note_id, ai_title, tags FROM notes_fts ORDER BY note_id`)
.all() as Array<{ note_id: string; ai_title: string; tags: string }>;
expect(rows.map((r) => r.note_id)).toEqual(['a', 'b']);
const a = rows.find((r) => r.note_id === 'a')!;
expect(a.ai_title).toBe('회의록');
expect(a.tags.split(' ').sort()).toEqual(['기획', '회의']);
const b = rows.find((r) => r.note_id === 'b')!;
expect(b.tags).toBe('');
});
it('AFTER INSERT trigger syncs new note', () => {
up(db);
db.prepare(`INSERT INTO notes (id, raw_text, ai_title, ai_summary, ai_status, created_at, updated_at, status)
VALUES ('d', '새 메모', '새 제목', '새 요약', 'pending', '2026-05-09T00:00:00Z', '2026-05-09T00:00:00Z', 'active')`).run();
const r = db.prepare(`SELECT raw_text, ai_title FROM notes_fts WHERE note_id=?`).get('d') as { raw_text: string; ai_title: string };
expect(r.raw_text).toBe('새 메모');
expect(r.ai_title).toBe('새 제목');
});
it('AFTER UPDATE trigger syncs raw_text + ai_title + ai_summary', () => {
up(db);
db.prepare(`UPDATE notes SET raw_text=?, ai_title=?, ai_summary=?, updated_at=? WHERE id=?`)
.run('수정한 본문', '수정 제목', '수정 요약', '2026-05-10T00:00:00Z', 'a');
const r = db.prepare(`SELECT raw_text, ai_title, ai_summary FROM notes_fts WHERE note_id=?`).get('a') as {
raw_text: string; ai_title: string; ai_summary: string;
};
expect(r.raw_text).toBe('수정한 본문');
expect(r.ai_title).toBe('수정 제목');
expect(r.ai_summary).toBe('수정 요약');
});
it('AFTER DELETE trigger removes FTS row', () => {
up(db);
db.prepare(`DELETE FROM notes WHERE id=?`).run('a');
const r = db.prepare(`SELECT * FROM notes_fts WHERE note_id=?`).all('a');
expect(r).toHaveLength(0);
});
it('exports version=7', async () => {
const mod = await import('../../src/main/db/migrations/m007_fts.js');
expect(mod.version).toBe(7);
});
});

View File

@@ -51,11 +51,11 @@ describe('migration v3 — soft delete columns', () => {
db.close();
});
it('user_version reaches 3', () => {
it('user_version reaches latest (7)', () => {
const db = new Database(':memory:');
runMigrations(db);
const row = db.prepare('PRAGMA user_version').get() as { user_version: number };
expect(row.user_version).toBe(3);
expect(row.user_version).toBe(7);
db.close();
});
@@ -73,3 +73,47 @@ describe('migration v3 — soft delete columns', () => {
db.close();
});
});
describe('migration v5 — ai_status disabled enum', () => {
it("CHECK constraint accepts 'disabled'", () => {
const db = new Database(':memory:');
runMigrations(db);
expect(() => {
db.prepare(
`INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at)
VALUES ('d1', 't', 'disabled', '2026-05-01T00:00:00Z', '2026-05-01T00:00:00Z')`
).run();
}).not.toThrow();
db.close();
});
it('preserves existing notes (status, due_date, deleted_at, recall fields)', () => {
// m004 까지만 적용된 상태에서 데이터 insert 후 m005 까지 마이그레이션 → 데이터 보존 확인.
// runMigrations 가 user_version 으로 idempotent 라 한 번에 5 까지 가지만,
// 본 테스트는 single runMigrations 후 m004 시점에 가까운 row 를 넣고 cols 확인.
const db = new Database(':memory:');
runMigrations(db);
db.prepare(
`INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at, status, due_date, deleted_at)
VALUES ('p1', 'old', 'done', '2026-04-01T00:00:00Z', '2026-04-01T00:00:00Z', 'archived', '2026-05-10', NULL)`
).run();
const row = db.prepare('SELECT status, due_date, ai_status FROM notes WHERE id=?').get('p1') as any;
expect(row.status).toBe('archived');
expect(row.due_date).toBe('2026-05-10');
expect(row.ai_status).toBe('done');
db.close();
});
it('preserves idx_notes_ai_status + idx_notes_created_at + idx_notes_deleted_at', () => {
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 }>;
const names = indexes.map((i) => i.name);
expect(names).toContain('idx_notes_ai_status');
expect(names).toContain('idx_notes_created_at');
expect(names).toContain('idx_notes_deleted_at');
db.close();
});
});

View File

@@ -0,0 +1,98 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { handlers, mockApp, mockCollectAutostartState } = vi.hoisted(() => ({
handlers: {} as Record<string, (...args: unknown[]) => unknown>,
mockApp: {
getLoginItemSettings: vi.fn(),
setLoginItemSettings: vi.fn(),
getVersion: vi.fn(() => '0.2.7'),
getPath: vi.fn(() => '/profile')
},
mockCollectAutostartState: vi.fn()
}));
vi.mock('electron', () => ({
default: {
ipcMain: {
handle: (ch: string, fn: (...args: unknown[]) => unknown) => {
handlers[ch] = fn;
}
},
app: mockApp,
dialog: {},
Notification: vi.fn(function (this: unknown) {
Object.assign(this as object, { show: vi.fn() });
}),
shell: { openPath: vi.fn() },
clipboard: { writeText: vi.fn() }
}
}));
vi.mock('../../src/main/services/AutostartDiagnostic', () => ({
collectAutostartState: mockCollectAutostartState
}));
vi.mock('../../src/main/windows/inboxWindow.js', () => ({
getInboxWindow: vi.fn(() => null)
}));
import { registerSettingsApi } from '../../src/main/ipc/settingsApi';
describe('settingsApi — autostart IPC', () => {
beforeEach(() => {
Object.keys(handlers).forEach((k) => delete handlers[k]);
mockApp.getLoginItemSettings.mockReset();
mockApp.setLoginItemSettings.mockReset();
mockCollectAutostartState.mockReset();
});
it('settings:autostart-state returns AutostartState wrapped with openAtLogin', async () => {
mockCollectAutostartState.mockResolvedValue({
withArgs: { openAtLogin: true, executableWillLaunchAtLogin: true },
noArgs: { openAtLogin: false, executableWillLaunchAtLogin: true },
execPath: '/path/to/exe'
});
registerSettingsApi();
const r = await handlers['settings:autostart-state']!() as {
openAtLogin: boolean;
diagnostic: { withArgs: { openAtLogin: boolean } };
};
expect(r.openAtLogin).toBe(true);
expect(r.diagnostic.withArgs.openAtLogin).toBe(true);
expect(r.diagnostic).toHaveProperty('noArgs');
expect(r.diagnostic).toHaveProperty('execPath');
});
it('settings:autostart-set calls setLoginItemSettings + returns diagnostic', async () => {
mockCollectAutostartState.mockResolvedValue({
withArgs: { openAtLogin: false, executableWillLaunchAtLogin: false },
noArgs: { openAtLogin: false, executableWillLaunchAtLogin: false },
execPath: '/path/to/exe'
});
registerSettingsApi();
const r = await handlers['settings:autostart-set']!({}, false) as {
openAtLogin: boolean;
diagnostic: { withArgs: { openAtLogin: boolean } };
};
expect(mockApp.setLoginItemSettings).toHaveBeenCalledWith({ openAtLogin: false, args: ['--hidden'] });
expect(r.openAtLogin).toBe(false);
expect(r.diagnostic.withArgs.openAtLogin).toBe(false);
});
it('Task 22 — old channels removed', async () => {
mockCollectAutostartState.mockResolvedValue({
withArgs: { openAtLogin: false, executableWillLaunchAtLogin: false },
noArgs: { openAtLogin: false, executableWillLaunchAtLogin: false },
execPath: '/path/to/exe'
});
registerSettingsApi();
expect(handlers['settings:get-autostart']).toBeUndefined();
expect(handlers['settings:set-autostart']).toBeUndefined();
});
});

View File

@@ -32,6 +32,7 @@ const noteStub = (id: string): Note => ({
userIntent: null, intentPromptedAt: null,
dueDate: '2026-04-20', dueDateEditedByUser: false,
deletedAt: null, lastRecalledAt: null, recallDismissedAt: null,
status: 'active', statusChangedAt: null, moveReason: null,
createdAt: '2026-05-01T00:00:00Z', updatedAt: '2026-05-01T00:00:00Z',
tags: [], media: []
});

View File

@@ -37,7 +37,8 @@ const note = (id: string): Note => ({
dueDate: null, dueDateEditedByUser: false,
userIntent: null, intentPromptedAt: null,
createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
deletedAt: null, lastRecalledAt: null, recallDismissedAt: null
deletedAt: null, lastRecalledAt: null, recallDismissedAt: null,
status: 'active', statusChangedAt: null, moveReason: null
});
describe('store recall actions', () => {

View File

@@ -0,0 +1,50 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../../src/renderer/inbox/api.js', () => ({
inboxApi: {
search: vi.fn(),
reviewAggregate: vi.fn(),
listNotes: vi.fn(() => []),
getContinuity: vi.fn(() => ({ weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null })),
getPendingCount: vi.fn(() => 0),
getOllamaStatus: vi.fn(() => ({ ok: true })),
getTodayCount: vi.fn(() => 0),
getTrashCount: vi.fn(() => 0),
listExpired: vi.fn(() => []),
getFailedCount: vi.fn(() => 0),
listRecallCandidate: vi.fn(() => null),
countsByStatus: vi.fn(() => ({ active: 0, completed: 0, archived: 0, trashed: 0 })),
getSettings: vi.fn(() => ({ ai_enabled: true })),
listByStatus: vi.fn(() => [])
}
}));
import { useInbox } from '../../src/renderer/inbox/store';
import { inboxApi } from '../../src/renderer/inbox/api.js';
describe('store — searchNotes', () => {
beforeEach(() => {
vi.clearAllMocks();
useInbox.setState({ searchQuery: '', searchResults: null, view: 'inbox' });
});
it('빈 query → searchResults null + IPC 미호출', async () => {
await useInbox.getState().searchNotes(' ');
expect(useInbox.getState().searchResults).toBeNull();
expect(inboxApi.search).not.toHaveBeenCalled();
});
it('keyword query → IPC 호출 + searchResults set', async () => {
(inboxApi.search as ReturnType<typeof vi.fn>).mockResolvedValue([{ id: 'a' }]);
await useInbox.getState().searchNotes('회의');
expect(inboxApi.search).toHaveBeenCalledWith('회의', { status: 'active' });
expect(useInbox.getState().searchResults).toEqual([{ id: 'a' }]);
});
it('clearSearch — query + results 모두 초기화', () => {
useInbox.setState({ searchQuery: '회의', searchResults: [{ id: 'a' } as never] });
useInbox.getState().clearSearch();
expect(useInbox.getState().searchQuery).toBe('');
expect(useInbox.getState().searchResults).toBeNull();
});
});

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