56 Commits

Author SHA1 Message Date
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
85 changed files with 14845 additions and 538 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

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

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,204 @@
# 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 마이그레이션 (m005)
```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. 테스트 전략
| 영역 | 단위 |
|---|---|
| m005 마이그레이션 | 기존 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) 회귀 |
**목표**: 단위 490 → 약 505 (+15), 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,228 @@
# 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 마이그레이션 (m006)
```sql
CREATE VIRTUAL TABLE notes_fts USING fts5(
note_id UNINDEXED,
raw_text,
title,
summary,
tags,
tokenize='unicode61'
);
-- 기존 notes 모두 인덱스
INSERT INTO notes_fts (note_id, raw_text, title, summary, tags)
SELECT id, raw_text, title, summary, tags_csv FROM notes WHERE status != 'trashed';
```
`tokenize='unicode61'` — 한국어 partial tokenize 가능 (단어 boundary). 향후 `tokenize='porter unicode61'` 또는 한국어 전용 tokenizer (예: `mecab-ko-fts5`) 검토 가능 — Cut D 는 unicode61 default.
`tags_csv` — notes.tags (JSON array) 를 csv 로 flatten 하여 인덱스 (예: `"기획 회의 결재"`).
### 3-2. Trigger — auto-sync
`notes` INSERT/UPDATE/DELETE 시 `notes_fts` 자동 sync:
```sql
CREATE TRIGGER notes_ai AFTER INSERT ON notes BEGIN
INSERT INTO notes_fts (note_id, raw_text, title, summary, tags)
VALUES (NEW.id, NEW.raw_text, NEW.title, NEW.summary, NEW.tags_csv);
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, title=NEW.title, summary=NEW.summary, tags=NEW.tags_csv
WHERE note_id = NEW.id;
END;
```
Cut C 의 `updateRawText``notes.raw_text` UPDATE → trigger 자동 발동 → FTS5 갱신.
`tags_csv` 는 별도 generated column 또는 NoteRepository 에서 수동 갱신 (zod parse 후 csv join). YAGNI: 수동 갱신.
### 3-3. NoteRepository.search
```ts
search(query: string, opts: { limit?: number; status?: NoteStatus }): Note[] {
const limit = opts.limit ?? 50;
const statusClause = opts.status ? `AND n.status = ?` : '';
const sql = `
SELECT n.* FROM notes n
JOIN notes_fts f ON n.id = f.note_id
WHERE notes_fts MATCH ? ${statusClause}
ORDER BY rank LIMIT ?
`;
const args = opts.status ? [query, opts.status, limit] : [query, limit];
return this.db.prepare(sql).all(...args) as Note[];
}
```
`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): {
totalCount: number;
recentNotes: Note[];
tagCounts: Array<{ tag: string; count: number }>;
dueProgress: { total: number; passed: number; pending: number };
} {
const cutoff = computeCutoff(period, now);
// 단일 transaction 안에 N개 query
const totalCount = this.db.prepare(`SELECT COUNT(*) as c FROM notes WHERE created_at >= ? AND status != 'trashed'`).get(cutoff).c;
const recentNotes = this.db.prepare(`SELECT * FROM notes WHERE created_at >= ? AND status != 'trashed' ORDER BY created_at DESC LIMIT 50`).all(cutoff);
// tagCounts — JSON tags array unnest → group by
// dueProgress — due_date 컬럼 + KST 비교
return { ... };
}
```
`computeCutoff('daily', now)` = KST 자정. `'weekly'` = 7일 전 KST. `'monthly'` = 30일 전 KST.
### 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. 테스트 전략
| 영역 | 단위 |
|---|---|
| m006 마이그레이션 | FTS5 virtual table 생성 + 기존 notes backfill (status != 'trashed' 만) |
| Trigger sync | INSERT/UPDATE/DELETE → notes_fts 자동 sync |
| `search` | 한국어 token 매칭 + status filter |
| inbox header search box | debounce + 빈 값 → 기본 list 복귀 |
| ReviewView 단위 | aggregate query 결과 렌더 |
| `reviewAggregate` | period 별 cutoff 정확 + tag count + due progress |
**목표**: 단위 505 → 약 528 (+23), 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). 정책 일관 |
---
## 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.9",
"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,10 @@ 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';
const migrations = [m001, m002, m003];
const migrations = [m001, m002, m003, m004, m005];
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

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

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,16 @@
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, NoteStatus, NoteTag } from '@shared/types';
import { kstTodayIso } from '../../shared/util/kstDate.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;
@@ -48,15 +55,19 @@ 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);
this.db
.prepare(`INSERT INTO pending_jobs (note_id, attempts, next_run_at)
VALUES (?, 0, ?)`)
.run(id, now);
VALUES (?, ?, ?, ?, ?)`)
.run(id, input.rawText, aiStatus, now, 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 };
@@ -205,6 +216,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 와 별도 경로).
@@ -410,25 +465,101 @@ export class NoteRepository {
.run(now, id);
}
/**
* 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));
}
/**
* 휴지통에서 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 노트는 재처리 안 함 (이미 결과 있음)
});
@@ -656,7 +787,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 +800,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,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,40 @@ 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'),
}
};

View File

@@ -12,7 +12,8 @@ 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';
export function App(): React.ReactElement {
const {
@@ -21,8 +22,27 @@ 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 [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,14 +53,22 @@ 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 (showSettings) return <SettingsPage />;
const showRecovery = continuity.showRecoveryToast && !recoveryDismissed;
const filtered = selectFilteredNotes({ notes, tagFilter });
@@ -59,30 +87,47 @@ 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>
<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); }}
@@ -157,10 +202,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,11 @@
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';
interface Props {
note: Note;
@@ -109,19 +110,23 @@ 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 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 };
@@ -209,6 +214,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 +344,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>
)}
@@ -351,33 +378,115 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
</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);
}}
/>
)}
</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,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

@@ -5,11 +5,26 @@ 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';
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 +36,17 @@ 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;
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>;
@@ -52,6 +73,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 +87,10 @@ export const useInbox = create<InboxState>((set, get) => ({
failedCount: 0,
recallCandidate: null,
recallSnoozeUntilMs: null,
ai_enabled: true,
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 +99,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 +114,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 +163,31 @@ 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);
}
},
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 });

View File

@@ -11,7 +11,10 @@ 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;
@@ -37,6 +40,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 +64,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 +112,50 @@ 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>;
}
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,156 @@
// @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 } = 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'
}))
}));
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
}
}));
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();
});
});
});

View File

@@ -852,3 +852,205 @@ 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);
});
});

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

@@ -51,11 +51,11 @@ describe('migration v3 — soft delete columns', () => {
db.close();
});
it('user_version reaches 3', () => {
it('user_version reaches latest (5)', () => {
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(5);
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,65 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import type { Note } from '@shared/types';
const mockApi = {
listNotes: vi.fn(async () => [] as Note[]),
listTrash: vi.fn(async () => [] as Note[]),
getTrashCount: vi.fn(async () => 0),
getContinuity: vi.fn(async () => ({ weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null })),
getPendingCount: vi.fn(async () => 0),
getOllamaStatus: vi.fn(async () => ({ ok: true })),
getTodayCount: vi.fn(async () => 0),
getFailedCount: vi.fn(async () => 0),
listExpired: vi.fn(async () => [] as Note[]),
listRecallCandidate: vi.fn(async () => null),
restoreNote: vi.fn(async () => {}),
permanentDeleteNote: vi.fn(async () => ({ confirmed: true })),
emptyTrash: vi.fn(async () => ({ confirmed: true, count: 0 })),
trashExpiredBatch: vi.fn(async () => ({ confirmed: true, trashedCount: 0 })),
onNoteUpdated: vi.fn(() => () => {}),
updateAiFields: vi.fn(async () => {}),
setDueDate: vi.fn(async () => {}),
setIntent: vi.fn(async () => {}),
dismissIntent: vi.fn(async () => {}),
ollamaRecheck: vi.fn(async () => ({ ok: true })),
retryAllFailed: vi.fn(async () => {}),
markRecallOpened: vi.fn(async () => {}),
dismissRecall: vi.fn(async () => {}),
emitRecallSnoozed: vi.fn(async () => {})
};
vi.mock('../../src/renderer/inbox/api.js', () => ({ inboxApi: mockApi }));
describe('inbox store — showSettings', () => {
beforeEach(async () => {
const { useInbox } = await import('../../src/renderer/inbox/store.js');
useInbox.setState({
notes: [], trashNotes: [], trashCount: 0, showTrash: false,
loading: false, tagFilter: null, pendingCount: 0, todayCount: 0,
ollamaStatus: { ok: true },
continuity: { weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null },
expiredCandidates: [], expiredSnoozeUntilMs: null,
failedCount: 0, recallCandidate: null, recallSnoozeUntilMs: null,
showSettings: false
});
Object.values(mockApi).forEach((fn) => 'mockClear' in fn && (fn as any).mockClear());
});
it('initial state has showSettings=false', async () => {
const { useInbox } = await import('../../src/renderer/inbox/store.js');
expect(useInbox.getState().showSettings).toBe(false);
});
it('setShowSettings(true) sets state', async () => {
const { useInbox } = await import('../../src/renderer/inbox/store.js');
useInbox.getState().setShowSettings(true);
expect(useInbox.getState().showSettings).toBe(true);
});
it('setShowSettings(false) toggles back', async () => {
const { useInbox } = await import('../../src/renderer/inbox/store.js');
useInbox.getState().setShowSettings(true);
useInbox.getState().setShowSettings(false);
expect(useInbox.getState().showSettings).toBe(false);
});
});

View File

@@ -21,6 +21,9 @@ function sample(id: string, tags: string[]): Note {
deletedAt: null,
lastRecalledAt: null,
recallDismissedAt: null,
status: 'active',
statusChangedAt: null,
moveReason: null,
createdAt: '2026-04-26T00:00:00Z',
updatedAt: '2026-04-26T00:00:00Z',
tags: tags.map((name) => ({ name, source: 'ai' as const })),

View File

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

View File

@@ -0,0 +1,86 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import type { Note } from '@shared/types';
const mockApi = {
listNotes: vi.fn(async () => [] as Note[]),
listTrash: vi.fn(async () => [] as Note[]),
listByStatus: vi.fn(async () => [] as Note[]),
countsByStatus: vi.fn(async () => ({ active: 0, completed: 0, archived: 0, trashed: 0 })),
getTrashCount: vi.fn(async () => 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),
getFailedCount: vi.fn(async () => 0),
listExpired: vi.fn(async () => [] as Note[]),
listRecallCandidate: vi.fn(async () => null),
restoreNote: vi.fn(async () => {}),
permanentDeleteNote: vi.fn(async () => ({ confirmed: true })),
emptyTrash: vi.fn(async () => ({ confirmed: true, count: 0 })),
trashExpiredBatch: vi.fn(async () => ({ confirmed: true, trashedCount: 0 })),
onNoteUpdated: vi.fn(() => () => {}),
updateAiFields: vi.fn(async () => {}),
setDueDate: vi.fn(async () => {}),
setIntent: vi.fn(async () => {}),
dismissIntent: vi.fn(async () => {}),
ollamaRecheck: vi.fn(async () => ({ ok: true })),
retryAllFailed: vi.fn(async () => {}),
markRecallOpened: vi.fn(async () => {}),
dismissRecall: vi.fn(async () => {}),
emitRecallSnoozed: vi.fn(async () => {})
};
vi.mock('../../src/renderer/inbox/api.js', () => ({ inboxApi: mockApi }));
describe('inbox store — view enum', () => {
beforeEach(async () => {
const { useInbox } = await import('../../src/renderer/inbox/store.js');
useInbox.setState({
view: 'inbox',
counts: { active: 0, completed: 0, archived: 0, trashed: 0 },
notes: [], trashNotes: [], trashCount: 0,
showTrash: false, showSettings: false,
loading: false, tagFilter: null, pendingCount: 0, todayCount: 0,
ollamaStatus: { ok: true },
continuity: { weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null },
expiredCandidates: [], expiredSnoozeUntilMs: null,
failedCount: 0, recallCandidate: null, recallSnoozeUntilMs: null
});
Object.values(mockApi).forEach((fn) => 'mockClear' in fn && (fn as any).mockClear());
});
it('initial view is inbox', async () => {
const { useInbox } = await import('../../src/renderer/inbox/store.js');
expect(useInbox.getState().view).toBe('inbox');
});
it('setView changes view', async () => {
const { useInbox } = await import('../../src/renderer/inbox/store.js');
useInbox.getState().setView('completed');
expect(useInbox.getState().view).toBe('completed');
});
it('counts initialized to zero per status', async () => {
const { useInbox } = await import('../../src/renderer/inbox/store.js');
expect(useInbox.getState().counts).toEqual({ active: 0, completed: 0, archived: 0, trashed: 0 });
});
it('backward-compat: showTrash mirrors view==="trash"', async () => {
const { useInbox } = await import('../../src/renderer/inbox/store.js');
useInbox.getState().setView('trash');
expect(useInbox.getState().showTrash).toBe(true);
useInbox.getState().setView('inbox');
expect(useInbox.getState().showTrash).toBe(false);
});
it('backward-compat: showSettings mirrors view==="settings"', async () => {
const { useInbox } = await import('../../src/renderer/inbox/store.js');
useInbox.getState().setView('settings');
expect(useInbox.getState().showSettings).toBe(true);
useInbox.getState().setView('inbox');
expect(useInbox.getState().showSettings).toBe(false);
});
});

71
tests/unit/tray.test.ts Normal file
View File

@@ -0,0 +1,71 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('electron', () => ({
default: {
app: {
on: vi.fn(),
getPath: vi.fn(),
getVersion: vi.fn(() => '0.2.7'),
isPackaged: false,
getLoginItemSettings: vi.fn(() => ({ openAtLogin: false }))
},
Tray: vi.fn(function (this: unknown) {
Object.assign(this as object, {
setToolTip: vi.fn(),
setContextMenu: vi.fn(),
on: vi.fn()
});
}),
Menu: { buildFromTemplate: vi.fn((items: unknown) => ({ items })) },
nativeImage: { createEmpty: vi.fn() },
dialog: {},
shell: {},
clipboard: {}
}
}));
import { createTray, type TrayCallbacks } from '../../src/main/tray';
describe('tray menu — slim 4 items', () => {
beforeEach(() => { vi.clearAllMocks(); });
function makeCallbacks(): TrayCallbacks {
return {
showInbox: vi.fn(),
showCapture: vi.fn(),
showSettings: vi.fn()
};
}
it('builds menu with 4 click items + 2 separators', async () => {
createTray(makeCallbacks());
const electron = (await import('electron')).default;
const calls = (electron.Menu.buildFromTemplate as any).mock.calls;
const items = calls[calls.length - 1][0];
const labels = items.filter((i: any) => i.type !== 'separator').map((i: any) => i.label);
expect(labels).toEqual(['한 줄 적기', '보관한 메모 보기', '설정...', '종료']);
});
it('does not include removed items', async () => {
createTray(makeCallbacks());
const electron = (await import('electron')).default;
const calls = (electron.Menu.buildFromTemplate as any).mock.calls;
const items = calls[calls.length - 1][0];
const labels = items.filter((i: any) => i.type !== 'separator').map((i: any) => i.label);
expect(labels).not.toContain('지금 백업');
expect(labels).not.toContain('내보내기...');
expect(labels).not.toContain('Ollama 재확인');
expect(labels).not.toContain('Ollama 설정...');
});
it('"설정..." click invokes showSettings callback', async () => {
const cb = makeCallbacks();
createTray(cb);
const electron = (await import('electron')).default;
const calls = (electron.Menu.buildFromTemplate as any).mock.calls;
const items = calls[calls.length - 1][0];
const settingsItem = items.find((i: any) => i.label === '설정...');
settingsItem.click();
expect(cb.showSettings).toHaveBeenCalled();
});
});

View File

@@ -1,11 +1,13 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import { resolve } from 'node:path';
export default defineConfig({
plugins: [react()],
test: {
environment: 'node',
globals: false,
include: ['tests/unit/**/*.test.ts'],
include: ['tests/unit/**/*.test.ts', 'tests/unit/**/*.test.tsx'],
exclude: ['tests/integration/**', 'tests/e2e/**'],
coverage: { reporter: ['text', 'html'] }
},