Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e8cddc7889 | |||
|
|
e19f6a8de7 | ||
|
|
ccfdbce79b | ||
|
|
cffd1cec90 | ||
|
|
c5f2b8337a | ||
|
|
836828636c | ||
|
|
8a8652e87a | ||
|
|
ce6c5ea756 | ||
|
|
39bbf8f443 | ||
|
|
5f964aa2f5 | ||
|
|
3a8137f334 | ||
|
|
3b53cec663 | ||
|
|
9c8ba8ad09 | ||
|
|
f30fbddd38 | ||
|
|
77effb4526 | ||
|
|
feb7c62f19 | ||
|
|
95ed0fba93 | ||
|
|
6ab518410e | ||
|
|
5cd38f2537 | ||
|
|
fca28fb0c4 | ||
|
|
7301f4d73d | ||
|
|
91bf98f1a2 | ||
|
|
5b37529175 | ||
|
|
c9d374ade6 | ||
|
|
b1b7bfee26 | ||
|
|
66bae5e317 | ||
|
|
5a605ef98f | ||
|
|
c2be135031 | ||
|
|
9f47c13649 | ||
|
|
a51f241b94 | ||
| 8bc33da954 | |||
|
|
a991008689 | ||
|
|
54e2f5b10f | ||
|
|
8b2920fee4 | ||
|
|
0447b69b82 | ||
|
|
476a519fb5 | ||
|
|
9230ebff9d | ||
|
|
983306e004 | ||
|
|
05c45c1e10 | ||
|
|
a2c17a8b0d | ||
|
|
3cfa60bbba | ||
|
|
075f395b6d | ||
|
|
e485b77888 | ||
|
|
e2c53a28dc | ||
|
|
df27a9637e | ||
|
|
6fdb72101f | ||
|
|
341f55505d | ||
|
|
b3e16ff5bc | ||
| 8f2b9adb3a | |||
|
|
7187aea0a9 |
1133
docs/superpowers/plans/2026-05-05-v026-bugs-cleanup.md
Normal file
1133
docs/superpowers/plans/2026-05-05-v026-bugs-cleanup.md
Normal file
File diff suppressed because it is too large
Load Diff
2364
docs/superpowers/plans/2026-05-07-v027-cross-platform.md
Normal file
2364
docs/superpowers/plans/2026-05-07-v027-cross-platform.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,31 @@
|
||||
# Dogfood 피드백 수집
|
||||
|
||||
**작성일:** 2026-04-25 (open)
|
||||
**작성일:** 2026-04-25 (open, ongoing — v0.4 slice → v0.2.x cuts 까지 누적)
|
||||
**최종 갱신:** 2026-05-05 (v0.2.6 정식 cut 후, F8~F13 dogfood 발견 추가)
|
||||
**저자:** 김태현 (dlsrks0734@gmail.com)
|
||||
**문서 성격:** 슬라이스 v0.4 dogfood 중 발견된 본인 피드백을 수집·정제하는 living document. 각 항목은 후속 spec 으로 승격될 후보다. 정식 spec 이 분기될 만큼 성숙하면 별도 파일로 추출하고 여기엔 링크만 남긴다.
|
||||
**문서 성격:** v0.4 slice → v0.2.x cuts 동안 누적된 본인 dogfood 피드백 수집·정제 living document. 각 항목은 후속 spec/cut 으로 승격될 후보다.
|
||||
|
||||
**선행 문서:**
|
||||
- `docs/superpowers/specs/2026-04-24-inkling-vertical-slice-design.md` v0.4 (슬라이스 본문)
|
||||
- `docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md` (v0.2.3 7항목 cut 로드맵)
|
||||
- `docs/superpowers/strategy/strategy.md` (심리학 전략)
|
||||
- `docs/superpowers/strategy/dogfood-strategy.md` (dogfood 운영안 — 본 문서와 의존 없음)
|
||||
- `docs/superpowers/strategy/dogfood-strategy.md` (dogfood 운영안)
|
||||
- `docs/superpowers/v024-backlog.md` (PR review deferred + dogfood 발견 누적 backlog, 잔여 24건)
|
||||
|
||||
---
|
||||
|
||||
## 진척 흐름 요약 (2026-05-05 기준)
|
||||
|
||||
본 문서가 작성된 v0.4 slice 시점 이후 흐름:
|
||||
|
||||
| 시점 | Cut | 주요 피드백 / 발견 |
|
||||
|---|---|---|
|
||||
| 2026-05-01 | v0.2.3 7항목 roadmap (PR #13~#19) | v0.2.2 dogfood 발견 6건 (`memory/project_v022_feedback.md`) → telemetry skeleton + 휴지통 + 만료 추천 + Ollama 회복 + AI retry + 태그 vocab + RecallBanner |
|
||||
| 2026-05-04 | v0.2.3.1 (PR #21) → v0.2.4 (PR #22, semver 우회) | **F8 11434 reserved**, **F9 multi-instance**, **F10 버전 정보 부재** |
|
||||
| 2026-05-05 | v0.2.5 critical hotfix (PR #23) | **F11 single-instance lock 부재** (critical, 해결됨) |
|
||||
| 2026-05-05 | v0.2.6 정식 cut (PR #24) | **F12 autostart 풀림**, **F13 hidden-start race** (해결됨) |
|
||||
|
||||
**v0.2.7 brainstorm 트리거**: dogfood ≥1주 soak 후 telemetry export + 신규 피드백 + 잔여 backlog 24건 일괄 triage.
|
||||
|
||||
---
|
||||
|
||||
@@ -925,6 +943,372 @@ slice §1.3 종료 조건 ("크래시 0회") 와 별개로, **"데이터 손실
|
||||
|
||||
---
|
||||
|
||||
## F8. Windows 11434 포트 OS-level reserved (🚀 promoted → v0.2.3.1/v0.2.4)
|
||||
|
||||
**진행 상태:** 🚀 promoted → PR #21 (in-app Ollama 설정), v0.2.4 release.
|
||||
|
||||
**발견:** 2026-05-04 dogfood 시작 시도 직후, Windows 머신.
|
||||
|
||||
### 관찰
|
||||
|
||||
v0.2.3 binary 설치 후 첫 실행 시 OllamaBanner 가 "unreachable" 상태 지속. `curl http://localhost:11434/api/tags` 응답 없음. 하지만 `ollama app.exe` 프로세스는 떠있음. 진단 결과:
|
||||
- Ollama server 가 11434 bind 무한 실패: `Error: listen tcp 127.0.0.1:11434: bind: An attempt was made to access a socket in a way forbidden by its access permissions.`
|
||||
- `netsh int ipv4 show excludedportrange protocol=tcp` → 11242–11941 범위 (8개 100-port 블록) reserved
|
||||
- 원인: WSL2 (Ubuntu Running) + Hyper-V `hns` (Host Network Service) 가 부팅 시 동적 NAT 용 포트 블록 할당 — 11434 가 포함됨
|
||||
|
||||
### 제안 방향
|
||||
|
||||
**v0.2.3.1 → v0.2.4 cut**: env var 의존 제거, in-app endpoint/model 설정 UI 추가. 트레이 메뉴 + OllamaBanner "설정" 링크 → modal → JSON 영속화 (`<profileDir>/settings.json`).
|
||||
|
||||
추가: 사용자가 빈 포트 (11942 등) 로 Ollama 재시작 후 in-app 에서 endpoint 변경 — 시스템 설정 변경 (excludedportrange 등) 없이 dogfood 즉시 unblock.
|
||||
|
||||
### 결정 대기
|
||||
|
||||
- (해결됨) endpoint + model 둘 다 in-app 설정 가능해야 하는가 → Q1=B 결정 (v0.2.3.1 spec)
|
||||
- (해결됨) JSON 영속화 vs SQLite 새 테이블 → Q3=B JSON (migration 회피)
|
||||
- (해결됨) Settings precedence → settings > env > default
|
||||
|
||||
### 가설·측정
|
||||
|
||||
| # | 가설 | 측정 |
|
||||
|---|------|------|
|
||||
| H8.1 | dogfood 사용자 의 ≥30% 가 비기본 endpoint 사용 (LAN 또는 다른 포트) | telemetry `ollama_settings_changed` (count-only, v0.2.7) |
|
||||
| H8.2 | 11434 reserved 가 다른 머신에도 발생 | LAN 머신 / 동료 dogfood |
|
||||
|
||||
### 범위
|
||||
|
||||
- **In:** SettingsService + ProviderHolder + OllamaSettingsModal + IPC + 트레이 메뉴 항목
|
||||
- **Out:** Multi-provider abstraction (OpenAI 등), Settings dropdown UI (v0.2.4 freetext)
|
||||
|
||||
### 영향
|
||||
|
||||
- **Spec:** `docs/superpowers/specs/2026-05-04-v0231-ollama-settings-design.md`
|
||||
- **Backlog 잔여:** #39 (`ollama_unreachable.reason` 의 endpoint URL PII 우회 노출 — telemetry 하드닝 v0.2.7)
|
||||
|
||||
---
|
||||
|
||||
## F9. 앱 다중 인스턴스 spawn — SQLite race 위험 (🚀 promoted → v0.2.5 critical hotfix)
|
||||
|
||||
**진행 상태:** 🚀 promoted → PR #23 critical hotfix, v0.2.5 release.
|
||||
|
||||
**발견:** 2026-05-05 dogfood 도중. v0.2.4 설치 후 단축키 / 트레이 / 시작메뉴 클릭 반복 시.
|
||||
|
||||
### 관찰
|
||||
|
||||
앱 아이콘 클릭 시마다 새 process spawn. 트레이 아이콘 여러 개 누적. 작업관리자 에서 `Inkling.exe` process 다중 확인. 잠재 영향:
|
||||
- SQLite WAL 동시 write → DB 손상 가능
|
||||
- AiWorker 중복 polling → 같은 pending_jobs 두 process race
|
||||
- HealthChecker 중복 polling → Ollama 부하 + telemetry 이중 emit
|
||||
- `settings.json` atomic write 의 temp/rename race
|
||||
|
||||
### 제안 방향
|
||||
|
||||
**Critical hotfix** — `app.requestSingleInstanceLock()` 호출 + `second-instance` event handler 로 기존 inbox 창 restore + show + focus.
|
||||
|
||||
### 결정 대기
|
||||
|
||||
(해결됨) — Electron 표준 패턴 직접 적용.
|
||||
|
||||
### 범위
|
||||
|
||||
- **In:** `src/main/index.ts` 진입점 (whenReady 전 lock 획득). 단일 21줄 변경.
|
||||
- **Out:** None — cross-platform 자동 동작.
|
||||
|
||||
### 영향
|
||||
|
||||
- v0.2.5 critical hotfix release (`Inkling Setup 0.2.5.exe`)
|
||||
- **Backlog 잔여:** #46 hidden-start race (NSIS 직후 사용자 클릭 + autostart `--hidden` 동시 시도) — v0.2.6 (PR #24) 에서 `additionalData` 패턴으로 해결 완료.
|
||||
|
||||
### 비고
|
||||
|
||||
dogfood 발견 → 4시간 안에 hotfix release. 잠재 데이터 손실급 버그라 patch increment (v0.2.5) 사용. v0.2.4 사용자 즉시 업그레이드 권장.
|
||||
|
||||
---
|
||||
|
||||
## F10. 버전 / 빌드 정보 표시 surface 부재 (🚀 promoted → v0.2.4)
|
||||
|
||||
**진행 상태:** 🚀 promoted → PR #22, v0.2.4 release.
|
||||
|
||||
**발견:** 2026-05-04 dogfood 도중. 머신 간 버전 일치 검증 시도 시.
|
||||
|
||||
### 관찰
|
||||
|
||||
설치된 Inkling 의 버전을 UI 어디서도 확인 못함. 트레이 메뉴 / Inbox 푸터 / About 모달 모두 부재. 핸드오프 후 다른 머신 (Mac vs Windows) 에서 같은 버전인지 검증 path 없음. issue report 시 첨부할 디버그 정보 (Electron / Node / OS / profileDir) 도 노출 안 됨.
|
||||
|
||||
### 제안 방향
|
||||
|
||||
트레이 메뉴 마지막 항목 (종료 직전) "Inkling 정보..." → native `dialog.showMessageBox`. 표시:
|
||||
- 버전 (`app.getVersion()`)
|
||||
- Electron / Node 버전
|
||||
- OS (`platform()` + `release()`)
|
||||
- 데이터 위치 (`app.getPath('userData')`)
|
||||
|
||||
버튼: 확인 / 데이터 위치 열기 (`shell.openPath`) / 정보 복사 (`clipboard.writeText`).
|
||||
|
||||
### 영향
|
||||
|
||||
- v0.2.4 cut 동봉. 별도 spec 없음 (단순 추가).
|
||||
- **Backlog 잔여:** Inbox footer 형태 검토 (v0.2.7 — 항상 보이는 작은 버전 라벨)
|
||||
|
||||
---
|
||||
|
||||
## F11. Single-instance lock 부재 (🚀 promoted)
|
||||
|
||||
**진행 상태:** 🚀 promoted → F9 와 동일 cut (v0.2.5 hotfix).
|
||||
|
||||
F9 와 root cause 동일. 원래 별개 발견이었지만 fix 가 같음 — 본 항목은 F9 의 부분으로 흡수.
|
||||
|
||||
---
|
||||
|
||||
## F12. 윈도우 자동 실행 옵션이 재시작 후 풀려있는 버그 (🚀 promoted — v0.2.7 deeper fix)
|
||||
|
||||
**진행 상태:** 🚀 promoted → docs/superpowers/specs/2026-05-06-v027-cross-platform-design.md §9. v0.2.7 진단 노출 (withArgs/noArgs/registry/execPath + mismatch 경고 + 재등록 버튼) 적용 — 설정 페이지 "자동 실행" 섹션에서 사용자가 진단 정보 직접 확인 + 1-클릭 재등록 가능.
|
||||
|
||||
**발견:** 2026-05-05 dogfood 도중. autostart 토글 후 재시작.
|
||||
|
||||
### 관찰
|
||||
|
||||
트레이 메뉴 "윈도우 시작 시 자동 실행" 체크 → 종료 → 재실행 → 체크박스 풀려있음. `app.setLoginItemSettings({ openAtLogin, args: ['--hidden'] })` 호출 후 다음 부팅 시 `app.getLoginItemSettings()` 가 `openAtLogin=false` 반환.
|
||||
|
||||
### 제안 방향 (현 cut: 진단 fallback)
|
||||
|
||||
추정 원인:
|
||||
- (a) Windows registry path mismatch (NSIS 설치 위치 변경 / 버전 업데이트 시 새 디렉터리)
|
||||
- (b) Electron `setLoginItemSettings` Windows 구현 의 path canonicalization
|
||||
- (c) `args: ['--hidden']` 와 actual launch args 비교 mismatch — `getLoginItemSettings()` 가 args 없이 호출되면 mismatch
|
||||
|
||||
**v0.2.6 적용**:
|
||||
- `getLoginItemSettings({ args: ['--hidden'] })` 명시 → 트레이 checkbox checked 상태 가 실제 args 와 정합 비교
|
||||
- `autostart.state` 진단 로그 (withArgs vs noArgs 비교 + executableWillLaunchAtLogin) — dogfood 에서 실제 동작 로그 수집
|
||||
|
||||
### 결정 대기
|
||||
|
||||
dogfood 후 `autostart.state` 로그 분석:
|
||||
- args 명시 fix 만으로 충분 → close
|
||||
- 여전히 mismatch → registry 직접 inspect (HKCU\Software\Microsoft\Windows\CurrentVersion\Run\inkling) → exe path 확인 → executable path canonicalization 검토
|
||||
|
||||
### 영향
|
||||
|
||||
- dogfood UX 핵심 마찰 — autostart 가 핸드오프 시 매번 수동 재설정 필요. 자동 실행 의도 자체가 dogfood "잊지 않고 매일 사용" 목적인데 깨짐.
|
||||
- v0.2.6 진단 fallback 로 일단 시도, **v0.2.7 deeper fix 영역**.
|
||||
|
||||
---
|
||||
|
||||
## F13. PR review 발견: restoreNote production path dead code (🚀 promoted → v0.2.6 round 1 Critical fix)
|
||||
|
||||
**진행 상태:** 🚀 promoted → PR #24 round 1 Critical fix, v0.2.6 release.
|
||||
|
||||
**발견:** 2026-05-05 PR #24 round 1 reviewer (Claude code-reviewer subagent).
|
||||
|
||||
### 관찰
|
||||
|
||||
v0.2.6 cut 의 B1 (#10 restoreNote) 작업이 새 메서드 `NoteRepository.restoreNote(id)` 추가했지만 — **production path 가 옛 메서드 `repo.restore()` 호출 중**. CaptureService.restoreNote (line 93) 가 `this.repo.restore(noteId)` 호출 → `deleted_at = NULL` 만 set, `ai_status='failed'` 그대로 + `pending_jobs` 미재생성.
|
||||
|
||||
즉 새 메서드는 unit test 만 검증, 실제 사용자가 trash → AI fail → restore 흐름 시 영구 fail 상태 그대로.
|
||||
|
||||
### Fix
|
||||
|
||||
**Round 1 Critical fix (commit `a991008`)**:
|
||||
- `CaptureService.restoreNote` 가 `repo.restoreNote` 호출 (production path 활성화)
|
||||
- `before` 의 `ai_status` 가 `failed` 또는 `pending` 이면 `worker.enqueue(noteId)` 추가 호출 — in-memory AiWorker queue 갱신 (다음 app start 까지 대기 X)
|
||||
|
||||
테스트 +2 (CaptureService 의 enqueue 호출 검증).
|
||||
|
||||
### 비고
|
||||
|
||||
Round 1 reviewer 의 발견 가치 = **production path 와 unit test 가 갈라진 dead code 패턴**. 비슷한 패턴 다른 곳도 점검 가치. v0.2.7 brainstorm 시 grep 으로 "신 메서드 vs 옛 메서드 둘 다 존재 + 호출자 옛 사용" 패턴 검사.
|
||||
|
||||
---
|
||||
|
||||
## F14. macOS dock 클릭 시 hidden 창 재현 안 됨 (🚀 promoted → v0.2.7)
|
||||
|
||||
**진행 상태:** 🚀 promoted → docs/superpowers/specs/2026-05-06-v027-cross-platform-design.md §8. 2026-05-05 v0.2.6 dogfood 발견.
|
||||
|
||||
**발견:** 2026-05-05 김태현 macOS dogfood 도중.
|
||||
|
||||
### 관찰
|
||||
|
||||
- macOS 에서 inbox 창 닫음 (빨간 신호등) → 창 사라짐.
|
||||
- Dock 에는 Inkling 아이콘이 "켜져 있음" 표시 (점) 으로 그대로.
|
||||
- Dock 아이콘 클릭 → **창이 안 뜸**. 클릭이 그냥 무시됨.
|
||||
- 트레이 메뉴 "보관한 메모 보기" 로는 정상 호출 가능 (별 이슈 없음).
|
||||
- 앱을 완전히 종료 후 재실행 해야만 창이 다시 뜸.
|
||||
|
||||
### 제안 방향
|
||||
|
||||
추정 원인 (코드 확인 결과):
|
||||
|
||||
- [src/main/windows/inboxWindow.ts:39-44](src/main/windows/inboxWindow.ts#L39-L44) — close 이벤트에서 `app.isQuitting === false` 면 `e.preventDefault()` + `inboxWindow.hide()`. 즉 창은 살아있고 단지 hidden.
|
||||
- [src/main/index.ts:411-413](src/main/index.ts#L411-L413) — `app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createInboxWindow(); })`. 하지만 hidden 창은 destroy 되지 않았으므로 `getAllWindows().length === 1`. 분기 안 타고 no-op.
|
||||
|
||||
따라서 fix 방향은 단순:
|
||||
|
||||
```ts
|
||||
app.on('activate', () => {
|
||||
const win = getInboxWindow();
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.show();
|
||||
win.focus();
|
||||
} else {
|
||||
createInboxWindow();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
(B4 #46 second-instance 핸들러의 windows-restore 로직과 동일 패턴 — 거기서는 macOS dock 이 아닌 Windows multi-launch 경로지만 의도가 같음.)
|
||||
|
||||
### 결정 대기
|
||||
|
||||
- macOS 에서 트레이 (menubar extra) 도 사용 중인가? Windows 에서 트레이가 핵심 surface 인 반면 macOS 에서는 dock 클릭이 자연스러운 1차 surface.
|
||||
- 현재 macOS 빌드는 "기본 동작 유지" 수준 (메모리 우선순위 정책). dogfood 1차 타깃이 Windows 라 macOS 마찰을 어디까지 잡을지 cost-benefit 결정 필요.
|
||||
|
||||
### 가설·측정
|
||||
|
||||
- Fix 후 macOS dogfood 시 dock click → 창 즉시 등장 (1 클릭, < 100ms 체감).
|
||||
- regression 측정: Windows 에서 close→tray 메뉴 경로 영향 없음 (activate 이벤트는 macOS 전용 흐름이라 Windows 무관해야 함).
|
||||
|
||||
### 범위
|
||||
|
||||
- 1-file edit (`src/main/index.ts` activate 핸들러 5줄).
|
||||
- 단위 테스트 추가 어려움 (BrowserWindow + activate 이벤트 mocking 비용 ↑) — manual dogfood 검증 으로 충분.
|
||||
- v0.2.7 telemetry export 와 함께 묶어 cut.
|
||||
|
||||
### 영향
|
||||
|
||||
- macOS dogfood UX 마찰. "앱이 떠 있는데 안 보임 + 클릭 무반응" 은 사용자가 "고장났나?" 의심하게 만드는 첫 신호 — 신뢰 손상 큼.
|
||||
- Windows 는 트레이 중심이라 동일 증상 없음. macOS 단독 이슈.
|
||||
- v0.2.7 우선순위: F12 autostart 풀림 (Windows) 와 동급의 "잊지 않고 매일 쓰는" 흐름 마찰.
|
||||
|
||||
---
|
||||
|
||||
## F15. Linux 앱 빌드 (🚀 promoted → v0.2.7 / CLI 부분 거부)
|
||||
|
||||
**진행 상태:** 🚀 promoted (Linux 빌드만) → `docs/superpowers/specs/2026-05-06-v027-cross-platform-design.md` §5. CLI 부분은 ❌ rejected (DB/Ollama 동시접근 race + monorepo 부담 대비 본인 dogfood metric 직접 기여 적음).
|
||||
|
||||
**발견:** 2026-05-05 dogfood 외부 피드백 + 본인 후속 결정.
|
||||
|
||||
### 관찰
|
||||
|
||||
- Linux 터미널 사용자가 "Inkling 을 터미널에서 쓰고 싶다" 요청.
|
||||
- 본인 macOS 흐름에서도 GUI quick-capture 띄우는 비용 > `ink "한 줄"` 한 방 비용.
|
||||
- 현재 빌드 타겟: Windows NSIS x64 + macOS DMG arm64. Linux 타겟 자체 없음 → Linux 사용자에게 입구 자체 부재.
|
||||
|
||||
### 결정된 방향 (narrow scope)
|
||||
|
||||
**범위 (v0.2.7 narrow):**
|
||||
- CLI **capture-only**: `inkling capture "한 줄"` 만 노출. search/recall/restore/edit 등 v0.3+ 로 deferred.
|
||||
- 플랫폼: **Linux + macOS** (Windows CLI 제외 — 본인 Windows 는 GUI 흐름).
|
||||
- Linux 앱 빌드 추가: **AppImage + deb** 1차. rpm 은 외부 demand 후.
|
||||
|
||||
**아키텍처 — race-free 설계:**
|
||||
- CLI = SQLite WAL writer 로 `notes` INSERT (raw_text) + `pending_jobs` INSERT (note_id) 만 수행.
|
||||
- AI 워커는 **Electron 메인 단일 보유**. CLI 는 워커 안 띄움.
|
||||
- Electron 떠있으면 → 워커가 즉시 pending_jobs poll → AI 처리.
|
||||
- Electron 안 떠있으면 → 다음 launch 시 처리 (기존 pending_jobs 큐 모델 그대로).
|
||||
- 동일 DB 파일 동시 접근은 SQLite WAL 로 안전 (1 writer + N readers). Single-instance lock 은 **Electron 인스턴스 끼리만** 적용 — CLI 는 짧은 transaction 만 수행 후 종료, lock 우회 무관.
|
||||
|
||||
**플랫폼별 build 비용:**
|
||||
|
||||
| 타겟 | 빌드 비용 | 커버 |
|
||||
|---|---|---|
|
||||
| AppImage | 매우 낮음 (Mac/Linux cross-build 가능) | 모든 desktop linux 배포판 (1-file portable) |
|
||||
| deb | 낮음 (Mac brew `dpkg` `fakeroot`) | Debian/Ubuntu/Mint |
|
||||
| rpm | 중간 (Linux 호스트 또는 Docker) — **defer** | Fedora/RHEL/openSUSE |
|
||||
|
||||
**CLI 패키징:**
|
||||
- pkg / nexe 로 Node CLI → 단일 바이너리. Linux + macOS 각각 빌드.
|
||||
- 또는 Electron 앱 안에 `inkling` 심볼릭 링크 노출 (Mac: `/Applications/Inkling.app/Contents/MacOS/inkling`, Linux: AppImage 내 path 노출 / deb postinst hook).
|
||||
- 결정 대기 — v0.2.7 brainstorm 때 spike.
|
||||
|
||||
### 결정 대기 (v0.2.7 brainstorm)
|
||||
|
||||
- CLI 패키징: 별도 단일 바이너리 vs Electron 앱 번들 내 노출 — install UX 영향 큼.
|
||||
- AI 처리 시점 표기: capture 시 즉시 stdout 으로 "queued" 만 회신할지, `--wait` 플래그로 처리 완료 대기할지.
|
||||
- AppImage + deb 동시 vs 단계적.
|
||||
|
||||
### 가설·측정
|
||||
|
||||
- 본인 macOS 흐름에서 quick-capture window 호출 vs `ink "..."` time-to-capture 비교. 후자가 의미 있게 빠르면 본인 Aha metric (7일/3일) 에도 기여 가능.
|
||||
- 외부 Linux 사용자 1주 soak 후 capture 발생 빈도 (telemetry) — 채널 살아있는지 확인.
|
||||
|
||||
### 범위
|
||||
|
||||
- Linux 앱 빌드 + AppImage + deb: 1~2일.
|
||||
- CLI capture-only (Mac/Linux 양쪽): 2~3일.
|
||||
- better-sqlite3 prebuild 매트릭스 확장 (linux-x64): 부수 작업, 0.5일.
|
||||
- 합 약 4~5일 spike — v0.2.7 cut 1개로 가능.
|
||||
|
||||
### 영향
|
||||
|
||||
- 본인 macOS dogfood capture 가속 (직접 효용).
|
||||
- Linux 사용자 입구 제공 (외부 확장 첫걸음).
|
||||
- v0.4 slice 자기 종료 조건 (본인 2주 완주) 와 병행 가능 — capture 만 노출하므로 v0.4 핵심 흐름 (recall, AI tag, due) 변경 없음.
|
||||
- Risk: Linux native ABI 첫 진입 — better-sqlite3 prebuild 가 linux-x64 에서 깔끔히 떨어질지 dogfood 검증 필요.
|
||||
|
||||
---
|
||||
|
||||
## F16. 트레이 의존도 ↓ + 별도 설정 페이지 (🚀 promoted → v0.2.7)
|
||||
|
||||
**진행 상태:** 🚀 promoted → docs/superpowers/specs/2026-05-06-v027-cross-platform-design.md §6, §7. 트레이 = quick-capture / 보관함 / 설정 / 종료 4-항목 minimum 으로 강등 + inbox window 안 SettingsPage (AI provider · 자동 실행 · 백업/복원 · 내보내기/가져오기 · 텔레메트리 · 정보 6 섹션) 신설.
|
||||
|
||||
**발견:** 2026-05-05 dogfood 후속 결정.
|
||||
|
||||
### 관찰
|
||||
|
||||
- 현재 [src/main/tray.ts](src/main/tray.ts) 가 **13개 항목** 보유: 보관함 열기, 한 줄 적기, 백업, 내보내기, 복원, 동기화, telemetry export, Ollama 재확인, AI 재처리, **Ollama 설정...**, **자동 실행 토글**, 정보, 종료.
|
||||
- Windows: SysTray 잘 보임 → 핵심 surface 로 기능했음.
|
||||
- **macOS**: menubar extra 로 뜨지만 사용자가 메뉴바 가득찼을 때 가려짐. dock 사용자 흐름과 분리되어 발견성 ↓.
|
||||
- **Linux**: 모던 GNOME 등 일부 DE 는 system tray 자체 없음 (extension 필요). KDE/Cinnamon 은 보임.
|
||||
- 결과: macOS / Linux 사용자가 **Ollama 설정 / 자동 실행 토글 등 핵심 설정에 접근 불가**.
|
||||
|
||||
### 결정된 방향 (정책 변화)
|
||||
|
||||
**1. 트레이 = 최소 surface 로 강등.**
|
||||
- 잔류: 한 줄 적기 (quick capture), 보관함 열기, 종료.
|
||||
- 이동: Ollama 설정, 자동 실행, 백업/복원/내보내기, telemetry export, AI 재처리, Ollama 재확인, 정보 → **설정 페이지** 또는 inbox window 안 메뉴.
|
||||
|
||||
**2. 별도 설정 페이지 신설 (inbox window 안 또는 별도 윈도우).**
|
||||
- 예상 섹션: AI provider (Ollama endpoint/model — 기존 OllamaSettingsModal 흡수), 자동 실행, 백업/복원, 내보내기/가져오기, 텔레메트리, 정보.
|
||||
- 현재 OllamaSettingsModal 은 inbox 안 modal — 그대로 활용하거나 페이지화 검토.
|
||||
|
||||
**3. 1차 액션 진입점 재설계.**
|
||||
- macOS: dock 클릭 (F14 fix 와 동시) → inbox 창 → 메뉴 또는 톱니바퀴 → 설정.
|
||||
- Linux: 앱 launcher 또는 CLI (`inkling settings` 후보) → inbox 창 → 설정.
|
||||
- Windows: 트레이 우클릭 "한 줄 적기" + "보관함 열기" 만 잔류 + 트레이에서 "설정..." 한 항목 추가 (inbox 안 설정 페이지로 라우팅).
|
||||
|
||||
### 결정 대기 (v0.2.7 brainstorm)
|
||||
|
||||
- 설정 surface 형태:
|
||||
- (a) inbox 안 별도 라우트 (e.g., `/settings`) — SPA 내부 페이지
|
||||
- (b) 별도 BrowserWindow — 독립 윈도우
|
||||
- (c) 메뉴바 (Application Menu) 사용 — macOS 표준이지만 Win/Linux 와 일관성 깨짐
|
||||
- 추천 잠정: (a) — 최소 비용, 기존 inbox UI 연속성, OllamaSettingsModal 흡수 자연스러움.
|
||||
- 트레이 잔류 항목: "설정..." 1줄 잔류 vs 완전 제거 (Windows 사용자 경험 시 trade-off).
|
||||
- 자동 실행 토글의 실제 동작 — F12 deeper fix 가 설정 페이지 안에서 더 명확한 진단 노출 가능 (registry path 확인, args 미스매치 표시 등).
|
||||
|
||||
### 가설·측정
|
||||
|
||||
- 새 사용자 (macOS) 가 Ollama endpoint 변경 작업 완료까지 클릭 수: 현재 (트레이 메뉴에 가려짐) vs 설정 페이지 (inbox → 설정) — 후자가 의미 있게 짧으면 채택.
|
||||
- 트레이 메뉴 항목별 사용 빈도 telemetry — v0.2.7 export 후 실제 어떤 항목이 자주 클릭되는지 보고 잔류 우선순위 결정.
|
||||
|
||||
### 범위
|
||||
|
||||
- 1~2일: OllamaSettingsModal 을 설정 페이지 섹션으로 흡수.
|
||||
- 1~2일: 자동 실행 / 백업 / 복원 / 내보내기 등 트레이 click 핸들러를 설정 페이지 버튼으로 이동 (메인 IPC 는 그대로).
|
||||
- 0.5일: 트레이 메뉴 슬림화 (3~4 항목으로).
|
||||
- 합 3~5일. CLI (F15) 와 묶으면 v0.2.7 한 cut 가능.
|
||||
|
||||
### 영향
|
||||
|
||||
- **macOS / Linux 입구 정상화** — F14 (dock 클릭) + F15 (CLI 입구) 와 정합. 외부 Linux 사용자 + 본인 macOS 모두 설정 접근 가능.
|
||||
- **F12 autostart 영향**: 자동 실행 토글이 설정 페이지로 이동하면 진단 정보 (registry path, args 비교 결과) 노출 가능 → deeper fix 자연스럽게 결합.
|
||||
- **트레이 코드 단순화**: 13 항목 → 3~4 항목. v0.2.6 의 TrayCallbacks 객체화 (C2) 효과 가시화.
|
||||
- Risk: Windows 사용자 흐름 변경 — 트레이 한 클릭으로 끝나던 동작이 inbox 열기 → 설정 → 항목 클릭 으로 늘어남. 단, 빈도 낮은 동작 (Ollama 설정 변경, 백업 등) 만 이동하고 자주 쓰는 캡처/보관함 은 트레이 잔류 → 체감 마찰 ↓ 예상.
|
||||
|
||||
---
|
||||
|
||||
## (다음 항목 자리)
|
||||
|
||||
새 피드백 추가 시 `## F8. 짧은 제목 (🌱 raw)` 헤더로 시작. 표준 슬롯 6개 채우거나 비워둔 채 시작 가능.
|
||||
새 피드백 추가 시 `## F17. 짧은 제목 (🌱 raw)` 헤더로 시작. 표준 슬롯 6개 채우거나 비워둔 채 시작 가능.
|
||||
|
||||
dogfood ≥1주 soak (v0.2.6 release 후) 동안 새 발견 항목들 여기 누적 → v0.2.7 brainstorm 트리거.
|
||||
|
||||
133
docs/superpowers/specs/2026-05-05-v026-bugs-cleanup-design.md
Normal file
133
docs/superpowers/specs/2026-05-05-v026-bugs-cleanup-design.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# v0.2.6 Bugs + Cleanup — Design Spec
|
||||
|
||||
> 작성: 2026-05-05 · 정식 v0.2.6 cut. backlog 16건 (bug 4 + cleanup 12, 13 task 로 cluster) 통합 처리. dogfood telemetry 미수집 영역 (#7/#16/#18/#25/#33/#35/#36/#39/#40 등 14건) 은 v0.2.7 brainstorm 영역으로 별도.
|
||||
|
||||
## 1. Goal
|
||||
|
||||
dogfood UX 마찰 (autostart 풀림, trashCount 부정확, restore 시 AI 미재처리) 즉시 해소 + 코드베이스 cleanup (KST helper 통합, TrayCallbacks 객체화, AiFailedReason union 통합 등) 으로 v0.2.7 brainstorm 시 신규 feature 작업 friction 제거.
|
||||
|
||||
## 2. Scope (16 backlog 항목 → 13 task)
|
||||
|
||||
### Bug fixes (B1~B4)
|
||||
|
||||
| Task | 항목 | 작업 요약 |
|
||||
|---|---|---|
|
||||
| **B1** | #10 | `NoteRepository.restoreNote(id)` 가 `ai_status='failed'` 인 노트 복구 시 `ai_status='pending'` reset + `pending_jobs INSERT` |
|
||||
| **B2** | #12 | `NoteRepository.countTrashed()` 추가 + IPC `inbox:trashCount` 가 SQL 정확 N 반환 (UI 200 cap 제거) |
|
||||
| **B3** | #45 | autostart 풀림: `app.getLoginItemSettings({ args: ['--hidden'] })` (args 비교 정확도) + path canonicalization 검토. fallback: 진단 로그만 추가 시 backlog 유지 |
|
||||
| **B4** | #46 | `app.requestSingleInstanceLock(additionalData)` + `second-instance(event, argv, cwd, additionalData)` 에서 hidden flag 체크 → 두 번째 hidden 이면 inbox 창 안 띄움 |
|
||||
|
||||
### Cleanup refactor (C1~C9)
|
||||
|
||||
| Task | 항목 (cluster) | 작업 요약 |
|
||||
|---|---|---|
|
||||
| **C1** | #3 + #19 + #34 | KST helper 통합 → `src/shared/util/kstDate.ts`. 4 callsite migrate (`TelemetryService.todayKstIso`, `telemetryStats.kstDate`, `AiWorker.todayKstAsDate/Iso`, store `snoozeExpired/snoozeRecall`) |
|
||||
| **C2** | #4 + #23 + #26 | `interface TrayCallbacks` + `createTray(callbacks: TrayCallbacks)` 1-arg refactor. positional 10개 → object |
|
||||
| **C3** | #27 | `refreshTrayFailedCount` module-scoped state 제거 → TrayCallbacks 객체 안 reactive 함수 또는 store-driven 패턴 |
|
||||
| **C4** | #5 | `export type AiFailedReason = 'unreachable' \| 'schema' \| 'timeout' \| 'other'` 단일 export + zod `z.enum` 의 `z.infer` 로 type 파생. 3 callsite migrate |
|
||||
| **C5** | #21 | `hasNoteId(ev: TelemetryEvent): ev is TelemetryEventWithNoteId` type predicate helper → `tests/unit/TelemetryService.test.ts` 의 4-line narrowing 체인 단축 |
|
||||
| **C6** | #22 | NoteRepository hydrate 의 `as any[]` → `Record<string, unknown>[]` (또는 explicit row interface) 일괄 cleanup |
|
||||
| **C7** | #24 + #41 | `<Banner severity="warning"\|"error"\|"info">` shared component → ExpiryBanner / OllamaBanner / FailedBanner / RecallBanner / OllamaSettingsModal 5 callsite migrate |
|
||||
| **C8** | #8 | `telemetryStats.aggregateStats` if/else if 끝에 `else { const _: never = ev; }` exhaustiveness check |
|
||||
| **C9** | #15 + #29 + #42 + #9 | microfixes 묶음: `inbox:delete`→`inbox:trash` rename / `getTopUsedTags(20)` → `VOCAB_TOP_N` const / `OllamaSettingsModal` zod URL pre-check / 휴지통 회수율 ratio 코멘트 1줄 |
|
||||
|
||||
## 3. Out of scope
|
||||
|
||||
- Telemetry 데이터 필요 (14건): #7 reason 분포 / #16 permanent_delete 빈도 / #18 loadExpired consumer / #20 telemetry .catch silent / #25 HealthChecker dedup / #28 unreachableBackoffStep / #29 top-N 튜닝값 (extract 만 본 cut, 튜닝은 v0.2.7) / #30 LIMIT-then-filter 정책 / #31 vocabSet COLLATE / #32 per-tag emit 병렬화 / #33 promptVersion payload / #35 recall_shown lifetime / #36 IPC handle vs on / #39 ollama reason PII / #40 Settings race flicker
|
||||
- 별도 brainstorm 영역 (3건): #11 restoreNote precondition / #14 ARIA 패턴 / #17 dialog 버튼 순서 / #37 NoteCard id ref-forwarding
|
||||
|
||||
## 4. Architecture changes
|
||||
|
||||
대부분 cosmetic refactor 또는 isolated bug fix. 주목할 architecture-level 변경:
|
||||
|
||||
### 4.1 KST helper 통합 (C1)
|
||||
- 신규 `src/shared/util/kstDate.ts` (main + renderer 양쪽 import 가능)
|
||||
- 기존 4 callsite 의 inline KST 계산 제거
|
||||
- API: `kstTodayIso(now?: Date): string`, `nextKstMidnightMs(now?: Date): number`
|
||||
- KST_OFFSET_MS 상수 단일
|
||||
|
||||
### 4.2 TrayCallbacks 객체화 (C2 + C3)
|
||||
- `interface TrayCallbacks` — 10+ 개 callback + state getter
|
||||
- `createTray(callbacks: TrayCallbacks): void` — 1-arg signature
|
||||
- module state (_failedCount, _todayCount, _ollamaOk) 는 TrayCallbacks 의 reactive getter / setter 패턴 또는 explicit refresh 함수 (`refreshTray(state: { todayCount, failedCount, ollamaOk })`)
|
||||
|
||||
### 4.3 Banner shared component (C7)
|
||||
- `<Banner severity="warning"|"error"|"info" icon? title? children>` — wrapping/styling 일원화
|
||||
- 5 callsite 가 themed inline style 제거 → severity prop
|
||||
- CSS variables 또는 hardcoded theme map (single source)
|
||||
|
||||
### 4.4 NoteRepository.restoreNote behavior change (B1)
|
||||
- 기존: `UPDATE notes SET deleted_at = NULL WHERE id = ?`
|
||||
- 변경: 추가로 `ai_status='failed'` 였을 경우 → `ai_status='pending'` reset + `INSERT OR IGNORE INTO pending_jobs`
|
||||
- atomic transaction
|
||||
- AiWorker 가 자동으로 다음 loop iteration 에서 처리
|
||||
|
||||
## 5. Tests
|
||||
|
||||
추정 +17 cases (413 → 430):
|
||||
|
||||
| Task | 신규 단위 |
|
||||
|---|---|
|
||||
| B1 | +3 (restore failed note re-enqueues, restore done note 영향 X, restore cancelled note 영향 X) |
|
||||
| B2 | +2 (countTrashed 정확, dialog message 정확 N) |
|
||||
| B3 | +1-2 (autostart args 비교, 가능하다면 mock electron app) |
|
||||
| B4 | +1 (additionalData hidden flag 가 second-instance 에 전달, mock test) |
|
||||
| C1 | +2 (kstTodayIso, nextKstMidnightMs) — 기존 4 callsite test 가 자동 검증 |
|
||||
| C2 | refactor only, 기존 tray 테스트 유지 |
|
||||
| C3 | refactor only |
|
||||
| C4 | refactor only |
|
||||
| C5 | +2 (hasNoteId predicate) |
|
||||
| C6 | refactor only |
|
||||
| C7 | refactor only (UI 컴포넌트 unit test X 패턴) |
|
||||
| C8 | +1 (exhaustive guard 컴파일 단계) |
|
||||
| C9 | +1 (Modal URL pre-check), 나머지 refactor only |
|
||||
|
||||
총 신규: ~13-15 (보수적). 단위 413 → **~426-428** 예상.
|
||||
|
||||
## 6. Privacy invariant
|
||||
|
||||
- B1/B2: telemetry 영향 없음
|
||||
- B3/B4: telemetry emit 없음 (autostart event 미수집)
|
||||
- C 시리즈: 모두 cosmetic refactor — invariant 영향 0
|
||||
- 본 cut 에서 신규 telemetry kind 추가 0
|
||||
|
||||
## 7. Gates (roadmap §3.1)
|
||||
|
||||
- typecheck 0
|
||||
- 단위 413 → ~427 (+13~15)
|
||||
- e2e 1/1
|
||||
- backward compat: 기존 사용자 데이터 + UI 동작 영향 0 (단 B1 은 의도적 동작 추가, B2 는 UI N 표시 정확화)
|
||||
|
||||
## 8. Risk + Fallback
|
||||
|
||||
### B3 (autostart 풀림) 진단 불확실
|
||||
가장 risky. Windows registry 디버깅 결과 깨끗한 fix 안 나올 수 있음. **Fallback 정책**:
|
||||
- 진단 절차 적용해도 fix 안 되면 → 진단 로그만 추가 (`logger.info('autostart.state', { stored, current, mismatch })`) → backlog #45 유지 → 본 cut 에서 task drop
|
||||
- 다른 task 영향 없음 (각 task 독립적)
|
||||
|
||||
### C1 KST helper 의 alias 경계
|
||||
`src/shared/util/kstDate.ts` 가 main + renderer 양쪽에서 import 되어야. 기존 `@main/util/kstDate.ts` 는 renderer 에서 import 불가 (alias 분리). `src/shared/` 가 양쪽 가능 패턴. 검증 필요.
|
||||
|
||||
### C2 TrayCallbacks 객체화 의 backward compat
|
||||
기존 createTray 호출자 (index.ts 1곳) 한 군데만 변경 → 안전. tray 테스트 영향 최소.
|
||||
|
||||
## 9. 작업 순서
|
||||
|
||||
순서대로 subagent dispatch. 의존성:
|
||||
- B1, B2: 독립
|
||||
- B3: 독립 (Windows-specific, mock 어려움)
|
||||
- B4: 독립
|
||||
- C1 → 다른 task 영향 X (shared util 추가)
|
||||
- C2 → C3 (TrayCallbacks 객체에 refreshTrayFailedCount 흡수)
|
||||
- C4, C5, C6, C7, C8, C9: 독립
|
||||
|
||||
권장 순서: **B1 → B2 → B4 → B3 → C1 → C4 → C5 → C6 → C8 → C2 → C3 → C7 → C9**.
|
||||
|
||||
이유: B3 (위험) 을 cleanup 시작 직전에 두어 fail 시 빠르게 회피. C2/C3 cluster 는 묶어서. C7 (Banner shared) 는 isolated UI cleanup, 마지막 그룹.
|
||||
|
||||
## 10. Roadmap relation
|
||||
|
||||
- v0.2.6 정식 cut (이전 v0.2.4/v0.2.5 는 patch / hotfix)
|
||||
- 머지 후 binary 빌드 v0.2.6 (Windows + Mac) + Gitea release
|
||||
- v0.2.7 brainstorm 트리거: dogfood ≥1주 soak + telemetry export 모인 후, 잔여 backlog 14건 (data-dependent) + 신규 피드백 일괄 triage
|
||||
- backlog file 본 cut 후 prune (16 건 처리 완료 표기) + rename 검토 (`v027-backlog.md` 또는 `feature-backlog.md`)
|
||||
407
docs/superpowers/specs/2026-05-06-v027-cross-platform-design.md
Normal file
407
docs/superpowers/specs/2026-05-06-v027-cross-platform-design.md
Normal file
@@ -0,0 +1,407 @@
|
||||
# v0.2.7 — Cross-Platform 입구 정상화 (Design)
|
||||
|
||||
**작성일:** 2026-05-06
|
||||
**저자:** 김태현 (dlsrks0734@gmail.com)
|
||||
**선행 문서:**
|
||||
|
||||
- `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` (F12, F14, F15, F16)
|
||||
- `docs/superpowers/v024-backlog.md` (잔여 24건)
|
||||
- `docs/superpowers/strategy/dogfood-strategy.md` (운영안)
|
||||
|
||||
**Cut 라벨:** v0.2.7 (semver 엄밀히는 MINOR — 새 플랫폼 + 새 surface — 이지만 본 프로젝트 관습상 v0.2.x 를 feature lane 으로 사용 중이므로 v0.2.7 라벨 유지)
|
||||
|
||||
---
|
||||
|
||||
## 1. Cut 정체성
|
||||
|
||||
**"Cross-platform 입구 정상화" cut.** F12 / F14 / F15 / F16 4개 항목을 한 묶음으로 처리. 핵심 동기:
|
||||
|
||||
> Windows 트레이 의존을 끊고 macOS / Linux 사용자에게 동등한 입구를 제공한다.
|
||||
|
||||
현재 13개 트레이 메뉴 항목이 macOS / Linux (특히 모던 GNOME) 에서 발견 / 접근성이 떨어져 핵심 설정 (Ollama endpoint, 자동 실행 등) 진입이 막히는 구조적 문제. 트레이를 deemphasis 하고 inbox 윈도우 안에 통합 설정 페이지를 둔다. 동시에 macOS dock 동작 정상화 (F14) + Linux 앱 빌드 추가 (F15 축소판) + 자동 실행 진단 노출 (F12 deeper fix) 까지 함께 처리한다.
|
||||
|
||||
**의도적으로 빠진 것:**
|
||||
|
||||
- ~~CLI (`inkling capture` 등)~~ — DB / Ollama 동시접근 race + monorepo 재구성 부담 대비 본인 dogfood metric 직접 기여 적음. v0.2.7 에서 제외. 외부 demand 누적 시 v0.3+ 재거론.
|
||||
|
||||
---
|
||||
|
||||
## 2. 범위
|
||||
|
||||
| 항목 | 출처 | 작업 |
|
||||
|---|---|---|
|
||||
| **F15 (축소판)** | dogfood F15 | Linux 앱 빌드 (AppImage + deb x64) + better-sqlite3 prebuild linux-x64 매트릭스 |
|
||||
| **F16** | dogfood F16 | 트레이 슬림 (13 → 4) + inbox 안 설정 페이지 (4 섹션) |
|
||||
| **F14** | dogfood F14 | macOS dock 클릭 시 hidden 창 show/focus (activate 핸들러 5줄 수정) |
|
||||
| **F12 deeper fix** | dogfood F12 (v0.2.6 진단 fallback 후속) | 설정 페이지 "자동 실행" 섹션 안에 진단 패널 노출 (withArgs vs noArgs / executableWillLaunchAtLogin / registry path) |
|
||||
|
||||
---
|
||||
|
||||
## 3. Architecture 변화
|
||||
|
||||
| 영역 | 현재 (v0.2.6) | v0.2.7 |
|
||||
|---|---|---|
|
||||
| 설정 진입 | 트레이 메뉴 13개 항목 | 트레이 4개 + 설정 페이지 (inbox 내부 라우트) |
|
||||
| Ollama 설정 | OllamaSettingsModal (트레이에서만 진입) | 설정 페이지 안 "AI 제공자" 섹션 (modal 흡수) |
|
||||
| 자동 실행 | 트레이 checkbox + args 명시 | 설정 페이지 안 섹션 + 진단 패널 |
|
||||
| macOS dock 클릭 | activate 핸들러 no-op (length===0 분기 못 탐) | `getInboxWindow().show() + focus()` 분기 추가 |
|
||||
| Linux 배포 | 없음 | AppImage + deb 산출물 |
|
||||
| 빌드 매트릭스 | win-x64 + mac-arm64 | + linux-x64 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 구현 순서 (Approach 2: Risk-reduction first)
|
||||
|
||||
```
|
||||
1. Linux 빌드 (가장 unknown — better-sqlite3 prebuild linux-x64 검증)
|
||||
↓ AppImage + deb 산출 + Linux VM/WSL2 smoke test
|
||||
2. 설정 페이지 (inbox 내부 라우트 + 4 섹션)
|
||||
↓ OllamaSettingsModal 흡수
|
||||
3. 트레이 슬림 (13 → 4)
|
||||
↓ 제거된 click 핸들러 → 설정 페이지 버튼으로 이동
|
||||
4. F14 macOS dock 클릭 fix
|
||||
↓ activate 핸들러 5줄
|
||||
5. F12 deeper fix (자동 실행 진단 노출)
|
||||
↓ IPC settings:autostart-state + 진단 panel UI
|
||||
```
|
||||
|
||||
Linux 빌드를 먼저 두는 이유: native ABI 트랩 (메모 `project_inkling_status.md`) 이 linux-x64 에서 재발할 수 있음. 만약 prebuild 가 깔끔히 떨어지지 않으면 v0.2.7 scope 조정 (예: AppImage 만, deb 는 v0.2.8) 여유. 설정 페이지 / 트레이 슬림 / F14 / F12 는 모두 코드 작성 risk 가 낮은 영역이라 후순위로 안전.
|
||||
|
||||
---
|
||||
|
||||
## 5. Linux 빌드 디테일
|
||||
|
||||
### 5-1. electron-builder config 추가
|
||||
|
||||
```json
|
||||
"linux": {
|
||||
"target": [
|
||||
{ "target": "AppImage", "arch": ["x64"] },
|
||||
{ "target": "deb", "arch": ["x64"] }
|
||||
],
|
||||
"category": "Utility",
|
||||
"synopsis": "로컬 메모 캡처 + AI 태그",
|
||||
"description": "Inkling — 잠깐 스친 생각을 잡아두는 로컬-우선 메모 도구."
|
||||
}
|
||||
```
|
||||
|
||||
### 5-2. npm scripts 추가
|
||||
|
||||
```json
|
||||
"predist:linux": "npm run rebuild:electron && npm run build",
|
||||
"dist:linux": "electron-builder --linux --x64"
|
||||
```
|
||||
|
||||
`rebuild:electron` 의 `--target=41.3.0` 그대로. `prebuild-install` 이 linux-x64 prebuild 를 npm 레지스트리에서 받아오는지 검증. 없으면 `node-gyp` fallback 으로 로컬 컴파일.
|
||||
|
||||
### 5-3. 빌드 호스트 전략
|
||||
|
||||
**1차: macOS 호스트** (이미 DMG 빌드 호스트). brew 로 도구 설치:
|
||||
|
||||
```bash
|
||||
brew install dpkg fakeroot
|
||||
```
|
||||
|
||||
electron-builder 가 cross-build 지원. AppImage 는 Mac 에서 직접 빌드 가능 (Linux 유저랜드 도구만 필요한 부분은 electron-builder 내장 + AppImageKit 자동 다운로드). deb 는 dpkg-deb 필요.
|
||||
|
||||
**Fallback (1차 실패 시): Docker on Mac/Windows.** `electronuserland/builder` 이미지로 Linux 빌드 환경 격리. v0.2.7 scope 안에서 결정.
|
||||
|
||||
### 5-4. Smoke test
|
||||
|
||||
`dist/` 산출물:
|
||||
|
||||
- `Inkling-0.2.7.AppImage` (x64) — Linux VM 또는 WSL2 에서 chmod +x → 실행 → 마이그레이션 통과 확인 → capture / recall 한 사이클.
|
||||
- `inkling_0.2.7_amd64.deb` — Ubuntu/Debian VM 또는 WSL2 에서 `sudo dpkg -i` → `inkling` 실행 → 동일 검증.
|
||||
|
||||
검증 항목:
|
||||
|
||||
1. better-sqlite3 native module 로드 성공 (마이그레이션 0 → m003 통과)
|
||||
2. Ollama 연결 시도 (settings.json 의 endpoint 또는 `INKLING_OLLAMA_ENDPOINT` env) — 본인 LAN 서버 `http://192.168.0.47:11434` 사용
|
||||
3. capture 한 줄 → AI 처리 → tag 표시
|
||||
4. 트레이 (KDE/Cinnamon DE 가정) 4 항목 표시
|
||||
5. 트레이 없는 DE (모던 GNOME) — launcher 에서 앱 실행 → inbox 윈도우 → 톱니바퀴 → 설정 페이지 진입
|
||||
|
||||
---
|
||||
|
||||
## 6. 설정 페이지 디테일
|
||||
|
||||
### 6-1. 라우팅 방식
|
||||
|
||||
React Router 도입 안 함 (의존성 + 학습 비용). zustand store 의 `view: 'inbox' | 'trash' | 'settings'` state + 조건부 렌더 — 기존 trash view 와 동일 패턴. 새 의존성 0.
|
||||
|
||||
### 6-2. 진입점
|
||||
|
||||
| 진입 | 동작 |
|
||||
|---|---|
|
||||
| 트레이 "설정..." 클릭 | main → IPC `inbox:navigate` 'settings' → renderer store action `setView('settings')` + inbox 윈도우 show/focus |
|
||||
| inbox 헤더 톱니바퀴 아이콘 | renderer store action `setView('settings')` |
|
||||
| 설정 페이지 안 "← 돌아가기" 버튼 | `setView('inbox')` |
|
||||
|
||||
### 6-3. 섹션 4개
|
||||
|
||||
#### 6-3-1. AI 제공자
|
||||
|
||||
흡수 대상: OllamaSettingsModal 전체 + 트레이 "Ollama 재확인".
|
||||
|
||||
UI 요소:
|
||||
|
||||
- Endpoint URL 입력 (zod 검증 — 기존 modal 의 `safeParse` 재활용)
|
||||
- Model 입력 (빈 값 guard)
|
||||
- "지금 재확인" 버튼 → ProviderHolder 의 health check trigger
|
||||
- 마지막 ping 결과 표시 (성공 시각 또는 실패 사유)
|
||||
- "기본값으로 되돌리기" 버튼
|
||||
|
||||
저장: 기존 SettingsService (atomic temp+rename + zod) 그대로.
|
||||
|
||||
#### 6-3-2. 자동 실행
|
||||
|
||||
흡수 대상: 트레이 "윈도우 시작 시 자동 실행" checkbox.
|
||||
|
||||
UI 요소:
|
||||
|
||||
- 토글 ("앱 시작 시 자동으로 실행")
|
||||
- 진단 패널 (펼치기 가능 — 평소엔 접혀 있음)
|
||||
- "재등록" 버튼 (setLoginItemSettings 강제 재호출)
|
||||
|
||||
진단 패널 디테일은 §9 (F12 deeper fix) 참조.
|
||||
|
||||
#### 6-3-3. 백업 / 복원 / 내보내기
|
||||
|
||||
흡수 대상: 트레이의 5개 항목 — "지금 백업" / "내보내기..." / "백업에서 복원..." / "지금 동기화" / "사용 로그 내보내기...".
|
||||
|
||||
UI 요소: 5개 버튼 + 각 작업 마지막 실행 시각 (가능하면) + 결과 toast.
|
||||
|
||||
IPC 핸들러는 기존 그대로 — 트레이 click 핸들러였던 함수를 IPC 핸들러로 등록 + renderer 에서 invoke.
|
||||
|
||||
#### 6-3-4. 정보
|
||||
|
||||
흡수 대상: 트레이 "Inkling 정보..." dialog.
|
||||
|
||||
UI 요소: 버전 / Electron / Node / OS / 데이터 위치 텍스트 + "데이터 위치 열기" 버튼 + "정보 복사" 버튼.
|
||||
|
||||
기존 `showAboutDialog` 의 detail 문자열 그대로 활용 — clipboard.writeText / shell.openPath 호출도 동일.
|
||||
|
||||
### 6-4. 제외 항목
|
||||
|
||||
- "지금 AI 처리 (실패 N건)" — 이미 inbox FailedBanner 가 surface. 트레이 / 설정 둘 다 제거.
|
||||
- "Ollama 재확인" 트레이 메뉴 단독 — OllamaBanner (끊김 시) + 설정 페이지 AI 섹션 "지금 재확인" 버튼이 surface. 트레이 단독 메뉴 제거.
|
||||
|
||||
---
|
||||
|
||||
## 7. 트레이 슬림 디테일
|
||||
|
||||
### 7-1. 잔류 4개 (Win / Mac / Linux 동일)
|
||||
|
||||
```ts
|
||||
items.push({ label: '한 줄 적기', click: cb.showCapture });
|
||||
items.push({ label: '보관한 메모 보기', click: cb.showInbox });
|
||||
items.push({ type: 'separator' });
|
||||
items.push({ label: '설정...', click: cb.showSettings });
|
||||
items.push({ type: 'separator' });
|
||||
items.push({ label: '종료', click: () => { app.isQuitting = true; app.quit(); } });
|
||||
```
|
||||
|
||||
todayCount tooltip (`Inkling — 오늘 N`) 잔류. F4-C 의 "오늘 N번 잡아둠" 비활성 라벨도 잔류 (정체성 신호).
|
||||
|
||||
### 7-2. TrayCallbacks / TrayState 갱신
|
||||
|
||||
```ts
|
||||
export interface TrayCallbacks {
|
||||
showInbox: () => void;
|
||||
showCapture: () => void;
|
||||
showSettings: () => void; // NEW — IPC 'inbox:navigate' 'settings' 송출
|
||||
}
|
||||
|
||||
// 메뉴 영향 state 슬림
|
||||
export interface TrayState {
|
||||
todayCount: number;
|
||||
}
|
||||
```
|
||||
|
||||
**제거 대상:**
|
||||
|
||||
- `runBackup`, `runExport`, `runImport`, `runSync`, `runExportTelemetry` callback (5개) → 설정 페이지 버튼으로 이동
|
||||
- `runOllamaRecheck`, `runRetryAllFailed`, `runOpenOllamaSettings` callback (3개) → 설정 페이지 또는 banner 로 이동
|
||||
- `ollamaOk`, `failedCount` state field (2개) → 트레이 메뉴 영향 사라짐 (banner 가 surface)
|
||||
- `refreshTray({ ollamaOk })`, `refreshTray({ failedCount })` 호출부 (HealthChecker, AiWorker) → 제거. todayCount 만 남음.
|
||||
|
||||
v0.2.6 의 Partial<TrayState> 패턴 그대로 활용 — 인터페이스 좁아질 뿐.
|
||||
|
||||
### 7-3. 자동 실행 토글 트레이 잔류 X
|
||||
|
||||
기존 트레이 안 checkbox (`type: 'checkbox'`) 는 제거. 설정 페이지 "자동 실행" 섹션 토글이 단일 진입점.
|
||||
|
||||
이유: 자동 실행 토글은 빈도 낮은 액션 + F12 진단이 같은 자리에 있어야 의미. 트레이 잔류 시 두 surface mismatch 위험.
|
||||
|
||||
---
|
||||
|
||||
## 8. F14 macOS dock 클릭 fix
|
||||
|
||||
[src/main/index.ts:411-413](src/main/index.ts#L411-L413) 수정:
|
||||
|
||||
```ts
|
||||
app.on('activate', () => {
|
||||
const win = getInboxWindow();
|
||||
if (win && !win.isDestroyed()) {
|
||||
if (!win.isVisible()) win.show();
|
||||
win.focus();
|
||||
} else {
|
||||
createInboxWindow();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
second-instance 핸들러 (B4 #46) 와 패턴 일치 — 양쪽 모두 "살아있으면 show, 죽었으면 create".
|
||||
|
||||
테스트: BrowserWindow + activate 이벤트 mocking 비용 ↑ → manual dogfood 검증으로 충분 (macOS 빨간 신호등 → dock 클릭 → 즉시 창 등장).
|
||||
|
||||
---
|
||||
|
||||
## 9. F12 deeper fix — 자동 실행 진단 노출
|
||||
|
||||
### 9-1. 정보 모델
|
||||
|
||||
```ts
|
||||
// 신규 IPC: settings:autostart-state
|
||||
interface AutostartState {
|
||||
withArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean };
|
||||
noArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean };
|
||||
execPath: string; // process.execPath
|
||||
registryPath?: string; // Windows only
|
||||
registryValue?: string; // Windows only — null 또는 string
|
||||
}
|
||||
```
|
||||
|
||||
### 9-2. main process 핸들러
|
||||
|
||||
```ts
|
||||
ipcMain.handle('settings:autostart-state', async () => {
|
||||
const withArgs = app.getLoginItemSettings({ args: ['--hidden'] });
|
||||
const noArgs = app.getLoginItemSettings();
|
||||
const state: AutostartState = {
|
||||
withArgs: {
|
||||
openAtLogin: withArgs.openAtLogin,
|
||||
executableWillLaunchAtLogin: withArgs.executableWillLaunchAtLogin
|
||||
},
|
||||
noArgs: {
|
||||
openAtLogin: noArgs.openAtLogin,
|
||||
executableWillLaunchAtLogin: noArgs.executableWillLaunchAtLogin
|
||||
},
|
||||
execPath: process.execPath
|
||||
};
|
||||
if (process.platform === 'win32') {
|
||||
state.registryPath = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run\\Inkling';
|
||||
state.registryValue = await readRegistryValueSilent(state.registryPath);
|
||||
}
|
||||
return state;
|
||||
});
|
||||
```
|
||||
|
||||
`readRegistryValueSilent`: `child_process.execFile('reg', ['query', path, '/v', 'Inkling'])` 1회. 실패 시 null 반환 (silent fallback — 사용자에 에러 노출 X).
|
||||
|
||||
새 dependency 추가 X (`winreg` 등 X) — built-in `child_process` + Windows `reg.exe` 만 활용.
|
||||
|
||||
### 9-3. UI
|
||||
|
||||
설정 페이지 "자동 실행" 섹션:
|
||||
|
||||
```
|
||||
[ ] 앱 시작 시 자동으로 실행
|
||||
상태: ✅ 등록됨 / ⚠️ 등록 안 됨 / ⚠️ args 미스매치
|
||||
▾ 진단 정보 (펼치기)
|
||||
- 표준 조회 (args 명시): openAtLogin=true, willLaunch=true
|
||||
- 비교 조회 (args 없이): openAtLogin=false, willLaunch=true ← mismatch ⚠️
|
||||
- 실행 파일 경로: /Applications/Inkling.app/Contents/MacOS/Inkling
|
||||
- registry 경로 (Windows): HKCU\...\Run\Inkling
|
||||
- registry 값: "C:\Users\...\Inkling.exe" --hidden
|
||||
[ 재등록 ] 버튼
|
||||
```
|
||||
|
||||
### 9-4. dogfood 시나리오
|
||||
|
||||
1. 토글 ON → 재시작 → 풀려있으면 진단 패널 펼침.
|
||||
2. withArgs vs noArgs mismatch 보임 → args canonicalization 문제 확인.
|
||||
3. registry 값 vs execPath 비교 — 다르면 path canonicalization 문제 (NSIS 재설치 시 path 바뀜).
|
||||
4. "재등록" 버튼 → setLoginItemSettings 재호출 → 다시 재시작 → 효과 측정.
|
||||
|
||||
수집된 데이터로 v0.2.8 root cause fix 작성.
|
||||
|
||||
---
|
||||
|
||||
## 10. 테스트 전략
|
||||
|
||||
| 영역 | 단위 | e2e | Manual dogfood |
|
||||
|---|---|---|---|
|
||||
| Linux 빌드 (F15) | - | - | AppImage + deb 산출 + Linux VM 실행 + 마이그레이션/캡처/recall 한 사이클 |
|
||||
| 설정 페이지 라우팅 | zustand store action `setView('settings')` 단위 | (선택) 트레이 "설정..." → IPC → view 전환 e2e | 실제 클릭 흐름 |
|
||||
| Ollama 섹션 흡수 | 기존 OllamaSettingsModal 단위 + 흡수 후 회귀 | - | 1회 |
|
||||
| 자동 실행 진단 IPC | autostart-state 핸들러 단위 (mock electron API + child_process) | - | Win 토글 → 재시작 → 진단 패널 mismatch 검출 |
|
||||
| 트레이 슬림 | tray.ts buildMenu 단위 (4 항목 검증 + 제거된 항목 부재) | - | - |
|
||||
| F14 dock fix | (mock 비용 ↑) — manual 만 | - | macOS dock 클릭 |
|
||||
| F12 진단 UI | mismatch 시 ⚠️ 렌더 단위 | - | F12 시나리오 재현 |
|
||||
|
||||
**목표**: 단위 426 → 약 450 (+24), e2e 1 유지 또는 +1.
|
||||
|
||||
---
|
||||
|
||||
## 11. Risk / Known unknowns
|
||||
|
||||
| Risk | 발생 시 대응 |
|
||||
|---|---|
|
||||
| linux-x64 prebuild 부재 → node-gyp 빌드 실패 | Docker `electronuserland/builder` fallback. 그래도 실패 시 v0.2.7 scope 조정: AppImage 만, deb 는 v0.2.8. [2026-05-07 검증: ✅ prebuild 존재 — electron-v145 (v41.3.0 ABI) 다운로드 성공, better_sqlite3.node 파일 생성] |
|
||||
| ELECTRON_RUN_AS_NODE 함정 (메모) 가 Linux 환경에서 재현 | smoke test launch env 에서 strip — 기존 e2e 의 strip 패턴 그대로 |
|
||||
| AppImage 가 모던 GNOME 에서 트레이 표시 안 됨 | 의도적 — 그래서 dock/launcher → inbox → 설정 페이지 흐름이 안전망. F14 fix 가 이 흐름의 핵심. |
|
||||
| 설정 페이지 라우팅이 inbox 의 keyboard shortcut / hotkey 와 충돌 | view='settings' 시 inbox-only shortcut 비활성. zustand state 분기. |
|
||||
| 자동 실행 진단 패널이 Mac/Linux 에선 의미 없는 정보 노출 | 플랫폼 분기 — Mac/Linux 는 registry 행 숨김 + executableWillLaunchAtLogin 만 표시 |
|
||||
| 트레이 callback 8개 제거 시 import 그래프에서 dead code 잔존 | 제거 후 typecheck + grep 으로 검증 |
|
||||
|
||||
---
|
||||
|
||||
### v0.2.7 Linux 빌드 1차 시도 결과 (2026-05-07, Windows 호스트)
|
||||
|
||||
`npm run dist:linux` 실행 — Windows 11 호스트.
|
||||
|
||||
**진행 단계:**
|
||||
|
||||
- ✅ predist:linux (electron rebuild + electron-vite build) — 성공
|
||||
- ✅ electron-builder linux x64 패키징 prep — 성공
|
||||
- ✅ electron-v41.3.0-linux-x64.zip 다운로드 (117 MB)
|
||||
- ✅ `dist/linux-unpacked/` 스테이징 생성 — 322 MB (electron + native modules + app)
|
||||
- ✅ appimage-12.0.1.7z 다운로드 (mksquashfs 등 Linux 유저랜드 도구 캐시)
|
||||
- ⚠️ **AppImage 패키징 실패** — `mksquashfs` 실행 불가
|
||||
- ⏭️ **deb 패키징은 시도조차 못함** (AppImage 실패로 빌드 중단)
|
||||
|
||||
**핵심 에러:**
|
||||
|
||||
```text
|
||||
⨯ cannot execute cause=exec: "C:\Users\...\electron-builder\Cache\appimage\appimage-12.0.1\linux-x64\mksquashfs": file does not exist
|
||||
⨯ failed to build AppImage error=...app-builder.exe process failed ERR_ELECTRON_BUILDER_CANNOT_EXECUTE
|
||||
Exit code: 2
|
||||
```
|
||||
|
||||
**진단:** `mksquashfs` 파일은 캐시에 존재하나 (270 KB Linux ELF 바이너리), Windows 가 ELF 를 실행 불가 → electron-builder 가 "file does not exist" 로 보고. AppImage cross-build from Windows 는 **근본적으로 불가능** (WSL/Docker/Linux/Mac 호스트 필요).
|
||||
|
||||
**결론:**
|
||||
|
||||
- AppImage: ⚠️ 실패 (Windows 호스트는 mksquashfs ELF 실행 불가 — 환경 제약)
|
||||
- deb: ⚠️ 미시도 (`dpkg-deb` + `fakeroot` 부재 추정. AppImage 실패로 도달 못함)
|
||||
|
||||
**Fallback 결정:** Mac (사용자 업무 호스트) 또는 Linux/WSL/Docker 핸드오프 필수. Windows 단독으로는 v0.2.7 Linux 산출물 생성 불가. plan Task 3 은 "시도 + 결과 기록" 이 핵심이었고, **macOS 후속 시도가 본 빌드** — Windows 시도는 환경 한계 확인용.
|
||||
|
||||
**권장 후속:**
|
||||
|
||||
1. 사용자 macOS 업무 호스트에서 동일 명령 (`npm run dist:linux`) 재시도. brew 로 `dpkg` + `fakeroot` 사전 설치 (`brew install dpkg`). AppImage 는 macOS 에서 정상 cross-build 가능 (mksquashfs Mach-O 바이너리 caching).
|
||||
2. macOS 에서도 deb 가 실패할 경우 — v0.2.7 scope 를 **AppImage only** 로 축소, deb 는 v0.2.8 또는 Docker `electronuserland/builder` 환경으로 이동. package.json `linux.target` 에서 deb 제거하거나 별도 task 로 분리.
|
||||
3. 향후 자동화: GitHub Actions / Gitea Actions 에서 ubuntu-latest runner 로 Linux build 자동화 (현 수동 cross-build 환경 의존성 제거).
|
||||
|
||||
---
|
||||
|
||||
## 12. v0.2.7 후
|
||||
|
||||
**잔여 backlog (24건)** — `docs/superpowers/v024-backlog.md`:
|
||||
|
||||
- v0.2.6 final reviewer minor cleanup 6건 — kstDate 의미 정정 / NoteRepository.test.ts as any / store.ts trashCount race 등
|
||||
- telemetry data-dependent 14건 — VOCAB_TOP_N 튜닝, recallBanner 임계값 등 — v0.2.8 후보 (v0.2.7 dogfood soak ≥1주 후 telemetry export 누적)
|
||||
- v0.2.7 본 cut 안 신규 발견 — F12 root cause 가 진단 데이터 누적 후 결정될 가능성
|
||||
|
||||
**v0.2.8 트리거**: v0.2.7 release 후 dogfood ≥1주 soak + telemetry export + F12 진단 데이터 → v0.2.8 brainstorm.
|
||||
@@ -1,9 +1,11 @@
|
||||
# Inkling — Dogfooding 전략
|
||||
|
||||
**작성일:** 2026-04-25
|
||||
**대상:** 김태현 (저자 본인) — 슬라이스 v0.4 dogfood 단계
|
||||
**스펙 의존:** `2026-04-24-inkling-vertical-slice-design.md` v0.4 §1.3 (종료 조건)
|
||||
**최종 갱신:** 2026-05-05 (v0.2.6 release 후 — environment step 갱신, 현재 단계 표기)
|
||||
**대상:** 김태현 (저자 본인) — 슬라이스 v0.4 → v0.2.6 dogfood 진행 중
|
||||
**스펙 의존:** `2026-04-24-inkling-vertical-slice-design.md` v0.4 §1.3 (종료 조건) + `2026-05-01-v023-feedback-roadmap-design.md` (v0.2.3 7항목 cut)
|
||||
**전략 의존:** `strategy.md` §1·§2·§5 (행동 정의, Capture→Clarify→Capitalize, 회복 친화 스트릭)
|
||||
**현재 binary:** v0.2.6 (`Inkling Setup 0.2.6.exe` — 2026-05-05 release)
|
||||
|
||||
---
|
||||
|
||||
@@ -27,14 +29,24 @@ dogfood 첫 날 시작 전, 환경을 한 번에 정렬한다.
|
||||
|
||||
### 1.1 환경
|
||||
|
||||
- [ ] `.nvmrc` 의 Node 버전 (24.15.0) 활성화
|
||||
- [ ] `INKLING_OLLAMA_ENDPOINT` 가 LAN Ollama (`http://192.168.0.47:11434`) 를 가리킴
|
||||
- [ ] LAN Ollama 에 `gemma4:e4b` 가 pull 된 상태 확인 (`curl http://192.168.0.47:11434/api/tags`)
|
||||
- [ ] `npm run build` 후 `npm start` 로 정식 실행 (dev 모드 아님 — dogfood 는 프로덕션 빌드)
|
||||
- [ ] 윈도우 트레이에 Inkling 아이콘 떠 있음
|
||||
**v0.2.6 release 기준 (2026-05-05 갱신)**:
|
||||
|
||||
- [ ] **설치**: Gitea release 페이지 (`https://gitea.altair823.xyz/altair823-org/inkling/releases/tag/v0.2.6`) 에서 `Inkling-Setup-0.2.6.exe` 다운로드 + 설치
|
||||
- 또는 source 빌드: `npm run dist:win` (Windows) / `npm run dist:mac` (Mac arm64)
|
||||
- [ ] **Ollama 설정** (v0.2.3.1 PR #21 부터 in-app 가능):
|
||||
- 트레이 메뉴 → "Ollama 설정..." → endpoint + model 직접 입력
|
||||
- 또는 env var fallback: `INKLING_OLLAMA_ENDPOINT=http://192.168.0.47:11434` (LAN 서버)
|
||||
- 또는 default: `http://localhost:11434` (로컬 ollama serve 시)
|
||||
- **Windows 11434 reserved 머신 (Hyper-V/WSL2 사용 시)**: `OLLAMA_HOST=127.0.0.1:11942` setx + Inkling 설정 endpoint 도 11942 (자세한 내용 F8)
|
||||
- [ ] LAN Ollama 에 `gemma4:e4b` 가 pull 된 상태 확인 (`curl <endpoint>/api/tags`)
|
||||
- [ ] 윈도우 트레이에 Inkling 아이콘 떠 있음 (단일 instance — v0.2.5 PR #23 hotfix 로 multi-spawn 차단)
|
||||
- [ ] `Ctrl+Shift+J` 가 다른 앱(Chrome, Edge DevTools 등)에 충돌 없이 잡힘
|
||||
- [ ] OS 알림 권한 허용 — 첫 토스트 후 시스템 트레이에서 확인
|
||||
- [ ] `%APPDATA%\Inkling\default\inkling.db` 가 새로 생성됨 (이전 dogfood 데이터 분리하려면 이 파일을 백업·삭제)
|
||||
- [ ] **데이터 위치 확인** (v0.2.4 PR #22 트레이 "Inkling 정보..." → "데이터 위치 열기" 로 즉시 확인):
|
||||
- Windows: `%APPDATA%\Inkling\Inkling\profiles\default\inkling.sqlite`
|
||||
- macOS: `~/Library/Application Support/Inkling/Inkling/profiles/default/inkling.sqlite`
|
||||
- 이전 dogfood 데이터 분리하려면 이 디렉터리 백업·삭제
|
||||
- [ ] **autostart 확인** (v0.2.6 진단 fallback 적용 중, F12 dogfood verify 영역): 트레이 메뉴 "윈도우 시작 시 자동 실행" 체크 → 종료 → 재실행 → 체크박스 유지 여부 확인 (`autostart.state` 로그 같이 확인 = `<userData>/Inkling/logs/main-YYYY-MM-DD.log`)
|
||||
|
||||
### 1.2 dogfood 로그 파일 준비
|
||||
|
||||
|
||||
@@ -1,23 +1,64 @@
|
||||
# v0.2.4 Backlog
|
||||
# v0.2.x Backlog
|
||||
|
||||
> v0.2.3 cut (7항목 / PR #13~#19) 동안 final reviewer + PR review round 1 에서 발견된 minor / nit 중 의도적으로 deferred 한 항목 누적. v0.2.3 dogfood soak 후 신규 피드백 + 본 리스트 일괄 triage → v0.2.4 cut 결정.
|
||||
> 누적 backlog. v0.2.3 cut (7항목 / PR #13~#19) 시점부터 PR review deferred + dogfood 발견 모두 합산. **파일명은 historic** (`v024-backlog.md`) — v0.2.4 ~ v0.2.6 cut 후에도 이어 사용. **v0.2.7 brainstorm 시** 신규 피드백 + 잔여 일괄 triage.
|
||||
|
||||
**누적 시작일:** 2026-05-01 (#7 telemetry skeleton 머지 시점)
|
||||
**최종 갱신:** 2026-05-05 (v0.2.4 patch cut — backlog 5건 처리)
|
||||
**총 항목 수:** 45 (잔여 39 = 45 − [#1 stale + #2/#6/#13/#44/#45 본 cut 처리 5건] 단 #45 는 별도 cut, 아래 표 참조)
|
||||
**최종 갱신:** 2026-05-07 (v0.2.7 cross-platform cut — #45 자동실행 deeper fix)
|
||||
**총 항목 수:** 46 (#1 stale 포함)
|
||||
**잔여:** 23건 (=46 − 처리 22 − stale 1)
|
||||
|
||||
## 처리 이력
|
||||
## 처리 이력 / 진행 흐름
|
||||
|
||||
| 항목 | 상태 | Cut |
|
||||
|---|---|---|
|
||||
| #1 (`now()` 2번 호출) | 이미 fix (PR #13 round 1 — backlog stale) | - |
|
||||
| #2 (`DAY_MS` magic) | ✅ 처리 | v0.2.4 patch (commit `ef5d3da`) |
|
||||
| #6 (`media.gc.run()` `.catch`) | ✅ 처리 | v0.2.4 patch (commit `ef5d3da`) |
|
||||
| #13 (NoteCard `onDeleted` dead-code) | ✅ 처리 | v0.2.4 patch (commit `c87c248`) |
|
||||
| #44 (버전 정보 surface) | ✅ 처리 (트레이 "Inkling 정보..." + native dialog) | v0.2.4 patch (commit `d3dfe1e`) |
|
||||
| #45 (자동실행 풀림 버그) | 별도 cut 예정 (Windows registry 디버깅) | TBD |
|
||||
| #1 (`now()` 2번 호출) | ✅ 이미 fix (PR #13 round 1 — backlog stale) | - |
|
||||
| #2 (`DAY_MS` magic) | ✅ 처리 | v0.2.4 (commit `ef5d3da`) |
|
||||
| #6 (`media.gc.run()` `.catch`) | ✅ 처리 | v0.2.4 (commit `ef5d3da`) |
|
||||
| #13 (NoteCard `onDeleted` dead-code) | ✅ 처리 | v0.2.4 (commit `c87c248`) |
|
||||
| #44 (버전 정보 surface) | ✅ 처리 (트레이 "Inkling 정보..." + native dialog) | v0.2.4 (commit `d3dfe1e`) |
|
||||
| **out-of-backlog**: multi-instance bug (single-instance lock) | ✅ critical hotfix | v0.2.5 (PR #23, `7187aea`) |
|
||||
| #10 (restoreNote + pending_jobs) | ✅ 처리 (repo 메서드 + CaptureService production path) | v0.2.6 (commit `df27a96` + `a991008`) |
|
||||
| #12 (trashCount cap) | ✅ 이미 fix (v0.2.3 #4) — tests +2 추가 | v0.2.6 (commit `e2c53a2`) |
|
||||
| #45 (자동실행 풀림 버그) | ✅ 처리 (진단 노출 + 재등록 버튼) | v0.2.7 (commit `8a8652e` + `8368286`) |
|
||||
| #46 (hidden-start race) | ✅ 처리 (`additionalData` + handler hidden flag) | v0.2.6 (commit `e485b77`) |
|
||||
| #3+#19+#34 (KST helper 통합) | ✅ 처리 → `src/shared/util/kstDate.ts` (4 callsite migrate) | v0.2.6 (commit `3cfa60b`) |
|
||||
| #5 (AiFailedReason union) | ✅ 처리 (zod z.infer 단일 export) | v0.2.6 (commit `a2c17a8`) |
|
||||
| #21 (hasNoteId predicate) | ✅ 처리 (NO_NOTE_ID_KINDS Set + type predicate) | v0.2.6 (commit `05c45c1`) |
|
||||
| #22 (hydrate `as any[]`) | ✅ 처리 (`as Record<string, unknown>[]` 통일) | v0.2.6 (commit `983306e`) |
|
||||
| #8 (stats exhaustiveness) | ✅ 처리 (`else { _: never = ev }`) | v0.2.6 (commit `9230ebf`) |
|
||||
| #4+#23+#26+#27 (TrayCallbacks 객체화) | ✅ 처리 (1-arg + `Partial<TrayState>`) | v0.2.6 (commit `476a519`) |
|
||||
| #24+#41 (Banner shared component) | ✅ 처리 (`Banner severity=...` 4 callsite) | v0.2.6 (commit `0447b69`) |
|
||||
| #15 (IPC channel rename) | ✅ 처리 (`inbox:delete` → `inbox:trash`) | v0.2.6 (commit `8b2920f`) |
|
||||
| #29 (VOCAB_TOP_N const) | ✅ 처리 (튜닝 자체는 telemetry 후) | v0.2.6 (commit `8b2920f`) |
|
||||
| #42 (Modal URL pre-check) | ✅ 처리 (zod safeParse) | v0.2.6 (commit `8b2920f`) |
|
||||
| #9 (휴지통 회수율 ratio 코멘트) | ✅ 처리 (1줄 코멘트) | v0.2.6 (commit `8b2920f`) |
|
||||
|
||||
**잔여 39건.** v0.2.5 brainstorm 시 신규 dogfood 피드백 + 잔여 39건 일괄 triage.
|
||||
### v0.2.6 PR #24 round 1 발견 (Critical fix)
|
||||
|
||||
| 항목 | 상태 | Cut |
|
||||
|---|---|---|
|
||||
| **B1 production path** (CaptureService.restoreNote 가 옛 `repo.restore` 호출) | ✅ Critical fix (commit `a991008`) | v0.2.6 round 1 |
|
||||
|
||||
### v0.2.6 final reviewer + round 1 minors (deferred)
|
||||
|
||||
| 항목 | 상태 |
|
||||
|---|---|
|
||||
| NoteRepository.countToday inline KST_OFFSET_MS | v0.2.7 cleanup (C1 spec 외) |
|
||||
| BackupService / ContinuityService inline KST_OFFSET_MS | v0.2.7 cleanup |
|
||||
| NoteRepository.test.ts:125 `as any` | v0.2.7 (C6 spec 외) |
|
||||
| OllamaSettingsModal `#fce4e4` inline (C7 spec 5번째) | modal 컨텍스트라 보류 |
|
||||
| `kstDate(ts)` semantic naming (telemetryStats) | v0.2.7 |
|
||||
| store.ts:177 trashCount race on `trashExpiredBatch` | pre-existing, v0.2.7 |
|
||||
| ExpiryBanner useEffect 24h+ closure | edge case, defer |
|
||||
|
||||
**잔여 24건** (= 46 − 처리 21 − stale 1). v0.2.7 brainstorm 시 신규 dogfood 피드백 + #45 deeper fix + 위 deferred 항목 일괄 triage.
|
||||
|
||||
## 명명 노트
|
||||
|
||||
- v0.2.3.1 / v0.2.4 / v0.2.5 는 **dogfood unblock patch** (semver bump 강제 / hotfix)
|
||||
- v0.2.6 = 첫 정식 cut (16 backlog 항목 처리)
|
||||
- v0.2.7 = 다음 정식 feature cut (telemetry data-dependent 14건 + 신규 피드백 + 잔여 deferred)
|
||||
- 본 backlog 파일은 v0.2.7 cut 시점에 prune + rename 검토 (`v027-backlog.md` 또는 stable 한 `feature-backlog.md`)
|
||||
|
||||
## Defer 사유 카테고리
|
||||
|
||||
@@ -29,12 +70,12 @@
|
||||
|
||||
## How to apply
|
||||
|
||||
v0.2.4 brainstorm 시 본 리스트를 1차 backlog 로 사용. 항목별로:
|
||||
v0.2.6 brainstorm 시 본 리스트를 1차 backlog 로 사용. 항목별로:
|
||||
|
||||
- (a) 그대로 cleanup
|
||||
- (b) #4~#6 영향 받아 변형
|
||||
- (c) defer-further 결정
|
||||
- (d) drop (만에 하나 outdated)
|
||||
- (d) drop (만에 하나 outdated 또는 v0.2.4/v0.2.5 patch 가 우회 처리)
|
||||
|
||||
## v0.2.3 #7 Telemetry skeleton 누적 (2026-05-01)
|
||||
|
||||
@@ -147,11 +188,20 @@ v0.2.4 brainstorm 시 본 리스트를 1차 backlog 로 사용. 항목별로:
|
||||
- (b) Electron `setLoginItemSettings` Windows 구현 의 path canonicalization 이슈
|
||||
- (c) 우리 `args: ['--hidden']` 와 actual launch 시 args 비교 mismatch
|
||||
- 영향: dogfood UX 핵심 마찰 — autostart 가 핸드오프 시 매번 수동 재설정 필요. 자동 실행 의도 자체가 dogfood "잊지 않고 매일 사용" 목적인데 깨짐.
|
||||
- v0.2.4 에서 우선순위 높음. 진단 절차: (1) `app.getLoginItemSettings({ args: ['--hidden'] })` 형태로 args 전달해 비교 정확도 올리기, (2) registry 직접 inspect (`HKCU\Software\Microsoft\Windows\CurrentVersion\Run\inkling`) 로 path/args 확인, (3) executable path canonicalization (electron 이 short path 변환 적용 여부).
|
||||
- v0.2.6 에서 우선순위 높음. 진단 절차: (1) `app.getLoginItemSettings({ args: ['--hidden'] })` 형태로 args 전달해 비교 정확도 올리기, (2) registry 직접 inspect (`HKCU\Software\Microsoft\Windows\CurrentVersion\Run\inkling`) 로 path/args 확인, (3) executable path canonicalization (electron 이 short path 변환 적용 여부).
|
||||
|
||||
## v0.2.5 critical hotfix 누적 (2026-05-05)
|
||||
|
||||
> v0.2.5 single-instance lock hotfix (PR #23) 의 reviewer deferred 항목.
|
||||
|
||||
46. **Hidden-start race (NSIS installer 자동 실행 + 사용자 클릭 충돌)** — NSIS installer 가 설치 직후 사용자가 시작메뉴 / 데스크톱 아이콘 클릭 (`inkling.exe`) + autostart entry (`inkling.exe --hidden`) 을 짧은 간격에 둘 다 시도 시 — 첫 lock 보유자에 따라 visible 여부 race. 본 cut 의 `second-instance` handler 는 무조건 inbox 창 띄움 (사용자 클릭 = 보고 싶다는 강한 시그널 가정). 매우 드문 시나리오 + lock 자체는 정상 동작 (한 쪽만 살아남음).
|
||||
- 영향: drm-edge 케이스만, 실 사용 거의 X
|
||||
- v0.2.6 에서: `app.requestSingleInstanceLock(additionalData)` 의 `additionalData: { hidden: startedHidden }` 전달 → `second-instance(event, argv, cwd, additionalData)` 에서 두 번째 호출이 hidden 이면 창 안 띄우는 정책. 첫 instance 가 자기 자신의 hidden 상태와 비교해 visible 결정.
|
||||
- PR #23 round 1 reviewer Important — acknowledge only, defer to v0.2.6.
|
||||
|
||||
## post-cut next-step (status, not backlog)
|
||||
|
||||
38. **v0.2.3 cut 7/7 완료 → binary 빌드 단계** — slice §7 strict-pin patch 증분으로 v0.2.3 binary 빌드 + dogfood 핸드오프. ≥1주 soak 후 telemetry export 분석으로 v0.2.4 brainstorm 트리거. (✓ 2026-05-02 빌드 완료, hotfix #20 + publish:null 포함, release 재생성 완료)
|
||||
38. **빌드 / release 흐름 (status)** — v0.2.3 cut 7/7 (PR #13~#19) → binary v0.2.3 release → 11434 포트 reserved 발견 → v0.2.3.1 attempt (PR #21) → semver 거부 → v0.2.4 (PR #22, backlog 5건 + Ollama 설정 UI) → release → multi-instance bug 발견 → **v0.2.5 critical hotfix** (PR #23, single-instance lock) → release ✅ (2026-05-05). 다음: dogfood ≥1주 soak → telemetry export + 신규 피드백 → **v0.2.6 brainstorm 트리거** (잔여 backlog 40건 일괄 triage).
|
||||
|
||||
## v0.2.3 cut 후 final reviewer 가 칭찬한 부분
|
||||
|
||||
|
||||
761
package-lock.json
generated
761
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "inkling",
|
||||
"version": "0.2.4",
|
||||
"version": "0.2.7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "inkling",
|
||||
"version": "0.2.4",
|
||||
"version": "0.2.7",
|
||||
"dependencies": {
|
||||
"better-sqlite3": "12.9.0",
|
||||
"electron-log": "5.2.0",
|
||||
@@ -18,6 +18,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.59.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/better-sqlite3": "7.6.11",
|
||||
"@types/node": "24.0.0",
|
||||
"@types/react": "19.0.0",
|
||||
@@ -26,12 +28,71 @@
|
||||
"electron": "41.3.0",
|
||||
"electron-builder": "26.8.1",
|
||||
"electron-vite": "5.0.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"typescript": "6.0.3",
|
||||
"undici": "8.1.0",
|
||||
"vite": "7.3.2",
|
||||
"vitest": "4.1.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@adobe/css-tools": {
|
||||
"version": "4.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz",
|
||||
"integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color": {
|
||||
"version": "5.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
|
||||
"integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/generational-cache": "^1.0.1",
|
||||
"@csstools/css-calc": "^3.2.0",
|
||||
"@csstools/css-color-parser": "^4.1.0",
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/dom-selector": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
|
||||
"integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/generational-cache": "^1.0.1",
|
||||
"@asamuzakjp/nwsapi": "^2.3.9",
|
||||
"bidi-js": "^1.0.3",
|
||||
"css-tree": "^3.2.1",
|
||||
"is-potential-custom-element-name": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/generational-cache": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
|
||||
"integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/nwsapi": {
|
||||
"version": "2.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
|
||||
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||
@@ -282,6 +343,16 @@
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
|
||||
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
||||
@@ -330,6 +401,159 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@bramus/specificity": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
|
||||
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"css-tree": "^3.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"specificity": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/color-helpers": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
|
||||
"integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-calc": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz",
|
||||
"integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-color-parser": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz",
|
||||
"integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@csstools/color-helpers": "^6.0.2",
|
||||
"@csstools/css-calc": "^3.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-parser-algorithms": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
|
||||
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-syntax-patches-for-csstree": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz",
|
||||
"integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT-0",
|
||||
"peerDependencies": {
|
||||
"css-tree": "^3.2.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"css-tree": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-tokenizer": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
|
||||
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@develar/schema-utils": {
|
||||
"version": "2.6.5",
|
||||
"resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz",
|
||||
@@ -1245,6 +1469,24 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@exodus/bytes": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz",
|
||||
"integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@noble/hashes": "^1.8.0 || ^2.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@noble/hashes": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/fs-minipass": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
|
||||
@@ -1831,6 +2073,90 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom": {
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.10.4",
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@types/aria-query": "^5.0.1",
|
||||
"aria-query": "5.3.0",
|
||||
"dom-accessibility-api": "^0.5.9",
|
||||
"lz-string": "^1.5.0",
|
||||
"picocolors": "1.1.1",
|
||||
"pretty-format": "^27.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/jest-dom": {
|
||||
"version": "6.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
|
||||
"integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@adobe/css-tools": "^4.4.0",
|
||||
"aria-query": "^5.0.0",
|
||||
"css.escape": "^1.5.1",
|
||||
"dom-accessibility-api": "^0.6.3",
|
||||
"picocolors": "^1.1.1",
|
||||
"redent": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14",
|
||||
"npm": ">=6",
|
||||
"yarn": ">=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
|
||||
"integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@testing-library/react": {
|
||||
"version": "16.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
|
||||
"integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@testing-library/dom": "^10.0.0",
|
||||
"@types/react": "^18.0.0 || ^19.0.0",
|
||||
"@types/react-dom": "^18.0.0 || ^19.0.0",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@types/aria-query": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
@@ -2443,6 +2769,16 @@
|
||||
"dev": true,
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/aria-query": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
|
||||
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"dequal": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/assert-plus": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz",
|
||||
@@ -2566,6 +2902,16 @@
|
||||
"node": "20.x || 22.x || 23.x || 24.x || 25.x"
|
||||
}
|
||||
},
|
||||
"node_modules/bidi-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
||||
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"require-from-string": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/bindings": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
|
||||
@@ -3069,6 +3415,27 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/css-tree": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
|
||||
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mdn-data": "2.27.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/css.escape": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
|
||||
"integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
@@ -3076,6 +3443,20 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
||||
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-mimetype": "^5.0.0",
|
||||
"whatwg-url": "^16.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -3094,6 +3475,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/decompress-response": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||
@@ -3188,6 +3576,16 @@
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dequal": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
@@ -3329,6 +3727,14 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-accessibility-api": {
|
||||
"version": "0.5.16",
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "16.6.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||
@@ -3657,6 +4063,19 @@
|
||||
"once": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
|
||||
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/env-paths": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
|
||||
@@ -4360,6 +4779,19 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/html-encoding-sniffer": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
|
||||
"integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@exodus/bytes": "^1.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/http-cache-semantics": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
|
||||
@@ -4460,6 +4892,16 @@
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/indent-string": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
|
||||
"integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/inflight": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||
@@ -4494,6 +4936,13 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-potential-custom-element-name": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
||||
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/isbinaryfile": {
|
||||
"version": "5.0.7",
|
||||
"resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz",
|
||||
@@ -4565,6 +5014,67 @@
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
"version": "29.1.1",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
|
||||
"integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/css-color": "^5.1.11",
|
||||
"@asamuzakjp/dom-selector": "^7.1.1",
|
||||
"@bramus/specificity": "^2.4.2",
|
||||
"@csstools/css-syntax-patches-for-csstree": "^1.1.3",
|
||||
"@exodus/bytes": "^1.15.0",
|
||||
"css-tree": "^3.2.1",
|
||||
"data-urls": "^7.0.0",
|
||||
"decimal.js": "^10.6.0",
|
||||
"html-encoding-sniffer": "^6.0.0",
|
||||
"is-potential-custom-element-name": "^1.0.1",
|
||||
"lru-cache": "^11.3.5",
|
||||
"parse5": "^8.0.1",
|
||||
"saxes": "^6.0.0",
|
||||
"symbol-tree": "^3.2.4",
|
||||
"tough-cookie": "^6.0.1",
|
||||
"undici": "^7.25.0",
|
||||
"w3c-xmlserializer": "^5.0.0",
|
||||
"webidl-conversions": "^8.0.1",
|
||||
"whatwg-mimetype": "^5.0.0",
|
||||
"whatwg-url": "^16.0.1",
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"canvas": "^3.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"canvas": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom/node_modules/lru-cache": {
|
||||
"version": "11.3.6",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz",
|
||||
"integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom/node_modules/undici": {
|
||||
"version": "7.25.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
|
||||
"integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jsesc": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
||||
@@ -4667,6 +5177,17 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lz-string": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
@@ -4701,6 +5222,13 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/mdn-data": {
|
||||
"version": "2.27.1",
|
||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
|
||||
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
|
||||
"dev": true,
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/mime": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz",
|
||||
@@ -4747,6 +5275,16 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/min-indent": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
|
||||
"integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||
@@ -5069,6 +5607,19 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/parse5": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
|
||||
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"entities": "^8.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/path-is-absolute": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||
@@ -5271,6 +5822,36 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-format": {
|
||||
"version": "27.5.1",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
|
||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1",
|
||||
"ansi-styles": "^5.0.0",
|
||||
"react-is": "^17.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-format/node_modules/ansi-styles": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/proc-log": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz",
|
||||
@@ -5386,6 +5967,14 @@
|
||||
"react": "^19.2.5"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.18.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
||||
@@ -5423,6 +6012,20 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/redent": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
||||
"integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"indent-string": "^4.0.0",
|
||||
"strip-indent": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
@@ -5433,6 +6036,16 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/resedit": {
|
||||
"version": "1.7.2",
|
||||
"resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz",
|
||||
@@ -5607,6 +6220,19 @@
|
||||
"node": ">=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/saxes": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
||||
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"xmlchars": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=v12.22.7"
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||
@@ -5884,6 +6510,19 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-indent": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
|
||||
"integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"min-indent": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-json-comments": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
|
||||
@@ -5919,6 +6558,13 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/symbol-tree": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tar": {
|
||||
"version": "7.5.13",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz",
|
||||
@@ -6112,6 +6758,26 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts": {
|
||||
"version": "7.0.30",
|
||||
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.30.tgz",
|
||||
"integrity": "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tldts-core": "^7.0.30"
|
||||
},
|
||||
"bin": {
|
||||
"tldts": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts-core": {
|
||||
"version": "7.0.30",
|
||||
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.30.tgz",
|
||||
"integrity": "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tmp": {
|
||||
"version": "0.2.5",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
|
||||
@@ -6132,6 +6798,32 @@
|
||||
"tmp": "^0.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tough-cookie": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",
|
||||
"integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tldts": "^7.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
|
||||
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"punycode": "^2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/truncate-utf8-bytes": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz",
|
||||
@@ -6956,6 +7648,54 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-xmlserializer": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
|
||||
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-mimetype": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
|
||||
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
|
||||
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@exodus/bytes": "^1.11.0",
|
||||
"tr46": "^6.0.0",
|
||||
"webidl-conversions": "^8.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz",
|
||||
@@ -7013,6 +7753,16 @@
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/xml-name-validator": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlbuilder": {
|
||||
"version": "15.1.1",
|
||||
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
|
||||
@@ -7023,6 +7773,13 @@
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlchars": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
|
||||
18
package.json
18
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "inkling",
|
||||
"version": "0.2.4",
|
||||
"version": "0.2.7",
|
||||
"private": true,
|
||||
"description": "Inkling — local-first 한 줄 보관 도구",
|
||||
"author": "altair823 <dlsrks0734@gmail.com>",
|
||||
@@ -28,7 +28,9 @@
|
||||
"predist:win": "npm run rebuild:electron && npm run build",
|
||||
"dist:win": "electron-builder --win --x64",
|
||||
"predist:mac": "npm run rebuild:electron && npm run build",
|
||||
"dist:mac": "electron-builder --mac --arm64"
|
||||
"dist:mac": "electron-builder --mac --arm64",
|
||||
"predist:linux": "npm run rebuild:electron && npm run build",
|
||||
"dist:linux": "electron-builder --linux --x64"
|
||||
},
|
||||
"build": {
|
||||
"appId": "xyz.altair823.inkling",
|
||||
@@ -59,6 +61,15 @@
|
||||
],
|
||||
"category": "public.app-category.productivity",
|
||||
"identity": null
|
||||
},
|
||||
"linux": {
|
||||
"target": [
|
||||
{ "target": "AppImage", "arch": ["x64"] },
|
||||
{ "target": "deb", "arch": ["x64"] }
|
||||
],
|
||||
"category": "Utility",
|
||||
"synopsis": "로컬 메모 캡처 + AI 태그",
|
||||
"description": "Inkling — 잠깐 스친 생각을 잡아두는 로컬-우선 메모 도구."
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -72,6 +83,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.59.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/better-sqlite3": "7.6.11",
|
||||
"@types/node": "24.0.0",
|
||||
"@types/react": "19.0.0",
|
||||
@@ -80,6 +93,7 @@
|
||||
"electron": "41.3.0",
|
||||
"electron-builder": "26.8.1",
|
||||
"electron-vite": "5.0.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"typescript": "6.0.3",
|
||||
"undici": "8.1.0",
|
||||
"vite": "7.3.2",
|
||||
|
||||
@@ -1,22 +1,15 @@
|
||||
import type { NoteRepository } from '../repository/NoteRepository.js';
|
||||
import type { Note } from '@shared/types';
|
||||
import type { AiFailedReason } from '../services/telemetryEvents.js';
|
||||
import { ProviderHolder } from './ProviderHolder.js';
|
||||
import { parseAllCandidates } from '../services/dueDateParser.js';
|
||||
import { ZodError } from 'zod';
|
||||
import { kstTodayAsDate, kstTodayIso } from '../../shared/util/kstDate.js';
|
||||
|
||||
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
||||
// v0.2.6 #29 — backlog 의 top-N 튜닝은 dogfood telemetry 후 (현재 magic 만 추출).
|
||||
const VOCAB_TOP_N = 20;
|
||||
|
||||
function todayKstAsDate(now: Date): Date {
|
||||
// Returns a Date object whose UTC year/month/day match KST today
|
||||
const k = new Date(now.getTime() + KST_OFFSET_MS);
|
||||
return new Date(Date.UTC(k.getUTCFullYear(), k.getUTCMonth(), k.getUTCDate()));
|
||||
}
|
||||
|
||||
function todayKstAsIso(now: Date): string {
|
||||
return todayKstAsDate(now).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function classifyReason(err: unknown): 'unreachable' | 'schema' | 'timeout' | 'other' {
|
||||
function classifyReason(err: unknown): AiFailedReason {
|
||||
if (err instanceof ZodError) return 'schema';
|
||||
const msg = err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase();
|
||||
if (msg.includes('econnrefused') || msg.includes('enotfound') || msg.includes('fetch failed') || msg.includes('econnreset') || msg.includes('unreachable')) {
|
||||
@@ -31,7 +24,7 @@ function classifyReason(err: unknown): 'unreachable' | 'schema' | 'timeout' | 'o
|
||||
export interface AiTelemetryEmitter {
|
||||
emit(input:
|
||||
| { kind: 'ai_succeeded'; payload: { noteId: string; durationMs: number; attempts: number } }
|
||||
| { kind: 'ai_failed'; payload: { noteId: string; reason: 'unreachable' | 'schema' | 'timeout' | 'other'; attempts: number } }
|
||||
| { kind: 'ai_failed'; payload: { noteId: string; reason: AiFailedReason; attempts: number } }
|
||||
| { kind: 'tag_vocab_hit'; payload: { tagId: number; vocabSize: number } }
|
||||
| { kind: 'tag_vocab_miss'; payload: { vocabSize: number } }
|
||||
): Promise<void>;
|
||||
@@ -131,10 +124,10 @@ export class AiWorker {
|
||||
const note = this.repo.findById(job.noteId);
|
||||
if (!note || note.deletedAt !== null || note.aiStatus !== 'pending') return;
|
||||
const nowDate = this.now();
|
||||
const todayDate = todayKstAsDate(nowDate);
|
||||
const todayIso = todayKstAsIso(nowDate);
|
||||
const todayDate = kstTodayAsDate(nowDate);
|
||||
const todayIso = kstTodayIso(nowDate);
|
||||
const candidates = parseAllCandidates(note.rawText, todayDate);
|
||||
const vocab = this.repo.getTopUsedTags(20);
|
||||
const vocab = this.repo.getTopUsedTags(VOCAB_TOP_N);
|
||||
const res = await this.holder.get().generate({
|
||||
text: note.rawText,
|
||||
todayKst: todayIso,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import electron from 'electron';
|
||||
const { app, BrowserWindow, Notification, dialog } = electron;
|
||||
const { app, Notification, dialog } = electron;
|
||||
import '@shared/types';
|
||||
import { existsSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
@@ -19,11 +19,12 @@ import { ProviderHolder } from './ai/ProviderHolder.js';
|
||||
import { AiWorker } from './ai/AiWorker.js';
|
||||
import { registerCaptureApi } from './ipc/captureApi.js';
|
||||
import { registerInboxApi, pushNoteUpdated, pushOllamaStatus } from './ipc/inboxApi.js';
|
||||
import { registerSettingsApi, navigateInbox } from './ipc/settingsApi.js';
|
||||
import { createInboxWindow, getInboxWindow } from './windows/inboxWindow.js';
|
||||
import {
|
||||
createQuickCaptureWindow, showQuickCapture, getQuickCaptureWindow
|
||||
} from './windows/quickCaptureWindow.js';
|
||||
import { createTray, refreshTray, refreshTrayOllama, refreshTrayFailedCount } from './tray.js';
|
||||
import { createTray, refreshTray } from './tray.js';
|
||||
import { MediaGc } from './services/MediaGc.js';
|
||||
import { BackupService } from './services/BackupService.js';
|
||||
import { ExportService } from './services/ExportService.js';
|
||||
@@ -31,11 +32,36 @@ import { ImportService } from './services/ImportService.js';
|
||||
import { SyncService } from './services/SyncService.js';
|
||||
import { TelemetryService } from './services/TelemetryService.js';
|
||||
import { SettingsService } from './services/SettingsService.js';
|
||||
import { collectAutostartState } from './services/AutostartDiagnostic.js';
|
||||
import { DEFAULT_OLLAMA_ENDPOINT, DEFAULT_OLLAMA_MODEL } from '../shared/constants.js';
|
||||
|
||||
const HIDDEN_ARG = '--hidden';
|
||||
const startedHidden = process.argv.includes(HIDDEN_ARG);
|
||||
|
||||
// CRITICAL — single-instance lock + hidden-flag 전달 (v0.2.6 #46).
|
||||
// 두 번째 .exe 가 hidden 으로 spawn 됐다면 (autostart) 첫 instance 의 inbox 창
|
||||
// 띄우지 않음 — 사용자가 명시적으로 클릭한 게 아니므로.
|
||||
const additionalData = { hidden: startedHidden };
|
||||
const gotLock = app.requestSingleInstanceLock(additionalData);
|
||||
if (!gotLock) {
|
||||
app.quit();
|
||||
} else {
|
||||
app.on('second-instance', (_e, _argv, _cwd, secondData) => {
|
||||
const data = secondData as { hidden?: boolean } | undefined;
|
||||
// 두 번째가 hidden 으로 spawn (autostart 등) — UI 띄우지 않음
|
||||
if (data?.hidden === true) return;
|
||||
// 사용자가 명시적으로 .exe / 단축키 / 트레이로 띄움 → inbox 창 보이게
|
||||
const win = getInboxWindow();
|
||||
if (win) {
|
||||
if (win.isMinimized()) win.restore();
|
||||
if (!win.isVisible()) win.show();
|
||||
win.focus();
|
||||
} else {
|
||||
createInboxWindow();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
initLogger();
|
||||
logger.info('app.start', {
|
||||
@@ -59,6 +85,8 @@ app.whenReady().then(async () => {
|
||||
writeFileSync(initFlag, new Date().toISOString());
|
||||
logger.info('autostart.enabled.firstRun');
|
||||
}
|
||||
// v0.2.6 #45 진단 — startup 로그. 같은 정보가 SettingsPage 진단 패널에도 surface (collectAutostartState single source of truth).
|
||||
void collectAutostartState().then((state) => logger.info('autostart.state', { ...state }));
|
||||
}
|
||||
const db = openDb(paths.dbFile);
|
||||
const repo = new NoteRepository(db);
|
||||
@@ -88,7 +116,6 @@ app.whenReady().then(async () => {
|
||||
onUpdate: (status) => {
|
||||
logger.info('ai.health', { ...status } as Record<string, unknown>);
|
||||
pushOllamaStatus(getInboxWindow, status);
|
||||
refreshTrayOllama(status.ok);
|
||||
},
|
||||
onTelemetry: (ev) => {
|
||||
if (ev.kind === 'ollama_unreachable') {
|
||||
@@ -106,8 +133,8 @@ app.whenReady().then(async () => {
|
||||
onUpdate: (note) => {
|
||||
pushNoteUpdated(getInboxWindow, note);
|
||||
// F4-C: AI 처리 완료 = 새 캡처가 inbox 에 합류한 시점, tray 도 즉시 갱신.
|
||||
refreshTray(repo.countToday());
|
||||
refreshTrayFailedCount(repo.countFailed());
|
||||
// v0.2.7 Phase 3 — failedCount 메뉴 항목 제거됨 → todayCount 만 갱신.
|
||||
refreshTray({ todayCount: repo.countToday() });
|
||||
},
|
||||
logger,
|
||||
telemetry
|
||||
@@ -131,6 +158,8 @@ app.whenReady().then(async () => {
|
||||
repo, continuity, capture, health, intent,
|
||||
getInboxWindow, settings: settingsSvc, providerHolder
|
||||
});
|
||||
// registerSettingsApi 는 backup / exportSvc / importSvc / syncSvc / telemetry 가
|
||||
// 생성된 뒤에 호출 (Task 10) — 아래 BackupService/ExportService/... 초기화 직후로 이동.
|
||||
|
||||
const hotkeys = new HotkeyService();
|
||||
const reg = hotkeys.register({
|
||||
@@ -159,6 +188,12 @@ app.whenReady().then(async () => {
|
||||
.then((r) => logger.info('backup.daily', { ...r } as Record<string, unknown>))
|
||||
.catch((e) => logger.warn('backup.daily.failed', { reason: String(e) }));
|
||||
|
||||
// v0.2.7 Task 10 — 설정 페이지 IPC (autostart + backup/export/import/sync/telemetry).
|
||||
// backup / exportSvc / importSvc / syncSvc / telemetry 가 모두 준비된 뒤 등록.
|
||||
registerSettingsApi({
|
||||
backup, exportSvc, importSvc, syncSvc, telemetry, getInboxWindow
|
||||
});
|
||||
|
||||
let backupOnQuitDone = false;
|
||||
let trayInterval: NodeJS.Timeout | null = null;
|
||||
app.on('before-quit', (e) => {
|
||||
@@ -191,194 +226,34 @@ app.whenReady().then(async () => {
|
||||
});
|
||||
});
|
||||
|
||||
createTray(
|
||||
() => createInboxWindow(),
|
||||
() => showQuickCapture(),
|
||||
async () => {
|
||||
try {
|
||||
const r = await backup.runDaily();
|
||||
new Notification({
|
||||
title: 'Inkling',
|
||||
body: r.snapshotted
|
||||
? `백업 완료 — ${r.removed?.length ?? 0}개 정리`
|
||||
: `오늘 백업이 이미 있습니다`,
|
||||
silent: true
|
||||
}).show();
|
||||
} catch (e) {
|
||||
logger.warn('backup.manual.failed', { reason: String(e) });
|
||||
new Notification({
|
||||
title: 'Inkling',
|
||||
body: '백업을 만들지 못했습니다.',
|
||||
silent: true
|
||||
}).show();
|
||||
}
|
||||
},
|
||||
async () => {
|
||||
const win = getInboxWindow();
|
||||
const dialogOpts: Electron.OpenDialogOptions = {
|
||||
title: '내보낼 폴더 선택',
|
||||
message: '선택한 폴더에 노트를 마크다운으로 내보냅니다. 이미지가 함께 포함됩니다. raw_text 가 평문으로 보관되니 비공개 위치를 권장합니다.',
|
||||
buttonLabel: '여기에 내보내기',
|
||||
properties: ['openDirectory', 'createDirectory']
|
||||
};
|
||||
const result = win
|
||||
? await dialog.showOpenDialog(win, dialogOpts)
|
||||
: await dialog.showOpenDialog(dialogOpts);
|
||||
if (result.canceled || result.filePaths.length === 0) return;
|
||||
try {
|
||||
const r = await exportSvc.export(result.filePaths[0]!, { includeMedia: true });
|
||||
logger.info('export.done', {
|
||||
outDir: r.outDir,
|
||||
noteCount: r.noteCount,
|
||||
mediaCount: r.mediaCount,
|
||||
bytes: r.bytes
|
||||
});
|
||||
new Notification({
|
||||
title: 'Inkling',
|
||||
body: `내보내기 완료 — 노트 ${r.noteCount}개, 이미지 ${r.mediaCount}개`,
|
||||
silent: true
|
||||
}).show();
|
||||
} catch (e) {
|
||||
logger.warn('export.failed', { reason: String(e) });
|
||||
new Notification({
|
||||
title: 'Inkling',
|
||||
body: '내보내기를 완료하지 못했습니다.',
|
||||
silent: true
|
||||
}).show();
|
||||
}
|
||||
},
|
||||
async () => {
|
||||
const win = getInboxWindow();
|
||||
const dirOpts: Electron.OpenDialogOptions = {
|
||||
title: '복원할 백업 폴더 선택',
|
||||
message: 'F5 export 형식의 폴더를 선택하세요. notes/ 하위의 마크다운 파일이 적재됩니다.',
|
||||
buttonLabel: '여기서 복원',
|
||||
properties: ['openDirectory']
|
||||
};
|
||||
const dirResult = win
|
||||
? await dialog.showOpenDialog(win, dirOpts)
|
||||
: await dialog.showOpenDialog(dirOpts);
|
||||
if (dirResult.canceled || dirResult.filePaths.length === 0) return;
|
||||
const sourceDir = dirResult.filePaths[0]!;
|
||||
let plan;
|
||||
try {
|
||||
plan = await importSvc.preview(sourceDir);
|
||||
} catch (e) {
|
||||
logger.warn('import.preview.failed', { reason: String(e) });
|
||||
new Notification({
|
||||
title: 'Inkling',
|
||||
body: '백업 폴더를 읽지 못했습니다.',
|
||||
silent: true
|
||||
}).show();
|
||||
return;
|
||||
}
|
||||
const detail = `총 ${plan.total}개 노트\n · 신규 ${plan.newCount}개\n · 동일 (스킵) ${plan.unchangedCount}개\n · 충돌→새 id (${plan.forkedCount}개, raw_text 보존)\n\n이미지 ${plan.mediaCount}개 복사 예정.`;
|
||||
const confirmOpts: Electron.MessageBoxOptions = {
|
||||
type: 'question',
|
||||
buttons: ['복원', '취소'],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
title: 'Inkling 복원',
|
||||
message: '복원 미리보기',
|
||||
detail
|
||||
};
|
||||
const confirm = win
|
||||
? await dialog.showMessageBox(win, confirmOpts)
|
||||
: await dialog.showMessageBox(confirmOpts);
|
||||
if (confirm.response !== 0) return;
|
||||
try {
|
||||
const r = await importSvc.run(sourceDir);
|
||||
logger.info('import.done', {
|
||||
total: r.total,
|
||||
new: r.newCount,
|
||||
unchanged: r.unchangedCount,
|
||||
forked: r.forkedCount,
|
||||
media: r.mediaCount
|
||||
});
|
||||
new Notification({
|
||||
title: 'Inkling',
|
||||
body: `복원 완료 — 신규 ${r.newCount}개, 스킵 ${r.unchangedCount}개, 충돌 ${r.forkedCount}개`,
|
||||
silent: true
|
||||
}).show();
|
||||
} catch (e) {
|
||||
logger.warn('import.run.failed', { reason: String(e) });
|
||||
new Notification({
|
||||
title: 'Inkling',
|
||||
body: '복원을 완료하지 못했습니다.',
|
||||
silent: true
|
||||
}).show();
|
||||
}
|
||||
},
|
||||
async () => {
|
||||
// runSync — 트레이 "지금 동기화"
|
||||
try {
|
||||
const r = await syncSvc.sync();
|
||||
if (!r.ok) {
|
||||
logger.warn('sync.failed', { reason: r.reason });
|
||||
const body = r.reason === 'not_configured'
|
||||
? `${syncSvc.getSyncDir()} 에서 git init + remote 설정이 필요합니다.`
|
||||
: '동기화를 완료하지 못했습니다.';
|
||||
new Notification({ title: 'Inkling', body, silent: true }).show();
|
||||
return;
|
||||
}
|
||||
if (r.changed) {
|
||||
logger.info('sync.done', { sha: r.sha, pushed: r.pushed });
|
||||
new Notification({ title: 'Inkling', body: '동기화 완료', silent: true }).show();
|
||||
} else {
|
||||
new Notification({ title: 'Inkling', body: '변경 사항 없음', silent: true }).show();
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('sync.exception', { reason: String(e) });
|
||||
new Notification({ title: 'Inkling', body: '동기화를 완료하지 못했습니다.', silent: true }).show();
|
||||
}
|
||||
},
|
||||
/* runExportTelemetry */ async () => {
|
||||
const win = getInboxWindow();
|
||||
const dialogOpts: Electron.OpenDialogOptions = {
|
||||
title: '사용 로그를 내보낼 폴더 선택',
|
||||
message: '선택한 폴더에 events.jsonl + stats.md 가 생성됩니다. raw_text/요약/제목/태그 이름은 미포함입니다.',
|
||||
buttonLabel: '여기로 내보내기',
|
||||
properties: ['openDirectory', 'createDirectory']
|
||||
};
|
||||
const result = win
|
||||
? await dialog.showOpenDialog(win, dialogOpts)
|
||||
: await dialog.showOpenDialog(dialogOpts);
|
||||
if (result.canceled || result.filePaths.length === 0) return;
|
||||
try {
|
||||
const r = await telemetry.exportTo(result.filePaths[0]!);
|
||||
logger.info('telemetry.export', { eventCount: r.eventCount, outDir: result.filePaths[0] });
|
||||
new Notification({
|
||||
title: 'Inkling',
|
||||
body: `사용 로그 내보내기 완료 — ${r.eventCount}개 이벤트`,
|
||||
silent: true
|
||||
}).show();
|
||||
} catch (e) {
|
||||
logger.warn('telemetry.export.failed', { reason: String(e) });
|
||||
new Notification({
|
||||
title: 'Inkling',
|
||||
body: '사용 로그 내보내기를 완료하지 못했습니다.',
|
||||
silent: true
|
||||
}).show();
|
||||
}
|
||||
},
|
||||
/* runOllamaRecheck */ () => { void health.runOnce({ manual: true }); },
|
||||
/* runRetryAllFailed */ () => { void capture.retryAllFailed(); },
|
||||
/* runOpenOllamaSettings */ () => {
|
||||
const win = getInboxWindow();
|
||||
if (win) win.webContents.send('inbox:openOllamaSettings');
|
||||
}
|
||||
);
|
||||
// v0.2.7 Phase 3 (Task 16) — TrayCallbacks 슬림: 10 → 3.
|
||||
// 백업/내보내기/복원/동기화/사용 로그/Ollama 재확인/AI 재처리/Ollama 설정/정보 →
|
||||
// 모두 설정 페이지로 이전 (registerSettingsApi 의 IPC 핸들러가 본문 보유).
|
||||
createTray({
|
||||
showInbox: () => createInboxWindow(),
|
||||
showCapture: () => showQuickCapture(),
|
||||
showSettings: () => navigateInbox('settings')
|
||||
});
|
||||
|
||||
// F4-C 환경 앵커 — tray tooltip + 메뉴 첫 항목을 오늘 KST 캡처 수로 갱신.
|
||||
// 초기 1회 + 60s interval. AiWorker.onUpdate 도 별도 갱신 트리거.
|
||||
// cleanup 은 위 통합 before-quit 핸들러에서 처리.
|
||||
refreshTray(repo.countToday());
|
||||
refreshTrayFailedCount(repo.countFailed());
|
||||
// v0.2.7 Phase 3 — failedCount 메뉴 항목 제거됨 → todayCount 만 갱신.
|
||||
refreshTray({ todayCount: repo.countToday() });
|
||||
trayInterval = setInterval(() => {
|
||||
refreshTray(repo.countToday());
|
||||
refreshTray({ todayCount: repo.countToday() });
|
||||
}, 60_000);
|
||||
|
||||
// F14 (v0.2.7) — macOS dock 클릭 시 hidden inbox 창 show/focus.
|
||||
// 기존: BrowserWindow.getAllWindows().length === 0 만 검사 → quickCapture 등이
|
||||
// 떠 있으면 inbox 창이 hidden 인 채로 남았음.
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createInboxWindow();
|
||||
const win = getInboxWindow();
|
||||
if (win && !win.isDestroyed()) {
|
||||
if (!win.isVisible()) win.show();
|
||||
win.focus();
|
||||
} else {
|
||||
createInboxWindow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,7 +39,7 @@ export function registerInboxApi(deps: InboxIpcDeps): void {
|
||||
deps.repo.setDueDate(arg.noteId, arg.date);
|
||||
});
|
||||
|
||||
ipcMain.handle('inbox:delete', async (_e, noteId: string) => {
|
||||
ipcMain.handle('inbox:trash', async (_e, noteId: string) => {
|
||||
await deps.capture.deleteNote(noteId);
|
||||
});
|
||||
|
||||
|
||||
268
src/main/ipc/settingsApi.ts
Normal file
268
src/main/ipc/settingsApi.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
import electron from 'electron';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import { platform, release, EOL } from 'node:os';
|
||||
const { ipcMain, app, dialog, Notification, shell, clipboard } = electron;
|
||||
import { logger } from '../logger.js';
|
||||
import type { BackupService } from '../services/BackupService.js';
|
||||
import type { ExportService } from '../services/ExportService.js';
|
||||
import type { ImportService } from '../services/ImportService.js';
|
||||
import type { SyncService } from '../services/SyncService.js';
|
||||
import type { TelemetryService } from '../services/TelemetryService.js';
|
||||
import { collectAutostartState } from '../services/AutostartDiagnostic.js';
|
||||
import { getInboxWindow as getInboxWindowSingleton } from '../windows/inboxWindow.js';
|
||||
|
||||
/**
|
||||
* 외부 (트레이 / second-instance / 기타 main 프로세스 호출자) 에서 inbox 창에 view 전환을
|
||||
* 요청하는 진입점. 창이 숨겨져 있으면 show + focus 후 'inbox:navigate' IPC 이벤트를
|
||||
* renderer 로 전달.
|
||||
*
|
||||
* Task 13 (v0.2.7) — 트레이 "설정..." 메뉴 wiring 은 Task 16 에서 본 함수 호출.
|
||||
*/
|
||||
export function navigateInbox(view: 'inbox' | 'trash' | 'settings'): void {
|
||||
const win = getInboxWindowSingleton();
|
||||
if (win && !win.isDestroyed()) {
|
||||
if (!win.isVisible()) win.show();
|
||||
win.focus();
|
||||
win.webContents.send('inbox:navigate', view);
|
||||
}
|
||||
}
|
||||
|
||||
export interface SettingsIpcDeps {
|
||||
backup: BackupService;
|
||||
exportSvc: ExportService;
|
||||
importSvc: ImportService;
|
||||
syncSvc: SyncService;
|
||||
telemetry: TelemetryService;
|
||||
getInboxWindow: () => BrowserWindow | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.7 설정 페이지 IPC 핸들러.
|
||||
*
|
||||
* - 자동 실행 (Task 22 통일): `settings:autostart-state` (조회) / `settings:autostart-set` (변경).
|
||||
* 둘 다 `{ openAtLogin, diagnostic }` 반환 — diagnostic 은 withArgs/noArgs/execPath/registry 진단.
|
||||
* args=['--hidden'] 명시 — 자동 실행 시 백그라운드 모드로 시작 (Quick Capture only).
|
||||
*
|
||||
* - 백업/내보내기/복원/동기화/사용 로그 (Task 10): 기존 `src/main/index.ts` 트레이 callback
|
||||
* (runBackup, runExport, runImport, runSync, runExportTelemetry) 본문을 그대로 IPC 핸들러로
|
||||
* 복사. 트레이 callback 자체 제거는 Task 16 (Phase 3) — 본 task 에선 잔류 (의도적 중복).
|
||||
*/
|
||||
export function registerSettingsApi(deps?: SettingsIpcDeps): void {
|
||||
// v0.2.7 F12 deeper fix (Task 21~22) — 진단 정보 포함된 autostart 상태 조회/변경.
|
||||
// 옛 'settings:get-autostart' / 'settings:set-autostart' 채널은 본 통일에서 제거됨.
|
||||
ipcMain.handle('settings:autostart-state', async () => {
|
||||
const diag = await collectAutostartState();
|
||||
return { openAtLogin: diag.withArgs.openAtLogin, diagnostic: diag };
|
||||
});
|
||||
|
||||
ipcMain.handle('settings:autostart-set', async (_e, open: boolean) => {
|
||||
app.setLoginItemSettings({ openAtLogin: open, args: ['--hidden'] });
|
||||
const diag = await collectAutostartState();
|
||||
return { openAtLogin: diag.withArgs.openAtLogin, diagnostic: diag };
|
||||
});
|
||||
|
||||
// v0.2.7 정보 섹션 (Task 11) — 트레이 showAboutDialog 의 detail 형식 그대로 (clipboard 일관성).
|
||||
// 트레이 showAboutDialog 자체 제거는 Task 25 (Phase 6 cleanup) — 본 task 는 추가만.
|
||||
ipcMain.handle('settings:get-app-info', () => ({
|
||||
version: app.getVersion(),
|
||||
electron: process.versions.electron ?? '?',
|
||||
node: process.versions.node ?? '?',
|
||||
os: `${platform()} ${release()}`,
|
||||
profileDir: app.getPath('userData')
|
||||
}));
|
||||
|
||||
ipcMain.handle('settings:open-profile-dir', async () => {
|
||||
await shell.openPath(app.getPath('userData'));
|
||||
});
|
||||
|
||||
ipcMain.handle('settings:copy-app-info', () => {
|
||||
const v = app.getVersion();
|
||||
const detail = [
|
||||
`버전: ${v}`,
|
||||
`Electron: ${process.versions.electron ?? '?'}`,
|
||||
`Node: ${process.versions.node ?? '?'}`,
|
||||
`OS: ${platform()} ${release()}`,
|
||||
`데이터 위치: ${app.getPath('userData')}`
|
||||
].join(EOL);
|
||||
clipboard.writeText(`Inkling ${v}${EOL}${detail}`);
|
||||
});
|
||||
|
||||
if (!deps) return;
|
||||
const { backup, exportSvc, importSvc, syncSvc, telemetry, getInboxWindow } = deps;
|
||||
|
||||
ipcMain.handle('settings:run-backup', async () => {
|
||||
try {
|
||||
const r = await backup.runDaily();
|
||||
new Notification({
|
||||
title: 'Inkling',
|
||||
body: r.snapshotted
|
||||
? `백업 완료 — ${r.removed?.length ?? 0}개 정리`
|
||||
: `오늘 백업이 이미 있습니다`,
|
||||
silent: true
|
||||
}).show();
|
||||
} catch (e) {
|
||||
logger.warn('backup.manual.failed', { reason: String(e) });
|
||||
new Notification({
|
||||
title: 'Inkling',
|
||||
body: '백업을 만들지 못했습니다.',
|
||||
silent: true
|
||||
}).show();
|
||||
}
|
||||
return { ok: true } as const;
|
||||
});
|
||||
|
||||
ipcMain.handle('settings:run-export', async () => {
|
||||
const win = getInboxWindow();
|
||||
const dialogOpts: Electron.OpenDialogOptions = {
|
||||
title: '내보낼 폴더 선택',
|
||||
message: '선택한 폴더에 노트를 마크다운으로 내보냅니다. 이미지가 함께 포함됩니다. raw_text 가 평문으로 보관되니 비공개 위치를 권장합니다.',
|
||||
buttonLabel: '여기에 내보내기',
|
||||
properties: ['openDirectory', 'createDirectory']
|
||||
};
|
||||
const result = win
|
||||
? await dialog.showOpenDialog(win, dialogOpts)
|
||||
: await dialog.showOpenDialog(dialogOpts);
|
||||
if (result.canceled || result.filePaths.length === 0) return { ok: true } as const;
|
||||
try {
|
||||
const r = await exportSvc.export(result.filePaths[0]!, { includeMedia: true });
|
||||
logger.info('export.done', {
|
||||
outDir: r.outDir,
|
||||
noteCount: r.noteCount,
|
||||
mediaCount: r.mediaCount,
|
||||
bytes: r.bytes
|
||||
});
|
||||
new Notification({
|
||||
title: 'Inkling',
|
||||
body: `내보내기 완료 — 노트 ${r.noteCount}개, 이미지 ${r.mediaCount}개`,
|
||||
silent: true
|
||||
}).show();
|
||||
} catch (e) {
|
||||
logger.warn('export.failed', { reason: String(e) });
|
||||
new Notification({
|
||||
title: 'Inkling',
|
||||
body: '내보내기를 완료하지 못했습니다.',
|
||||
silent: true
|
||||
}).show();
|
||||
}
|
||||
return { ok: true } as const;
|
||||
});
|
||||
|
||||
ipcMain.handle('settings:run-import', async () => {
|
||||
const win = getInboxWindow();
|
||||
const dirOpts: Electron.OpenDialogOptions = {
|
||||
title: '복원할 백업 폴더 선택',
|
||||
message: 'F5 export 형식의 폴더를 선택하세요. notes/ 하위의 마크다운 파일이 적재됩니다.',
|
||||
buttonLabel: '여기서 복원',
|
||||
properties: ['openDirectory']
|
||||
};
|
||||
const dirResult = win
|
||||
? await dialog.showOpenDialog(win, dirOpts)
|
||||
: await dialog.showOpenDialog(dirOpts);
|
||||
if (dirResult.canceled || dirResult.filePaths.length === 0) return { ok: true } as const;
|
||||
const sourceDir = dirResult.filePaths[0]!;
|
||||
let plan;
|
||||
try {
|
||||
plan = await importSvc.preview(sourceDir);
|
||||
} catch (e) {
|
||||
logger.warn('import.preview.failed', { reason: String(e) });
|
||||
new Notification({
|
||||
title: 'Inkling',
|
||||
body: '백업 폴더를 읽지 못했습니다.',
|
||||
silent: true
|
||||
}).show();
|
||||
return { ok: true } as const;
|
||||
}
|
||||
const detail = `총 ${plan.total}개 노트\n · 신규 ${plan.newCount}개\n · 동일 (스킵) ${plan.unchangedCount}개\n · 충돌→새 id (${plan.forkedCount}개, raw_text 보존)\n\n이미지 ${plan.mediaCount}개 복사 예정.`;
|
||||
const confirmOpts: Electron.MessageBoxOptions = {
|
||||
type: 'question',
|
||||
buttons: ['복원', '취소'],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
title: 'Inkling 복원',
|
||||
message: '복원 미리보기',
|
||||
detail
|
||||
};
|
||||
const confirm = win
|
||||
? await dialog.showMessageBox(win, confirmOpts)
|
||||
: await dialog.showMessageBox(confirmOpts);
|
||||
if (confirm.response !== 0) return { ok: true } as const;
|
||||
try {
|
||||
const r = await importSvc.run(sourceDir);
|
||||
logger.info('import.done', {
|
||||
total: r.total,
|
||||
new: r.newCount,
|
||||
unchanged: r.unchangedCount,
|
||||
forked: r.forkedCount,
|
||||
media: r.mediaCount
|
||||
});
|
||||
new Notification({
|
||||
title: 'Inkling',
|
||||
body: `복원 완료 — 신규 ${r.newCount}개, 스킵 ${r.unchangedCount}개, 충돌 ${r.forkedCount}개`,
|
||||
silent: true
|
||||
}).show();
|
||||
} catch (e) {
|
||||
logger.warn('import.run.failed', { reason: String(e) });
|
||||
new Notification({
|
||||
title: 'Inkling',
|
||||
body: '복원을 완료하지 못했습니다.',
|
||||
silent: true
|
||||
}).show();
|
||||
}
|
||||
return { ok: true } as const;
|
||||
});
|
||||
|
||||
ipcMain.handle('settings:run-sync', async () => {
|
||||
try {
|
||||
const r = await syncSvc.sync();
|
||||
if (!r.ok) {
|
||||
logger.warn('sync.failed', { reason: r.reason });
|
||||
const body = r.reason === 'not_configured'
|
||||
? `${syncSvc.getSyncDir()} 에서 git init + remote 설정이 필요합니다.`
|
||||
: '동기화를 완료하지 못했습니다.';
|
||||
new Notification({ title: 'Inkling', body, silent: true }).show();
|
||||
return { ok: true } as const;
|
||||
}
|
||||
if (r.changed) {
|
||||
logger.info('sync.done', { sha: r.sha, pushed: r.pushed });
|
||||
new Notification({ title: 'Inkling', body: '동기화 완료', silent: true }).show();
|
||||
} else {
|
||||
new Notification({ title: 'Inkling', body: '변경 사항 없음', silent: true }).show();
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('sync.exception', { reason: String(e) });
|
||||
new Notification({ title: 'Inkling', body: '동기화를 완료하지 못했습니다.', silent: true }).show();
|
||||
}
|
||||
return { ok: true } as const;
|
||||
});
|
||||
|
||||
ipcMain.handle('settings:run-export-telemetry', async () => {
|
||||
const win = getInboxWindow();
|
||||
const dialogOpts: Electron.OpenDialogOptions = {
|
||||
title: '사용 로그를 내보낼 폴더 선택',
|
||||
message: '선택한 폴더에 events.jsonl + stats.md 가 생성됩니다. raw_text/요약/제목/태그 이름은 미포함입니다.',
|
||||
buttonLabel: '여기로 내보내기',
|
||||
properties: ['openDirectory', 'createDirectory']
|
||||
};
|
||||
const result = win
|
||||
? await dialog.showOpenDialog(win, dialogOpts)
|
||||
: await dialog.showOpenDialog(dialogOpts);
|
||||
if (result.canceled || result.filePaths.length === 0) return { ok: true } as const;
|
||||
try {
|
||||
const r = await telemetry.exportTo(result.filePaths[0]!);
|
||||
logger.info('telemetry.export', { eventCount: r.eventCount, outDir: result.filePaths[0] });
|
||||
new Notification({
|
||||
title: 'Inkling',
|
||||
body: `사용 로그 내보내기 완료 — ${r.eventCount}개 이벤트`,
|
||||
silent: true
|
||||
}).show();
|
||||
} catch (e) {
|
||||
logger.warn('telemetry.export.failed', { reason: String(e) });
|
||||
new Notification({
|
||||
title: 'Inkling',
|
||||
body: '사용 로그 내보내기를 완료하지 못했습니다.',
|
||||
silent: true
|
||||
}).show();
|
||||
}
|
||||
return { ok: true } as const;
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
import { v7 as uuidv7, v4 as uuidv4 } from 'uuid';
|
||||
import type { Note, NoteMedia, NoteTag } from '@shared/types';
|
||||
import { todayInKstString } from '../util/kstDate.js';
|
||||
import { kstTodayIso } from '../../shared/util/kstDate.js';
|
||||
|
||||
export interface CreateNoteInput { rawText: string; }
|
||||
|
||||
@@ -78,7 +78,7 @@ export class NoteRepository {
|
||||
}
|
||||
|
||||
findById(id: string): Note | null {
|
||||
const row = this.db.prepare('SELECT * FROM notes WHERE id=?').get(id) as any;
|
||||
const row = this.db.prepare('SELECT * FROM notes WHERE id=?').get(id) as Record<string, unknown>;
|
||||
if (!row) return null;
|
||||
return this.hydrate(row);
|
||||
}
|
||||
@@ -92,21 +92,21 @@ export class NoteRepository {
|
||||
WHERE deleted_at IS NULL AND created_at < ?
|
||||
ORDER BY created_at DESC, id DESC LIMIT ?`
|
||||
)
|
||||
.all(opts.cursor, limit) as any[])
|
||||
.all(opts.cursor, limit) as Record<string, unknown>[])
|
||||
: (this.db
|
||||
.prepare(
|
||||
`SELECT * FROM notes
|
||||
WHERE deleted_at IS NULL
|
||||
ORDER BY created_at DESC, id DESC LIMIT ?`
|
||||
)
|
||||
.all(limit) as any[]);
|
||||
.all(limit) as Record<string, unknown>[]);
|
||||
return rows.map((r) => this.hydrate(r));
|
||||
}
|
||||
|
||||
listAll(): Note[] {
|
||||
const rows = this.db
|
||||
.prepare(`SELECT * FROM notes WHERE deleted_at IS NULL ORDER BY created_at ASC, id ASC`)
|
||||
.all() as any[];
|
||||
.all() as Record<string, unknown>[];
|
||||
return rows.map((r) => this.hydrate(r));
|
||||
}
|
||||
|
||||
@@ -410,6 +410,31 @@ export class NoteRepository {
|
||||
.run(now, id);
|
||||
}
|
||||
|
||||
restoreNote(id: string): void {
|
||||
const tx = this.db.transaction(() => {
|
||||
const before = this.db.prepare(`SELECT ai_status FROM notes WHERE id = ?`).get(id) as { ai_status: string } | undefined;
|
||||
const now = new Date().toISOString();
|
||||
this.db.prepare(`UPDATE notes SET deleted_at = NULL, updated_at = ? WHERE id = ?`).run(now, id);
|
||||
|
||||
// v0.2.6 #10 — failed 노트 restore 시 pending 으로 reset + pending_jobs 재생성
|
||||
if (before?.ai_status === 'failed') {
|
||||
this.db.prepare(
|
||||
`UPDATE notes SET ai_status='pending', ai_error=NULL, updated_at=? WHERE id=?`
|
||||
).run(now, id);
|
||||
this.db.prepare(
|
||||
`INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)`
|
||||
).run(id, now);
|
||||
} else if (before?.ai_status === 'pending') {
|
||||
// pending 인 채로 trash 됐다면 pending_jobs 도 미정상 상태일 수 있음 — 재생성 (idempotent)
|
||||
this.db.prepare(
|
||||
`INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)`
|
||||
).run(id, now);
|
||||
}
|
||||
// done 노트는 재처리 안 함 (이미 결과 있음)
|
||||
});
|
||||
tx();
|
||||
}
|
||||
|
||||
permanentDelete(id: string): void {
|
||||
this.db.prepare('DELETE FROM notes WHERE id=?').run(id);
|
||||
}
|
||||
@@ -428,7 +453,7 @@ export class NoteRepository {
|
||||
const limit = Math.max(1, Math.min(200, opts.limit));
|
||||
const rows = this.db
|
||||
.prepare(`SELECT * FROM notes WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC, id DESC LIMIT ?`)
|
||||
.all(limit) as any[];
|
||||
.all(limit) as Record<string, unknown>[];
|
||||
return rows.map((r) => this.hydrate(r));
|
||||
}
|
||||
|
||||
@@ -576,7 +601,7 @@ export class NoteRepository {
|
||||
* Caller may inject `now` for testability; defaults to `new Date()`.
|
||||
*/
|
||||
findExpiredCandidates(now: Date = new Date()): Note[] {
|
||||
const today = todayInKstString(now);
|
||||
const today = kstTodayIso(now);
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM notes
|
||||
@@ -586,18 +611,18 @@ export class NoteRepository {
|
||||
AND ai_status = 'done'
|
||||
ORDER BY created_at DESC, id DESC`
|
||||
)
|
||||
.all(today) as any[];
|
||||
.all(today) as Record<string, unknown>[];
|
||||
return rows.map((r) => this.hydrate(r));
|
||||
}
|
||||
|
||||
getAllPendingJobs(): Array<{ noteId: string; attempts: number; nextRunAt: string }> {
|
||||
const rows = this.db
|
||||
.prepare(`SELECT note_id, attempts, next_run_at FROM pending_jobs`)
|
||||
.all() as any[];
|
||||
.all() as Record<string, unknown>[];
|
||||
return rows.map((r) => ({
|
||||
noteId: r.note_id,
|
||||
attempts: r.attempts,
|
||||
nextRunAt: r.next_run_at
|
||||
noteId: r.note_id as string,
|
||||
attempts: r.attempts as number,
|
||||
nextRunAt: r.next_run_at as string
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -613,39 +638,39 @@ export class NoteRepository {
|
||||
.run(nextRunAt, lastError.slice(0, 500), noteId);
|
||||
}
|
||||
|
||||
private hydrate(row: any): Note {
|
||||
private hydrate(row: Record<string, unknown>): Note {
|
||||
const tags = this.db
|
||||
.prepare(
|
||||
`SELECT t.name, nt.source
|
||||
FROM note_tags nt JOIN tags t ON t.id = nt.tag_id
|
||||
WHERE nt.note_id = ? ORDER BY t.name`
|
||||
)
|
||||
.all(row.id) as Array<{ name: string; source: 'ai' | 'user' }>;
|
||||
.all(row.id as string) as Array<{ name: string; source: 'ai' | 'user' }>;
|
||||
const media = this.db
|
||||
.prepare(
|
||||
`SELECT id, kind, rel_path as relPath, mime, bytes FROM media WHERE note_id=?`
|
||||
)
|
||||
.all(row.id) as NoteMedia[];
|
||||
.all(row.id as string) as NoteMedia[];
|
||||
return {
|
||||
id: row.id,
|
||||
rawText: row.raw_text,
|
||||
aiTitle: row.ai_title,
|
||||
aiSummary: row.ai_summary,
|
||||
aiStatus: row.ai_status,
|
||||
aiError: row.ai_error,
|
||||
aiProvider: row.ai_provider,
|
||||
aiGeneratedAt: row.ai_generated_at,
|
||||
titleEditedByUser: row.title_edited_by_user === 1,
|
||||
summaryEditedByUser: row.summary_edited_by_user === 1,
|
||||
userIntent: row.user_intent,
|
||||
intentPromptedAt: row.intent_prompted_at,
|
||||
dueDate: row.due_date ?? null,
|
||||
dueDateEditedByUser: row.due_date_edited_by_user === 1,
|
||||
deletedAt: row.deleted_at ?? null,
|
||||
lastRecalledAt: row.last_recalled_at ?? null,
|
||||
recallDismissedAt: row.recall_dismissed_at ?? null,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
id: row.id as string,
|
||||
rawText: row.raw_text as string,
|
||||
aiTitle: row.ai_title as string | null,
|
||||
aiSummary: row.ai_summary as string | null,
|
||||
aiStatus: row.ai_status as 'pending' | 'done' | 'failed',
|
||||
aiError: row.ai_error as string | null,
|
||||
aiProvider: row.ai_provider as string | null,
|
||||
aiGeneratedAt: row.ai_generated_at as string | null,
|
||||
titleEditedByUser: (row.title_edited_by_user as number) === 1,
|
||||
summaryEditedByUser: (row.summary_edited_by_user as number) === 1,
|
||||
userIntent: row.user_intent as string | null,
|
||||
intentPromptedAt: row.intent_prompted_at as string | null,
|
||||
dueDate: (row.due_date as string | null) ?? null,
|
||||
dueDateEditedByUser: (row.due_date_edited_by_user as number) === 1,
|
||||
deletedAt: (row.deleted_at as string | null) ?? null,
|
||||
lastRecalledAt: (row.last_recalled_at as string | null) ?? null,
|
||||
recallDismissedAt: (row.recall_dismissed_at as string | null) ?? null,
|
||||
createdAt: row.created_at as string,
|
||||
updatedAt: row.updated_at as string,
|
||||
tags: tags as NoteTag[],
|
||||
media
|
||||
};
|
||||
|
||||
56
src/main/services/AutostartDiagnostic.ts
Normal file
56
src/main/services/AutostartDiagnostic.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import electron from 'electron';
|
||||
import { execFile } from 'node:child_process';
|
||||
const { app } = electron;
|
||||
|
||||
/**
|
||||
* v0.2.7 F12 deeper fix — 자동 실행 진단 정보 수집.
|
||||
*
|
||||
* Electron 의 `app.getLoginItemSettings()` 는 args 가 매칭돼야만 정확한 상태를 반환 →
|
||||
* `args: ['--hidden']` 으로 등록 vs `args: undefined` 로 조회하면 mismatch 가 발생할 수 있다.
|
||||
* 그래서 두 호출 결과를 모두 노출 (withArgs / noArgs) + Win 에서는 registry 직접 조회까지.
|
||||
*/
|
||||
export interface AutostartState {
|
||||
withArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean };
|
||||
noArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean };
|
||||
execPath: string;
|
||||
registryPath?: string;
|
||||
registryValue?: string | null;
|
||||
}
|
||||
|
||||
const WIN_REGISTRY_PATH = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run';
|
||||
const WIN_REGISTRY_KEY = 'Inkling';
|
||||
|
||||
export async function collectAutostartState(): Promise<AutostartState> {
|
||||
const w = app.getLoginItemSettings({ args: ['--hidden'] });
|
||||
const n = app.getLoginItemSettings();
|
||||
const state: AutostartState = {
|
||||
withArgs: { openAtLogin: w.openAtLogin, executableWillLaunchAtLogin: w.executableWillLaunchAtLogin },
|
||||
noArgs: { openAtLogin: n.openAtLogin, executableWillLaunchAtLogin: n.executableWillLaunchAtLogin },
|
||||
execPath: process.execPath
|
||||
};
|
||||
if (process.platform === 'win32') {
|
||||
state.registryPath = `${WIN_REGISTRY_PATH}\\${WIN_REGISTRY_KEY}`;
|
||||
state.registryValue = await readRegistrySilent();
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* `reg query` 로 HKCU\\...\\Run\\Inkling 의 값을 조회.
|
||||
* 키가 없으면 reg.exe 가 exit 1 → silent fallback (null).
|
||||
*
|
||||
* promisify(execFile) 대신 직접 Promise 로 wrapping — 테스트에서 vi.mock 이
|
||||
* `util.promisify.custom` symbol 을 보전하지 못해 stdout 이 undefined 가 되는 이슈 회피.
|
||||
*/
|
||||
function readRegistrySilent(): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
execFile('reg', ['query', WIN_REGISTRY_PATH, '/v', WIN_REGISTRY_KEY], (err, stdout) => {
|
||||
if (err) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
const m = stdout.match(/REG_SZ\s+(.+)/);
|
||||
resolve(m && m[1] ? m[1].trim() : null);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -88,9 +88,14 @@ export class CaptureService {
|
||||
|
||||
async restoreNote(noteId: string): Promise<void> {
|
||||
// 이미 active 인 노트는 telemetry emit skip — restore/trash ratio 오염 방지.
|
||||
const note = this.repo.findById(noteId);
|
||||
if (!note || note.deletedAt === null) return;
|
||||
this.repo.restore(noteId);
|
||||
const before = this.repo.findById(noteId);
|
||||
if (!before || before.deletedAt === null) return;
|
||||
// v0.2.6 #10 — production path: repo.restoreNote (ai_status reset + pending_jobs 재생성)
|
||||
this.repo.restoreNote(noteId);
|
||||
// v0.2.6 #10 — in-memory AiWorker queue 갱신: DB 갱신만으로는 다음 앱 실행 시까지 처리 X
|
||||
if (before.aiStatus === 'failed' || before.aiStatus === 'pending') {
|
||||
await this.deps.enqueue(noteId);
|
||||
}
|
||||
if (this.deps.telemetry) {
|
||||
await this.deps.telemetry.emit({ kind: 'restore', payload: { noteId } }).catch(() => {});
|
||||
}
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
import { mkdir, appendFile, readFile, readdir, unlink, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { validateEvent, TelemetryEvent } from './telemetryEvents.js';
|
||||
import type { AiFailedReason } from './telemetryEvents.js';
|
||||
import { aggregateStats } from './telemetryStats.js';
|
||||
|
||||
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function todayKstIso(now: Date): string {
|
||||
const k = new Date(now.getTime() + KST_OFFSET_MS);
|
||||
return new Date(Date.UTC(k.getUTCFullYear(), k.getUTCMonth(), k.getUTCDate()))
|
||||
.toISOString().slice(0, 10);
|
||||
}
|
||||
import { kstTodayIso, DAY_MS } from '../../shared/util/kstDate.js';
|
||||
|
||||
export interface TelemetryServiceOptions {
|
||||
silent?: boolean;
|
||||
@@ -19,7 +12,7 @@ export interface TelemetryServiceOptions {
|
||||
export type EmitInput =
|
||||
| { kind: 'capture'; payload: { noteId: string; rawTextLength: number; hasMedia: boolean } }
|
||||
| { kind: 'ai_succeeded'; payload: { noteId: string; durationMs: number; attempts: number } }
|
||||
| { kind: 'ai_failed'; payload: { noteId: string; reason: 'unreachable' | 'schema' | 'timeout' | 'other'; attempts: number } }
|
||||
| { kind: 'ai_failed'; payload: { noteId: string; reason: AiFailedReason; attempts: number } }
|
||||
| { kind: 'trash'; payload: { noteId: string } }
|
||||
| { kind: 'restore'; payload: { noteId: string } }
|
||||
| { kind: 'permanent_delete'; payload: { noteId: string } }
|
||||
@@ -54,7 +47,7 @@ export class TelemetryService {
|
||||
return { removed };
|
||||
}
|
||||
const cutoff = new Date(this.now().getTime() - this.retentionDays * DAY_MS);
|
||||
const cutoffIso = todayKstIso(cutoff); // KST 일자 비교
|
||||
const cutoffIso = kstTodayIso(cutoff); // KST 일자 비교
|
||||
for (const name of entries) {
|
||||
const m = /^events-(\d{4}-\d{2}-\d{2})\.jsonl$/.exec(name);
|
||||
if (!m) continue;
|
||||
@@ -77,7 +70,7 @@ export class TelemetryService {
|
||||
const nowDate = this.now();
|
||||
const ts = nowDate.toISOString();
|
||||
const event = validateEvent({ ts, kind: input.kind, payload: input.payload });
|
||||
const filePath = join(this.dir, `events-${todayKstIso(nowDate)}.jsonl`);
|
||||
const filePath = join(this.dir, `events-${kstTodayIso(nowDate)}.jsonl`);
|
||||
try {
|
||||
await mkdir(this.dir, { recursive: true });
|
||||
await appendFile(filePath, JSON.stringify(event) + '\n', 'utf8');
|
||||
@@ -96,7 +89,7 @@ export class TelemetryService {
|
||||
return events;
|
||||
}
|
||||
const cutoffMs = this.now().getTime() - this.retentionDays * DAY_MS;
|
||||
const cutoffIso = todayKstIso(new Date(cutoffMs));
|
||||
const cutoffIso = kstTodayIso(new Date(cutoffMs));
|
||||
// 회차 1 review (PR #13) — 매직 슬라이스 `n.slice(7, 17)` 대신 정규식 capture 그룹으로
|
||||
// 일자를 추출. prefix 변경 시 정규식 한 곳만 고치면 됨.
|
||||
const datePattern = /^events-(\d{4}-\d{2}-\d{2})\.jsonl$/;
|
||||
|
||||
@@ -12,11 +12,12 @@ const AiSucceededPayload = z.object({
|
||||
attempts: z.number().int().nonnegative()
|
||||
}).strict();
|
||||
|
||||
const AiFailedReason = z.enum(['unreachable', 'schema', 'timeout', 'other']);
|
||||
export const AiFailedReasonSchema = z.enum(['unreachable', 'schema', 'timeout', 'other']);
|
||||
export type AiFailedReason = z.infer<typeof AiFailedReasonSchema>;
|
||||
|
||||
const AiFailedPayload = z.object({
|
||||
noteId: z.string().min(1),
|
||||
reason: AiFailedReason,
|
||||
reason: AiFailedReasonSchema,
|
||||
attempts: z.number().int().nonnegative()
|
||||
}).strict();
|
||||
|
||||
@@ -92,3 +93,23 @@ export type TelemetryKind = TelemetryEvent['kind'];
|
||||
export function validateEvent(raw: unknown): TelemetryEvent {
|
||||
return TelemetryEventSchema.parse(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.6 #21 — type predicate helper. payload.noteId 가 있는 event kind 만 narrow.
|
||||
* union 확장 시 NO_NOTE_ID_KINDS Set 한 곳만 갱신.
|
||||
*/
|
||||
const NO_NOTE_ID_KINDS = new Set<TelemetryKind>([
|
||||
'empty_trash',
|
||||
'expired_banner_shown',
|
||||
'expired_batch_trash',
|
||||
'ollama_unreachable',
|
||||
'ollama_recovered',
|
||||
'ollama_recheck_manual',
|
||||
'ai_retry_manual',
|
||||
'tag_vocab_hit',
|
||||
'tag_vocab_miss'
|
||||
]);
|
||||
|
||||
export function hasNoteId(ev: TelemetryEvent): ev is Extract<TelemetryEvent, { payload: { noteId: string } }> {
|
||||
return !NO_NOTE_ID_KINDS.has(ev.kind);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import type { TelemetryEvent } from './telemetryEvents.js';
|
||||
|
||||
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
||||
import { kstTodayIso } from '../../shared/util/kstDate.js';
|
||||
|
||||
function kstDate(ts: string): string {
|
||||
const d = new Date(ts);
|
||||
const k = new Date(d.getTime() + KST_OFFSET_MS);
|
||||
return new Date(Date.UTC(k.getUTCFullYear(), k.getUTCMonth(), k.getUTCDate()))
|
||||
.toISOString().slice(0, 10);
|
||||
return kstTodayIso(new Date(ts));
|
||||
}
|
||||
|
||||
interface DailyRow {
|
||||
@@ -133,12 +129,18 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta
|
||||
} else if (ev.kind === 'recall_snoozed') {
|
||||
row.recall_snoozed += 1;
|
||||
recallSnoozedCount += 1;
|
||||
} else {
|
||||
// v0.2.6 #8 — 새 telemetry kind 추가 시 본 함수 분기 누락을 컴파일 단계에서 catch.
|
||||
const _exhaustive: never = ev;
|
||||
void _exhaustive;
|
||||
}
|
||||
}
|
||||
const days = Array.from(byDay.values()).sort((a, b) => a.date.localeCompare(b.date));
|
||||
const aiTotal = aiSucceeded + aiFailed;
|
||||
const successRate = aiTotal === 0 ? 'N/A' : `${(aiSucceeded / aiTotal * 100).toFixed(1)}% (${aiSucceeded}/${aiTotal})`;
|
||||
const avgDuration = durationN === 0 ? 'N/A' : `${Math.round(durationSum / durationN)}`;
|
||||
// v0.2.6 #9 — 회수율 = restore / trash event 비율 (event-level — 한 노트 trash-restore 반복 시
|
||||
// 100% 가능, unique-note 회수율 아님. spec §6.2 "회수 도구 동작?" 질문에 충분).
|
||||
const trashRecoveryRate = trashCount === 0
|
||||
? 'N/A'
|
||||
: `${(restoreCount / trashCount * 100).toFixed(1)}% (${restoreCount}/${trashCount})`;
|
||||
|
||||
183
src/main/tray.ts
183
src/main/tray.ts
@@ -1,156 +1,81 @@
|
||||
import electron from 'electron';
|
||||
import type { Tray as TrayType, MenuItemConstructorOptions } from 'electron';
|
||||
import { platform, release, EOL } from 'node:os';
|
||||
const { app, Tray, Menu, nativeImage, dialog, shell, clipboard } = electron;
|
||||
const { app, Tray, Menu, nativeImage } = electron;
|
||||
|
||||
function showAboutDialog(): void {
|
||||
const version = app.getVersion();
|
||||
const electronVersion = process.versions.electron ?? '?';
|
||||
const nodeVersion = process.versions.node ?? '?';
|
||||
const profileDir = app.getPath('userData');
|
||||
// OS EOL 사용 — 클립보드 → Notepad 등에서 줄바꿈 정상.
|
||||
const detail = [
|
||||
`버전: ${version}`,
|
||||
`Electron: ${electronVersion}`,
|
||||
`Node: ${nodeVersion}`,
|
||||
`OS: ${platform()} ${release()}`,
|
||||
`데이터 위치: ${profileDir}`
|
||||
].join(EOL);
|
||||
void dialog.showMessageBox({
|
||||
type: 'info',
|
||||
title: 'Inkling 정보',
|
||||
message: `Inkling ${version}`,
|
||||
detail,
|
||||
buttons: ['확인', '데이터 위치 열기', '정보 복사'],
|
||||
defaultId: 0,
|
||||
cancelId: 0
|
||||
}).then((r) => {
|
||||
if (r.response === 1) void shell.openPath(profileDir);
|
||||
if (r.response === 2) clipboard.writeText(`Inkling ${version}${EOL}${detail}`);
|
||||
}).catch(() => {
|
||||
// dialog reject 는 일반 사용에서 발생 X — main process crash 등 예외 케이스 silent.
|
||||
});
|
||||
// v0.2.7 Phase 3 (Task 15) — showAboutDialog 제거됨.
|
||||
// "Inkling 정보..." 트레이 항목이 사라짐 → 동일 기능은 설정 페이지의 InfoSection 이 담당.
|
||||
// settings:get-app-info / settings:copy-app-info IPC 핸들러 (settingsApi.ts) 가 역할 인계.
|
||||
|
||||
/**
|
||||
* v0.2.7 Phase 3 (Task 14) — 트레이 메뉴 슬림. 13 → 4 항목.
|
||||
*
|
||||
* 백업/내보내기/복원/동기화/사용 로그/Ollama 재확인/AI 재처리/Ollama 설정/자동실행/정보 →
|
||||
* 모두 설정 페이지로 이전. 트레이는 4 항목만 노출:
|
||||
* 1. 한 줄 적기 (showCapture)
|
||||
* 2. 보관한 메모 보기 (showInbox)
|
||||
* 3. 설정... (showSettings — 설정 페이지로 navigate)
|
||||
* 4. 종료
|
||||
*/
|
||||
export interface TrayCallbacks {
|
||||
showInbox: () => void;
|
||||
showCapture: () => void;
|
||||
showSettings: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.7 Phase 3 (Task 14) — TrayState 슬림. todayCount 만 잔류 (오늘 N번 잡아둠 라벨).
|
||||
* ollamaOk / failedCount 메뉴 항목이 사라져 더 이상 필요 없음.
|
||||
*/
|
||||
export interface TrayState {
|
||||
todayCount: number;
|
||||
}
|
||||
|
||||
let tray: TrayType | null = null;
|
||||
let _showInbox: () => void = () => {};
|
||||
let _showCapture: () => void = () => {};
|
||||
let _runBackup: () => void = () => {};
|
||||
let _runExport: () => void = () => {};
|
||||
let _runImport: () => void = () => {};
|
||||
let _runSync: () => void = () => {};
|
||||
let _runExportTelemetry: () => void = () => {};
|
||||
let _runOllamaRecheck: () => void = () => {};
|
||||
let _ollamaOk = true;
|
||||
let _todayCount = 0;
|
||||
let _runRetryAllFailed: () => void = () => {};
|
||||
let _failedCount = 0;
|
||||
let _runOpenOllamaSettings: () => void = () => {};
|
||||
let _callbacks: TrayCallbacks | null = null;
|
||||
let _state: TrayState = { todayCount: 0 };
|
||||
|
||||
function buildMenu() {
|
||||
function buildMenu(): electron.Menu {
|
||||
const items: MenuItemConstructorOptions[] = [];
|
||||
const cb = _callbacks;
|
||||
if (!cb) {
|
||||
// createTray 호출 전이면 빈 메뉴 (defensive)
|
||||
return Menu.buildFromTemplate([{ label: '로딩 중...', enabled: false }]);
|
||||
}
|
||||
// F4-C: count > 0 시 비활성 라벨로 정체성 신호 노출. count = 0 시 메뉴를 자연스럽게 시작.
|
||||
if (_todayCount > 0) {
|
||||
items.push({ label: `오늘 ${_todayCount}번 잡아둠`, enabled: false });
|
||||
if (_state.todayCount > 0) {
|
||||
items.push({ label: `오늘 ${_state.todayCount}번 잡아둠`, enabled: false });
|
||||
items.push({ type: 'separator' });
|
||||
}
|
||||
items.push({ label: '보관한 메모 보기', click: _showInbox });
|
||||
items.push({ label: '한 줄 적기', click: _showCapture });
|
||||
items.push({ label: '한 줄 적기', click: cb.showCapture });
|
||||
items.push({ label: '보관한 메모 보기', click: cb.showInbox });
|
||||
items.push({ type: 'separator' });
|
||||
items.push({ label: '설정...', click: cb.showSettings });
|
||||
items.push({ type: 'separator' });
|
||||
items.push({ label: '지금 백업', click: _runBackup });
|
||||
items.push({ label: '내보내기...', click: _runExport });
|
||||
items.push({ label: '백업에서 복원...', click: _runImport });
|
||||
items.push({ label: '지금 동기화', click: _runSync });
|
||||
items.push({ label: '사용 로그 내보내기...', click: _runExportTelemetry });
|
||||
items.push({
|
||||
label: 'Ollama 재확인',
|
||||
enabled: !_ollamaOk,
|
||||
click: _runOllamaRecheck
|
||||
});
|
||||
items.push({
|
||||
label: `지금 AI 처리 (실패 ${_failedCount}건)`,
|
||||
enabled: _failedCount > 0,
|
||||
click: _runRetryAllFailed
|
||||
});
|
||||
items.push({ label: 'Ollama 설정...', click: () => _runOpenOllamaSettings() });
|
||||
if (app.isPackaged) {
|
||||
const { openAtLogin } = app.getLoginItemSettings();
|
||||
items.push({
|
||||
label: '윈도우 시작 시 자동 실행',
|
||||
type: 'checkbox',
|
||||
checked: openAtLogin,
|
||||
click: (item) => {
|
||||
app.setLoginItemSettings({
|
||||
openAtLogin: item.checked,
|
||||
args: ['--hidden']
|
||||
});
|
||||
}
|
||||
});
|
||||
items.push({ type: 'separator' });
|
||||
} else {
|
||||
items.push({ type: 'separator' });
|
||||
}
|
||||
items.push({ label: 'Inkling 정보...', click: showAboutDialog });
|
||||
items.push({ label: '종료', click: () => { app.isQuitting = true; app.quit(); } });
|
||||
return Menu.buildFromTemplate(items);
|
||||
}
|
||||
|
||||
export function createTray(
|
||||
showInbox: () => void,
|
||||
showCapture: () => void,
|
||||
runBackup: () => void,
|
||||
runExport: () => void,
|
||||
runImport: () => void,
|
||||
runSync: () => void,
|
||||
runExportTelemetry: () => void,
|
||||
runOllamaRecheck: () => void,
|
||||
runRetryAllFailed: () => void,
|
||||
runOpenOllamaSettings: () => void
|
||||
): TrayType {
|
||||
_showInbox = showInbox;
|
||||
_showCapture = showCapture;
|
||||
_runBackup = runBackup;
|
||||
_runExport = runExport;
|
||||
_runImport = runImport;
|
||||
_runSync = runSync;
|
||||
_runExportTelemetry = runExportTelemetry;
|
||||
_runOllamaRecheck = runOllamaRecheck;
|
||||
_runRetryAllFailed = runRetryAllFailed;
|
||||
_runOpenOllamaSettings = runOpenOllamaSettings;
|
||||
/**
|
||||
* v0.2.6 C2 — 1-arg createTray. 기존 10 positional 폐기.
|
||||
* v0.2.7 Phase 3 — TrayCallbacks 3-필드로 슬림.
|
||||
*/
|
||||
export function createTray(callbacks: TrayCallbacks): TrayType {
|
||||
_callbacks = callbacks;
|
||||
const icon = nativeImage.createEmpty();
|
||||
tray = new Tray(icon);
|
||||
tray.setToolTip(`Inkling — 오늘 ${_todayCount}`);
|
||||
tray.setToolTip(`Inkling — 오늘 ${_state.todayCount}`);
|
||||
tray.setContextMenu(buildMenu());
|
||||
tray.on('click', showInbox);
|
||||
tray.on('click', callbacks.showInbox);
|
||||
return tray;
|
||||
}
|
||||
|
||||
/**
|
||||
* F4-C 환경 앵커 — tooltip + 메뉴 첫 항목을 오늘 캡처 수로 갱신.
|
||||
* `src/main/index.ts` 가 60s interval / AiWorker onUpdate 시점에 호출.
|
||||
* v0.2.6 C3 — 통합 state 갱신. partial 으로 받아 _state merge + 메뉴 재빌드.
|
||||
* v0.2.7 Phase 3 — TrayState 가 todayCount 만 갖도록 슬림.
|
||||
*/
|
||||
export function refreshTray(todayCount: number): void {
|
||||
_todayCount = todayCount;
|
||||
if (tray === null) return;
|
||||
tray.setToolTip(`Inkling — 오늘 ${todayCount}`);
|
||||
tray.setContextMenu(buildMenu());
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.3 #1 — Ollama 상태가 변할 때 main 의 health.onUpdate 가 호출.
|
||||
* 메뉴의 "Ollama 재확인" 활성/비활성 상태 갱신.
|
||||
*/
|
||||
export function refreshTrayOllama(ok: boolean): void {
|
||||
_ollamaOk = ok;
|
||||
if (tray === null) return;
|
||||
tray.setContextMenu(buildMenu());
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.3 #2 — AiWorker.onUpdate 시 실패 카운트 변하면 메뉴 라벨 + enabled 갱신.
|
||||
*/
|
||||
export function refreshTrayFailedCount(count: number): void {
|
||||
_failedCount = count;
|
||||
export function refreshTray(state: Partial<TrayState>): void {
|
||||
_state = { ..._state, ...state };
|
||||
if (tray === null) return;
|
||||
tray.setToolTip(`Inkling — 오늘 ${_state.todayCount}`);
|
||||
tray.setContextMenu(buildMenu());
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Calendar date (YYYY-MM-DD) in Asia/Seoul timezone for the given instant.
|
||||
*
|
||||
* v0.2.3 #5 — used by NoteRepository.findExpiredCandidates to compare against
|
||||
* notes.due_date (also stored as YYYY-MM-DD per slice §F1).
|
||||
*/
|
||||
export function todayInKstString(now: Date): string {
|
||||
const k = new Date(now.getTime() + KST_OFFSET_MS);
|
||||
return new Date(
|
||||
Date.UTC(k.getUTCFullYear(), k.getUTCMonth(), k.getUTCDate())
|
||||
).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Epoch ms of the next 00:00 KST strictly after `now`.
|
||||
*
|
||||
* v0.2.3 #5 — used by store.snoozeExpired to compute the in-memory snooze
|
||||
* deadline ("오늘 그만").
|
||||
*/
|
||||
export function nextKstMidnightMs(now: number): number {
|
||||
const kstNow = now + KST_OFFSET_MS;
|
||||
// Floor to KST midnight, then add one day.
|
||||
const kstMidnightFloor = Math.floor(kstNow / 86_400_000) * 86_400_000;
|
||||
const nextKstMidnight = kstMidnightFloor + 86_400_000;
|
||||
return nextKstMidnight - KST_OFFSET_MS;
|
||||
}
|
||||
@@ -12,7 +12,7 @@ const api: InklingApi = {
|
||||
updateAiFields: (noteId, fields) =>
|
||||
ipcRenderer.invoke('inbox:updateAi', { noteId, fields }),
|
||||
setDueDate: (noteId, date) => ipcRenderer.invoke('inbox:setDueDate', { noteId, date }),
|
||||
deleteNote: (noteId) => ipcRenderer.invoke('inbox:delete', noteId),
|
||||
deleteNote: (noteId) => ipcRenderer.invoke('inbox:trash', noteId),
|
||||
setIntent: (noteId, text) => ipcRenderer.invoke('inbox:setIntent', { noteId, text }),
|
||||
dismissIntent: (noteId) => ipcRenderer.invoke('inbox:dismissIntent', noteId),
|
||||
getContinuity: () => ipcRenderer.invoke('inbox:continuity'),
|
||||
@@ -47,11 +47,25 @@ const api: InklingApi = {
|
||||
emitRecallSnoozed: (id: string) => ipcRenderer.invoke('inbox:emitRecallSnoozed', id),
|
||||
loadOllamaSettings: () => ipcRenderer.invoke('inbox:loadOllamaSettings'),
|
||||
saveOllamaSettings: (v: { endpoint: string; model: string }) => ipcRenderer.invoke('inbox:saveOllamaSettings', v),
|
||||
onOpenOllamaSettings: (cb: () => void) => {
|
||||
const handler = () => cb();
|
||||
ipcRenderer.on('inbox:openOllamaSettings', handler);
|
||||
return () => ipcRenderer.removeListener('inbox:openOllamaSettings', handler);
|
||||
// v0.2.7 Task 13 — 외부 (트레이) 에서 view 전환 요청 listener.
|
||||
onNavigate: (cb: (view: 'inbox' | 'trash' | 'settings') => void) => {
|
||||
const listener = (_e: unknown, view: 'inbox' | 'trash' | 'settings') => cb(view);
|
||||
ipcRenderer.on('inbox:navigate', listener);
|
||||
return () => ipcRenderer.off('inbox:navigate', listener);
|
||||
},
|
||||
// v0.2.7 자동 실행 (Task 22 통일) — 진단 정보 포함 응답
|
||||
getAutostart: () => ipcRenderer.invoke('settings:autostart-state'),
|
||||
setAutostart: (open: boolean) => ipcRenderer.invoke('settings:autostart-set', open),
|
||||
// v0.2.7 백업/복원/동기화/텔레메트리 (Task 10) — 트레이 callback 의 IPC 대응
|
||||
runBackup: () => ipcRenderer.invoke('settings:run-backup'),
|
||||
runExport: () => ipcRenderer.invoke('settings:run-export'),
|
||||
runImport: () => ipcRenderer.invoke('settings:run-import'),
|
||||
runSync: () => ipcRenderer.invoke('settings:run-sync'),
|
||||
runExportTelemetry: () => ipcRenderer.invoke('settings:run-export-telemetry'),
|
||||
// v0.2.7 정보 섹션 (Task 11) — 트레이 showAboutDialog 의 IPC 대응
|
||||
getAppInfo: () => ipcRenderer.invoke('settings:get-app-info'),
|
||||
openProfileDir: () => ipcRenderer.invoke('settings:open-profile-dir'),
|
||||
copyAppInfo: () => ipcRenderer.invoke('settings:copy-app-info'),
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import { TagUndoToast } from './components/TagUndoToast.js';
|
||||
import { ExpiryBanner } from './components/ExpiryBanner.js';
|
||||
import { FailedBanner } from './components/FailedBanner.js';
|
||||
import { RecallBanner } from './components/RecallBanner.js';
|
||||
import { OllamaSettingsModal } from './components/OllamaSettingsModal.js';
|
||||
import { SettingsPage } from './components/SettingsPage.js';
|
||||
|
||||
export function App(): React.ReactElement {
|
||||
const {
|
||||
@@ -21,8 +21,9 @@ export function App(): React.ReactElement {
|
||||
continuity, tagFilter, setTagFilter,
|
||||
toggleShowTrash, restoreNote, permanentDeleteNote, emptyTrash
|
||||
} = useInbox();
|
||||
const showSettings = useInbox((s) => s.showSettings);
|
||||
const setShowSettings = useInbox((s) => s.setShowSettings);
|
||||
const [recoveryDismissed, setRecoveryDismissed] = useState(isRecoveryDismissedToday());
|
||||
const [ollamaSettingsOpen, setOllamaSettingsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void loadInitial();
|
||||
@@ -33,14 +34,26 @@ export function App(): React.ReactElement {
|
||||
const unsubOllama = inboxApi.onOllamaStatus((status) => {
|
||||
useInbox.setState({ ollamaStatus: status });
|
||||
});
|
||||
const unsubOllamaSettings = inboxApi.onOpenOllamaSettings(() => setOllamaSettingsOpen(true));
|
||||
const unsubNav = inboxApi.onNavigate((view) => {
|
||||
if (view === 'settings') {
|
||||
useInbox.getState().setShowSettings(true);
|
||||
} else if (view === 'inbox') {
|
||||
useInbox.getState().setShowSettings(false);
|
||||
if (useInbox.getState().showTrash) void useInbox.getState().toggleShowTrash();
|
||||
} else if (view === 'trash') {
|
||||
useInbox.getState().setShowSettings(false);
|
||||
if (!useInbox.getState().showTrash) void useInbox.getState().toggleShowTrash();
|
||||
}
|
||||
});
|
||||
const onFocus = () => { void refreshMeta(); };
|
||||
window.addEventListener('focus', onFocus);
|
||||
return () => { unsubNote(); unsubOllama(); unsubOllamaSettings(); window.removeEventListener('focus', onFocus); };
|
||||
return () => { unsubNote(); unsubOllama(); unsubNav(); window.removeEventListener('focus', onFocus); };
|
||||
// onOllamaStatus 콜백은 useInbox.setState 직접 호출 — store reference 가 안정적이라
|
||||
// deps array 에 추가 불필요. mount 시 1회 구독 + unmount 시 해제.
|
||||
}, [loadInitial, refreshMeta, upsertNote]);
|
||||
|
||||
if (showSettings) return <SettingsPage />;
|
||||
|
||||
const showRecovery = continuity.showRecoveryToast && !recoveryDismissed;
|
||||
const filtered = selectFilteredNotes({ notes, tagFilter });
|
||||
|
||||
@@ -78,11 +91,25 @@ export function App(): React.ReactElement {
|
||||
<ContinuityBadge />
|
||||
<IdentityCounter />
|
||||
</div>
|
||||
<button
|
||||
aria-label="설정 열기"
|
||||
onClick={() => setShowSettings(true)}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: 4,
|
||||
fontSize: 16,
|
||||
marginLeft: 8
|
||||
}}
|
||||
>
|
||||
⚙
|
||||
</button>
|
||||
</div>
|
||||
<main className="main">
|
||||
{!showTrash && (
|
||||
<>
|
||||
<OllamaBanner onOpenSettings={() => setOllamaSettingsOpen(true)} />
|
||||
<OllamaBanner onOpenSettings={() => setShowSettings(true)} />
|
||||
<RecoveryToast
|
||||
show={showRecovery}
|
||||
onDismiss={() => { markRecoveryDismissed(); setRecoveryDismissed(true); }}
|
||||
@@ -157,10 +184,6 @@ export function App(): React.ReactElement {
|
||||
)}
|
||||
</main>
|
||||
<TagUndoToast />
|
||||
<OllamaSettingsModal
|
||||
open={ollamaSettingsOpen}
|
||||
onClose={() => setOllamaSettingsOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
27
src/renderer/inbox/components/Banner.tsx
Normal file
27
src/renderer/inbox/components/Banner.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* v0.2.6 #24+#41 — 4 banner 의 inline style 중복 제거. severity 별 theme map.
|
||||
*/
|
||||
const THEMES = {
|
||||
warning: { bg: '#fff7e6', border: '#d99500', text: '#946100' },
|
||||
error: { bg: '#fce4e4', border: '#a33', text: '#a33' },
|
||||
info: { bg: '#e8f0fe', border: '#4a7ec0', text: '#234' }
|
||||
} as const;
|
||||
|
||||
interface Props {
|
||||
severity: 'warning' | 'error' | 'info';
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Banner({ severity, children }: Props): React.ReactElement {
|
||||
const t = THEMES[severity];
|
||||
return (
|
||||
<div style={{
|
||||
background: t.bg, border: `1px solid ${t.border}`, color: t.text,
|
||||
borderRadius: 6, padding: '8px 12px', margin: '8px 0', fontSize: 13
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import type { Note } from '@shared/types';
|
||||
import { useInbox } from '../store.js';
|
||||
import { Banner } from './Banner.js';
|
||||
|
||||
export function ExpiryBanner(): React.ReactElement | null {
|
||||
const candidates = useInbox((s) => s.expiredCandidates);
|
||||
@@ -72,10 +73,7 @@ function ExpiryBannerInner({ candidates, onTrash, onSnooze }: InnerProps): React
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: '#fff7e6', border: '1px solid #d99500', borderRadius: 6,
|
||||
padding: '8px 12px', margin: '8px 0', fontSize: 13
|
||||
}}>
|
||||
<Banner severity="warning">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span>⏰ <b>오늘 기준 만료 {candidates.length}개</b></span>
|
||||
<button
|
||||
@@ -152,6 +150,6 @@ function ExpiryBannerInner({ candidates, onTrash, onSnooze }: InnerProps): React
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,31 @@
|
||||
import React from 'react';
|
||||
import { useInbox } from '../store.js';
|
||||
import { Banner } from './Banner.js';
|
||||
|
||||
export function FailedBanner(): React.ReactElement | null {
|
||||
const count = useInbox((s) => s.failedCount);
|
||||
const retryAllFailed = useInbox((s) => s.retryAllFailed);
|
||||
if (count === 0) return null;
|
||||
return (
|
||||
<div style={{
|
||||
background: '#fce4e4', border: '1px solid #a33', borderRadius: 6,
|
||||
padding: '8px 12px', margin: '8px 0', fontSize: 13,
|
||||
display: 'flex', alignItems: 'center', gap: 8
|
||||
}}>
|
||||
<span style={{ flex: 1 }}>❌ AI 처리 실패 <b>{count}</b>건</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
retryAllFailed().catch((e) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('retryAllFailed failed', e);
|
||||
});
|
||||
}}
|
||||
style={{
|
||||
background: '#a33', color: '#fff',
|
||||
border: 'none', borderRadius: 4,
|
||||
padding: '4px 12px', fontSize: 12, cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
재시도
|
||||
</button>
|
||||
</div>
|
||||
<Banner severity="error">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ flex: 1 }}>❌ AI 처리 실패 <b>{count}</b>건</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
retryAllFailed().catch((e) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('retryAllFailed failed', e);
|
||||
});
|
||||
}}
|
||||
style={{
|
||||
background: '#a33', color: '#fff',
|
||||
border: 'none', borderRadius: 4,
|
||||
padding: '4px 12px', fontSize: 12, cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
재시도
|
||||
</button>
|
||||
</div>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { useInbox } from '../store.js';
|
||||
import { Banner } from './Banner.js';
|
||||
|
||||
interface OllamaBannerProps {
|
||||
onOpenSettings?: () => void;
|
||||
@@ -14,7 +15,8 @@ export function OllamaBanner({ onOpenSettings }: OllamaBannerProps = {}): React.
|
||||
? '`ollama pull gemma4:e4b` 실행 후 앱을 재시작해주세요.'
|
||||
: 'Inkling 정리가 잠시 멈췄습니다. Ollama를 실행해주세요.';
|
||||
return (
|
||||
<div className="banner warn" style={{ flexDirection: 'column', alignItems: 'flex-start' }}>
|
||||
<Banner severity="warning">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%' }}>
|
||||
<span style={{ flex: 1 }}>⚠ {message}</span>
|
||||
<button
|
||||
@@ -51,6 +53,7 @@ export function OllamaBanner({ onOpenSettings }: OllamaBannerProps = {}): React.
|
||||
진단: {status.reason}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { inboxApi } from '../api.js';
|
||||
import { DEFAULT_OLLAMA_ENDPOINT, DEFAULT_OLLAMA_MODEL } from '../../../shared/constants.js';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function OllamaSettingsModal({ open, onClose }: Props): React.ReactElement | null {
|
||||
const [endpoint, setEndpoint] = useState(DEFAULT_OLLAMA_ENDPOINT);
|
||||
const [model, setModel] = useState(DEFAULT_OLLAMA_MODEL);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// 마운트/open 시 현재 설정 fetch
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
void inboxApi.loadOllamaSettings().then((s) => {
|
||||
if (s) {
|
||||
setEndpoint(s.endpoint);
|
||||
setModel(s.model);
|
||||
}
|
||||
setError(null);
|
||||
});
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
async function handleSave() {
|
||||
if (saving) return; // m4 fix: synchronous double-click 가드
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await inboxApi.saveOllamaSettings({ endpoint, model });
|
||||
if (r.ok) {
|
||||
onClose();
|
||||
} else {
|
||||
setError(r.reason);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape' && !saving) onClose();
|
||||
if (e.key === 'Enter' && !saving) void handleSave();
|
||||
}}
|
||||
tabIndex={-1}
|
||||
style={{
|
||||
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
background: '#fff', borderRadius: 8, padding: 20, minWidth: 400, maxWidth: 500,
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.2)'
|
||||
}}>
|
||||
<h2 style={{ margin: '0 0 12px 0', fontSize: 16 }}>Ollama 설정</h2>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>
|
||||
Endpoint URL
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={endpoint}
|
||||
onChange={(e) => setEndpoint(e.target.value)}
|
||||
placeholder="http://localhost:11434"
|
||||
autoFocus
|
||||
style={{ width: '100%', padding: '6px 8px', fontSize: 13, border: '1px solid #ccc', borderRadius: 4 }}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>
|
||||
Model
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
placeholder="gemma4:e4b"
|
||||
style={{ width: '100%', padding: '6px 8px', fontSize: 13, border: '1px solid #ccc', borderRadius: 4 }}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<div style={{
|
||||
background: '#fce4e4', color: '#a33', padding: '6px 10px', borderRadius: 4,
|
||||
fontSize: 12, marginBottom: 12
|
||||
}}>
|
||||
저장 실패: {error}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={saving}
|
||||
style={{
|
||||
background: 'transparent', color: '#666',
|
||||
border: '1px solid #ccc', borderRadius: 4,
|
||||
padding: '6px 14px', fontSize: 12, cursor: saving ? 'not-allowed' : 'pointer'
|
||||
}}
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void handleSave()}
|
||||
disabled={saving}
|
||||
style={{
|
||||
background: saving ? '#999' : '#0a4b80', color: '#fff',
|
||||
border: 'none', borderRadius: 4,
|
||||
padding: '6px 14px', fontSize: 12, cursor: saving ? 'not-allowed' : 'pointer'
|
||||
}}
|
||||
>
|
||||
{saving ? '검증 중...' : '저장'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useInbox } from '../store.js';
|
||||
import { inboxApi } from '../api.js';
|
||||
import { Banner } from './Banner.js';
|
||||
|
||||
export function RecallBanner(): React.ReactElement | null {
|
||||
const candidate = useInbox((s) => s.recallCandidate);
|
||||
@@ -47,10 +48,7 @@ export function RecallBanner(): React.ReactElement | null {
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: '#e8f0fe', border: '1px solid #4a7ec0', borderRadius: 6,
|
||||
padding: '8px 12px', margin: '8px 0', fontSize: 13
|
||||
}}>
|
||||
<Banner severity="info">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span>💭 <b>오늘 회상해볼 노트</b></span>
|
||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: '#234' }}>
|
||||
@@ -90,7 +88,7 @@ export function RecallBanner(): React.ReactElement | null {
|
||||
더 이상
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
45
src/renderer/inbox/components/SettingsPage.tsx
Normal file
45
src/renderer/inbox/components/SettingsPage.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
import { useInbox } from '../store.js';
|
||||
import { AiProviderSection } from './settings/AiProviderSection.js';
|
||||
import { AutostartSection } from './settings/AutostartSection.js';
|
||||
import { BackupSection } from './settings/BackupSection.js';
|
||||
import { InfoSection } from './settings/InfoSection.js';
|
||||
|
||||
export function SettingsPage(): React.ReactElement {
|
||||
const setShowSettings = useInbox((s) => s.setShowSettings);
|
||||
return (
|
||||
<div style={{ padding: 16, maxWidth: 720, margin: '0 auto' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16 }}>
|
||||
<button
|
||||
onClick={() => setShowSettings(false)}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
fontSize: 14,
|
||||
cursor: 'pointer',
|
||||
color: '#0a4b80'
|
||||
}}
|
||||
>
|
||||
← 돌아가기
|
||||
</button>
|
||||
<h1 style={{ fontSize: 18, margin: 0 }}>설정</h1>
|
||||
</div>
|
||||
<section style={{ marginBottom: 24 }}>
|
||||
<h2 style={{ fontSize: 14, marginBottom: 8 }}>AI 제공자</h2>
|
||||
<AiProviderSection />
|
||||
</section>
|
||||
<section style={{ marginBottom: 24 }}>
|
||||
<h2 style={{ fontSize: 14, marginBottom: 8 }}>자동 실행</h2>
|
||||
<AutostartSection />
|
||||
</section>
|
||||
<section style={{ marginBottom: 24 }}>
|
||||
<h2 style={{ fontSize: 14, marginBottom: 8 }}>백업 / 복원</h2>
|
||||
<BackupSection />
|
||||
</section>
|
||||
<section style={{ marginBottom: 24 }}>
|
||||
<h2 style={{ fontSize: 14, marginBottom: 8 }}>정보</h2>
|
||||
<InfoSection />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
131
src/renderer/inbox/components/settings/AiProviderSection.tsx
Normal file
131
src/renderer/inbox/components/settings/AiProviderSection.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { z } from 'zod';
|
||||
import { inboxApi } from '../../api.js';
|
||||
|
||||
const endpointSchema = z.string().url();
|
||||
|
||||
export function AiProviderSection(): React.ReactElement {
|
||||
const [endpoint, setEndpoint] = useState('');
|
||||
const [model, setModel] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saveResult, setSaveResult] = useState<string | null>(null);
|
||||
const [recheckResult, setRecheckResult] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
const s = await inboxApi.loadOllamaSettings();
|
||||
if (s) {
|
||||
setEndpoint(s.endpoint);
|
||||
setModel(s.model);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
async function onSave(): Promise<void> {
|
||||
const r = endpointSchema.safeParse(endpoint);
|
||||
if (!r.success) {
|
||||
setError('올바른 URL 형식이 아닙니다 (예: http://localhost:11434)');
|
||||
setSaveResult(null);
|
||||
return;
|
||||
}
|
||||
if (model.trim() === '') {
|
||||
setError('모델 이름을 입력해주세요');
|
||||
setSaveResult(null);
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
const result = await inboxApi.saveOllamaSettings({ endpoint, model });
|
||||
if (result.ok) {
|
||||
setSaveResult('저장됨');
|
||||
} else {
|
||||
setSaveResult(null);
|
||||
setError(`저장 실패: ${result.reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function onRecheck(): Promise<void> {
|
||||
setRecheckResult('확인 중...');
|
||||
const r = await inboxApi.ollamaRecheck();
|
||||
setRecheckResult(r.ok ? '연결됨' : `연결 실패: ${r.reason ?? '알 수 없는 이유'}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: 8, fontSize: 12, color: '#666' }}>
|
||||
Endpoint
|
||||
<input
|
||||
type="text"
|
||||
value={endpoint}
|
||||
onChange={(e) => setEndpoint(e.target.value)}
|
||||
placeholder="http://localhost:11434"
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
padding: '6px 8px',
|
||||
marginTop: 4,
|
||||
fontSize: 13,
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: 4
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: 'block', marginBottom: 8, fontSize: 12, color: '#666' }}>
|
||||
Model
|
||||
<input
|
||||
type="text"
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
placeholder="gemma2:2b"
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
padding: '6px 8px',
|
||||
marginTop: 4,
|
||||
fontSize: 13,
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: 4
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{error && (
|
||||
<div style={{ color: '#c33', fontSize: 12, marginBottom: 8 }}>{error}</div>
|
||||
)}
|
||||
{saveResult && (
|
||||
<div style={{ fontSize: 12, marginBottom: 8, color: '#0a4b80' }}>{saveResult}</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
onClick={() => void onSave()}
|
||||
style={{
|
||||
background: '#0a4b80',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 4,
|
||||
padding: '6px 14px',
|
||||
fontSize: 12,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
저장
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void onRecheck()}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
color: '#0a4b80',
|
||||
border: '1px solid #0a4b80',
|
||||
borderRadius: 4,
|
||||
padding: '6px 14px',
|
||||
fontSize: 12,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
지금 재확인
|
||||
</button>
|
||||
</div>
|
||||
{recheckResult && (
|
||||
<div style={{ fontSize: 12, marginTop: 8 }}>{recheckResult}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
95
src/renderer/inbox/components/settings/AutostartSection.tsx
Normal file
95
src/renderer/inbox/components/settings/AutostartSection.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import type { AutostartResponse } from '@shared/types';
|
||||
import { inboxApi } from '../../api.js';
|
||||
|
||||
export function AutostartSection(): React.ReactElement {
|
||||
const [data, setData] = useState<AutostartResponse | null>(null);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
const r = await inboxApi.getAutostart();
|
||||
setData(r);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
async function onToggle(e: React.ChangeEvent<HTMLInputElement>): Promise<void> {
|
||||
const r = await inboxApi.setAutostart(e.target.checked);
|
||||
setData(r);
|
||||
}
|
||||
|
||||
// Task 24 — 현재 openAtLogin 값으로 다시 setLoginItemSettings 호출 → mismatch 복구.
|
||||
// (예: registry 누락된 채로 withArgs.openAtLogin=true 인 경우 등.)
|
||||
async function onReregister(): Promise<void> {
|
||||
if (!data) return;
|
||||
const r = await inboxApi.setAutostart(data.openAtLogin);
|
||||
setData(r);
|
||||
}
|
||||
|
||||
if (data === null) {
|
||||
return <div style={{ fontSize: 12, color: '#666' }}>로딩 중...</div>;
|
||||
}
|
||||
|
||||
const d = data.diagnostic;
|
||||
// v0.2.7 F12 deeper fix — withArgs vs noArgs 의 openAtLogin 불일치, 또는
|
||||
// executableWillLaunchAtLogin = false 면 mismatch 로 간주 (등록은 됐지만 실제론
|
||||
// 로그인 시 실행되지 않을 수 있는 상태).
|
||||
const mismatch = d.withArgs.openAtLogin !== d.noArgs.openAtLogin
|
||||
|| (data.openAtLogin && !d.withArgs.executableWillLaunchAtLogin);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label style={{ display: 'flex', gap: 8, alignItems: 'center', fontSize: 13 }}>
|
||||
<input type="checkbox" checked={data.openAtLogin} onChange={onToggle} />
|
||||
앱 시작 시 자동으로 실행
|
||||
</label>
|
||||
{mismatch && (
|
||||
<div style={{ color: '#c33', fontSize: 12, marginTop: 4 }}>
|
||||
⚠️ 등록 상태 불일치 감지 — 진단 정보 확인 후 재등록을 시도하세요.
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<button
|
||||
onClick={() => { void onReregister(); }}
|
||||
style={{ fontSize: 12, padding: '4px 10px' }}
|
||||
>
|
||||
재등록
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: 12,
|
||||
color: '#0a4b80',
|
||||
marginTop: 4,
|
||||
padding: 0
|
||||
}}
|
||||
>
|
||||
{expanded ? '▾' : '▸'} 진단 정보
|
||||
</button>
|
||||
{expanded && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
lineHeight: 1.6,
|
||||
marginTop: 4,
|
||||
fontFamily: 'monospace',
|
||||
background: '#f5f5f5',
|
||||
padding: 8,
|
||||
borderRadius: 4,
|
||||
wordBreak: 'break-all'
|
||||
}}
|
||||
>
|
||||
<div>표준 (--hidden 인자): openAtLogin={String(d.withArgs.openAtLogin)}, willLaunch={String(d.withArgs.executableWillLaunchAtLogin)}</div>
|
||||
<div>비교 (인자 없이): openAtLogin={String(d.noArgs.openAtLogin)}, willLaunch={String(d.noArgs.executableWillLaunchAtLogin)}</div>
|
||||
<div>실행 파일 경로: {d.execPath}</div>
|
||||
{d.registryPath !== undefined && <div>registry 경로: {d.registryPath}</div>}
|
||||
{d.registryValue !== undefined && <div>registry 값: {d.registryValue ?? '(없음)'}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
src/renderer/inbox/components/settings/BackupSection.tsx
Normal file
25
src/renderer/inbox/components/settings/BackupSection.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React, { useState } from 'react';
|
||||
import { inboxApi } from '../../api.js';
|
||||
|
||||
export function BackupSection(): React.ReactElement {
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
|
||||
// IPC 핸들러 (settingsApi.ts) 가 자체 try/catch + Notification 으로 결과를 사용자에게 알림.
|
||||
// 이 컴포넌트의 status 는 보조 진행 표시 — 결과 (성공/실패) 는 native UX 에 의존.
|
||||
async function run(label: string, fn: () => Promise<unknown>): Promise<void> {
|
||||
setStatus(`${label}: 진행 중...`);
|
||||
await fn();
|
||||
setStatus(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<button onClick={() => run('지금 백업', () => inboxApi.runBackup())}>지금 백업</button>
|
||||
<button onClick={() => run('내보내기', () => inboxApi.runExport())}>내보내기...</button>
|
||||
<button onClick={() => run('백업에서 복원', () => inboxApi.runImport())}>백업에서 복원...</button>
|
||||
<button onClick={() => run('지금 동기화', () => inboxApi.runSync())}>지금 동기화</button>
|
||||
<button onClick={() => run('사용 로그 내보내기', () => inboxApi.runExportTelemetry())}>사용 로그 내보내기...</button>
|
||||
{status && <div style={{ fontSize: 12 }}>{status}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
src/renderer/inbox/components/settings/InfoSection.tsx
Normal file
41
src/renderer/inbox/components/settings/InfoSection.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { inboxApi } from '../../api.js';
|
||||
|
||||
interface AppInfo {
|
||||
version: string;
|
||||
electron: string;
|
||||
node: string;
|
||||
os: string;
|
||||
profileDir: string;
|
||||
}
|
||||
|
||||
export function InfoSection(): React.ReactElement {
|
||||
const [info, setInfo] = useState<AppInfo | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => setInfo(await inboxApi.getAppInfo()))();
|
||||
}, []);
|
||||
|
||||
if (!info) return <div style={{ fontSize: 12 }}>로딩 중...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<dl style={{ fontSize: 12, lineHeight: 1.6 }}>
|
||||
<dt style={{ fontWeight: 600 }}>버전</dt>
|
||||
<dd>{info.version}</dd>
|
||||
<dt style={{ fontWeight: 600 }}>Electron</dt>
|
||||
<dd>{info.electron}</dd>
|
||||
<dt style={{ fontWeight: 600 }}>Node</dt>
|
||||
<dd>{info.node}</dd>
|
||||
<dt style={{ fontWeight: 600 }}>OS</dt>
|
||||
<dd>{info.os}</dd>
|
||||
<dt style={{ fontWeight: 600 }}>데이터 위치</dt>
|
||||
<dd style={{ wordBreak: 'break-all' }}>{info.profileDir}</dd>
|
||||
</dl>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
||||
<button onClick={() => void inboxApi.openProfileDir()}>데이터 위치 열기</button>
|
||||
<button onClick={() => void inboxApi.copyAppInfo()}>정보 복사</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { create } from 'zustand';
|
||||
import type { Note, WeeklyContinuity } from '@shared/types';
|
||||
import { inboxApi } from './api.js';
|
||||
import { nextKstMidnightMs } from '@shared/util/kstDate.js';
|
||||
|
||||
export { selectFilteredNotes } from './selectFilteredNotes.js';
|
||||
|
||||
@@ -9,6 +10,7 @@ interface InboxState {
|
||||
trashNotes: Note[];
|
||||
trashCount: number;
|
||||
showTrash: boolean;
|
||||
showSettings: boolean;
|
||||
continuity: WeeklyContinuity;
|
||||
pendingCount: number;
|
||||
ollamaStatus: { ok: boolean; reason?: string };
|
||||
@@ -25,6 +27,7 @@ interface InboxState {
|
||||
upsertNote: (note: Note) => void;
|
||||
removeNote: (id: string) => void;
|
||||
setTagFilter: (tag: string | null) => void;
|
||||
setShowSettings: (open: boolean) => void;
|
||||
toggleShowTrash: () => Promise<void>;
|
||||
loadTrash: () => Promise<void>;
|
||||
restoreNote: (id: string) => Promise<void>;
|
||||
@@ -51,6 +54,7 @@ export const useInbox = create<InboxState>((set, get) => ({
|
||||
trashNotes: [],
|
||||
trashCount: 0,
|
||||
showTrash: false,
|
||||
showSettings: false,
|
||||
continuity: emptyContinuity,
|
||||
pendingCount: 0,
|
||||
ollamaStatus: { ok: true },
|
||||
@@ -133,6 +137,9 @@ export const useInbox = create<InboxState>((set, get) => ({
|
||||
setTagFilter(tag) {
|
||||
set({ tagFilter: tag });
|
||||
},
|
||||
setShowSettings(open) {
|
||||
set({ showSettings: open });
|
||||
},
|
||||
async toggleShowTrash() {
|
||||
const next = !get().showTrash;
|
||||
set({ showTrash: next });
|
||||
@@ -177,12 +184,7 @@ export const useInbox = create<InboxState>((set, get) => ({
|
||||
});
|
||||
},
|
||||
snoozeExpired() {
|
||||
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
||||
const now = Date.now();
|
||||
const kstNow = now + KST_OFFSET_MS;
|
||||
const kstMidnightFloor = Math.floor(kstNow / 86_400_000) * 86_400_000;
|
||||
const nextKstMidnight = kstMidnightFloor + 86_400_000;
|
||||
set({ expiredSnoozeUntilMs: nextKstMidnight - KST_OFFSET_MS });
|
||||
set({ expiredSnoozeUntilMs: nextKstMidnightMs(Date.now()) });
|
||||
},
|
||||
async recheckOllama() {
|
||||
const status = await inboxApi.ollamaRecheck();
|
||||
@@ -212,12 +214,7 @@ export const useInbox = create<InboxState>((set, get) => ({
|
||||
set({ recallCandidate, recallSnoozeUntilMs: null });
|
||||
},
|
||||
async snoozeRecall() {
|
||||
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
||||
const now = Date.now();
|
||||
const kstNow = now + KST_OFFSET_MS;
|
||||
const kstMidnightFloor = Math.floor(kstNow / 86_400_000) * 86_400_000;
|
||||
const nextKstMidnight = kstMidnightFloor + 86_400_000;
|
||||
set({ recallSnoozeUntilMs: nextKstMidnight - KST_OFFSET_MS });
|
||||
set({ recallSnoozeUntilMs: nextKstMidnightMs(Date.now()) });
|
||||
// m1 fix — candidate=null 인 race 케이스 (사용자가 banner 닫힌 직후 클릭) 시
|
||||
// snooze 는 적용하되 emit 만 skip. telemetry 누락 받아들임 (의도적).
|
||||
const candidate = get().recallCandidate;
|
||||
|
||||
@@ -57,6 +57,20 @@ export interface CaptureApi {
|
||||
hide(): void;
|
||||
}
|
||||
|
||||
// v0.2.7 F12 deeper fix — 자동 실행 진단 정보 (AutostartDiagnostic.collectAutostartState 결과).
|
||||
export interface AutostartDiagnostic {
|
||||
withArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean };
|
||||
noArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean };
|
||||
execPath: string;
|
||||
registryPath?: string;
|
||||
registryValue?: string | null;
|
||||
}
|
||||
|
||||
export interface AutostartResponse {
|
||||
openAtLogin: boolean;
|
||||
diagnostic: AutostartDiagnostic;
|
||||
}
|
||||
|
||||
export interface InboxApi {
|
||||
listNotes(opts: { limit: number; cursor?: string }): Promise<Note[]>;
|
||||
updateAiFields(
|
||||
@@ -91,7 +105,27 @@ export interface InboxApi {
|
||||
emitRecallSnoozed(id: string): Promise<void>;
|
||||
loadOllamaSettings(): Promise<{ endpoint: string; model: string } | null>;
|
||||
saveOllamaSettings(v: { endpoint: string; model: string }): Promise<{ ok: true } | { ok: false; reason: string }>;
|
||||
onOpenOllamaSettings(cb: () => void): () => void;
|
||||
// v0.2.7 Task 13 — 외부 (트레이 등) 에서 view 전환 요청 구독.
|
||||
onNavigate(cb: (view: 'inbox' | 'trash' | 'settings') => void): () => void;
|
||||
// v0.2.7 자동 실행 (Task 22 통일) — 진단 정보 포함 응답
|
||||
getAutostart(): Promise<AutostartResponse>;
|
||||
setAutostart(open: boolean): Promise<AutostartResponse>;
|
||||
// v0.2.7 백업 / 복원 / 동기화 / 텔레메트리 — 트레이 callback 의 IPC 대응 (Task 10)
|
||||
runBackup(): Promise<{ ok: true }>;
|
||||
runExport(): Promise<{ ok: true }>;
|
||||
runImport(): Promise<{ ok: true }>;
|
||||
runSync(): Promise<{ ok: true }>;
|
||||
runExportTelemetry(): Promise<{ ok: true }>;
|
||||
// 정보 섹션 — 트레이 showAboutDialog 의 IPC 대응.
|
||||
getAppInfo(): Promise<{
|
||||
version: string;
|
||||
electron: string;
|
||||
node: string;
|
||||
os: string;
|
||||
profileDir: string;
|
||||
}>;
|
||||
openProfileDir(): Promise<void>;
|
||||
copyAppInfo(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface InklingApi {
|
||||
|
||||
42
src/shared/util/kstDate.ts
Normal file
42
src/shared/util/kstDate.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* KST timezone helpers — main + renderer 양쪽에서 import 가능.
|
||||
* v0.2.6 C1: backlog #3+#19+#34 통합 (기존 src/main/util/kstDate.ts 이동).
|
||||
*/
|
||||
|
||||
export const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
||||
export const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* KST 자정 기준 today YYYY-MM-DD.
|
||||
*
|
||||
* 기존 todayInKstString (NoteRepository.findExpiredCandidates),
|
||||
* TelemetryService.todayKstIso, telemetryStats.kstDate, AiWorker.todayKstAsIso
|
||||
* 4 callsite 통합.
|
||||
*/
|
||||
export function kstTodayIso(now: Date = new Date()): string {
|
||||
const k = new Date(now.getTime() + KST_OFFSET_MS);
|
||||
return new Date(Date.UTC(k.getUTCFullYear(), k.getUTCMonth(), k.getUTCDate()))
|
||||
.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* 다음 KST 자정의 epoch ms (UTC).
|
||||
*
|
||||
* 기존 nextKstMidnightMs (store.snoozeExpired) + store.snoozeRecall inline 통합.
|
||||
*/
|
||||
export function nextKstMidnightMs(now: number = Date.now()): number {
|
||||
const kstNow = now + KST_OFFSET_MS;
|
||||
const kstMidnightFloor = Math.floor(kstNow / DAY_MS) * DAY_MS;
|
||||
const nextKstMidnight = kstMidnightFloor + DAY_MS;
|
||||
return nextKstMidnight - KST_OFFSET_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* KST today (00:00 KST 의 UTC Date 객체). AiWorker 의 dueDateParser 가 candidate 비교용.
|
||||
*
|
||||
* 기존 AiWorker.todayKstAsDate 통합.
|
||||
*/
|
||||
export function kstTodayAsDate(now: Date = new Date()): Date {
|
||||
const k = new Date(now.getTime() + KST_OFFSET_MS);
|
||||
return new Date(Date.UTC(k.getUTCFullYear(), k.getUTCMonth(), k.getUTCDate()));
|
||||
}
|
||||
44
tests/unit/AiProviderSection.test.tsx
Normal file
44
tests/unit/AiProviderSection.test.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
|
||||
|
||||
vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
inboxApi: {
|
||||
loadOllamaSettings: vi.fn(async () => ({ endpoint: 'http://localhost:11434', model: 'gemma2:2b' })),
|
||||
saveOllamaSettings: vi.fn(async () => ({ ok: true })),
|
||||
ollamaRecheck: vi.fn(async () => ({ ok: true }))
|
||||
}
|
||||
}));
|
||||
|
||||
import { AiProviderSection } from '../../src/renderer/inbox/components/settings/AiProviderSection';
|
||||
|
||||
describe('AiProviderSection', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('loads current settings on mount', async () => {
|
||||
render(<AiProviderSection />);
|
||||
expect(await screen.findByDisplayValue('http://localhost:11434')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('gemma2:2b')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('rejects invalid endpoint URL', async () => {
|
||||
render(<AiProviderSection />);
|
||||
await screen.findByDisplayValue('http://localhost:11434');
|
||||
const input = screen.getByLabelText(/Endpoint/);
|
||||
fireEvent.change(input, { target: { value: 'not-a-url' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /저장/ }));
|
||||
expect(await screen.findByText(/올바른 URL/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('"지금 재확인" calls ollamaRecheck and shows result', async () => {
|
||||
const { inboxApi } = await import('../../src/renderer/inbox/api.js');
|
||||
render(<AiProviderSection />);
|
||||
await screen.findByDisplayValue('http://localhost:11434');
|
||||
fireEvent.click(screen.getByRole('button', { name: /지금 재확인/ }));
|
||||
expect(inboxApi.ollamaRecheck).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
91
tests/unit/App.test.tsx
Normal file
91
tests/unit/App.test.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
|
||||
|
||||
vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
inboxApi: {
|
||||
listNotes: vi.fn(async () => []),
|
||||
getContinuity: vi.fn(async () => ({
|
||||
weekStart: '', weekCount: 0, weekTarget: 7,
|
||||
consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null
|
||||
})),
|
||||
getPendingCount: vi.fn(async () => 0),
|
||||
getOllamaStatus: vi.fn(async () => ({ ok: true })),
|
||||
getTodayCount: vi.fn(async () => 0),
|
||||
getTrashCount: vi.fn(async () => 0),
|
||||
listExpired: vi.fn(async () => []),
|
||||
getFailedCount: vi.fn(async () => 0),
|
||||
listRecallCandidate: vi.fn(async () => null),
|
||||
onNoteUpdated: vi.fn(() => () => undefined),
|
||||
onOllamaStatus: vi.fn(() => () => undefined),
|
||||
onNavigate: vi.fn(() => () => undefined),
|
||||
// 4 섹션 mounted 시 호출되는 stub
|
||||
loadOllamaSettings: vi.fn(async () => ({ endpoint: '', model: '' })),
|
||||
saveOllamaSettings: vi.fn(async () => ({ ok: true })),
|
||||
ollamaRecheck: vi.fn(async () => ({ ok: true })),
|
||||
getAutostart: vi.fn(async () => ({
|
||||
openAtLogin: false,
|
||||
diagnostic: {
|
||||
withArgs: { openAtLogin: false, executableWillLaunchAtLogin: false },
|
||||
noArgs: { openAtLogin: false, executableWillLaunchAtLogin: false },
|
||||
execPath: '/p'
|
||||
}
|
||||
})),
|
||||
setAutostart: vi.fn(async () => ({
|
||||
openAtLogin: false,
|
||||
diagnostic: {
|
||||
withArgs: { openAtLogin: false, executableWillLaunchAtLogin: false },
|
||||
noArgs: { openAtLogin: false, executableWillLaunchAtLogin: false },
|
||||
execPath: '/p'
|
||||
}
|
||||
})),
|
||||
runBackup: vi.fn(async () => ({ ok: true })),
|
||||
runExport: vi.fn(async () => ({ ok: true })),
|
||||
runImport: vi.fn(async () => ({ ok: true })),
|
||||
runSync: vi.fn(async () => ({ ok: true })),
|
||||
runExportTelemetry: vi.fn(async () => ({ ok: true })),
|
||||
getAppInfo: vi.fn(async () => ({ version: '0.2.7', electron: '?', node: '?', os: '?', profileDir: '?' })),
|
||||
openProfileDir: vi.fn(async () => undefined),
|
||||
copyAppInfo: vi.fn(async () => undefined)
|
||||
}
|
||||
}));
|
||||
|
||||
import { App } from '../../src/renderer/inbox/App';
|
||||
import { useInbox } from '../../src/renderer/inbox/store';
|
||||
import { inboxApi } from '../../src/renderer/inbox/api.js';
|
||||
|
||||
describe('App — settings view', () => {
|
||||
beforeEach(() => {
|
||||
cleanup();
|
||||
useInbox.setState({ showSettings: false, notes: [], trashNotes: [], trashCount: 0 });
|
||||
});
|
||||
|
||||
it('renders SettingsPage when showSettings=true', async () => {
|
||||
useInbox.setState({ showSettings: true });
|
||||
render(<App />);
|
||||
expect(await screen.findByText('설정')).toBeInTheDocument();
|
||||
expect(screen.getByText('AI 제공자')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('header gear icon click sets showSettings=true', async () => {
|
||||
render(<App />);
|
||||
fireEvent.click(await screen.findByLabelText('설정 열기'));
|
||||
expect(useInbox.getState().showSettings).toBe(true);
|
||||
});
|
||||
|
||||
it('inbox:navigate "settings" event sets showSettings=true', async () => {
|
||||
const navHandlers: Array<(view: 'inbox' | 'trash' | 'settings') => void> = [];
|
||||
vi.mocked(inboxApi.onNavigate).mockImplementation((cb) => {
|
||||
navHandlers.push(cb);
|
||||
return () => {
|
||||
const i = navHandlers.indexOf(cb);
|
||||
if (i >= 0) navHandlers.splice(i, 1);
|
||||
};
|
||||
});
|
||||
render(<App />);
|
||||
await waitFor(() => expect(navHandlers.length).toBeGreaterThan(0));
|
||||
navHandlers.forEach((h) => h('settings'));
|
||||
await waitFor(() => expect(useInbox.getState().showSettings).toBe(true));
|
||||
});
|
||||
});
|
||||
96
tests/unit/AutostartDiagnostic.test.ts
Normal file
96
tests/unit/AutostartDiagnostic.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
const { mockApp, mockExecFile } = vi.hoisted(() => ({
|
||||
mockApp: { getLoginItemSettings: vi.fn() },
|
||||
mockExecFile: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
default: { app: mockApp }
|
||||
}));
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
execFile: mockExecFile
|
||||
}));
|
||||
|
||||
import { collectAutostartState } from '../../src/main/services/AutostartDiagnostic';
|
||||
|
||||
const ORIGINAL_PLATFORM = process.platform;
|
||||
|
||||
function setPlatform(p: NodeJS.Platform): void {
|
||||
Object.defineProperty(process, 'platform', { value: p, configurable: true });
|
||||
}
|
||||
|
||||
describe('AutostartDiagnostic — collectAutostartState', () => {
|
||||
beforeEach(() => {
|
||||
mockApp.getLoginItemSettings.mockReset();
|
||||
mockExecFile.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setPlatform(ORIGINAL_PLATFORM);
|
||||
});
|
||||
|
||||
it('returns withArgs / noArgs / execPath structure', async () => {
|
||||
setPlatform('darwin');
|
||||
mockApp.getLoginItemSettings
|
||||
.mockReturnValueOnce({ openAtLogin: true, executableWillLaunchAtLogin: true })
|
||||
.mockReturnValueOnce({ openAtLogin: false, executableWillLaunchAtLogin: true });
|
||||
|
||||
const state = await collectAutostartState();
|
||||
|
||||
expect(state.withArgs).toEqual({ openAtLogin: true, executableWillLaunchAtLogin: true });
|
||||
expect(state.noArgs).toEqual({ openAtLogin: false, executableWillLaunchAtLogin: true });
|
||||
expect(state.execPath).toBe(process.execPath);
|
||||
});
|
||||
|
||||
it('passes args=["--hidden"] for the first call, no args for the second', async () => {
|
||||
setPlatform('darwin');
|
||||
mockApp.getLoginItemSettings
|
||||
.mockReturnValueOnce({ openAtLogin: true, executableWillLaunchAtLogin: true })
|
||||
.mockReturnValueOnce({ openAtLogin: true, executableWillLaunchAtLogin: true });
|
||||
|
||||
await collectAutostartState();
|
||||
|
||||
expect(mockApp.getLoginItemSettings).toHaveBeenNthCalledWith(1, { args: ['--hidden'] });
|
||||
expect(mockApp.getLoginItemSettings).toHaveBeenNthCalledWith(2);
|
||||
});
|
||||
|
||||
it('non-win32: does not set registryPath/registryValue', async () => {
|
||||
setPlatform('darwin');
|
||||
mockApp.getLoginItemSettings.mockReturnValue({ openAtLogin: true, executableWillLaunchAtLogin: true });
|
||||
|
||||
const state = await collectAutostartState();
|
||||
|
||||
expect(state.registryPath).toBeUndefined();
|
||||
expect(state.registryValue).toBeUndefined();
|
||||
expect(mockExecFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Windows: returns registryPath + registryValue when reg.exe succeeds', async () => {
|
||||
setPlatform('win32');
|
||||
mockApp.getLoginItemSettings.mockReturnValue({ openAtLogin: true, executableWillLaunchAtLogin: true });
|
||||
mockExecFile.mockImplementation((_cmd: string, _args: string[], cb: (err: Error | null, stdout: string, stderr: string) => void) => {
|
||||
cb(null, '\r\nHKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Run\r\n Inkling REG_SZ "C:\\Users\\u\\Inkling.exe" --hidden\r\n', '');
|
||||
});
|
||||
|
||||
const state = await collectAutostartState();
|
||||
|
||||
expect(state.registryPath).toBe('HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run\\Inkling');
|
||||
expect(state.registryValue).toContain('Inkling.exe');
|
||||
expect(state.registryValue).toContain('--hidden');
|
||||
});
|
||||
|
||||
it('Windows: silent fallback on reg.exe error', async () => {
|
||||
setPlatform('win32');
|
||||
mockApp.getLoginItemSettings.mockReturnValue({ openAtLogin: true, executableWillLaunchAtLogin: true });
|
||||
mockExecFile.mockImplementation((_cmd: string, _args: string[], cb: (err: Error | null, stdout: string, stderr: string) => void) => {
|
||||
cb(new Error('not found'), '', '');
|
||||
});
|
||||
|
||||
const state = await collectAutostartState();
|
||||
|
||||
expect(state.registryPath).toBe('HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run\\Inkling');
|
||||
expect(state.registryValue).toBeNull();
|
||||
});
|
||||
});
|
||||
115
tests/unit/AutostartSection.test.tsx
Normal file
115
tests/unit/AutostartSection.test.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
|
||||
|
||||
function makeDiag(open: boolean): {
|
||||
withArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean };
|
||||
noArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean };
|
||||
execPath: string;
|
||||
} {
|
||||
return {
|
||||
withArgs: { openAtLogin: open, executableWillLaunchAtLogin: open },
|
||||
noArgs: { openAtLogin: open, executableWillLaunchAtLogin: open },
|
||||
execPath: '/path/to/exe'
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
inboxApi: {
|
||||
getAutostart: vi.fn(async () => ({ openAtLogin: true, diagnostic: makeDiag(true) })),
|
||||
setAutostart: vi.fn(async (open: boolean) => ({ openAtLogin: open, diagnostic: makeDiag(open) }))
|
||||
}
|
||||
}));
|
||||
|
||||
import { AutostartSection } from '../../src/renderer/inbox/components/settings/AutostartSection';
|
||||
|
||||
describe('AutostartSection', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('renders toggle reflecting current state', async () => {
|
||||
render(<AutostartSection />);
|
||||
const toggle = await screen.findByRole('checkbox');
|
||||
expect(toggle).toBeChecked();
|
||||
});
|
||||
|
||||
it('clicking toggle calls setAutostart', async () => {
|
||||
const { inboxApi } = await import('../../src/renderer/inbox/api.js');
|
||||
render(<AutostartSection />);
|
||||
const toggle = await screen.findByRole('checkbox');
|
||||
fireEvent.click(toggle);
|
||||
await waitFor(() => expect(inboxApi.setAutostart).toHaveBeenCalledWith(false));
|
||||
});
|
||||
|
||||
it('renders diagnostic panel when expanded, shows mismatch warning + execPath', async () => {
|
||||
const { inboxApi } = await import('../../src/renderer/inbox/api.js');
|
||||
vi.mocked(inboxApi.getAutostart).mockResolvedValueOnce({
|
||||
openAtLogin: true,
|
||||
diagnostic: {
|
||||
withArgs: { openAtLogin: true, executableWillLaunchAtLogin: true },
|
||||
noArgs: { openAtLogin: false, executableWillLaunchAtLogin: true },
|
||||
execPath: '/path/to/Inkling.exe'
|
||||
}
|
||||
});
|
||||
render(<AutostartSection />);
|
||||
await screen.findByRole('checkbox');
|
||||
fireEvent.click(screen.getByRole('button', { name: /진단 정보/ }));
|
||||
expect(await screen.findByText(/⚠️/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/path\/to\/Inkling\.exe/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/표준 \(--hidden 인자\)/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/비교 \(인자 없이\)/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows registry info when present (Win)', async () => {
|
||||
const { inboxApi } = await import('../../src/renderer/inbox/api.js');
|
||||
vi.mocked(inboxApi.getAutostart).mockResolvedValueOnce({
|
||||
openAtLogin: true,
|
||||
diagnostic: {
|
||||
withArgs: { openAtLogin: true, executableWillLaunchAtLogin: true },
|
||||
noArgs: { openAtLogin: true, executableWillLaunchAtLogin: true },
|
||||
execPath: 'C:\\app.exe',
|
||||
registryPath: 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run\\Inkling',
|
||||
registryValue: '"C:\\app.exe" --hidden'
|
||||
}
|
||||
});
|
||||
render(<AutostartSection />);
|
||||
await screen.findByRole('checkbox');
|
||||
fireEvent.click(screen.getByRole('button', { name: /진단 정보/ }));
|
||||
expect(screen.getByText(/registry 경로/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/registry 값/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('no mismatch warning when withArgs == noArgs and willLaunch=true', async () => {
|
||||
const { inboxApi } = await import('../../src/renderer/inbox/api.js');
|
||||
vi.mocked(inboxApi.getAutostart).mockResolvedValueOnce({
|
||||
openAtLogin: true,
|
||||
diagnostic: {
|
||||
withArgs: { openAtLogin: true, executableWillLaunchAtLogin: true },
|
||||
noArgs: { openAtLogin: true, executableWillLaunchAtLogin: true },
|
||||
execPath: '/p'
|
||||
}
|
||||
});
|
||||
render(<AutostartSection />);
|
||||
await screen.findByRole('checkbox');
|
||||
expect(screen.queryByText(/⚠️/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('"재등록" button calls setAutostart with current openAtLogin value', async () => {
|
||||
const { inboxApi } = await import('../../src/renderer/inbox/api.js');
|
||||
vi.mocked(inboxApi.getAutostart).mockResolvedValueOnce({
|
||||
openAtLogin: true,
|
||||
diagnostic: {
|
||||
withArgs: { openAtLogin: true, executableWillLaunchAtLogin: true },
|
||||
noArgs: { openAtLogin: true, executableWillLaunchAtLogin: true },
|
||||
execPath: '/p'
|
||||
}
|
||||
});
|
||||
render(<AutostartSection />);
|
||||
await screen.findByRole('checkbox');
|
||||
fireEvent.click(screen.getByRole('button', { name: /재등록/ }));
|
||||
await waitFor(() => expect(inboxApi.setAutostart).toHaveBeenCalledWith(true));
|
||||
});
|
||||
});
|
||||
39
tests/unit/BackupSection.test.tsx
Normal file
39
tests/unit/BackupSection.test.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
|
||||
|
||||
vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
inboxApi: {
|
||||
runBackup: vi.fn(async () => ({ ok: true })),
|
||||
runExport: vi.fn(async () => ({ ok: true })),
|
||||
runImport: vi.fn(async () => ({ ok: true })),
|
||||
runSync: vi.fn(async () => ({ ok: true })),
|
||||
runExportTelemetry: vi.fn(async () => ({ ok: true }))
|
||||
}
|
||||
}));
|
||||
|
||||
import { BackupSection } from '../../src/renderer/inbox/components/settings/BackupSection';
|
||||
|
||||
describe('BackupSection', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('renders 5 buttons', () => {
|
||||
render(<BackupSection />);
|
||||
expect(screen.getByRole('button', { name: /지금 백업/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^내보내기/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /백업에서 복원/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /지금 동기화/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /사용 로그/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking 지금 백업 calls runBackup', async () => {
|
||||
const { inboxApi } = await import('../../src/renderer/inbox/api.js');
|
||||
render(<BackupSection />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /지금 백업/ }));
|
||||
await waitFor(() => expect(inboxApi.runBackup).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
@@ -324,6 +324,52 @@ describe('CaptureService.trashExpiredBatch', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('CaptureService.restoreNote — enqueue on failed/pending (#10 production path)', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
let store: MediaStore;
|
||||
let tmp: string;
|
||||
let enqueued: string[];
|
||||
let svc: CaptureService;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
repo = new NoteRepository(db);
|
||||
tmp = mkdtempSync(join(tmpdir(), 'inkling-restore-'));
|
||||
store = new MediaStore(tmp);
|
||||
enqueued = [];
|
||||
svc = new CaptureService(repo, store, {
|
||||
enqueue: async (id) => { enqueued.push(id); },
|
||||
celebrate: () => {}
|
||||
});
|
||||
});
|
||||
|
||||
it('restoreNote calls worker.enqueue when restoring failed note', async () => {
|
||||
const { id } = repo.create({ rawText: 'x' });
|
||||
repo.markAiFailed(id, 'unreachable');
|
||||
repo.trash(id, new Date().toISOString());
|
||||
enqueued.length = 0; // reset
|
||||
|
||||
await svc.restoreNote(id);
|
||||
|
||||
expect(repo.findById(id)!.aiStatus).toBe('pending');
|
||||
expect(enqueued).toContain(id);
|
||||
});
|
||||
|
||||
it('restoreNote does not enqueue done note', async () => {
|
||||
const { id } = repo.create({ rawText: 'x' });
|
||||
repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
|
||||
repo.trash(id, new Date().toISOString());
|
||||
enqueued.length = 0; // reset
|
||||
|
||||
await svc.restoreNote(id);
|
||||
|
||||
expect(repo.findById(id)!.aiStatus).toBe('done');
|
||||
expect(enqueued).not.toContain(id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CaptureService.retryAllFailed', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
|
||||
49
tests/unit/InfoSection.test.tsx
Normal file
49
tests/unit/InfoSection.test.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
|
||||
|
||||
vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
inboxApi: {
|
||||
getAppInfo: vi.fn(async () => ({
|
||||
version: '0.2.7',
|
||||
electron: '41.3.0',
|
||||
node: '22.x',
|
||||
os: 'darwin 23.6.0',
|
||||
profileDir: '/Users/u/Library/Application Support/Inkling'
|
||||
})),
|
||||
openProfileDir: vi.fn(async () => undefined),
|
||||
copyAppInfo: vi.fn(async () => undefined)
|
||||
}
|
||||
}));
|
||||
|
||||
import { InfoSection } from '../../src/renderer/inbox/components/settings/InfoSection';
|
||||
|
||||
describe('InfoSection', () => {
|
||||
beforeEach(() => { vi.clearAllMocks(); cleanup(); });
|
||||
|
||||
it('renders version, electron, node, OS, profileDir', async () => {
|
||||
render(<InfoSection />);
|
||||
expect(await screen.findByText(/0\.2\.7/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/41\.3\.0/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/22\.x/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/darwin/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Library\/Application Support\/Inkling/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('"데이터 위치 열기" calls openProfileDir', async () => {
|
||||
const { inboxApi } = await import('../../src/renderer/inbox/api.js');
|
||||
render(<InfoSection />);
|
||||
await screen.findByText(/0\.2\.7/);
|
||||
fireEvent.click(screen.getByRole('button', { name: /데이터 위치 열기/ }));
|
||||
await waitFor(() => expect(inboxApi.openProfileDir).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('"정보 복사" calls copyAppInfo', async () => {
|
||||
const { inboxApi } = await import('../../src/renderer/inbox/api.js');
|
||||
render(<InfoSection />);
|
||||
await screen.findByText(/0\.2\.7/);
|
||||
fireEvent.click(screen.getByRole('button', { name: /정보 복사/ }));
|
||||
await waitFor(() => expect(inboxApi.copyAppInfo).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
@@ -267,6 +267,49 @@ describe('NoteRepository', () => {
|
||||
repo.updateAiResult(d, { title: 't', summary: 'a\nb\nc', tags: ['x'], dueDate: todayKst, provider: 'p' });
|
||||
expect(repo.findRecallCandidate()?.id).toBe(d);
|
||||
});
|
||||
|
||||
it('restoreNote re-enqueues failed note (ai_status reset to pending + pending_jobs INSERT)', () => {
|
||||
const id = repo.create({ rawText: 'x' }).id;
|
||||
repo.markAiFailed(id, 'unreachable');
|
||||
repo.trash(id, new Date().toISOString());
|
||||
expect(repo.findById(id)!.aiStatus).toBe('failed');
|
||||
|
||||
repo.restoreNote(id);
|
||||
|
||||
const after = repo.findById(id)!;
|
||||
expect(after.deletedAt).toBeNull();
|
||||
expect(after.aiStatus).toBe('pending');
|
||||
expect(after.aiError).toBeNull();
|
||||
const job = db.prepare('SELECT * FROM pending_jobs WHERE note_id=?').get(id);
|
||||
expect(job).toBeDefined();
|
||||
});
|
||||
|
||||
it('restoreNote does not re-enqueue done note', () => {
|
||||
const id = repo.create({ rawText: 'x' }).id;
|
||||
repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
|
||||
repo.trash(id, new Date().toISOString());
|
||||
expect(repo.findById(id)!.aiStatus).toBe('done');
|
||||
|
||||
repo.restoreNote(id);
|
||||
|
||||
expect(repo.findById(id)!.aiStatus).toBe('done');
|
||||
const job = db.prepare('SELECT * FROM pending_jobs WHERE note_id=?').get(id);
|
||||
expect(job).toBeUndefined();
|
||||
});
|
||||
|
||||
it('restoreNote re-enqueues pending note (defensive)', () => {
|
||||
const id = repo.create({ rawText: 'x' }).id;
|
||||
// 인공적으로 pending_jobs 비운 후 trash
|
||||
db.prepare('DELETE FROM pending_jobs WHERE note_id=?').run(id);
|
||||
repo.trash(id, new Date().toISOString());
|
||||
expect(repo.findById(id)!.aiStatus).toBe('pending');
|
||||
|
||||
repo.restoreNote(id);
|
||||
|
||||
expect(repo.findById(id)!.aiStatus).toBe('pending');
|
||||
const job = db.prepare('SELECT * FROM pending_jobs WHERE note_id=?').get(id);
|
||||
expect(job).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('NoteRepository.trash', () => {
|
||||
@@ -449,6 +492,19 @@ describe('NoteRepository.countTrashed', () => {
|
||||
expect(repo.countTrashed()).toBe(10);
|
||||
expect(repo.listTrashed({ limit: 5 })).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('countTrashed returns accurate count (>200 not capped)', () => {
|
||||
const now = new Date().toISOString();
|
||||
for (let i = 0; i < 250; i++) {
|
||||
const id = repo.create({ rawText: `n${i}` }).id;
|
||||
repo.trash(id, now);
|
||||
}
|
||||
expect(repo.countTrashed()).toBe(250);
|
||||
});
|
||||
|
||||
it('countTrashed returns 0 for empty trash', () => {
|
||||
expect(repo.countTrashed()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Active queries exclude deleted notes', () => {
|
||||
|
||||
74
tests/unit/SettingsPage.test.tsx
Normal file
74
tests/unit/SettingsPage.test.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
|
||||
|
||||
// inboxApi 는 window.inkling.inbox 를 참조하므로 jsdom 환경에서 import 자체가 throw.
|
||||
// SettingsPage 가 마운트하는 AiProviderSection 의 useEffect 가 loadOllamaSettings 를 호출하므로
|
||||
// 빈 객체 대신 필요한 메서드를 stub 한다.
|
||||
vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
inboxApi: {
|
||||
loadOllamaSettings: vi.fn(async () => null),
|
||||
saveOllamaSettings: vi.fn(async () => ({ ok: true })),
|
||||
ollamaRecheck: vi.fn(async () => ({ ok: true })),
|
||||
getAutostart: vi.fn(async () => ({
|
||||
openAtLogin: false,
|
||||
diagnostic: {
|
||||
withArgs: { openAtLogin: false, executableWillLaunchAtLogin: false },
|
||||
noArgs: { openAtLogin: false, executableWillLaunchAtLogin: false },
|
||||
execPath: '/p'
|
||||
}
|
||||
})),
|
||||
setAutostart: vi.fn(async (open: boolean) => ({
|
||||
openAtLogin: open,
|
||||
diagnostic: {
|
||||
withArgs: { openAtLogin: open, executableWillLaunchAtLogin: open },
|
||||
noArgs: { openAtLogin: open, executableWillLaunchAtLogin: open },
|
||||
execPath: '/p'
|
||||
}
|
||||
})),
|
||||
runBackup: vi.fn(async () => ({ ok: true })),
|
||||
runExport: vi.fn(async () => ({ ok: true })),
|
||||
runImport: vi.fn(async () => ({ ok: true })),
|
||||
runSync: vi.fn(async () => ({ ok: true })),
|
||||
runExportTelemetry: vi.fn(async () => ({ ok: true })),
|
||||
getAppInfo: vi.fn(async () => ({
|
||||
version: '0.2.7',
|
||||
electron: '41.3.0',
|
||||
node: '22.x',
|
||||
os: 'darwin 23.6.0',
|
||||
profileDir: '/tmp/Inkling'
|
||||
})),
|
||||
openProfileDir: vi.fn(async () => undefined),
|
||||
copyAppInfo: vi.fn(async () => undefined)
|
||||
}
|
||||
}));
|
||||
|
||||
import { SettingsPage } from '../../src/renderer/inbox/components/SettingsPage';
|
||||
import { useInbox } from '../../src/renderer/inbox/store';
|
||||
|
||||
describe('SettingsPage', () => {
|
||||
beforeEach(() => {
|
||||
cleanup();
|
||||
useInbox.setState({ showSettings: true });
|
||||
});
|
||||
|
||||
it('renders header with "← 돌아가기" button', () => {
|
||||
render(<SettingsPage />);
|
||||
expect(screen.getByRole('button', { name: /돌아가기/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders 4 section headings', () => {
|
||||
render(<SettingsPage />);
|
||||
expect(screen.getByText('AI 제공자')).toBeInTheDocument();
|
||||
expect(screen.getByText('자동 실행')).toBeInTheDocument();
|
||||
expect(screen.getByText('백업 / 복원')).toBeInTheDocument();
|
||||
expect(screen.getByText('정보')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking "← 돌아가기" sets showSettings to false', () => {
|
||||
render(<SettingsPage />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /돌아가기/ }));
|
||||
expect(useInbox.getState().showSettings).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { mkdtempSync, rmSync, readFileSync, existsSync, readdirSync, writeFileSy
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { TelemetryService } from '@main/services/TelemetryService.js';
|
||||
import { hasNoteId } from '@main/services/telemetryEvents.js';
|
||||
|
||||
describe('TelemetryService.emit', () => {
|
||||
let dir: string;
|
||||
@@ -147,11 +148,7 @@ describe('TelemetryService.readAllRecent', () => {
|
||||
const events = await svc.readAllRecent();
|
||||
expect(events).toHaveLength(3);
|
||||
// discriminant narrowing — noteId 없는 kind(empty_trash/expired_banner_shown/expired_batch_trash) 가 섞이면 명시적으로 실패
|
||||
expect(events.map((e) =>
|
||||
(e.kind === 'empty_trash' || e.kind === 'expired_banner_shown' || e.kind === 'expired_batch_trash' || e.kind === 'ollama_unreachable' || e.kind === 'ollama_recovered' || e.kind === 'ollama_recheck_manual' || e.kind === 'ai_retry_manual' || e.kind === 'tag_vocab_hit' || e.kind === 'tag_vocab_miss')
|
||||
? null
|
||||
: e.payload.noteId
|
||||
)).toEqual(['a', 'b', 'b']);
|
||||
expect(events.map((e) => hasNoteId(e) ? e.payload.noteId : null)).toEqual(['a', 'b', 'b']);
|
||||
});
|
||||
|
||||
it('skips malformed lines (silent — invariant)', async () => {
|
||||
@@ -164,7 +161,7 @@ describe('TelemetryService.readAllRecent', () => {
|
||||
expect(events).toHaveLength(1);
|
||||
const ev = events[0]!;
|
||||
expect(ev.kind).toBe('capture');
|
||||
if (ev.kind !== 'empty_trash' && ev.kind !== 'expired_banner_shown' && ev.kind !== 'expired_batch_trash' && ev.kind !== 'ollama_unreachable' && ev.kind !== 'ollama_recovered' && ev.kind !== 'ollama_recheck_manual' && ev.kind !== 'ai_retry_manual' && ev.kind !== 'tag_vocab_hit' && ev.kind !== 'tag_vocab_miss') expect(ev.payload.noteId).toBe('a');
|
||||
if (hasNoteId(ev)) expect(ev.payload.noteId).toBe('a');
|
||||
});
|
||||
|
||||
it('returns [] when dir missing', async () => {
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { todayInKstString, nextKstMidnightMs } from '@main/util/kstDate.js';
|
||||
import { kstTodayIso, nextKstMidnightMs, kstTodayAsDate } from '@shared/util/kstDate.js';
|
||||
|
||||
describe('todayInKstString', () => {
|
||||
describe('kstTodayIso', () => {
|
||||
it('returns KST calendar date as YYYY-MM-DD', () => {
|
||||
// 2026-05-01 12:00 UTC = 2026-05-01 21:00 KST
|
||||
expect(todayInKstString(new Date('2026-05-01T12:00:00Z'))).toBe('2026-05-01');
|
||||
expect(kstTodayIso(new Date('2026-05-01T12:00:00Z'))).toBe('2026-05-01');
|
||||
});
|
||||
|
||||
it('handles UTC→KST date rollover (UTC 23:30 → KST next day 08:30)', () => {
|
||||
expect(todayInKstString(new Date('2026-05-01T23:30:00Z'))).toBe('2026-05-02');
|
||||
expect(kstTodayIso(new Date('2026-05-01T23:30:00Z'))).toBe('2026-05-02');
|
||||
});
|
||||
|
||||
it('handles KST midnight exactly (UTC 15:00 = KST 00:00 next day)', () => {
|
||||
expect(todayInKstString(new Date('2026-05-01T15:00:00Z'))).toBe('2026-05-02');
|
||||
expect(kstTodayIso(new Date('2026-05-01T15:00:00Z'))).toBe('2026-05-02');
|
||||
});
|
||||
|
||||
it('boundary — UTC 14:59:59 still KST 23:59:59 same day', () => {
|
||||
// KST 5/4 23:59:59 = UTC 5/4 14:59:59
|
||||
const utcDate = new Date('2026-05-04T14:59:59Z');
|
||||
expect(kstTodayIso(utcDate)).toBe('2026-05-04');
|
||||
});
|
||||
|
||||
it('KST 5/5 00:30 (UTC 5/4 15:30) returns 2026-05-05', () => {
|
||||
const utcDate = new Date('2026-05-04T15:30:00Z');
|
||||
expect(kstTodayIso(utcDate)).toBe('2026-05-05');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,4 +45,19 @@ describe('nextKstMidnightMs', () => {
|
||||
expect(next - now).toBeGreaterThan(23 * 60 * 60 * 1000);
|
||||
expect(next - now).toBeLessThan(24 * 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
it('KST 5/5 00:30 → next KST midnight = 5/6 00:00 KST = 5/5 15:00 UTC', () => {
|
||||
const utcMs = new Date('2026-05-04T15:30:00Z').getTime();
|
||||
const next = nextKstMidnightMs(utcMs);
|
||||
expect(new Date(next).toISOString()).toBe('2026-05-05T15:00:00.000Z');
|
||||
});
|
||||
});
|
||||
|
||||
describe('kstTodayAsDate', () => {
|
||||
it('returns UTC Date at KST 00:00', () => {
|
||||
// KST 5/5 00:30 → KST 5/5 00:00 = UTC 5/4 15:00
|
||||
const utcDate = new Date('2026-05-04T15:30:00Z');
|
||||
const result = kstTodayAsDate(utcDate);
|
||||
expect(result.toISOString()).toBe('2026-05-05T00:00:00.000Z');
|
||||
});
|
||||
});
|
||||
|
||||
98
tests/unit/settingsApi.test.ts
Normal file
98
tests/unit/settingsApi.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { handlers, mockApp, mockCollectAutostartState } = vi.hoisted(() => ({
|
||||
handlers: {} as Record<string, (...args: unknown[]) => unknown>,
|
||||
mockApp: {
|
||||
getLoginItemSettings: vi.fn(),
|
||||
setLoginItemSettings: vi.fn(),
|
||||
getVersion: vi.fn(() => '0.2.7'),
|
||||
getPath: vi.fn(() => '/profile')
|
||||
},
|
||||
mockCollectAutostartState: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
default: {
|
||||
ipcMain: {
|
||||
handle: (ch: string, fn: (...args: unknown[]) => unknown) => {
|
||||
handlers[ch] = fn;
|
||||
}
|
||||
},
|
||||
app: mockApp,
|
||||
dialog: {},
|
||||
Notification: vi.fn(function (this: unknown) {
|
||||
Object.assign(this as object, { show: vi.fn() });
|
||||
}),
|
||||
shell: { openPath: vi.fn() },
|
||||
clipboard: { writeText: vi.fn() }
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('../../src/main/services/AutostartDiagnostic', () => ({
|
||||
collectAutostartState: mockCollectAutostartState
|
||||
}));
|
||||
|
||||
vi.mock('../../src/main/windows/inboxWindow.js', () => ({
|
||||
getInboxWindow: vi.fn(() => null)
|
||||
}));
|
||||
|
||||
import { registerSettingsApi } from '../../src/main/ipc/settingsApi';
|
||||
|
||||
describe('settingsApi — autostart IPC', () => {
|
||||
beforeEach(() => {
|
||||
Object.keys(handlers).forEach((k) => delete handlers[k]);
|
||||
mockApp.getLoginItemSettings.mockReset();
|
||||
mockApp.setLoginItemSettings.mockReset();
|
||||
mockCollectAutostartState.mockReset();
|
||||
});
|
||||
|
||||
it('settings:autostart-state returns AutostartState wrapped with openAtLogin', async () => {
|
||||
mockCollectAutostartState.mockResolvedValue({
|
||||
withArgs: { openAtLogin: true, executableWillLaunchAtLogin: true },
|
||||
noArgs: { openAtLogin: false, executableWillLaunchAtLogin: true },
|
||||
execPath: '/path/to/exe'
|
||||
});
|
||||
|
||||
registerSettingsApi();
|
||||
|
||||
const r = await handlers['settings:autostart-state']!() as {
|
||||
openAtLogin: boolean;
|
||||
diagnostic: { withArgs: { openAtLogin: boolean } };
|
||||
};
|
||||
|
||||
expect(r.openAtLogin).toBe(true);
|
||||
expect(r.diagnostic.withArgs.openAtLogin).toBe(true);
|
||||
expect(r.diagnostic).toHaveProperty('noArgs');
|
||||
expect(r.diagnostic).toHaveProperty('execPath');
|
||||
});
|
||||
|
||||
it('settings:autostart-set calls setLoginItemSettings + returns diagnostic', async () => {
|
||||
mockCollectAutostartState.mockResolvedValue({
|
||||
withArgs: { openAtLogin: false, executableWillLaunchAtLogin: false },
|
||||
noArgs: { openAtLogin: false, executableWillLaunchAtLogin: false },
|
||||
execPath: '/path/to/exe'
|
||||
});
|
||||
|
||||
registerSettingsApi();
|
||||
|
||||
const r = await handlers['settings:autostart-set']!({}, false) as {
|
||||
openAtLogin: boolean;
|
||||
diagnostic: { withArgs: { openAtLogin: boolean } };
|
||||
};
|
||||
|
||||
expect(mockApp.setLoginItemSettings).toHaveBeenCalledWith({ openAtLogin: false, args: ['--hidden'] });
|
||||
expect(r.openAtLogin).toBe(false);
|
||||
expect(r.diagnostic.withArgs.openAtLogin).toBe(false);
|
||||
});
|
||||
|
||||
it('Task 22 — old channels removed', async () => {
|
||||
mockCollectAutostartState.mockResolvedValue({
|
||||
withArgs: { openAtLogin: false, executableWillLaunchAtLogin: false },
|
||||
noArgs: { openAtLogin: false, executableWillLaunchAtLogin: false },
|
||||
execPath: '/path/to/exe'
|
||||
});
|
||||
registerSettingsApi();
|
||||
expect(handlers['settings:get-autostart']).toBeUndefined();
|
||||
expect(handlers['settings:set-autostart']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
65
tests/unit/store.showSettings.test.ts
Normal file
65
tests/unit/store.showSettings.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import type { Note } from '@shared/types';
|
||||
|
||||
const mockApi = {
|
||||
listNotes: vi.fn(async () => [] as Note[]),
|
||||
listTrash: vi.fn(async () => [] as Note[]),
|
||||
getTrashCount: vi.fn(async () => 0),
|
||||
getContinuity: vi.fn(async () => ({ weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null })),
|
||||
getPendingCount: vi.fn(async () => 0),
|
||||
getOllamaStatus: vi.fn(async () => ({ ok: true })),
|
||||
getTodayCount: vi.fn(async () => 0),
|
||||
getFailedCount: vi.fn(async () => 0),
|
||||
listExpired: vi.fn(async () => [] as Note[]),
|
||||
listRecallCandidate: vi.fn(async () => null),
|
||||
restoreNote: vi.fn(async () => {}),
|
||||
permanentDeleteNote: vi.fn(async () => ({ confirmed: true })),
|
||||
emptyTrash: vi.fn(async () => ({ confirmed: true, count: 0 })),
|
||||
trashExpiredBatch: vi.fn(async () => ({ confirmed: true, trashedCount: 0 })),
|
||||
onNoteUpdated: vi.fn(() => () => {}),
|
||||
updateAiFields: vi.fn(async () => {}),
|
||||
setDueDate: vi.fn(async () => {}),
|
||||
setIntent: vi.fn(async () => {}),
|
||||
dismissIntent: vi.fn(async () => {}),
|
||||
ollamaRecheck: vi.fn(async () => ({ ok: true })),
|
||||
retryAllFailed: vi.fn(async () => {}),
|
||||
markRecallOpened: vi.fn(async () => {}),
|
||||
dismissRecall: vi.fn(async () => {}),
|
||||
emitRecallSnoozed: vi.fn(async () => {})
|
||||
};
|
||||
|
||||
vi.mock('../../src/renderer/inbox/api.js', () => ({ inboxApi: mockApi }));
|
||||
|
||||
describe('inbox store — showSettings', () => {
|
||||
beforeEach(async () => {
|
||||
const { useInbox } = await import('../../src/renderer/inbox/store.js');
|
||||
useInbox.setState({
|
||||
notes: [], trashNotes: [], trashCount: 0, showTrash: false,
|
||||
loading: false, tagFilter: null, pendingCount: 0, todayCount: 0,
|
||||
ollamaStatus: { ok: true },
|
||||
continuity: { weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null },
|
||||
expiredCandidates: [], expiredSnoozeUntilMs: null,
|
||||
failedCount: 0, recallCandidate: null, recallSnoozeUntilMs: null,
|
||||
showSettings: false
|
||||
});
|
||||
Object.values(mockApi).forEach((fn) => 'mockClear' in fn && (fn as any).mockClear());
|
||||
});
|
||||
|
||||
it('initial state has showSettings=false', async () => {
|
||||
const { useInbox } = await import('../../src/renderer/inbox/store.js');
|
||||
expect(useInbox.getState().showSettings).toBe(false);
|
||||
});
|
||||
|
||||
it('setShowSettings(true) sets state', async () => {
|
||||
const { useInbox } = await import('../../src/renderer/inbox/store.js');
|
||||
useInbox.getState().setShowSettings(true);
|
||||
expect(useInbox.getState().showSettings).toBe(true);
|
||||
});
|
||||
|
||||
it('setShowSettings(false) toggles back', async () => {
|
||||
const { useInbox } = await import('../../src/renderer/inbox/store.js');
|
||||
useInbox.getState().setShowSettings(true);
|
||||
useInbox.getState().setShowSettings(false);
|
||||
expect(useInbox.getState().showSettings).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { validateEvent } from '@main/services/telemetryEvents.js';
|
||||
import { validateEvent, hasNoteId } from '@main/services/telemetryEvents.js';
|
||||
|
||||
describe('validateEvent — happy path', () => {
|
||||
it('accepts capture event', () => {
|
||||
@@ -333,3 +333,19 @@ describe('validateEvent — recall', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasNoteId', () => {
|
||||
it('returns true for noteId-bearing events', () => {
|
||||
const e1 = validateEvent({ ts: '2026-05-05T00:00:00Z', kind: 'capture', payload: { noteId: 'n1', rawTextLength: 5, hasMedia: false } });
|
||||
const e2 = validateEvent({ ts: '2026-05-05T00:00:00Z', kind: 'recall_shown', payload: { noteId: 'n1', ageDays: 14 } });
|
||||
expect(hasNoteId(e1)).toBe(true);
|
||||
expect(hasNoteId(e2)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for noteId-less events', () => {
|
||||
const e1 = validateEvent({ ts: '2026-05-05T00:00:00Z', kind: 'empty_trash', payload: { count: 5 } });
|
||||
const e2 = validateEvent({ ts: '2026-05-05T00:00:00Z', kind: 'tag_vocab_hit', payload: { tagId: 1, vocabSize: 10 } });
|
||||
expect(hasNoteId(e1)).toBe(false);
|
||||
expect(hasNoteId(e2)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
71
tests/unit/tray.test.ts
Normal file
71
tests/unit/tray.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
default: {
|
||||
app: {
|
||||
on: vi.fn(),
|
||||
getPath: vi.fn(),
|
||||
getVersion: vi.fn(() => '0.2.7'),
|
||||
isPackaged: false,
|
||||
getLoginItemSettings: vi.fn(() => ({ openAtLogin: false }))
|
||||
},
|
||||
Tray: vi.fn(function (this: unknown) {
|
||||
Object.assign(this as object, {
|
||||
setToolTip: vi.fn(),
|
||||
setContextMenu: vi.fn(),
|
||||
on: vi.fn()
|
||||
});
|
||||
}),
|
||||
Menu: { buildFromTemplate: vi.fn((items: unknown) => ({ items })) },
|
||||
nativeImage: { createEmpty: vi.fn() },
|
||||
dialog: {},
|
||||
shell: {},
|
||||
clipboard: {}
|
||||
}
|
||||
}));
|
||||
|
||||
import { createTray, type TrayCallbacks } from '../../src/main/tray';
|
||||
|
||||
describe('tray menu — slim 4 items', () => {
|
||||
beforeEach(() => { vi.clearAllMocks(); });
|
||||
|
||||
function makeCallbacks(): TrayCallbacks {
|
||||
return {
|
||||
showInbox: vi.fn(),
|
||||
showCapture: vi.fn(),
|
||||
showSettings: vi.fn()
|
||||
};
|
||||
}
|
||||
|
||||
it('builds menu with 4 click items + 2 separators', async () => {
|
||||
createTray(makeCallbacks());
|
||||
const electron = (await import('electron')).default;
|
||||
const calls = (electron.Menu.buildFromTemplate as any).mock.calls;
|
||||
const items = calls[calls.length - 1][0];
|
||||
const labels = items.filter((i: any) => i.type !== 'separator').map((i: any) => i.label);
|
||||
expect(labels).toEqual(['한 줄 적기', '보관한 메모 보기', '설정...', '종료']);
|
||||
});
|
||||
|
||||
it('does not include removed items', async () => {
|
||||
createTray(makeCallbacks());
|
||||
const electron = (await import('electron')).default;
|
||||
const calls = (electron.Menu.buildFromTemplate as any).mock.calls;
|
||||
const items = calls[calls.length - 1][0];
|
||||
const labels = items.filter((i: any) => i.type !== 'separator').map((i: any) => i.label);
|
||||
expect(labels).not.toContain('지금 백업');
|
||||
expect(labels).not.toContain('내보내기...');
|
||||
expect(labels).not.toContain('Ollama 재확인');
|
||||
expect(labels).not.toContain('Ollama 설정...');
|
||||
});
|
||||
|
||||
it('"설정..." click invokes showSettings callback', async () => {
|
||||
const cb = makeCallbacks();
|
||||
createTray(cb);
|
||||
const electron = (await import('electron')).default;
|
||||
const calls = (electron.Menu.buildFromTemplate as any).mock.calls;
|
||||
const items = calls[calls.length - 1][0];
|
||||
const settingsItem = items.find((i: any) => i.label === '설정...');
|
||||
settingsItem.click();
|
||||
expect(cb.showSettings).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,13 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: 'node',
|
||||
globals: false,
|
||||
include: ['tests/unit/**/*.test.ts'],
|
||||
include: ['tests/unit/**/*.test.ts', 'tests/unit/**/*.test.tsx'],
|
||||
exclude: ['tests/integration/**', 'tests/e2e/**'],
|
||||
coverage: { reporter: ['text', 'html'] }
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user