158 Commits

Author SHA1 Message Date
th-kim0823
f676c1638e chore(release): v0.3.10 — macOS fullscreen QuickCapture fix
macOS fullscreen Space 위에 QuickCapture 띄우기. 이전엔 핫키 누를 때 강제 Space 전환.

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

Windows / Linux 동작 변경 없음.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

단위 710 → 724, typecheck 0

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

helper 에 5 필드 default (active / null / null / null / false) 추가. 회귀 fix.
2026-05-10 04:45:43 +09:00
altair823
e2e8b9b921 feat(v031): buildVisionPrompt + GenerateInput.images + GenerateOptions.visionModel
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 04:43:03 +09:00
altair823
3eb0ef1316 feat(v031): VisionDetect — isVisionCapable + refreshVisionCache (fetch 주입)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 04:42:57 +09:00
altair823
463be7cf26 feat(v031): SettingsService.{getVisionModel,setVisionModel,getVisionCapableCache,setVisionCapableCache}
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 04:42:52 +09:00
altair823
7a56184ad2 docs(plan): v0.3.1 Cut F — 멀티모달 vision AI (spec 정정: 단위 679, SettingsService 개별 메서드, 'skipped' enum 미도입, fallback 미구현) 2026-05-10 04:38:45 +09:00
a54f134343 Merge pull request 'v0.3.0 Cut E — 양방향 git sync + Configure UI + Conflict resolution (F21)' (#30) from worktree-v030-cut-e-bidirectional-sync into main
Reviewed-on: #30
2026-05-09 19:24:42 +00:00
altair823
401414608b fix(v030): SyncConflict noteId→path + populate localText/remoteText (final review fix)
final code review (Opus) 발견 2 important issues:

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

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

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

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

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

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

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

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

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

548/548 + typecheck 0 유지.

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

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

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

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

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

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

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

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

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

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

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

472/472 + typecheck 0 유지.

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

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

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

460/460 pass, typecheck 0.

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

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

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

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

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

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

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

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

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

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

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

Round 1 Important 도 함께 처리.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

PR #23 round 1 reviewer Important deferred 처리.

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

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

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

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

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

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

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

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

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

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

총 항목 46 / 잔여 40건.

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 00:42:50 +09:00
167 changed files with 31479 additions and 910 deletions

8
.gitignore vendored
View File

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

View File

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

View File

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

24
assets/icon.svg Normal file
View File

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

After

Width:  |  Height:  |  Size: 988 B

BIN
build/icon.icns Normal file

Binary file not shown.

BIN
build/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 353 KiB

BIN
build/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,240 @@
# 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. local export — 현재 SQLite 상태를 syncDir 에 markdown 으로 출력
await this.exportSvc.export(this.syncDir, { includeMedia: true });
await git.addAll();
const localChanged = await git.hasUncommittedChanges();
// 2. local commit (변경 있으면)
let localSha: string | null = null;
if (localChanged) {
const c = await git.commit(`chore(notes): sync ${this.now().toISOString()}`);
localSha = c.sha;
}
// 3. fetch
const fetchR = await git.fetch();
if (fetchR.exitCode !== 0) return { ok: false, reason: `fetch failed: ${fetchR.stderr}` };
// 4. rebase onto origin/main
const rebaseR = await git.rebaseOnto('origin/main');
if (rebaseR.exitCode !== 0) {
// conflict — abort + conflict 목록 반환 (UI 가 활성)
await git.rebaseAbort();
return { ok: false, reason: 'conflict', conflicts: await this.listConflictsFromMarkdown() };
}
// 5. re-import (rebase 후 markdown 변경 → SQLite upsertFromSync)
const imported = await this.importSvc.applySyncFromDir(this.syncDir);
// 6. push
try { await git.push(); } catch (e) { return { ok: false, reason: `push failed: ${(e as Error).message}` }; }
return { ok: true, changed: localChanged || imported.changedCount > 0, localSha, importedCount: imported.changedCount, pushed: true };
}
```
**6 단계 흐름 — local export 가 fetch 보다 먼저 (Cut E 정정)**: spec 초안은 fetch 우선이었으나, local export → commit 후 fetch + rebase 가 git workflow 표준 (rebase 가 local commit 위에 origin commit 적용). local export 안 한 상태로 fetch + rebase → 혼란 발생.
`SyncStatus` 인터페이스 확장:
```ts
export interface SyncStatus {
ok: boolean;
reason?: string;
changed?: boolean;
localSha?: string | null;
pushed?: boolean;
importedCount?: number;
conflicts?: Array<{ noteId: string; localText: string; remoteText: string }>; // reason='conflict' 시
}
```
### 3-2. ImportService 활용 (실제 코드 정정)
기존 ImportService 는 `run(sourceDir)` 메서드 (백업 복원 흐름) — `parsedToInput``repo.importNote()` 호출. spec 작성 시 가정한 `importAll(dir)` 시그니처는 실재 코드와 다름.
`repo.importNote()` 의 기존 conflict 정책 (export tree 복원용):
- id 없음 → INSERT (`status: 'inserted'`)
- id 있음 + raw_text 동일 → no-op (`status: 'skipped'`)
- id 있음 + raw_text 다름 → fork-on-id-collision (fresh uuidv7) (`status: 'forked'`)
**Cut E sync 정책 — fork 미적합, in-place update + revision 보존**:
sync 에서 양 기기 raw_text 가 다를 때 fork 하면 노트 갯수 무한 증가 → 부적합. 신설 메서드 `repo.upsertFromSync(input)`:
- id 없음 → INSERT (m006 trigger 가 capture revision 자동 생성)
- id 있음 + raw_text 동일 → metadata 갱신 path
- source.updatedAt > local.updatedAt 인 경우만 ai_title/ai_summary/tags/status/dueDate 갱신
- tags 변경 시 `rebuildFtsTagsForNote` 호출 (Cut D single write path)
- 동등/older 면 skip
- id 있음 + raw_text 다름 → 옵션 분기:
- source.updatedAt > local.updatedAt → `updateRawText(id, sourceRawText, sourceUpdatedAt)` (Cut C single write path) → 새 user revision INSERT, latest = source
- local.updatedAt > source.updatedAt → skip (다음 push 가 source 갱신할 것)
- 동일 timestamp + 다른 raw_text → SyncService 가 conflict 마킹 (rebase 단계 git markdown conflict 가 먼저 잡힘 — 본 분기는 rare)
**revision edited_by**: 'sync' enum 추가 안 함 — `updateRawText` 의 default 'user' 그대로 활용 (sync = user-edited 변경 전파 = 의미상 user). YAGNI: m008 회피.
### 3-3. GitClient 확장
```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 |
**목표**: 단위 608 → 약 635 (+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,278 @@
# 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 모델 (gemma family — gemma3 / gemma4 default capable) 활용 — 이미지 + raw_text 결합 prompt 또는 이미지 단독 분석 → title/summary/tags 자동 생성. F22 prerequisite (Cut A) 이미 완료.
---
## 2. 범위
| 항목 | 결정 |
|---|---|
| **F24 default 모델** | gemma family — gemma3 / gemma4 둘 다 vision-capable hint (한국어 + 이미지 둘 다 강함, 본인 메모 `gemma4:e4b` 텍스트 모델과 같은 가족) |
| **prompt 모드** | 단일 vision 모델 호출 (vision 모델이 텍스트도 처리). 모델 capability 부족 시 2단계 fallback (자동) |
| **capability detection** | app launch 시 1회 + 설정 페이지 manual refresh 버튼 |
| **F23 OFF 시 자동 OFF** | `ai_enabled=false` → vision 도 자동 OFF (자명) |
---
## 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 (실제 SettingsService API)
zod schema 확장 (기존 ai_enabled / sync_* 와 동일 strict 패턴):
```ts
const SettingsSchema = z.object({
// ... 기존 ollama / ai_enabled / onboarding_completed / sync_*
vision_model: z.string().nullable().optional(),
vision_capable_cache: z.array(z.string()).optional(),
vision_cache_at: z.string().optional()
}).strict();
```
신규 SettingsService 메서드 (개별 setter/getter — `get/set` 일반화 X):
```ts
async getVisionModel(): Promise<string | null>;
async setVisionModel(value: string | null): Promise<void>;
async getVisionCapableCache(): Promise<{ models: string[]; at: string | null }>;
async setVisionCapableCache(models: string[], now: Date): Promise<void>;
```
### 3-3. AppLaunchDetect
`src/main/services/VisionDetect.ts` 신규 — pure 함수 + 외부 fetch 주입 (테스트 가능):
```ts
export async function refreshVisionCache(deps: {
settings: SettingsService;
endpoint: string;
now?: () => Date;
fetchImpl?: typeof fetch;
}): Promise<{ ok: true; models: string[] } | { ok: false; reason: string }> {
if (!(await deps.settings.isAiEnabled())) {
return { ok: false, reason: 'ai_disabled' };
}
const fetchFn = deps.fetchImpl ?? fetch;
let body: { models?: Array<{ name: string; details?: { family?: string; families?: string[] } }> };
try {
const r = await fetchFn(`${deps.endpoint}/api/tags`);
if (!r.ok) return { ok: false, reason: `tags http ${r.status}` };
body = await r.json();
} catch (e) {
return { ok: false, reason: `unreachable: ${(e as Error).message}` };
}
const capable = (body.models ?? []).filter(isVisionCapable).map((m) => m.name);
await deps.settings.setVisionCapableCache(capable, deps.now ? deps.now() : new Date());
return { ok: true, models: capable };
}
```
main process `whenReady` 안에서 fire-and-forget 호출. 실패 silent (cache 유지). settings:refresh-vision-cache IPC 가 동일 함수 호출 (manual "다시 감지" 버튼).
### 3-4. 설정 페이지 UI (AI 제공자 섹션 확장)
```
[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 통합 (실제 API 정정)
기존 `AiWorker.processJob``repo.findById(noteId)` 로 hydrate 된 `Note` 받음 — `note.media` 가 이미 join 결과로 채워져 있어 별도 `listMediaByNote` 호출 불필요. `MediaStore.absolutePath(relPath)` 로 디스크 path 추출.
```ts
// src/main/ai/AiWorker.ts processJob 흐름
const note = this.repo.findById(job.noteId);
if (!note || ...) return;
const visionModel = await this.settings.getVisionModel();
let images: Array<{ base64: string; mime: string }> | undefined;
if (visionModel && note.media.length > 0) {
images = await Promise.all(
note.media.map(async (m) => {
const buf = await readFile(this.mediaStore.absolutePath(m.relPath));
// 이미지당 5MB cap (base64 메모리 폭주 방지)
if (buf.byteLength > 5 * 1024 * 1024) {
throw new Error(`image ${m.relPath} exceeds 5MB cap`);
}
return { base64: buf.toString('base64'), mime: m.mime };
})
);
}
const res = await this.holder.get().generate({
text: note.rawText,
images,
todayKst,
dueDateCandidates: candidates,
vocab
}, { visionModel });
```
`visionModel && note.media.length > 0` 둘 다 true 일 때만 vision path. 그 외는 기존 text-only path 유지 (호환 보존). image 5MB cap 초과 시 throw → 기존 AiWorker 의 attempts 카운트 + ai_status='failed' 분기 활용.
AiWorker 의 `settings: SettingsService` 의존성 추가 — 기존 생성자에 신규 파라미터.
---
## 6. 이미지만 있는 capture (정정 — 신규 enum 도입 X)
`raw_text` 빈 값 + media 첨부만 케이스:
- **vision enabled** (`visionModel` 설정 + media 있음): AiWorker 의 vision path → 의미 있는 title/summary/tags 응답
- **vision disabled** (`visionModel` null): 기존 text-only 흐름 그대로 — 빈 prompt → AI 응답이 무의미하면 ai_status='failed' 분기 (재시도 가능). dogfood 시 빈도 측정 후 'skipped' enum 도입 여부 재평가.
**'skipped' 신규 enum 미도입 (YAGNI)**: m008 마이그레이션 (CHECK relax via table recreate) 부담 + 이미지-only capture 가 본 cut 의 main use case 가 아님. 사용자가 vision 활성 후 retry 하거나 raw_text 추가 후 reprocess 하는 우회로 충분. 정책 검토는 dogfood 후 별도 cut.
---
## 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' 분기 |
**목표**: 단위 679 → 약 701 (+22, isVisionCapable 5 + refreshVisionCache 4 + SettingsService vision 4 + LocalOllamaProvider vision path 3 + buildVisionPrompt 2 + AiWorker vision integration 3 + UI dropdown 1), typecheck 0.
---
## 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 미지원) | **본 cut 미구현 (YAGNI)** — 자동 2단계 fallback (caption 추출 → 텍스트 모델 종합) 은 v0.3.2+ 검토. dogfood 시 capability detection 정확도 우선 |
---
## 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

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

View File

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

View File

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

@@ -1,23 +1,79 @@
# v0.2.4 Backlog
# v0.2.x Backlog
> v0.2.3 cut (7항목 / PR #13~#19) 동안 final reviewer + PR review round 1 에서 발견된 minor / nit 중 의도적으로 deferred 한 항목 누적. v0.2.3 dogfood soak 후 신규 피드백 + 본 리스트 일괄 triage → v0.2.4 cut 결정.
> 누적 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.4 patch cut — backlog 5건 처리)
**총 항목 수:** 45 (잔여 39 = 45 [#1 stale + #2/#6/#13/#44/#45 본 cut 처리 5건] 단 #45 는 별도 cut, 아래 표 참조)
**최종 갱신:** 2026-05-10 (v0.3.2 cleanup cut — 잠재 bug 4 + cosmetic 5 + #20 deferred)
**총 항목 수:** 46 (#1 stale 포함)
**잔여:** 14건 (=46 처리 31 stale 1)
## 처리 이력
## 처리 이력 / 진행 흐름
| 항목 | 상태 | Cut |
|---|---|---|
| #1 (`now()` 2번 호출) | 이미 fix (PR #13 round 1 — backlog stale) | - |
| #2 (`DAY_MS` magic) | ✅ 처리 | v0.2.4 patch (commit `ef5d3da`) |
| #6 (`media.gc.run()` `.catch`) | ✅ 처리 | v0.2.4 patch (commit `ef5d3da`) |
| #13 (NoteCard `onDeleted` dead-code) | ✅ 처리 | v0.2.4 patch (commit `c87c248`) |
| #44 (버전 정보 surface) | ✅ 처리 (트레이 "Inkling 정보..." + native dialog) | v0.2.4 patch (commit `d3dfe1e`) |
| #45 (자동실행 풀림 버그) | 별도 cut 예정 (Windows registry 디버깅) | TBD |
| #1 (`now()` 2번 호출) | 이미 fix (PR #13 round 1 — backlog stale) | - |
| #2 (`DAY_MS` magic) | ✅ 처리 | v0.2.4 (commit `ef5d3da`) |
| #6 (`media.gc.run()` `.catch`) | ✅ 처리 | v0.2.4 (commit `ef5d3da`) |
| #13 (NoteCard `onDeleted` dead-code) | ✅ 처리 | v0.2.4 (commit `c87c248`) |
| #44 (버전 정보 surface) | ✅ 처리 (트레이 "Inkling 정보..." + native dialog) | v0.2.4 (commit `d3dfe1e`) |
| **out-of-backlog**: multi-instance bug (single-instance lock) | ✅ critical hotfix | v0.2.5 (PR #23, `7187aea`) |
| #10 (restoreNote + pending_jobs) | ✅ 처리 (repo 메서드 + CaptureService production path) | v0.2.6 (commit `df27a96` + `a991008`) |
| #12 (trashCount cap) | ✅ 이미 fix (v0.2.3 #4) — tests +2 추가 | v0.2.6 (commit `e2c53a2`) |
| #45 (자동실행 풀림 버그) | ✅ 처리 (진단 노출 + 재등록 버튼) | v0.2.7 (commit `8a8652e` + `8368286`) |
| #46 (hidden-start race) | ✅ 처리 (`additionalData` + handler hidden flag) | v0.2.6 (commit `e485b77`) |
| #3+#19+#34 (KST helper 통합) | ✅ 처리 → `src/shared/util/kstDate.ts` (4 callsite migrate) | v0.2.6 (commit `3cfa60b`) |
| #5 (AiFailedReason union) | ✅ 처리 (zod z.infer 단일 export) | v0.2.6 (commit `a2c17a8`) |
| #21 (hasNoteId predicate) | ✅ 처리 (NO_NOTE_ID_KINDS Set + type predicate) | v0.2.6 (commit `05c45c1`) |
| #22 (hydrate `as any[]`) | ✅ 처리 (`as Record<string, unknown>[]` 통일) | v0.2.6 (commit `983306e`) |
| #8 (stats exhaustiveness) | ✅ 처리 (`else { _: never = ev }`) | v0.2.6 (commit `9230ebf`) |
| #4+#23+#26+#27 (TrayCallbacks 객체화) | ✅ 처리 (1-arg + `Partial<TrayState>`) | v0.2.6 (commit `476a519`) |
| #24+#41 (Banner shared component) | ✅ 처리 (`Banner severity=...` 4 callsite) | v0.2.6 (commit `0447b69`) |
| #15 (IPC channel rename) | ✅ 처리 (`inbox:delete``inbox:trash`) | v0.2.6 (commit `8b2920f`) |
| #29 (VOCAB_TOP_N const) | ✅ 처리 (튜닝 자체는 telemetry 후) | v0.2.6 (commit `8b2920f`) |
| #42 (Modal URL pre-check) | ✅ 처리 (zod safeParse) | v0.2.6 (commit `8b2920f`) |
| #9 (휴지통 회수율 ratio 코멘트) | ✅ 처리 (1줄 코멘트) | v0.2.6 (commit `8b2920f`) |
**잔여 39건.** v0.2.5 brainstorm 시 신규 dogfood 피드백 + 잔여 39건 일괄 triage.
### v0.2.6 PR #24 round 1 발견 (Critical fix)
| 항목 | 상태 | Cut |
|---|---|---|
| **B1 production path** (CaptureService.restoreNote 가 옛 `repo.restore` 호출) | ✅ Critical fix (commit `a991008`) | v0.2.6 round 1 |
### v0.3.2 cleanup cut (2026-05-10)
| 항목 | 상태 | Cut |
|---|---|---|
| #14 (탭 ARIA `aria-pressed``role="tab"`) | ✅ 처리 | v0.3.2 |
| #18 (`loadExpired` 미사용 제거) | ✅ 처리 | v0.3.2 |
| #19 (KST inline 5 callsite 잔여 migrate) | ✅ 처리 | v0.3.2 |
| #20 (telemetry `.catch` silent → debug log) | 🟡 deferred (CaptureService logger 미주입 — constructor 변경 회피) | v0.3.2 audit |
| #31 (vocabSet COLLATE NOCASE 정합) | ✅ 처리 | v0.3.2 |
| #32 (per-tag emit `Promise.all` 병렬화) | ✅ 처리 | v0.3.2 |
| #36 (recall IPC `handle``on`) | ✅ 처리 | v0.3.2 |
| #39 (`ollama_unreachable.reason` PII 마스킹) | ✅ 처리 | v0.3.2 |
| #41+#42 (`OllamaSettingsModal` 폐기 audit) | ✅ 자연 소멸 (v0.2.7) | v0.3.2 audit |
| time-dependent test flake fix | ✅ 처리 | v0.3.2 |
### v0.2.6 final reviewer + round 1 minors (deferred)
| 항목 | 상태 |
|---|---|
| NoteRepository.countToday inline KST_OFFSET_MS | v0.2.7 cleanup (C1 spec 외) |
| BackupService / ContinuityService inline KST_OFFSET_MS | v0.2.7 cleanup |
| NoteRepository.test.ts:125 `as any` | v0.2.7 (C6 spec 외) |
| OllamaSettingsModal `#fce4e4` inline (C7 spec 5번째) | modal 컨텍스트라 보류 |
| `kstDate(ts)` semantic naming (telemetryStats) | v0.2.7 |
| store.ts:177 trashCount race on `trashExpiredBatch` | pre-existing, v0.2.7 |
| ExpiryBanner useEffect 24h+ closure | edge case, defer |
**잔여 24건** (= 46 처리 21 stale 1). v0.2.7 brainstorm 시 신규 dogfood 피드백 + #45 deeper fix + 위 deferred 항목 일괄 triage.
## 명명 노트
- v0.2.3.1 / v0.2.4 / v0.2.5 는 **dogfood unblock patch** (semver bump 강제 / hotfix)
- v0.2.6 = 첫 정식 cut (16 backlog 항목 처리)
- v0.2.7 = 다음 정식 feature cut (telemetry data-dependent 14건 + 신규 피드백 + 잔여 deferred)
- 본 backlog 파일은 v0.2.7 cut 시점에 prune + rename 검토 (`v027-backlog.md` 또는 stable 한 `feature-backlog.md`)
## Defer 사유 카테고리
@@ -29,12 +85,12 @@
## How to apply
v0.2.4 brainstorm 시 본 리스트를 1차 backlog 로 사용. 항목별로:
v0.2.6 brainstorm 시 본 리스트를 1차 backlog 로 사용. 항목별로:
- (a) 그대로 cleanup
- (b) #4~#6 영향 받아 변형
- (c) defer-further 결정
- (d) drop (만에 하나 outdated)
- (d) drop (만에 하나 outdated 또는 v0.2.4/v0.2.5 patch 가 우회 처리)
## v0.2.3 #7 Telemetry skeleton 누적 (2026-05-01)
@@ -147,11 +203,20 @@ v0.2.4 brainstorm 시 본 리스트를 1차 backlog 로 사용. 항목별로:
- (b) Electron `setLoginItemSettings` Windows 구현 의 path canonicalization 이슈
- (c) 우리 `args: ['--hidden']` 와 actual launch 시 args 비교 mismatch
- 영향: dogfood UX 핵심 마찰 — autostart 가 핸드오프 시 매번 수동 재설정 필요. 자동 실행 의도 자체가 dogfood "잊지 않고 매일 사용" 목적인데 깨짐.
- v0.2.4 에서 우선순위 높음. 진단 절차: (1) `app.getLoginItemSettings({ args: ['--hidden'] })` 형태로 args 전달해 비교 정확도 올리기, (2) registry 직접 inspect (`HKCU\Software\Microsoft\Windows\CurrentVersion\Run\inkling`) 로 path/args 확인, (3) executable path canonicalization (electron 이 short path 변환 적용 여부).
- v0.2.6 에서 우선순위 높음. 진단 절차: (1) `app.getLoginItemSettings({ args: ['--hidden'] })` 형태로 args 전달해 비교 정확도 올리기, (2) registry 직접 inspect (`HKCU\Software\Microsoft\Windows\CurrentVersion\Run\inkling`) 로 path/args 확인, (3) executable path canonicalization (electron 이 short path 변환 적용 여부).
## v0.2.5 critical hotfix 누적 (2026-05-05)
> v0.2.5 single-instance lock hotfix (PR #23) 의 reviewer deferred 항목.
46. **Hidden-start race (NSIS installer 자동 실행 + 사용자 클릭 충돌)** — NSIS installer 가 설치 직후 사용자가 시작메뉴 / 데스크톱 아이콘 클릭 (`inkling.exe`) + autostart entry (`inkling.exe --hidden`) 을 짧은 간격에 둘 다 시도 시 — 첫 lock 보유자에 따라 visible 여부 race. 본 cut 의 `second-instance` handler 는 무조건 inbox 창 띄움 (사용자 클릭 = 보고 싶다는 강한 시그널 가정). 매우 드문 시나리오 + lock 자체는 정상 동작 (한 쪽만 살아남음).
- 영향: drm-edge 케이스만, 실 사용 거의 X
- v0.2.6 에서: `app.requestSingleInstanceLock(additionalData)``additionalData: { hidden: startedHidden }` 전달 → `second-instance(event, argv, cwd, additionalData)` 에서 두 번째 호출이 hidden 이면 창 안 띄우는 정책. 첫 instance 가 자기 자신의 hidden 상태와 비교해 visible 결정.
- PR #23 round 1 reviewer Important — acknowledge only, defer to v0.2.6.
## post-cut next-step (status, not backlog)
38. **v0.2.3 cut 7/7 완료 → binary 빌드 단계** — slice §7 strict-pin patch 증분으로 v0.2.3 binary 빌드 + dogfood 핸드오프. ≥1주 soak telemetry export 분석으로 v0.2.4 brainstorm 트리거. (✓ 2026-05-02 빌드 완료, hotfix #20 + publish:null 포함, release 재생성 완료)
38. **빌드 / release 흐름 (status)** — v0.2.3 cut 7/7 (PR #13~#19) → binary v0.2.3 release → 11434 포트 reserved 발견 → v0.2.3.1 attempt (PR #21) → semver 거부 → v0.2.4 (PR #22, backlog 5건 + Ollama 설정 UI) → release → multi-instance bug 발견 → **v0.2.5 critical hotfix** (PR #23, single-instance lock) → release ✅ (2026-05-05). 다음: dogfood ≥1주 soak telemetry export + 신규 피드백 → **v0.2.6 brainstorm 트리거** (잔여 backlog 40건 일괄 triage).
## v0.2.3 cut 후 final reviewer 가 칭찬한 부분

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.4",
"version": "0.3.10",
"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

@@ -1,22 +1,18 @@
import { readFile } from 'node:fs/promises';
import type { NoteRepository } from '../repository/NoteRepository.js';
import type { Note } from '@shared/types';
import type { AiFailedReason } from '../services/telemetryEvents.js';
import type { SettingsService } from '../services/SettingsService.js';
import type { MediaStore } from '../services/MediaStore.js';
import { ProviderHolder } from './ProviderHolder.js';
import { parseAllCandidates } from '../services/dueDateParser.js';
import { ZodError } from 'zod';
import { kstTodayAsDate, kstTodayIso } from '../../shared/util/kstDate.js';
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
// v0.2.6 #29 — backlog 의 top-N 튜닝은 dogfood telemetry 후 (현재 magic 만 추출).
const VOCAB_TOP_N = 20;
function todayKstAsDate(now: Date): Date {
// Returns a Date object whose UTC year/month/day match KST today
const k = new Date(now.getTime() + KST_OFFSET_MS);
return new Date(Date.UTC(k.getUTCFullYear(), k.getUTCMonth(), k.getUTCDate()));
}
function todayKstAsIso(now: Date): string {
return todayKstAsDate(now).toISOString().slice(0, 10);
}
function classifyReason(err: unknown): 'unreachable' | 'schema' | 'timeout' | 'other' {
function classifyReason(err: unknown): AiFailedReason {
if (err instanceof ZodError) return 'schema';
const msg = err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase();
if (msg.includes('econnrefused') || msg.includes('enotfound') || msg.includes('fetch failed') || msg.includes('econnreset') || msg.includes('unreachable')) {
@@ -31,7 +27,7 @@ function classifyReason(err: unknown): 'unreachable' | 'schema' | 'timeout' | 'o
export interface AiTelemetryEmitter {
emit(input:
| { kind: 'ai_succeeded'; payload: { noteId: string; durationMs: number; attempts: number } }
| { kind: 'ai_failed'; payload: { noteId: string; reason: 'unreachable' | 'schema' | 'timeout' | 'other'; attempts: number } }
| { kind: 'ai_failed'; payload: { noteId: string; reason: AiFailedReason; attempts: number } }
| { kind: 'tag_vocab_hit'; payload: { tagId: number; vocabSize: number } }
| { kind: 'tag_vocab_miss'; payload: { vocabSize: number } }
): Promise<void>;
@@ -48,6 +44,10 @@ export interface AiWorkerOptions {
};
now?: () => Date;
telemetry?: AiTelemetryEmitter;
/** v0.3.1 Cut F — vision 지원. 미전달 시 vision 비활성. */
settings?: Pick<SettingsService, 'getVisionModel'>;
/** v0.3.1 Cut F — 첨부 이미지 절대경로 변환. settings 와 함께 전달 시 vision 활성. */
mediaStore?: Pick<MediaStore, 'absolutePath'>;
}
interface Job { noteId: string; attempts: number; }
@@ -63,6 +63,8 @@ export class AiWorker {
private logger: NonNullable<AiWorkerOptions['logger']>;
private now: () => Date;
private telemetry?: AiTelemetryEmitter;
private settings?: Pick<SettingsService, 'getVisionModel'>;
private mediaStore?: Pick<MediaStore, 'absolutePath'>;
constructor(
private repo: NoteRepository,
@@ -75,6 +77,8 @@ export class AiWorker {
this.logger = opts.logger ?? { info: () => {}, warn: () => {}, error: () => {} };
this.now = opts.now ?? (() => new Date());
this.telemetry = opts.telemetry;
this.settings = opts.settings;
this.mediaStore = opts.mediaStore;
}
async enqueue(noteId: string): Promise<void> {
@@ -131,16 +135,31 @@ export class AiWorker {
const note = this.repo.findById(job.noteId);
if (!note || note.deletedAt !== null || note.aiStatus !== 'pending') return;
const nowDate = this.now();
const todayDate = todayKstAsDate(nowDate);
const todayIso = todayKstAsIso(nowDate);
const todayDate = kstTodayAsDate(nowDate);
const todayIso = kstTodayIso(nowDate);
const candidates = parseAllCandidates(note.rawText, todayDate);
const vocab = this.repo.getTopUsedTags(20);
const res = await this.holder.get().generate({
text: note.rawText,
todayKst: todayIso,
dueDateCandidates: candidates,
vocab
});
const vocab = this.repo.getTopUsedTags(VOCAB_TOP_N);
// v0.3.1 Cut F — vision path: visionModel + note.media → base64 images
// final review fix: note.media[].bytes 로 fast-fail (readFile/base64 비용 회피).
// 5MB cap 초과 시 throw → AiWorker 의 'other' 분기 → markAiFailed 도달.
const visionModel = this.settings ? await this.settings.getVisionModel() : null;
let images: Array<{ base64: string; mime: string }> | undefined;
if (visionModel && note.media.length > 0 && this.mediaStore) {
const oversize = note.media.find((m) => m.bytes > 5 * 1024 * 1024);
if (oversize) {
throw new Error(`image ${oversize.relPath} exceeds 5MB cap (${oversize.bytes} bytes)`);
}
images = await Promise.all(
note.media.map(async (m) => {
const buf = await readFile(this.mediaStore!.absolutePath(m.relPath));
return { base64: buf.toString('base64'), mime: m.mime };
})
);
}
const res = await this.holder.get().generate(
{ text: note.rawText, images, todayKst: todayIso, dueDateCandidates: candidates, vocab },
{ visionModel: visionModel ?? undefined }
);
// AI primary: AI's dueDate is final (no rule merge)
this.repo.updateAiResult(job.noteId, {
title: res.title,
@@ -157,7 +176,8 @@ export class AiWorker {
candidatesCount: candidates.length
});
if (this.telemetry) {
await this.telemetry.emit({
const telemetry = this.telemetry;
await telemetry.emit({
kind: 'ai_succeeded',
payload: {
noteId: job.noteId,
@@ -167,23 +187,25 @@ export class AiWorker {
}).catch(() => {});
// v0.2.3 #3 — per-tag vocab hit/miss 분류 (updateAiResult 후 → tagId 보장)
// dedup: AI 응답에 같은 태그 중복 가능 — INSERT OR IGNORE 와 정합한 1-emit/태그 보장
const vocabSet = new Set(vocab);
for (const tagName of new Set(res.tags)) {
if (vocabSet.has(tagName)) {
const tagId = this.repo.getTagIdByName(tagName);
if (tagId !== null) {
await this.telemetry.emit({
kind: 'tag_vocab_hit',
payload: { tagId, vocabSize: vocab.length }
const vocabSet = new Set(vocab.map((v) => v.toLowerCase()));
await Promise.all(
Array.from(new Set(res.tags)).map(async (tagName) => {
if (vocabSet.has(tagName.toLowerCase())) {
const tagId = this.repo.getTagIdByName(tagName);
if (tagId !== null) {
await telemetry.emit({
kind: 'tag_vocab_hit',
payload: { tagId, vocabSize: vocab.length }
}).catch(() => {});
}
} else {
await telemetry.emit({
kind: 'tag_vocab_miss',
payload: { vocabSize: vocab.length }
}).catch(() => {});
}
} else {
await this.telemetry.emit({
kind: 'tag_vocab_miss',
payload: { vocabSize: vocab.length }
}).catch(() => {});
}
}
})
);
}
this.emit(job.noteId);
return;

View File

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

@@ -1,9 +1,18 @@
import { request } from 'undici';
import { parseAiResponse, type AiResponse } from './schema.js';
import { buildPrompt } from './prompt.js';
import type { GenerateInput, HealthResult, InferenceProvider } from './InferenceProvider.js';
import { buildVisionPrompt } from './visionPrompt.js';
import type { GenerateInput, GenerateOptions, HealthResult, InferenceProvider } from './InferenceProvider.js';
import { DEFAULT_OLLAMA_ENDPOINT, DEFAULT_OLLAMA_MODEL } from '../../shared/constants.js';
function classifyFetchError(e: unknown): 'network' | 'timeout' | 'dns' | 'other' {
const msg = ((e as Error)?.message ?? '').toLowerCase();
if (msg.includes('aborted') || msg.includes('timeout')) return 'timeout';
if (msg.includes('econnrefused') || msg.includes('econnreset')) return 'network';
if (msg.includes('enotfound') || msg.includes('eai_again')) return 'dns';
return 'other';
}
export interface LocalOllamaOptions {
endpoint?: string;
model?: string;
@@ -30,29 +39,39 @@ export class LocalOllamaProvider implements InferenceProvider {
this.name = `local-ollama/${this.model}`;
}
async generate(input: GenerateInput): Promise<AiResponse> {
async generate(input: GenerateInput, opts?: GenerateOptions): Promise<AiResponse> {
const useVision = !!opts?.visionModel && (input.images?.length ?? 0) > 0;
const model = useVision ? opts!.visionModel! : this.model;
const prompt = useVision
? buildVisionPrompt(input.text, input.todayKst, input.dueDateCandidates.map((c) => c.iso ?? c.matchedToken ?? ''), input.vocab ?? [])
: buildPrompt(input.text, input.todayKst, input.dueDateCandidates, input.vocab ?? []);
this.abortController = new AbortController();
const timer = setTimeout(() => this.abortController?.abort(), this.timeoutMs);
try {
const body: Record<string, unknown> = {
model,
prompt,
format: 'json',
stream: false,
options: { temperature: this.temperature, num_predict: this.numPredict }
};
if (useVision) {
body.images = input.images!.map((i) => i.base64);
}
const res = await request(`${this.endpoint}/api/generate`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
model: this.model,
prompt: buildPrompt(input.text, input.todayKst, input.dueDateCandidates, input.vocab ?? []),
format: 'json',
stream: false,
options: { temperature: this.temperature, num_predict: this.numPredict }
}),
body: JSON.stringify(body),
signal: this.abortController.signal
});
if (res.statusCode < 200 || res.statusCode >= 300) {
throw new Error(`ollama http ${res.statusCode}`);
}
const body = (await res.body.json()) as { response?: string };
if (!body.response) throw new Error('missing response field');
const responseBody = (await res.body.json()) as { response?: string };
if (!responseBody.response) throw new Error('missing response field');
let parsed: unknown;
try { parsed = JSON.parse(body.response); }
try { parsed = JSON.parse(responseBody.response); }
catch (err) { throw new Error(`invalid json in response: ${String(err)}`); }
return parseAiResponse(parsed);
} finally {
@@ -66,6 +85,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' });
@@ -75,7 +127,8 @@ export class LocalOllamaProvider implements InferenceProvider {
return found ? { ok: true, model: this.model }
: { ok: false, reason: `${this.model} not installed` };
} catch (err) {
return { ok: false, reason: `unreachable: ${(err as Error).message}` };
const cls = classifyFetchError(err);
return { ok: false, reason: `unreachable:${cls}` };
}
}
}

View File

@@ -0,0 +1,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

@@ -0,0 +1,17 @@
export function buildVisionPrompt(
text: string,
todayKst: string,
dueCandidates: string[],
vocab: string[]
): string {
return `다음 메모와 첨부 이미지를 종합 분석해 한국어로 요약하세요.
메모 본문 (비어 있을 수 있음):
${text || '(이미지만 있음)'}
이미지 분석 시 주요 시각적 정보 (텍스트, 사람, 장면) 도 포함해 요약하세요.
출력 JSON: { "title": "...", "summary": "...", "tags": [...], "due_date": "..." }
오늘: ${todayKst}
가능한 due 후보: ${dueCandidates.join(', ')}
빈출 태그: ${vocab.slice(0, 20).join(', ')}`;
}

View File

@@ -2,8 +2,12 @@ import type Database from 'better-sqlite3';
import * as m001 from './m001_initial.js';
import * as m002 from './m002_due_date.js';
import * as m003 from './m003_soft_delete.js';
import * as m004 from './m004_status.js';
import * as m005 from './m005_ai_disabled.js';
import * as m006 from './m006_revisions.js';
import * as m007 from './m007_fts.js';
const migrations = [m001, m002, m003];
const migrations = [m001, m002, m003, m004, m005, m006, m007];
export function latestVersion(): number {
return migrations[migrations.length - 1]!.version;

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import electron from 'electron';
const { app, BrowserWindow, Notification, dialog } = electron;
const { app, Notification, dialog } = electron;
import '@shared/types';
import { existsSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
@@ -17,25 +17,59 @@ import { HealthChecker } from './services/HealthChecker.js';
import { LocalOllamaProvider } from './ai/LocalOllamaProvider.js';
import { ProviderHolder } from './ai/ProviderHolder.js';
import { AiWorker } from './ai/AiWorker.js';
import { refreshVisionCache } from './services/VisionDetect.js';
import { registerCaptureApi } from './ipc/captureApi.js';
import { registerInboxApi, pushNoteUpdated, pushOllamaStatus } from './ipc/inboxApi.js';
import { registerSettingsApi, navigateInbox } from './ipc/settingsApi.js';
import { createInboxWindow, getInboxWindow } from './windows/inboxWindow.js';
import {
createQuickCaptureWindow, showQuickCapture, getQuickCaptureWindow
} from './windows/quickCaptureWindow.js';
import { createTray, refreshTray, refreshTrayOllama, refreshTrayFailedCount } from './tray.js';
import { createTray, refreshTray } from './tray.js';
import { MediaGc } from './services/MediaGc.js';
import { BackupService } from './services/BackupService.js';
import { ExportService } from './services/ExportService.js';
import { ImportService } from './services/ImportService.js';
import { SyncService } from './services/SyncService.js';
import { SyncTimer } from './services/SyncTimer.js';
import { TelemetryService } from './services/TelemetryService.js';
import { SettingsService } from './services/SettingsService.js';
import { collectAutostartState } from './services/AutostartDiagnostic.js';
import { registerSchemesAsPrivileged, registerInklingMediaProtocol } from './protocol/inklingMedia.js';
import { DEFAULT_OLLAMA_ENDPOINT, DEFAULT_OLLAMA_MODEL } from '../shared/constants.js';
const HIDDEN_ARG = '--hidden';
const startedHidden = process.argv.includes(HIDDEN_ARG);
// v0.2.8 Cut A — `inkling-media://` custom protocol 스킴은 app.whenReady() 전에
// privileged 등록 필수 (Electron 표준). 이미지 asset 을 main process 가 직접
// 서빙해 file:// hack 없이 작동.
registerSchemesAsPrivileged();
// CRITICAL — single-instance lock + hidden-flag 전달 (v0.2.6 #46).
// 두 번째 .exe 가 hidden 으로 spawn 됐다면 (autostart) 첫 instance 의 inbox 창
// 띄우지 않음 — 사용자가 명시적으로 클릭한 게 아니므로.
const additionalData = { hidden: startedHidden };
const gotLock = app.requestSingleInstanceLock(additionalData);
if (!gotLock) {
app.quit();
} else {
app.on('second-instance', (_e, _argv, _cwd, secondData) => {
const data = secondData as { hidden?: boolean } | undefined;
// 두 번째가 hidden 으로 spawn (autostart 등) — UI 띄우지 않음
if (data?.hidden === true) return;
// 사용자가 명시적으로 .exe / 단축키 / 트레이로 띄움 → inbox 창 보이게
const win = getInboxWindow();
if (win) {
if (win.isMinimized()) win.restore();
if (!win.isVisible()) win.show();
win.focus();
} else {
createInboxWindow();
}
});
}
app.whenReady().then(async () => {
initLogger();
logger.info('app.start', {
@@ -47,6 +81,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 }))
@@ -59,6 +96,8 @@ app.whenReady().then(async () => {
writeFileSync(initFlag, new Date().toISOString());
logger.info('autostart.enabled.firstRun');
}
// v0.2.6 #45 진단 — startup 로그. 같은 정보가 SettingsPage 진단 패널에도 surface (collectAutostartState single source of truth).
void collectAutostartState().then((state) => logger.info('autostart.state', { ...state }));
}
const db = openDb(paths.dbFile);
const repo = new NoteRepository(db);
@@ -84,11 +123,17 @@ app.whenReady().then(async () => {
const provider = new LocalOllamaProvider({ endpoint: resolvedEndpoint, model: resolvedModel });
const providerHolder = new ProviderHolder(provider);
// v0.3.1 Cut F — app launch 시 vision capability cache 갱신 (fire-and-forget).
// 실패 silent (cache 유지). 사용자가 설정 페이지에서 "다시 감지" manual trigger 가능.
void refreshVisionCache({ settings: settingsSvc, endpoint: resolvedEndpoint }).catch(() => {});
const health = new HealthChecker(providerHolder, {
// v0.2.9 Cut B Task 14 — AI 비활성 시 health polling skip (Ollama 미설치 환경 무영향).
isAiEnabled: () => settingsSvc.isAiEnabled(),
onUpdate: (status) => {
logger.info('ai.health', { ...status } as Record<string, unknown>);
pushOllamaStatus(getInboxWindow, status);
refreshTrayOllama(status.ok);
},
onTelemetry: (ev) => {
if (ev.kind === 'ollama_unreachable') {
@@ -106,11 +151,14 @@ app.whenReady().then(async () => {
onUpdate: (note) => {
pushNoteUpdated(getInboxWindow, note);
// F4-C: AI 처리 완료 = 새 캡처가 inbox 에 합류한 시점, tray 도 즉시 갱신.
refreshTray(repo.countToday());
refreshTrayFailedCount(repo.countFailed());
// v0.2.7 Phase 3 — failedCount 메뉴 항목 제거됨 → todayCount 만 갱신.
refreshTray({ todayCount: repo.countToday() });
},
logger,
telemetry
telemetry,
// v0.3.1 Cut F — vision 지원
settings: settingsSvc,
mediaStore: store
});
const notify = new NotificationService({
@@ -123,14 +171,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({
@@ -152,18 +206,29 @@ app.whenReady().then(async () => {
const exportSvc = new ExportService(repo, store);
const importSvc = new ImportService(repo, store);
const syncSvc = new SyncService(paths.profileDir, exportSvc);
const syncSvc = new SyncService(paths.profileDir, exportSvc, importSvc);
const syncTimer = new SyncTimer(syncSvc, settingsSvc);
const backup = new BackupService(db, join(paths.profileDir, 'backups'));
void backup.runDaily()
.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,
syncTimer
});
void syncTimer.start();
let backupOnQuitDone = false;
let trayInterval: NodeJS.Timeout | null = null;
app.on('before-quit', (e) => {
// 모든 cleanup 한 곳에 통합 — sync (idempotent) → async backup chain.
health.stop();
syncTimer.stop();
if (trayInterval !== null) {
clearInterval(trayInterval);
trayInterval = null;
@@ -191,194 +256,34 @@ app.whenReady().then(async () => {
});
});
createTray(
() => createInboxWindow(),
() => showQuickCapture(),
async () => {
try {
const r = await backup.runDaily();
new Notification({
title: 'Inkling',
body: r.snapshotted
? `백업 완료 — ${r.removed?.length ?? 0}개 정리`
: `오늘 백업이 이미 있습니다`,
silent: true
}).show();
} catch (e) {
logger.warn('backup.manual.failed', { reason: String(e) });
new Notification({
title: 'Inkling',
body: '백업을 만들지 못했습니다.',
silent: true
}).show();
}
},
async () => {
const win = getInboxWindow();
const dialogOpts: Electron.OpenDialogOptions = {
title: '내보낼 폴더 선택',
message: '선택한 폴더에 노트를 마크다운으로 내보냅니다. 이미지가 함께 포함됩니다. raw_text 가 평문으로 보관되니 비공개 위치를 권장합니다.',
buttonLabel: '여기에 내보내기',
properties: ['openDirectory', 'createDirectory']
};
const result = win
? await dialog.showOpenDialog(win, dialogOpts)
: await dialog.showOpenDialog(dialogOpts);
if (result.canceled || result.filePaths.length === 0) return;
try {
const r = await exportSvc.export(result.filePaths[0]!, { includeMedia: true });
logger.info('export.done', {
outDir: r.outDir,
noteCount: r.noteCount,
mediaCount: r.mediaCount,
bytes: r.bytes
});
new Notification({
title: 'Inkling',
body: `내보내기 완료 — 노트 ${r.noteCount}개, 이미지 ${r.mediaCount}`,
silent: true
}).show();
} catch (e) {
logger.warn('export.failed', { reason: String(e) });
new Notification({
title: 'Inkling',
body: '내보내기를 완료하지 못했습니다.',
silent: true
}).show();
}
},
async () => {
const win = getInboxWindow();
const dirOpts: Electron.OpenDialogOptions = {
title: '복원할 백업 폴더 선택',
message: 'F5 export 형식의 폴더를 선택하세요. notes/ 하위의 마크다운 파일이 적재됩니다.',
buttonLabel: '여기서 복원',
properties: ['openDirectory']
};
const dirResult = win
? await dialog.showOpenDialog(win, dirOpts)
: await dialog.showOpenDialog(dirOpts);
if (dirResult.canceled || dirResult.filePaths.length === 0) return;
const sourceDir = dirResult.filePaths[0]!;
let plan;
try {
plan = await importSvc.preview(sourceDir);
} catch (e) {
logger.warn('import.preview.failed', { reason: String(e) });
new Notification({
title: 'Inkling',
body: '백업 폴더를 읽지 못했습니다.',
silent: true
}).show();
return;
}
const detail = `${plan.total}개 노트\n · 신규 ${plan.newCount}\n · 동일 (스킵) ${plan.unchangedCount}\n · 충돌→새 id (${plan.forkedCount}개, raw_text 보존)\n\n이미지 ${plan.mediaCount}개 복사 예정.`;
const confirmOpts: Electron.MessageBoxOptions = {
type: 'question',
buttons: ['복원', '취소'],
defaultId: 0,
cancelId: 1,
title: 'Inkling 복원',
message: '복원 미리보기',
detail
};
const confirm = win
? await dialog.showMessageBox(win, confirmOpts)
: await dialog.showMessageBox(confirmOpts);
if (confirm.response !== 0) return;
try {
const r = await importSvc.run(sourceDir);
logger.info('import.done', {
total: r.total,
new: r.newCount,
unchanged: r.unchangedCount,
forked: r.forkedCount,
media: r.mediaCount
});
new Notification({
title: 'Inkling',
body: `복원 완료 — 신규 ${r.newCount}개, 스킵 ${r.unchangedCount}개, 충돌 ${r.forkedCount}`,
silent: true
}).show();
} catch (e) {
logger.warn('import.run.failed', { reason: String(e) });
new Notification({
title: 'Inkling',
body: '복원을 완료하지 못했습니다.',
silent: true
}).show();
}
},
async () => {
// runSync — 트레이 "지금 동기화"
try {
const r = await syncSvc.sync();
if (!r.ok) {
logger.warn('sync.failed', { reason: r.reason });
const body = r.reason === 'not_configured'
? `${syncSvc.getSyncDir()} 에서 git init + remote 설정이 필요합니다.`
: '동기화를 완료하지 못했습니다.';
new Notification({ title: 'Inkling', body, silent: true }).show();
return;
}
if (r.changed) {
logger.info('sync.done', { sha: r.sha, pushed: r.pushed });
new Notification({ title: 'Inkling', body: '동기화 완료', silent: true }).show();
} else {
new Notification({ title: 'Inkling', body: '변경 사항 없음', silent: true }).show();
}
} catch (e) {
logger.warn('sync.exception', { reason: String(e) });
new Notification({ title: 'Inkling', body: '동기화를 완료하지 못했습니다.', silent: true }).show();
}
},
/* 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');
}
);
// v0.2.7 Phase 3 (Task 16) — TrayCallbacks 슬림: 10 → 3.
// 백업/내보내기/복원/동기화/사용 로그/Ollama 재확인/AI 재처리/Ollama 설정/정보 →
// 모두 설정 페이지로 이전 (registerSettingsApi 의 IPC 핸들러가 본문 보유).
createTray({
showInbox: () => createInboxWindow(),
showCapture: () => showQuickCapture(),
showSettings: () => navigateInbox('settings')
});
// F4-C 환경 앵커 — tray tooltip + 메뉴 첫 항목을 오늘 KST 캡처 수로 갱신.
// 초기 1회 + 60s interval. AiWorker.onUpdate 도 별도 갱신 트리거.
// cleanup 은 위 통합 before-quit 핸들러에서 처리.
refreshTray(repo.countToday());
refreshTrayFailedCount(repo.countFailed());
// v0.2.7 Phase 3 — failedCount 메뉴 항목 제거됨 → todayCount 만 갱신.
refreshTray({ todayCount: repo.countToday() });
trayInterval = setInterval(() => {
refreshTray(repo.countToday());
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 {
@@ -39,7 +47,7 @@ export function registerInboxApi(deps: InboxIpcDeps): void {
deps.repo.setDueDate(arg.noteId, arg.date);
});
ipcMain.handle('inbox:delete', async (_e, noteId: string) => {
ipcMain.handle('inbox:trash', async (_e, noteId: string) => {
await deps.capture.deleteNote(noteId);
});
@@ -142,17 +150,133 @@ export function registerInboxApi(deps: InboxIpcDeps): void {
ipcMain.handle('inbox:retryAllFailed', async () => deps.capture.retryAllFailed());
ipcMain.handle('inbox:failedCount', () => deps.repo.countFailed());
// v0.3.9 — per-note retry/cancel. failed/pending 사용자 막힘 해소 path.
// status 변경 후 pushNoteUpdated 로 renderer counts/list 자동 sync.
ipcMain.handle('inbox:retry-one-failed', async (_e, id: string) => {
const r = await deps.capture.retryOneFailed(id);
if (r.ok) {
const updated = deps.repo.findById(id);
if (updated !== null) pushNoteUpdated(deps.getInboxWindow, updated);
}
return r;
});
ipcMain.handle('inbox:cancel-pending', (_e, id: string) => {
const r = deps.capture.cancelPending(id);
if (r.ok) {
const updated = deps.repo.findById(id);
if (updated !== null) pushNoteUpdated(deps.getInboxWindow, updated);
}
return r;
});
ipcMain.handle('inbox:listRecallCandidate', () => deps.capture.listRecallCandidate());
ipcMain.handle('inbox:markRecallOpened', (_e, id: string) => deps.capture.markRecallOpened(id));
ipcMain.handle('inbox:dismissRecall', (_e, id: string) => deps.capture.dismissRecall(id));
ipcMain.handle('inbox:emitRecallShown', (_e, id: string) => deps.capture.emitRecallShown(id));
ipcMain.handle('inbox:emitRecallSnoozed', (_e, id: string) => deps.capture.emitRecallSnoozed(id));
ipcMain.on('inbox:emitRecallShown', (_e, id: string) => { void deps.capture.emitRecallShown(id); });
ipcMain.on('inbox:emitRecallSnoozed', (_e, id: string) => { void deps.capture.emitRecallSnoozed(id); });
ipcMain.handle('inbox:loadOllamaSettings', async () => {
const s = await deps.settings.load();
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).
// v0.3.8 — setStatus 도 pushNoteUpdated emit. 이전엔 emit 안 해서 renderer 의 store
// (counts/search/list) 가 stale 되어 NoteCard 호출처마다 명시적 refreshMeta 호출이
// 필요했음. push 화 시 onNoteUpdated 콜백 1개 path 로 일관 갱신.
ipcMain.handle(
'inbox:set-status',
async (_e, id: string, status: NoteStatus, reason: string | null) => {
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);
const updated = deps.repo.findById(id);
if (updated !== null) pushNoteUpdated(deps.getInboxWindow, updated);
return { ok: true as const };
}
);
// v0.2.9 Cut B Task 9 — AI 자동 분류 (status 추천).
// Ollama provider.generateRaw 호출 + JSON 응답 파싱. 에러/실패 시 archived fallback
// (사용자 데이터 보존 우선). 자세한 prompt + parse 로직은 src/main/ai/classifyStatus.ts.
ipcMain.handle('ai:classify-status', async (_e, id: string, reason: string) => {
const note = deps.repo.findById(id);
if (note === null) {
return {
recommended: 'archived' as const,
rationale: '메모를 찾을 수 없음 — 안전하게 보관 추천'
};
}
const provider = deps.providerHolder.get();
return classifyStatus({
provider,
rawText: note.rawText,
summary: note.aiSummary ?? '',
reason: typeof reason === 'string' ? reason : ''
});
});
// v0.2.9 Cut B Task 16 — disabled 메모 (ai_enabled OFF 시기 캡처) 일괄 재투입.
// OFF→ON 전환 후 사용자가 "지금 모두 처리" 버튼 클릭 path. repo.requeueDisabled 가
// ai_status='pending' + pending_jobs row 보장, worker.enqueue 가 in-memory queue 갱신.
ipcMain.handle('inbox:enqueue-disabled', async () => {
// requeue 전 대상 id 수집 — UPDATE 가 status 바꾸므로 select 후 update 필요 없이
// requeueDisabled 가 처리한 다음 pending_jobs 에서 다시 가져와 enqueue.
const targets = deps.repo.getAllPendingJobs().map((j) => j.noteId);
const before = new Set(targets);
const count = deps.repo.requeueDisabled();
if (count > 0 && deps.enqueue) {
const after = deps.repo.getAllPendingJobs();
// requeue 직후 새로 들어온 pending_jobs row 만 enqueue (기존 row 는 이미 in-memory queue 에).
for (const j of after) {
if (!before.has(j.noteId)) {
await deps.enqueue(j.noteId);
}
}
}
return { count };
});
ipcMain.handle('inbox:get-disabled-count', () => deps.repo.countByAiStatus('disabled'));
ipcMain.handle('inbox:saveOllamaSettings', async (_e, value: { endpoint: string; model: string }) => {
// 검증: 새 인스턴스로 healthCheck
const trial = new LocalOllamaProvider({ endpoint: value.endpoint, model: value.model });
@@ -169,6 +293,49 @@ export function registerInboxApi(deps: InboxIpcDeps): void {
await deps.health.runOnce();
return { ok: true };
});
// v0.2.10 Cut C — raw_text 가변 + revision 보존.
// updateRawText: 빈 문자열 reject (trim 후 length===0). 그 외엔 그대로 (newline/space 보존).
// listRevisions: 그대로 반환 (camelCase 이미 hydrate 됨).
// restoreRevision: repo throw → { ok: false } (UI 가 에러 표시).
ipcMain.handle('inbox:update-raw-text', async (_e, id: string, newText: string) => {
if (typeof newText !== 'string' || newText.trim().length === 0) {
return { ok: false as const, reason: 'empty' as const };
}
deps.repo.updateRawText(id, newText);
return { ok: true as const };
});
ipcMain.handle('inbox:list-revisions', (_e, id: string) => deps.repo.listRevisions(id));
ipcMain.handle('inbox:restore-revision', async (_e, id: string, revId: number) => {
try {
deps.repo.restoreRevision(id, revId);
return { ok: true as const };
} catch (e) {
return { ok: false as const, reason: (e as Error).message };
}
});
// v0.2.11 Cut D — FTS5 검색 + 회고 aggregate.
ipcMain.handle(
'inbox:search',
(_e, query: string, opts: { limit?: number; status?: NoteStatus } = {}) =>
deps.repo.search(query, opts)
);
ipcMain.handle('inbox:review-aggregate', (_e, period: 'daily' | 'weekly' | 'monthly') => {
const VALID = ['daily', 'weekly', 'monthly'] as const;
if (!(VALID as readonly string[]).includes(period)) {
return {
totalCount: 0,
recentNotes: [],
tagCounts: [],
dueProgress: { total: 0, passed: 0, pending: 0 }
};
}
return deps.repo.reviewAggregate(period);
});
}
export function pushNoteUpdated(getWin: () => BrowserWindow | null, note: Note): void {

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

@@ -0,0 +1,414 @@
import electron from 'electron';
import type { BrowserWindow } from 'electron';
import { platform, release, EOL } from 'node:os';
import { mkdir } from 'node:fs/promises';
const { ipcMain, app, dialog, Notification, shell, clipboard } = electron;
import { logger } from '../logger.js';
import type { BackupService } from '../services/BackupService.js';
import type { ExportService } from '../services/ExportService.js';
import type { ImportService } from '../services/ImportService.js';
import type { SyncService } from '../services/SyncService.js';
import { GitClient } from '../services/GitClient.js';
import type { TelemetryService } from '../services/TelemetryService.js';
import type { SettingsService } from '../services/SettingsService.js';
import type { SyncTimer } from '../services/SyncTimer.js';
import { collectAutostartState } from '../services/AutostartDiagnostic.js';
import { getInboxWindow as getInboxWindowSingleton } from '../windows/inboxWindow.js';
import { refreshVisionCache } from '../services/VisionDetect.js';
import { DEFAULT_OLLAMA_ENDPOINT } from '../../shared/constants.js';
/**
* 외부 (트레이 / second-instance / 기타 main 프로세스 호출자) 에서 inbox 창에 view 전환을
* 요청하는 진입점. 창이 숨겨져 있으면 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;
syncTimer?: SyncTimer;
}
/**
* 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:set-sync-auto-enabled', async (_e, value: boolean) => {
await deps.settings.setAutoSyncEnabled(value);
await deps.syncTimer?.reconfigure();
return { ok: true as const };
});
ipcMain.handle('settings:set-sync-interval-min', async (_e, value: number) => {
try {
await deps.settings.setSyncIntervalMin(value);
await deps.syncTimer?.reconfigure();
return { ok: true as const };
} catch (e) {
return { ok: false as const, reason: (e as Error).message };
}
});
ipcMain.handle('settings:run-backup', async () => {
try {
const r = await backup.runDaily();
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.localSha, 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;
});
// v0.3.0 Cut E — sync IPC.
// settings:configure-sync — URL 저장 + git init + remote add (없으면).
// null URL → 저장만 (init 안 함). 빈 문자열도 null 처리.
ipcMain.handle('settings:configure-sync', async (_e, url: string | null) => {
const trimmed = typeof url === 'string' ? url.trim() : '';
const finalUrl = trimmed.length === 0 ? null : trimmed;
try {
await deps.settings.setSyncRepoUrl(finalUrl);
} catch (e) {
return { ok: false as const, reason: `persist failed: ${(e as Error).message}` };
}
if (finalUrl === null) {
await deps.syncTimer?.reconfigure();
return { ok: true as const };
}
// git init + remote add origin
const syncDir = deps.syncSvc.getSyncDir();
try {
await mkdir(syncDir, { recursive: true });
} catch (e) {
return { ok: false as const, reason: `mkdir failed: ${(e as Error).message}` };
}
const git = new GitClient(syncDir);
if (!(await git.isRepo())) {
const init = await git.run(['init']);
if (init.exitCode !== 0) {
return { ok: false as const, reason: `git init failed: ${init.stderr}` };
}
}
if (!(await git.hasRemote())) {
const add = await git.run(['remote', 'add', 'origin', finalUrl]);
if (add.exitCode !== 0) {
return { ok: false as const, reason: `remote add failed: ${add.stderr}` };
}
} else {
// remote exists — update URL
const set = await git.run(['remote', 'set-url', 'origin', finalUrl]);
if (set.exitCode !== 0) {
return { ok: false as const, reason: `remote set-url failed: ${set.stderr}` };
}
}
await deps.syncTimer?.reconfigure();
return { ok: true as const };
});
// settings:test-sync-connection — git ls-remote 결과
ipcMain.handle('settings:test-sync-connection', async () => {
const syncDir = deps.syncSvc.getSyncDir();
const git = new GitClient(syncDir);
if (!(await git.isRepo())) return { ok: false as const, reason: 'not_initialized' };
const r = await git.run(['ls-remote', 'origin']);
if (r.exitCode !== 0) return { ok: false as const, reason: r.stderr || 'connection failed' };
return { ok: true as const };
});
// sync:list-conflicts — SyncService 캐시 결과
ipcMain.handle('sync:list-conflicts', () => deps.syncSvc.listConflicts());
// sync:resolve-conflict — local/remote 2 choice. path = git index conflict 경로.
ipcMain.handle('sync:resolve-conflict', async (_e, path: string, choice: 'local' | 'remote') => {
if (choice !== 'local' && choice !== 'remote') {
return { ok: false as const, reason: 'invalid choice' };
}
return deps.syncSvc.resolveConflict(path, choice);
});
// sync:get-status — lastAt + lastResult + nextAt 계산
ipcMain.handle('sync:get-status', async () => {
const last = deps.syncSvc.getLastStatus();
let nextAt: string | null = null;
if (await deps.settings.isAutoSyncEnabled()) {
const intervalMin = await deps.settings.getSyncIntervalMin();
const baseMs = last.lastAt ? new Date(last.lastAt).getTime() : Date.now();
nextAt = new Date(baseMs + intervalMin * 60 * 1000).toISOString();
}
return { lastAt: last.lastAt, lastResult: last.lastResult, nextAt };
});
// v0.3.1 Cut F — vision IPC
ipcMain.handle('settings:get-vision-models', async () => {
const cache = await deps.settings.getVisionCapableCache();
const selected = await deps.settings.getVisionModel();
return { models: cache.models, at: cache.at, selected };
});
ipcMain.handle('settings:set-vision-model', async (_e, value: string | null) => {
const sanitized = typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
await deps.settings.setVisionModel(sanitized);
return { ok: true as const };
});
ipcMain.handle('settings:refresh-vision-cache', async () => {
// Cut F final review fix — index.ts 의 resolvedEndpoint (settings → env → default)
// 와 동일한 fallback 체인 사용. settings.ollama 미설정 + env / default 만 있는 dev
// 환경에서도 manual "다시 감지" 가 동작하도록.
const all = await deps.settings.getAll();
const endpoint = all.ollama?.endpoint
?? process.env.INKLING_OLLAMA_ENDPOINT
?? DEFAULT_OLLAMA_ENDPOINT;
return refreshVisionCache({ settings: deps.settings, endpoint });
});
}

View File

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

View File

@@ -1,9 +1,17 @@
import type Database from 'better-sqlite3';
import { v7 as uuidv7, v4 as uuidv4 } from 'uuid';
import type { Note, NoteMedia, NoteTag } from '@shared/types';
import { todayInKstString } from '../util/kstDate.js';
import type { AiStatus, Note, NoteMedia, NoteRevision, NoteStatus, NoteTag } from '@shared/types';
import { kstTodayIso, KST_OFFSET_MS } from '../../shared/util/kstDate.js';
import { sanitizeFtsQuery, computeCutoff, type ReviewPeriod } from './ftsHelpers.js';
export interface CreateNoteInput { rawText: string; }
export interface CreateNoteInput {
rawText: string;
/**
* v0.2.9 Cut B — settings.ai_enabled=false 일 때 'disabled' 로 insert + pending_jobs skip.
* 미지정 시 기존 'pending' default + pending_jobs enqueue (backward compat).
*/
aiStatus?: AiStatus;
}
export interface NewMediaRow {
noteId: string;
@@ -15,7 +23,10 @@ export interface NewMediaRow {
export interface ImportNoteInput {
/** Proposed id from the export file. May be replaced if it collides with
* an existing row whose `raw_text` differs (raw_text invariant guard). */
* an existing row whose `raw_text` differs — fork-on-conflict so a single
* id never resolves to two distinct historical baselines (v0.2.10 Cut C
* changed `raw_text 불변` policy → `raw_text 가변` + revision history; the
* baseline distinction is now preserved per-id, edit history per-note). */
id: string;
rawText: string;
createdAt: string;
@@ -40,23 +51,54 @@ export interface ImportNoteResult {
status: ImportNoteStatus;
}
export interface UpsertFromSyncInput {
id: string;
rawText: string;
createdAt: string;
updatedAt: string;
aiTitle: string | null;
aiSummary: string | null;
titleEditedByUser: boolean;
summaryEditedByUser: boolean;
aiProvider: string | null;
aiGeneratedAt: string | null;
userIntent: string | null;
intentPromptedAt: string | null;
tags: { name: string; source: 'ai' | 'user' }[];
status: NoteStatus;
statusChangedAt: string | null;
moveReason: string | null;
dueDate: string | null;
dueDateEditedByUser: boolean;
}
export type UpsertFromSyncStatus = 'inserted' | 'updated' | 'skipped';
const KEBAB_CASE_RE = /^[a-z0-9-]+$/;
export class NoteRepository {
constructor(private db: Database.Database) {}
create(input: CreateNoteInput): { id: string } {
create(input: CreateNoteInput, now: Date = new Date()): { id: string } {
const id = uuidv7();
const now = new Date().toISOString();
const ts = now.toISOString();
const aiStatus: AiStatus = input.aiStatus ?? 'pending';
const tx = this.db.transaction(() => {
this.db
.prepare(`INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at)
VALUES (?, ?, 'pending', ?, ?)`)
.run(id, input.rawText, now, now);
VALUES (?, ?, ?, ?, ?)`)
.run(id, input.rawText, aiStatus, ts, ts);
this.db
.prepare(`INSERT INTO pending_jobs (note_id, attempts, next_run_at)
VALUES (?, 0, ?)`)
.run(id, now);
.prepare(`INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by)
VALUES (?, ?, ?, 'capture')`)
.run(id, input.rawText, ts);
// pending_jobs 는 'pending' 일 때만 생성 — 'disabled' 노트는 worker 가 처리 안 함.
if (aiStatus === 'pending') {
this.db
.prepare(`INSERT INTO pending_jobs (note_id, attempts, next_run_at)
VALUES (?, 0, ?)`)
.run(id, ts);
}
});
tx();
return { id };
@@ -78,7 +120,7 @@ export class NoteRepository {
}
findById(id: string): Note | null {
const row = this.db.prepare('SELECT * FROM notes WHERE id=?').get(id) as any;
const row = this.db.prepare('SELECT * FROM notes WHERE id=?').get(id) as Record<string, unknown>;
if (!row) return null;
return this.hydrate(row);
}
@@ -92,21 +134,21 @@ export class NoteRepository {
WHERE deleted_at IS NULL AND created_at < ?
ORDER BY created_at DESC, id DESC LIMIT ?`
)
.all(opts.cursor, limit) as any[])
.all(opts.cursor, limit) as Record<string, unknown>[])
: (this.db
.prepare(
`SELECT * FROM notes
WHERE deleted_at IS NULL
ORDER BY created_at DESC, id DESC LIMIT ?`
)
.all(limit) as any[]);
.all(limit) as Record<string, unknown>[]);
return rows.map((r) => this.hydrate(r));
}
listAll(): Note[] {
const rows = this.db
.prepare(`SELECT * FROM notes WHERE deleted_at IS NULL ORDER BY created_at ASC, id ASC`)
.all() as any[];
.all() as Record<string, unknown>[];
return rows.map((r) => this.hydrate(r));
}
@@ -143,6 +185,7 @@ export class NoteRepository {
linkTag.run(id, tagRow.id);
}
this.db.prepare(`DELETE FROM pending_jobs WHERE note_id=?`).run(id);
this.rebuildFtsTagsForNote(id);
});
tx();
}
@@ -205,6 +248,91 @@ export class NoteRepository {
return { ids };
}
/**
* v0.3.9 — 단일 failed 노트 재시도. retryAllFailed 의 per-row 로직 동일.
* NoteCard 의 per-note "재시도" 버튼 path. failed 가 아닌 status 면 no-op.
*/
retryOneFailed(id: string, now: string): { ok: boolean } {
const row = this.db
.prepare(`SELECT ai_status FROM notes WHERE id=? AND deleted_at IS NULL`)
.get(id) as { ai_status: string } | undefined;
if (!row || row.ai_status !== 'failed') return { ok: false };
const tx = this.db.transaction(() => {
this.db
.prepare(`UPDATE notes SET ai_status='pending', ai_error=NULL, updated_at=? WHERE id=?`)
.run(now, id);
this.db
.prepare(`INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)`)
.run(id, now);
});
tx();
return { ok: true };
}
/**
* v0.3.9 — pending 노트의 AI 처리 cancel. ai_status='disabled' 로 전환 + pending_jobs 삭제.
* raw_text 는 보존. 사용자가 무한 pending (Ollama 끊김 등) 에서 빠져나오는 path.
* pending 외 status 면 no-op.
*/
cancelPending(id: string, now: string): { ok: boolean } {
const row = this.db
.prepare(`SELECT ai_status FROM notes WHERE id=? AND deleted_at IS NULL`)
.get(id) as { ai_status: string } | undefined;
if (!row || row.ai_status !== 'pending') return { ok: false };
const tx = this.db.transaction(() => {
this.db
.prepare(`UPDATE notes SET ai_status='disabled', ai_error=NULL, updated_at=? WHERE id=?`)
.run(now, id);
this.db.prepare(`DELETE FROM pending_jobs WHERE note_id=?`).run(id);
});
tx();
return { ok: true };
}
/**
* v0.2.9 Cut B Task 16 — 모든 ai_status='disabled' 노트를 'pending' 으로 reset 하고
* pending_jobs 재투입. 사용자가 settings.ai_enabled OFF→ON 전환 후 "지금 모두 처리"
* 버튼을 누른 path. 단일 transaction. 호출자가 `now` 주입 가능 (테스트성).
*
* INSERT OR IGNORE — race 안전 (이미 pending_jobs row 존재 시 skip).
* 반환값 = 처리된 노트 수 (UI 가 "N건 처리됨" 토스트 등 표시용).
*/
requeueDisabled(now: Date = new Date()): number {
const tx = this.db.transaction(() => {
const ts = now.toISOString();
const targets = this.db
.prepare(`SELECT id FROM notes WHERE ai_status='disabled'`)
.all() as Array<{ id: string }>;
for (const { id } of targets) {
this.db
.prepare(`UPDATE notes SET ai_status='pending', updated_at=? WHERE id=?`)
.run(ts, id);
this.db
.prepare(
`INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)`
)
.run(id, ts);
}
return targets.length;
});
return tx();
}
/**
* v0.2.9 Cut B Task 16 — ai_status 별 row count.
* 설정 페이지의 "원문만 저장된 메모 N건" 표기용 (status='disabled' 카운트).
* deleted_at 필터 없음 — disabled 메모도 trash 갈 수 있는데 사용자 의도는
* "AI 처리할 게 얼마나 남았나?" 라 trashed 까지 포함되면 안 됨. → deleted_at IS NULL 추가.
*/
countByAiStatus(status: AiStatus): number {
const row = this.db
.prepare(
`SELECT COUNT(*) AS c FROM notes WHERE ai_status=? AND deleted_at IS NULL`
)
.get(status) as { c: number };
return row.c;
}
/**
* pending_jobs 의 next_run_at + last_error 만 갱신, attempts 변경 없음.
* v0.2.3 #2 — unreachable/timeout 무한 retry 시 사용 (incrementJobAttempt 와 별도 경로).
@@ -328,6 +456,7 @@ export class NoteRepository {
const row = getOrInsert.get(t) as { id: number };
link.run(id, row.id);
}
this.rebuildFtsTagsForNote(id);
}
});
tx();
@@ -410,6 +539,251 @@ export class NoteRepository {
.run(now, id);
}
/**
* v0.2.10 Cut C — 사용자가 raw_text 정정. notes.raw_text 갱신 + note_revisions 에
* edited_by='user' 새 row INSERT. 단일 transaction. 호출자 `now` 주입 가능 (테스트성).
*
* 옛 raw_text 는 backfill (m006) 으로 capture revision 에 이미 보존됨.
*/
updateRawText(id: string, newText: string, now: Date = new Date()): void {
const ts = now.toISOString();
const tx = this.db.transaction(() => {
this.db
.prepare(`UPDATE notes SET raw_text=?, updated_at=? WHERE id=?`)
.run(newText, ts, id);
this.db
.prepare(
`INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by)
VALUES (?, ?, ?, 'user')`
)
.run(id, newText, ts);
});
tx();
}
/**
* v0.2.10 Cut C — 노트의 모든 revision (capture + user) 을 최신순 반환.
* NoteCard 의 "이력" modal 에서 사용. edited_at DESC + rev_id DESC tiebreak.
*/
listRevisions(id: string): NoteRevision[] {
const rows = this.db
.prepare(
`SELECT rev_id, note_id, raw_text, edited_at, edited_by
FROM note_revisions
WHERE note_id = ?
ORDER BY edited_at DESC, rev_id DESC`
)
.all(id) as Array<{
rev_id: number;
note_id: string;
raw_text: string;
edited_at: string;
edited_by: 'user' | 'capture';
}>;
return rows.map((r) => ({
revId: r.rev_id,
noteId: r.note_id,
rawText: r.raw_text,
editedAt: r.edited_at,
editedBy: r.edited_by
}));
}
/**
* v0.2.10 Cut C — 옛 revision 의 raw_text 를 latest 로 복원. chain 끊지 않고
* 새 user revision 으로 INSERT (linear history 유지). revId 가 해당 note 의 것이
* 아니면 throw — restore 대상 잘못 매칭 방지.
*/
restoreRevision(id: string, revId: number, now: Date = new Date()): void {
const rev = this.db
.prepare(`SELECT raw_text FROM note_revisions WHERE rev_id=? AND note_id=?`)
.get(revId, id) as { raw_text: string } | undefined;
if (!rev) throw new Error(`revision ${revId} not found for note ${id}`);
this.updateRawText(id, rev.raw_text, now);
}
/**
* v0.2.9 Cut B — 노트 status 4분기 전이 (active/completed/archived/trashed).
* status + status_changed_at + move_reason + updated_at 갱신 + deleted_at
* backward-compat 동기화 (status='trashed' → deleted_at=ts, 그 외 → NULL).
*
* 단일 transaction. 호출자가 `now` 주입 가능 (테스트성).
*/
setStatus(
id: string,
status: NoteStatus,
reason: string | null,
now: Date = new Date()
): void {
const tx = this.db.transaction(() => {
const ts = now.toISOString();
this.db
.prepare(
`UPDATE notes
SET status = ?,
move_reason = ?,
status_changed_at = ?,
updated_at = ?
WHERE id = ?`
)
.run(status, reason, ts, ts, id);
// backward compat: deleted_at 컬럼은 m004 이후로도 status='trashed' 와 동기화.
if (status === 'trashed') {
this.db.prepare(`UPDATE notes SET deleted_at = ? WHERE id = ?`).run(ts, id);
} else {
this.db.prepare(`UPDATE notes SET deleted_at = NULL WHERE id = ?`).run(id);
}
});
tx();
}
/**
* v0.2.9 Cut B Task 4 — status 별 row count. 4탭 헤더 badge 용.
* tags/media hydrate 없음 (cheap path, listByStatus 와 별도).
*/
countByStatus(status: NoteStatus): number {
const row = this.db
.prepare(`SELECT COUNT(*) AS c FROM notes WHERE status = ?`)
.get(status) as { c: number };
return row.c;
}
/**
* v0.2.9 Cut B — status 별 노트 목록. status_changed_at DESC (최근 전이 우선),
* NULL 은 created_at fallback. limit cap 200 (list/listTrashed 와 동일).
*/
listByStatus(status: NoteStatus, opts: { limit?: number } = {}): Note[] {
const limit = Math.max(1, Math.min(200, opts.limit ?? 200));
const rows = this.db
.prepare(
`SELECT * FROM notes
WHERE status = ?
ORDER BY COALESCE(status_changed_at, created_at) DESC, id DESC
LIMIT ?`
)
.all(status, limit) as Record<string, unknown>[];
return rows.map((r) => this.hydrate(r));
}
/**
* v0.2.11 Cut D — FTS5 검색. notes_fts MATCH + rank 정렬 + 기본 trashed 제외.
* 빈/공백 query → []. multi-token 은 implicit AND. FTS5 special chars 는 sanitize.
*/
search(query: string, opts: { limit?: number; status?: NoteStatus } = {}): Note[] {
const sanitized = sanitizeFtsQuery(query);
if (sanitized.length === 0) return [];
const limit = Math.max(1, Math.min(200, opts.limit ?? 50));
const statusClause = opts.status ? `AND n.status = ?` : `AND n.status != 'trashed'`;
const sql = `
SELECT n.* FROM notes n
JOIN notes_fts f ON n.id = f.note_id
WHERE notes_fts MATCH ? ${statusClause}
ORDER BY rank
LIMIT ?
`;
const args: unknown[] = opts.status ? [sanitized, opts.status, limit] : [sanitized, limit];
const rows = this.db.prepare(sql).all(...args) as Record<string, unknown>[];
return rows.map((r) => this.hydrate(r));
}
/**
* v0.2.11 Cut D — 회고 view aggregate. period 별 KST 자정 cutoff 이후 노트
* (status != 'trashed') 의 totalCount / recentNotes(50) / tagCounts(DESC) /
* dueProgress(passed/pending KST today 기준).
*/
reviewAggregate(period: ReviewPeriod, now: Date = new Date()): {
totalCount: number;
recentNotes: Note[];
tagCounts: Array<{ tag: string; count: number }>;
dueProgress: { total: number; passed: number; pending: number };
} {
const cutoff = computeCutoff(period, now);
const todayIso = kstTodayIso(now);
const totalCount = (this.db
.prepare(`SELECT COUNT(*) AS c FROM notes WHERE created_at >= ? AND status != 'trashed'`)
.get(cutoff) as { c: number }).c;
const recentRows = this.db
.prepare(
`SELECT * FROM notes
WHERE created_at >= ? AND status != 'trashed'
ORDER BY created_at DESC, id DESC LIMIT 50`
)
.all(cutoff) as Record<string, unknown>[];
const recentNotes = recentRows.map((r) => this.hydrate(r));
const tagCounts = this.db
.prepare(
`SELECT t.name AS tag, COUNT(*) AS count
FROM note_tags nt
JOIN notes n ON n.id = nt.note_id
JOIN tags t ON t.id = nt.tag_id
WHERE n.created_at >= ? AND n.status != 'trashed'
GROUP BY t.id
ORDER BY count DESC, t.name ASC`
)
.all(cutoff) as Array<{ tag: string; count: number }>;
const dueRow = this.db
.prepare(
`SELECT
COUNT(*) AS total,
SUM(CASE WHEN due_date < ? THEN 1 ELSE 0 END) AS passed,
SUM(CASE WHEN due_date >= ? THEN 1 ELSE 0 END) AS pending
FROM notes
WHERE created_at >= ?
AND status != 'trashed'
AND due_date IS NOT NULL`
)
.get(todayIso, todayIso, cutoff) as { total: number; passed: number | null; pending: number | null };
const dueProgress = {
total: dueRow.total,
passed: dueRow.passed ?? 0,
pending: dueRow.pending ?? 0
};
return { totalCount, recentNotes, tagCounts, dueProgress };
}
/**
* 휴지통에서 active 로 복원. setStatus('active') 로 status + deleted_at 동기화 +
* v0.2.6 #10 round 1 fix 보존 (ai_status='failed' / 'pending' 시 pending_jobs 재투입).
*/
restoreNote(id: string): void {
const tx = this.db.transaction(() => {
const before = this.db
.prepare(`SELECT ai_status FROM notes WHERE id = ?`)
.get(id) as { ai_status: string } | undefined;
// setStatus('active', null) — reason clear + deleted_at NULL + updated_at 갱신.
this.setStatus(id, 'active', null);
const now = new Date().toISOString();
// v0.2.6 #10 — failed 노트 restore 시 pending 으로 reset + pending_jobs 재생성
if (before?.ai_status === 'failed') {
this.db
.prepare(
`UPDATE notes SET ai_status='pending', ai_error=NULL, updated_at=? WHERE id=?`
)
.run(now, id);
this.db
.prepare(
`INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)`
)
.run(id, now);
} else if (before?.ai_status === 'pending') {
// pending 인 채로 trash 됐다면 pending_jobs 도 미정상 상태일 수 있음 — 재생성 (idempotent)
this.db
.prepare(
`INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)`
)
.run(id, now);
}
// done 노트는 재처리 안 함 (이미 결과 있음)
});
tx();
}
permanentDelete(id: string): void {
this.db.prepare('DELETE FROM notes WHERE id=?').run(id);
}
@@ -428,7 +802,7 @@ export class NoteRepository {
const limit = Math.max(1, Math.min(200, opts.limit));
const rows = this.db
.prepare(`SELECT * FROM notes WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC, id DESC LIMIT ?`)
.all(limit) as any[];
.all(limit) as Record<string, unknown>[];
return rows.map((r) => this.hydrate(r));
}
@@ -458,11 +832,22 @@ export class NoteRepository {
/**
* Import a note from an external source (F5 export tree).
* Conflict policy:
* Conflict policy (fork-on-id-collision):
* - id missing in DB → INSERT (status: 'inserted')
* - id present + raw_text identical → no-op (status: 'skipped')
* - id present + raw_text differs → INSERT under fresh uuidv7
* to preserve the raw_text-immutable invariant (status: 'forked')
* - id present + raw_text differs → INSERT under fresh uuidv7 so the same id
* never points at two different baselines (status: 'forked'). v0.2.10 Cut C
* relaxed the `raw_text 불변` policy → `raw_text 가변 + note_revisions 보존`,
* but per-id baseline distinction is still required for sync determinism.
*
* v0.2.10 Cut C — INSERT/fork 시 동일 transaction 안에서 note_revisions 에
* 'capture' 첫 revision INSERT (createdAt = edited_at). 미수행 시 first user
* edit 직후 import 시점 본문이 history 에서 사라지는 회귀 (final review 발견).
*
* v0.2.11 Cut D — INSERT/fork 시 tags 추가 후 rebuildFtsTagsForNote(finalId)
* 호출 — m007 trigger 가 빈 tags='' 로 FTS row 만들고, note_tags INSERT 만으로는
* notes_fts.tags 갱신 안 됨. 미수행 시 import 한 노트가 tag keyword 검색에서
* 매칭 안 되는 회귀 (final review 발견).
*
* deletedAt merge (v0.2.3 #4, spec §8.2): source/dest 중 IS NOT NULL 우선
* (삭제 보존). skip 케이스에서 source NN + dest NULL 일 때만 dest 갱신.
@@ -513,6 +898,12 @@ export class NoteRepository {
input.createdAt,
input.updatedAt
);
this.db
.prepare(
`INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by)
VALUES (?, ?, ?, 'capture')`
)
.run(finalId, input.rawText, input.createdAt);
if (input.tags.length > 0) {
const getOrInsertTag = this.db.prepare(
`INSERT INTO tags(name) VALUES(?) ON CONFLICT(name) DO UPDATE SET name=name RETURNING id`
@@ -528,12 +919,151 @@ export class NoteRepository {
if (t.source === 'ai') linkAi.run(finalId, row.id);
else linkUser.run(finalId, row.id);
}
// v0.2.11 Cut D — note_tags 변경 후 notes_fts.tags 동기화 (single write path).
this.rebuildFtsTagsForNote(finalId);
}
});
tx();
return { id: finalId, status };
}
/**
* v0.3.0 Cut E — sync 전용 upsert. 기존 importNote 의 fork-on-id-collision 정책은
* sync 에 부적합 (양 기기 raw_text 가 다를 때마다 fork → 노트 갯수 무한 증가).
*
* 3 분기:
* - id 없음 → INSERT (capture revision + tags FTS sync)
* - id 있음 + raw_text 동일 → source.updatedAt 가 더 최신일 때만 metadata 갱신
* - id 있음 + raw_text 다름 → source 가 더 최신이면 updateRawText (new user revision),
* local 이 더 최신이면 skip
*
* tags 변경 시 rebuildFtsTagsForNote 호출 — Cut D single write path 재사용.
* raw_text 변경 시 updateRawText 호출 — Cut C single write path 재사용.
*/
upsertFromSync(input: UpsertFromSyncInput): { id: string; status: UpsertFromSyncStatus } {
const existing = this.db
.prepare(`SELECT raw_text, updated_at, status FROM notes WHERE id=?`)
.get(input.id) as { raw_text: string; updated_at: string; status: NoteStatus } | undefined;
if (!existing) {
// INSERT path
const tx = this.db.transaction(() => {
this.db
.prepare(
`INSERT INTO notes
(id, raw_text, ai_title, ai_summary, ai_status, ai_provider, ai_generated_at,
title_edited_by_user, summary_edited_by_user,
user_intent, intent_prompted_at,
created_at, updated_at,
due_date, due_date_edited_by_user,
status, status_changed_at, move_reason)
VALUES (?, ?, ?, ?, 'done', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
input.id,
input.rawText,
input.aiTitle,
input.aiSummary,
input.aiProvider,
input.aiGeneratedAt,
input.titleEditedByUser ? 1 : 0,
input.summaryEditedByUser ? 1 : 0,
input.userIntent,
input.intentPromptedAt,
input.createdAt,
input.updatedAt,
input.dueDate,
input.dueDateEditedByUser ? 1 : 0,
input.status,
input.statusChangedAt,
input.moveReason
);
this.db
.prepare(
`INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by)
VALUES (?, ?, ?, 'capture')`
)
.run(input.id, input.rawText, input.createdAt);
if (input.tags.length > 0) {
const getOrInsertTag = this.db.prepare(
`INSERT INTO tags(name) VALUES(?) ON CONFLICT(name) DO UPDATE SET name=name RETURNING id`
);
const linkAi = this.db.prepare(
`INSERT OR IGNORE INTO note_tags(note_id, tag_id, source) VALUES(?, ?, 'ai')`
);
const linkUser = this.db.prepare(
`INSERT OR IGNORE INTO note_tags(note_id, tag_id, source) VALUES(?, ?, 'user')`
);
for (const t of input.tags) {
const row = getOrInsertTag.get(t.name) as { id: number };
if (t.source === 'ai') linkAi.run(input.id, row.id);
else linkUser.run(input.id, row.id);
}
this.rebuildFtsTagsForNote(input.id);
}
});
tx();
return { id: input.id, status: 'inserted' };
}
if (input.updatedAt <= existing.updated_at) {
return { id: input.id, status: 'skipped' };
}
if (existing.raw_text !== input.rawText) {
this.updateRawText(input.id, input.rawText, new Date(input.updatedAt));
}
const tx = this.db.transaction(() => {
this.db
.prepare(
`UPDATE notes
SET ai_title = CASE WHEN title_edited_by_user = 1 THEN ai_title ELSE ? END,
ai_summary = CASE WHEN summary_edited_by_user = 1 THEN ai_summary ELSE ? END,
ai_provider = ?,
ai_generated_at = ?,
due_date = CASE WHEN due_date_edited_by_user = 1 THEN due_date ELSE ? END,
status = ?,
status_changed_at = ?,
move_reason = ?,
updated_at = ?
WHERE id = ?`
)
.run(
input.aiTitle,
input.aiSummary,
input.aiProvider,
input.aiGeneratedAt,
input.dueDate,
input.status,
input.statusChangedAt,
input.moveReason,
input.updatedAt,
input.id
);
this.db.prepare(`DELETE FROM note_tags WHERE note_id=?`).run(input.id);
if (input.tags.length > 0) {
const getOrInsertTag = this.db.prepare(
`INSERT INTO tags(name) VALUES(?) ON CONFLICT(name) DO UPDATE SET name=name RETURNING id`
);
const linkAi = this.db.prepare(
`INSERT OR IGNORE INTO note_tags(note_id, tag_id, source) VALUES(?, ?, 'ai')`
);
const linkUser = this.db.prepare(
`INSERT OR IGNORE INTO note_tags(note_id, tag_id, source) VALUES(?, ?, 'user')`
);
for (const t of input.tags) {
const row = getOrInsertTag.get(t.name) as { id: number };
if (t.source === 'ai') linkAi.run(input.id, row.id);
else linkUser.run(input.id, row.id);
}
}
this.rebuildFtsTagsForNote(input.id);
});
tx();
return { id: input.id, status: 'updated' };
}
getPendingCount(): number {
const row = this.db
.prepare(
@@ -550,7 +1080,6 @@ export class NoteRepository {
* and count rows whose UTC ISO `created_at` lies inside.
*/
countToday(now: Date = new Date()): number {
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
const kstNow = new Date(now.getTime() + KST_OFFSET_MS);
const kstYear = kstNow.getUTCFullYear();
const kstMonth = kstNow.getUTCMonth();
@@ -576,7 +1105,7 @@ export class NoteRepository {
* Caller may inject `now` for testability; defaults to `new Date()`.
*/
findExpiredCandidates(now: Date = new Date()): Note[] {
const today = todayInKstString(now);
const today = kstTodayIso(now);
const rows = this.db
.prepare(
`SELECT * FROM notes
@@ -586,18 +1115,18 @@ export class NoteRepository {
AND ai_status = 'done'
ORDER BY created_at DESC, id DESC`
)
.all(today) as any[];
.all(today) as Record<string, unknown>[];
return rows.map((r) => this.hydrate(r));
}
getAllPendingJobs(): Array<{ noteId: string; attempts: number; nextRunAt: string }> {
const rows = this.db
.prepare(`SELECT note_id, attempts, next_run_at FROM pending_jobs`)
.all() as any[];
.all() as Record<string, unknown>[];
return rows.map((r) => ({
noteId: r.note_id,
attempts: r.attempts,
nextRunAt: r.next_run_at
noteId: r.note_id as string,
attempts: r.attempts as number,
nextRunAt: r.next_run_at as string
}));
}
@@ -613,39 +1142,59 @@ export class NoteRepository {
.run(nextRunAt, lastError.slice(0, 500), noteId);
}
private hydrate(row: any): Note {
/**
* v0.2.11 Cut D — note_tags 변경 후 notes_fts.tags 컬럼 (csv) 재구성.
* 단일 write path 패턴: tags 변경하는 모든 메서드가 같은 transaction 끝에서 호출.
*/
private rebuildFtsTagsForNote(noteId: string): void {
const row = this.db
.prepare(
`SELECT COALESCE(GROUP_CONCAT(t.name, ' '), '') AS csv
FROM note_tags nt JOIN tags t ON t.id = nt.tag_id
WHERE nt.note_id = ?`
)
.get(noteId) as { csv: string };
this.db
.prepare(`UPDATE notes_fts SET tags = ? WHERE note_id = ?`)
.run(row.csv, noteId);
}
private hydrate(row: Record<string, unknown>): Note {
const tags = this.db
.prepare(
`SELECT t.name, nt.source
FROM note_tags nt JOIN tags t ON t.id = nt.tag_id
WHERE nt.note_id = ? ORDER BY t.name`
)
.all(row.id) as Array<{ name: string; source: 'ai' | 'user' }>;
.all(row.id as string) as Array<{ name: string; source: 'ai' | 'user' }>;
const media = this.db
.prepare(
`SELECT id, kind, rel_path as relPath, mime, bytes FROM media WHERE note_id=?`
)
.all(row.id) as NoteMedia[];
.all(row.id as string) as NoteMedia[];
return {
id: row.id,
rawText: row.raw_text,
aiTitle: row.ai_title,
aiSummary: row.ai_summary,
aiStatus: row.ai_status,
aiError: row.ai_error,
aiProvider: row.ai_provider,
aiGeneratedAt: row.ai_generated_at,
titleEditedByUser: row.title_edited_by_user === 1,
summaryEditedByUser: row.summary_edited_by_user === 1,
userIntent: row.user_intent,
intentPromptedAt: row.intent_prompted_at,
dueDate: row.due_date ?? null,
dueDateEditedByUser: row.due_date_edited_by_user === 1,
deletedAt: row.deleted_at ?? null,
lastRecalledAt: row.last_recalled_at ?? null,
recallDismissedAt: row.recall_dismissed_at ?? null,
createdAt: row.created_at,
updatedAt: row.updated_at,
id: row.id as string,
rawText: row.raw_text as string,
aiTitle: row.ai_title as string | null,
aiSummary: row.ai_summary as string | null,
aiStatus: row.ai_status as AiStatus,
aiError: row.ai_error as string | null,
aiProvider: row.ai_provider as string | null,
aiGeneratedAt: row.ai_generated_at as string | null,
titleEditedByUser: (row.title_edited_by_user as number) === 1,
summaryEditedByUser: (row.summary_edited_by_user as number) === 1,
userIntent: row.user_intent as string | null,
intentPromptedAt: row.intent_prompted_at as string | null,
dueDate: (row.due_date as string | null) ?? null,
dueDateEditedByUser: (row.due_date_edited_by_user as number) === 1,
deletedAt: (row.deleted_at as string | null) ?? null,
lastRecalledAt: (row.last_recalled_at as string | null) ?? null,
recallDismissedAt: (row.recall_dismissed_at as string | null) ?? null,
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[],
media
};

View File

@@ -0,0 +1,35 @@
/**
* v0.2.11 Cut D — FTS5 검색 + 회고 view 의 순수 함수 헬퍼.
*/
import { KST_OFFSET_MS } from '../../shared/util/kstDate.js';
// FTS5 special chars: " * ( ) : 외에도 - (NOT 연산자), ^ (column prefix), ` (escape),
// AND/OR/NOT keyword 도 query parser 가 special 처리. v0.3.9 — backtick/dash/caret 추가.
// 한국어 사용자가 의도 없이 입력할 가능성 가장 높은 punctuation 까지 sanitize.
const FTS5_SPECIAL_CHARS_RE = /["*():`^\-]/g;
const WS_COLLAPSE_RE = /\s+/g;
/**
* FTS5 MATCH 쿼리에 안전한 형태로 변환. special chars 공백 치환 + 공백 정리.
* 다중 토큰은 그대로 두어 FTS5 implicit AND 활용.
*/
export function sanitizeFtsQuery(input: string): string {
return input.replace(FTS5_SPECIAL_CHARS_RE, ' ').replace(WS_COLLAPSE_RE, ' ').trim();
}
export type ReviewPeriod = 'daily' | 'weekly' | 'monthly';
/**
* 회고 cutoff = period 시작점의 KST 자정 (UTC ISO).
* daily = 오늘 0시, weekly = 7일 전 0시, monthly = 30일 전 0시.
*/
export function computeCutoff(period: ReviewPeriod, now: Date): string {
const kstNow = new Date(now.getTime() + KST_OFFSET_MS);
const y = kstNow.getUTCFullYear();
const m = kstNow.getUTCMonth();
const d = kstNow.getUTCDate();
const todayMidKstUtc = Date.UTC(y, m, d) - KST_OFFSET_MS;
const days = period === 'daily' ? 0 : period === 'weekly' ? 7 : 30;
return new Date(todayMidKstUtc - days * 24 * 60 * 60 * 1000).toISOString();
}

View File

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

View File

@@ -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 };
}
@@ -88,9 +106,14 @@ export class CaptureService {
async restoreNote(noteId: string): Promise<void> {
// 이미 active 인 노트는 telemetry emit skip — restore/trash ratio 오염 방지.
const note = this.repo.findById(noteId);
if (!note || note.deletedAt === null) return;
this.repo.restore(noteId);
const before = this.repo.findById(noteId);
if (!before || before.deletedAt === null) return;
// v0.2.6 #10 — production path: repo.restoreNote (ai_status reset + pending_jobs 재생성)
this.repo.restoreNote(noteId);
// v0.2.6 #10 — in-memory AiWorker queue 갱신: DB 갱신만으로는 다음 앱 실행 시까지 처리 X
if (before.aiStatus === 'failed' || before.aiStatus === 'pending') {
await this.deps.enqueue(noteId);
}
if (this.deps.telemetry) {
await this.deps.telemetry.emit({ kind: 'restore', payload: { noteId } }).catch(() => {});
}
@@ -184,6 +207,24 @@ export class CaptureService {
return { count: ids.length };
}
/**
* v0.3.9 — 단일 failed 노트 재시도. NoteCard 의 per-note "재시도" 버튼 path.
* repo.retryOneFailed 후 worker.enqueue 재투입.
*/
async retryOneFailed(id: string): Promise<{ ok: boolean }> {
const r = this.repo.retryOneFailed(id, new Date().toISOString());
if (r.ok) await this.deps.enqueue(id);
return r;
}
/**
* v0.3.9 — pending 노트 cancel. ai_status='disabled' + pending_jobs 삭제.
* 사용자가 무한 pending (Ollama 끊김 등) 에서 빠져나오는 path.
*/
cancelPending(id: string): { ok: boolean } {
return this.repo.cancelPending(id, new Date().toISOString());
}
/** v0.2.3 #6 — 회상 후보 1건 fetch. */
async listRecallCandidate(): Promise<Note | null> {
return this.repo.findRecallCandidate();

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -8,7 +8,20 @@ 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(),
// v0.3.0 Cut E — 양방향 git sync 설정. 모두 optional — 미구성 시 sync 비활성.
sync_repo_url: z.string().nullable().optional(),
sync_auto_enabled: z.boolean().optional(),
sync_interval_min: z.number().int().min(5).optional(),
// v0.3.1 Cut F
vision_model: z.string().nullable().optional(),
vision_capable_cache: z.array(z.string()).optional(),
vision_cache_at: z.string().optional()
}).strict();
export type Settings = z.infer<typeof SettingsSchema>;
@@ -34,10 +47,115 @@ 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);
}
/**
* v0.3.0 Cut E — sync 저장소 URL. null/빈 문자열 = sync 비활성. 본 메서드는 값만 저장,
* git init/remote add 는 별도 호출자 (settings:configure-sync IPC) 가 담당.
*/
async getSyncRepoUrl(): Promise<string | null> {
const s = await this.load();
return s.sync_repo_url ?? null;
}
async setSyncRepoUrl(value: string | null): Promise<void> {
const current = await this.load();
const next: Settings = { ...current, sync_repo_url: value };
await this.persist(next);
}
/** v0.3.0 Cut E — 자동 주기 sync 활성. configured 일 때만 의미 있음. 기본 true. */
async isAutoSyncEnabled(): Promise<boolean> {
const s = await this.load();
return s.sync_auto_enabled ?? true;
}
async setAutoSyncEnabled(value: boolean): Promise<void> {
const current = await this.load();
const next: Settings = { ...current, sync_auto_enabled: value };
await this.persist(next);
}
/** v0.3.0 Cut E — 자동 주기 sync interval (분). 기본 30, min 5. */
async getSyncIntervalMin(): Promise<number> {
const s = await this.load();
return s.sync_interval_min ?? 30;
}
async setSyncIntervalMin(value: number): Promise<void> {
if (!Number.isInteger(value) || value < 5) {
throw new Error(`sync_interval_min must be an integer >= 5 (got ${value})`);
}
const current = await this.load();
const next: Settings = { ...current, sync_interval_min: value };
await this.persist(next);
}
/** v0.3.1 Cut F — 선택된 vision model. null = 미선택. */
async getVisionModel(): Promise<string | null> {
const s = await this.load();
return s.vision_model ?? null;
}
async setVisionModel(value: string | null): Promise<void> {
const current = await this.load();
const next: Settings = { ...current, vision_model: value };
await this.persist(next);
}
/** v0.3.1 Cut F — /api/tags 조회 결과 캐시. 기본 빈 배열 + null timestamp. */
async getVisionCapableCache(): Promise<{ models: string[]; at: string | null }> {
const s = await this.load();
return { models: s.vision_capable_cache ?? [], at: s.vision_cache_at ?? null };
}
async setVisionCapableCache(models: string[], now: Date): Promise<void> {
const current = await this.load();
const next: Settings = { ...current, vision_capable_cache: models, vision_cache_at: now.toISOString() };
await this.persist(next);
}
private async persist(next: Settings): Promise<void> {
await mkdir(dirname(this.filePath), { recursive: true });
const tmpPath = this.filePath + '.tmp';
await writeFile(tmpPath, JSON.stringify(next, null, 2), 'utf8');

View File

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

View File

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

View File

@@ -1,16 +1,9 @@
import { mkdir, appendFile, readFile, readdir, unlink, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { validateEvent, TelemetryEvent } from './telemetryEvents.js';
import type { AiFailedReason } from './telemetryEvents.js';
import { aggregateStats } from './telemetryStats.js';
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
const DAY_MS = 24 * 60 * 60 * 1000;
function todayKstIso(now: Date): string {
const k = new Date(now.getTime() + KST_OFFSET_MS);
return new Date(Date.UTC(k.getUTCFullYear(), k.getUTCMonth(), k.getUTCDate()))
.toISOString().slice(0, 10);
}
import { kstTodayIso, DAY_MS } from '../../shared/util/kstDate.js';
export interface TelemetryServiceOptions {
silent?: boolean;
@@ -19,7 +12,7 @@ export interface TelemetryServiceOptions {
export type EmitInput =
| { kind: 'capture'; payload: { noteId: string; rawTextLength: number; hasMedia: boolean } }
| { kind: 'ai_succeeded'; payload: { noteId: string; durationMs: number; attempts: number } }
| { kind: 'ai_failed'; payload: { noteId: string; reason: 'unreachable' | 'schema' | 'timeout' | 'other'; attempts: number } }
| { kind: 'ai_failed'; payload: { noteId: string; reason: AiFailedReason; attempts: number } }
| { kind: 'trash'; payload: { noteId: string } }
| { kind: 'restore'; payload: { noteId: string } }
| { kind: 'permanent_delete'; payload: { noteId: string } }
@@ -54,7 +47,7 @@ export class TelemetryService {
return { removed };
}
const cutoff = new Date(this.now().getTime() - this.retentionDays * DAY_MS);
const cutoffIso = todayKstIso(cutoff); // KST 일자 비교
const cutoffIso = kstTodayIso(cutoff); // KST 일자 비교
for (const name of entries) {
const m = /^events-(\d{4}-\d{2}-\d{2})\.jsonl$/.exec(name);
if (!m) continue;
@@ -77,7 +70,7 @@ export class TelemetryService {
const nowDate = this.now();
const ts = nowDate.toISOString();
const event = validateEvent({ ts, kind: input.kind, payload: input.payload });
const filePath = join(this.dir, `events-${todayKstIso(nowDate)}.jsonl`);
const filePath = join(this.dir, `events-${kstTodayIso(nowDate)}.jsonl`);
try {
await mkdir(this.dir, { recursive: true });
await appendFile(filePath, JSON.stringify(event) + '\n', 'utf8');
@@ -96,7 +89,7 @@ export class TelemetryService {
return events;
}
const cutoffMs = this.now().getTime() - this.retentionDays * DAY_MS;
const cutoffIso = todayKstIso(new Date(cutoffMs));
const cutoffIso = kstTodayIso(new Date(cutoffMs));
// 회차 1 review (PR #13) — 매직 슬라이스 `n.slice(7, 17)` 대신 정규식 capture 그룹으로
// 일자를 추출. prefix 변경 시 정규식 한 곳만 고치면 됨.
const datePattern = /^events-(\d{4}-\d{2}-\d{2})\.jsonl$/;

View File

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

View File

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

View File

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

View File

@@ -12,11 +12,12 @@ const AiSucceededPayload = z.object({
attempts: z.number().int().nonnegative()
}).strict();
const AiFailedReason = z.enum(['unreachable', 'schema', 'timeout', 'other']);
export const AiFailedReasonSchema = z.enum(['unreachable', 'schema', 'timeout', 'other']);
export type AiFailedReason = z.infer<typeof AiFailedReasonSchema>;
const AiFailedPayload = z.object({
noteId: z.string().min(1),
reason: AiFailedReason,
reason: AiFailedReasonSchema,
attempts: z.number().int().nonnegative()
}).strict();
@@ -92,3 +93,23 @@ export type TelemetryKind = TelemetryEvent['kind'];
export function validateEvent(raw: unknown): TelemetryEvent {
return TelemetryEventSchema.parse(raw);
}
/**
* v0.2.6 #21 — type predicate helper. payload.noteId 가 있는 event kind 만 narrow.
* union 확장 시 NO_NOTE_ID_KINDS Set 한 곳만 갱신.
*/
const NO_NOTE_ID_KINDS = new Set<TelemetryKind>([
'empty_trash',
'expired_banner_shown',
'expired_batch_trash',
'ollama_unreachable',
'ollama_recovered',
'ollama_recheck_manual',
'ai_retry_manual',
'tag_vocab_hit',
'tag_vocab_miss'
]);
export function hasNoteId(ev: TelemetryEvent): ev is Extract<TelemetryEvent, { payload: { noteId: string } }> {
return !NO_NOTE_ID_KINDS.has(ev.kind);
}

View File

@@ -1,12 +1,8 @@
import type { TelemetryEvent } from './telemetryEvents.js';
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
import { kstTodayIso } from '../../shared/util/kstDate.js';
function kstDate(ts: string): string {
const d = new Date(ts);
const k = new Date(d.getTime() + KST_OFFSET_MS);
return new Date(Date.UTC(k.getUTCFullYear(), k.getUTCMonth(), k.getUTCDate()))
.toISOString().slice(0, 10);
return kstTodayIso(new Date(ts));
}
interface DailyRow {
@@ -133,12 +129,18 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta
} else if (ev.kind === 'recall_snoozed') {
row.recall_snoozed += 1;
recallSnoozedCount += 1;
} else {
// v0.2.6 #8 — 새 telemetry kind 추가 시 본 함수 분기 누락을 컴파일 단계에서 catch.
const _exhaustive: never = ev;
void _exhaustive;
}
}
const days = Array.from(byDay.values()).sort((a, b) => a.date.localeCompare(b.date));
const aiTotal = aiSucceeded + aiFailed;
const successRate = aiTotal === 0 ? 'N/A' : `${(aiSucceeded / aiTotal * 100).toFixed(1)}% (${aiSucceeded}/${aiTotal})`;
const avgDuration = durationN === 0 ? 'N/A' : `${Math.round(durationSum / durationN)}`;
// v0.2.6 #9 — 회수율 = restore / trash event 비율 (event-level — 한 노트 trash-restore 반복 시
// 100% 가능, unique-note 회수율 아님. spec §6.2 "회수 도구 동작?" 질문에 충분).
const trashRecoveryRate = trashCount === 0
? 'N/A'
: `${(restoreCount / trashCount * 100).toFixed(1)}% (${restoreCount}/${trashCount})`;

View File

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

View File

@@ -1,28 +0,0 @@
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
/**
* Calendar date (YYYY-MM-DD) in Asia/Seoul timezone for the given instant.
*
* v0.2.3 #5 — used by NoteRepository.findExpiredCandidates to compare against
* notes.due_date (also stored as YYYY-MM-DD per slice §F1).
*/
export function todayInKstString(now: Date): string {
const k = new Date(now.getTime() + KST_OFFSET_MS);
return new Date(
Date.UTC(k.getUTCFullYear(), k.getUTCMonth(), k.getUTCDate())
).toISOString().slice(0, 10);
}
/**
* Epoch ms of the next 00:00 KST strictly after `now`.
*
* v0.2.3 #5 — used by store.snoozeExpired to compute the in-memory snooze
* deadline ("오늘 그만").
*/
export function nextKstMidnightMs(now: number): number {
const kstNow = now + KST_OFFSET_MS;
// Floor to KST midnight, then add one day.
const kstMidnightFloor = Math.floor(kstNow / 86_400_000) * 86_400_000;
const nextKstMidnight = kstMidnightFloor + 86_400_000;
return nextKstMidnight - KST_OFFSET_MS;
}

View File

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

View File

@@ -12,7 +12,7 @@ const api: InklingApi = {
updateAiFields: (noteId, fields) =>
ipcRenderer.invoke('inbox:updateAi', { noteId, fields }),
setDueDate: (noteId, date) => ipcRenderer.invoke('inbox:setDueDate', { noteId, date }),
deleteNote: (noteId) => ipcRenderer.invoke('inbox:delete', noteId),
deleteNote: (noteId) => ipcRenderer.invoke('inbox:trash', noteId),
setIntent: (noteId, text) => ipcRenderer.invoke('inbox:setIntent', { noteId, text }),
dismissIntent: (noteId) => ipcRenderer.invoke('inbox:dismissIntent', noteId),
getContinuity: () => ipcRenderer.invoke('inbox:continuity'),
@@ -40,18 +40,69 @@ const api: InklingApi = {
},
retryAllFailed: () => ipcRenderer.invoke('inbox:retryAllFailed'),
getFailedCount: () => ipcRenderer.invoke('inbox:failedCount'),
retryOneFailed: (id: string) => ipcRenderer.invoke('inbox:retry-one-failed', id),
cancelPending: (id: string) => ipcRenderer.invoke('inbox:cancel-pending', id),
listRecallCandidate: () => ipcRenderer.invoke('inbox:listRecallCandidate'),
markRecallOpened: (id: string) => ipcRenderer.invoke('inbox:markRecallOpened', id),
dismissRecall: (id: string) => ipcRenderer.invoke('inbox:dismissRecall', id),
emitRecallShown: (id: string) => ipcRenderer.invoke('inbox:emitRecallShown', id),
emitRecallSnoozed: (id: string) => ipcRenderer.invoke('inbox:emitRecallSnoozed', id),
emitRecallShown: (id: string) => { ipcRenderer.send('inbox:emitRecallShown', id); },
emitRecallSnoozed: (id: string) => { ipcRenderer.send('inbox:emitRecallSnoozed', id); },
loadOllamaSettings: () => ipcRenderer.invoke('inbox:loadOllamaSettings'),
saveOllamaSettings: (v: { endpoint: string; model: string }) => ipcRenderer.invoke('inbox:saveOllamaSettings', v),
onOpenOllamaSettings: (cb: () => void) => {
const handler = () => cb();
ipcRenderer.on('inbox:openOllamaSettings', handler);
return () => ipcRenderer.removeListener('inbox:openOllamaSettings', handler);
// v0.2.7 Task 13 — 외부 (트레이) 에서 view 전환 요청 listener.
onNavigate: (cb: (view: 'inbox' | 'trash' | 'settings') => void) => {
const listener = (_e: unknown, view: 'inbox' | 'trash' | 'settings') => cb(view);
ipcRenderer.on('inbox:navigate', listener);
return () => ipcRenderer.off('inbox:navigate', listener);
},
// v0.2.7 자동 실행 (Task 22 통일) — 진단 정보 포함 응답
getAutostart: () => ipcRenderer.invoke('settings:autostart-state'),
setAutostart: (open: boolean) => ipcRenderer.invoke('settings:autostart-set', open),
// v0.2.7 백업/복원/동기화/텔레메트리 (Task 10) — 트레이 callback 의 IPC 대응
runBackup: () => ipcRenderer.invoke('settings:run-backup'),
runExport: () => ipcRenderer.invoke('settings:run-export'),
runImport: () => ipcRenderer.invoke('settings:run-import'),
runSync: () => ipcRenderer.invoke('settings:run-sync'),
runExportTelemetry: () => ipcRenderer.invoke('settings:run-export-telemetry'),
// v0.2.7 정보 섹션 (Task 11) — 트레이 showAboutDialog 의 IPC 대응
getAppInfo: () => ipcRenderer.invoke('settings:get-app-info'),
openProfileDir: () => ipcRenderer.invoke('settings:open-profile-dir'),
copyAppInfo: () => ipcRenderer.invoke('settings:copy-app-info'),
// v0.2.8 Cut A — 첨부 이미지를 OS 기본 뷰어로 열기 (Task 3).
openMedia: (relPath: string) => ipcRenderer.invoke('inbox:open-media', relPath),
// v0.2.9 Cut B Task 4 — status 별 list + counts.
listByStatus: (status, opts) => ipcRenderer.invoke('inbox:list-by-status', status, opts ?? {}),
countsByStatus: () => ipcRenderer.invoke('inbox:counts-by-status'),
// v0.2.9 Cut B Task 8 — 4분기 status 전이 + AI 자동 분류 추천.
setStatus: (id, status, reason) => ipcRenderer.invoke('inbox:set-status', id, status, reason),
classifyStatus: (id, reason) => ipcRenderer.invoke('ai:classify-status', id, reason),
// v0.2.9 Cut B Task 12 — settings read + AI/onboarding 토글 (첫 launch wizard 분기 포함).
getSettings: () => ipcRenderer.invoke('settings:get'),
setAiEnabled: (enabled: boolean) => ipcRenderer.invoke('settings:set-ai-enabled', enabled),
setOnboardingCompleted: (completed: boolean) => ipcRenderer.invoke('settings:set-onboarding-completed', completed),
// v0.2.9 Cut B Task 16 — disabled 메모 재투입 + count.
enqueueDisabled: () => ipcRenderer.invoke('inbox:enqueue-disabled'),
getDisabledCount: () => ipcRenderer.invoke('inbox:get-disabled-count'),
// v0.2.10 Cut C — raw_text 가변 + revision 보존.
updateRawText: (noteId: string, newText: string) => ipcRenderer.invoke('inbox:update-raw-text', noteId, newText),
listRevisions: (noteId: string) => ipcRenderer.invoke('inbox:list-revisions', noteId),
restoreRevision: (noteId: string, revId: number) => ipcRenderer.invoke('inbox:restore-revision', noteId, revId),
// v0.2.11 Cut D — search + 회고 aggregate.
search: (query, opts) => ipcRenderer.invoke('inbox:search', query, opts ?? {}),
reviewAggregate: (period) => ipcRenderer.invoke('inbox:review-aggregate', period),
// v0.3.0 Cut E — 양방향 sync.
configureSync: (url: string | null) => ipcRenderer.invoke('settings:configure-sync', url),
testSyncConnection: () => ipcRenderer.invoke('settings:test-sync-connection'),
listConflicts: () => ipcRenderer.invoke('sync:list-conflicts'),
resolveConflict: (path: string, choice: 'local' | 'remote') =>
ipcRenderer.invoke('sync:resolve-conflict', path, choice),
getSyncStatus: () => ipcRenderer.invoke('sync:get-status'),
setSyncAutoEnabled: (value: boolean) => ipcRenderer.invoke('settings:set-sync-auto-enabled', value),
setSyncIntervalMin: (value: number) => ipcRenderer.invoke('settings:set-sync-interval-min', value),
// v0.3.1 Cut F — vision capability + 모델 선택
getVisionModels: () => ipcRenderer.invoke('settings:get-vision-models'),
setVisionModel: (value: string | null) => ipcRenderer.invoke('settings:set-vision-model', value),
refreshVisionCache: () => ipcRenderer.invoke('settings:refresh-vision-cache'),
}
};

View File

@@ -12,7 +12,11 @@ import { TagUndoToast } from './components/TagUndoToast.js';
import { ExpiryBanner } from './components/ExpiryBanner.js';
import { FailedBanner } from './components/FailedBanner.js';
import { RecallBanner } from './components/RecallBanner.js';
import { OllamaSettingsModal } from './components/OllamaSettingsModal.js';
import { SettingsPage } from './components/SettingsPage.js';
import { OnboardingWizard } from './components/OnboardingWizard.js';
import { SearchBox } from './components/SearchBox.js';
import { ReviewView } from './components/ReviewView.js';
import type { InboxView } from './store.js';
export function App(): React.ReactElement {
const {
@@ -21,8 +25,28 @@ export function App(): React.ReactElement {
continuity, tagFilter, setTagFilter,
toggleShowTrash, restoreNote, permanentDeleteNote, emptyTrash
} = useInbox();
const showSettings = useInbox((s) => s.showSettings);
const setShowSettings = useInbox((s) => s.setShowSettings);
// v0.2.9 Cut B Task 5 — 4탭 (Inbox/완료/보관/휴지통).
const view = useInbox((s) => s.view);
const counts = useInbox((s) => s.counts);
const setView = useInbox((s) => s.setView);
const searchResults = useInbox((s) => s.searchResults);
const [recoveryDismissed, setRecoveryDismissed] = useState(isRecoveryDismissedToday());
const [ollamaSettingsOpen, setOllamaSettingsOpen] = useState(false);
// v0.2.9 Cut B Task 12 — 첫 launch onboarding 분기. null = 로딩, true = 표시, false = 미표시.
const [showOnboarding, setShowOnboarding] = useState<boolean | null>(null);
useEffect(() => {
void (async () => {
try {
const settings = await inboxApi.getSettings();
setShowOnboarding(!settings.onboarding_completed);
} catch {
// 안전한 fallback — settings 읽기 실패 시 wizard 미표시 (기존 사용자 무영향).
setShowOnboarding(false);
}
})();
}, []);
useEffect(() => {
void loadInitial();
@@ -33,16 +57,29 @@ export function App(): React.ReactElement {
const unsubOllama = inboxApi.onOllamaStatus((status) => {
useInbox.setState({ ollamaStatus: status });
});
const unsubOllamaSettings = inboxApi.onOpenOllamaSettings(() => setOllamaSettingsOpen(true));
const unsubNav = inboxApi.onNavigate((view) => {
// v0.2.9 Cut B Task 4 — setView 가 mirror state (showTrash/showSettings) 동기 갱신.
useInbox.getState().setView(view);
});
const onFocus = () => { void refreshMeta(); };
window.addEventListener('focus', onFocus);
return () => { unsubNote(); unsubOllama(); unsubOllamaSettings(); window.removeEventListener('focus', onFocus); };
return () => { unsubNote(); unsubOllama(); unsubNav(); window.removeEventListener('focus', onFocus); };
// onOllamaStatus 콜백은 useInbox.setState 직접 호출 — store reference 가 안정적이라
// deps array 에 추가 불필요. mount 시 1회 구독 + unmount 시 해제.
}, [loadInitial, refreshMeta, upsertNote]);
if (showOnboarding === null) return <></>;
if (showOnboarding) return <OnboardingWizard onClose={() => setShowOnboarding(false)} />;
if (view === 'review-daily') return <ReviewView period="daily" />;
if (view === 'review-weekly') return <ReviewView period="weekly" />;
if (view === 'review-monthly') return <ReviewView period="monthly" />;
if (showSettings) return <SettingsPage />;
const showRecovery = continuity.showRecoveryToast && !recoveryDismissed;
const filtered = selectFilteredNotes({ notes, tagFilter });
const displayed = searchResults !== null ? searchResults : filtered;
const tabBtnStyle = (active: boolean): React.CSSProperties => ({
background: active ? '#0a4b80' : 'transparent',
@@ -59,38 +96,78 @@ 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}
type="button"
role="tab"
aria-selected={view === t.key}
onClick={() => setView(t.key)}
style={tabBtnStyle(view === t.key)}
>
{t.label}({t.count})
</button>
))}
</div>
<select
aria-label="회고 기간"
value={view.startsWith('review-') ? view.replace('review-', '') : ''}
onChange={(e) => {
const v = e.target.value;
if (v === 'daily' || v === 'weekly' || v === 'monthly') setView(`review-${v}` as InboxView);
}}
style={{ marginLeft: 8, fontSize: 12, padding: '4px 6px', border: '1px solid #0a4b80', borderRadius: 4, color: '#0a4b80', background: 'transparent' }}
>
<option value="">📅 </option>
<option value="daily"></option>
<option value="weekly"></option>
<option value="monthly"></option>
</select>
<SearchBox />
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 2, marginLeft: 'auto' }}>
<ContinuityBadge />
<IdentityCounter />
</div>
<button
aria-label="설정 열기"
onClick={() => setShowSettings(true)}
style={{
background: 'transparent',
border: 'none',
cursor: 'pointer',
padding: 4,
fontSize: 16,
marginLeft: 8
}}
>
</button>
</div>
<main className="main">
{!showTrash && (
<>
<OllamaBanner onOpenSettings={() => setOllamaSettingsOpen(true)} />
<RecoveryToast
show={showRecovery}
onDismiss={() => { markRecoveryDismissed(); setRecoveryDismissed(true); }}
/>
<PendingBanner />
<FailedBanner />
<ExpiryBanner />
<RecallBanner />
{/* AI/만료/회상 배너는 active 노트 컨텍스트 — inbox 탭에서만 노출.
completed/archived 에서는 무관 컨텐츠라 숨김. */}
{view === 'inbox' && (
<>
<OllamaBanner onOpenSettings={() => setShowSettings(true)} />
<RecoveryToast
show={showRecovery}
onDismiss={() => { markRecoveryDismissed(); setRecoveryDismissed(true); }}
/>
<PendingBanner />
<FailedBanner />
<ExpiryBanner />
<RecallBanner />
</>
)}
{tagFilter !== null && (
<div style={{
background: '#eaf3ff', color: '#0a4b80', padding: '6px 12px',
@@ -110,12 +187,14 @@ export function App(): React.ReactElement {
)}
{loading && notes.length === 0 ? (
<div className="empty"> </div>
) : searchResults !== null && displayed.length === 0 ? (
<div className="empty"> .</div>
) : notes.length === 0 ? (
<div className="empty">릿 . <code>Ctrl+Shift+J</code></div>
) : filtered.length === 0 ? (
) : displayed.length === 0 ? (
<div className="empty"> .</div>
) : (
filtered.map((n) => (
displayed.map((n) => (
<NoteCard
key={n.id} note={n} mode="inbox"
onDeleted={() => removeNote(n.id)}
@@ -157,10 +236,6 @@ export function App(): React.ReactElement {
)}
</main>
<TagUndoToast />
<OllamaSettingsModal
open={ollamaSettingsOpen}
onClose={() => setOllamaSettingsOpen(false)}
/>
</>
);
}

View File

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

View File

@@ -0,0 +1,135 @@
import React, { useEffect, useState } from 'react';
import type { SyncConflict } from '@shared/types';
import { inboxApi } from '../api.js';
import type { SyncHelpAnchor } from './SyncHelpModal.js';
interface Props {
onClose: () => void;
onResolved: () => void;
onOpenHelp?: (anchor: SyncHelpAnchor) => void;
}
const overlayStyle: React.CSSProperties = {
position: 'fixed', top: 0, left: 0, width: '100vw', height: '100vh',
background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center',
justifyContent: 'center', zIndex: 100
};
const modalStyle: React.CSSProperties = {
background: '#fff', borderRadius: 8, padding: 20, width: 600,
maxHeight: '70vh', overflow: 'auto', boxShadow: '0 4px 16px rgba(0,0,0,0.2)'
};
const rowStyle: React.CSSProperties = {
border: '1px solid #eee', borderRadius: 6, padding: 10, marginTop: 8
};
export function ConflictModal({ onClose, onResolved, onOpenHelp }: Props): React.ReactElement {
const [conflicts, setConflicts] = useState<SyncConflict[]>([]);
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
void (async () => {
const c = await inboxApi.listConflicts();
if (!cancelled) setConflicts(c);
})();
return () => { cancelled = true; };
}, []);
// Escape key 로 닫기.
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
}
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [onClose]);
async function onChoose(path: string, choice: 'local' | 'remote') {
setBusy(path);
setError(null);
const r = await inboxApi.resolveConflict(path, choice);
setBusy(null);
if (!r.ok) {
setError(`해결 실패: ${r.reason}`);
return;
}
const next = conflicts.filter((c) => c.path !== path);
setConflicts(next);
if (next.length === 0) {
onResolved();
onClose();
}
}
return (
<div style={overlayStyle} onClick={onClose}>
<div style={modalStyle} onClick={(e) => e.stopPropagation()}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h3 style={{ margin: 0, fontSize: 16 }}> ({conflicts.length})</h3>
<button onClick={onClose} aria-label="닫기" style={{ background: 'none', border: 'none', fontSize: 18, cursor: 'pointer', color: '#888' }}>×</button>
</div>
{error !== null && <div style={{ marginTop: 10, fontSize: 12, color: '#c93030' }}>{error}</div>}
{conflicts.map((c) => (
<div key={c.path} style={rowStyle}>
<div style={{ fontSize: 12, color: '#888', marginBottom: 6 }}>{c.path}</div>
<div style={{ display: 'flex', gap: 12 }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 11, color: '#666', marginBottom: 4 }}> </div>
<pre style={preStyle()}>{c.localText || '(미리보기 없음)'}</pre>
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 11, color: '#666', marginBottom: 4 }}> </div>
<pre style={preStyle()}>{c.remoteText || '(미리보기 없음)'}</pre>
</div>
</div>
<div style={{ marginTop: 8, fontSize: 11, color: '#666', lineHeight: 1.5 }}>
<div><b> </b>: .</div>
<div><b> </b>: .</div>
{onOpenHelp && (
<button
onClick={() => onOpenHelp('main-conflict')}
style={{ background: 'none', border: 'none', color: '#0a4b80', cursor: 'pointer', fontSize: 11, padding: 0, marginTop: 2, textDecoration: 'underline' }}
>
</button>
)}
</div>
<div style={{ marginTop: 8, display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button
onClick={() => { void onChoose(c.path, 'local'); }}
disabled={busy === c.path}
style={chooseBtnStyle('#0a4b80')}
>
{busy === c.path ? '처리 중…' : '내 것 사용'}
</button>
<button
onClick={() => { void onChoose(c.path, 'remote'); }}
disabled={busy === c.path}
style={chooseBtnStyle('#236b1a')}
>
{busy === c.path ? '처리 중…' : '원격 사용'}
</button>
</div>
</div>
))}
</div>
</div>
);
}
function preStyle(): React.CSSProperties {
return {
margin: 0, whiteSpace: 'pre-wrap', fontSize: 11, color: '#444',
background: '#fafafa', padding: 6, borderRadius: 3, maxHeight: 120, overflow: 'auto'
};
}
function chooseBtnStyle(color: string): React.CSSProperties {
return {
background: 'none', border: `1px solid ${color}`, color, cursor: 'pointer',
fontSize: 12, padding: '4px 10px', borderRadius: 4
};
}

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react';
import type { Note } from '@shared/types';
import { useInbox } from '../store.js';
import { Banner } from './Banner.js';
export function ExpiryBanner(): React.ReactElement | null {
const candidates = useInbox((s) => s.expiredCandidates);
@@ -72,10 +73,7 @@ function ExpiryBannerInner({ candidates, onTrash, onSnooze }: InnerProps): React
}
return (
<div style={{
background: '#fff7e6', border: '1px solid #d99500', borderRadius: 6,
padding: '8px 12px', margin: '8px 0', fontSize: 13
}}>
<Banner severity="warning">
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span> <b> {candidates.length}</b></span>
<button
@@ -152,6 +150,6 @@ function ExpiryBannerInner({ candidates, onTrash, onSnooze }: InnerProps): React
</button>
</>
)}
</div>
</Banner>
);
}

View File

@@ -1,32 +1,34 @@
import React from 'react';
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 (
<div style={{
background: '#fce4e4', border: '1px solid #a33', borderRadius: 6,
padding: '8px 12px', margin: '8px 0', fontSize: 13,
display: 'flex', alignItems: 'center', gap: 8
}}>
<span style={{ flex: 1 }}> AI <b>{count}</b></span>
<button
onClick={() => {
retryAllFailed().catch((e) => {
// eslint-disable-next-line no-console
console.warn('retryAllFailed failed', e);
});
}}
style={{
background: '#a33', color: '#fff',
border: 'none', borderRadius: 4,
padding: '4px 12px', fontSize: 12, cursor: 'pointer'
}}
>
</button>
</div>
<Banner severity="error">
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ flex: 1 }}> AI <b>{count}</b></span>
<button
onClick={() => {
retryAllFailed().catch((e) => {
// eslint-disable-next-line no-console
console.warn('retryAllFailed failed', e);
});
}}
style={{
background: '#a33', color: '#fff',
border: 'none', borderRadius: 4,
padding: '4px 12px', fontSize: 12, cursor: 'pointer'
}}
>
</button>
</div>
</Banner>
);
}

View File

@@ -0,0 +1,171 @@
import React, { useEffect, useState } from 'react';
import { inboxApi } from '../api.js';
import type { NoteStatus } from '@shared/types';
interface Props {
noteId: string;
rawText: string;
summary: string;
/** 현재 노트 status. 이 값을 제외한 나머지 status 가 이동 버튼으로 노출. */
currentStatus: NoteStatus;
onClose: () => void;
onMoved: (status: NoteStatus, reason: string | null) => void;
}
/**
* 메모 이동 Modal.
*
* 사유 입력 + AI 자동 분류 + 수동 status 선택. 버튼은 currentStatus 를 제외한
* 나머지 status 만 노출 (v0.3.6 까지는 완료/보관/휴지통 hardcode 라 완료/보관 노트가
* inbox 로 못 돌아오던 버그를 v0.3.7 에서 정정).
*/
const ALL_STATUSES: readonly NoteStatus[] = ['active', 'completed', 'archived', 'trashed'];
export function MoveStatusModal({
noteId,
currentStatus,
onClose,
onMoved
}: Props): React.ReactElement {
const [reason, setReason] = useState('');
const [recommendation, setRecommendation] = useState<{
status: NoteStatus;
rationale: string;
} | null>(null);
const [classifying, setClassifying] = useState(false);
// Escape key 로 modal 닫기. mount 동안만 listener 활성.
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
}
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [onClose]);
async function move(status: NoteStatus): Promise<void> {
const trimmedReason = reason.trim() === '' ? null : reason.trim();
await inboxApi.setStatus(noteId, status, trimmedReason);
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="이동"
onClick={onClose}
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
onClick={(e) => e.stopPropagation()}
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>
{ALL_STATUSES.filter((s) => s !== currentStatus).map((s) => (
<button key={s} onClick={() => void move(s)}>
{statusLabel(s)}
</button>
))}
<button onClick={onClose} style={{ marginLeft: 'auto' }}>
</button>
</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':
// 헤더 탭 표기 ('Inbox') 와 일치. UI 전반에서 active = Inbox 동의어.
return 'Inbox';
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,13 @@
import React, { useState } from 'react';
import type { Note } from '@shared/types';
import { KST_OFFSET_MS } from '@shared/util/kstDate.js';
import { inboxApi } from '../api.js';
import { useInbox } from '../store.js';
import { EditableField } from './EditableField.js';
import { IntentBanner } from './IntentBanner.js';
import { pushTagUndo } from './TagUndoToast.js';
import { MoveStatusModal } from './MoveStatusModal.js';
import { RevisionHistoryModal } from './RevisionHistoryModal.js';
interface Props {
note: Note;
@@ -25,7 +28,6 @@ function isPastDue(iso: string, today: string): boolean {
}
function todayKstIso(): string {
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
const k = new Date(Date.now() + KST_OFFSET_MS);
return k.toISOString().slice(0, 10);
}
@@ -109,19 +111,21 @@ 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) || '(빈 메모)';
// 이동 modal 열림 여부. 클릭 시 MoveStatusModal 에서 사유 + AI 분류 + 수동 분류 선택.
const [moveOpen, setMoveOpen] = useState(false);
const [editingRaw, setEditingRaw] = useState(false);
const [draftRaw, setDraftRaw] = useState('');
const [showRevisions, setShowRevisions] = useState(false);
React.useEffect(() => { setLocal(note); }, [note]);
const formatted = new Date(note.createdAt).toLocaleString('ko-KR');
async function handleDelete() {
if (!window.confirm('이 기억을 버릴까요? 되돌릴 수 없습니다.')) return;
await inboxApi.deleteNote(note.id);
onDeleted?.();
}
async function saveTitle(next: string) {
await inboxApi.updateAiFields(note.id, { title: next });
const updated = { ...local, aiTitle: next, titleEditedByUser: true };
@@ -145,6 +149,17 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
setLocal(updated); onUpdated(updated);
}
async function saveRaw() {
const next = draftRaw;
if (next.trim().length === 0) return;
const r = await inboxApi.updateRawText(note.id, next);
if (!r.ok) return;
const updated = { ...local, rawText: next, updatedAt: new Date().toISOString() };
setLocal(updated);
onUpdated(updated);
setEditingRaw(false);
}
async function removeTag(tagName: string) {
const removed = local.tags.find((t) => t.name === tagName);
const nextTagNames = local.tags.filter((t) => t.name !== tagName).map((t) => t.name);
@@ -200,13 +215,51 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
)}
{local.aiStatus === 'pending' && (
<div style={{ fontSize: 16, fontWeight: 600, color: '#666', marginTop: 4 }}>
Inkling이
<div style={{ marginTop: 4, display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ fontSize: 16, fontWeight: 600, color: '#666' }}>
Inkling이
</div>
{/* v0.3.9 — pending cancel UI. Ollama 끊김 / 무한 pending 시 사용자 unblock path. */}
<button
onClick={async () => {
await inboxApi.cancelPending(local.id);
// push 기반 — onNoteUpdated 가 store 자동 갱신.
}}
style={{
background: 'none', border: '1px solid #ccc', color: '#666',
cursor: 'pointer', fontSize: 11, padding: '2px 8px', borderRadius: 4
}}
title="AI 자동 처리를 건너뛰고 원문만 보관"
>
</button>
</div>
)}
{local.aiStatus === 'failed' && (
<div title={local.aiError ?? ''} style={{ fontSize: 16, fontWeight: 600, color: '#a55', marginTop: 4 }}>
<div style={{ marginTop: 4, display: 'flex', alignItems: 'center', gap: 8 }}>
<div title={local.aiError ?? ''} style={{ fontSize: 16, fontWeight: 600, color: '#a55' }}>
</div>
{/* v0.3.9 — per-note 재시도 UI. FailedBanner 의 일괄 재시도와 별개. */}
<button
onClick={async () => {
await inboxApi.retryOneFailed(local.id);
}}
style={{
background: 'none', border: '1px solid #a55', color: '#a55',
cursor: 'pointer', fontSize: 11, padding: '2px 8px', borderRadius: 4
}}
title="이 노트만 AI 처리 재시도"
>
</button>
</div>
)}
{/* v0.2.9 Cut B Task 13 — ai_status='disabled': raw_text 첫 줄 fallback title.
summary/tags 는 hide. 원문은 아래 "원문 보기" 영역에서 항상 표시. */}
{isAiDisabled && (
<div style={{ marginTop: 4 }}>
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 600 }}>{fallbackTitle}</h3>
</div>
)}
{local.aiStatus === 'done' && (
@@ -332,9 +385,24 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
)}
{local.media.length > 0 && (
<div style={{ marginTop: 10, display: 'flex', gap: 6 }}>
<div style={{ marginTop: 10, display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{local.media.map((m) => (
<div key={m.id} style={{ width: 48, height: 48, background: '#eee', borderRadius: 4 }} title={m.relPath} />
// alt="" — decorative (relPath 는 사용자 의미 X). title 이 hover tooltip.
<img
key={m.id}
src={`inkling-media://${m.relPath}`}
alt=""
title={m.relPath}
onClick={() => { void inboxApi.openMedia(m.relPath); }}
style={{
width: 48,
height: 48,
objectFit: 'cover',
borderRadius: 4,
cursor: 'pointer',
border: '1px solid #e0e0e0'
}}
/>
))}
</div>
)}
@@ -344,40 +412,119 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
{rawOpen ? '▾ 원문 접기' : '▸ 원문 보기'}
</button>
{rawOpen && (
<pre style={{ marginTop: 6, whiteSpace: 'pre-wrap', fontSize: 12, color: '#555', background: '#fafafa', padding: 8, borderRadius: 4 }}>
{local.rawText}
</pre>
<div style={{ marginTop: 6 }}>
{editingRaw ? (
<div>
<textarea
aria-label="원문 편집"
value={draftRaw}
onChange={(e) => setDraftRaw(e.target.value)}
style={{ width: '100%', minHeight: 80, fontSize: 12, fontFamily: 'inherit', padding: 8, border: '1px solid #ddd', borderRadius: 4, boxSizing: 'border-box' }}
/>
<div style={{ marginTop: 4, display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
<button onClick={() => setEditingRaw(false)} style={{ background: 'none', border: '1px solid #ccc', color: '#444', cursor: 'pointer', fontSize: 12, padding: '3px 10px', borderRadius: 4 }}></button>
<button onClick={() => { void saveRaw(); }} style={{ background: '#0a4b80', border: 'none', color: '#fff', cursor: 'pointer', fontSize: 12, padding: '3px 10px', borderRadius: 4 }}></button>
</div>
</div>
) : (
<>
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', fontSize: 12, color: '#555', background: '#fafafa', padding: 8, borderRadius: 4 }}>
{local.rawText}
</pre>
<div style={{ marginTop: 4, display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
<button onClick={() => setShowRevisions(true)} style={{ background: 'none', border: 'none', color: '#0a4b80', cursor: 'pointer', fontSize: 12, padding: 0 }}></button>
<button onClick={() => { setDraftRaw(local.rawText); setEditingRaw(true); }} style={{ background: 'none', border: '1px solid #ccc', color: '#444', cursor: 'pointer', fontSize: 12, padding: '3px 10px', borderRadius: 4 }}></button>
</div>
</>
)}
</div>
)}
</div>
<div style={{ marginTop: 10, textAlign: 'right' }}>
{isTrash ? (
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button
onClick={onRestore}
style={{
background: 'none', border: '1px solid #0a4b80', color: '#0a4b80',
cursor: 'pointer', fontSize: 12, padding: '4px 10px', borderRadius: 4
}}
>
🔄
</button>
<button
onClick={onPermanentDelete}
style={{
background: 'none', border: '1px solid #c93030', color: '#c93030',
cursor: 'pointer', fontSize: 12, padding: '4px 10px', borderRadius: 4
}}
>
🗑
</button>
</div>
) : (
<button onClick={() => void handleDelete()} style={{ background: 'none', border: 'none', color: '#c93030', cursor: 'pointer', fontSize: 12 }}>
🗑
<div
style={{
display: 'flex',
gap: 8,
justifyContent: 'flex-end',
alignItems: 'center'
}}
>
{/* 이동 버튼 — 클릭 시 MoveStatusModal 진입.
사유 입력 + AI 자동 분류 + 수동 status 선택 한 곳에서 처리. */}
<button
onClick={() => setMoveOpen(true)}
aria-label="이동"
style={{
background: 'none',
border: '1px solid #ccc',
color: '#444',
cursor: 'pointer',
fontSize: 12,
padding: '4px 10px',
borderRadius: 4
}}
>
</button>
)}
{/* trash mode 만 영구 삭제 + 복구 보존 (휴지통 단독 액션). */}
{isTrash && (
<>
<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>
{moveOpen && (
<MoveStatusModal
noteId={local.id}
rawText={local.rawText}
summary={local.aiSummary ?? ''}
currentStatus={local.status}
onClose={() => setMoveOpen(false)}
onMoved={(newStatus, reason) => {
const updated = { ...local, status: newStatus, moveReason: reason };
setLocal(updated);
onUpdated(updated);
// inbox/완료/보관/휴지통 view 의 list 가 status 별로 필터되므로 status 변경 시 onDeleted 호출.
if (newStatus !== local.status) onDeleted?.();
// setStatus IPC 는 pushNoteUpdated emit 안 함 → 헤더 탭 counts 가 stale.
// refreshMeta 로 server-authoritative counts 재로드.
void useInbox.getState().refreshMeta();
setMoveOpen(false);
}}
/>
)}
{showRevisions && (
<RevisionHistoryModal
noteId={local.id}
onClose={() => setShowRevisions(false)}
onRestored={(newRawText) => {
const updated = { ...local, rawText: newRawText, updatedAt: new Date().toISOString() };
setLocal(updated);
onUpdated(updated);
}}
/>
)}
</div>
);
}

View File

@@ -1,20 +1,25 @@
import React from 'react';
import { useInbox } from '../store.js';
import { Banner } from './Banner.js';
interface OllamaBannerProps {
onOpenSettings?: () => void;
}
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
? '`ollama pull gemma4:e4b` 실행 후 앱을 재시작해주세요.'
: 'Inkling 정리가 잠시 멈췄습니다. Ollama를 실행해주세요.';
return (
<div className="banner warn" style={{ flexDirection: 'column', alignItems: 'flex-start' }}>
<Banner severity="warning">
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%' }}>
<span style={{ flex: 1 }}> {message}</span>
<button
@@ -51,6 +56,7 @@ export function OllamaBanner({ onOpenSettings }: OllamaBannerProps = {}): React.
: {status.reason}
</span>
) : null}
</div>
</div>
</Banner>
);
}

View File

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

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useRef, useState } from 'react';
import { useInbox } from '../store.js';
import { inboxApi } from '../api.js';
import { Banner } from './Banner.js';
export function RecallBanner(): React.ReactElement | null {
const candidate = useInbox((s) => s.recallCandidate);
@@ -47,10 +48,7 @@ export function RecallBanner(): React.ReactElement | null {
}
return (
<div style={{
background: '#e8f0fe', border: '1px solid #4a7ec0', borderRadius: 6,
padding: '8px 12px', margin: '8px 0', fontSize: 13
}}>
<Banner severity="info">
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span>💭 <b> </b></span>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: '#234' }}>
@@ -90,7 +88,7 @@ export function RecallBanner(): React.ReactElement | null {
</button>
</div>
</div>
</Banner>
);
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,50 @@
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';
import { SyncSection } from './settings/SyncSection.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>
<section style={{ marginBottom: 24 }}>
<h2 style={{ fontSize: 14, marginBottom: 8 }}></h2>
<SyncSection />
</section>
</div>
);
}

View File

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

View File

@@ -0,0 +1,199 @@
import React, { useEffect, useState } from 'react';
import { z } from 'zod';
import { inboxApi } from '../../api.js';
import { VisionSection } from './VisionSection.js';
const endpointSchema = z.string().url();
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>
)}
<VisionSection />
</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

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

View File

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

View File

@@ -1,14 +1,32 @@
import { create } from 'zustand';
import type { Note, WeeklyContinuity } from '@shared/types';
import type { Note, ReviewAggregate, WeeklyContinuity } from '@shared/types';
import { inboxApi } from './api.js';
import { nextKstMidnightMs } from '@shared/util/kstDate.js';
export { selectFilteredNotes } from './selectFilteredNotes.js';
// v0.2.9 Cut B Task 4 — 4탭 view enum + settings.
// 'inbox' = active, 'completed'/'archived' = NoteStatus 그대로, 'trash' = trashed (mirror), 'settings' = SettingsPage.
export type InboxView =
| 'inbox' | 'completed' | 'archived' | 'trash' | 'settings'
| 'review-daily' | 'review-weekly' | 'review-monthly';
export interface InboxCounts {
active: number;
completed: number;
archived: number;
trashed: number;
}
interface InboxState {
notes: Note[];
trashNotes: Note[];
trashCount: number;
showTrash: boolean;
showSettings: boolean;
// v0.2.9 Cut B Task 4 — view enum + counts. showTrash/showSettings 는 mirror 로 잠시 잔류.
view: InboxView;
counts: InboxCounts;
continuity: WeeklyContinuity;
pendingCount: number;
ollamaStatus: { ok: boolean; reason?: string };
@@ -20,17 +38,26 @@ interface InboxState {
failedCount: number;
recallCandidate: Note | null;
recallSnoozeUntilMs: number | null;
// v0.2.9 Cut B Task 14 — AI 비활성 모드에서는 OllamaBanner/FailedBanner render skip.
// 기본 true (기존 사용자 무영향). loadInitial / refreshMeta 가 settings 로드.
ai_enabled: boolean;
// v0.2.11 Cut D — FTS5 search + review aggregate state.
searchQuery: string;
searchResults: Note[] | null; // null = 검색 안 한 상태
reviewData: ReviewAggregate | null;
loadInitial: () => Promise<void>;
refreshMeta: () => Promise<void>;
upsertNote: (note: Note) => void;
removeNote: (id: string) => void;
setTagFilter: (tag: string | null) => void;
setShowSettings: (open: boolean) => void;
setView: (view: InboxView) => void;
loadByView: (view: 'inbox' | 'completed' | 'archived' | 'trash') => Promise<void>;
toggleShowTrash: () => Promise<void>;
loadTrash: () => Promise<void>;
restoreNote: (id: string) => Promise<void>;
permanentDeleteNote: (id: string) => Promise<void>;
emptyTrash: () => Promise<void>;
loadExpired: () => Promise<void>;
trashExpiredBatch: (ids: string[]) => Promise<void>;
snoozeExpired: () => void;
recheckOllama: () => Promise<void>;
@@ -39,6 +66,11 @@ interface InboxState {
openRecall: (id: string) => Promise<void>;
dismissRecallNote: (id: string) => Promise<void>;
snoozeRecall: () => Promise<void>;
// v0.2.11 Cut D — search + review actions.
setSearchQuery: (q: string) => void;
searchNotes: (q: string) => Promise<void>;
clearSearch: () => void;
loadReview: (period: 'daily' | 'weekly' | 'monthly') => Promise<void>;
}
const emptyContinuity: WeeklyContinuity = {
@@ -51,6 +83,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 },
@@ -62,63 +97,122 @@ export const useInbox = create<InboxState>((set, get) => ({
failedCount: 0,
recallCandidate: null,
recallSnoozeUntilMs: null,
ai_enabled: true,
searchQuery: '',
searchResults: null,
reviewData: null,
async loadInitial() {
// v0.3.8 — IPC 실패 시 loading=true 영구 stuck 방지. catch 로 reset.
set({ loading: true });
const [notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate] = await Promise.all([
inboxApi.listNotes({ limit: 50 }),
inboxApi.getContinuity(),
inboxApi.getPendingCount(),
inboxApi.getOllamaStatus(),
inboxApi.getTodayCount(),
inboxApi.getTrashCount(),
inboxApi.listExpired(),
inboxApi.getFailedCount(),
inboxApi.listRecallCandidate()
]);
set({ notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, loading: false });
try {
const [notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, settings] = await Promise.all([
// inbox 탭은 status='active' 만 표시 — loadByView('inbox') 와 동일 path 로 일관성 확보.
// listNotes 는 deleted_at IS NULL 만 필터 (= active+completed+archived 혼재) 이라 부정확.
inboxApi.listByStatus('active', { limit: 50 }),
inboxApi.getContinuity(),
inboxApi.getPendingCount(),
inboxApi.getOllamaStatus(),
inboxApi.getTodayCount(),
inboxApi.getTrashCount(),
inboxApi.listExpired(),
inboxApi.getFailedCount(),
inboxApi.listRecallCandidate(),
inboxApi.countsByStatus(),
inboxApi.getSettings()
]);
set({ notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, ai_enabled: settings.ai_enabled ?? true, loading: false });
} catch (e) {
// 첫 launch 의 IPC 실패 (DB migration 실패 / main process 비정상) 시 무한 loading 회피.
// 빈 데이터로 진입하면 사용자가 캡처 시도 → 실제 fail 이 표면화 → 재시도 가능.
console.error('[inbox] loadInitial failed', e);
set({ loading: false });
}
},
async refreshMeta() {
const [continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate] = await Promise.all([
inboxApi.getContinuity(),
inboxApi.getPendingCount(),
inboxApi.getOllamaStatus(),
inboxApi.getTodayCount(),
inboxApi.getTrashCount(),
inboxApi.listExpired(),
inboxApi.getFailedCount(),
inboxApi.listRecallCandidate()
]);
set({ continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate });
try {
const [continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, settings] = await Promise.all([
inboxApi.getContinuity(),
inboxApi.getPendingCount(),
inboxApi.getOllamaStatus(),
inboxApi.getTodayCount(),
inboxApi.getTrashCount(),
inboxApi.listExpired(),
inboxApi.getFailedCount(),
inboxApi.listRecallCandidate(),
inboxApi.countsByStatus(),
inboxApi.getSettings()
]);
set({ continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, ai_enabled: settings.ai_enabled ?? true });
} catch (e) {
// refreshMeta 는 background poll/event 에서 자주 호출 → fail 무시 (다음 호출에 회복).
console.error('[inbox] refreshMeta failed', e);
}
},
upsertNote(note) {
// trashCount 는 server-authoritative. trashNotes 가 cache-loaded (showTrash=true) 일
// 때만 trashCount 를 local recompute. 그 외엔 server 값 (refreshMeta) 보존.
const showTrash = get().showTrash;
if (note.deletedAt !== null) {
// trash 노트: notes 에서 제거 + trashNotes 에 upsert
const cleanNotes = get().notes.filter((n) => n.id !== note.id);
const ti = get().trashNotes.findIndex((n) => n.id === note.id);
const nextTrash = get().trashNotes.slice();
if (ti >= 0) nextTrash[ti] = note;
// v0.3.8 — status 가 current view 와 매칭될 때만 notes 에 유지. 그 외엔 제거.
// 이전 구현은 trashed 외 모든 status 를 notes 에 누적 → 사용자가 inbox view 에서
// 완료/보관 으로 옮긴 노트가 list 에 잔류하는 버그. push-based (setStatus 도 emit) 로
// 모든 status 전이가 upsertNote 를 거치므로 view-aware filter 가 필수.
//
// trashCount/trashNotes 는 server-authoritative. trashNotes 가 cache-loaded
// (view='trash') 일 때만 trashCount 를 local recompute. 그 외엔 server 값
// (refreshMeta) 보존. searchResults 도 별도로 갱신 (status 변경 시 list 에서 제거).
const state = get();
const view = state.view;
const showTrash = state.showTrash;
const viewStatus: 'active' | 'completed' | 'archived' | 'trashed' | null =
view === 'inbox' ? 'active' :
view === 'completed' ? 'completed' :
view === 'archived' ? 'archived' :
view === 'trash' ? 'trashed' : null;
// trashNotes — note.status='trashed' 면 upsert, 아니면 제거.
const cleanTrash = state.trashNotes.filter((n) => n.id !== note.id);
let nextTrash = cleanTrash;
if (note.status === 'trashed') {
const ti = state.trashNotes.findIndex((n) => n.id === note.id);
nextTrash = cleanTrash.slice();
if (ti >= 0) nextTrash.splice(ti, 0, note);
else nextTrash.unshift(note);
set({
notes: cleanNotes,
trashNotes: nextTrash,
...(showTrash ? { trashCount: nextTrash.length } : {})
});
} else {
// active 노트: trashNotes 에서 제거 + notes 에 upsert (restore 케이스 포함)
const cleanTrash = get().trashNotes.filter((n) => n.id !== note.id);
const i = get().notes.findIndex((n) => n.id === note.id);
const nextNotes = get().notes.slice();
if (i >= 0) nextNotes[i] = note;
else nextNotes.unshift(note);
set({
notes: nextNotes,
trashNotes: cleanTrash,
...(showTrash ? { trashCount: cleanTrash.length } : {})
});
}
// notes — current view 의 status 와 매칭되는 경우만 유지/upsert.
// viewStatus=null (review/settings/검색) 이면 notes 직접 렌더 안 함 → 갱신 skip.
const cleanNotes = state.notes.filter((n) => n.id !== note.id);
let nextNotes = state.notes;
if (viewStatus !== null) {
if (note.status === viewStatus) {
const i = state.notes.findIndex((n) => n.id === note.id);
nextNotes = cleanNotes.slice();
if (i >= 0) nextNotes.splice(i, 0, note);
else nextNotes.unshift(note);
} else {
nextNotes = cleanNotes;
}
}
// searchResults — null 아니면 동일 패턴으로 갱신 (status 가 current search status 와
// 안 맞으면 제거, 맞으면 upsert).
let nextSearch = state.searchResults;
if (state.searchResults !== null) {
const cleanSearch = state.searchResults.filter((n) => n.id !== note.id);
if (viewStatus === null || note.status === viewStatus) {
// search 가 active 한 view 가 review/settings 면 status filter 없음 → 모두 keep.
const i = state.searchResults.findIndex((n) => n.id === note.id);
nextSearch = cleanSearch.slice();
if (i >= 0) nextSearch.splice(i, 0, note);
else nextSearch.unshift(note);
} else {
nextSearch = cleanSearch;
}
}
set({
notes: nextNotes,
trashNotes: nextTrash,
searchResults: nextSearch,
...(showTrash ? { trashCount: nextTrash.length } : {})
});
},
removeNote(id) {
const cleanNotes = get().notes.filter((n) => n.id !== id);
@@ -133,6 +227,49 @@ 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) {
// view 전환 시 검색/태그 필터 reset — 이전 view 의 필터가 새 view 에 잘못 적용되는 것 방지.
set({
view,
showTrash: view === 'trash',
showSettings: view === 'settings',
searchResults: null,
searchQuery: '',
tagFilter: null
});
// status view 면 해당 status fetch. inbox 도 포함 — 다른 탭에서 돌아올 때 notes 가
// 이전 status 로 stale 한 상태이므로 재로드 필요.
if (view === 'inbox' || view === 'completed' || view === 'archived' || view === 'trash') {
void get().loadByView(view);
}
// v0.2.11 Cut D — review-* view 진입 시 aggregate 로드.
if (view === 'review-daily') void get().loadReview('daily');
if (view === 'review-weekly') void get().loadReview('weekly');
if (view === 'review-monthly') void get().loadReview('monthly');
},
async loadByView(view) {
// v0.3.8 — IPC 실패 시 stale 한 이전 view 의 notes 가 계속 노출되는 사고 방지.
// fail 시 빈 배열로 reset 해서 사용자에게 "비어있음" 으로 표시 (혼동 < stale).
const status =
view === 'trash' ? 'trashed' : view === 'inbox' ? 'active' : view;
try {
const notes = await inboxApi.listByStatus(status, { limit: 200 });
if (view === 'trash') {
set({ trashNotes: notes, trashCount: notes.length });
} else {
set({ notes });
}
} catch (e) {
console.error('[inbox] loadByView failed', view, e);
if (view === 'trash') set({ trashNotes: [] });
else set({ notes: [] });
}
},
async toggleShowTrash() {
const next = !get().showTrash;
set({ showTrash: next });
@@ -145,11 +282,12 @@ export const useInbox = create<InboxState>((set, get) => ({
async restoreNote(id) {
await inboxApi.restoreNote(id);
// 낙관적 갱신: main 은 trash/restore 시 pushNoteUpdated 를 보내지 않음
// (현재 AiWorker.onUpdate 만 push). 자가 반영이 primary 메커니즘.
// (현재 AiWorker.onUpdate + setStatus 만 push). 자가 반영이 primary 메커니즘.
// 전제: 호출 시점에 trashNotes 에 노트가 존재 (T14 trash view 한정 호출).
// v0.3.8 — status 도 'active' 로 함께 갱신. upsertNote 가 status='trashed' 만 trash 로 라우팅.
const note = get().trashNotes.find((n) => n.id === id);
if (note) {
get().upsertNote({ ...note, deletedAt: null });
get().upsertNote({ ...note, deletedAt: null, status: 'active' });
}
},
async permanentDeleteNote(id) {
@@ -162,10 +300,6 @@ export const useInbox = create<InboxState>((set, get) => ({
set({ trashNotes: [], trashCount: 0 });
}
},
async loadExpired() {
const expiredCandidates = await inboxApi.listExpired();
set({ expiredCandidates });
},
async trashExpiredBatch(ids: string[]) {
const r = await inboxApi.trashExpiredBatch(ids);
if (!r.confirmed) return;
@@ -177,12 +311,7 @@ export const useInbox = create<InboxState>((set, get) => ({
});
},
snoozeExpired() {
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
const now = Date.now();
const kstNow = now + KST_OFFSET_MS;
const kstMidnightFloor = Math.floor(kstNow / 86_400_000) * 86_400_000;
const nextKstMidnight = kstMidnightFloor + 86_400_000;
set({ expiredSnoozeUntilMs: nextKstMidnight - KST_OFFSET_MS });
set({ expiredSnoozeUntilMs: nextKstMidnightMs(Date.now()) });
},
async recheckOllama() {
const status = await inboxApi.ollamaRecheck();
@@ -212,17 +341,50 @@ export const useInbox = create<InboxState>((set, get) => ({
set({ recallCandidate, recallSnoozeUntilMs: null });
},
async snoozeRecall() {
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
const now = Date.now();
const kstNow = now + KST_OFFSET_MS;
const kstMidnightFloor = Math.floor(kstNow / 86_400_000) * 86_400_000;
const nextKstMidnight = kstMidnightFloor + 86_400_000;
set({ recallSnoozeUntilMs: nextKstMidnight - KST_OFFSET_MS });
set({ recallSnoozeUntilMs: nextKstMidnightMs(Date.now()) });
// m1 fix — candidate=null 인 race 케이스 (사용자가 banner 닫힌 직후 클릭) 시
// snooze 는 적용하되 emit 만 skip. telemetry 누락 받아들임 (의도적).
const candidate = get().recallCandidate;
if (candidate) {
await inboxApi.emitRecallSnoozed(candidate.id);
inboxApi.emitRecallSnoozed(candidate.id);
}
},
// v0.2.11 Cut D — FTS5 search + review aggregate actions.
setSearchQuery(q) {
set({ searchQuery: q });
if (q.trim().length === 0) set({ searchResults: null });
},
async searchNotes(q) {
if (q.trim().length === 0) {
set({ searchResults: null });
return;
}
const view = get().view;
// 회고/설정 view 일 때는 status filter 무의미 → 그대로 전체 검색
const status = view === 'completed' || view === 'archived' || view === 'trash'
? (view === 'trash' ? 'trashed' : view)
: view === 'inbox' ? 'active' : undefined;
try {
const r = await inboxApi.search(q, status ? { status } : {});
set({ searchResults: r });
} catch (e) {
// FTS5 query parse error (special char 미escape) / IPC fail → 빈 결과로.
console.error('[inbox] searchNotes failed', e);
set({ searchResults: [] });
}
},
clearSearch() {
set({ searchQuery: '', searchResults: null });
},
async loadReview(period) {
try {
const data = await inboxApi.reviewAggregate(period);
set({ reviewData: data });
} catch (e) {
// review IPC fail 시 reviewData=null → ReviewView 의 "불러오는 중…" 영구 표시 회피.
// 빈 aggregate 로 set 해서 사용자에게 "0건" 표기.
console.error('[inbox] loadReview failed', period, e);
set({ reviewData: { totalCount: 0, tagCounts: [], dueProgress: { total: 0, passed: 0, pending: 0 }, recentNotes: [] } });
}
}
}));

View File

@@ -11,13 +11,60 @@ export interface NoteMedia {
bytes: number;
}
export type AiStatus = 'pending' | 'done' | 'failed';
export type AiStatus = 'pending' | 'done' | 'failed' | 'disabled';
// v0.2.9 Cut B — 노트 status 4분기 (사용자 액션). m004 마이그레이션 + setStatus.
export type NoteStatus = 'active' | 'completed' | 'archived' | 'trashed';
export interface NoteTag {
name: string;
source: 'ai' | 'user';
}
// v0.2.10 Cut C — note_revisions 테이블 row.
// 'capture' = 최초 캡처 시점, 'user' = 사용자가 raw_text 정정한 시점.
export interface NoteRevision {
revId: number;
noteId: string;
rawText: string;
editedAt: string;
editedBy: 'user' | 'capture';
}
// v0.2.11 Cut D — 회고 view aggregate.
export type ReviewPeriod = 'daily' | 'weekly' | 'monthly';
export interface ReviewAggregate {
totalCount: number;
recentNotes: Note[];
tagCounts: Array<{ tag: string; count: number }>;
dueProgress: { total: number; passed: number; pending: number };
}
// v0.3.0 Cut E — 양방향 sync 결과 + conflict.
// `path` = git index 의 conflict 파일 상대경로 (예: 'notes/2026-05-09-abc12345-회의.md').
// F5 export 의 filename 은 date-id8-slug 패턴 — UUID 가 아니라 path 가 맞는 식별자.
export interface SyncConflict {
path: string;
localText: string;
remoteText: string;
}
export interface SyncStatus {
ok: boolean;
reason?: string;
changed?: boolean;
localSha?: string | null;
pushed?: boolean;
importedCount?: number;
conflicts?: SyncConflict[];
}
export interface SyncStatusSnapshot {
lastAt: string | null;
lastResult: SyncStatus | null;
nextAt: string | null;
}
export interface Note {
id: string;
rawText: string;
@@ -37,6 +84,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 +108,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(
@@ -84,14 +149,86 @@ export interface InboxApi {
onOllamaStatus(cb: (status: { ok: boolean; reason?: string }) => void): () => void;
retryAllFailed(): Promise<{ count: number }>;
getFailedCount(): Promise<number>;
// v0.3.9 — per-note retry/cancel. failed/pending 노트의 사용자 unblock path.
retryOneFailed(id: string): Promise<{ ok: boolean }>;
cancelPending(id: string): Promise<{ ok: boolean }>;
listRecallCandidate(): Promise<Note | null>;
markRecallOpened(id: string): Promise<{ note: Note }>;
dismissRecall(id: string): Promise<{ note: Note }>;
emitRecallShown(id: string): Promise<void>;
emitRecallSnoozed(id: string): Promise<void>;
emitRecallShown(id: string): void;
emitRecallSnoozed(id: string): void;
loadOllamaSettings(): Promise<{ endpoint: string; model: string } | null>;
saveOllamaSettings(v: { endpoint: string; model: string }): Promise<{ ok: true } | { ok: false; reason: string }>;
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;
sync_repo_url?: string | null;
sync_auto_enabled?: boolean;
sync_interval_min?: number;
// v0.3.1 Cut F
vision_model?: string | null;
vision_capable_cache?: string[];
vision_cache_at?: string;
}>;
setAiEnabled(enabled: boolean): Promise<{ ok: true }>;
setOnboardingCompleted(completed: boolean): Promise<{ ok: true }>;
// v0.2.9 Cut B Task 16 — ai_status='disabled' 메모 재투입 (사용자가 ai_enabled OFF→ON 전환 시).
enqueueDisabled(): Promise<{ count: number }>;
getDisabledCount(): Promise<number>;
// v0.2.10 Cut C — raw_text 가변 + revision 보존.
updateRawText(noteId: string, newText: string): Promise<{ ok: true } | { ok: false; reason: string }>;
listRevisions(noteId: string): Promise<NoteRevision[]>;
restoreRevision(noteId: string, revId: number): Promise<{ ok: true } | { ok: false; reason: string }>;
// v0.2.11 Cut D — FTS5 search + 회고 aggregate.
search(query: string, opts?: { limit?: number; status?: NoteStatus }): Promise<Note[]>;
reviewAggregate(period: ReviewPeriod): Promise<ReviewAggregate>;
// v0.3.0 Cut E — 양방향 sync.
configureSync(url: string | null): Promise<{ ok: true } | { ok: false; reason: string }>;
testSyncConnection(): Promise<{ ok: true } | { ok: false; reason: string }>;
listConflicts(): Promise<SyncConflict[]>;
resolveConflict(path: string, choice: 'local' | 'remote'): Promise<{ ok: true } | { ok: false; reason: string }>;
getSyncStatus(): Promise<SyncStatusSnapshot>;
setSyncAutoEnabled(enabled: boolean): Promise<{ ok: true }>;
setSyncIntervalMin(value: number): Promise<{ ok: true } | { ok: false; reason: string }>;
// v0.3.1 Cut F — vision capability detection + 모델 선택.
getVisionModels(): Promise<{ models: string[]; at: string | null; selected: string | null }>;
setVisionModel(value: string | null): Promise<{ ok: true }>;
refreshVisionCache(): Promise<{ ok: true; models: string[] } | { ok: false; reason: string }>;
}
export interface InklingApi {

View File

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

View File

@@ -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,101 @@
// @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 })),
// v0.3.1 Cut F — VisionSection 이 AiProviderSection 에 마운트되어 호출.
getVisionModels: vi.fn(async () => ({ models: [], at: null, selected: null })),
setVisionModel: vi.fn(async () => ({ ok: true as const })),
refreshVisionCache: vi.fn(async () => ({ ok: true as const, models: [] }))
}
}));
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());
});
});

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