39 Commits

Author SHA1 Message Date
th-kim0823
7418eb9363 docs(changelog): v0.3.14 force re-tag dogfood fixes 7건 반영
기존 v0.3.14 'AI 처리 fail 원인 가시화' 노트 위에 force re-tag 로 묶인
후속 변경 (capture / cmd 안내 / macOS / notes 재처리 / expiry / sync /
settings 풀어쓰기) section 을 prepend. 새 minor 안 늘리는 패턴 유지.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 14:29:26 +09:00
th-kim0823
906e9b6f7d feat(settings): SectionIntro 설명 paragraph + SyncHelpModal 풀어쓰기
dogfood: 설정 페이지의 각 section 이 너무 단답형이고 도움말 텍스트도
기술 용어 (rebase, fast-forward, NTP) 위주라 불친절.

- 공통 SectionIntro 컴포넌트 신설 (12px gray paragraph, margin-bottom 12).
- 6 section (AI 제공자 / Vision / 자동실행 / 백업 / 동기화 / 정보) 상단에
  "이게 뭐고 왜 필요한지" 1-2 문장 안내 추가. 톤은 담백 + 업무적 (존댓말,
  Inkling 1인칭).
- SyncHelpModal section 1, 2, 3 의 기술 용어를 사용자 언어로 풀어쓰기.
  "fetch + rebase" → "원격 변경 먼저 받아오기", "NTP" → "기기 시각 어긋남",
  "non-fast-forward push 거부" → "업로드 거부 시 자동 재시도" 등.

시각/레이아웃은 그대로 유지 — 텍스트 변경만.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:12:23 +09:00
th-kim0823
2b5ba8a50e fix(sync): manifest.exported_at 제거 — no-op push 회피
dogfood: 노트 변경이 0건이어도 자동 sync 가 매번 commit + push 를 생성.
원인은 manifest.json 의 exported_at timestamp 가 매 export 마다 갱신되어
git diff 가 항상 1줄 발생.

해결: composeManifest 의 exportedAt 입력 제거 + 출력 JSON 에서 필드 삭제.
이 필드는 ImportService 가 read 하지 않고 UI 표시도 없는 cosmetic 정보였음.
이제 노트 변경 있을 때만 commit/push 가 일어난다.

회귀 테스트: 같은 input 으로 두 번 호출 시 stable 출력 invariant 추가.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:12:08 +09:00
th-kim0823
3c731cc754 feat(expiry): inbox 만 대상 + 오늘 당일 포함 + 헤딩/라벨/메모 바로가기
dogfood: 마감 알림이 (1) 완료/보관 status 노트도 포함하고 (2) 오늘 당일
마감 메모는 빠져 있어 사용자 불편.

NoteRepository.findExpiredCandidates 변경:
- due_date < today → <=today (오늘 당일 포함)
- status='active' 필터 추가 (inbox 만, completed/archived/trashed 제외)
- ORDER BY due_date DESC → 오늘 → 어제 → 그저께 순

ExpiryBanner UX:
- 헤딩 분리 카운트 "오늘 마감 X · 지난 Y" (한 쪽만이면 단독 표시)
- 노트 옆 due_date → 상대 라벨 ([오늘] / [N일 지남]) + hover tooltip 으로
  원본 ISO 날짜 노출
- 노트 제목 클릭 → note-{id} 로 smooth scroll (RecallBanner 와 동일 패턴).
  checkbox 와 분리하기 위해 label → div + button 으로 구조 변경.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:11:58 +09:00
th-kim0823
352457189e feat(notes): 원문 편집/이력 복원 시 AI 재처리
dogfood: 사용자가 노트 본문 수정해도 기존 AI 제목/요약이 그대로 남는 문제.
NoteRepository.markAiPendingForReprocess 추가 — done/failed/pending 노트를
pending 으로 reset + pending_jobs 재투입. disabled 는 사용자가 명시적으로
비활성화한 상태라 존중하여 no-op.

inboxApi 의 update-raw-text / restore-revision 핸들러가 raw 갱신 후 위
헬퍼 + worker.enqueue + pushNoteUpdated 호출. NoteCard.saveRaw 는 optimistic
으로 aiStatus='pending' 즉시 반영 → UI 가 "Inkling이 정리하는 중…" 즉시
표시, 백엔드 push 로 자동 sync. updateAiResult 의 user-edit 가드가 사용자가
직접 편집한 title/summary 는 새 AI 결과로 덮어쓰지 않으므로 안전.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:11:38 +09:00
th-kim0823
a68feae20e fix(macos): hidden autostart dock indicator + 자동실행 mismatch false positive
두 macOS 한정 버그 묶음:

1. autostart --hidden 으로 spawn 시 quickCapture (NSPanel) 만 떠 있어
   dock running indicator (점) 가 표출 안 됨 — NSPanel 은 NSApp main window
   로 register 안 됨. inboxWindow 를 hidden 상태로 미리 create + ready-to-show
   시점에 showInactive → hide trick 으로 NSApp 에 register, 사용자 화면
   깜빡임 없이 dock 점 켜짐.

2. SettingsPage 의 자동실행 mismatch 경고가 macOS 에서 false positive.
   macOS 13+ 의 SMAppService API 가 args 옵션 무시 + unsigned/Electron
   앱에 대해 executableWillLaunchAtLogin 을 자주 false 로 반환 → 정상 등록
   상태에서도 경고 떠 있음. AutostartDiagnostic 결과에 platform 필드 추가,
   willLaunch 신호는 win32 에서만 mismatch 판정에 사용.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:11:17 +09:00
th-kim0823
64935d943c chore(ux): macOS 사용자 위해 Cmd 키 hint 안내
renderer 두 곳의 단축키 안내 텍스트가 'Ctrl+...' hardcoded 였음. 사용자에게
보여지는 hint 만 platform-aware 로 분기 (navigator.platform 검사) — Mac
에서는 'Cmd+Shift+J', 'Cmd+Enter' 로 표시.

main 의 globalShortcut accelerator 는 이미 platform 별 분기되어 있어 별개
영향 없음. UI 안내만 일치시키는 변경.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:11:03 +09:00
th-kim0823
9cf6cafab2 fix(capture): blur-on-hide 제거 — esc/cmd+enter 까지 창 유지
dogfood: 사용자가 quickCapture 띄운 채로 다른 창 클릭 시 즉시 hide 되어
저장/취소 의도를 명확히 표시하기 전에 사라지는 현상. blur 핸들러 제거 →
ESC (취소) / Cmd+Enter (저장) 누를 때까지 창 유지. alwaysOnTop +
screen-saver level 이라 다른 앱 위에 떠 있음.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:10:52 +09:00
th-kim0823
d3bc972783 fix(vision): 본문 빈 + 이미지 only 케이스 AI 호출 skip
gemma4:26b 등 vision 모델이 본문 없는 이미지 단독 입력을 의미 있게 처리 못 함
(여러 prompt 시도에도 빈 응답). 모델 한계 수용:

- AiWorker 가 rawText.trim()==='' && media.length>0 detect 시 vision call skip
- 자동 placeholder: '첨부 이미지' / '첨부 이미지 N장' + summary
- ai_provider='image-only-skip' (디버그성 식별자)
- NoteCard 노란 배너 제거 (사용자가 한계 수용, placeholder 자체로 충분)
- 사용자는 EditableField 로 제목/요약 직접 편집 가능

cold-start timeout / parseJsonLoose fallback / schema coerce 부담 모두 skip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:28:58 +09:00
th-kim0823
30b14d2b74 fix(vision): 본문 빈 케이스에 one-shot 예시 추가
gemma4:26b 가 본문 없이 이미지만 받으면 null 반환하는 한계 우회.
prompt 강화 + null 금지 명시 만으로 부족. one-shot 예시 (강아지/화이트보드)
2개로 모델이 입출력 구조 따라가도록 유도.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:56:43 +09:00
th-kim0823
431b35a72a fix(vision): graceful fallback 배너 문구 정밀화
설정 다 한 사용자도 본문 없이 이미지만 첨부하면 placeholder 떨어지는 케이스 잦음
(gemma4:26b 등 vision 모델의 본문 없는 이미지 처리 한계).

배너가 "설정 확인" 권유 → 사용자 혼란.
"본문 없이 이미지만 첨부한 경우 일부 vision 모델이 빈 응답" + "본문 추가 또는 직접 수정"
으로 변경. 실제 원인 명시.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:51:40 +09:00
th-kim0823
e34f036f20 fix(vision): graceful fallback 가시화 + 진단 로그
이전 fix 후에도 사용자가 "(첨부 메모)" placeholder 만 보이면 왜 fail 한지 모름.
가능성 큰 원인: vision_model 미설정 → text-only path → 본문 빈 응답.

- AiWorker: ai.vision.decide 로그 추가 — visionActive / visionModelConfigured /
  mediaCount / mediaStorePresent. logs/main.log 에서 진단 가능.
- NoteCard: ai_status='done' + title='(첨부 메모)' + media 있을 때 노란 banner.
  "vision 모델 설정 확인 + 직접 편집" 안내.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:44:41 +09:00
th-kim0823
c616555d7d fix(vision): repetition loop 대응 + parseJsonLoose graceful fallback
gemma4:26b 가 "기기기기..." repetition loop 에 빠져 num_predict cap 도달 →
JSON truncate → unparseable. 두 가지 fix:

- Ollama body 에 repeat_penalty: 1.15 추가 (token repetition 억제)
- parseJsonLoose fail 시 throw 대신 {} 반환 → schema graceful coerce 가
  placeholder title/summary 채움. raw_text 는 보존 → 사용자 데이터 무손실.
- coerceNullable 가 undefined 도 처리 (빈 객체 케이스).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:39:54 +09:00
th-kim0823
218868206b fix(schema): null/empty title/summary 를 placeholder 로 coerce
gemma4:26b 가 본문 빈 케이스에 title=null/summary=null 반환 → schema throw.
prompt 강화로 부족. schema 단계에서 graceful coerce:
- null/empty title → '(첨부 메모)'
- null/empty summary → '내용을 자동으로 정리하지 못했습니다.'
- 영어 title → '(첨부 메모)' (이전엔 throw)
- malformed/empty due_date → null (이전엔 throw)

raw_text 는 호출자가 보존하므로 사용자 데이터 손실 없음.
사용자가 후에 NoteCard 에서 직접 편집 가능.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:24:20 +09:00
th-kim0823
b2be29bd33 fix(vision): 본문 빈 케이스 prompt 강화 — null title/summary 회귀
gemma4:26b 가 본문 없이 이미지만 있을 때 title=null/summary=null 반환.
prompt 가 "(이미지만 있음)" 만 던지는 게 신호 약함. 본문 비었으면
이미지 내용으로 한국어 채우라고 명시 + "null 반환 금지" 규칙 추가.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:15:18 +09:00
th-kim0823
4266376b23 chore(release): v0.3.14 — AI fail 원인 가시화
- NoteCard: failed 노트에 <details> "원인 보기" 접힘 섹션 추가.
  ai_error 전체 노출 (wrap + word-break).
- AiWorker: markAiFailed 시 [reason] provider prefix 추가.
  사용자가 timeout/schema/other 카테고리 + 모델명 즉시 식별.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:07:53 +09:00
th-kim0823
bd71bba2da chore(release): v0.3.13 — vision generate timeout 120s → 300s
gemma4:26b (25B MoE + vision encoder 550M) 등 대형 vision 모델의
cold-start 가 60-180s 소요. 기본 120s timeout 으로 첫 호출 fail 빈번.
vision path 에 한해 Math.max(timeoutMs, 300_000) — text-only 영향 없음.

gemma4:26b 가 Text+Image 양 modality 지원 검증 완료
(blog.google/gemma-4, ollama.com/library/gemma4:26b).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:39:11 +09:00
th-kim0823
713553a038 chore(release): v0.3.12 — vision AI 응답 robust parse
vision model 의 markdown fence / prose 섞인 응답에서 JSON 추출 fallback.
prompt 에 title 한국어 / kebab tags / JSON-only 출력 명시 강화.

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

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

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

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

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

Windows / Linux 동작 변경 없음.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:04:46 +09:00
64 changed files with 2708 additions and 311 deletions

4
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

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`

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "inkling",
"version": "0.3.1",
"version": "0.3.14",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "inkling",
"version": "0.3.1",
"version": "0.3.14",
"dependencies": {
"better-sqlite3": "12.9.0",
"electron-log": "5.2.0",

View File

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

View File

@@ -137,6 +137,23 @@ export class AiWorker {
const nowDate = this.now();
const todayDate = kstTodayAsDate(nowDate);
const todayIso = kstTodayIso(nowDate);
// v0.3.14 — 본문 빈 + 이미지만 첨부 케이스는 모델이 의미 있는 응답 못 함
// (gemma4:26b 등 vision 모델의 한계 확인). AI 호출 skip, 자동 placeholder 적용 후
// 즉시 done. 사용자가 후에 NoteCard 의 EditableField 로 제목/요약 편집 가능.
const rawEmpty = note.rawText.trim().length === 0;
if (rawEmpty && note.media.length > 0) {
const n = note.media.length;
const title = n === 1 ? '첨부 이미지' : `첨부 이미지 ${n}`;
const summary = `이미지 ${n}장이 첨부된 메모입니다.\n원문 영역에서 이미지 확인할 수 있습니다.\n제목과 요약을 클릭해 직접 편집할 수 있습니다.`;
this.repo.updateAiResult(job.noteId, {
title, summary, tags: [], provider: 'image-only-skip', dueDate: null
});
this.logger.info('ai.skip.image-only', { noteId: job.noteId, mediaCount: n });
this.emit(job.noteId);
return;
}
const candidates = parseAllCandidates(note.rawText, todayDate);
const vocab = this.repo.getTopUsedTags(VOCAB_TOP_N);
// v0.3.1 Cut F — vision path: visionModel + note.media → base64 images
@@ -144,7 +161,17 @@ export class AiWorker {
// 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 visionActive = !!(visionModel && note.media.length > 0 && this.mediaStore);
// v0.3.14 — vision 활성 여부 진단 로그. 사용자가 vision_model 미설정으로 text-only
// path 가 도는지 / 이미지가 모델에 전달되는지 확인 가능 (logs/main.log).
this.logger.info('ai.vision.decide', {
noteId: job.noteId,
visionActive,
visionModelConfigured: !!visionModel,
mediaCount: note.media.length,
mediaStorePresent: !!this.mediaStore
});
if (visionActive) {
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)`);
@@ -233,8 +260,13 @@ export class AiWorker {
const nextRunAt = new Date(Date.now() + (this.backoffsMs[attempt + 1] ?? 0)).toISOString();
this.repo.incrementJobAttempt(job.noteId, nextRunAt, msg);
if (isLast) {
this.repo.markAiFailed(job.noteId, msg);
this.logger.error('ai.failed', { noteId: job.noteId, err: msg });
// v0.3.14 — ai_error 에 reason + provider name prefix 추가. NoteCard 의 "원인 보기"
// 가 사용자에게 보여주는 raw 메시지에 context (timeout/unreachable/schema/other +
// 어느 모델이 fail 했는지) 가 포함되어 진단성 향상.
const provider = this.holder.get().name;
const annotated = `[${reason}] ${provider}\n${msg}`;
this.repo.markAiFailed(job.noteId, annotated);
this.logger.error('ai.failed', { noteId: job.noteId, err: msg, reason, provider });
if (this.telemetry) {
await this.telemetry.emit({
kind: 'ai_failed',

View File

@@ -13,6 +13,29 @@ function classifyFetchError(e: unknown): 'network' | 'timeout' | 'dns' | 'other'
return 'other';
}
/**
* v0.3.11 — vision model 이 'format:json' constraint 를 부분적으로 따라 markdown 코드
* 펜스 / prose 가 섞인 응답을 반환할 때 fallback. 첫 '{' ~ 마지막 '}' substring 만
* 추출해서 JSON.parse 재시도.
*
* v0.3.14 — fail 시 throw 대신 `{}` 반환. schema 의 graceful coerce 가 빈 객체를
* placeholder title/summary 로 채움 → 사용자 데이터 손실 없이 노트 보관 (raw_text 그대로).
* 모델이 repetition loop 로 num_predict cap 도달해 JSON truncate 된 케이스에 robust.
* 원본 응답 snippet 은 console.warn 으로 로그 (디버그성).
*/
function parseJsonLoose(raw: string): unknown {
try { return JSON.parse(raw); } catch { /* fallback below */ }
const first = raw.indexOf('{');
const last = raw.lastIndexOf('}');
if (first >= 0 && last > first) {
const slice = raw.slice(first, last + 1);
try { return JSON.parse(slice); } catch { /* fall through */ }
}
// 빈 객체 반환 → schema coerce 로 placeholder 적용. 원본 일부는 stderr 에 남김.
console.warn(`[LocalOllamaProvider] unparseable response, falling back to {}: ${raw.slice(0, 200).replace(/\s+/g, ' ')}`);
return {};
}
export interface LocalOllamaOptions {
endpoint?: string;
model?: string;
@@ -46,15 +69,22 @@ export class LocalOllamaProvider implements InferenceProvider {
? buildVisionPrompt(input.text, input.todayKst, input.dueDateCandidates.map((c) => c.iso ?? c.matchedToken ?? ''), input.vocab ?? [])
: buildPrompt(input.text, input.todayKst, input.dueDateCandidates, input.vocab ?? []);
// v0.3.13 — vision model 은 cold-start (모델 load + 이미지 encoding) 가 매우 느려
// 120s 기본 timeout 으로 첫 호출 fail 빈번. gemma4:26b (MoE 25B) 같은 대형 vision
// 모델은 첫 generate 가 60-180s 소요. 5분 (300s) 으로 확장.
const effectiveTimeout = useVision ? Math.max(this.timeoutMs, 300_000) : this.timeoutMs;
this.abortController = new AbortController();
const timer = setTimeout(() => this.abortController?.abort(), this.timeoutMs);
const timer = setTimeout(() => this.abortController?.abort(), effectiveTimeout);
try {
const body: Record<string, unknown> = {
model,
prompt,
format: 'json',
stream: false,
options: { temperature: this.temperature, num_predict: this.numPredict }
// v0.3.14 — repeat_penalty 추가. vision 모델 (특히 gemma 시리즈) 이 "기기기기..."
// 같은 repetition loop 에 빠져 num_predict cap 도달 → JSON truncate → unparseable.
// 1.15 는 Ollama 권장 범위 (1.0-1.3) 안쪽 conservative 값.
options: { temperature: this.temperature, num_predict: this.numPredict, repeat_penalty: 1.15 }
};
if (useVision) {
body.images = input.images!.map((i) => i.base64);
@@ -70,9 +100,8 @@ export class LocalOllamaProvider implements InferenceProvider {
}
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(responseBody.response); }
catch (err) { throw new Error(`invalid json in response: ${String(err)}`); }
// v0.3.11 — vision model 응답이 markdown fence / prose 섞인 경우 fallback 추출.
const parsed = parseJsonLoose(responseBody.response);
return parseAiResponse(parsed);
} finally {
clearTimeout(timer);

View File

@@ -39,13 +39,34 @@ function validateDueDate(d: string | null | undefined): string | null {
return d;
}
export function parseAiResponse(raw: unknown): AiResponse {
const parsed = RawResponseSchema.parse(raw);
if (!KOREAN_REGEX.test(parsed.title)) {
throw new Error('title must contain Korean characters');
/**
* vision 모델 (gemma4:26b 등) 이 본문 빈 케이스에 title/summary null 반환하는 케이스
* 대응. null → placeholder 한국어 문자열로 coerce 후 schema 통과. 빈 string / empty regex
* dueDate 도 null 로 normalize. raw_text 는 호출자가 보존하므로 사용자 데이터 손실 없음.
*/
function coerceNullable(raw: unknown): unknown {
if (typeof raw !== 'object' || raw === null) return raw;
const obj = { ...(raw as Record<string, unknown>) };
if (obj.title === null || obj.title === undefined || obj.title === '') obj.title = '(첨부 메모)';
if (obj.summary === null || obj.summary === undefined || obj.summary === '') obj.summary = '내용을 자동으로 정리하지 못했습니다.';
// due_date 의 빈 string / regex mismatch 도 null 로 강제 (schema 가 거부하지 않게).
if (obj.due_date === '' || (typeof obj.due_date === 'string' && !ISO_DATE_REGEX.test(obj.due_date))) {
obj.due_date = null;
}
// tags 가 null 이면 빈 배열로.
if (obj.tags === null || obj.tags === undefined) obj.tags = [];
return obj;
}
export function parseAiResponse(raw: unknown): AiResponse {
const coerced = coerceNullable(raw);
const parsed = RawResponseSchema.parse(coerced);
// title 이 한국어 0 자면 fallback placeholder 적용 (영어 title 도 fail 안 함).
// placeholder 는 한국어 포함이라 자기 자신 통과.
const titleHasKorean = KOREAN_REGEX.test(parsed.title);
const finalTitle = titleHasKorean ? parsed.title : '(첨부 메모)';
return {
title: parsed.title.slice(0, 60),
title: finalTitle.slice(0, 60),
summary: normalizeSummary(parsed.summary),
tags: parsed.tags.filter((t) => KEBAB_REGEX.test(t)).slice(0, 3),
dueDate: validateDueDate(parsed.due_date)

View File

@@ -4,13 +4,33 @@ export function buildVisionPrompt(
dueCandidates: string[],
vocab: string[]
): string {
return `다음 메모와 첨부 이미지를 종합 분석해 한국어로 요약하세요.
// v0.3.14 — 본문 빈 케이스에 one-shot 예시 추가. gemma4:26b 등이 본문 없이
// 이미지만 받으면 null 반환하는 한계 우회. 예시 입력/출력 구조 따라가도록 유도.
const bodySection = text
? `메모 본문:\n${text}\n\n첨부 이미지도 함께 분석해 요약에 반영하세요.`
: `본문이 없습니다. 첨부 이미지의 내용 (텍스트/사람/장면/문서 등) 만으로 한국어 title 과 summary 를 작성하세요. null 반환 절대 금지.
메모 본문 (비어 있을 수 있음):
${text || '(이미지만 있음)'}
예시 (이미지: 갈색 강아지가 잔디 위에 앉은 사진):
{"title":"잔디 위 강아지","summary":"갈색 강아지가 잔디 위에 앉아 있다.\\n배경에 나무가 보인다.\\n날씨가 맑다.","tags":["pet"],"due_date":null}
예시 (이미지: 회의실 화이트보드 사진):
{"title":"회의실 화이트보드","summary":"화이트보드에 일정과 안건이 적혀 있다.\\n프로젝트 이름이 보인다.\\n다이어그램이 그려져 있다.","tags":["meeting"],"due_date":null}
이제 첨부된 실제 이미지를 보고 같은 형식으로 작성하세요.`;
return `다음 메모를 한국어로 분석해 JSON 으로 정리하세요.
${bodySection}
규칙 (위반 시 재시도):
- "title": 한국어 문자열 필수, null 금지. 60자 이내. 영어 단독 금지.
- "summary": 한국어 문자열 필수, null 금지. 3줄. 이미지 시각 정보 (텍스트/사람/장면) 포함.
- "tags": 영문 kebab-case 배열 (예: ["meeting-notes"]), 최대 3개. 한국어 태그 금지. 없으면 [].
- "due_date": ISO YYYY-MM-DD 또는 null. 빈 문자열 금지.
오직 JSON 객체 하나만 출력. markdown 코드 펜스 (\`\`\`) / 설명 prose 금지.
출력 형식: {"title":"...","summary":"...","tags":[],"due_date":null}
이미지 분석 시 주요 시각적 정보 (텍스트, 사람, 장면) 도 포함해 요약하세요.
출력 JSON: { "title": "...", "summary": "...", "tags": [...], "due_date": "..." }
오늘: ${todayKst}
가능한 due 후보: ${dueCandidates.join(', ')}
빈출 태그: ${vocab.slice(0, 20).join(', ')}`;

View File

@@ -193,9 +193,10 @@ app.whenReady().then(async () => {
});
if (!reg.ok) logger.warn('hotkey.register.failed', { reason: reg.reason });
if (!startedHidden) {
createInboxWindow();
}
// macOS LoginItems autostart 시 startedHidden=true 로 spawn — 그대로 두면 quickCapture
// (NSPanel) 만 떠 있어 dock running indicator 미표출. inboxWindow 를 hidden 상태로
// 미리 create 하면 NSApp register → 점 표출 + 사용자가 dock 아이콘 확인으로 앱 살아있음 인지.
createInboxWindow({ visible: !startedHidden });
createQuickCaptureWindow();
await worker.loadFromDb();

View File

@@ -150,6 +150,25 @@ 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));
@@ -198,6 +217,9 @@ export function registerInboxApi(deps: InboxIpcDeps): void {
// v0.2.9 Cut B Task 8 — status 4분기 직접 전이 (사유 포함).
// Modal 의 "완료/보관/휴지통" 버튼 path. backward compat 동기화는
// NoteRepository.setStatus 내부에서 처리 (deleted_at sync).
// v0.3.8 — setStatus 도 pushNoteUpdated emit. 이전엔 emit 안 해서 renderer 의 store
// (counts/search/list) 가 stale 되어 NoteCard 호출처마다 명시적 refreshMeta 호출이
// 필요했음. push 화 시 onNoteUpdated 콜백 1개 path 로 일관 갱신.
ipcMain.handle(
'inbox:set-status',
async (_e, id: string, status: NoteStatus, reason: string | null) => {
@@ -206,6 +228,8 @@ export function registerInboxApi(deps: InboxIpcDeps): void {
return { ok: false as const, reason: 'invalid status' as const };
}
deps.repo.setStatus(id, status, reason);
const updated = deps.repo.findById(id);
if (updated !== null) pushNoteUpdated(deps.getInboxWindow, updated);
return { ok: true as const };
}
);
@@ -279,6 +303,7 @@ export function registerInboxApi(deps: InboxIpcDeps): void {
return { ok: false as const, reason: 'empty' as const };
}
deps.repo.updateRawText(id, newText);
await reprocessAi(deps, id);
return { ok: true as const };
});
@@ -287,6 +312,7 @@ export function registerInboxApi(deps: InboxIpcDeps): void {
ipcMain.handle('inbox:restore-revision', async (_e, id: string, revId: number) => {
try {
deps.repo.restoreRevision(id, revId);
await reprocessAi(deps, id);
return { ok: true as const };
} catch (e) {
return { ok: false as const, reason: (e as Error).message };
@@ -320,6 +346,19 @@ export function pushNoteUpdated(getWin: () => BrowserWindow | null, note: Note):
w.webContents.send('note:updated', note);
}
/**
* 원문 변경 후 AI 재처리 트리거. ai_status='pending' 으로 reset + pending_jobs 재투입 +
* worker enqueue + renderer push. disabled 노트는 사용자 명시 비활성화 의도 존중하여 skip.
*/
async function reprocessAi(deps: InboxIpcDeps, id: string): Promise<void> {
const now = new Date().toISOString();
const r = deps.repo.markAiPendingForReprocess(id, now);
if (!r.ok) return;
if (deps.enqueue) await deps.enqueue(id);
const updated = deps.repo.findById(id);
if (updated !== null) pushNoteUpdated(deps.getInboxWindow, updated);
}
export function pushOllamaStatus(getWin: () => BrowserWindow | null, status: HealthResult): void {
const w = getWin();
if (!w || w.isDestroyed()) return;

View File

@@ -1,6 +1,7 @@
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';
@@ -324,6 +325,11 @@ export function registerSettingsApi(deps?: SettingsIpcDeps): void {
// 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())) {

View File

@@ -248,6 +248,70 @@ 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 };
}
/**
* raw_text 편집/복원 직후 AI 재처리 트리거. done/failed/pending 노트를 pending 으로
* reset + pending_jobs row 보장 (attempts=0). disabled 는 사용자의 명시적 비활성화
* 의도 존중 — no-op. updateAiResult 의 user-edit 가드 (title_edited_by_user 등) 가
* 사용자가 직접 편집한 필드는 새 AI 결과로 덮어쓰지 않음.
*/
markAiPendingForReprocess(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 === 'disabled') 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 REPLACE 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 전환 후 "지금 모두 처리"
@@ -1057,9 +1121,12 @@ export class NoteRepository {
}
/**
* Notes whose due_date is strictly before today (KST calendar) and that are
* still active (not trashed) and AI-processed. Includes both AI-extracted and
* user-edited due_date (v0.2.3 #5 spec §1 Q1=B).
* Notes whose due_date is today (KST calendar) or already past, that are still
* active (inbox only — completed/archived/trashed 제외), and AI-processed.
* Includes both AI-extracted and user-edited due_date.
*
* 정렬: due_date DESC → 오늘 당일 먼저, 그 다음 어제, 그 전... 같은 due_date 내에선
* created_at DESC, id DESC tiebreak.
*
* Caller may inject `now` for testability; defaults to `new Date()`.
*/
@@ -1069,10 +1136,11 @@ export class NoteRepository {
.prepare(
`SELECT * FROM notes
WHERE due_date IS NOT NULL
AND due_date < ?
AND due_date <= ?
AND deleted_at IS NULL
AND status = 'active'
AND ai_status = 'done'
ORDER BY created_at DESC, id DESC`
ORDER BY due_date DESC, created_at DESC, id DESC`
)
.all(today) as Record<string, unknown>[];
return rows.map((r) => this.hydrate(r));

View File

@@ -4,11 +4,14 @@
import { KST_OFFSET_MS } from '../../shared/util/kstDate.js';
const FTS5_SPECIAL_CHARS_RE = /["*():]/g;
// 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 쿼리에 안전한 형태로 변환. " * ( ) : 제거 + 공백 정리.
* FTS5 MATCH 쿼리에 안전한 형태로 변환. special chars 공백 치환 + 공백 정리.
* 다중 토큰은 그대로 두어 FTS5 implicit AND 활용.
*/
export function sanitizeFtsQuery(input: string): string {

View File

@@ -13,6 +13,12 @@ export interface AutostartState {
withArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean };
noArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean };
execPath: string;
/**
* 플랫폼 분기용. macOS 13+ 의 SMAppService API 는 args 옵션 무시 + unsigned/Electron
* 앱에 대해 executableWillLaunchAtLogin 이 false 를 반환할 수 있어, mismatch 판정에서
* 해당 신호를 제외해야 false positive 방지 가능.
*/
platform: NodeJS.Platform;
registryPath?: string;
registryValue?: string | null;
}
@@ -26,7 +32,8 @@ export async function collectAutostartState(): Promise<AutostartState> {
const state: AutostartState = {
withArgs: { openAtLogin: w.openAtLogin, executableWillLaunchAtLogin: w.executableWillLaunchAtLogin },
noArgs: { openAtLogin: n.openAtLogin, executableWillLaunchAtLogin: n.executableWillLaunchAtLogin },
execPath: process.execPath
execPath: process.execPath,
platform: process.platform
};
if (process.platform === 'win32') {
state.registryPath = `${WIN_REGISTRY_PATH}\\${WIN_REGISTRY_KEY}`;

View File

@@ -146,9 +146,9 @@ export class CaptureService {
}
/**
* 만료 후보 (due_date < today KST, active, ai_status=done) 조회.
* 마감 임박 후보 (due_date today KST, status=active inbox, ai_status=done) 조회.
* 오늘 당일 마감 메모도 포함하여 사용자에게 미리 인지시킨다 (정렬은 due_date DESC).
* candidates 가 비지 않고 signature 가 직전과 다르면 expired_banner_shown 자동 emit.
* v0.2.3 #5 spec §6.2 — dedup 위치 main 통합.
*/
async listExpired(now: Date = new Date()): Promise<Note[]> {
const candidates = this.repo.findExpiredCandidates(now);
@@ -207,6 +207,24 @@ export class CaptureService {
return { count: ids.length };
}
/**
* v0.3.9 — 단일 failed 노트 재시도. NoteCard 의 per-note "재시도" 버튼 path.
* repo.retryOneFailed 후 worker.enqueue 재투입.
*/
async retryOneFailed(id: string): Promise<{ ok: boolean }> {
const r = this.repo.retryOneFailed(id, new Date().toISOString());
if (r.ok) await this.deps.enqueue(id);
return r;
}
/**
* v0.3.9 — pending 노트 cancel. ai_status='disabled' + pending_jobs 삭제.
* 사용자가 무한 pending (Ollama 끊김 등) 에서 빠져나오는 path.
*/
cancelPending(id: string): { ok: boolean } {
return this.repo.cancelPending(id, new Date().toISOString());
}
/** v0.2.3 #6 — 회상 후보 1건 fetch. */
async listRecallCandidate(): Promise<Note | null> {
return this.repo.findRecallCandidate();

View File

@@ -136,7 +136,6 @@ export class ExportService {
totalBytes += Buffer.byteLength(indexJsonl, 'utf8');
const manifest = composeManifest({
exportedAt: this.now().toISOString(),
noteCount: notes.length,
mediaCount
});

View File

@@ -253,14 +253,15 @@ export function composeIndexJsonl(
// ---------------------------------------------------------------------------
export function composeManifest(input: {
exportedAt: string;
noteCount: number;
mediaCount: number;
}): string {
// exported_at 필드 의도적 제외 — note 변경 없이도 git sync 가 매 호출마다
// timestamp 갱신 1줄 commit 을 만들어 history 노이즈와 불필요한 push 유발.
// import path 는 inkling_export_version 만 read 하므로 안전.
return JSON.stringify(
{
inkling_export_version: 1,
exported_at: input.exportedAt,
note_count: input.noteCount,
media_count: input.mediaCount
},

View File

@@ -11,10 +11,13 @@ export function getInboxWindow(): BrowserWindowType | null {
return inboxWindow;
}
export function createInboxWindow(): BrowserWindowType {
export function createInboxWindow(opts: { visible?: boolean } = {}): BrowserWindowType {
const visible = opts.visible ?? true;
if (inboxWindow && !inboxWindow.isDestroyed()) {
inboxWindow.show();
inboxWindow.focus();
if (visible) {
inboxWindow.show();
inboxWindow.focus();
}
return inboxWindow;
}
@@ -43,6 +46,19 @@ export function createInboxWindow(): BrowserWindowType {
}
});
inboxWindow.once('ready-to-show', () => inboxWindow?.show());
inboxWindow.once('ready-to-show', () => {
if (visible) {
inboxWindow?.show();
return;
}
// macOS hidden autostart: regular NSWindow 를 NSApp 에 register 해야 dock running
// indicator (점) 가 표출된다. panel type 의 quickCapture 만 있으면 NSPanel 미인지 →
// dock 점이 안 보여 "앱이 안 떠 있는 것처럼" 보이는 버그. showInactive 로 focus 점유
// 없이 짧게 표출 후 즉시 hide — 사용자 화면 깜빡임 최소화.
if (process.platform === 'darwin') {
inboxWindow?.showInactive();
inboxWindow?.hide();
}
});
return inboxWindow;
}

View File

@@ -16,23 +16,35 @@ 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 {
win.loadFile(join(__dirname, '../renderer/quickcapture/index.html'));
}
win.on('blur', () => { if (win?.isVisible()) win.hide(); });
return win;
}

View File

@@ -40,6 +40,8 @@ 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),

View File

@@ -18,6 +18,9 @@ import { SearchBox } from './components/SearchBox.js';
import { ReviewView } from './components/ReviewView.js';
import type { InboxView } from './store.js';
// QuickCapture 단축키 modifier — macOS 는 Cmd, 그 외는 Ctrl.
const MOD_KEY = /Mac/i.test(navigator.platform) ? 'Cmd' : 'Ctrl';
export function App(): React.ReactElement {
const {
notes, trashNotes, trashCount, showTrash,
@@ -153,15 +156,21 @@ export function App(): React.ReactElement {
<main className="main">
{!showTrash && (
<>
<OllamaBanner onOpenSettings={() => setShowSettings(true)} />
<RecoveryToast
show={showRecovery}
onDismiss={() => { markRecoveryDismissed(); setRecoveryDismissed(true); }}
/>
<PendingBanner />
<FailedBanner />
<ExpiryBanner />
<RecallBanner />
{/* AI/만료/회상 배너는 active 노트 컨텍스트 — inbox 탭에서만 노출.
completed/archived 에서는 무관 컨텐츠라 숨김. */}
{view === 'inbox' && (
<>
<OllamaBanner onOpenSettings={() => setShowSettings(true)} />
<RecoveryToast
show={showRecovery}
onDismiss={() => { markRecoveryDismissed(); setRecoveryDismissed(true); }}
/>
<PendingBanner />
<FailedBanner />
<ExpiryBanner />
<RecallBanner />
</>
)}
{tagFilter !== null && (
<div style={{
background: '#eaf3ff', color: '#0a4b80', padding: '6px 12px',
@@ -184,7 +193,7 @@ export function App(): React.ReactElement {
) : searchResults !== null && displayed.length === 0 ? (
<div className="empty"> .</div>
) : notes.length === 0 ? (
<div className="empty">릿 . <code>Ctrl+Shift+J</code></div>
<div className="empty">릿 . <code>{MOD_KEY}+Shift+J</code></div>
) : displayed.length === 0 ? (
<div className="empty"> .</div>
) : (

View File

@@ -1,10 +1,12 @@
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 = {
@@ -22,7 +24,7 @@ const rowStyle: React.CSSProperties = {
border: '1px solid #eee', borderRadius: 6, padding: 10, marginTop: 8
};
export function ConflictModal({ onClose, onResolved }: Props): React.ReactElement {
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);
@@ -36,6 +38,15 @@ export function ConflictModal({ onClose, onResolved }: Props): React.ReactElemen
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);
@@ -74,6 +85,18 @@ export function ConflictModal({ onClose, onResolved }: Props): React.ReactElemen
<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'); }}

View File

@@ -2,6 +2,35 @@ import React, { useEffect, useState } from 'react';
import type { Note } from '@shared/types';
import { useInbox } from '../store.js';
import { Banner } from './Banner.js';
import { DAY_MS, kstTodayIso } from '@shared/util/kstDate.js';
/**
* due_date 대비 오늘 (KST) 의 상대 라벨. 오늘 = "오늘", 지난 = "N일 지남".
* findExpiredCandidates 가 미래 due 는 반환하지 않으므로 음수 케이스 미고려.
*/
function dueRelativeLabel(due: string, todayKst: string): string {
if (due === todayKst) return '오늘';
const dueUtc = Date.UTC(
Number(due.slice(0, 4)), Number(due.slice(5, 7)) - 1, Number(due.slice(8, 10))
);
const todayUtc = Date.UTC(
Number(todayKst.slice(0, 4)), Number(todayKst.slice(5, 7)) - 1, Number(todayKst.slice(8, 10))
);
const days = Math.round((todayUtc - dueUtc) / DAY_MS);
return `${days}일 지남`;
}
function headingText(todayCount: number, overdueCount: number): string {
if (todayCount > 0 && overdueCount > 0) return `오늘 마감 ${todayCount} · 지난 ${overdueCount}`;
if (todayCount > 0) return `오늘 마감 ${todayCount}`;
return `지난 ${overdueCount}`;
}
/** RecallBanner 와 동일 패턴 — NoteCard 의 `note-{id}` element 로 smooth scroll. */
function scrollToNote(id: string): void {
const el = document.getElementById(`note-${id}`);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
export function ExpiryBanner(): React.ReactElement | null {
const candidates = useInbox((s) => s.expiredCandidates);
@@ -58,6 +87,10 @@ function ExpiryBannerInner({ candidates, onTrash, onSnooze }: InnerProps): React
const allSelected = candidates.length > 0 && candidates.every((c) => selected.has(c.id));
const someSelected = selected.size > 0 && !allSelected;
const todayKst = kstTodayIso();
const todayCount = candidates.filter((c) => c.dueDate === todayKst).length;
const overdueCount = candidates.length - todayCount;
function toggleAll() {
if (allSelected) setSelected(new Set());
else setSelected(new Set(candidates.map((c) => c.id)));
@@ -75,7 +108,7 @@ function ExpiryBannerInner({ candidates, onTrash, onSnooze }: InnerProps): React
return (
<Banner severity="warning">
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span> <b> {candidates.length}</b></span>
<span> <b>{headingText(todayCount, overdueCount)}</b></span>
<button
onClick={() => setExpanded((e) => !e)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#946100' }}
@@ -107,33 +140,51 @@ function ExpiryBannerInner({ candidates, onTrash, onSnooze }: InnerProps): React
<span style={{ color: '#666' }}> ({selected.size}/{candidates.length})</span>
</label>
<div>
{candidates.map((n) => (
<label
key={n.id}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '4px 0', cursor: 'pointer'
}}
>
<input
type="checkbox"
checked={selected.has(n.id)}
onChange={() => toggle(n.id)}
/>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{n.aiTitle ?? n.rawText.slice(0, 60)}
</span>
<span style={{ color: '#946100', fontSize: 12 }}>due {n.dueDate}</span>
{n.tags[0] && (
<span style={{
background: '#fce8b2', color: '#946100', padding: '0 6px',
borderRadius: 10, fontSize: 11
}}>
#{n.tags[0].name}
{candidates.map((n) => {
const title = n.aiTitle ?? n.rawText.slice(0, 60);
return (
<div
key={n.id}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '4px 0'
}}
>
<input
type="checkbox"
checked={selected.has(n.id)}
onChange={() => toggle(n.id)}
aria-label={`${title} 선택`}
/>
<button
type="button"
onClick={() => scrollToNote(n.id)}
title="해당 메모로 이동"
style={{
flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
background: 'none', border: 'none', padding: 0,
cursor: 'pointer', color: 'inherit', font: 'inherit', textAlign: 'left'
}}
>
{title}
</button>
<span
style={{ color: '#946100', fontSize: 12 }}
title={`due ${n.dueDate}`}
>
{dueRelativeLabel(n.dueDate ?? todayKst, todayKst)}
</span>
)}
</label>
))}
{n.tags[0] && (
<span style={{
background: '#fce8b2', color: '#946100', padding: '0 6px',
borderRadius: 10, fontSize: 11
}}>
#{n.tags[0].name}
</span>
)}
</div>
);
})}
</div>
<button
onClick={() => onTrash(Array.from(selected))}

View File

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

View File

@@ -1,12 +1,12 @@
import React, { useState } from 'react';
import type { Note, NoteStatus } from '@shared/types';
import type { Note } from '@shared/types';
import { KST_OFFSET_MS } from '@shared/util/kstDate.js';
import { inboxApi } from '../api.js';
import { useInbox } from '../store.js';
import { EditableField } from './EditableField.js';
import { IntentBanner } from './IntentBanner.js';
import { pushTagUndo } from './TagUndoToast.js';
import { MoveStatusModal, statusLabelWithParticle } from './MoveStatusModal.js';
import { MoveStatusModal } from './MoveStatusModal.js';
import { RevisionHistoryModal } from './RevisionHistoryModal.js';
interface Props {
@@ -116,17 +116,12 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
const [local, setLocal] = useState(note);
const isAiDisabled = local.aiStatus === 'disabled';
const fallbackTitle = local.rawText.split('\n')[0]?.slice(0, 60) || '(빈 메모)';
// v0.2.9 Cut B Task 6 — 이동 메뉴 dropdown + MoveStatusModal target.
const [moveTarget, setMoveTarget] = useState<NoteStatus | null>(null);
const [menuOpen, setMenuOpen] = useState(false);
// 이동 modal 열림 여부. 클릭 시 MoveStatusModal 에서 사유 + AI 분류 + 수동 분류 선택.
const [moveOpen, setMoveOpen] = useState(false);
const [editingRaw, setEditingRaw] = useState(false);
const [draftRaw, setDraftRaw] = useState('');
const [showRevisions, setShowRevisions] = useState(false);
const possibleTargets: NoteStatus[] = (
['active', 'completed', 'archived', 'trashed'] as NoteStatus[]
).filter((s) => s !== local.status);
React.useEffect(() => { setLocal(note); }, [note]);
const formatted = new Date(note.createdAt).toLocaleString('ko-KR');
@@ -159,7 +154,15 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
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() };
// disabled 노트는 AI 재처리 안 됨 (서버에서 skip) — aiStatus 유지.
// 그 외는 optimistic 으로 pending 표시 → AiWorker 완료 시 push 로 자동 sync.
const willReprocess = local.aiStatus !== 'disabled';
const updated = {
...local,
rawText: next,
updatedAt: new Date().toISOString(),
...(willReprocess ? { aiStatus: 'pending' as const, aiError: null } : {})
};
setLocal(updated);
onUpdated(updated);
setEditingRaw(false);
@@ -220,13 +223,61 @@ 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 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div 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.3.14 — fail 원인 inline 표시. ai_error 의 raw message 가 그대로 사용자에게
보여서 디버깅 + 모델/네트워크 이슈 진단 가능. 너무 길면 <details> 로 접힘. */}
{local.aiError !== null && local.aiError.length > 0 && (
<details style={{ marginTop: 4 }}>
<summary style={{ fontSize: 12, color: '#a55', cursor: 'pointer' }}>
</summary>
<pre style={{
fontSize: 11, color: '#666', background: '#fff0f0', padding: 6,
borderRadius: 4, marginTop: 4, whiteSpace: 'pre-wrap', wordBreak: 'break-word'
}}>
{local.aiError}
</pre>
</details>
)}
</div>
)}
{/* v0.2.9 Cut B Task 13 — ai_status='disabled': raw_text 첫 줄 fallback title.
@@ -424,64 +475,23 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
alignItems: 'center'
}}
>
{/* v0.2.9 Cut B Task 6 — 모든 view 공통 "이동 ▾" dropdown.
현재 status 와 다른 3개 목적지만 표시. */}
<div style={{ position: 'relative' }}>
<button
onClick={() => setMenuOpen((o) => !o)}
aria-label="이동"
style={{
background: 'none',
border: '1px solid #ccc',
color: '#444',
cursor: 'pointer',
fontSize: 12,
padding: '4px 10px',
borderRadius: 4
}}
>
</button>
{menuOpen && (
<div
style={{
position: 'absolute',
right: 0,
top: '100%',
marginTop: 2,
background: '#fff',
border: '1px solid #ccc',
borderRadius: 4,
padding: 4,
zIndex: 10,
minWidth: 140,
boxShadow: '0 2px 6px rgba(0,0,0,0.08)'
}}
>
{possibleTargets.map((t) => (
<button
key={t}
onClick={() => {
setMoveTarget(t);
setMenuOpen(false);
}}
style={{
display: 'block',
width: '100%',
textAlign: 'left',
background: 'none',
border: 'none',
padding: '6px 8px',
fontSize: 12,
cursor: 'pointer'
}}
>
{statusLabelWithParticle(t)}
</button>
))}
</div>
)}
</div>
{/* 이동 버튼 — 클릭 시 MoveStatusModal 진입.
사유 입력 + AI 자동 분류 + 수동 status 선택 한 곳에서 처리. */}
<button
onClick={() => setMoveOpen(true)}
aria-label="이동"
style={{
background: 'none',
border: '1px solid #ccc',
color: '#444',
cursor: 'pointer',
fontSize: 12,
padding: '4px 10px',
borderRadius: 4
}}
>
</button>
{/* trash mode 만 영구 삭제 + 복구 보존 (휴지통 단독 액션). */}
{isTrash && (
@@ -509,19 +519,23 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
</div>
</div>
{moveTarget !== null && (
{moveOpen && (
<MoveStatusModal
noteId={local.id}
rawText={local.rawText}
summary={local.aiSummary ?? ''}
onClose={() => setMoveTarget(null)}
currentStatus={local.status}
onClose={() => setMoveOpen(false)}
onMoved={(newStatus, reason) => {
const updated = { ...local, status: newStatus, moveReason: reason };
setLocal(updated);
onUpdated(updated);
// inbox/trash mode 의 list 가 status 별로 필터되므로 onDeleted (제거) 도 호출.
// inbox/완료/보관/휴지통 view 의 list 가 status 별로 필터되므로 status 변경 시 onDeleted 호출.
if (newStatus !== local.status) onDeleted?.();
setMoveTarget(null);
// setStatus IPC 는 pushNoteUpdated emit 안 함 → 헤더 탭 counts 가 stale.
// refreshMeta 로 server-authoritative counts 재로드.
void useInbox.getState().refreshMeta();
setMoveOpen(false);
}}
/>
)}

View File

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

View File

@@ -14,13 +14,37 @@ const periodLabel: Record<Props['period'], string> = {
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, fontSize: 13, color: '#666' }}> </div>;
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 }}>
<h2 style={{ fontSize: 18, margin: 0 }}>{periodLabel[period]} </h2>
<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>

View File

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

View File

@@ -0,0 +1,132 @@
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}> Inkling . "충돌 해결…" , "내 것 사용" "원격 사용" .</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>
<p style={pStyle}> Inkling . .</p>
<ul style={{ paddingLeft: 18, margin: '4px 0' }}>
<li style={liStyle}><b> </b>: . .</li>
<li style={liStyle}><b> </b>: . .</li>
<li style={liStyle}><b> </b>: , + + . .</li>
<li style={liStyle}><b> </b>: 30 ( ). .</li>
</ul>
</section>
<section id="silent" style={sectionStyle}>
<h4 style={h4Style}>3. </h4>
<p style={pStyle}> .</p>
<ul style={{ paddingLeft: 18, margin: '4px 0' }}>
<li style={liStyle}><b> </b>: . macOS / Windows , .</li>
<li style={liStyle}><b> </b>: . .</li>
<li style={liStyle}><b> </b>: . , 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

@@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
import { z } from 'zod';
import { inboxApi } from '../../api.js';
import { VisionSection } from './VisionSection.js';
import { SectionIntro } from './SectionIntro.js';
const endpointSchema = z.string().url();
@@ -78,6 +79,10 @@ export function AiProviderSection(): React.ReactElement {
return (
<div>
<SectionIntro>
AI . Inkling Ollama
. .
</SectionIntro>
{/* v0.2.9 Cut B Task 15 — AI 자동 처리 토글 (가장 위, 스위치 의미가 가장 큰 결정) */}
{aiEnabled !== null && (
<label style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 12, fontSize: 13 }}>

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react';
import type { AutostartResponse } from '@shared/types';
import { inboxApi } from '../../api.js';
import { SectionIntro } from './SectionIntro.js';
export function AutostartSection(): React.ReactElement {
const [data, setData] = useState<AutostartResponse | null>(null);
@@ -31,14 +32,19 @@ export function AutostartSection(): React.ReactElement {
}
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);
// withArgs vs noArgs 의 openAtLogin 불일치는 양 플랫폼에서 진짜 mismatch 시그널.
// executableWillLaunchAtLogin 은 Win 에서만 신뢰 — macOS 13+ SMAppService API 는
// LoginItems 에 등록되어 있어도 unsigned/Electron 앱에 대해 false 를 자주 반환해
// false positive 가 발생함. macOS 는 이 신호를 mismatch 판정에서 제외.
const willLaunchSignal = d.platform === 'win32' && data.openAtLogin && !d.withArgs.executableWillLaunchAtLogin;
const mismatch = d.withArgs.openAtLogin !== d.noArgs.openAtLogin || willLaunchSignal;
return (
<div>
<SectionIntro>
Inkling . ,
Cmd+Shift+J (macOS) / Ctrl+Shift+J (Windows) .
</SectionIntro>
<label style={{ display: 'flex', gap: 8, alignItems: 'center', fontSize: 13 }}>
<input type="checkbox" checked={data.openAtLogin} onChange={onToggle} />

View File

@@ -1,5 +1,6 @@
import React, { useState } from 'react';
import { inboxApi } from '../../api.js';
import { SectionIntro } from './SectionIntro.js';
export function BackupSection(): React.ReactElement {
const [status, setStatus] = useState<string | null>(null);
@@ -14,6 +15,10 @@ export function BackupSection(): React.ReactElement {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<SectionIntro>
.
1 , .
</SectionIntro>
<button onClick={() => run('지금 백업', () => inboxApi.runBackup())}> </button>
<button onClick={() => run('내보내기', () => inboxApi.runExport())}>...</button>
<button onClick={() => run('백업에서 복원', () => inboxApi.runImport())}> ...</button>

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react';
import { inboxApi } from '../../api.js';
import { SectionIntro } from './SectionIntro.js';
interface AppInfo {
version: string;
@@ -20,6 +21,9 @@ export function InfoSection(): React.ReactElement {
return (
<div>
<SectionIntro>
.
</SectionIntro>
<dl style={{ fontSize: 12, lineHeight: 1.6 }}>
<dt style={{ fontWeight: 600 }}></dt>
<dd>{info.version}</dd>

View File

@@ -0,0 +1,12 @@
import React from 'react';
interface Props { children: React.ReactNode; }
/** Settings page 각 section 상단에 표시되는 간단한 설명 paragraph. */
export function SectionIntro({ children }: Props): React.ReactElement {
return (
<p style={{ fontSize: 12, color: '#666', lineHeight: 1.6, margin: '0 0 12px 0' }}>
{children}
</p>
);
}

View File

@@ -2,6 +2,8 @@ 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';
import { SectionIntro } from './SectionIntro.js';
export function SyncSection(): React.ReactElement {
const [url, setUrl] = useState('');
@@ -12,6 +14,7 @@ export function SyncSection(): React.ReactElement {
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 () => {
@@ -62,6 +65,10 @@ export function SyncSection(): React.ReactElement {
return (
<section style={{ marginTop: 24 }}>
<h3 style={{ fontSize: 14, marginBottom: 8 }}> </h3>
<SectionIntro>
Git . URL
. .
</SectionIntro>
<div style={{ display: 'flex', gap: 6, marginBottom: 8 }}>
<input
@@ -78,6 +85,9 @@ export function SyncSection(): React.ReactElement {
<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 && (
@@ -129,10 +139,18 @@ export function SyncSection(): React.ReactElement {
onResolved={async () => {
setStatus(await inboxApi.getSyncStatus());
}}
onOpenHelp={(anchor) => setShowHelp({ open: true, anchor })}
/>
)}
</>
)}
{showHelp.open && (
<SyncHelpModal
onClose={() => setShowHelp({ open: false })}
initialAnchor={showHelp.anchor}
/>
)}
</section>
);
}

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react';
import { inboxApi } from '../../api.js';
import { SectionIntro } from './SectionIntro.js';
export function VisionSection(): React.ReactElement {
const [models, setModels] = useState<string[]>([]);
@@ -44,6 +45,10 @@ export function VisionSection(): React.ReactElement {
return (
<section style={{ marginTop: 16 }}>
<h4 style={{ fontSize: 13, marginBottom: 6 }}> ()</h4>
<SectionIntro>
vision . ,
.
</SectionIntro>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginBottom: 6 }}>
<select
aria-label="이미지 분석 모델"

View File

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

View File

@@ -52,7 +52,7 @@ interface InboxState {
setTagFilter: (tag: string | null) => void;
setShowSettings: (open: boolean) => void;
setView: (view: InboxView) => void;
loadByView: (view: 'completed' | 'archived' | 'trash') => Promise<void>;
loadByView: (view: 'inbox' | 'completed' | 'archived' | 'trash') => Promise<void>;
toggleShowTrash: () => Promise<void>;
loadTrash: () => Promise<void>;
restoreNote: (id: string) => Promise<void>;
@@ -102,66 +102,117 @@ export const useInbox = create<InboxState>((set, get) => ({
searchResults: null,
reviewData: null,
async loadInitial() {
// v0.3.8 — IPC 실패 시 loading=true 영구 stuck 방지. catch 로 reset.
set({ loading: true });
const [notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, settings] = await Promise.all([
inboxApi.listNotes({ limit: 50 }),
inboxApi.getContinuity(),
inboxApi.getPendingCount(),
inboxApi.getOllamaStatus(),
inboxApi.getTodayCount(),
inboxApi.getTrashCount(),
inboxApi.listExpired(),
inboxApi.getFailedCount(),
inboxApi.listRecallCandidate(),
inboxApi.countsByStatus(),
inboxApi.getSettings()
]);
set({ notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, ai_enabled: settings.ai_enabled ?? true, loading: false });
try {
const [notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, settings] = await Promise.all([
// inbox 탭은 status='active' 만 표시 — loadByView('inbox') 와 동일 path 로 일관성 확보.
// listNotes 는 deleted_at IS NULL 만 필터 (= active+completed+archived 혼재) 이라 부정확.
inboxApi.listByStatus('active', { limit: 50 }),
inboxApi.getContinuity(),
inboxApi.getPendingCount(),
inboxApi.getOllamaStatus(),
inboxApi.getTodayCount(),
inboxApi.getTrashCount(),
inboxApi.listExpired(),
inboxApi.getFailedCount(),
inboxApi.listRecallCandidate(),
inboxApi.countsByStatus(),
inboxApi.getSettings()
]);
set({ notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, ai_enabled: settings.ai_enabled ?? true, loading: false });
} catch (e) {
// 첫 launch 의 IPC 실패 (DB migration 실패 / main process 비정상) 시 무한 loading 회피.
// 빈 데이터로 진입하면 사용자가 캡처 시도 → 실제 fail 이 표면화 → 재시도 가능.
console.error('[inbox] loadInitial failed', e);
set({ loading: false });
}
},
async refreshMeta() {
const [continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, settings] = await Promise.all([
inboxApi.getContinuity(),
inboxApi.getPendingCount(),
inboxApi.getOllamaStatus(),
inboxApi.getTodayCount(),
inboxApi.getTrashCount(),
inboxApi.listExpired(),
inboxApi.getFailedCount(),
inboxApi.listRecallCandidate(),
inboxApi.countsByStatus(),
inboxApi.getSettings()
]);
set({ continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, ai_enabled: settings.ai_enabled ?? true });
try {
const [continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, settings] = await Promise.all([
inboxApi.getContinuity(),
inboxApi.getPendingCount(),
inboxApi.getOllamaStatus(),
inboxApi.getTodayCount(),
inboxApi.getTrashCount(),
inboxApi.listExpired(),
inboxApi.getFailedCount(),
inboxApi.listRecallCandidate(),
inboxApi.countsByStatus(),
inboxApi.getSettings()
]);
set({ continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, ai_enabled: settings.ai_enabled ?? true });
} catch (e) {
// refreshMeta 는 background poll/event 에서 자주 호출 → fail 무시 (다음 호출에 회복).
console.error('[inbox] refreshMeta failed', e);
}
},
upsertNote(note) {
// trashCount 는 server-authoritative. trashNotes 가 cache-loaded (showTrash=true) 일
// 때만 trashCount 를 local recompute. 그 외엔 server 값 (refreshMeta) 보존.
const showTrash = get().showTrash;
if (note.deletedAt !== null) {
// trash 노트: notes 에서 제거 + trashNotes 에 upsert
const cleanNotes = get().notes.filter((n) => n.id !== note.id);
const ti = get().trashNotes.findIndex((n) => n.id === note.id);
const nextTrash = get().trashNotes.slice();
if (ti >= 0) nextTrash[ti] = note;
// v0.3.8 — status 가 current view 와 매칭될 때만 notes 에 유지. 그 외엔 제거.
// 이전 구현은 trashed 외 모든 status 를 notes 에 누적 → 사용자가 inbox view 에서
// 완료/보관 으로 옮긴 노트가 list 에 잔류하는 버그. push-based (setStatus 도 emit) 로
// 모든 status 전이가 upsertNote 를 거치므로 view-aware filter 가 필수.
//
// trashCount/trashNotes 는 server-authoritative. trashNotes 가 cache-loaded
// (view='trash') 일 때만 trashCount 를 local recompute. 그 외엔 server 값
// (refreshMeta) 보존. searchResults 도 별도로 갱신 (status 변경 시 list 에서 제거).
const state = get();
const view = state.view;
const showTrash = state.showTrash;
const viewStatus: 'active' | 'completed' | 'archived' | 'trashed' | null =
view === 'inbox' ? 'active' :
view === 'completed' ? 'completed' :
view === 'archived' ? 'archived' :
view === 'trash' ? 'trashed' : null;
// trashNotes — note.status='trashed' 면 upsert, 아니면 제거.
const cleanTrash = state.trashNotes.filter((n) => n.id !== note.id);
let nextTrash = cleanTrash;
if (note.status === 'trashed') {
const ti = state.trashNotes.findIndex((n) => n.id === note.id);
nextTrash = cleanTrash.slice();
if (ti >= 0) nextTrash.splice(ti, 0, note);
else nextTrash.unshift(note);
set({
notes: cleanNotes,
trashNotes: nextTrash,
...(showTrash ? { trashCount: nextTrash.length } : {})
});
} else {
// active 노트: trashNotes 에서 제거 + notes 에 upsert (restore 케이스 포함)
const cleanTrash = get().trashNotes.filter((n) => n.id !== note.id);
const i = get().notes.findIndex((n) => n.id === note.id);
const nextNotes = get().notes.slice();
if (i >= 0) nextNotes[i] = note;
else nextNotes.unshift(note);
set({
notes: nextNotes,
trashNotes: cleanTrash,
...(showTrash ? { trashCount: cleanTrash.length } : {})
});
}
// notes — current view 의 status 와 매칭되는 경우만 유지/upsert.
// viewStatus=null (review/settings/검색) 이면 notes 직접 렌더 안 함 → 갱신 skip.
const cleanNotes = state.notes.filter((n) => n.id !== note.id);
let nextNotes = state.notes;
if (viewStatus !== null) {
if (note.status === viewStatus) {
const i = state.notes.findIndex((n) => n.id === note.id);
nextNotes = cleanNotes.slice();
if (i >= 0) nextNotes.splice(i, 0, note);
else nextNotes.unshift(note);
} else {
nextNotes = cleanNotes;
}
}
// searchResults — null 아니면 동일 패턴으로 갱신 (status 가 current search status 와
// 안 맞으면 제거, 맞으면 upsert).
let nextSearch = state.searchResults;
if (state.searchResults !== null) {
const cleanSearch = state.searchResults.filter((n) => n.id !== note.id);
if (viewStatus === null || note.status === viewStatus) {
// search 가 active 한 view 가 review/settings 면 status filter 없음 → 모두 keep.
const i = state.searchResults.findIndex((n) => n.id === note.id);
nextSearch = cleanSearch.slice();
if (i >= 0) nextSearch.splice(i, 0, note);
else nextSearch.unshift(note);
} else {
nextSearch = cleanSearch;
}
}
set({
notes: nextNotes,
trashNotes: nextTrash,
searchResults: nextSearch,
...(showTrash ? { trashCount: nextTrash.length } : {})
});
},
removeNote(id) {
const cleanNotes = get().notes.filter((n) => n.id !== id);
@@ -182,13 +233,18 @@ export const useInbox = create<InboxState>((set, get) => ({
else get().setView('inbox');
},
setView(view) {
// view 전환 시 검색/태그 필터 reset — 이전 view 의 필터가 새 view 에 잘못 적용되는 것 방지.
set({
view,
showTrash: view === 'trash',
showSettings: view === 'settings'
showSettings: view === 'settings',
searchResults: null,
searchQuery: '',
tagFilter: null
});
// settings/inbox 외 status view 면 해당 status fetch.
if (view === 'completed' || view === 'archived' || view === 'trash') {
// status view 면 해당 status fetch. inbox 도 포함 — 다른 탭에서 돌아올 때 notes 가
// 이전 status 로 stale 한 상태이므로 재로드 필요.
if (view === 'inbox' || view === 'completed' || view === 'archived' || view === 'trash') {
void get().loadByView(view);
}
// v0.2.11 Cut D — review-* view 진입 시 aggregate 로드.
@@ -197,12 +253,21 @@ export const useInbox = create<InboxState>((set, get) => ({
if (view === 'review-monthly') void get().loadReview('monthly');
},
async loadByView(view) {
const status = view === 'trash' ? 'trashed' : view;
const notes = await inboxApi.listByStatus(status, { limit: 200 });
if (view === 'trash') {
set({ trashNotes: notes, trashCount: notes.length });
} else {
set({ notes });
// v0.3.8 — IPC 실패 시 stale 한 이전 view 의 notes 가 계속 노출되는 사고 방지.
// fail 시 빈 배열로 reset 해서 사용자에게 "비어있음" 으로 표시 (혼동 < stale).
const status =
view === 'trash' ? 'trashed' : view === 'inbox' ? 'active' : view;
try {
const notes = await inboxApi.listByStatus(status, { limit: 200 });
if (view === 'trash') {
set({ trashNotes: notes, trashCount: notes.length });
} else {
set({ notes });
}
} catch (e) {
console.error('[inbox] loadByView failed', view, e);
if (view === 'trash') set({ trashNotes: [] });
else set({ notes: [] });
}
},
async toggleShowTrash() {
@@ -217,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) {
@@ -298,14 +364,27 @@ export const useInbox = create<InboxState>((set, get) => ({
const status = view === 'completed' || view === 'archived' || view === 'trash'
? (view === 'trash' ? 'trashed' : view)
: view === 'inbox' ? 'active' : undefined;
const r = await inboxApi.search(q, status ? { status } : {});
set({ searchResults: r });
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) {
const data = await inboxApi.reviewAggregate(period);
set({ reviewData: data });
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

@@ -3,6 +3,9 @@ import { captureApi } from './api.js';
interface PastedImage { url: string; buffer: ArrayBuffer; }
// 저장 단축키 modifier — macOS 는 Cmd, 그 외는 Ctrl.
const MOD_KEY = /Mac/i.test(navigator.platform) ? 'Cmd' : 'Ctrl';
export function App(): React.ReactElement {
const [text, setText] = useState('');
const [images, setImages] = useState<PastedImage[]>([]);
@@ -65,7 +68,7 @@ export function App(): React.ReactElement {
{images.map((i, idx) => (<img key={idx} src={i.url} alt="" />))}
</div>
)}
<div className="hint">Ctrl+Enter · Esc · </div>
<div className="hint">{MOD_KEY}+Enter · Esc · </div>
{err && <div className="err">{err}</div>}
</div>
);

View File

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

View File

@@ -113,6 +113,8 @@ export interface AutostartDiagnostic {
withArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean };
noArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean };
execPath: string;
/** mismatch 판정 플랫폼 분기용 (macOS 의 SMAppService API 한계 우회). */
platform: NodeJS.Platform;
registryPath?: string;
registryValue?: string | null;
}
@@ -149,6 +151,9 @@ 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 }>;

View File

@@ -98,6 +98,53 @@ describe('AiWorker — vision path (v0.3.1 Cut F)', () => {
expect(calls[0]![0].images).toBeUndefined();
});
it('v0.3.14 — 본문 빈 + 이미지만 첨부 → generate 호출 skip + 자동 placeholder', async () => {
const { id } = repo.create({ rawText: '' }); // 빈 본문
await mkdir(join(workDir, 'media', id), { recursive: true });
await writeFile(join(workDir, 'media', id, '1.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47]));
repo.insertMedia([{ noteId: id, kind: 'image', relPath: `media/${id}/1.png`, mime: 'image/png', bytes: 4 }]);
const calls: Array<Parameters<InferenceProvider['generate']>> = [];
const generate = vi.fn(async (
input: Parameters<InferenceProvider['generate']>[0],
opts?: Parameters<InferenceProvider['generate']>[1]
): Promise<AiResponse> => {
calls.push([input, opts]);
return { title: 't', summary: 'a\nb\nc', tags: [], dueDate: null };
});
const getVisionModel = vi.fn(async (): Promise<string | null> => 'gemma4:26b');
const worker = makeWorker(generate, getVisionModel);
await worker.enqueue(id);
await worker.drain();
// vision 호출 자체 skip
expect(calls.length).toBe(0);
// 노트가 자동 placeholder 로 done
const note = repo.findById(id);
expect(note?.aiStatus).toBe('done');
expect(note?.aiTitle).toContain('첨부 이미지');
expect(note?.aiSummary).toContain('이미지');
expect(note?.aiProvider).toBe('image-only-skip');
});
it('v0.3.14 — 이미지 다수 첨부 시 placeholder 가 개수 포함', async () => {
const { id } = repo.create({ rawText: '' });
await mkdir(join(workDir, 'media', id), { recursive: true });
await writeFile(join(workDir, 'media', id, '1.png'), Buffer.from([0x89]));
await writeFile(join(workDir, 'media', id, '2.png'), Buffer.from([0x89]));
repo.insertMedia([
{ noteId: id, kind: 'image', relPath: `media/${id}/1.png`, mime: 'image/png', bytes: 1 },
{ noteId: id, kind: 'image', relPath: `media/${id}/2.png`, mime: 'image/png', bytes: 1 }
]);
const generate = vi.fn(async (): Promise<AiResponse> => ({ title: 't', summary: 'a\nb\nc', tags: [], dueDate: null }));
const getVisionModel = vi.fn(async (): Promise<string | null> => 'gemma4:26b');
const worker = makeWorker(generate, getVisionModel);
await worker.enqueue(id);
await worker.drain();
const note = repo.findById(id);
expect(note?.aiTitle).toContain('2장');
});
it('5MB 초과 이미지 → throw → AiWorker 의 fail 분기 (generate 미호출)', async () => {
const { id } = repo.create({ rawText: 'big image' });
await mkdir(join(workDir, 'media', id), { recursive: true });

View File

@@ -42,6 +42,7 @@ describe('AutostartDiagnostic — collectAutostartState', () => {
expect(state.withArgs).toEqual({ openAtLogin: true, executableWillLaunchAtLogin: true });
expect(state.noArgs).toEqual({ openAtLogin: false, executableWillLaunchAtLogin: true });
expect(state.execPath).toBe(process.execPath);
expect(state.platform).toBe('darwin');
});
it('passes args=["--hidden"] for the first call, no args for the second', async () => {

View File

@@ -3,15 +3,17 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
function makeDiag(open: boolean): {
function makeDiag(open: boolean, platform: NodeJS.Platform = 'win32'): {
withArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean };
noArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean };
execPath: string;
platform: NodeJS.Platform;
} {
return {
withArgs: { openAtLogin: open, executableWillLaunchAtLogin: open },
noArgs: { openAtLogin: open, executableWillLaunchAtLogin: open },
execPath: '/path/to/exe'
execPath: '/path/to/exe',
platform
};
}
@@ -51,7 +53,8 @@ describe('AutostartSection', () => {
diagnostic: {
withArgs: { openAtLogin: true, executableWillLaunchAtLogin: true },
noArgs: { openAtLogin: false, executableWillLaunchAtLogin: true },
execPath: '/path/to/Inkling.exe'
execPath: '/path/to/Inkling.exe',
platform: 'win32'
}
});
render(<AutostartSection />);
@@ -71,6 +74,7 @@ describe('AutostartSection', () => {
withArgs: { openAtLogin: true, executableWillLaunchAtLogin: true },
noArgs: { openAtLogin: true, executableWillLaunchAtLogin: true },
execPath: 'C:\\app.exe',
platform: 'win32',
registryPath: 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run\\Inkling',
registryValue: '"C:\\app.exe" --hidden'
}
@@ -89,7 +93,8 @@ describe('AutostartSection', () => {
diagnostic: {
withArgs: { openAtLogin: true, executableWillLaunchAtLogin: true },
noArgs: { openAtLogin: true, executableWillLaunchAtLogin: true },
execPath: '/p'
execPath: '/p',
platform: 'win32'
}
});
render(<AutostartSection />);
@@ -97,6 +102,38 @@ describe('AutostartSection', () => {
expect(screen.queryByText(/⚠️/)).not.toBeInTheDocument();
});
it('macOS: no false-positive mismatch when willLaunch=false (SMAppService 한계)', async () => {
const { inboxApi } = await import('../../src/renderer/inbox/api.js');
vi.mocked(inboxApi.getAutostart).mockResolvedValueOnce({
openAtLogin: true,
diagnostic: {
withArgs: { openAtLogin: true, executableWillLaunchAtLogin: false },
noArgs: { openAtLogin: true, executableWillLaunchAtLogin: false },
execPath: '/Applications/Inkling.app',
platform: 'darwin'
}
});
render(<AutostartSection />);
await screen.findByRole('checkbox');
expect(screen.queryByText(/⚠️/)).not.toBeInTheDocument();
});
it('Win: mismatch warning when openAtLogin=true but willLaunch=false', async () => {
const { inboxApi } = await import('../../src/renderer/inbox/api.js');
vi.mocked(inboxApi.getAutostart).mockResolvedValueOnce({
openAtLogin: true,
diagnostic: {
withArgs: { openAtLogin: true, executableWillLaunchAtLogin: false },
noArgs: { openAtLogin: true, executableWillLaunchAtLogin: false },
execPath: 'C:\\app.exe',
platform: 'win32'
}
});
render(<AutostartSection />);
await screen.findByRole('checkbox');
expect(await screen.findByText(/⚠️/)).toBeInTheDocument();
});
it('"재등록" button calls setAutostart with current openAtLogin value', async () => {
const { inboxApi } = await import('../../src/renderer/inbox/api.js');
vi.mocked(inboxApi.getAutostart).mockResolvedValueOnce({
@@ -104,7 +141,8 @@ describe('AutostartSection', () => {
diagnostic: {
withArgs: { openAtLogin: true, executableWillLaunchAtLogin: true },
noArgs: { openAtLogin: true, executableWillLaunchAtLogin: true },
execPath: '/p'
execPath: '/p',
platform: 'win32'
}
});
render(<AutostartSection />);

View File

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

View File

@@ -45,13 +45,47 @@ describe('LocalOllamaProvider', () => {
expect(parsed.prompt).toContain('Prefer reusing');
});
it('generate throws on non-JSON', async () => {
it('v0.3.14 — generate falls back to placeholder when JSON unparseable', async () => {
// 이전엔 throw 했지만 schema graceful coerce 추가 후 placeholder 채워서 통과.
// truncated / repetition-loop 응답에서 사용자 데이터 (raw_text) 무손실 보존.
mock.get('http://localhost:11434').intercept({ path: '/api/generate', method: 'POST' }).reply(200, {
response: 'not json'
});
await expect(
new LocalOllamaProvider().generate({ text: 'x', todayKst: '2026-04-26', dueDateCandidates: [] })
).rejects.toThrow(/json/i);
const r = await new LocalOllamaProvider().generate({ text: 'x', todayKst: '2026-04-26', dueDateCandidates: [] });
expect(r.title).toBe('(첨부 메모)');
expect(r.summary.length).toBeGreaterThan(0);
});
it('v0.3.14 — body 에 repeat_penalty 포함 (repetition loop 방지)', async () => {
let capturedBody: string = '';
mock.get('http://localhost:11434').intercept({
path: '/api/generate', method: 'POST'
}).reply((opts) => {
capturedBody = opts.body as string;
return { statusCode: 200, data: JSON.stringify({
response: JSON.stringify({ title: '회의', summary: 'a\nb\nc', tags: [] })
}) };
});
await new LocalOllamaProvider().generate({ text: 'x', todayKst: '2026-04-26', dueDateCandidates: [] });
const parsed = JSON.parse(capturedBody) as { options: { repeat_penalty: number } };
expect(parsed.options.repeat_penalty).toBe(1.15);
});
it('v0.3.11 — generate extracts JSON from markdown fence', async () => {
// vision model 이 ```json ... ``` 형태로 응답하는 경우 fallback 으로 추출.
mock.get('http://localhost:11434').intercept({ path: '/api/generate', method: 'POST' }).reply(200, {
response: '```json\n{"title":"회의","summary":"a\\nb\\nc","tags":["meet"]}\n```'
});
const r = await new LocalOllamaProvider().generate({ text: 'x', todayKst: '2026-04-26', dueDateCandidates: [] });
expect(r.title).toBe('회의');
});
it('v0.3.11 — generate extracts JSON when prose 가 앞뒤로 섞임', async () => {
mock.get('http://localhost:11434').intercept({ path: '/api/generate', method: 'POST' }).reply(200, {
response: 'Here is the response:\n{"title":"회의","summary":"a\\nb\\nc","tags":[]}\nDone.'
});
const r = await new LocalOllamaProvider().generate({ text: 'x', todayKst: '2026-04-26', dueDateCandidates: [] });
expect(r.title).toBe('회의');
});
it('generate aborts on timeout', async () => {

View File

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

View File

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

View File

@@ -592,6 +592,7 @@ describe('NoteRepository.findExpiredCandidates', () => {
edited?: boolean;
deletedAt?: string | null;
aiStatus?: 'pending' | 'done' | 'failed';
status?: 'active' | 'completed' | 'archived' | 'trashed';
}): string {
const { id } = repo.create({ rawText: opts.rawText });
db.prepare(
@@ -599,19 +600,21 @@ describe('NoteRepository.findExpiredCandidates', () => {
SET due_date = ?,
due_date_edited_by_user = ?,
ai_status = ?,
deleted_at = ?
deleted_at = ?,
status = ?
WHERE id = ?`
).run(
opts.dueDate,
opts.edited ? 1 : 0,
opts.aiStatus ?? 'done',
opts.deletedAt ?? null,
opts.status ?? 'active',
id
);
return id;
}
it('returns notes with due_date < today (KST), ORDER BY created_at DESC', () => {
it('returns notes with due_date <= today (KST), ORDER BY due_date DESC then created_at DESC', () => {
const a = makeDone({ rawText: 'a', dueDate: '2026-04-20' });
db.prepare(`UPDATE notes SET created_at = ? WHERE id = ?`).run('2026-04-30T10:00:00Z', a);
const b = makeDone({ rawText: 'b', dueDate: '2026-04-25' });
@@ -620,6 +623,14 @@ describe('NoteRepository.findExpiredCandidates', () => {
expect(r.map((n) => n.id)).toEqual([b, a]);
});
it('includes notes with due_date == today (오늘 당일 우선 표시)', () => {
const past = makeDone({ rawText: 'a', dueDate: '2026-04-30' });
const todayNote = makeDone({ rawText: 'b', dueDate: '2026-05-01' });
const r = repo.findExpiredCandidates(new Date('2026-05-01T12:00:00Z'));
// 오늘 당일이 먼저, 그 다음 지난 메모.
expect(r.map((n) => n.id)).toEqual([todayNote, past]);
});
it('includes both AI-extracted and user-edited due_date (Q1=B 회귀 가드)', () => {
const ai = makeDone({ rawText: 'a', dueDate: '2026-04-20', edited: false });
const manual = makeDone({ rawText: 'b', dueDate: '2026-04-22', edited: true });
@@ -629,7 +640,7 @@ describe('NoteRepository.findExpiredCandidates', () => {
it('excludes trashed notes (deleted_at IS NOT NULL)', () => {
const a = makeDone({ rawText: 'a', dueDate: '2026-04-20' });
makeDone({ rawText: 'b', dueDate: '2026-04-21', deletedAt: '2026-04-30T00:00:00Z' });
makeDone({ rawText: 'b', dueDate: '2026-04-21', deletedAt: '2026-04-30T00:00:00Z', status: 'trashed' });
const r = repo.findExpiredCandidates(new Date('2026-05-01T12:00:00Z'));
expect(r.map((n) => n.id)).toEqual([a]);
});
@@ -649,11 +660,12 @@ describe('NoteRepository.findExpiredCandidates', () => {
expect(r.map((n) => n.id)).toEqual([dated]);
});
it('excludes notes with due_date == today (boundary, not expired)', () => {
const past = makeDone({ rawText: 'a', dueDate: '2026-04-30' });
makeDone({ rawText: 'b', dueDate: '2026-05-01' });
it('excludes completed / archived notes (inbox 만 — 사용자 의도: 완료/보관은 알림 제외)', () => {
const active = makeDone({ rawText: 'a', dueDate: '2026-04-20' });
makeDone({ rawText: 'b', dueDate: '2026-04-20', status: 'completed' });
makeDone({ rawText: 'c', dueDate: '2026-04-20', status: 'archived' });
const r = repo.findExpiredCandidates(new Date('2026-05-01T12:00:00Z'));
expect(r.map((n) => n.id)).toEqual([past]);
expect(r.map((n) => n.id)).toEqual([active]);
});
});
@@ -770,6 +782,41 @@ describe('NoteRepository — failed retry helpers', () => {
expect(jobs[0]!.nextRunAt).toBe('2026-04-30T00:00:00.000Z');
});
it('v0.3.9 — retryOneFailed: failed → pending + pending_jobs INSERT', () => {
const a = makeFailed('a');
const b = makeFailed('b');
const r = repo.retryOneFailed(a, '2026-05-01T12:00:00.000Z');
expect(r).toEqual({ ok: true });
expect(repo.findById(a)!.aiStatus).toBe('pending');
expect(repo.findById(a)!.aiError).toBeNull();
expect(repo.findById(b)!.aiStatus).toBe('failed'); // 다른 노트 영향 없음
const jobs = repo.getAllPendingJobs();
expect(jobs.find((j) => j.noteId === a)).toBeDefined();
});
it('v0.3.9 — retryOneFailed: non-failed status 면 no-op', () => {
const { id } = repo.create({ rawText: 'pending note' });
const r = repo.retryOneFailed(id, '2026-05-01T12:00:00.000Z');
expect(r).toEqual({ ok: false });
});
it('v0.3.9 — cancelPending: pending → disabled + pending_jobs DELETE', () => {
const { id } = repo.create({ rawText: 'x' }); // ai_status=pending
expect(repo.findById(id)!.aiStatus).toBe('pending');
const r = repo.cancelPending(id, '2026-05-01T12:00:00.000Z');
expect(r).toEqual({ ok: true });
expect(repo.findById(id)!.aiStatus).toBe('disabled');
const jobs = repo.getAllPendingJobs().filter((j) => j.noteId === id);
expect(jobs).toHaveLength(0);
});
it('v0.3.9 — cancelPending: non-pending status 면 no-op', () => {
const id = makeFailed('a');
const r = repo.cancelPending(id, '2026-05-01T12:00:00.000Z');
expect(r).toEqual({ ok: false });
expect(repo.findById(id)!.aiStatus).toBe('failed'); // 변경 없음
});
it('setNextRunAt — attempts 변경 없이 next_run_at + last_error 갱신', () => {
const { id } = repo.create({ rawText: 'x' });
repo.incrementJobAttempt(id, '2026-05-01T11:00:00.000Z', 'first error');

View File

@@ -8,6 +8,8 @@ import { render, screen, fireEvent, cleanup } from '@testing-library/react';
// 빈 객체 대신 필요한 메서드를 stub 한다.
vi.mock('../../src/renderer/inbox/api.js', () => ({
inboxApi: {
// setShowSettings(false) → setView('inbox') → loadByView('inbox') 가 listByStatus 호출.
listByStatus: vi.fn(async () => []),
loadOllamaSettings: vi.fn(async () => null),
saveOllamaSettings: vi.fn(async () => ({ ok: true })),
ollamaRecheck: vi.fn(async () => ({ ok: true })),

View File

@@ -0,0 +1,65 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
import React from 'react';
import { SyncHelpModal } from '../../src/renderer/inbox/components/SyncHelpModal';
describe('SyncHelpModal', () => {
beforeEach(() => {
cleanup();
});
it('4 섹션 헤더 렌더링', () => {
render(<SyncHelpModal onClose={() => {}} />);
expect(screen.getByRole('heading', { name: /충돌 해결/ })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: /자동으로 처리되는 일/ })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: /모르고 넘어가기 쉬운 함정/ })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: /Setup/ })).toBeInTheDocument();
});
it('각 섹션이 anchor id 보유', () => {
const { container } = render(<SyncHelpModal onClose={() => {}} />);
expect(container.querySelector('#main-conflict')).not.toBeNull();
expect(container.querySelector('#auto')).not.toBeNull();
expect(container.querySelector('#silent')).not.toBeNull();
expect(container.querySelector('#setup')).not.toBeNull();
});
it('초기 anchor prop 으로 해당 섹션 scrollIntoView 호출', () => {
const scrollSpy = vi.fn();
Element.prototype.scrollIntoView = scrollSpy;
render(<SyncHelpModal onClose={() => {}} initialAnchor="main-conflict" />);
expect(scrollSpy).toHaveBeenCalled();
});
it('X 버튼 클릭 → onClose 호출', () => {
const onClose = vi.fn();
render(<SyncHelpModal onClose={onClose} />);
fireEvent.click(screen.getByRole('button', { name: /닫기/ }));
expect(onClose).toHaveBeenCalled();
});
it('overlay 클릭 → onClose 호출', () => {
const onClose = vi.fn();
const { container } = render(<SyncHelpModal onClose={onClose} />);
const overlay = container.firstChild as HTMLElement;
fireEvent.click(overlay);
expect(onClose).toHaveBeenCalled();
});
it('modal body 클릭 → onClose 호출 X (stopPropagation)', () => {
const onClose = vi.fn();
render(<SyncHelpModal onClose={onClose} />);
fireEvent.click(screen.getByRole('heading', { name: /충돌 해결/ }));
expect(onClose).not.toHaveBeenCalled();
});
it('주요 시나리오 키워드 본문 포함 (회귀)', () => {
render(<SyncHelpModal onClose={() => {}} />);
expect(screen.getByText(/편집\/편집/)).toBeInTheDocument();
expect(screen.getByText(/삭제\/편집/)).toBeInTheDocument();
expect(screen.getByText(/AI 결과 충돌/)).toBeInTheDocument();
expect(screen.getByText(/git@https:\/\//)).toBeInTheDocument();
});
});

View File

@@ -72,4 +72,12 @@ describe('SyncSection', () => {
expect(mockSetAuto).toHaveBeenCalledWith(false);
});
});
it('도움말 버튼 클릭 → SyncHelpModal open', async () => {
render(<SyncSection />);
await waitFor(() => screen.getByRole('button', { name: /저장/ }));
fireEvent.click(screen.getByRole('button', { name: /^도움말$/ }));
await waitFor(() => screen.getByRole('heading', { name: /동기화 도움말/ }));
expect(screen.getByRole('heading', { name: /동기화 도움말/ })).toBeInTheDocument();
});
});

View File

@@ -13,10 +13,21 @@ describe('parseAiResponse', () => {
expect(r.tags).toEqual(['api-timeout', 'meeting']);
});
it('rejects title without Korean', () => {
expect(() =>
parseAiResponse({ title: 'English only', summary: 'a\nb\nc', tags: [] })
).toThrow(/korean/i);
it('영어 title → (첨부 메모) placeholder fallback (vision graceful 처리)', () => {
const r = parseAiResponse({ title: 'English only', summary: 'a\nb\nc', tags: [] });
expect(r.title).toBe('(첨부 메모)');
});
it('null title/summary → placeholder coerce (vision 본문 빈 케이스)', () => {
const r = parseAiResponse({ title: null, summary: null, tags: [], due_date: null });
expect(r.title).toBe('(첨부 메모)');
expect(r.summary.startsWith('내용을 자동으로 정리하지 못했습니다')).toBe(true);
});
it('empty string title/summary → placeholder coerce', () => {
const r = parseAiResponse({ title: '', summary: '', tags: [] });
expect(r.title).toBe('(첨부 메모)');
expect(r.summary.length).toBeGreaterThan(0);
});
it('pads short summary to 3 lines', () => {
@@ -82,10 +93,14 @@ describe('parseAiResponse', () => {
expect(r.dueDate).toBeNull();
});
it('rejects malformed due_date string', () => {
expect(() =>
parseAiResponse({ title: '내일', summary: 'a\nb\nc', tags: [], due_date: 'tomorrow' })
).toThrow();
it('malformed due_date string → null coerce (vision graceful 처리)', () => {
const r = parseAiResponse({ title: '내일', summary: 'a\nb\nc', tags: [], due_date: 'tomorrow' });
expect(r.dueDate).toBeNull();
});
it('empty string due_date → null coerce', () => {
const r = parseAiResponse({ title: '내일', summary: 'a\nb\nc', tags: [], due_date: '' });
expect(r.dueDate).toBeNull();
});
it('coerces invalid date that passes regex (e.g. 2026-13-99) to null', () => {

View File

@@ -230,16 +230,22 @@ describe('composeIndexJsonl', () => {
});
describe('composeManifest', () => {
it('emits pretty JSON with required fields', () => {
it('emits pretty JSON with required fields (timestamp-free)', () => {
const m = composeManifest({
exportedAt: '2026-04-26T00:00:00.000Z',
noteCount: 42,
mediaCount: 17
});
const obj = JSON.parse(m);
expect(obj.inkling_export_version).toBe(1);
expect(obj.exported_at).toBe('2026-04-26T00:00:00.000Z');
expect(obj.note_count).toBe(42);
expect(obj.media_count).toBe(17);
// exported_at 필드 제거 — sync git history noise 방지.
expect(obj.exported_at).toBeUndefined();
});
it('두 번 호출 결과 stable (sync no-op invariant — 같은 input 이면 git diff 0)', () => {
const a = composeManifest({ noteCount: 5, mediaCount: 2 });
const b = composeManifest({ noteCount: 5, mediaCount: 2 });
expect(a).toBe(b);
});
});

View File

@@ -15,6 +15,12 @@ describe('sanitizeFtsQuery', () => {
it('returns empty string for whitespace-only', () => {
expect(sanitizeFtsQuery(' ')).toBe('');
});
it('v0.3.9 — dash/caret/backtick 추가 sanitize', () => {
expect(sanitizeFtsQuery('key-value')).toBe('key value');
expect(sanitizeFtsQuery('^prefix')).toBe('prefix');
expect(sanitizeFtsQuery('back`tick')).toBe('back tick');
expect(sanitizeFtsQuery('-NOT')).toBe('NOT');
});
});
describe('computeCutoff', () => {

View File

@@ -22,6 +22,7 @@ function makeDeps(overrides: Partial<InboxIpcDeps> = {}): InboxIpcDeps {
updateRawText: vi.fn(),
listRevisions: vi.fn(() => []),
restoreRevision: vi.fn(),
markAiPendingForReprocess: vi.fn(() => ({ ok: false })),
findById: vi.fn(),
list: vi.fn(),
listByStatus: vi.fn(),

View File

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

View File

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

View File

@@ -95,6 +95,37 @@ describe('useInbox — trash state (v0.2.3 #4)', () => {
expect(useInbox.getState().notes).toHaveLength(1);
});
it('view-aware upsertNote — inbox view 에서 status=completed 노트 push → notes 에서 제거', async () => {
const { useInbox } = await import('../../src/renderer/inbox/store.js');
// view='inbox' (default), active 노트 upsert
useInbox.getState().upsertNote(noteStub('a'));
expect(useInbox.getState().notes).toHaveLength(1);
// 같은 노트가 completed 로 status 변경 → 현재 view 와 안 맞으므로 notes 에서 제거
const completed: Note = { ...noteStub('a'), status: 'completed' };
useInbox.getState().upsertNote(completed);
expect(useInbox.getState().notes).toHaveLength(0);
});
it('view-aware upsertNote — completed view 에서 active 노트 push → notes 에 추가 안 됨', async () => {
const { useInbox } = await import('../../src/renderer/inbox/store.js');
useInbox.setState({ view: 'completed' });
useInbox.getState().upsertNote(noteStub('a')); // status='active'
expect(useInbox.getState().notes).toHaveLength(0);
// completed status 면 추가
const completed: Note = { ...noteStub('a'), status: 'completed' };
useInbox.getState().upsertNote(completed);
expect(useInbox.getState().notes).toHaveLength(1);
});
it('view-aware upsertNote — searchResults 가 있을 때 status mismatch → searchResults 에서 제거', async () => {
const { useInbox } = await import('../../src/renderer/inbox/store.js');
// 이전 test 가 view='completed' 로 설정한 채 끝났을 수 있어 명시적 초기화.
useInbox.setState({ view: 'inbox', searchResults: [noteStub('a')] });
const completed: Note = { ...noteStub('a'), status: 'completed' };
useInbox.getState().upsertNote(completed);
expect(useInbox.getState().searchResults).toHaveLength(0);
});
it('emptyTrash with cancelled confirm leaves trashNotes intact', async () => {
mockApi.emptyTrash.mockResolvedValueOnce({ confirmed: false, count: 0 });
const { useInbox } = await import('../../src/renderer/inbox/store.js');

View File

@@ -2,8 +2,10 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
vi.mock('electron', () => ({ default: { ipcMain: { handle: vi.fn() }, dialog: {}, shell: {} } }));
vi.mock('../../src/main/services/GitClient.js');
vi.mock('node:fs/promises', () => ({ mkdir: vi.fn(async () => undefined) }));
import electron from 'electron';
import { mkdir } from 'node:fs/promises';
import { GitClient } from '../../src/main/services/GitClient.js';
import { registerSettingsApi } from '../../src/main/ipc/settingsApi.js';
import type { SettingsIpcDeps } from '../../src/main/ipc/settingsApi.js';
@@ -105,6 +107,25 @@ describe('sync IPC channels', () => {
expect(r).toEqual({ ok: true });
});
// Regression: syncDir 미생성 상태에서 `git -C <syncDir> init` 호출 시
// git 이 chdir 실패로 죽음 → mkdir(recursive) 가 init 보다 먼저 호출되어야 함.
// (runSync 의 line 135 패턴과 동일.)
it('mkdir(syncDir, recursive) 가 git init 전에 호출됨', async () => {
const { deps, gitInstance } = makeDeps();
gitInstance.isRepo.mockResolvedValue(false);
const callOrder: string[] = [];
(mkdir as unknown as ReturnType<typeof vi.fn>).mockImplementationOnce(async () => { callOrder.push('mkdir'); });
(gitInstance.run as unknown as ReturnType<typeof vi.fn>).mockImplementation(async (args: string[]) => {
callOrder.push(`git:${args[0]}`);
return { stdout: '', stderr: '', exitCode: 0 };
});
registerSettingsApi(deps as SettingsIpcDeps);
const h = getHandler('settings:configure-sync');
await h({}, 'git@github.com:user/repo.git');
expect(mkdir).toHaveBeenCalledWith('/tmp/sync', { recursive: true });
expect(callOrder.indexOf('mkdir')).toBeLessThan(callOrder.indexOf('git:init'));
});
it('valid URL → isRepo=true, hasRemote=true → remote set-url', async () => {
const { deps, gitInstance } = makeDeps();
gitInstance.isRepo.mockResolvedValue(true);

View File

@@ -15,9 +15,18 @@ describe('buildVisionPrompt', () => {
expect(result).toContain('work, meeting, project, todo');
});
it('uses (이미지만 있음) placeholder when text is empty', () => {
it('본문 빈 경우 이미지 묘사 + null 금지 명시 + one-shot 예시 (v0.3.14+)', () => {
const result = buildVisionPrompt('', '2026-05-09', [], []);
expect(result).toContain('(이미지만 있음)');
expect(result).not.toContain('\n\n\n'); // no double-blank from empty text
expect(result).toContain('본문이 없습니다');
expect(result).toContain('null 반환 절대 금지');
expect(result).toContain('예시'); // one-shot 예시 포함
expect(result).toContain('잔디 위 강아지'); // 예시 1
expect(result).not.toContain('메모 본문:\n');
});
it('본문 있는 경우 본문 우선 + 이미지 함께 분석 명시', () => {
const result = buildVisionPrompt('회의 메모', '2026-05-09', [], []);
expect(result).toContain('메모 본문:\n회의 메모');
expect(result).toContain('첨부 이미지도 함께 분석');
});
});