Compare commits
62 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a54f134343 | |||
|
|
401414608b | ||
|
|
2ef4802050 | ||
|
|
e3f6c711a7 | ||
|
|
87c18a4c2d | ||
|
|
9e48624495 | ||
|
|
62e68dcfe7 | ||
|
|
8436846657 | ||
|
|
33588b09df | ||
|
|
9a1f0e269a | ||
|
|
bbfd0cccda | ||
|
|
dba64c546f | ||
|
|
662abdb508 | ||
| 2e9a82face | |||
|
|
735d5494f2 | ||
|
|
5801a98a00 | ||
|
|
9feb712c60 | ||
|
|
be125b8ace | ||
|
|
f5e43133be | ||
|
|
143684ce8a | ||
|
|
e60a2a23c8 | ||
|
|
726d155d04 | ||
|
|
19edeab7b1 | ||
|
|
1104a8c666 | ||
| c4e7536086 | |||
|
|
39b8d1e728 | ||
|
|
e32223d28c | ||
|
|
81fbacb21e | ||
|
|
ff1a015226 | ||
|
|
b4c2d85b26 | ||
|
|
7541d3c9e4 | ||
|
|
18deee5900 | ||
|
|
76c23457ee | ||
|
|
88ce78d860 | ||
|
|
07e61bc9e1 | ||
| d59e8388b6 | |||
|
|
3fab44b466 | ||
|
|
f42d03f70c | ||
|
|
ba08190722 | ||
|
|
6070562358 | ||
|
|
c21fca57dd | ||
|
|
49fbed050a | ||
|
|
bc67dea2c8 | ||
|
|
c65d6c810e | ||
|
|
d2c7bf1b39 | ||
|
|
d3150976d4 | ||
|
|
495c3d12a2 | ||
|
|
9eb7abc831 | ||
|
|
d4dce9bf34 | ||
|
|
92375edc31 | ||
|
|
606ac94976 | ||
|
|
fd839f6afe | ||
|
|
facbf54025 | ||
|
|
06a1caf2bd | ||
|
|
7d2b8c95ec | ||
| b20473a593 | |||
|
|
6db449f86d | ||
|
|
29259eef32 | ||
|
|
4d4dac5523 | ||
|
|
9cdea1531c | ||
|
|
f6bea623bf | ||
|
|
470384bf80 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -7,3 +7,7 @@ dist/
|
||||
coverage/
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
||||
# build/ 산출물 — icon.{ico,icns,png} 만 커밋, 중간 산출물은 무시
|
||||
build/icons/
|
||||
build/icon-source.png
|
||||
|
||||
24
assets/icon.svg
Normal file
24
assets/icon.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" role="img" aria-label="Inkling">
|
||||
<!-- 배경 -->
|
||||
<rect width="1024" height="1024" rx="192" fill="#1a6b6e"/>
|
||||
|
||||
<!-- 화살표 marker -->
|
||||
<defs>
|
||||
<marker id="head" markerWidth="14" markerHeight="14" refX="6" refY="7" orient="auto" markerUnits="strokeWidth">
|
||||
<path d="M 0 0 L 12 7 L 0 14 Z" fill="#5fdbc8"/>
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
<!-- sync 호 1개 (270도, 시작점 + 끝 화살표) -->
|
||||
<path d="M 512 132 A 380 380 0 1 1 132 512"
|
||||
stroke="#5fdbc8" stroke-width="36" stroke-linecap="round" fill="none"
|
||||
marker-end="url(#head)"/>
|
||||
<circle cx="512" cy="132" r="28" fill="#5fdbc8"/>
|
||||
|
||||
<!-- 노트 1장 (단일 흰색 paper) -->
|
||||
<rect x="332" y="332" width="360" height="360" rx="32" fill="#ffffff"/>
|
||||
|
||||
<!-- 텍스트 라인 2개 -->
|
||||
<rect x="376" y="436" width="272" height="28" rx="14" fill="#1a6b6e"/>
|
||||
<rect x="376" y="510" width="200" height="28" rx="14" fill="#1a6b6e"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 988 B |
BIN
build/icon.icns
Normal file
BIN
build/icon.icns
Normal file
Binary file not shown.
BIN
build/icon.ico
Normal file
BIN
build/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 353 KiB |
BIN
build/icon.png
Normal file
BIN
build/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
1314
docs/superpowers/plans/2026-05-09-v0210-cut-c-raw-text-revisions.md
Normal file
1314
docs/superpowers/plans/2026-05-09-v0210-cut-c-raw-text-revisions.md
Normal file
File diff suppressed because it is too large
Load Diff
705
docs/superpowers/plans/2026-05-09-v028-cut-a.md
Normal file
705
docs/superpowers/plans/2026-05-09-v028-cut-a.md
Normal file
@@ -0,0 +1,705 @@
|
||||
# v0.2.8 Cut A Implementation Plan — 이미지 렌더링 + 앱 아이콘
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** F22 (NoteCard 의 회색 placeholder → 실제 `<img>` + 클릭 시 OS viewer) + chore (앱 아이콘 SVG → ICO/ICNS/PNG 다중 size + electron-builder 통합).
|
||||
|
||||
**Architecture:** Electron renderer 보안 정책 우회를 위해 main process 에 `inkling-media://` custom protocol 등록 — `<profileDir>/media/<noteId>/<filename>` 을 fetch 가능하게 함. NoteCard 가 protocol URL 을 `<img src>` 로 사용. 클릭 시 IPC `inbox:open-media` → `shell.openPath` 로 OS default viewer 열기. 앱 아이콘은 `assets/icon.svg` (이미 작성) 를 `electron-icon-builder` 로 한 번 빌드 → `build/icon.ico/icns/png` 산출물 git 추적 + electron-builder config 매핑.
|
||||
|
||||
**Tech Stack:** Electron 41 protocol API + React 19 + better-sqlite3 + electron-icon-builder + sharp (SVG 변환 fallback)
|
||||
|
||||
**선행 spec:** [docs/superpowers/specs/2026-05-09-v028-cut-a-design.md](docs/superpowers/specs/2026-05-09-v028-cut-a-design.md)
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### 신규 파일
|
||||
|
||||
| 경로 | 책임 |
|
||||
|---|---|
|
||||
| `src/main/protocol/inklingMedia.ts` | `inkling-media://` scheme 권한 + handler 등록 (path traversal 검사 + inferMime) |
|
||||
| `tests/unit/inklingMedia.test.ts` | protocol handler 단위 테스트 (path traversal 403 / 정상 200 / 404 / mime) |
|
||||
| `assets/icon.svg` | (이미 v0.2.7 turn 에서 생성 — Cut A 시작 전 commit 필요 시 재확인) |
|
||||
|
||||
### 수정 파일
|
||||
|
||||
| 경로 | 변경 |
|
||||
|---|---|
|
||||
| `src/main/index.ts` | top-level `protocol.registerSchemesAsPrivileged` + whenReady 안 `registerInklingMediaProtocol(paths.profileDir)` 호출 + `registerInboxApi` 가 신규 IPC 채널 등록 |
|
||||
| `src/main/ipc/inboxApi.ts` | 신규 IPC `inbox:open-media` 핸들러 (path traversal 검사 + `shell.openPath`) |
|
||||
| `src/preload/index.ts` | `inbox:open-media` 채널 화이트리스트 |
|
||||
| `src/shared/types.ts` | `InboxApi` 에 `openMedia(relPath: string): Promise<{ ok: boolean; reason?: string }>` 시그니처 추가 |
|
||||
| `src/renderer/inbox/api.ts` | inboxApi 객체에 `openMedia` wrapper |
|
||||
| `src/renderer/inbox/components/NoteCard.tsx` | 회색 `<div>` placeholder → `<img src="inkling-media://...">` + onClick |
|
||||
| `tests/unit/NoteCard.test.tsx` | 신규 또는 추가 — `<img>` 렌더 + 클릭 시 IPC 호출 |
|
||||
| `package.json` | devDep `electron-icon-builder` + script `build:icons` + `build.win/mac/linux.icon` 경로 |
|
||||
| `.gitignore` | `build/` 안 `icon.ico/icns/png` 만 추적 (whitelist) |
|
||||
| `build/icon.ico` / `build/icon.icns` / `build/icon.png` | 빌드 산출물 commit |
|
||||
| `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` | F22 진행 상태 🚀 promoted 마킹 |
|
||||
| `docs/superpowers/v024-backlog.md` | (해당 없음 — Cut A 의 작업이 backlog # 와 매핑 X) |
|
||||
|
||||
---
|
||||
|
||||
## Phase 개요
|
||||
|
||||
```
|
||||
Phase 1: F22 inkling-media protocol (Task 1)
|
||||
Phase 2: F22 NoteCard <img> + 클릭 (Task 2)
|
||||
Phase 3: F22 IPC inbox:open-media (Task 3)
|
||||
Phase 4: chore 앱 아이콘 빌드 + config (Task 4)
|
||||
Phase 5: verification + version bump (Task 5)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: inkling-media protocol
|
||||
|
||||
### Task 1: protocol 등록 + handler + 단위 테스트
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `src/main/protocol/inklingMedia.ts`
|
||||
- Create: `tests/unit/inklingMedia.test.ts`
|
||||
- Modify: `src/main/index.ts`
|
||||
|
||||
- [ ] **Step 1: failing test 작성**
|
||||
|
||||
```ts
|
||||
// tests/unit/inklingMedia.test.ts
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { join, sep } from 'node:path';
|
||||
|
||||
const mockReadFile = vi.fn();
|
||||
vi.mock('node:fs/promises', () => ({ readFile: mockReadFile }));
|
||||
|
||||
const mockHandle = vi.fn();
|
||||
vi.mock('electron', () => ({
|
||||
default: {
|
||||
protocol: {
|
||||
registerSchemesAsPrivileged: vi.fn(),
|
||||
handle: mockHandle
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
import { registerInklingMediaProtocol, inferMime } from '../../src/main/protocol/inklingMedia';
|
||||
|
||||
describe('inferMime', () => {
|
||||
it('returns image/png for .png', () => { expect(inferMime('foo.png')).toBe('image/png'); });
|
||||
it('returns image/jpeg for .jpg and .jpeg', () => {
|
||||
expect(inferMime('foo.jpg')).toBe('image/jpeg');
|
||||
expect(inferMime('foo.jpeg')).toBe('image/jpeg');
|
||||
});
|
||||
it('returns image/gif for .gif', () => { expect(inferMime('foo.gif')).toBe('image/gif'); });
|
||||
it('returns image/webp for .webp', () => { expect(inferMime('foo.webp')).toBe('image/webp'); });
|
||||
it('returns application/octet-stream for unknown', () => { expect(inferMime('foo.xyz')).toBe('application/octet-stream'); });
|
||||
});
|
||||
|
||||
describe('inkling-media protocol handler', () => {
|
||||
beforeEach(() => { vi.clearAllMocks(); });
|
||||
|
||||
function getHandler(profileDir: string): (req: Request) => Promise<Response> {
|
||||
registerInklingMediaProtocol(profileDir);
|
||||
return mockHandle.mock.calls[0][1];
|
||||
}
|
||||
|
||||
it('serves valid file with correct mime', async () => {
|
||||
mockReadFile.mockResolvedValueOnce(Buffer.from([1, 2, 3]));
|
||||
const handler = getHandler('/profile');
|
||||
const res = await handler(new Request('inkling-media://media/note1/img.png'));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('content-type')).toBe('image/png');
|
||||
expect(mockReadFile).toHaveBeenCalledWith(join('/profile', 'media', 'note1', 'img.png'));
|
||||
});
|
||||
|
||||
it('returns 403 on path traversal attempt', async () => {
|
||||
const handler = getHandler('/profile');
|
||||
const res = await handler(new Request('inkling-media://media/../etc/passwd'));
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockReadFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 404 when file missing', async () => {
|
||||
mockReadFile.mockRejectedValueOnce(new Error('ENOENT'));
|
||||
const handler = getHandler('/profile');
|
||||
const res = await handler(new Request('inkling-media://media/note1/missing.png'));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 테스트 실행 → fail**
|
||||
|
||||
```bash
|
||||
npm run rebuild:node
|
||||
npx vitest run tests/unit/inklingMedia.test.ts
|
||||
```
|
||||
|
||||
Expected: `Cannot find module '../../src/main/protocol/inklingMedia'`.
|
||||
|
||||
- [ ] **Step 3: protocol handler 작성**
|
||||
|
||||
```ts
|
||||
// src/main/protocol/inklingMedia.ts
|
||||
import electron from 'electron';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { join, normalize, sep, extname } from 'node:path';
|
||||
|
||||
const { protocol } = electron;
|
||||
|
||||
export function registerSchemesAsPrivileged(): void {
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{ scheme: 'inkling-media', privileges: { secure: true, supportFetchAPI: true, stream: true } }
|
||||
]);
|
||||
}
|
||||
|
||||
export function inferMime(filename: string): string {
|
||||
const ext = extname(filename).toLowerCase();
|
||||
switch (ext) {
|
||||
case '.png': return 'image/png';
|
||||
case '.jpg':
|
||||
case '.jpeg': return 'image/jpeg';
|
||||
case '.gif': return 'image/gif';
|
||||
case '.webp': return 'image/webp';
|
||||
default: return 'application/octet-stream';
|
||||
}
|
||||
}
|
||||
|
||||
export function registerInklingMediaProtocol(profileDir: string): void {
|
||||
const mediaRoot = join(profileDir, 'media');
|
||||
protocol.handle('inkling-media', async (req) => {
|
||||
const url = new URL(req.url);
|
||||
// host + pathname 합쳐서 relPath 구성. inkling-media://media/<noteId>/<file> 형식.
|
||||
// URL parse 시 host = 'media', pathname = '/<noteId>/<file>'
|
||||
const relPath = decodeURIComponent((url.host + url.pathname).replace(/^media\//, 'media/'));
|
||||
const target = normalize(join(profileDir, relPath));
|
||||
if (!target.startsWith(mediaRoot + sep) && target !== mediaRoot) {
|
||||
return new Response(null, { status: 403 });
|
||||
}
|
||||
try {
|
||||
const data = await fs.readFile(target);
|
||||
return new Response(new Uint8Array(data), {
|
||||
headers: { 'content-type': inferMime(target) }
|
||||
});
|
||||
} catch {
|
||||
return new Response(null, { status: 404 });
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 테스트 통과 확인**
|
||||
|
||||
```bash
|
||||
npx vitest run tests/unit/inklingMedia.test.ts
|
||||
```
|
||||
|
||||
Expected: 모든 test pass.
|
||||
|
||||
- [ ] **Step 5: src/main/index.ts 통합**
|
||||
|
||||
`src/main/index.ts` 의 import 추가:
|
||||
|
||||
```ts
|
||||
import { registerSchemesAsPrivileged, registerInklingMediaProtocol } from './protocol/inklingMedia.js';
|
||||
```
|
||||
|
||||
top-level (whenReady 이전, app 생성 직후) 에서:
|
||||
|
||||
```ts
|
||||
registerSchemesAsPrivileged();
|
||||
```
|
||||
|
||||
`whenReady` 안에서 (paths 초기화 후):
|
||||
|
||||
```ts
|
||||
registerInklingMediaProtocol(paths.profileDir);
|
||||
```
|
||||
|
||||
- [ ] **Step 6: typecheck + 전체 회귀**
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
npx vitest run
|
||||
```
|
||||
|
||||
Expected: 0 errors + 460 → 466 (+6 inferMime 5 + handler 3).
|
||||
|
||||
- [ ] **Step 7: commit**
|
||||
|
||||
```bash
|
||||
git add src/main/protocol/inklingMedia.ts tests/unit/inklingMedia.test.ts src/main/index.ts
|
||||
git commit -m "feat(v028): inkling-media:// custom protocol + path traversal 검사"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: NoteCard `<img>` + 클릭
|
||||
|
||||
### Task 2: NoteCard placeholder → `<img>` 교체
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/renderer/inbox/components/NoteCard.tsx`
|
||||
- Create: `tests/unit/NoteCard.test.tsx` (없으면 신규)
|
||||
|
||||
- [ ] **Step 1: failing test 작성**
|
||||
|
||||
```tsx
|
||||
// tests/unit/NoteCard.test.tsx
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
|
||||
import type { Note } from '@shared/types';
|
||||
|
||||
const mockOpenMedia = vi.fn(async () => ({ ok: true }));
|
||||
vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
inboxApi: {
|
||||
openMedia: mockOpenMedia,
|
||||
// 다른 inboxApi 메서드 stub (NoteCard 가 사용할 수 있음)
|
||||
permanentDeleteNote: vi.fn(),
|
||||
restoreNote: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
import { NoteCard } from '../../src/renderer/inbox/components/NoteCard';
|
||||
|
||||
const baseNote: Note = {
|
||||
id: 'n1',
|
||||
rawText: 'test',
|
||||
title: 'T',
|
||||
summary: '',
|
||||
tags: [],
|
||||
aiStatus: 'complete',
|
||||
createdAt: '2026-05-09T00:00:00Z',
|
||||
updatedAt: '2026-05-09T00:00:00Z',
|
||||
deletedAt: null,
|
||||
media: [
|
||||
{ id: 'm1', relPath: 'media/n1/img1.png', mime: 'image/png' },
|
||||
{ id: 'm2', relPath: 'media/n1/img2.jpg', mime: 'image/jpeg' }
|
||||
]
|
||||
} as unknown as Note;
|
||||
|
||||
describe('NoteCard — image rendering', () => {
|
||||
beforeEach(() => { vi.clearAllMocks(); cleanup(); });
|
||||
|
||||
it('renders <img> for each media item', () => {
|
||||
render(<NoteCard note={baseNote} isTrash={false} />);
|
||||
const imgs = screen.getAllByRole('img');
|
||||
expect(imgs).toHaveLength(2);
|
||||
expect(imgs[0].getAttribute('src')).toBe('inkling-media://media/n1/img1.png');
|
||||
expect(imgs[1].getAttribute('src')).toBe('inkling-media://media/n1/img2.jpg');
|
||||
});
|
||||
|
||||
it('clicking <img> calls inboxApi.openMedia', () => {
|
||||
render(<NoteCard note={baseNote} isTrash={false} />);
|
||||
fireEvent.click(screen.getAllByRole('img')[0]);
|
||||
expect(mockOpenMedia).toHaveBeenCalledWith('media/n1/img1.png');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 테스트 실행 → fail**
|
||||
|
||||
```bash
|
||||
npx vitest run tests/unit/NoteCard.test.tsx
|
||||
```
|
||||
|
||||
Expected: `getAllByRole('img')` 미존재 (현재 회색 div 만).
|
||||
|
||||
- [ ] **Step 3: NoteCard 갱신**
|
||||
|
||||
[src/renderer/inbox/components/NoteCard.tsx:334-340](src/renderer/inbox/components/NoteCard.tsx#L334-L340) 의 회색 placeholder div 부분을 다음으로 교체:
|
||||
|
||||
```tsx
|
||||
{local.media.length > 0 && (
|
||||
<div style={{ marginTop: 10, display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{local.media.map((m) => (
|
||||
<img
|
||||
key={m.id}
|
||||
src={`inkling-media://${m.relPath}`}
|
||||
alt=""
|
||||
title={m.relPath}
|
||||
onClick={() => { void inboxApi.openMedia(m.relPath); }}
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
objectFit: 'cover',
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
border: '1px solid #e0e0e0'
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
`inboxApi` import 가 이미 있는지 확인 — 있으면 그대로. 없으면 추가:
|
||||
|
||||
```tsx
|
||||
import { inboxApi } from '../api.js';
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 테스트 통과 + typecheck**
|
||||
|
||||
```bash
|
||||
npx vitest run tests/unit/NoteCard.test.tsx
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
Expected: 2 test pass (`openMedia` 가 types.ts 에 아직 없으니 typecheck error 가능 — Task 3 에서 해결). 일단 임시로 NoteCard.test.tsx 의 mock 만 동작.
|
||||
|
||||
만약 typecheck error 발생: `inboxApi.openMedia` 가 InboxApi 인터페이스에 없음 → 일시 `(inboxApi as any).openMedia(m.relPath)` 로 cast. **Task 3 에서 정식 시그니처 추가 후 cast 제거.**
|
||||
|
||||
- [ ] **Step 5: commit (with TODO note for Task 3)**
|
||||
|
||||
```bash
|
||||
git add src/renderer/inbox/components/NoteCard.tsx tests/unit/NoteCard.test.tsx
|
||||
git commit -m "feat(v028): NoteCard 이미지 <img> 렌더링 + onClick (openMedia 시그니처는 Task 3)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: IPC `inbox:open-media`
|
||||
|
||||
### Task 3: main IPC 핸들러 + api.ts wrapper + types
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/main/ipc/inboxApi.ts`
|
||||
- Modify: `src/preload/index.ts`
|
||||
- Modify: `src/shared/types.ts`
|
||||
- Modify: `src/renderer/inbox/api.ts`
|
||||
- Modify: `src/renderer/inbox/components/NoteCard.tsx` (Task 2 cast 제거)
|
||||
|
||||
- [ ] **Step 1: failing test 작성 (IPC handler 단위)**
|
||||
|
||||
```ts
|
||||
// tests/unit/inboxApi-openMedia.test.ts
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { join, sep } from 'node:path';
|
||||
|
||||
const handlers: Record<string, Function> = {};
|
||||
const mockOpenPath = vi.fn(async () => '');
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
default: {
|
||||
ipcMain: { handle: (ch: string, fn: Function) => { handlers[ch] = fn; } },
|
||||
shell: { openPath: mockOpenPath }
|
||||
}
|
||||
}));
|
||||
|
||||
import { registerInboxApi } from '../../src/main/ipc/inboxApi';
|
||||
|
||||
describe('inbox:open-media IPC', () => {
|
||||
beforeEach(() => { vi.clearAllMocks(); for (const k of Object.keys(handlers)) delete handlers[k]; });
|
||||
|
||||
it('opens valid relPath with shell.openPath', async () => {
|
||||
// 기존 registerInboxApi 가 deps 받으므로 minimal stub 만 — paths 만 사용
|
||||
registerInboxApi({ paths: { profileDir: '/profile' } } as any);
|
||||
const r = await handlers['inbox:open-media'](null, 'media/note1/img.png');
|
||||
expect(r).toEqual({ ok: true });
|
||||
expect(mockOpenPath).toHaveBeenCalledWith(join('/profile', 'media/note1/img.png'));
|
||||
});
|
||||
|
||||
it('rejects path traversal', async () => {
|
||||
registerInboxApi({ paths: { profileDir: '/profile' } } as any);
|
||||
const r = await handlers['inbox:open-media'](null, '../etc/passwd');
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toBe('invalid path');
|
||||
expect(mockOpenPath).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 테스트 실행 → fail**
|
||||
|
||||
```bash
|
||||
npx vitest run tests/unit/inboxApi-openMedia.test.ts
|
||||
```
|
||||
|
||||
Expected: handler 미등록.
|
||||
|
||||
- [ ] **Step 3: src/main/ipc/inboxApi.ts 핸들러 추가**
|
||||
|
||||
기존 `registerInboxApi(deps)` 함수 안에 다음 추가 (다른 IPC 핸들러와 같은 패턴):
|
||||
|
||||
```ts
|
||||
import { join, normalize, sep } from 'node:path';
|
||||
import electron from 'electron';
|
||||
const { ipcMain, shell } = electron;
|
||||
|
||||
// registerInboxApi 안:
|
||||
ipcMain.handle('inbox:open-media', async (_e, relPath: string) => {
|
||||
const mediaRoot = join(deps.paths.profileDir, 'media');
|
||||
const target = normalize(join(deps.paths.profileDir, relPath));
|
||||
if (!target.startsWith(mediaRoot + sep) && target !== mediaRoot) {
|
||||
return { ok: false, reason: 'invalid path' };
|
||||
}
|
||||
await shell.openPath(target);
|
||||
return { ok: true };
|
||||
});
|
||||
```
|
||||
|
||||
(기존 deps 타입에 `paths.profileDir` 가 있는지 확인. 없으면 SettingsIpcDeps 와 비슷한 형태로 추가.)
|
||||
|
||||
- [ ] **Step 4: src/shared/types.ts InboxApi 갱신**
|
||||
|
||||
```ts
|
||||
// 기존 InboxApi 인터페이스 안:
|
||||
openMedia(relPath: string): Promise<{ ok: true } | { ok: false; reason: string }>;
|
||||
```
|
||||
|
||||
- [ ] **Step 5: preload + api.ts wrapper**
|
||||
|
||||
`src/preload/index.ts` 의 invoke whitelist 또는 API expose 객체 안에 `'inbox:open-media'` 추가 (기존 `'inbox:*'` 채널 패턴 따름).
|
||||
|
||||
`src/renderer/inbox/api.ts` 의 inboxApi 객체에 추가:
|
||||
|
||||
```ts
|
||||
async openMedia(relPath: string) {
|
||||
return await window.inkling.invoke('inbox:open-media', relPath);
|
||||
}
|
||||
```
|
||||
|
||||
(또는 기존 wildcard re-export 사용 시 자동 노출 — `feb7c62` commit 의 `onNavigate` 처럼.)
|
||||
|
||||
- [ ] **Step 6: NoteCard.tsx cast 제거**
|
||||
|
||||
Task 2 에서 임시 `(inboxApi as any).openMedia` cast 했다면 → `inboxApi.openMedia` 정상 사용으로 변경. typecheck 통과 확인.
|
||||
|
||||
- [ ] **Step 7: 테스트 + typecheck + 회귀**
|
||||
|
||||
```bash
|
||||
npx vitest run tests/unit/inboxApi-openMedia.test.ts
|
||||
npm run typecheck
|
||||
npx vitest run
|
||||
```
|
||||
|
||||
Expected: IPC 2 test pass + 466 → 468 (+2) + 0 typecheck.
|
||||
|
||||
- [ ] **Step 8: commit**
|
||||
|
||||
```bash
|
||||
git add -A src/main/ipc/ src/preload/ src/shared/ src/renderer/ tests/unit/inboxApi-openMedia.test.ts
|
||||
git commit -m "feat(v028): IPC inbox:open-media + path traversal + NoteCard cast 정리"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: 앱 아이콘 빌드 + config
|
||||
|
||||
### Task 4: electron-icon-builder + 산출물 + builder config
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `package.json`
|
||||
- Modify: `.gitignore`
|
||||
- Create: `build/icon.ico`, `build/icon.icns`, `build/icon.png` (빌드 산출물)
|
||||
- (조건부) Create: `scripts/svg-to-png.mjs` (SVG → PNG fallback if needed)
|
||||
|
||||
- [ ] **Step 1: devDep 설치**
|
||||
|
||||
```bash
|
||||
npm install --save-dev electron-icon-builder
|
||||
```
|
||||
|
||||
확인: `package.json` 의 devDependencies 에 `"electron-icon-builder": "^2.x.x"` 추가됨.
|
||||
|
||||
- [ ] **Step 2: package.json scripts + builder config**
|
||||
|
||||
`package.json` 의 scripts 블록에 추가:
|
||||
|
||||
```json
|
||||
"build:icons": "electron-icon-builder --input=assets/icon.svg --output=build --flatten"
|
||||
```
|
||||
|
||||
(만약 SVG 직접 input 안 되면 — Step 3 에서 sharp fallback 으로 분기.)
|
||||
|
||||
`build` 블록 갱신 (기존 win/mac/linux 에 icon 키 추가):
|
||||
|
||||
```json
|
||||
"win": { "icon": "build/icon.ico", ... 기존 그대로 ... },
|
||||
"mac": { "icon": "build/icon.icns", ... 기존 그대로 ... },
|
||||
"linux": { "icon": "build/icon.png", ... 기존 그대로 ... }
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 빌드 실행**
|
||||
|
||||
```bash
|
||||
npm run build:icons
|
||||
```
|
||||
|
||||
Expected:
|
||||
- `build/icon.ico` (Win)
|
||||
- `build/icon.icns` (macOS)
|
||||
- `build/icon.png` (1024x1024, Linux)
|
||||
|
||||
만약 SVG 직접 안 되면 (electron-icon-builder 가 PNG 만 input 받음):
|
||||
|
||||
1. `scripts/svg-to-png.mjs` 작성:
|
||||
|
||||
```js
|
||||
import sharp from 'sharp';
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
|
||||
const [,, input, output, size = '1024'] = process.argv;
|
||||
const svg = readFileSync(input);
|
||||
const png = await sharp(svg).resize(Number(size), Number(size)).png().toBuffer();
|
||||
writeFileSync(output, png);
|
||||
console.log(`OK: ${output} (${size}x${size})`);
|
||||
```
|
||||
|
||||
2. `package.json` scripts 갱신:
|
||||
|
||||
```json
|
||||
"build:icons:png": "node scripts/svg-to-png.mjs assets/icon.svg build/icon-source.png 1024",
|
||||
"build:icons": "npm run build:icons:png && electron-icon-builder --input=build/icon-source.png --output=build --flatten"
|
||||
```
|
||||
|
||||
3. `npm install --save-dev sharp` (이미 있으면 skip).
|
||||
|
||||
4. 다시 `npm run build:icons` 실행.
|
||||
|
||||
- [ ] **Step 4: .gitignore 갱신 (build/ 안 산출물 whitelist)**
|
||||
|
||||
`.gitignore` 의 `build/` 또는 `dist/` 항목 확인. 만약 `build/` 가 ignore 되어 있다면:
|
||||
|
||||
```
|
||||
build/
|
||||
!build/icon.ico
|
||||
!build/icon.icns
|
||||
!build/icon.png
|
||||
```
|
||||
|
||||
(만약 `build/` 가 ignore 안 되어 있다면 — 모두 추적 가능 — Step 4 skip.)
|
||||
|
||||
- [ ] **Step 5: 산출물 확인 + commit**
|
||||
|
||||
```bash
|
||||
ls -la build/icon.ico build/icon.icns build/icon.png
|
||||
```
|
||||
|
||||
Expected: 3 파일 모두 size 양수 (수십 KB 이상).
|
||||
|
||||
```bash
|
||||
git add package.json package-lock.json .gitignore scripts/svg-to-png.mjs build/icon.ico build/icon.icns build/icon.png
|
||||
git commit -m "chore(v028): 앱 아이콘 (assets/icon.svg → ICO/ICNS/PNG) + electron-builder config"
|
||||
```
|
||||
|
||||
(scripts/svg-to-png.mjs 는 sharp fallback 사용 시만 추가.)
|
||||
|
||||
- [ ] **Step 6: typecheck + 전체 회귀**
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
npx vitest run
|
||||
```
|
||||
|
||||
Expected: 0 errors + 468 pass (이전 + Task 1-3 변경 포함). 아이콘 변경은 테스트 영향 X.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: verification + version bump
|
||||
|
||||
### Task 5: 회귀 + dogfood-feedback 마킹 + version bump
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` (F22 promoted 마킹)
|
||||
- Modify: `package.json` (version 0.2.7 → 0.2.8)
|
||||
|
||||
- [ ] **Step 1: 단위 + e2e + typecheck 일괄**
|
||||
|
||||
```bash
|
||||
npm run rebuild:node
|
||||
npm test
|
||||
npm run typecheck
|
||||
npm run rebuild:electron
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
Expected: 모두 pass. 단위 460 → 약 468 (+8: inferMime 5 + protocol handler 3 + NoteCard img 2 + IPC 2 = 12, 일부 mock 충돌 가능 — 실제 카운트 ±). e2e 1/1.
|
||||
|
||||
- [ ] **Step 2: 수동 launch 검증 (Win + macOS 가능 시)**
|
||||
|
||||
```bash
|
||||
npm run start
|
||||
```
|
||||
|
||||
체크리스트:
|
||||
|
||||
- inbox 의 capture-with-image 노트의 thumbnail = 실제 이미지 (회색 사각형 X)
|
||||
- thumbnail 클릭 → OS default viewer (예: Win Photos / macOS Preview) 열림
|
||||
- 새 아이콘이 트레이 / Windows taskbar / dock 정확 표시
|
||||
- 다중 이미지 노트의 grid layout (flex-wrap) 자연스러움
|
||||
|
||||
- [ ] **Step 3: dogfood-feedback.md F22 promoted 마킹**
|
||||
|
||||
`docs/superpowers/specs/2026-04-25-dogfood-feedback.md` 의 F22 entry 헤더 갱신:
|
||||
|
||||
```markdown
|
||||
## F22. NoteCard 이미지가 회색 placeholder 만 표시 (🚀 promoted → docs/superpowers/specs/2026-05-09-v028-cut-a-design.md)
|
||||
```
|
||||
|
||||
진행 상태 line 도 `🚀 promoted` 로 갱신.
|
||||
|
||||
- [ ] **Step 4: package.json version bump**
|
||||
|
||||
```json
|
||||
"version": "0.2.8"
|
||||
```
|
||||
|
||||
- [ ] **Step 5: commit**
|
||||
|
||||
```bash
|
||||
git add docs/superpowers/specs/2026-04-25-dogfood-feedback.md package.json
|
||||
git commit -m "chore(release): v0.2.8 — Cut A (이미지 렌더링 + 앱 아이콘)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:**
|
||||
|
||||
| Spec 섹션 | task |
|
||||
|---|---|
|
||||
| §3-1 protocol 등록 | Task 1 |
|
||||
| §3-2 NoteCard `<img>` | Task 2 |
|
||||
| §3-3 IPC inbox:open-media | Task 3 |
|
||||
| §3-4 보안 (path traversal) | Task 1 + Task 3 |
|
||||
| §4-1 devDep + scripts | Task 4 |
|
||||
| §4-2 builder config | Task 4 |
|
||||
| §4-3 산출물 git 추적 | Task 4 |
|
||||
| §4-4 SVG → PNG fallback | Task 4 (조건부) |
|
||||
| §5 테스트 | 각 task 안 단위 + Task 5 회귀 |
|
||||
| §6 Risk | Task 4 fallback 분기 |
|
||||
|
||||
모든 spec 요구가 task 매핑됨.
|
||||
|
||||
**Placeholder scan**: "TBD" / "TODO" / "implement later" 없음. 각 step 의 코드/명령 실행 가능 형태.
|
||||
|
||||
**Type consistency**:
|
||||
|
||||
- `InboxApi.openMedia(relPath: string): Promise<{ ok: true } | { ok: false; reason: string }>` — Task 3 정의, Task 2 cast → Task 3 cast 제거 일관.
|
||||
- `inferMime(filename: string): string` — Task 1 정의, Task 1 안 사용.
|
||||
- `registerInklingMediaProtocol(profileDir: string): void` / `registerSchemesAsPrivileged(): void` — Task 1 export, src/main/index.ts import 일관.
|
||||
|
||||
이슈 없음.
|
||||
|
||||
---
|
||||
|
||||
## Execution Handoff
|
||||
|
||||
Plan 작성 완료, `docs/superpowers/plans/2026-05-09-v028-cut-a.md` 저장.
|
||||
|
||||
두 가지 실행 옵션:
|
||||
|
||||
**1. Subagent-Driven (recommended)** — fresh subagent per task, two-stage review (spec compliance + code quality), 빠른 iteration
|
||||
|
||||
**2. Inline Execution** — 본 세션에서 task 일괄 실행 + checkpoint 마다 review
|
||||
|
||||
어느 쪽?
|
||||
1867
docs/superpowers/plans/2026-05-09-v029-cut-b.md
Normal file
1867
docs/superpowers/plans/2026-05-09-v029-cut-b.md
Normal file
File diff suppressed because it is too large
Load Diff
1443
docs/superpowers/plans/2026-05-10-v0211-cut-d-fts5-review.md
Normal file
1443
docs/superpowers/plans/2026-05-10-v0211-cut-d-fts5-review.md
Normal file
File diff suppressed because it is too large
Load Diff
1158
docs/superpowers/plans/2026-05-10-v030-cut-e-bidirectional-sync.md
Normal file
1158
docs/superpowers/plans/2026-05-10-v030-cut-e-bidirectional-sync.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1306,9 +1306,668 @@ app.on('activate', () => {
|
||||
- Risk: Windows 사용자 흐름 변경 — 트레이 한 클릭으로 끝나던 동작이 inbox 열기 → 설정 → 항목 클릭 으로 늘어남. 단, 빈도 낮은 동작 (Ollama 설정 변경, 백업 등) 만 이동하고 자주 쓰는 캡처/보관함 은 트레이 잔류 → 체감 마찰 ↓ 예상.
|
||||
|
||||
---
|
||||
## F17. 휴지통의 의미 혼재 — 완료/보관과 버림 구분 (🚀 promoted → docs/superpowers/specs/2026-05-09-v029-cut-b-design.md)
|
||||
|
||||
**진행 상태:** 🚀 promoted → v0.2.9 Cut B. status 4분기 (active/completed/archived/trashed) + AI 자동 분류 버튼 + 자유 텍스트 사유.
|
||||
|
||||
**발견:** 2026-05-09 v0.2.7 release 후 본인 dogfood.
|
||||
|
||||
### 관찰
|
||||
|
||||
- 현재 메모 destination = active(`deleted_at IS NULL`) 또는 휴지통(`deleted_at != NULL`) 단일 분기.
|
||||
- 사용자 의도가 갈리는 case:
|
||||
- (a) **불필요해서 버림** — 잘못 적은 캡처, 더 이상 의미 없음. 휴지통 자연스러움.
|
||||
- (b) **완료해서 더 이상 안 띄움** — 작업 끝났지만 기록은 보존하고 싶음. 휴지통은 의미 부적합 (회수 대상이 아니라 정리 대상).
|
||||
- (c) **급하지 않아 미루기** — 지금은 안 띄우지만 나중에 다시 보고 싶음. 휴지통도 active 도 어색.
|
||||
- 모두 휴지통으로 보내면 의미 혼재 + 30일 후 영구 삭제 정책 (현재) 이 (b)/(c) 의도 깨뜨림.
|
||||
|
||||
### 제안 방향 (옵션 분석)
|
||||
|
||||
**A. status 컬럼 분기** (notes 테이블에 `status`: `'active' | 'completed' | 'archived' | 'trashed'`):
|
||||
- `completed`: 작업 끝, 영구 보관 (회수 대상 아님, 검색 가능)
|
||||
- `archived`: 장기 보관 (회수 가능, 별도 view)
|
||||
- `trashed`: 30일 후 영구 삭제 (현재 휴지통)
|
||||
- inbox 탭 + 휴지통 탭 외에 "완료" / "보관함" 탭 추가
|
||||
- 마이그레이션 비용 + UI 변경 (NoteCard 액션 메뉴 + 헤더 탭)
|
||||
|
||||
**B. AI 자동 분류** (옵션 A 의 extension):
|
||||
- "완료" 키워드 / 패턴 (예: "X 끝남", "처리됨", "결재됨") AI 가 감지 → 자동 `status='completed'` 제안
|
||||
- 사용자가 confirm/dismiss
|
||||
- F19 의 recall 강화와 결합 시 효율 ↑
|
||||
|
||||
**C. 휴지통 의미 유지 + 보관함만 별도** (간단):
|
||||
- 휴지통은 그대로 (불필요 — 30일 영구 삭제)
|
||||
- 보관함만 신규 — `status='archived'` (회수 가능, 영구 보존)
|
||||
- 완료 / 미루기 모두 보관함 사용
|
||||
- 변경 작음, 의미 명확
|
||||
|
||||
### 결정 대기 (v0.2.8 brainstorm)
|
||||
|
||||
- 보관함 detail UI: 별도 탭 vs 별도 라우트 vs filter 토글
|
||||
- "완료" 와 "보관" 을 같은 destination 으로 둘지 (옵션 C) vs 분리 (옵션 A)
|
||||
- AI 자동 분류 (옵션 B) — 정확도 + false-positive risk 측정 필요. v0.2.8 본 cut 보다 나중 가능.
|
||||
|
||||
### 가설·측정
|
||||
|
||||
- 본인 dogfood: 2주간 휴지통 이동한 메모 중 (a)/(b)/(c) 비율 — telemetry 또는 자기 회상 으로 측정. (b)/(c) 비율 ≥30% 면 의미 분기 가치 큼.
|
||||
- 옵션 C 후 보관함 사용 빈도 — active 메모 대비 비율.
|
||||
|
||||
### 범위
|
||||
|
||||
- 옵션 A: 큰 작업 (DB 마이그레이션 + UI 탭 + IPC + telemetry kind 추가). 별도 spec 가치.
|
||||
- 옵션 C: 1주 spike 가능 (status 컬럼 또는 별도 `archived_at` 추가 + UI 탭).
|
||||
- 옵션 B: 추가 cut — 옵션 A/C 안정 후.
|
||||
|
||||
### 영향
|
||||
|
||||
- 메모 의미가 명확해짐 — "버림" vs "정리" 구분.
|
||||
- 휴지통의 30일 영구 삭제 정책 유지 가능 (의미 혼재 X).
|
||||
- F18 (사유 입력) 와 결합 시 사용자 의도 데이터 누적 → recall 알고리즘 (F19) 기여.
|
||||
|
||||
---
|
||||
|
||||
## F18. 메모 휴지통/보관 이동 시 사유 입력 (🚀 promoted → docs/superpowers/specs/2026-05-09-v029-cut-b-design.md)
|
||||
|
||||
**진행 상태:** 🚀 promoted → v0.2.9 Cut B. notes.move_reason 자유 텍스트 컬럼 + MoveStatusModal 사유 입력.
|
||||
|
||||
**발견:** 2026-05-09 v0.2.7 release 후 본인 dogfood.
|
||||
|
||||
### 관찰
|
||||
|
||||
- 휴지통 이동 = silent (단순 `deleted_at` 셋팅).
|
||||
- 사용자 의도 다양 ("완료", "급하지 않음", "더 이상 필요 없음", "잘못 적음" 등) — 데이터로 보존 안 됨.
|
||||
- 사유 데이터가 있으면:
|
||||
- 자기 회상 (왜 버렸는지 다시 보기)
|
||||
- recall 알고리즘 (F19) 학습 입력 — "급하지 않음" 메모는 N일 후 재surface
|
||||
- telemetry 분석 (어떤 종류 메모가 빠르게 trash 되는가)
|
||||
|
||||
### 제안 방향
|
||||
|
||||
**A. 자유 텍스트 reason 필드** (notes.deleted_reason VARCHAR 또는 별도 trash_log 테이블):
|
||||
- 휴지통 이동 시 prompt: "왜 버려? (선택사항)" 한 줄 입력
|
||||
- 빈 값 허용
|
||||
- 검색/필터 가능
|
||||
|
||||
**B. preset 사유 + 자유 텍스트 옵션**:
|
||||
- 빠른 선택: "완료" / "급하지 않음" / "잘못 적음" / "기타"
|
||||
- "기타" 선택 시 자유 텍스트
|
||||
- 통계 가능 (preset 분류)
|
||||
|
||||
**C. F17 의 status 분기 + 사유 결합**:
|
||||
- status='completed' 이면 사유 = "완료" 자동 inferred
|
||||
- status='archived' 이면 사유 입력 prompt
|
||||
- status='trashed' 이면 사유 선택사항
|
||||
|
||||
### 결정 대기
|
||||
|
||||
- 사유 입력 friction vs 데이터 가치 — 매번 묻으면 capture 흐름 깨짐 risk
|
||||
- preset 만 vs 자유 텍스트 — preset 만이 friction 최소
|
||||
- 위치: NoteCard 인라인 dropdown vs 별도 modal
|
||||
|
||||
### 가설·측정
|
||||
|
||||
- 본인 dogfood 1주 — 사유 입력 비율 (skip 없이 입력) ≥50% 면 데이터 가치 충분.
|
||||
- 사유 distribution — preset N개 / "기타" 비율 — preset 명세 검증.
|
||||
|
||||
### 범위
|
||||
|
||||
- A: 1일 (DB column + UI prompt + IPC). 가장 작음.
|
||||
- B: 1.5일 (+ preset UI).
|
||||
- C: F17 의 옵션 A/C 안에 포함 가능 (한 cut).
|
||||
|
||||
### 영향
|
||||
|
||||
- 사용자 의도 데이터 누적 → F19 recall 알고리즘 입력 + 자기 회상 surface.
|
||||
- F17 의 status 분기 가치 ↑ (사유 + status 의 의미 결합).
|
||||
- friction 우려 — preset 또는 skip 가능 으로 완화.
|
||||
|
||||
---
|
||||
|
||||
## F19. 획기적 recall 메커니즘 (✅ promoted v0.2.11 Cut D — A+D 옵션)
|
||||
|
||||
**진행 상태:** ✅ promoted v0.2.11 Cut D — A (FTS5 search) + D (일/주/월 회고 view) 적용. m007 마이그레이션 + `NoteRepository.search` + `reviewAggregate` + SearchBox + ReviewView. B/C/E/F 옵션은 v0.3+ deferred.
|
||||
|
||||
**발견:** 2026-05-09 v0.2.7 release 후 본인 dogfood. "메모의 빠른 기록도 중요하지만 적절한 recall 도 훨씬 중요" — 본인 표현.
|
||||
|
||||
### 관찰
|
||||
|
||||
- 현재 recall surface:
|
||||
- **RecallBanner** (v0.2.3 #6) — 1일 1회, 14~21일 전 candidate 1건. 한 번에 1건 + dismiss 후 다음 날까지 안 뜸.
|
||||
- **RecoveryToast** — 주간 회복 ping (1주 capture 끊김 시 한정).
|
||||
- **search/filter** — tag 필터링만, free text search 없음.
|
||||
- 빠른 기록 (capture) 는 잘 됨 — 한 줄 적기 + 트레이 hotkey 가 ≤1초 완성.
|
||||
- recall 은 약함:
|
||||
- 적극적 recall 안 됨 (메모를 다시 봐야 의미 있는데 surface 가 너무 약함)
|
||||
- search 부재 — "뭐였더라" 회상 시 인덱스 없음
|
||||
- context-based recall 없음 (시간/태그/장소 기반)
|
||||
- AI 가 capture 단계에서만 활용, recall 단계 X
|
||||
- 사용자 표현 "획기적 방법" — 현 RecallBanner 의 점진 개선이 아니라 질적 변화 요구.
|
||||
|
||||
### 제안 방향 (브레인스토밍 후보 — v0.2.8 spec 단계에서 압축)
|
||||
|
||||
**A. Free text search**:
|
||||
- inbox 헤더 search box → raw_text + summary + tags 인덱스 검색
|
||||
- SQLite FTS5 (Full-Text Search) 활용
|
||||
- 가장 작은 단위 첫걸음. 다른 옵션의 prerequisite.
|
||||
|
||||
**B. Context-based recall**:
|
||||
- 시간: "이 시간대 (오전/오후)" 메모 추천
|
||||
- 요일: "이전 월요일" 메모
|
||||
- 태그 클러스터링 — 현재 active tag 와 연관 메모 surface
|
||||
- 사용자가 inbox 보고 있을 때 sidebar 또는 banner 로 노출
|
||||
|
||||
**C. AI-driven 연관 메모 추천**:
|
||||
- 현재 보고 있는 메모와 의미 유사한 옛 메모 surface (embedding-based or LLM-judged)
|
||||
- "이거랑 비슷한 옛날 메모 N건" UI
|
||||
- Ollama embedding API 활용 가능 (모델 추가 부담)
|
||||
|
||||
**D. 회고 view (일/주/월)**:
|
||||
- "지난 주 기록" 한 페이지 — N건 메모 + tag distribution + due date 진행
|
||||
- 정해진 시점 (월요일 아침 등) banner 또는 별도 라우트
|
||||
|
||||
**E. Spaced repetition recall**:
|
||||
- Anki 같은 SM-2 알고리즘 — "잊을 만한 시점" 에 surface
|
||||
- 사용자가 confirm/dismiss → 다음 surface 시점 학습
|
||||
- 현 RecallBanner 의 발전형
|
||||
|
||||
**F. 검색 + AI 자연어 query**:
|
||||
- "지난 달 회의 메모 보여줘" 자연어 → SQL or filter 자동 변환
|
||||
- Ollama function-calling 또는 prompt template
|
||||
- C/D 의 통합 진입점.
|
||||
|
||||
### 결정 대기 (v0.2.8 brainstorm)
|
||||
|
||||
- "획기적" 의 정의: 현 RecallBanner 점진 vs 새 핵심 surface
|
||||
- 1차 cut scope — A (search) 만으로 충분한 가치인가, B/C 와 묶어야 가치인가
|
||||
- AI 의존 (C/F) 의 latency / 정확도 trade-off — Ollama 추론 비용
|
||||
- F17/F18 의 status + 사유 데이터 가 recall 입력으로 활용 가능한가
|
||||
|
||||
### 가설·측정
|
||||
|
||||
- 본인 dogfood: 옛 메모 다시 본 횟수 / 캡처 수 비율 — 현재 < 5% 추정. ≥20% 까지 끌어올리는 것이 "획기적" 기준.
|
||||
- search 사용 빈도 (A 만 도입 시) — 일 1회 미만이면 의미 약함.
|
||||
|
||||
### 범위
|
||||
|
||||
- A: 3-4일 (FTS5 + UI search box).
|
||||
- B: 1주 + (시간/태그/요일 로직 + UI).
|
||||
- C: 2주 (embedding 인프라 추가 — Ollama embedding 모델 + 벡터 저장).
|
||||
- D: 1주 (회고 라우트 + aggregate query).
|
||||
- E: 2주 (SM-2 + UI + 사용자 feedback loop).
|
||||
- F: C 위에 추가 1주.
|
||||
|
||||
→ 한 cut 에 다 넣기 무리. v0.2.8 = A + D 또는 A + B 권장. C/E/F 는 v0.3+.
|
||||
|
||||
### 영향
|
||||
|
||||
- 핵심 가치 (capture → 의미 있는 보존 → 다시 보기) 의 후반 절반 완성.
|
||||
- F1 (Due Date), F4 (Aha Moment), F17 (status 분기) 모두 recall 강화로 가치 ↑.
|
||||
- v0.4 slice 종료 조건 (본인 2주 dogfood 완주) 의 1주차 검증 항목 = recall 효과 측정.
|
||||
- 큰 cut — separate spec 이 자연스러움.
|
||||
|
||||
---
|
||||
|
||||
## F20. 기존 메모 본문 (raw_text) 수정 가능성 (✅ promoted v0.2.10 Cut C — invariant 폐기)
|
||||
|
||||
**진행 상태:** ✅ promoted v0.2.10 Cut C — `raw_text 불변` invariant 폐기, `note_revisions` 테이블로 변경 이력 보존. m006 마이그레이션 + `updateRawText`/`listRevisions`/`restoreRevision` repo API + RevisionHistoryModal UI. AI 재실행 input = current latest raw_text (옵션 B).
|
||||
|
||||
**발견:** 2026-05-09 v0.2.7 release 후 본인 dogfood.
|
||||
|
||||
### 관찰
|
||||
|
||||
- 현재 NoteCard 의 EditableField 는 AI 결과 필드 (title / summary / tags) 만 수정 가능.
|
||||
- raw_text (capture 시점 원본 본문) 는 read-only — 메모리 정책상 **load-bearing invariant**.
|
||||
- dogfood 시 빈도 높은 use case:
|
||||
- **오타 정정** — 빠른 capture 중 잘못 입력 (예: "회으" → "회의")
|
||||
- **의미 보강** — 나중에 보니 정보 부족 ("회의" → "월요일 분기 회의 안건")
|
||||
- **잘못된 캡처 정정** — 음성/clipboard 자동 입력 시 오인식
|
||||
|
||||
### 제안 방향 (옵션 분석 — invariant trade-off)
|
||||
|
||||
**A. raw_text 수정 허용** (invariant 폐기):
|
||||
- 가장 단순 — EditableField 가 raw_text 도 수정 가능.
|
||||
- 비용: capture 시점 원본 lost. AI 재실행 시 input 이 user-edited 본문 — 그 시점 의도와 다를 수 있음.
|
||||
- 영향: 다른 spec (F1 Due Date, F4 Aha Moment 등) 의 raw_text 불변 가정 재검토 필요.
|
||||
|
||||
**B. raw_text 불변 유지 + `user_edited_text` 필드 추가**:
|
||||
- 원본 보존 + 사용자 정정 별도 컬럼.
|
||||
- NoteCard 가 user_edited_text 우선 표시 (없으면 raw_text fallback).
|
||||
- AI 재실행 시 어느 입력을 사용할지 결정 — 원본 (안정성) 또는 사용자 정정 (의도 정확성).
|
||||
- 마이그레이션 + UI 분기 비용.
|
||||
|
||||
**C. raw_text 수정 허용 + revision history**:
|
||||
- `note_revisions` 테이블 — 변경 이력 보존.
|
||||
- 사용자가 옛 버전 회수 가능.
|
||||
- 비용 가장 큼 (스키마 + UI + 회수 흐름).
|
||||
|
||||
**D. invariant 유지 (현 동작)** — 이 피드백 reject:
|
||||
- 정책 사유: capture = "기록", 수정 시 의미 본질 변경.
|
||||
- 그러나 dogfood 실용 마찰 클 가능성.
|
||||
|
||||
### 결정 대기 (v0.2.8 brainstorm — 핵심 결정)
|
||||
|
||||
- **invariant 재검토**: 본인 dogfood 1주 누적 시 raw_text 정정 욕구 빈도 측정. 주 ≥3건 면 옵션 A/B/C 검토 가치.
|
||||
- AI 재실행 input: raw_text vs user_edited — 메모리 정책 `AI 재실행은 user-edited 필드 덮어쓰기 금지` 와 정합 검토.
|
||||
- F1 (Due Date 파서), F4 (Aha Moment) 의 raw_text 가정 영향 분석.
|
||||
|
||||
### 가설·측정
|
||||
|
||||
- 본인 dogfood 1주: 메모 정정 욕구 발생 횟수 / 캡처 수. ≥10% 면 옵션 A 또는 B 강한 motivation.
|
||||
- 옵션 B 시 user_edited 사용 비율 — ≥30% 면 분기 가치.
|
||||
|
||||
### 범위
|
||||
|
||||
- A: 1일 (EditableField 가 raw_text 도 처리 + IPC 수정).
|
||||
- B: 3-4일 (스키마 마이그레이션 + UI 분기 + AI 재실행 정책 결정).
|
||||
- C: 1주 + (revisions 테이블 + UI 회수 흐름).
|
||||
|
||||
### 영향
|
||||
|
||||
- **load-bearing invariant 재검토** — 메모리 정책 갱신 가치.
|
||||
- F1 / F4 / F17 / F19 모두 raw_text 가정 재검토 영향.
|
||||
- 사용자 마찰 ↓ (오타/오인식 정정 가능) vs 기록 본질 약화 trade-off.
|
||||
- 옵션 B 가 가장 균형 — 원본 보존 + 사용자 정정 모두 가능.
|
||||
|
||||
---
|
||||
|
||||
## F21. 다기기 git-based 동기화 (✅ promoted v0.3.0 Cut E — 양방향 + Configure UI + Conflict)
|
||||
|
||||
**진행 상태:** ✅ promoted v0.3.0 Cut E — 옵션 A (자동 rebase) + B (Configure UI) + C (conflict UI). SyncService 양방향 6단계 (export → commit → fetch → rebase → re-import → push), `NoteRepository.upsertFromSync` (sync 전용 3 분기), `SettingsService.{getSyncRepoUrl,isAutoSyncEnabled,getSyncIntervalMin}` + `SyncTimer` (자동 주기 + reconfigure), `SyncSection` UI + `ConflictModal` (local/remote 2 choice, both deferred v0.3.1+). 단위 608 → 679. dogfood 1주 soak 후 Cut F (F24 vision) 진입.
|
||||
|
||||
**발견:** 2026-05-09 v0.2.7 release 후 본인 dogfood. 사용자 표현: "그 중심에 git repo 를 쓸 수 있으면 좋겠어".
|
||||
|
||||
### 관찰 (현재 동작)
|
||||
|
||||
[src/main/services/SyncService.ts](src/main/services/SyncService.ts) 의 `sync()`:
|
||||
|
||||
1. SQLite → markdown export (ExportService) 를 `<profileDir>/sync/` 에 산출
|
||||
2. `git add -A && git commit -m "chore(notes): sync <ts>" && git push`
|
||||
3. `not_configured` 시 skip (`<profileDir>/sync/` 가 git repo + origin remote 가져야 함)
|
||||
|
||||
즉 **outbound only** — 다른 기기로 보내는 흐름은 있음.
|
||||
|
||||
### 누락 부분 (다기기 동기화 충족 X)
|
||||
|
||||
- **Pull**: 다른 기기에서 push 한 변경 가져오기 — `git fetch && git pull` 흐름 부재
|
||||
- **Re-import**: pull 한 markdown 을 SQLite 로 다시 적재 (`ImportService` 가 활용 가능 — F6 백업 복원 흐름과 유사)
|
||||
- **Conflict resolution**: 같은 노트를 두 기기에서 동시 수정 시 우선순위 + merge 정책
|
||||
- **Configure UI**: 사용자가 `<profileDir>/sync/` 에 git init + `git remote add origin <url>` 수동 — GUI 부재
|
||||
- **raw_text 불변 + user-edited 덮어쓰기 금지** (메모리 정책) 다기기 환경에서 어느 기기 user-edited 가 정답인가 결정 필요 — F20 (raw_text 수정 옵션 B `user_edited_text`) 와 강하게 연관
|
||||
|
||||
### 제안 방향
|
||||
|
||||
**A. SyncService 양방향화** (가장 작은 첫걸음):
|
||||
- `sync()` 가 push 전 pull 먼저 — `git fetch && git rebase origin/main` 또는 `merge`
|
||||
- pull 후 변경된 markdown → re-import 하여 SQLite 갱신
|
||||
- conflict 시 user prompt (또는 일단 fail + 수동 resolve 안내)
|
||||
|
||||
**B. Configure UI** (설정 페이지 안 신규 sub-section 또는 별도 섹션):
|
||||
- "동기화 저장소 URL" 입력 → SyncService 가 `<profileDir>/sync/` 에 git init + remote add origin 자동
|
||||
- 인증 안내 (SSH key / token) — Gitea/GitHub 양쪽 호환
|
||||
- 마지막 sync 결과 + 시간 표시
|
||||
|
||||
**C. Conflict resolution UX**:
|
||||
- 옵션 1: `git merge` 시도 → 실패하면 "양쪽 비교" UI (note id 단위, 각 기기 본문 + AI 결과 비교)
|
||||
- 옵션 2: timestamp 기반 자동 (마지막 수정 우선) — 데이터 lost risk
|
||||
- 옵션 3: "내 기기 우선" / "원격 우선" / "수동 merge" 사용자 선택
|
||||
|
||||
**D. F20 (user_edited_text) 옵션 B 와 결합**:
|
||||
- raw_text = 캡처 시점 원본 (절대 충돌 X — capture 는 한 기기에서만)
|
||||
- user_edited_text = 다기기 sync 대상. timestamp + conflict resolution 적용
|
||||
- AI 결과 (title/summary/tags) = 어느 기기 가장 최근 결과 사용
|
||||
|
||||
### 결정 대기 (v0.2.8 brainstorm)
|
||||
|
||||
- 충돌 처리 정책 — A 옵션의 default (rebase / merge / fail) 결정
|
||||
- F20 invariant 결정 후 결합 — F20 옵션 B (`user_edited_text`) 가 채택되면 sync 가치 ↑
|
||||
- pull 후 re-import 시점 — manual ("지금 동기화" 클릭) vs 주기적 (5분/30분 자동)
|
||||
- 다기기 운영 시 Aha Moment metric (7일/3일) 측정 — sync lag 영향
|
||||
|
||||
### 가설·측정
|
||||
|
||||
- 본인 dogfood: Mac + Windows 두 기기 사용 시 (메모: 본인 Mac=업무, Windows=개인+dogfood) — 현재 single-device. 양방향 sync 후 Mac dogfood 가능성 측정.
|
||||
- conflict 발생 빈도 — 양 기기에서 동일 노트 수정 케이스 (낮을 것 추정).
|
||||
|
||||
### 범위
|
||||
|
||||
- A (양방향 sync) + B (Configure UI): 1주 spike 가능.
|
||||
- A + B + C (conflict UI): 2주.
|
||||
- D (F20 결합): F20 채택 후 추가 1주.
|
||||
|
||||
### 영향
|
||||
|
||||
- 다기기 운영 → Aha Moment metric 직접 기여 (Mac 업무 시간에도 capture 가능).
|
||||
- F20 (raw_text 수정) + F19 (recall) 모두 sync 데이터 일관성 의존.
|
||||
- v0.4 slice 종료 조건 (본인 2주 dogfood 완주) 의 핵심 인프라 — 단일 기기 dogfood 의 한계 극복.
|
||||
|
||||
---
|
||||
|
||||
## F22. NoteCard 이미지가 회색 placeholder 만 표시 (🚀 promoted → docs/superpowers/specs/2026-05-09-v028-cut-a-design.md)
|
||||
|
||||
**진행 상태:** 🚀 promoted → v0.2.8 Cut A. inkling-media:// custom protocol + NoteCard `<img>` + IPC inbox:open-media + OS viewer 클릭. (commit 470384b + f6bea62 + 9cdea15)
|
||||
|
||||
**발견:** 2026-05-09 v0.2.7 release 후 본인 dogfood. 사용자 표현: "이미지 렌더링이 제대로 되지 않는 것 같아".
|
||||
|
||||
### 관찰
|
||||
|
||||
[src/renderer/inbox/components/NoteCard.tsx:334-340](src/renderer/inbox/components/NoteCard.tsx#L334-L340):
|
||||
|
||||
```tsx
|
||||
{local.media.length > 0 && (
|
||||
<div style={{ marginTop: 10, display: 'flex', gap: 6 }}>
|
||||
{local.media.map((m) => (
|
||||
<div key={m.id} style={{ width: 48, height: 48, background: '#eee', borderRadius: 4 }} title={m.relPath} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
**`<img>` 가 아니라 회색 `<div>`**. 즉 capture 시 첨부한 이미지가 보관함에서 회색 48x48 사각형만 표시 — title attribute (relPath) 만 hover tooltip 으로 보임. 실제 이미지 렌더링 자체 부재.
|
||||
|
||||
`MediaStore` 는 `<profileDir>/media/<noteId>/<filename>` 절대 경로로 파일 보존. relPath = `media/<noteId>/<filename>` 형태. Electron renderer 에서 직접 `file://` 또는 custom protocol 로 src 매핑 필요.
|
||||
|
||||
### 추정 원인 (placeholder 인 이유)
|
||||
|
||||
- 초기 v0.4 slice 단계에 thumbnail 렌더는 후순위로 미루고 placeholder 로 둔 채 그대로 잔류.
|
||||
- Electron renderer 가 raw `file://` 경로 보안 정책상 직접 접근 어려움 — custom protocol (`inkling-media://`) 또는 IPC handle 로 base64 변환 필요.
|
||||
|
||||
### 제안 방향
|
||||
|
||||
**A. Custom protocol 등록** (권장):
|
||||
- main process 에서 `protocol.registerFileProtocol('inkling-media', ...)` 등록 — `<profileDir>/media/` 하위 경로를 `inkling-media://<noteId>/<filename>` 으로 매핑
|
||||
- NoteCard: `<img src={`inkling-media://${m.relPath.slice(6)}`} alt="" />`
|
||||
- 보안: scheme 별 allowlist + protocol handler 가 path traversal 검사
|
||||
|
||||
**B. IPC 로 base64 변환** (작은 이미지에 한정):
|
||||
- `inboxApi.getMediaDataUrl(relPath)` → main 이 file 읽고 `data:image/png;base64,...` 반환
|
||||
- renderer 에 `<img src={dataUrl} />`
|
||||
- 큰 이미지 (수 MB) 시 메모리 부담
|
||||
|
||||
**C. file:// 직접** (Electron 특수 설정 필요):
|
||||
- `webPreferences.webSecurity: false` — 보안 약화 risk. **Reject**.
|
||||
|
||||
### 결정 대기
|
||||
|
||||
- thumbnail 표시 vs 클릭 시 full-size modal — UX 선택
|
||||
- 다중 이미지 (현재 capture 가 N개 첨부 가능) 의 grid layout
|
||||
- 이미지 alt text — capture 시 입력 또는 AI 자동 생성 (옵션)
|
||||
|
||||
### 가설·측정
|
||||
|
||||
- 본인 dogfood: capture 시 이미지 첨부 빈도 — 현재 추정치 < 일 1건. ≥ 일 1건이면 이미지 흐름 가치 큼.
|
||||
- 옵션 A 도입 후 NoteCard 클릭 시 modal full-size 사용 빈도 — UX 선택 검증.
|
||||
|
||||
### 범위
|
||||
|
||||
- A (custom protocol + thumbnail): 1-2일.
|
||||
- A + click → full-size modal: + 0.5일.
|
||||
- alt text AI 생성: 별도 cut.
|
||||
|
||||
### 영향
|
||||
|
||||
- 명확한 bug 수정 — 사용자 마찰 명백.
|
||||
- F19 (recall) 의 시각적 단서 — 이미지 보일 때 메모 회상 ↑.
|
||||
- v0.2.8 narrow scope 에 포함 가치 (1-2일 작업).
|
||||
|
||||
---
|
||||
## F23. 로컬 LLM 활성화 옵션 (Ollama-less 모드) (🚀 promoted → docs/superpowers/specs/2026-05-09-v029-cut-b-design.md)
|
||||
|
||||
**진행 상태:** 🚀 promoted → v0.2.9 Cut B. ai_status='disabled' enum + Onboarding wizard + 설정 토글 + Banner/HealthChecker 비활성 + requeueDisabled.
|
||||
|
||||
**발견:** 2026-05-09 v0.2.7 release 후 본인 dogfood. 사용자 표현: "Ollama 를 쓰지 못하는 환경을 위해 로컬 llm 활성화 옵션을 만들고, Ollama 를 안 쓰는 경우 그냥 원문만 저장하고 보여주도록".
|
||||
|
||||
### 관찰
|
||||
|
||||
- 현재 capture 흐름: `CaptureService.create()` → notes INSERT (ai_status='pending') → `pending_jobs` enqueue → AiWorker → Ollama 호출 → title/summary/tags 채움 → NoteCard 표시.
|
||||
- Ollama 의존 강제 — 회사 환경 / offline / low-resource device / 사용자 선호도 등 Ollama 안 쓰고 싶은 경우 옵션 부재.
|
||||
- 현재 Ollama 끊긴 동안 capture 는 가능 (notes INSERT + pending_jobs enqueue), 단 ai_status 가 'pending' 또는 'failed' 로 고착 → FailedBanner / OllamaBanner 가 지속 노출 (사용자 불필요한 마찰).
|
||||
|
||||
### 제안 방향
|
||||
|
||||
**핵심 설계: "AI 활성화" 토글 (default ON, OFF 시 raw-only 모드)**
|
||||
|
||||
설정 페이지 → AI 제공자 섹션 → 새 토글 "AI 자동 처리 사용" (default ON).
|
||||
|
||||
#### A. OFF 일 때 동작
|
||||
|
||||
1. `CaptureService.create()` 가 notes INSERT 시 ai_status='disabled' (신규 enum value) + pending_jobs enqueue **skip**.
|
||||
2. AiWorker 가 'disabled' 상태 노트를 pull 안 함.
|
||||
3. NoteCard 표시:
|
||||
- title = raw_text 첫 줄 (또는 빈 값) — fallback rendering
|
||||
- summary = 빈 값 (UI 에서 hide)
|
||||
- tags = 빈 배열 (tag filter 비활성)
|
||||
- raw_text 그대로 노출 (이미 NoteCard 가 표시함)
|
||||
4. OllamaBanner / FailedBanner 비활성 (AI off 면 의미 없음).
|
||||
5. 트레이/banner 의 "지금 AI 처리" / "Ollama 재확인" surface 비활성.
|
||||
|
||||
#### B. OFF → ON 전환 시
|
||||
|
||||
기존 ai_status='disabled' 노트들 — 두 옵션:
|
||||
|
||||
- **B1**: 기존 disabled 잔류 (사용자가 "지금 처리" 버튼 1회 눌러야 재처리). 안전.
|
||||
- **B2**: 자동 enqueue (모든 disabled → pending + pending_jobs INSERT). 사용자 의도 불일치 risk (옛 메모 갑자기 AI 처리되며 큐 폭증).
|
||||
|
||||
추천: **B1** — 사용자 명시적 trigger 만 옛 노트 처리. 새 노트는 ON 이후 capture 분만 자동 처리.
|
||||
|
||||
#### C. ON → OFF 전환 시
|
||||
|
||||
- 큐의 pending 잔재 — drain (현 실행 중) + enqueue stop.
|
||||
- 옵션: pending → disabled 자동 변환 (대량 cleanup) vs 잔류 (다시 ON 시 재개).
|
||||
- 추천: 잔류 (사용자 의도 보존).
|
||||
|
||||
### 결정 대기 (v0.2.8 brainstorm)
|
||||
|
||||
- ai_status 새 enum 값 'disabled' vs 별도 컬럼 (notes.ai_enabled BOOLEAN)
|
||||
- B1 vs B2 default — B1 추천.
|
||||
- raw-only 모드의 NoteCard title fallback — raw_text 첫 줄 / 첫 N자 / 사용자 입력 prompt.
|
||||
- F19 (recall) 가 raw-only 모드에서도 동작 — tag 부재 시 시간 기반 candidate 만.
|
||||
- F17 (status 분기) 의 AI 자동 분류 (옵션 B) 가 raw-only 모드에서 비활성 — 정합 검토.
|
||||
- 처음 설치 시 default — 메모리 정책 "주 타깃 OS 는 Windows + 본인 dogfood 우선" 고려, default ON 유지 (LAN Ollama 가정).
|
||||
|
||||
### 가설·측정
|
||||
|
||||
- 본인 dogfood: 회사 환경 (Mac 업무) 에서 Ollama 못 쓰는 시간 — raw-only 모드로 capture 만 가능해지면 dogfood metric 영향 측정.
|
||||
- 사용자 (외부) 가 raw-only 시작 후 ON 전환 비율 — onboarding 흐름 검증.
|
||||
|
||||
### 범위
|
||||
|
||||
- A 기본 (토글 + ai_status 'disabled' + capture skip): 2-3일.
|
||||
- B/C 전환 정책 (B1 추천 — 가장 작음): + 0.5일.
|
||||
- raw-only NoteCard fallback (title=raw_text 첫 줄): + 0.5일.
|
||||
- 합 3-4일 — v0.2.8 narrow scope 가능.
|
||||
|
||||
### 영향
|
||||
|
||||
- **Ollama 의존성 옵션화** — 환경 다양성 (회사 / offline / 저사양) 대응.
|
||||
- F17 (status 자동 분류 옵션 B) 무력화 — AI off 시 옵션 C (보관함만 별도) 가 default 흐름.
|
||||
- F19 (recall) 가 tag 데이터 부재로 단순화 — context-based / spaced repetition 기반 가능 (search 는 raw_text 가 인덱스 source).
|
||||
- F1 (Due Date 파서) 는 raw_text 정규식 기반이라 raw-only 모드에서도 동작 ✅.
|
||||
- 메모리 정책 "raw_text 불변" 의 가치 ↑ — raw_text 자체가 1차 surface 가 되므로 보존 의의 강화.
|
||||
- v0.4 slice 종료 조건 (본인 2주 dogfood 완주) 의 대안 경로 — Mac 업무 시간 raw-only capture + 저녁 Windows 에서 batch AI 처리 가능.
|
||||
|
||||
---
|
||||
|
||||
## F24. 이미지 멀티모달 AI 분석 (🌱 raw — v0.2.8/v0.3 후보, capability gated)
|
||||
|
||||
**진행 상태:** 🌱 raw — Ollama vision 모델 (llava / llama3.2-vision / gemma3-multimodal 등) 활용. 사용자 표현: "가능할 경우만 하면 될 것 같다" — capability detection + opt-in 명시.
|
||||
|
||||
**발견:** 2026-05-09 v0.2.7 release 후 본인 dogfood. F22 (이미지 렌더링) + F23 (Ollama-less 모드) 와 강하게 연관.
|
||||
|
||||
### 관찰
|
||||
|
||||
[src/main/ai/LocalOllamaProvider.ts:33-42](src/main/ai/LocalOllamaProvider.ts#L33-L42) — 현재 `/api/generate` 호출 시 text-only prompt:
|
||||
|
||||
```ts
|
||||
const res = await request(`${this.endpoint}/api/generate`, {
|
||||
prompt: buildPrompt(input.text, input.todayKst, input.dueDateCandidates, input.vocab ?? []),
|
||||
...
|
||||
});
|
||||
```
|
||||
|
||||
`InferenceProvider.GenerateInput` 인터페이스에 `images` 필드 부재. Ollama API 자체는 `images: string[]` (base64) 지원하나 모델이 vision 지원해야 함.
|
||||
|
||||
이미지가 있는 capture (paste 또는 첨부) 의 경우:
|
||||
- 현재: title/summary/tags 모두 raw_text 기반만 — 이미지 내용 ignore
|
||||
- 이미지만 있는 capture (raw_text 빈 값): AI 처리 무의미 → 사용자가 빈 메모 받음
|
||||
|
||||
### 제안 방향
|
||||
|
||||
**A. Vision capability detection + opt-in 모델 선택** (권장):
|
||||
1. 설정 페이지 → AI 제공자 섹션 → "이미지 분석 모델" 입력 (별도 필드, default 빈 값 = 비활성).
|
||||
2. main process 가 startup / 사용자 트리거 시 `GET /api/tags` 로 사용자 Ollama 의 모델 목록 조회 + vision capable 모델 (llava / llama3.2-vision / gemma3 family 등) 자동 감지 → 추천 표시.
|
||||
3. 사용자가 vision 모델 명시 + 본인 cluster 에 해당 모델 pull 되어 있어야 enable.
|
||||
4. capability 부재 (모델 없음 / endpoint 끊김 / 빈 값) 면 vision 분석 skip — 텍스트만 + raw_text 처리.
|
||||
|
||||
**B. 분석 흐름 (vision enabled 시)**:
|
||||
|
||||
각 capture 의 이미지 + raw_text 결합 prompt 전송:
|
||||
|
||||
- raw_text 있음 + 이미지 있음 → "다음 텍스트 + 이미지를 종합 요약" prompt
|
||||
- raw_text 빈 값 + 이미지만 → "이미지 내용 요약 + 한국어 태그" prompt
|
||||
- AI 응답 형식은 기존 (`title`/`summary`/`tags`) 그대로 — vision 결과가 raw_text 자리에 들어가 자연스럽게 채워짐
|
||||
|
||||
**C. InferenceProvider 인터페이스 확장**:
|
||||
|
||||
```ts
|
||||
interface GenerateInput {
|
||||
text: string;
|
||||
images?: Array<{ base64: string; mime: string }>;
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
`LocalOllamaProvider` 가 `images` 비어 있지 않으면 Ollama `/api/generate` 의 `images: [base64...]` 필드 추가 + vision 모델로 호출. provider 가 vision capability 부재 시 images ignore (graceful degrade).
|
||||
|
||||
### 결정 대기 (v0.2.8/v0.3 brainstorm)
|
||||
|
||||
- vision 모델 default 추천 — 한국어 + 이미지 동시 잘하는 모델 (llama3.2-vision / gemma3 family 등 — dogfood 검증 후 결정)
|
||||
- 이미지 base64 변환 위치 — main process (MediaStore 가 이미 file path 보유) 가 자연스러움
|
||||
- 처리 비용 — vision 모델 추론 시간 (수 초~수십 초) + 메모리 부담. capture 흐름 backend 처리 라 사용자 대기 X.
|
||||
- raw-only 모드 (F23 OFF) 와 정합 — F23 토글 OFF 시 vision 도 OFF (자명).
|
||||
- 이미지 alt text 자동 생성 (F22 가설 슬롯) 가능 — vision 모델이 alt 도 같이 출력하도록 prompt 설계.
|
||||
|
||||
### 가설·측정
|
||||
|
||||
- 본인 dogfood: capture 시 이미지 첨부 비율 — 현재 추정 < 일 1건. 일 ≥ 1건 누적 + vision 분석 결과 정확도 측정.
|
||||
- vision 결과의 사용자 수정 비율 — 높으면 모델 부적합 (다른 모델 / prompt 튜닝).
|
||||
- "이미지만 있는 capture" 의 처리 가능성 — vision 으로 의미 있는 title/summary 생성 가능한지 검증.
|
||||
|
||||
### 범위
|
||||
|
||||
- A (capability detection + 설정) + B (vision prompt) + C (InferenceProvider 확장): 1주.
|
||||
- 이미지 alt text 자동 생성: + 0.5일.
|
||||
- F22 (이미지 렌더링) 가 선행 prerequisite — 같이 묶으면 1.5주.
|
||||
|
||||
### 영향
|
||||
|
||||
- 멀티모달 capture — "사진만 찍고 끝" 흐름 가능 (회의 화이트보드 / 영수증 / 메뉴판 등).
|
||||
- F22 (이미지 렌더링) 의 가치 ↑ — 보이는 이미지 + AI 가 의미 추출.
|
||||
- F19 (recall) 강화 — vision-derived tags + summary 가 이미지 내용 기반 검색 가능하게 함.
|
||||
- F23 (Ollama-less 모드) 와 trade-off — AI 토글 OFF 시 vision 도 자동 OFF (자명, 추가 분기 X).
|
||||
- 메모리 정책 "raw_text 불변" 그대로 — vision 결과는 summary/tags 에만 반영.
|
||||
- Ollama 의존성 ↑ (vision 모델 추가 pull 부담) — F23 OFF 시 회피 가능.
|
||||
|
||||
---
|
||||
|
||||
## F25. 사이드바 — 메모 리스트 + 메모 저장소 리스트 (🌱 raw — v0.2.8/v0.3 후보, layout 큰 변화)
|
||||
|
||||
**진행 상태:** 🌱 raw — layout 재구성 + "저장소" 정의 필요. v0.2.8 brainstorm 시 F17/F21 와 함께 triage.
|
||||
|
||||
**발견:** 2026-05-09 v0.2.7 release 후 본인 dogfood. 사용자 표현: "사이드에 메모 리스트, 메모 저장소 리스트도 보여줬으면".
|
||||
|
||||
### 관찰
|
||||
|
||||
- 현재 inbox layout = single-pane (header + NoteCard list). 사이드바 부재.
|
||||
- "메모 저장소" 의미 모호 — 추정 옵션:
|
||||
- (a) **다중 profile/database** — "회사 메모 / 개인 / 학습" 등 분리. 현재 single profile (`<userData>/default/`).
|
||||
- (b) **카테고리/폴더** — 단일 DB 안 그룹화. 현재 tag 만 존재.
|
||||
- (c) **다중 sync repo** — F21 의 git sync 가 여러 remote 가능. 본인 + 외부 collaborator.
|
||||
- 사용자 의도 = (a) 가 가장 자연스러움 ("저장소" 표현). 본인이 dogfood 중 분리 욕구 발생 추정.
|
||||
|
||||
### 제안 방향 (사이드바 + 저장소 모델 분리 결정)
|
||||
|
||||
#### 1. 사이드바 layout
|
||||
|
||||
좌측 또는 우측 column. 폭 240-320px. 상단: 저장소 selector. 하단: 메모 list (현 inbox compact view).
|
||||
|
||||
```
|
||||
┌───────────┬────────────────────────────┐
|
||||
│ 저장소 │ inbox / 휴지통 (탭) │
|
||||
│ • 기본 ├────────────────────────────┤
|
||||
│ • 회사 │ │
|
||||
│ • 학습 │ NoteCard detail or grid │
|
||||
├───────────┤ │
|
||||
│ 메모 리스트│ │
|
||||
│ - title 1│ │
|
||||
│ - title 2│ │
|
||||
│ - title 3│ │
|
||||
└───────────┴────────────────────────────┘
|
||||
```
|
||||
|
||||
#### 2. "저장소" 정의 (큰 결정)
|
||||
|
||||
**A. 다중 profile** (`<userData>/<profile-name>/` 별도 DB):
|
||||
- 가장 정의 명확 — sqlite db / media / sync 모두 저장소 단위 분리
|
||||
- 사용자가 새 저장소 생성 → migrations 새로 적용 → 빈 DB
|
||||
- 메모 / 태그 / due / pending_jobs 모두 저장소 안 격리
|
||||
- 마이그레이션 부담 — `resolveProfilePaths` 가 'default' fixed 인 부분 다중 지원
|
||||
- AiWorker / HealthChecker / SyncService 등 모두 active profile 기반 재초기화 필요 (큰 refactor)
|
||||
|
||||
**B. 카테고리/폴더** (단일 DB 안 `notebook_id` 컬럼):
|
||||
- 가벼움 — schema 추가만, services 영향 적음
|
||||
- 단점: "회사 메모 따로 백업/sync" 불가 (단일 DB 백업)
|
||||
- F17 (status 분기) 와 비슷한 의미 layer (status + notebook 두 분기 가치 충돌 가능)
|
||||
|
||||
**C. 단일 profile + 다중 git remote** (F21 sync 관련):
|
||||
- 한 DB → 여러 sync 대상 (회사 git + 개인 git)
|
||||
- "저장소" 가 sync target 의미라면 자연스러움
|
||||
- 데이터 자체는 분리 안 됨 — "메모 저장소" 이름과 의미 불일치 risk
|
||||
|
||||
추천: **A** — "저장소" 의 사용자 의도가 데이터 분리 (회사/개인) 라면 명확. 그러나 큰 refactor.
|
||||
대안: **B** — 빠른 구현, 사용자 의도 (a) 일부 충족.
|
||||
|
||||
#### 3. 메모 리스트 (사이드바 안)
|
||||
|
||||
현 inbox NoteCard list 의 compact 버전 — title + tag chip 만, raw_text/summary hide. 클릭 시 main 에서 NoteCard expand.
|
||||
|
||||
main pane 의 view 옵션:
|
||||
- (i) 단일 detail (사이드 클릭으로 전환)
|
||||
- (ii) 현재처럼 NoteCard grid (사이드는 scroll/jump 용)
|
||||
|
||||
추천: (ii) — 현 흐름 보존, 사이드바는 navigation 보조.
|
||||
|
||||
### 결정 대기 (v0.2.8 brainstorm)
|
||||
|
||||
- "저장소" 정의 — A/B/C 중 사용자 의도 확인 (직접 묻거나, dogfood 중 욕구 정확히 파악)
|
||||
- 사이드바 토글 — default visible vs hide-by-default (좁은 화면 + 단일 저장소 시 noise)
|
||||
- 메모 리스트 sort 기준 — 최신순 / tag / due date
|
||||
- F17 (status 분기), F19 (recall search) 와 layout 조화 — 검색 박스 위치 (사이드 vs header)
|
||||
|
||||
### 가설·측정
|
||||
|
||||
- 본인 dogfood: "회사" / "개인" 메모 분리 욕구 빈도 — 1주 soak 후 측정. 빈도 낮으면 옵션 B 또는 C 충분.
|
||||
- 사이드바 사용 빈도 — main pane 만으로 동작 가능하면 사이드바 noise.
|
||||
|
||||
### 범위
|
||||
|
||||
- A (다중 profile + 사이드바 + 저장소 selector): 2-3주. AiWorker / HealthChecker / SyncService / 마이그레이션 모두 영향.
|
||||
- B (notebook_id + 사이드바): 1주. schema + repo 메서드 + UI.
|
||||
- C (다중 sync remote + 사이드바 navigation 만): 0.5주 (F21 의 일부).
|
||||
- 사이드바 자체 (저장소 결정 무관, navigation 기능만): 2-3일.
|
||||
|
||||
### 영향
|
||||
|
||||
- **layout 큰 변화** — 현재 single-pane → two-pane. 좁은 화면 (1280×720 dev) 영향 검증 필요.
|
||||
- F17 (status 분기) 와 conceptual overlap — "저장소" + "status" + "tag" 세 분기 layer 가 사용자 정신 부담.
|
||||
- F19 (recall search) — 사이드바에 search box 둘지 결정.
|
||||
- F21 (git sync) — 옵션 A 시 저장소별 별도 sync repo 자연스러움.
|
||||
- 본인 dogfood metric — Mac 업무 (회사 메모) + Windows 개인 (dogfood) 분리 시 가치 ↑.
|
||||
|
||||
---
|
||||
|
||||
|
||||
## (다음 항목 자리)
|
||||
|
||||
새 피드백 추가 시 `## F17. 짧은 제목 (🌱 raw)` 헤더로 시작. 표준 슬롯 6개 채우거나 비워둔 채 시작 가능.
|
||||
새 피드백 추가 시 `## F26. 짧은 제목 (🌱 raw)` 헤더로 시작. 표준 슬롯 6개 채우거나 비워둔 채 시작 가능.
|
||||
|
||||
dogfood ≥1주 soak (v0.2.6 release 후) 동안 새 발견 항목들 여기 누적 → v0.2.7 brainstorm 트리거.
|
||||
v0.2.8 release 후 dogfood ≥1주 soak 동안 새 발견 항목들 여기 누적 → v0.2.9 brainstorm 트리거.
|
||||
|
||||
206
docs/superpowers/specs/2026-05-09-v0210-cut-c-design.md
Normal file
206
docs/superpowers/specs/2026-05-09-v0210-cut-c-design.md
Normal file
@@ -0,0 +1,206 @@
|
||||
# v0.2.10 — Cut C Design (raw_text 수정 + revision history)
|
||||
|
||||
**작성일:** 2026-05-09
|
||||
**선행 문서:**
|
||||
|
||||
- `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` (F20)
|
||||
- `docs/superpowers/strategy/v028plus-roadmap.md` Cut C
|
||||
|
||||
**Cut 라벨:** v0.2.10 — load-bearing invariant 변경 (raw_text 불변 폐기 + revision history). semver 엄밀히 minor 이지만 v0.2.x 관습.
|
||||
|
||||
---
|
||||
|
||||
## 1. Cut 정체성
|
||||
|
||||
메모리 정책 `raw_text 불변` invariant 폐기 + 변경 이력 (revision) 보존. 사용자가 raw_text 자유 수정 + 옛 버전 회수 가능.
|
||||
|
||||
**load-bearing 정책 변경**:
|
||||
|
||||
- 옛: `raw_text 불변` (capture 시점 원본 영구 보존)
|
||||
- 새: `raw_text 가변` + `note_revisions 테이블` (옛 버전 모두 보존, rollback 가능)
|
||||
|
||||
이는 F1 / F4 / F17 / F19 의 raw_text 가정에 영향 — 모두 current latest raw_text 기준으로 동작 (시간 경과 시 정정된 값 사용).
|
||||
|
||||
---
|
||||
|
||||
## 2. 범위
|
||||
|
||||
| 항목 | 결정 |
|
||||
|---|---|
|
||||
| **F20** | C 옵션 — raw_text 수정 허용 + `note_revisions` 테이블 + 옛 버전 회수 UI. AI 재실행 input = current latest raw_text (B 옵션). |
|
||||
|
||||
---
|
||||
|
||||
## 3. Schema 마이그레이션 (m006)
|
||||
|
||||
> 메모: 본 스펙 작성 시점에는 m005 로 예상했으나 Cut B (v0.2.9) 에서 m005 (ai_disabled CHECK relax) 가 선점됨 → 실제 번호는 **m006**.
|
||||
|
||||
```sql
|
||||
CREATE TABLE note_revisions (
|
||||
rev_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
note_id TEXT NOT NULL,
|
||||
raw_text TEXT NOT NULL,
|
||||
edited_at TEXT NOT NULL,
|
||||
edited_by TEXT NOT NULL DEFAULT 'user', -- 'user' or 'capture' (최초 캡처)
|
||||
FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_note_revisions_note_id ON note_revisions(note_id, edited_at DESC);
|
||||
|
||||
-- 기존 notes 의 모든 raw_text 를 첫 revision 으로 backfill
|
||||
INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by)
|
||||
SELECT id, raw_text, created_at, 'capture' FROM notes;
|
||||
```
|
||||
|
||||
`note_revisions.rev_id = AUTOINCREMENT` — chronological 순서 보장. `edited_by` = 'user' (사용자 정정) 또는 'capture' (최초).
|
||||
|
||||
`notes.raw_text` 컬럼 그대로 — current latest 값. 검색 인덱스 (F19 FTS5) 가 이걸 source 로 사용 → revision 검색 X (latest only). YAGNI.
|
||||
|
||||
---
|
||||
|
||||
## 4. NoteRepository 메서드
|
||||
|
||||
```ts
|
||||
class NoteRepository {
|
||||
// 기존
|
||||
insert(input: ...): Note; // 내부에서 note_revisions INSERT (edited_by='capture')
|
||||
|
||||
// 신규
|
||||
updateRawText(id: string, newText: string, now: Date): void {
|
||||
const tx = this.db.transaction(() => {
|
||||
this.db.prepare(`UPDATE notes SET raw_text=?, updated_at=? WHERE id=?`).run(newText, now.toISOString(), id);
|
||||
this.db.prepare(`INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by) VALUES (?, ?, ?, 'user')`).run(id, newText, now.toISOString());
|
||||
});
|
||||
tx();
|
||||
}
|
||||
|
||||
listRevisions(id: string): NoteRevision[] {
|
||||
return this.db.prepare(`SELECT * FROM note_revisions WHERE note_id=? ORDER BY edited_at DESC`).all(id) as NoteRevision[];
|
||||
}
|
||||
|
||||
restoreRevision(id: string, revId: number, now: Date): void {
|
||||
const rev = this.db.prepare(`SELECT raw_text FROM note_revisions WHERE rev_id=? AND note_id=?`).get(revId, id) as { raw_text: string } | undefined;
|
||||
if (!rev) throw new Error(`revision ${revId} not found`);
|
||||
this.updateRawText(id, rev.raw_text, now); // 새 revision 으로 복원 (linear history 유지)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`restoreRevision` 은 옛 raw_text 를 **새 revision** 으로 INSERT — chain 끊지 않고 latest = restored. timestamp/순서 명확.
|
||||
|
||||
---
|
||||
|
||||
## 5. UI — NoteCard 수정 흐름
|
||||
|
||||
### 5-1. raw_text 편집 UI
|
||||
|
||||
기존 NoteCard 의 "원문 보기" 펼침 → 추가 "편집" 버튼:
|
||||
|
||||
```tsx
|
||||
{rawOpen && (
|
||||
<div>
|
||||
{editingRaw ? (
|
||||
<>
|
||||
<textarea value={draftRaw} onChange={e => setDraftRaw(e.target.value)} />
|
||||
<button onClick={onSaveRaw}>저장</button>
|
||||
<button onClick={() => setEditingRaw(false)}>취소</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<pre>{local.rawText}</pre>
|
||||
<button onClick={() => { setDraftRaw(local.rawText); setEditingRaw(true); }}>편집</button>
|
||||
<button onClick={() => setShowRevisions(true)}>이력</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
### 5-2. Revision 회수 UI
|
||||
|
||||
"이력" 클릭 → modal 또는 확장 panel:
|
||||
|
||||
```
|
||||
이력 (3 buah)
|
||||
[2026-05-12 14:30 사용자] 본문... [회수]
|
||||
[2026-05-10 09:15 사용자] 옛 본문... [회수]
|
||||
[2026-05-09 11:00 캡처] 최초 캡처 본문... [회수]
|
||||
```
|
||||
|
||||
회수 클릭 → confirm dialog ("이 버전으로 되돌릴까요? 현재 본문도 이력에 보존됩니다.") → `restoreRevision()` 호출.
|
||||
|
||||
---
|
||||
|
||||
## 6. AI 재실행 정책
|
||||
|
||||
**입력 = current notes.raw_text (latest)**. 옛 revision 은 AI 재실행 input X. 정책 일관 (사용자 정정 의도 반영).
|
||||
|
||||
`AiWorker` 의 input 추출 코드는 변경 없음 — `notes.raw_text` 그대로 사용.
|
||||
|
||||
---
|
||||
|
||||
## 7. F1 (Due Date) / F4 (Aha Moment) / F17 / F19 영향
|
||||
|
||||
| 영역 | 영향 |
|
||||
|---|---|
|
||||
| F1 Due Date 파서 | input = current raw_text. 사용자 정정 후 due 갱신 가능 — 정책 충실 (수정 시 의도 반영) |
|
||||
| F4 Aha Moment | capture 카운트 = notes 갯수. revision 갯수 무관 |
|
||||
| F17 status | 영향 X (raw_text 수정과 status 분기 독립) |
|
||||
| F19 search FTS5 | 인덱스 source = notes.raw_text (latest). revision 검색 미지원. 향후 cut 에서 옵션 |
|
||||
|
||||
---
|
||||
|
||||
## 8. IPC + types
|
||||
|
||||
```ts
|
||||
// 신규
|
||||
'inbox:update-raw-text': (id: string, newText: string) => Promise<{ ok: true }>
|
||||
'inbox:list-revisions': (id: string) => Promise<NoteRevision[]>
|
||||
'inbox:restore-revision': (id: string, revId: number) => Promise<{ ok: true }>
|
||||
|
||||
interface NoteRevision {
|
||||
revId: number;
|
||||
noteId: string;
|
||||
rawText: string;
|
||||
editedAt: string;
|
||||
editedBy: 'user' | 'capture';
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 테스트 전략
|
||||
|
||||
| 영역 | 단위 |
|
||||
|---|---|
|
||||
| m006 마이그레이션 | 기존 notes → revision backfill (edited_by='capture') |
|
||||
| `updateRawText` | notes.raw_text 갱신 + 새 revision INSERT atomic |
|
||||
| `listRevisions` | DESC 순 + edited_by 정확 |
|
||||
| `restoreRevision` | 옛 raw_text 가 새 revision 으로 INSERT + notes.raw_text 갱신 |
|
||||
| 편집 UI | textarea 입력 + 저장 → IPC 호출 + store 갱신 |
|
||||
| 이력 modal | revision 목록 표시 + 회수 클릭 → confirm + IPC |
|
||||
| AiWorker input | current notes.raw_text 사용 (revision X) 회귀 |
|
||||
|
||||
**목표**: 단위 548 → 약 567 (+19, m006 5 + create rev 1 + updateRawText 2 + listRevisions 1 + restoreRevision 2 + IPC 4 + NoteCard 편집 1 + RevisionHistoryModal 2 + findById 회귀 1), typecheck 0.
|
||||
|
||||
---
|
||||
|
||||
## 10. Risk
|
||||
|
||||
| Risk | 대응 |
|
||||
|---|---|
|
||||
| revision 무한 누적 (메모 1개당 100+ revision 시 DB bloat) | 향후 cut 에서 N개 cap 정책 (예: 최근 50개만 보존). 본 cut 은 unlimited |
|
||||
| 사용자가 실수로 옛 revision 회수 | confirm dialog 강제 |
|
||||
| F1 Due Date 가 raw_text 변경 시 재추출 안 함 | 별도 cut. 본 cut 은 raw_text 갱신 + 기존 due 잔류 (사용자 의도 보존) |
|
||||
| 메모리 정책 갱신 필수 | `project_inkling_status.md` 의 load-bearing invariant 갱신 |
|
||||
|
||||
---
|
||||
|
||||
## 11. 메모리 정책 갱신 (Cut C 머지 후 필수)
|
||||
|
||||
`raw_text 불변` → `raw_text 가변 + revision 보존`. 메모 갱신:
|
||||
|
||||
```
|
||||
- ~~raw_text 불변~~ → raw_text 가변 (사용자 편집 가능, note_revisions 테이블에 변경 이력 보존)
|
||||
- AI 재실행 input = current latest raw_text (옛 revision X)
|
||||
```
|
||||
297
docs/superpowers/specs/2026-05-09-v0211-cut-d-design.md
Normal file
297
docs/superpowers/specs/2026-05-09-v0211-cut-d-design.md
Normal file
@@ -0,0 +1,297 @@
|
||||
# v0.2.11 — Cut D Design (FTS5 search + 회고 view)
|
||||
|
||||
**작성일:** 2026-05-09
|
||||
**선행 문서:**
|
||||
|
||||
- `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` (F19)
|
||||
- `docs/superpowers/strategy/v028plus-roadmap.md` Cut D
|
||||
|
||||
**Cut 라벨:** v0.2.11
|
||||
|
||||
---
|
||||
|
||||
## 1. Cut 정체성
|
||||
|
||||
recall 핵심 가치 도달 — search + 회고 view. F19 의 6 옵션 중 **A (FTS5 search) + D (회고 view)** 2개. B/C/E/F 는 v0.3+ deferred.
|
||||
|
||||
---
|
||||
|
||||
## 2. 범위
|
||||
|
||||
| 항목 | 결정 |
|
||||
|---|---|
|
||||
| **F19-A** | SQLite FTS5 인덱스 + inbox 헤더 search box |
|
||||
| **F19-D** | 일/주/월 회고 라우트 — aggregate query + N건 list + tag distribution + due 진행 |
|
||||
|
||||
---
|
||||
|
||||
## 3. F19-A 디테일 (FTS5)
|
||||
|
||||
### 3-1. Schema 마이그레이션 (m007)
|
||||
|
||||
> 메모: 본 스펙 작성 시점에는 m006 로 예상했으나 Cut C (v0.2.10) 에서 m006 (note_revisions) 가 선점됨 → 실제 번호는 **m007**.
|
||||
|
||||
실제 schema 정정:
|
||||
- `notes.title`/`notes.summary` 컬럼 없음 → 실제 `notes.ai_title`/`notes.ai_summary` 사용
|
||||
- `notes.tags_csv` 컬럼 없음 → tags 는 `note_tags` join (note_tags.note_id ↔ tags.id)
|
||||
- `notes.status` (Cut B m004 도입) 사용 가능 — `status != 'trashed'` 필터
|
||||
|
||||
```sql
|
||||
CREATE VIRTUAL TABLE notes_fts USING fts5(
|
||||
note_id UNINDEXED,
|
||||
raw_text,
|
||||
ai_title,
|
||||
ai_summary,
|
||||
tags,
|
||||
tokenize='unicode61'
|
||||
);
|
||||
|
||||
-- 기존 notes (active/completed/archived 만 — trashed 제외) 모두 인덱스.
|
||||
-- tags 는 note_tags+tags JOIN 후 GROUP_CONCAT 으로 csv 구성.
|
||||
INSERT INTO notes_fts (note_id, raw_text, ai_title, ai_summary, tags)
|
||||
SELECT
|
||||
n.id,
|
||||
n.raw_text,
|
||||
COALESCE(n.ai_title, ''),
|
||||
COALESCE(n.ai_summary, ''),
|
||||
COALESCE((SELECT GROUP_CONCAT(t.name, ' ')
|
||||
FROM note_tags nt JOIN tags t ON t.id = nt.tag_id
|
||||
WHERE nt.note_id = n.id), '')
|
||||
FROM notes n
|
||||
WHERE n.status != 'trashed';
|
||||
```
|
||||
|
||||
`tokenize='unicode61'` — 한국어 partial tokenize 가능 (단어 boundary). 향후 `tokenize='porter unicode61'` 또는 한국어 전용 tokenizer (예: `mecab-ko-fts5`) 검토 가능 — Cut D 는 unicode61 default.
|
||||
|
||||
`tags` 컬럼 = note_tags JOIN 결과 csv (예: `"기획 회의 결재"`). `note_tags` 변경 시 NoteRepository 에서 명시적 헬퍼 (`rebuildFtsTagsForNote(noteId)`) 호출 — trigger 로 sync 어려움 (`note_tags` INSERT/DELETE 가 다른 노트 row 재계산 트리거하기 부담). 단일 write path 패턴 (Cut C 확립) 으로 강제.
|
||||
|
||||
### 3-2. Trigger — auto-sync (notes 컬럼 한정)
|
||||
|
||||
`notes` INSERT/UPDATE/DELETE 시 `notes_fts` 자동 sync (raw_text/ai_title/ai_summary 만; tags 는 별도 헬퍼):
|
||||
|
||||
```sql
|
||||
CREATE TRIGGER notes_ai AFTER INSERT ON notes BEGIN
|
||||
INSERT INTO notes_fts (note_id, raw_text, ai_title, ai_summary, tags)
|
||||
VALUES (NEW.id, NEW.raw_text, COALESCE(NEW.ai_title, ''), COALESCE(NEW.ai_summary, ''), '');
|
||||
END;
|
||||
|
||||
CREATE TRIGGER notes_ad AFTER DELETE ON notes BEGIN
|
||||
DELETE FROM notes_fts WHERE note_id = OLD.id;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER notes_au AFTER UPDATE ON notes BEGIN
|
||||
UPDATE notes_fts
|
||||
SET raw_text = NEW.raw_text,
|
||||
ai_title = COALESCE(NEW.ai_title, ''),
|
||||
ai_summary = COALESCE(NEW.ai_summary, '')
|
||||
WHERE note_id = NEW.id;
|
||||
END;
|
||||
```
|
||||
|
||||
Cut C 의 `updateRawText` 가 `notes.raw_text` UPDATE → trigger 자동 발동 → FTS5 갱신.
|
||||
|
||||
`tags` 갱신 path:
|
||||
- `NoteRepository.updateAiResult` (AI tags) / `updateUserAiFields` (사용자 tags) 모두 `note_tags` 변경 후 동일 transaction 안에서 `rebuildFtsTagsForNote(noteId)` 호출.
|
||||
|
||||
trashed 노트 처리 — `setStatus(id, 'trashed', ...)` 시 trigger AFTER UPDATE 발동되어 FTS row 가 그대로 유지됨. 검색 시 query 단계에서 `n.status != 'trashed'` 필터로 제외 (별도 FTS row cleanup 안 함 — YAGNI).
|
||||
|
||||
### 3-3. NoteRepository.search
|
||||
|
||||
```ts
|
||||
search(query: string, opts: { limit?: number; status?: NoteStatus } = {}): Note[] {
|
||||
if (query.trim().length === 0) return [];
|
||||
const limit = Math.max(1, Math.min(200, opts.limit ?? 50));
|
||||
const ftsQuery = sanitizeFtsQuery(query); // FTS5 special char escape
|
||||
const statusClause = opts.status ? `AND n.status = ?` : `AND n.status != 'trashed'`;
|
||||
const sql = `
|
||||
SELECT n.* FROM notes n
|
||||
JOIN notes_fts f ON n.id = f.note_id
|
||||
WHERE notes_fts MATCH ? ${statusClause}
|
||||
ORDER BY rank LIMIT ?
|
||||
`;
|
||||
const args = opts.status ? [ftsQuery, opts.status, limit] : [ftsQuery, limit];
|
||||
const rows = this.db.prepare(sql).all(...args) as Record<string, unknown>[];
|
||||
return rows.map((r) => this.hydrate(r));
|
||||
}
|
||||
```
|
||||
|
||||
`hydrate` — 기존 패턴 (tags + media join). `sanitizeFtsQuery` — FTS5 special chars (`"`, `*`, `(`, `)`, `:`) 이스케이프 및 multi-word AND 결합 (예: `기획 회의` → `"기획" AND "회의"` 또는 `기획 회의` 그대로 수용). YAGNI: 다중 토큰을 그대로 FTS5 implicit AND 로 보냄 + 따옴표 제거.
|
||||
|
||||
`status` 미지정 시 default = trashed 제외.
|
||||
|
||||
`MATCH` 쿼리 syntax — FTS5 standard (`"기획 회의"`, `회의 OR 결재`, `기획*` 등).
|
||||
|
||||
### 3-4. UI — inbox 헤더 search box
|
||||
|
||||
기존 헤더 (Inbox/완료/보관/휴지통 탭) 옆에 search input:
|
||||
|
||||
```tsx
|
||||
<input
|
||||
type="search"
|
||||
placeholder="검색..."
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
style={{ ... }}
|
||||
/>
|
||||
```
|
||||
|
||||
debounce 200ms → store action `searchNotes(query)` → `inboxApi.search(query, { status: currentView })` → result list 갱신.
|
||||
|
||||
빈 query → 기본 inbox list 복귀.
|
||||
|
||||
### 3-5. IPC
|
||||
|
||||
```ts
|
||||
'inbox:search': (query: string, opts: { status?: NoteStatus; limit?: number }) => Promise<Note[]>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. F19-D 디테일 (회고 view)
|
||||
|
||||
### 4-1. 라우트 추가
|
||||
|
||||
`useInbox.view` enum 에 `'review-daily' | 'review-weekly' | 'review-monthly'` 추가. 진입점:
|
||||
|
||||
- 헤더 메뉴: "📅 회고" 버튼 → 드롭다운 (일/주/월)
|
||||
- 또는 별도 라우트 (Settings 옆)
|
||||
|
||||
### 4-2. 회고 view 컴포넌트
|
||||
|
||||
```tsx
|
||||
// src/renderer/inbox/components/ReviewView.tsx
|
||||
export function ReviewView({ period }: { period: 'daily' | 'weekly' | 'monthly' }): ReactElement {
|
||||
const data = useReviewData(period); // store action — aggregate query 결과
|
||||
return (
|
||||
<div>
|
||||
<h2>{periodLabel(period)} 회고</h2>
|
||||
<div>총 N건 • 오늘 N건 • 평균 일 N건</div>
|
||||
<TagDistributionChart tags={data.tagCounts} />
|
||||
<DueProgressChart due={data.dueProgress} />
|
||||
<NoteList notes={data.recentNotes} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 4-3. Aggregate query
|
||||
|
||||
NoteRepository:
|
||||
|
||||
```ts
|
||||
reviewAggregate(period: 'daily' | 'weekly' | 'monthly', now: Date = new Date()): {
|
||||
totalCount: number;
|
||||
recentNotes: Note[];
|
||||
tagCounts: Array<{ tag: string; count: number }>;
|
||||
dueProgress: { total: number; passed: number; pending: number };
|
||||
} {
|
||||
const cutoff = computeCutoff(period, now); // ISO string — KST 자정 / 7일전 / 30일전
|
||||
const todayIso = kstTodayIso(now); // YYYY-MM-DD
|
||||
const totalCount = (this.db
|
||||
.prepare(`SELECT COUNT(*) as c FROM notes WHERE created_at >= ? AND status != 'trashed'`)
|
||||
.get(cutoff) as { c: number }).c;
|
||||
const recentRows = this.db
|
||||
.prepare(`SELECT * FROM notes WHERE created_at >= ? AND status != 'trashed'
|
||||
ORDER BY created_at DESC, id DESC LIMIT 50`)
|
||||
.all(cutoff) as Record<string, unknown>[];
|
||||
const recentNotes = recentRows.map((r) => this.hydrate(r));
|
||||
// tag counts via note_tags JOIN — period 안 노트의 태그만 집계
|
||||
const tagCounts = this.db
|
||||
.prepare(`SELECT t.name AS tag, COUNT(*) AS count
|
||||
FROM note_tags nt
|
||||
JOIN notes n ON n.id = nt.note_id
|
||||
JOIN tags t ON t.id = nt.tag_id
|
||||
WHERE n.created_at >= ? AND n.status != 'trashed'
|
||||
GROUP BY t.id
|
||||
ORDER BY count DESC, t.name ASC`)
|
||||
.all(cutoff) as Array<{ tag: string; count: number }>;
|
||||
// due progress — period 안 created 노트 중 due_date 가 있는 것
|
||||
const dueRow = this.db
|
||||
.prepare(`SELECT
|
||||
COUNT(*) AS total,
|
||||
SUM(CASE WHEN due_date < ? THEN 1 ELSE 0 END) AS passed,
|
||||
SUM(CASE WHEN due_date >= ? THEN 1 ELSE 0 END) AS pending
|
||||
FROM notes
|
||||
WHERE created_at >= ?
|
||||
AND status != 'trashed'
|
||||
AND due_date IS NOT NULL`)
|
||||
.get(todayIso, todayIso, cutoff) as { total: number; passed: number | null; pending: number | null };
|
||||
const dueProgress = {
|
||||
total: dueRow.total,
|
||||
passed: dueRow.passed ?? 0,
|
||||
pending: dueRow.pending ?? 0
|
||||
};
|
||||
return { totalCount, recentNotes, tagCounts, dueProgress };
|
||||
}
|
||||
```
|
||||
|
||||
`computeCutoff('daily', now)` = KST 자정 (오늘 시작) ISO. `'weekly'` = 7일 전 KST 자정 ISO. `'monthly'` = 30일 전 KST 자정 ISO. `kstTodayIso` 는 `src/shared/util/kstDate.ts` 에 이미 존재 (Cut B 활용).
|
||||
|
||||
period 별 query 는 동일 transaction 으로 wrap 해도 되나, read-only + 단일 호출이라 단순 sequential 호출로 충분 (better-sqlite3 동기 API).
|
||||
|
||||
### 4-4. Tag distribution chart
|
||||
|
||||
간단한 bar list (CSS — chart 라이브러리 X):
|
||||
|
||||
```tsx
|
||||
{data.tagCounts.slice(0, 10).map(t => (
|
||||
<div key={t.tag}>
|
||||
<span>{t.tag}</span>
|
||||
<div style={{ width: `${(t.count / max) * 100}%`, background: '#4ec5b8', height: 8 }} />
|
||||
<span>{t.count}</span>
|
||||
</div>
|
||||
))}
|
||||
```
|
||||
|
||||
### 4-5. Due progress
|
||||
|
||||
```
|
||||
완료 (passed): 12 / 25
|
||||
대기 (pending): 13
|
||||
이번 주 due: 3건
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 테스트 전략
|
||||
|
||||
| 영역 | 단위 |
|
||||
|---|---|
|
||||
| m007 마이그레이션 | FTS5 virtual table + trigger 3개 + 기존 notes backfill (status != 'trashed' + tags JOIN) |
|
||||
| Trigger sync | INSERT/UPDATE/DELETE → notes_fts 자동 sync (raw_text/ai_title/ai_summary) |
|
||||
| `rebuildFtsTagsForNote` 헬퍼 | note_tags 변경 후 FTS tags 컬럼 재구성 |
|
||||
| `updateAiResult` / `updateUserAiFields` | tags 변경 path 가 헬퍼 호출하여 FTS sync (회귀) |
|
||||
| `updateRawText` (Cut C) FTS sync 회귀 | trigger 자동 발동 검증 |
|
||||
| `search` | 한국어 token 매칭 + status filter + trashed 기본 제외 + 빈 query → [] |
|
||||
| `sanitizeFtsQuery` | FTS5 special char 이스케이프 + multi-word 통과 |
|
||||
| inbox header search box | debounce + 빈 값 → 기본 list 복귀 |
|
||||
| ReviewView 단위 | aggregate query 결과 렌더 + period 라벨 |
|
||||
| `reviewAggregate` | period 별 cutoff 정확 + tag count + due progress (passed/pending KST 비교) |
|
||||
| `computeCutoff` | daily/weekly/monthly KST 자정 ISO |
|
||||
|
||||
**목표**: 단위 569 → 약 595 (+26), typecheck 0.
|
||||
|
||||
---
|
||||
|
||||
## 6. Risk
|
||||
|
||||
| Risk | 대응 |
|
||||
|---|---|
|
||||
| FTS5 한국어 token 정확도 (unicode61 가 word boundary 부정확) | dogfood 검증. 부족 시 v0.3+ 에서 mecab-ko 또는 trigram tokenize 검토 |
|
||||
| FTS5 인덱스 size (notes 수만건 시 DB 크기 ↑) | 수만건 도달 전엔 무시. v0.3+ 에서 prune 또는 partial 인덱스 |
|
||||
| 회고 aggregate query latency | LIMIT 50 + index 활용 (`created_at DESC`). 수만건도 sub-second 예상 |
|
||||
| Cut C revision 추가 시 FTS 영향 | revision 은 인덱스 X (latest only). `notes` AFTER UPDATE trigger 가 raw_text 변경 자동 반영 |
|
||||
| `note_tags` 변경 누락 시 FTS tags stale | NoteRepository 의 tags 변경 path 모두에서 `rebuildFtsTagsForNote` 명시 호출 — single write path 패턴 강제 |
|
||||
| FTS5 special char crash | `sanitizeFtsQuery` 에서 `"`/`*`/`(`/`)`/`:` 이스케이프 또는 제거 |
|
||||
|
||||
---
|
||||
|
||||
## 7. v0.2.11 후
|
||||
|
||||
**Cut E** (v0.3.0) — F21 양방향 sync.
|
||||
|
||||
**dogfood verify**:
|
||||
|
||||
1. search 일 사용 빈도 (가설: ≥ 일 1회면 가치 있음)
|
||||
2. 회고 view 사용 빈도 (월요일 자동 prompt 추가 검토 — v0.3+)
|
||||
3. FTS5 한국어 token 정확도 (사용자 query 결과 만족도)
|
||||
226
docs/superpowers/specs/2026-05-09-v028-cut-a-design.md
Normal file
226
docs/superpowers/specs/2026-05-09-v028-cut-a-design.md
Normal file
@@ -0,0 +1,226 @@
|
||||
# v0.2.8 — Cut A Design (이미지 렌더링 + 앱 아이콘)
|
||||
|
||||
**작성일:** 2026-05-09
|
||||
**저자:** 김태현 (dlsrks0734@gmail.com)
|
||||
**선행 문서:**
|
||||
|
||||
- `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` (F22)
|
||||
- `docs/superpowers/strategy/v028plus-roadmap.md` (Cut A 분할 + 우선순위)
|
||||
|
||||
**Cut 라벨:** v0.2.8 (semver patch — bug fix + asset 추가)
|
||||
|
||||
---
|
||||
|
||||
## 1. Cut 정체성
|
||||
|
||||
**"이미지 렌더링 + 앱 아이콘 polish" cut.** 두 작은 항목 묶음:
|
||||
|
||||
- **F22 (이미지 렌더링)**: NoteCard 의 회색 placeholder div 를 실제 `<img>` 로 교체. Electron renderer 가 raw `file://` 직접 접근 어려운 보안 정책 우회 — `inkling-media://` custom protocol 등록.
|
||||
- **chore (앱 아이콘)**: 사용자 첨부 SVG (이미 `assets/icon.svg` 작성·검토 완료) → ICO/ICNS/PNG 다중 size 자동 생성 + electron-builder config 통합.
|
||||
|
||||
명확/작은 작업, 의사결정 거의 없음. 빠른 release polish.
|
||||
|
||||
---
|
||||
|
||||
## 2. 범위
|
||||
|
||||
| 항목 | 출처 | 작업 |
|
||||
|---|---|---|
|
||||
| **F22** | dogfood F22 | `inkling-media://` protocol + NoteCard `<img>` + 클릭 시 OS viewer (`shell.openPath`) |
|
||||
| **chore** | roadmap | `electron-icon-builder` devDep + `npm run build:icons` + electron-builder config (`build.win.icon` / `build.mac.icon` / `build.linux.icon`) |
|
||||
|
||||
---
|
||||
|
||||
## 3. F22 디테일
|
||||
|
||||
### 3-1. Custom protocol 등록
|
||||
|
||||
`src/main/index.ts` 의 `whenReady` **이전** (top-level) 에 scheme 권한 등록:
|
||||
|
||||
```ts
|
||||
import { protocol } from 'electron';
|
||||
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{ scheme: 'inkling-media', privileges: { secure: true, supportFetchAPI: true, stream: true } }
|
||||
]);
|
||||
```
|
||||
|
||||
`whenReady` 안에서 handler 등록:
|
||||
|
||||
```ts
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { join, normalize, sep } from 'node:path';
|
||||
|
||||
protocol.handle('inkling-media', async (req) => {
|
||||
const url = new URL(req.url);
|
||||
const relPath = decodeURIComponent(url.pathname).replace(/^\//, '');
|
||||
const mediaRoot = join(paths.profileDir, 'media');
|
||||
const target = normalize(join(mediaRoot, relPath));
|
||||
if (!target.startsWith(mediaRoot + sep)) {
|
||||
return new Response(null, { status: 403 });
|
||||
}
|
||||
try {
|
||||
const data = await fs.readFile(target);
|
||||
return new Response(data, { headers: { 'content-type': inferMime(target) } });
|
||||
} catch {
|
||||
return new Response(null, { status: 404 });
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
`inferMime()` — 파일 확장자 → MIME (png/jpg/jpeg/gif/webp). 작은 함수 (별도 util 또는 inline).
|
||||
|
||||
### 3-2. NoteCard 갱신
|
||||
|
||||
[src/renderer/inbox/components/NoteCard.tsx:336-338](src/renderer/inbox/components/NoteCard.tsx#L336-L338) 의 회색 div 를 `<img>` 로 교체:
|
||||
|
||||
```tsx
|
||||
{local.media.map((m) => (
|
||||
<img
|
||||
key={m.id}
|
||||
src={`inkling-media://${m.relPath}`}
|
||||
alt=""
|
||||
title={m.relPath}
|
||||
onClick={() => inboxApi.openMedia(m.relPath)}
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
objectFit: 'cover',
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
border: '1px solid #e0e0e0'
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
```
|
||||
|
||||
`m.relPath` 형식 = `media/<noteId>/<filename>`. URL 형식: `inkling-media://media/<noteId>/<filename>`. handler 가 prefix 제거 후 `<profileDir>/media/<noteId>/<filename>` 으로 resolve.
|
||||
|
||||
### 3-3. IPC `inbox:open-media`
|
||||
|
||||
`src/main/ipc/inboxApi.ts` 에 신규 핸들러:
|
||||
|
||||
```ts
|
||||
ipcMain.handle('inbox:open-media', async (_e, relPath: string) => {
|
||||
const mediaRoot = join(paths.profileDir, 'media');
|
||||
const target = normalize(join(mediaRoot, relPath));
|
||||
if (!target.startsWith(mediaRoot + sep)) return { ok: false, reason: 'invalid path' };
|
||||
await shell.openPath(target);
|
||||
return { ok: true };
|
||||
});
|
||||
```
|
||||
|
||||
preload 화이트리스트 + `src/shared/types.ts` `InboxApi.openMedia(relPath: string)` 시그니처 + `src/renderer/inbox/api.ts` wrapper.
|
||||
|
||||
### 3-4. 보안 검토
|
||||
|
||||
- **Path traversal**: protocol handler + IPC 핸들러 모두 `target.startsWith(mediaRoot + sep)` 검사. 통과 못 하면 403/실패.
|
||||
- **Schemes privileges**: `secure: true` 로 https 동등 권한 — webContents 가 페이지 안에서 `<img src="inkling-media://...">` 정상 로드.
|
||||
- **CORS**: same-origin 정책 영향 X (custom protocol 이라 별도). webContents 안 동일 origin 으로 인식.
|
||||
- **인증**: 단일 사용자 desktop app — 추가 인증 X.
|
||||
|
||||
---
|
||||
|
||||
## 4. chore 디테일
|
||||
|
||||
### 4-1. 의존성 + scripts
|
||||
|
||||
`package.json`:
|
||||
|
||||
```json
|
||||
"devDependencies": {
|
||||
"electron-icon-builder": "^2.0.1"
|
||||
},
|
||||
"scripts": {
|
||||
"build:icons": "electron-icon-builder --input=assets/icon.svg --output=build --flatten"
|
||||
}
|
||||
```
|
||||
|
||||
`--flatten` 옵션 = output 을 `build/icon.ico`, `build/icon.icns`, `build/icon.png` (1024x1024) 평면 배치. nested `build/icons/png/<size>.png` 도 함께.
|
||||
|
||||
### 4-2. electron-builder config
|
||||
|
||||
`package.json` 의 `build` 블록 갱신:
|
||||
|
||||
```json
|
||||
"win": { "icon": "build/icon.ico", ... },
|
||||
"mac": { "icon": "build/icon.icns", ... },
|
||||
"linux": { "icon": "build/icon.png", ..., "target": [ ... ] }
|
||||
```
|
||||
|
||||
기존 win/mac/linux 블록에 `"icon"` 키만 추가 (다른 설정 그대로).
|
||||
|
||||
### 4-3. 산출물 git 추적
|
||||
|
||||
`build/` 가 `.gitignore` 에 있다면 — 두 옵션:
|
||||
|
||||
- (a) **`build/icon.*` 만 ignore 풀고 commit** (size 약 200KB-1MB 작음 — 바이너리 commit 일반적). SVG 갱신 시 `npm run build:icons` 후 commit.
|
||||
- (b) **모두 ignore 유지** + `prebuild` script 등으로 빌드 시 매번 재생성. dist 빌드 시 자동 — 그러나 dev 환경 (npm start) 에서 아이콘 미생성 시 fallback 필요.
|
||||
|
||||
추천: **(a)** — 단순, 빌드 시간 ↓, dev 환경 문제 X.
|
||||
|
||||
`.gitignore` 갱신 예:
|
||||
|
||||
```
|
||||
build/
|
||||
!build/icon.ico
|
||||
!build/icon.icns
|
||||
!build/icon.png
|
||||
```
|
||||
|
||||
### 4-4. SVG 가 input 으로 바로 가능?
|
||||
|
||||
`electron-icon-builder` v2.0.1 docs 검토 — PNG 1024x1024 입력 권장, SVG 는 `librsvg` 등 의존. SVG 직접 안 되면 `sharp` 로 SVG → PNG 1024 변환 후 input.
|
||||
|
||||
대안 (SVG 직접 안 될 시):
|
||||
|
||||
```json
|
||||
"build:icons:png": "node scripts/svg-to-png.mjs assets/icon.svg build/icon-source.png 1024",
|
||||
"build:icons": "npm run build:icons:png && electron-icon-builder --input=build/icon-source.png --output=build --flatten"
|
||||
```
|
||||
|
||||
`scripts/svg-to-png.mjs` — `sharp` 활용 ~10줄 스크립트.
|
||||
|
||||
---
|
||||
|
||||
## 5. 테스트 전략
|
||||
|
||||
| 영역 | 단위 | 수동 |
|
||||
|---|---|---|
|
||||
| protocol handler — path traversal | mock fs + URL 입력 (`../etc/passwd` 형태) → 403 | - |
|
||||
| protocol handler — 정상 200 | mock fs.readFile → bytes + content-type 검증 | - |
|
||||
| protocol handler — 404 | fs.readFile reject → 404 | - |
|
||||
| `inferMime` | 확장자별 정확 mapping | - |
|
||||
| NoteCard `<img>` 렌더 | media 배열 길이 N → `<img>` N 개 (jsdom mock) | - |
|
||||
| `<img>` 클릭 → IPC | onClick stub → `inboxApi.openMedia` 호출 | - |
|
||||
| IPC `inbox:open-media` | path traversal mock → 'invalid path' 반환 | - |
|
||||
| 아이콘 빌드 | - | `npm run build:icons` → `build/icon.ico` `build/icon.icns` `build/icon.png` 존재 확인 |
|
||||
| Win exe 아이콘 | - | `npm run dist:win` → `Inkling Setup 0.2.8.exe` 우클릭 → properties → 아이콘 = 새 디자인 |
|
||||
| dogfood image flow | - | inbox 의 thumbnail 클릭 → OS viewer 열림 (Win + macOS) |
|
||||
|
||||
**목표**: 단위 460 → 약 467 (+7), typecheck 0.
|
||||
|
||||
---
|
||||
|
||||
## 6. Risk + Known unknowns
|
||||
|
||||
| Risk | 발생 시 대응 |
|
||||
|---|---|
|
||||
| `electron-icon-builder` SVG 직접 미지원 | `sharp` 로 SVG → PNG 1024 변환 (4-4 대안 적용) |
|
||||
| `protocol.handle` 가 Electron 41 미지원 (deprecated `protocol.registerFileProtocol` 만 있는 경우) | Electron 41 docs 확인 후 deprecated API 사용 또는 newer API |
|
||||
| `<img>` 가 inkling-media:// 로드 실패 (CSP 차단 등) | webContents 의 contentSecurityPolicy 검토. v0.2.5/6 의 single-instance lock + B4 #46 hidden flag 와 무관 |
|
||||
| Win/Mac dogfood 시 OS viewer 가 default 미설정 | 사용자 OS settings — Inkling 책임 외 (그러나 안내 메시지 가능) |
|
||||
|
||||
---
|
||||
|
||||
## 7. v0.2.8 후
|
||||
|
||||
**다음**: Cut B (v0.2.9) — F17 status 분기 + F18 사유 + F23 Ollama-less. 데이터 모델 정비 cut.
|
||||
|
||||
**Cut A 머지 후 dogfood verify 항목**:
|
||||
|
||||
1. inbox 의 capture-with-image 흐름 — 캡처 → 이미지 thumbnail 표시 → 클릭 → OS viewer
|
||||
2. 새 아이콘이 트레이 / Windows taskbar / dock 모두 정확 표시
|
||||
3. 다중 이미지 (capture 가 N개 첨부) 의 grid layout — flex-wrap 적용 시 N row 자연스러운지
|
||||
|
||||
이슈 발견 시 dogfood-feedback.md F26 부터 누적.
|
||||
269
docs/superpowers/specs/2026-05-09-v029-cut-b-design.md
Normal file
269
docs/superpowers/specs/2026-05-09-v029-cut-b-design.md
Normal file
@@ -0,0 +1,269 @@
|
||||
# v0.2.9 — Cut B Design (status 4분기 + 사유 + Ollama-less)
|
||||
|
||||
**작성일:** 2026-05-09
|
||||
**선행 문서:**
|
||||
|
||||
- `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` (F17, F18, F23)
|
||||
- `docs/superpowers/strategy/v028plus-roadmap.md` Cut B
|
||||
|
||||
**Cut 라벨:** v0.2.9 — semver 엄밀히 minor (새 status enum + onboarding wizard) 이지만 v0.2.x feature lane 관습 유지.
|
||||
|
||||
---
|
||||
|
||||
## 1. Cut 정체성
|
||||
|
||||
데이터 모델 정비 cut. 메모의 의미 분기 (active / completed / archived / trashed) + 이동 시 사유 입력 + Ollama-less 모드 onboarding. 세 항목이 같은 schema 영역 (notes 테이블 + ai_status enum) 영향이라 한 cut.
|
||||
|
||||
---
|
||||
|
||||
## 2. 범위
|
||||
|
||||
| 항목 | 결정 |
|
||||
|---|---|
|
||||
| **F17** | status 4분기 (`active`/`completed`/`archived`/`trashed`) + AI 자동 분류 버튼 (사유 입력 후 클릭 → AI 가 reason+raw_text 분석 → status 추천 → 사용자 confirm/dismiss) |
|
||||
| **F18** | 자유 텍스트 사유 (preset X — friction 최소). notes.move_reason 컬럼 또는 별도 trash_log 테이블 |
|
||||
| **F23** | 첫 launch wizard (Y/N) + Ollama 최적화 안내 + 설치 가이드 페이지 링크. ai_status='disabled' 신규 enum + capture skip + raw-only NoteCard fallback |
|
||||
|
||||
---
|
||||
|
||||
## 3. F17 디테일
|
||||
|
||||
### 3-1. Schema 마이그레이션 (m004)
|
||||
|
||||
```sql
|
||||
ALTER TABLE notes ADD COLUMN status TEXT NOT NULL DEFAULT 'active'
|
||||
CHECK (status IN ('active', 'completed', 'archived', 'trashed'));
|
||||
ALTER TABLE notes ADD COLUMN status_changed_at TEXT;
|
||||
ALTER TABLE notes ADD COLUMN move_reason TEXT;
|
||||
|
||||
-- 기존 deleted_at != NULL 노트 → status='trashed' migrate
|
||||
UPDATE notes SET status='trashed', status_changed_at=deleted_at
|
||||
WHERE deleted_at IS NOT NULL;
|
||||
```
|
||||
|
||||
`deleted_at` 컬럼 — backward compat 위해 잔류 (status='trashed' 와 동기화). 향후 cut 에서 제거 가능.
|
||||
|
||||
### 3-2. 인터페이스
|
||||
|
||||
```ts
|
||||
type NoteStatus = 'active' | 'completed' | 'archived' | 'trashed';
|
||||
|
||||
interface Note {
|
||||
// ... 기존 필드
|
||||
status: NoteStatus;
|
||||
statusChangedAt: string | null;
|
||||
moveReason: string | null;
|
||||
}
|
||||
```
|
||||
|
||||
NoteRepository 메서드:
|
||||
|
||||
- `setStatus(id: string, status: NoteStatus, reason: string | null): void`
|
||||
- `listByStatus(status: NoteStatus, limit?: number): Note[]`
|
||||
- 기존 `restoreNote()` → `setStatus(id, 'active', null)` 으로 재구현
|
||||
|
||||
### 3-3. UI — inbox 헤더 탭 4개
|
||||
|
||||
기존 Inbox / 휴지통 2탭 → **Inbox / 완료 / 보관 / 휴지통** 4탭. 헤더 폭 좁아질 수 있어 short label + count badge.
|
||||
|
||||
```tsx
|
||||
<button>Inbox(N)</button> <button>완료(N)</button> <button>보관(N)</button> <button>휴지통(N)</button>
|
||||
```
|
||||
|
||||
`useInbox` store 의 `view: 'inbox' | 'completed' | 'archived' | 'trash' | 'settings'` enum 확장 (기존 `showTrash` boolean + `showSettings` boolean → enum 통합 권장 — 또는 boolean 3개 유지). enum 통합이 깔끔.
|
||||
|
||||
### 3-4. NoteCard 액션 메뉴
|
||||
|
||||
기존 휴지통 버튼 1개 → 메뉴 (설정 페이지 내부의 dropdown 또는 inline 버튼 group):
|
||||
|
||||
- "완료로 이동"
|
||||
- "보관함으로 이동"
|
||||
- "휴지통으로 이동"
|
||||
|
||||
각 클릭 → 사유 입력 modal (한 줄 textarea, 빈 값 허용) → 확인 → `setStatus(id, target, reason)`.
|
||||
|
||||
### 3-5. AI 자동 분류 버튼
|
||||
|
||||
사유 입력 modal 안 옵션:
|
||||
|
||||
```
|
||||
사유: [____________________________]
|
||||
[ AI 자동 분류 ] [ 완료 ] [ 보관 ] [ 휴지통 ] [ 취소 ]
|
||||
```
|
||||
|
||||
"AI 자동 분류" 클릭 → main 의 `ai:classify-status` IPC → AiWorker 가 prompt:
|
||||
|
||||
```
|
||||
다음 메모와 사용자 사유를 보고 어디로 이동해야 할지 판단:
|
||||
- 메모 본문: <raw_text>
|
||||
- 메모 요약: <summary>
|
||||
- 사용자 사유: <reason>
|
||||
- 가능한 status: completed (작업 끝), archived (장기 보관, 회수 가능), trashed (불필요)
|
||||
JSON 출력: { "recommended": "completed|archived|trashed", "rationale": "..." }
|
||||
```
|
||||
|
||||
응답 → 사용자에게 추천 + rationale 표시:
|
||||
|
||||
```
|
||||
AI 추천: 완료
|
||||
이유: "처리됨" 표현 + 사용자 사유 "결재 끝" 일치
|
||||
[ 확정 ] [ 다른 status 선택 ]
|
||||
```
|
||||
|
||||
확정 → setStatus 적용. 다른 선택 → preset 버튼 노출.
|
||||
|
||||
### 3-6. IPC
|
||||
|
||||
```ts
|
||||
// 신규
|
||||
'inbox:set-status': (id: string, status: NoteStatus, reason: string | null) => Promise<{ ok: true }>
|
||||
'ai:classify-status': (id: string, reason: string) => Promise<{ recommended: NoteStatus; rationale: string }>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. F18 디테일
|
||||
|
||||
자유 텍스트 사유 입력 — F17 의 modal 안에 그대로 포함. 별도 컬럼 `notes.move_reason TEXT` (가장 마지막 사유 보존). 변경 이력 보존 시 별도 테이블 (`note_status_log`) 가능 but YAGNI — 마지막 사유만으로 충분.
|
||||
|
||||
빈 값 허용 (preset X 정책 따라). 검색/필터 — 향후 cut (F19 search) 에서 검색 인덱스 포함 가능.
|
||||
|
||||
---
|
||||
|
||||
## 5. F23 디테일
|
||||
|
||||
### 5-1. Schema
|
||||
|
||||
ai_status enum 확장: `pending | processing | complete | failed | disabled`. 마이그레이션 m004 동일 commit:
|
||||
|
||||
```sql
|
||||
-- ai_status 가 enum text 라 그대로 새 값 INSERT 가능. CHECK constraint 갱신:
|
||||
-- (SQLite 는 CHECK ALTER 직접 안 됨 → table 재생성 또는 trigger 추가)
|
||||
```
|
||||
|
||||
SQLite CHECK 갱신 어려움 — 옵션:
|
||||
|
||||
- (a) 기존 CHECK 제거 + 새 CHECK 추가 (table 재생성)
|
||||
- (b) CHECK 부재 + application-level 검증
|
||||
|
||||
추천: (b) — application 검증 (zod schema). 마이그레이션 비용 ↓.
|
||||
|
||||
### 5-2. Onboarding wizard
|
||||
|
||||
첫 launch (settings.json 의 `onboarding_completed` flag 부재) 시 모달:
|
||||
|
||||
```
|
||||
Inkling 사용 시작
|
||||
|
||||
Inkling 은 로컬 LLM (Ollama) 으로 메모를 자동 정리합니다.
|
||||
Ollama 가 설치되어 있고 한국어 지원 모델 (gemma3, gemma2 등) 이 pull 되어 있어야 최적의 경험이 가능합니다.
|
||||
|
||||
설치 가이드: https://ollama.com/download
|
||||
|
||||
[ AI 자동 처리 사용 (Ollama 필요) ]
|
||||
[ 원문만 저장 (AI 처리 안 함) ]
|
||||
[ 나중에 설정 ]
|
||||
```
|
||||
|
||||
3 옵션:
|
||||
|
||||
- (1) AI 사용 → settings.ai_enabled=true + onboarding_completed=true
|
||||
- (2) 원문만 → ai_enabled=false + onboarding_completed=true
|
||||
- (3) 나중에 → onboarding_completed=false (다음 launch 다시 prompt — 하지만 capture 가능, ai_enabled=null=undefined → default true)
|
||||
|
||||
추천: 3 옵션 모두 onboarding_completed=true 로 설정 + 사용자가 설정 페이지에서 언제든 변경. (3) 만 다시 prompt 면 friction.
|
||||
|
||||
수정: 3 옵션 모두 close 시 onboarding_completed=true. (3) 은 default ai_enabled=true (LAN Ollama 가정 본인 dogfood).
|
||||
|
||||
### 5-3. AI off 시 capture path
|
||||
|
||||
CaptureService.create():
|
||||
|
||||
```ts
|
||||
const aiEnabled = await settingsService.get('ai_enabled', true);
|
||||
if (!aiEnabled) {
|
||||
// notes INSERT with ai_status='disabled' + skip pending_jobs enqueue
|
||||
this.repo.insert({ ...input, ai_status: 'disabled' });
|
||||
return { id, ... };
|
||||
}
|
||||
// 기존 path
|
||||
```
|
||||
|
||||
### 5-4. NoteCard fallback
|
||||
|
||||
ai_status='disabled' 노트 → title fallback = raw_text 첫 60자 (또는 첫 줄).
|
||||
|
||||
```tsx
|
||||
const displayTitle = note.title?.trim() || note.rawText.split('\n')[0].slice(0, 60) || '(빈 메모)';
|
||||
```
|
||||
|
||||
summary/tags hide. raw_text 그대로.
|
||||
|
||||
### 5-5. Banner 비활성
|
||||
|
||||
ai_enabled=false → OllamaBanner / FailedBanner 자동 비활성 (state 가 false 면 render skip). HealthChecker 도 ai_enabled=false 시 polling 중단.
|
||||
|
||||
### 5-6. 설정 페이지 토글
|
||||
|
||||
AI 제공자 섹션 상단 추가:
|
||||
|
||||
```
|
||||
[ ] AI 자동 처리 사용
|
||||
|
||||
↳ AI 처리를 사용하면 메모의 제목/요약/태그가 자동 생성됩니다.
|
||||
Ollama 로컬 LLM 이 필요합니다. 설치 가이드: ollama.com/download
|
||||
```
|
||||
|
||||
토글 OFF → ON 전환 시: onboarding wizard 와 동일 prompt 재노출 (간소화 — 그냥 endpoint 검증 후 결과 표시).
|
||||
|
||||
### 5-7. 옛 노트 처리 (ON ↔ OFF 전환)
|
||||
|
||||
**B1 정책 채택** (roadmap):
|
||||
|
||||
- ON → OFF: 기존 pending 잔재 그대로 (드레인 후 enqueue stop)
|
||||
- OFF → ON: 기존 disabled 잔류 (사용자 명시 trigger 만 처리)
|
||||
|
||||
설정 페이지 안 "기존 disabled 메모 N건 — 지금 모두 처리" 버튼 (ON 전환 후 disabled count > 0 시 노출).
|
||||
|
||||
---
|
||||
|
||||
## 6. 테스트 전략
|
||||
|
||||
| 영역 | 단위 | 수동 |
|
||||
|---|---|---|
|
||||
| m004 마이그레이션 | mock db → status 컬럼 + 기존 deleted_at != NULL → trashed | - |
|
||||
| `setStatus` repo | 4 status 전환 + reason 저장 + statusChangedAt | - |
|
||||
| `listByStatus` | 각 status filter | - |
|
||||
| 4탭 UI 렌더 | view enum 4값 분기 + count badge | - |
|
||||
| 사유 입력 modal | 자유 텍스트 입력 + 빈 값 허용 + 4 status 버튼 | - |
|
||||
| `ai:classify-status` IPC | mock provider 응답 → recommended + rationale 반환 | - |
|
||||
| AI 자동 분류 UI | recommended 표시 + 확정 클릭 시 setStatus 호출 | - |
|
||||
| ai_status='disabled' enum | application zod 검증 + capture path skip pending_jobs | - |
|
||||
| Onboarding wizard | 첫 launch 시 표시 (settings 부재) + 3 옵션 결과 | 첫 launch 시 표시 확인 |
|
||||
| AI off 시 NoteCard | title fallback (raw 첫 줄), summary/tags hide | - |
|
||||
| AI off 시 Banner | render skip 회귀 | - |
|
||||
|
||||
**목표**: 단위 467 → 약 490 (+23), typecheck 0.
|
||||
|
||||
---
|
||||
|
||||
## 7. Risk
|
||||
|
||||
| Risk | 대응 |
|
||||
|---|---|
|
||||
| AI 자동 분류 정확도 낮음 | 추천만 표시, 사용자 confirm 강제. fallback = preset 4 status 버튼 |
|
||||
| status 4분기 + tag + reason layer 가 사용자 정신 부담 | dogfood 1주 측정. 사용 빈도 낮은 status 는 v0.2.10+ 에서 hide 옵션 |
|
||||
| Onboarding wizard 가 첫 launch 흐름 차단 | "나중에 설정" 옵션 제공 + close 가능 |
|
||||
| ai_enabled false 시 회귀 (기존 pending → 영원히 잔류) | 설정 페이지의 "기존 disabled 메모 N건 처리" 버튼 |
|
||||
|
||||
---
|
||||
|
||||
## 8. v0.2.9 후
|
||||
|
||||
**Cut C** (v0.2.10) — F20 raw_text revision history. AI 재실행 input = current raw_text (latest revision).
|
||||
|
||||
**dogfood verify**:
|
||||
|
||||
1. 4탭 사용 빈도 (active/completed/archived/trashed) — 사용 안 되는 status 발견 시 cut B+1 에서 hide
|
||||
2. AI 자동 분류 정확도 (사용자 confirm 비율)
|
||||
3. Onboarding wizard 의 3 옵션 비율
|
||||
240
docs/superpowers/specs/2026-05-09-v030-cut-e-design.md
Normal file
240
docs/superpowers/specs/2026-05-09-v030-cut-e-design.md
Normal file
@@ -0,0 +1,240 @@
|
||||
# v0.3.0 — Cut E Design (다기기 git-based 양방향 sync)
|
||||
|
||||
**작성일:** 2026-05-09
|
||||
**선행 문서:**
|
||||
|
||||
- `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` (F21)
|
||||
- `docs/superpowers/strategy/v028plus-roadmap.md` Cut E
|
||||
|
||||
**Cut 라벨:** v0.3.0 — semver MINOR (새 인프라 — 양방향 sync + Configure UI). Major 영역 진입.
|
||||
|
||||
---
|
||||
|
||||
## 1. Cut 정체성
|
||||
|
||||
기존 push-only `SyncService` → 양방향 (pull + import + conflict resolution + Configure UI). 다기기 (Mac 업무 + Windows 개인) dogfood 가능.
|
||||
|
||||
---
|
||||
|
||||
## 2. 범위
|
||||
|
||||
| 항목 | 결정 |
|
||||
|---|---|
|
||||
| **F21 옵션 A** | `git fetch && rebase` 후 markdown → SQLite re-import. 자동 rebase default (충돌 시 fail + 사용자 prompt) |
|
||||
| **F21 옵션 B** | 설정 페이지 안 "동기화 저장소" sub-section — URL 입력 + 인증 안내 + 마지막 sync 결과 |
|
||||
| **F21 옵션 C** | conflict UI — 자동 rebase 실패 시 양쪽 비교 + 사용자 선택 |
|
||||
| **pull 시점** | 양쪽 — manual ("지금 동기화") + 자동 주기 (사용자 설정 가능 interval, default 30분) |
|
||||
| **revision 결합 (Cut C)** | note_revisions 가 sync 대상 — 양 기기 rev 가 다른 chain 에 있으면 timestamp linear merge (옛 rev 가 sync source 로 inserted) |
|
||||
|
||||
---
|
||||
|
||||
## 3. SyncService 양방향화
|
||||
|
||||
### 3-1. 갱신된 sync() 흐름
|
||||
|
||||
```ts
|
||||
async sync(opts: { interval?: boolean } = {}): Promise<SyncStatus> {
|
||||
if (!(await this.isConfigured())) return { ok: false, reason: 'not_configured' };
|
||||
|
||||
const git = new GitClient(this.syncDir);
|
||||
|
||||
// 1. local export — 현재 SQLite 상태를 syncDir 에 markdown 으로 출력
|
||||
await this.exportSvc.export(this.syncDir, { includeMedia: true });
|
||||
await git.addAll();
|
||||
const localChanged = await git.hasUncommittedChanges();
|
||||
|
||||
// 2. local commit (변경 있으면)
|
||||
let localSha: string | null = null;
|
||||
if (localChanged) {
|
||||
const c = await git.commit(`chore(notes): sync ${this.now().toISOString()}`);
|
||||
localSha = c.sha;
|
||||
}
|
||||
|
||||
// 3. fetch
|
||||
const fetchR = await git.fetch();
|
||||
if (fetchR.exitCode !== 0) return { ok: false, reason: `fetch failed: ${fetchR.stderr}` };
|
||||
|
||||
// 4. rebase onto origin/main
|
||||
const rebaseR = await git.rebaseOnto('origin/main');
|
||||
if (rebaseR.exitCode !== 0) {
|
||||
// conflict — abort + conflict 목록 반환 (UI 가 활성)
|
||||
await git.rebaseAbort();
|
||||
return { ok: false, reason: 'conflict', conflicts: await this.listConflictsFromMarkdown() };
|
||||
}
|
||||
|
||||
// 5. re-import (rebase 후 markdown 변경 → SQLite upsertFromSync)
|
||||
const imported = await this.importSvc.applySyncFromDir(this.syncDir);
|
||||
|
||||
// 6. push
|
||||
try { await git.push(); } catch (e) { return { ok: false, reason: `push failed: ${(e as Error).message}` }; }
|
||||
|
||||
return { ok: true, changed: localChanged || imported.changedCount > 0, localSha, importedCount: imported.changedCount, pushed: true };
|
||||
}
|
||||
```
|
||||
|
||||
**6 단계 흐름 — local export 가 fetch 보다 먼저 (Cut E 정정)**: spec 초안은 fetch 우선이었으나, local export → commit 후 fetch + rebase 가 git workflow 표준 (rebase 가 local commit 위에 origin commit 적용). local export 안 한 상태로 fetch + rebase → 혼란 발생.
|
||||
|
||||
`SyncStatus` 인터페이스 확장:
|
||||
|
||||
```ts
|
||||
export interface SyncStatus {
|
||||
ok: boolean;
|
||||
reason?: string;
|
||||
changed?: boolean;
|
||||
localSha?: string | null;
|
||||
pushed?: boolean;
|
||||
importedCount?: number;
|
||||
conflicts?: Array<{ noteId: string; localText: string; remoteText: string }>; // reason='conflict' 시
|
||||
}
|
||||
```
|
||||
|
||||
### 3-2. ImportService 활용 (실제 코드 정정)
|
||||
|
||||
기존 ImportService 는 `run(sourceDir)` 메서드 (백업 복원 흐름) — `parsedToInput` → `repo.importNote()` 호출. spec 작성 시 가정한 `importAll(dir)` 시그니처는 실재 코드와 다름.
|
||||
|
||||
`repo.importNote()` 의 기존 conflict 정책 (export tree 복원용):
|
||||
|
||||
- id 없음 → INSERT (`status: 'inserted'`)
|
||||
- id 있음 + raw_text 동일 → no-op (`status: 'skipped'`)
|
||||
- id 있음 + raw_text 다름 → fork-on-id-collision (fresh uuidv7) (`status: 'forked'`)
|
||||
|
||||
**Cut E sync 정책 — fork 미적합, in-place update + revision 보존**:
|
||||
|
||||
sync 에서 양 기기 raw_text 가 다를 때 fork 하면 노트 갯수 무한 증가 → 부적합. 신설 메서드 `repo.upsertFromSync(input)`:
|
||||
|
||||
- id 없음 → INSERT (m006 trigger 가 capture revision 자동 생성)
|
||||
- id 있음 + raw_text 동일 → metadata 갱신 path
|
||||
- source.updatedAt > local.updatedAt 인 경우만 ai_title/ai_summary/tags/status/dueDate 갱신
|
||||
- tags 변경 시 `rebuildFtsTagsForNote` 호출 (Cut D single write path)
|
||||
- 동등/older 면 skip
|
||||
- id 있음 + raw_text 다름 → 옵션 분기:
|
||||
- source.updatedAt > local.updatedAt → `updateRawText(id, sourceRawText, sourceUpdatedAt)` (Cut C single write path) → 새 user revision INSERT, latest = source
|
||||
- local.updatedAt > source.updatedAt → skip (다음 push 가 source 갱신할 것)
|
||||
- 동일 timestamp + 다른 raw_text → SyncService 가 conflict 마킹 (rebase 단계 git markdown conflict 가 먼저 잡힘 — 본 분기는 rare)
|
||||
|
||||
**revision edited_by**: 'sync' enum 추가 안 함 — `updateRawText` 의 default 'user' 그대로 활용 (sync = user-edited 변경 전파 = 의미상 user). YAGNI: m008 회피.
|
||||
|
||||
### 3-3. GitClient 확장
|
||||
|
||||
```ts
|
||||
class GitClient {
|
||||
// 기존: run, isRepo, hasRemote, addAll, commit, push
|
||||
|
||||
// 신규
|
||||
async fetch(): Promise<GitExecResult>;
|
||||
async rebaseOnto(ref: string): Promise<GitExecResult>;
|
||||
async rebaseAbort(): Promise<GitExecResult>;
|
||||
async hasUncommittedChanges(): Promise<boolean>;
|
||||
async listConflicts(): Promise<string[]>; // git diff --name-only --diff-filter=U
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Configure UI (옵션 B)
|
||||
|
||||
설정 페이지 → 신규 sub-section "동기화 저장소":
|
||||
|
||||
```
|
||||
[동기화 저장소]
|
||||
저장소 URL: [git@gitea.example.com:user/inkling-notes.git]
|
||||
[ 저장 ] [ 연결 테스트 ]
|
||||
|
||||
마지막 sync: 2026-05-09 14:32 (성공, 3건 가져옴, 2건 보냄)
|
||||
다음 자동 sync: 2026-05-09 15:02
|
||||
|
||||
[ 자동 sync 사용 ]
|
||||
interval: [30] 분
|
||||
|
||||
[ 지금 동기화 ] [ 충돌 해결... ]
|
||||
```
|
||||
|
||||
저장소 URL 변경 → main 의 `settings:configure-sync` IPC 호출 → SyncService 가 `<profileDir>/sync/` 에 git init + remote add origin (없으면). 인증 (SSH key / token) 은 사용자 OS 설정 (`~/.ssh/` 또는 git credential helper) — Inkling 자체 인증 X, 안내 메시지만.
|
||||
|
||||
---
|
||||
|
||||
## 5. Conflict UI (옵션 C)
|
||||
|
||||
자동 rebase 실패 시 SyncService 가 `{ ok: false, reason: 'conflict', conflicts: [...] }` 반환. 설정 페이지 의 "충돌 해결..." 버튼 활성화.
|
||||
|
||||
클릭 → modal:
|
||||
|
||||
```
|
||||
충돌 N건
|
||||
|
||||
[note-id-1.md]
|
||||
< 내 기기 > | < 다른 기기 >
|
||||
본문 A | 본문 B
|
||||
|
|
||||
[ 내 것 사용 ] [ 원격 사용 ] [ 양쪽 보존 (옛 revision 으로) ]
|
||||
```
|
||||
|
||||
선택:
|
||||
|
||||
- **내 것 사용**: local 채택 (origin 변경 폐기)
|
||||
- **원격 사용**: origin 채택 (local 변경 → note_revisions 에 보존)
|
||||
- **양쪽 보존**: local + origin 모두 note_revisions 에 INSERT, latest = 사용자 선택 (또는 timestamp 더 최신)
|
||||
|
||||
확정 → SyncService.resolveConflict(noteId, choice) → git rebase --continue → push.
|
||||
|
||||
---
|
||||
|
||||
## 6. 자동 주기 sync
|
||||
|
||||
main process 가 settings.sync_interval_min (default 30) 마다 `SyncService.sync({ interval: true })` 호출. interval=true 시 conflict 발생해도 silent (notification 만, 사용자가 다음 manual 또는 conflict UI 진입 시 처리).
|
||||
|
||||
settings: `sync_auto_enabled: boolean` (default true 단, configured 일 때만), `sync_interval_min: number` (default 30, min 5).
|
||||
|
||||
---
|
||||
|
||||
## 7. IPC
|
||||
|
||||
```ts
|
||||
// 신규
|
||||
'settings:configure-sync': (url: string) => Promise<{ ok: true } | { ok: false; reason: string }>
|
||||
'settings:test-sync-connection': () => Promise<{ ok: true } | { ok: false; reason: string }> // git ls-remote
|
||||
'sync:list-conflicts': () => Promise<Array<{ noteId: string; localText: string; remoteText: string }>>
|
||||
'sync:resolve-conflict': (noteId: string, choice: 'local' | 'remote' | 'both') => Promise<{ ok: true }>
|
||||
'sync:get-status': () => Promise<{ lastAt: string | null; lastResult: SyncStatus | null; nextAt: string | null }>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 테스트 전략
|
||||
|
||||
| 영역 | 단위 |
|
||||
|---|---|
|
||||
| `GitClient.fetch / rebaseOnto / rebaseAbort` | mock execFile + 결과 검증 |
|
||||
| `SyncService.sync` 양방향 | mock GitClient + ImportService → 6 단계 흐름 |
|
||||
| 자동 rebase 성공 | conflict 없는 시나리오 |
|
||||
| 자동 rebase 실패 → abort | conflict 시 rebaseAbort + reason 반환 |
|
||||
| ImportService.importAll | markdown → notes UPSERT + revision INSERT |
|
||||
| revision merge | 양 chain → timestamp 순 linear |
|
||||
| Configure UI | URL 입력 → IPC → git init/remote add |
|
||||
| Conflict UI | 3 choice 별 sync 동작 |
|
||||
| 자동 주기 sync | timer + interval=true mode |
|
||||
|
||||
**목표**: 단위 608 → 약 635 (+27), typecheck 0.
|
||||
|
||||
---
|
||||
|
||||
## 9. Risk
|
||||
|
||||
| Risk | 대응 |
|
||||
|---|---|
|
||||
| 인증 설정 실패 (사용자 SSH key 부재) | Configure UI 의 "연결 테스트" 버튼 — git ls-remote 결과 사용자에게 표시 |
|
||||
| revision linear merge 정확도 | timestamp 단조 증가 가정 (양 기기 시계 동기화). NTP 부재 시 충돌 risk → 사용자 prompt |
|
||||
| 자동 주기 sync 의 silent 충돌 누적 | interval mode 충돌 시 notification + 충돌 UI 자동 popup option |
|
||||
| Cut C revision history 와 sync 결합 시 chain 분기 | 본 cut 의 정책: timestamp linear, branch 분기 미지원 (사용자 manual 결정으로 처리) |
|
||||
|
||||
---
|
||||
|
||||
## 10. v0.3.0 후
|
||||
|
||||
**Cut F** (v0.3.1) — F24 멀티모달 vision.
|
||||
|
||||
**dogfood verify**:
|
||||
|
||||
1. Mac + Windows 양 기기 sync 1주 — 충돌 빈도 측정
|
||||
2. 자동 주기 sync 의 timing — battery / network 영향
|
||||
3. revision merge 정확도 (사용자 confirm 비율)
|
||||
246
docs/superpowers/specs/2026-05-09-v031-cut-f-design.md
Normal file
246
docs/superpowers/specs/2026-05-09-v031-cut-f-design.md
Normal file
@@ -0,0 +1,246 @@
|
||||
# v0.3.1 — Cut F Design (멀티모달 vision AI)
|
||||
|
||||
**작성일:** 2026-05-09
|
||||
**선행 문서:**
|
||||
|
||||
- `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` (F24)
|
||||
- `docs/superpowers/strategy/v028plus-roadmap.md` Cut F
|
||||
|
||||
**Cut 라벨:** v0.3.1 — patch (vision 추가, 기존 기능 영향 X)
|
||||
|
||||
---
|
||||
|
||||
## 1. Cut 정체성
|
||||
|
||||
Ollama vision 모델 (gemma3 family default) 활용 — 이미지 + raw_text 결합 prompt 또는 이미지 단독 분석 → title/summary/tags 자동 생성. F22 prerequisite (Cut A) 이미 완료.
|
||||
|
||||
---
|
||||
|
||||
## 2. 범위
|
||||
|
||||
| 항목 | 결정 |
|
||||
|---|---|
|
||||
| **F24 default 모델** | gemma3 family (한국어 + 이미지 둘 다 강함, 본인 메모 `gemma4:e4b` 텍스트 모델과 같은 가족) |
|
||||
| **prompt 모드** | 단일 vision 모델 호출 (vision 모델이 텍스트도 처리). 모델 capability 부족 시 2단계 fallback (자동) |
|
||||
| **capability detection** | app launch 시 1회 + 설정 페이지 manual refresh 버튼 |
|
||||
| **F23 OFF 시 자동 OFF** | `ai_enabled=false` → vision 도 자동 OFF (자명) |
|
||||
|
||||
---
|
||||
|
||||
## 3. Capability Detection
|
||||
|
||||
### 3-1. Ollama API 활용
|
||||
|
||||
`GET /api/tags` → 사용자 Ollama instance 의 모델 목록. response:
|
||||
|
||||
```json
|
||||
{
|
||||
"models": [
|
||||
{ "name": "gemma4:e4b", "details": { "family": "gemma" } },
|
||||
{ "name": "gemma3:12b-vision", "details": { "family": "gemma3", "families": ["gemma3"] } },
|
||||
{ "name": "llava:13b", "details": { "family": "llava" } }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
vision capable 판정 — 모델 이름 또는 family 기반:
|
||||
|
||||
```ts
|
||||
const VISION_FAMILIES = new Set(['gemma3', 'llava', 'llama3.2-vision', 'minicpm-v', 'pixtral']);
|
||||
const VISION_NAME_HINTS = ['vision', 'vl', 'multimodal', 'gemma3'];
|
||||
|
||||
function isVisionCapable(model: { name: string; details?: { family?: string; families?: string[] } }): boolean {
|
||||
if (model.details?.family && VISION_FAMILIES.has(model.details.family)) return true;
|
||||
if (model.details?.families?.some(f => VISION_FAMILIES.has(f))) return true;
|
||||
return VISION_NAME_HINTS.some(h => model.name.toLowerCase().includes(h));
|
||||
}
|
||||
```
|
||||
|
||||
### 3-2. Settings storage
|
||||
|
||||
```ts
|
||||
interface SettingsSchema {
|
||||
// ... 기존
|
||||
vision_model?: string; // 사용자 명시 모델 (빈 값 = 비활성)
|
||||
vision_capable_cache?: string[]; // launch 시 detected 결과 cache
|
||||
vision_cache_at?: string; // ISO timestamp
|
||||
}
|
||||
```
|
||||
|
||||
### 3-3. AppLaunchDetect
|
||||
|
||||
```ts
|
||||
// src/main/index.ts whenReady 안 (settings 초기화 후)
|
||||
async function refreshVisionCache(): Promise<void> {
|
||||
if (!settingsService.get('ai_enabled', true)) return;
|
||||
try {
|
||||
const tags = await fetch(`${endpoint}/api/tags`).then(r => r.json());
|
||||
const capable = tags.models.filter(isVisionCapable).map((m: any) => m.name);
|
||||
settingsService.set('vision_capable_cache', capable);
|
||||
settingsService.set('vision_cache_at', new Date().toISOString());
|
||||
} catch {
|
||||
// network fail — silent, cache 유지
|
||||
}
|
||||
}
|
||||
|
||||
void refreshVisionCache();
|
||||
```
|
||||
|
||||
### 3-4. 설정 페이지 UI (AI 제공자 섹션 확장)
|
||||
|
||||
```
|
||||
[AI 제공자]
|
||||
Endpoint: [http://localhost:11434]
|
||||
모델: [gemma4:e4b]
|
||||
|
||||
[이미지 분석 모델 (선택사항)]
|
||||
[gemma3:12b-vision ▾] ← dropdown, 비어 있으면 비활성
|
||||
가능한 모델: gemma3:12b-vision, llava:13b, ...
|
||||
[ 다시 감지 ] 마지막 감지: 2026-05-09 14:30
|
||||
```
|
||||
|
||||
dropdown — `vision_capable_cache` 결과 + 빈 옵션. "다시 감지" → `refreshVisionCache()` + UI 갱신.
|
||||
|
||||
---
|
||||
|
||||
## 4. InferenceProvider 확장
|
||||
|
||||
### 4-1. 인터페이스
|
||||
|
||||
```ts
|
||||
// src/main/ai/InferenceProvider.ts
|
||||
interface GenerateInput {
|
||||
text: string;
|
||||
images?: Array<{ base64: string; mime: string }>; // NEW
|
||||
todayKst: string;
|
||||
dueDateCandidates: string[];
|
||||
vocab?: string[];
|
||||
}
|
||||
|
||||
interface InferenceProvider {
|
||||
generate(input: GenerateInput, opts?: { visionModel?: string }): Promise<AiResponse>;
|
||||
abort?(): void;
|
||||
}
|
||||
```
|
||||
|
||||
### 4-2. LocalOllamaProvider 갱신
|
||||
|
||||
```ts
|
||||
async generate(input: GenerateInput, opts?: { visionModel?: string }): Promise<AiResponse> {
|
||||
const useVision = !!opts?.visionModel && (input.images?.length ?? 0) > 0;
|
||||
const model = useVision ? opts.visionModel : this.textModel;
|
||||
|
||||
const body: any = {
|
||||
model,
|
||||
prompt: useVision
|
||||
? buildVisionPrompt(input.text, input.todayKst, input.dueDateCandidates, input.vocab ?? [])
|
||||
: buildPrompt(input.text, input.todayKst, input.dueDateCandidates, input.vocab ?? []),
|
||||
stream: false,
|
||||
format: 'json'
|
||||
};
|
||||
if (useVision) {
|
||||
body.images = input.images!.map(i => i.base64);
|
||||
}
|
||||
|
||||
const res = await request(`${this.endpoint}/api/generate`, body);
|
||||
// ... 기존 parse
|
||||
}
|
||||
```
|
||||
|
||||
### 4-3. buildVisionPrompt
|
||||
|
||||
```ts
|
||||
function buildVisionPrompt(text: string, todayKst: string, dueCandidates: string[], vocab: string[]): string {
|
||||
return `다음 메모와 첨부 이미지를 종합 분석해 한국어로 요약하세요.
|
||||
|
||||
메모 본문 (비어 있을 수 있음):
|
||||
${text || '(이미지만 있음)'}
|
||||
|
||||
이미지 분석 시 주요 시각적 정보 (텍스트, 사람, 장면) 도 포함해 요약하세요.
|
||||
출력 JSON: { "title": "...", "summary": "...", "tags": [...], "due_date": "..." }
|
||||
오늘: ${todayKst}
|
||||
가능한 due 후보: ${dueCandidates.join(', ')}
|
||||
빈출 태그: ${vocab.slice(0, 20).join(', ')}`;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. AiWorker 통합
|
||||
|
||||
CaptureService 가 capture 시 image 첨부했으면 → notes.media 에 저장 + pending_jobs INSERT. AiWorker 가 job 처리 시:
|
||||
|
||||
```ts
|
||||
// src/main/ai/AiWorker.ts
|
||||
async processJob(noteId: string): Promise<void> {
|
||||
const note = this.repo.getById(noteId);
|
||||
const media = this.repo.listMediaByNote(noteId);
|
||||
const visionModel = this.settings.get('vision_model');
|
||||
|
||||
let images: Array<{ base64: string; mime: string }> | undefined;
|
||||
if (visionModel && media.length > 0) {
|
||||
images = await Promise.all(media.map(async (m) => ({
|
||||
base64: (await fs.readFile(this.mediaStore.absolutePath(m.relPath))).toString('base64'),
|
||||
mime: m.mime
|
||||
})));
|
||||
}
|
||||
|
||||
const provider = this.providerHolder.get();
|
||||
const response = await provider.generate({ text: note.rawText, images, ... }, { visionModel });
|
||||
// ... 기존 결과 적용
|
||||
}
|
||||
```
|
||||
|
||||
`media.length > 0 && visionModel` 둘 다 true 일 때만 vision path. 그 외는 기존 text-only.
|
||||
|
||||
---
|
||||
|
||||
## 6. 이미지만 있는 capture
|
||||
|
||||
`raw_text` 빈 값 + media 첨부만:
|
||||
|
||||
- 기존 동작: notes INSERT (raw_text=''), AiWorker 가 빈 prompt 로 호출 → ai_status='failed' 또는 무의미 응답
|
||||
- vision enabled: AiWorker 가 vision prompt + images → 의미 있는 title/summary/tags 응답
|
||||
- vision disabled (visionModel 빈 값): notes 저장만, ai_status='disabled' 신규 enum 활용 (Cut B 의 ai_enabled false 와 비슷한 의미 — 그러나 부분 disable, 즉 "이미지 only 라 처리 불가" 상태)
|
||||
|
||||
추천: vision disabled + image-only capture 시 `ai_status='skipped'` 신규 enum (Cut B 의 'disabled' 와 다름). title fallback = "(이미지 N개)" 또는 첫 이미지 파일명.
|
||||
|
||||
---
|
||||
|
||||
## 7. 테스트 전략
|
||||
|
||||
| 영역 | 단위 |
|
||||
|---|---|
|
||||
| `isVisionCapable` | family / families / name hint 별 판정 |
|
||||
| `refreshVisionCache` | mock /api/tags → capable 추출 + settings 저장 |
|
||||
| 설정 페이지 dropdown | cache 기반 옵션 + "다시 감지" 클릭 → IPC |
|
||||
| `LocalOllamaProvider.generate` vision path | images 비어있음 → text-only / images 있음 + visionModel → vision body |
|
||||
| `buildVisionPrompt` | 빈 text + images 만 케이스 정확 prompt |
|
||||
| `AiWorker.processJob` vision integration | media + visionModel 있을 때만 base64 변환 |
|
||||
| 이미지 only capture | raw_text='' + media → vision 결과 정상 또는 'skipped' 분기 |
|
||||
|
||||
**목표**: 단위 555 → 약 575 (+20), typecheck 0.
|
||||
|
||||
---
|
||||
|
||||
## 8. Risk
|
||||
|
||||
| Risk | 대응 |
|
||||
|---|---|
|
||||
| vision 모델 추론 latency 큼 (수 초~분) | AiWorker backend 처리 — 사용자 대기 X. NoteCard 가 ai_status='processing' 표시 |
|
||||
| 이미지 base64 메모리 부담 | media 1개당 평균 < 1MB. 다중 이미지 시 N×base64 = 메모리 N배. cap (이미지당 max size 5MB) 적용 |
|
||||
| capability detection 실패 시 fallback | cache 부재 → vision dropdown 비어있음 표시 + "다시 감지" 안내 |
|
||||
| vision 모델 한국어 정확도 | dogfood 검증. gemma3 가 한국어 약하면 다른 family 추천 갱신 (메모리 정책 갱신) |
|
||||
| Ollama 가 vision images 필드 무시 (모델이 multimodal 미지원) | 자동 2단계 fallback — vision 모델로 caption 추출 → 텍스트 모델로 종합 (capability 부족 시) |
|
||||
|
||||
---
|
||||
|
||||
## 9. v0.3.1 후
|
||||
|
||||
**Cut G** (v0.3.2) — F25 사이드바 + notebook_id.
|
||||
|
||||
**dogfood verify**:
|
||||
|
||||
1. 이미지 capture 빈도 (가설: 일 ≥ 1건 = vision 가치)
|
||||
2. vision 결과 사용자 수정 비율 (정확도 측정)
|
||||
3. capability detection 정확도 (false-positive / false-negative)
|
||||
227
docs/superpowers/specs/2026-05-09-v032-cut-g-design.md
Normal file
227
docs/superpowers/specs/2026-05-09-v032-cut-g-design.md
Normal file
@@ -0,0 +1,227 @@
|
||||
# v0.3.2 — Cut G Design (사이드바 + notebook 카테고리)
|
||||
|
||||
**작성일:** 2026-05-09
|
||||
**선행 문서:**
|
||||
|
||||
- `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` (F25)
|
||||
- `docs/superpowers/strategy/v028plus-roadmap.md` Cut G
|
||||
|
||||
**Cut 라벨:** v0.3.2
|
||||
|
||||
---
|
||||
|
||||
## 1. Cut 정체성
|
||||
|
||||
inbox layout 재구성 — 사이드바 + 메모 카테고리 (notebook). single-pane → two-pane. 단일 DB 안 `notebook_id` 컬럼 (옵션 B — 1주 scope, 다중 profile 옵션 A 는 v0.4+ 후보).
|
||||
|
||||
---
|
||||
|
||||
## 2. 범위
|
||||
|
||||
| 항목 | 결정 |
|
||||
|---|---|
|
||||
| **F25 저장소 정의** | B — 카테고리/폴더 (notebook_id, 단일 DB 안 그룹화) |
|
||||
| **사이드바 가시성** | 사용자 토글 + last state 보존 (settings) |
|
||||
| **사이드바 내용** | 상단 notebook 목록 + 하단 메모 list (compact view) |
|
||||
|
||||
---
|
||||
|
||||
## 3. Schema 마이그레이션 (m007)
|
||||
|
||||
```sql
|
||||
CREATE TABLE notebooks (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT, -- accent color for UI (옵션)
|
||||
created_at TEXT NOT NULL,
|
||||
position INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
INSERT INTO notebooks (id, name, created_at, position)
|
||||
VALUES ('default', '기본', '2026-05-09T00:00:00Z', 0);
|
||||
|
||||
ALTER TABLE notes ADD COLUMN notebook_id TEXT NOT NULL DEFAULT 'default'
|
||||
REFERENCES notebooks(id) ON DELETE RESTRICT;
|
||||
|
||||
CREATE INDEX idx_notes_notebook_status ON notes(notebook_id, status, created_at DESC);
|
||||
```
|
||||
|
||||
기존 모든 notes → notebook_id='default'. 사용자가 새 notebook 생성 후 메모 이동 가능.
|
||||
|
||||
`ON DELETE RESTRICT` — notebook 삭제 시 노트 잔류해야 함. notebook 삭제 흐름은 사용자가 명시 (메모 이동 후 삭제).
|
||||
|
||||
---
|
||||
|
||||
## 4. NotebookRepository
|
||||
|
||||
```ts
|
||||
class NotebookRepository {
|
||||
list(): Notebook[];
|
||||
get(id: string): Notebook | undefined;
|
||||
create(name: string, color?: string): Notebook;
|
||||
rename(id: string, name: string): void;
|
||||
delete(id: string): void; // notebook 안 메모 0건일 때만 (RESTRICT 위반 시 throw)
|
||||
reorder(ids: string[]): void; // position 갱신
|
||||
countNotes(id: string, opts?: { status?: NoteStatus }): number;
|
||||
}
|
||||
```
|
||||
|
||||
NoteRepository 의 모든 query 에 `notebook_id` filter 추가:
|
||||
|
||||
```ts
|
||||
listByStatus(status: NoteStatus, opts: { notebookId?: string; limit?: number }): Note[];
|
||||
moveToNotebook(noteId: string, notebookId: string): void;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. UI — 사이드바
|
||||
|
||||
### 5-1. layout
|
||||
|
||||
```
|
||||
┌──────────────┬───────────────────────────────────┐
|
||||
│ [≡] Inkling │ [Inbox(N) 완료(N) 보관(N) 휴지통(N)] [🔍 search] [⚙] │
|
||||
├──────────────┼───────────────────────────────────┤
|
||||
│ 노트북 │ │
|
||||
│ • 기본 (12) │ NoteCard list (current view) │
|
||||
│ • 회사 (5) │ │
|
||||
│ • 학습 (3) │ │
|
||||
│ + 새 노트북 │ │
|
||||
├──────────────┤ │
|
||||
│ 메모 빠른 list│ │
|
||||
│ - title 1 │ │
|
||||
│ - title 2 │ │
|
||||
│ - title 3 │ │
|
||||
│ ... │ │
|
||||
└──────────────┴───────────────────────────────────┘
|
||||
```
|
||||
|
||||
폭: 240px (settings 의 `sidebar_width` 사용자 조정 가능, default 240, min 180, max 400).
|
||||
|
||||
### 5-2. 토글
|
||||
|
||||
헤더 좌측 햄버거 (`≡`) 버튼 → `useInbox.sidebarVisible` toggle. last state 저장 (`settings.sidebar_visible`).
|
||||
|
||||
키보드 shortcut: `Ctrl+B` (또는 `Cmd+B` macOS) — 빠른 토글.
|
||||
|
||||
### 5-3. Notebook 목록
|
||||
|
||||
상단 panel — `NotebookRepository.list()` + 각 notebook 의 active 메모 count.
|
||||
|
||||
- 클릭 → `useInbox.selectedNotebookId` 갱신 → main pane 의 NoteCard list 가 해당 notebook 만 표시.
|
||||
- 우클릭 → context menu: 이름 변경 / 색 변경 / 삭제 (메모 0건일 때만).
|
||||
- "+ 새 노트북" 버튼 → modal: name 입력 + color picker (선택사항) → create.
|
||||
|
||||
### 5-4. 메모 빠른 list
|
||||
|
||||
하단 panel — selected notebook + selected status (Inbox/완료/보관/휴지통 탭) 의 NoteCard 들의 compact view.
|
||||
|
||||
- title + tag chip 1-2 개 + 시간 (relative — "2시간 전")
|
||||
- 클릭 → main pane 가 해당 NoteCard 위치로 scroll (또는 강조)
|
||||
|
||||
main pane 의 NoteCard grid 와 사이드 빠른 list 는 동일 데이터 — 단지 view 다름. 사이드는 navigation, main 은 detail.
|
||||
|
||||
### 5-5. NoteCard 갱신 — notebook 이동
|
||||
|
||||
NoteCard 액션 메뉴 (Cut B 의 status 메뉴 옆):
|
||||
|
||||
- "다른 노트북으로 이동" → notebook 목록 dropdown → 선택 → `moveToNotebook` IPC
|
||||
|
||||
---
|
||||
|
||||
## 6. store 갱신
|
||||
|
||||
```ts
|
||||
interface InboxState {
|
||||
// 기존
|
||||
view: 'inbox' | 'completed' | 'archived' | 'trash' | 'review-daily' | 'review-weekly' | 'review-monthly' | 'settings';
|
||||
|
||||
// 신규 (Cut G)
|
||||
notebooks: Notebook[];
|
||||
selectedNotebookId: string;
|
||||
sidebarVisible: boolean;
|
||||
loadNotebooks: () => Promise<void>;
|
||||
selectNotebook: (id: string) => void;
|
||||
createNotebook: (name: string, color?: string) => Promise<void>;
|
||||
renameNotebook: (id: string, name: string) => Promise<void>;
|
||||
deleteNotebook: (id: string) => Promise<void>;
|
||||
toggleSidebar: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
`refreshMeta` / `loadInitial` 가 notebooks 도 함께 fetch.
|
||||
|
||||
---
|
||||
|
||||
## 7. IPC
|
||||
|
||||
```ts
|
||||
'inbox:list-notebooks': () => Promise<Notebook[]>
|
||||
'inbox:create-notebook': (name: string, color?: string) => Promise<Notebook>
|
||||
'inbox:rename-notebook': (id: string, name: string) => Promise<{ ok: true }>
|
||||
'inbox:delete-notebook': (id: string) => Promise<{ ok: true } | { ok: false; reason: string }>
|
||||
'inbox:move-to-notebook': (noteId: string, notebookId: string) => Promise<{ ok: true }>
|
||||
'inbox:reorder-notebooks': (ids: string[]) => Promise<{ ok: true }>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. F19 search 와 결합 (Cut D 후)
|
||||
|
||||
search box — 사이드바 도입 후 위치 검토:
|
||||
|
||||
- (a) **inbox 헤더 잔류** (Cut D 결정) — 단순. 사이드바 토글 무관.
|
||||
- (b) **사이드바 안 상단** — 사이드바 visible 일 때만 search. hidden 시 inbox 헤더 fallback.
|
||||
|
||||
추천: (a) — Cut D 결정 보존, 사이드바 토글 무관. UX 일관.
|
||||
|
||||
search 결과 — current selectedNotebookId 안만 또는 모든 notebook? settings 토글 또는 search options dropdown. 추천: 기본 current notebook 안 검색 + "모든 노트북에서 검색" 옵션.
|
||||
|
||||
---
|
||||
|
||||
## 9. 테스트 전략
|
||||
|
||||
| 영역 | 단위 |
|
||||
|---|---|
|
||||
| m007 마이그레이션 | notebooks 테이블 + 'default' INSERT + notes.notebook_id backfill |
|
||||
| `NotebookRepository.list/create/rename/delete/reorder` | 각 메서드 |
|
||||
| `delete` RESTRICT | 메모 잔류 시 throw |
|
||||
| `moveToNotebook` | notebook_id 갱신 + 카운트 영향 |
|
||||
| 사이드바 토글 | store action + settings 저장 |
|
||||
| Notebook 목록 렌더 | count badge + 클릭 → selectedNotebookId 갱신 |
|
||||
| 메모 빠른 list | selectedNotebook + selectedView 필터 |
|
||||
| Notebook 생성 modal | name 입력 + color picker → create |
|
||||
| Notebook 삭제 | 메모 잔류 시 error 표시 |
|
||||
| search + notebook scope | 'current notebook' / 'all' 옵션별 필터 |
|
||||
|
||||
**목표**: 단위 575 → 약 600 (+25), typecheck 0.
|
||||
|
||||
---
|
||||
|
||||
## 10. Risk
|
||||
|
||||
| Risk | 대응 |
|
||||
|---|---|
|
||||
| 사이드바 폭이 좁은 화면 (1280×720) 에서 너무 큼 | default hidden 옵션? settings 의 width 조정 + 좁은 화면 시 자동 hide |
|
||||
| Notebook 삭제 시 RESTRICT error UX | error message + "메모 N건 이동 후 다시 시도" 안내 |
|
||||
| 다중 notebook 시 search default scope 혼란 | search box 옆 'current/all' 토글 + 기본 current |
|
||||
| F21 sync (Cut E) 와 결합 시 notebook 정합성 | sync markdown export 가 notebook_id 도 frontmatter 에 포함 — Cut E ImportService 갱신 (미리 spec 잔류 — Cut G 머지 시 ImportService 갱신 commit 포함) |
|
||||
| 다중 profile 옵션 A 로 진화 시 notebook → profile 마이그레이션 | v0.4+ 영역. 본 cut 은 단일 profile + notebook 다 |
|
||||
|
||||
---
|
||||
|
||||
## 11. v0.3.2 후
|
||||
|
||||
**v0.4 후보** (사용자 dogfood metric 충족 후 외부 확장):
|
||||
|
||||
- F25 옵션 A (다중 profile 분리 DB) — 외부 user 확장 시
|
||||
- F19 옵션 B (context-based recall — 시간/태그/요일)
|
||||
- F19 옵션 E (spaced repetition)
|
||||
- F25 옵션 C (다중 sync remote)
|
||||
|
||||
**dogfood verify**:
|
||||
|
||||
1. 사이드바 사용 빈도 (열린 채로 유지 / 토글 자주)
|
||||
2. notebook 갯수 (본인 dogfood — 1개 vs N개)
|
||||
3. notebook 간 메모 이동 빈도 (분류 욕구 측정)
|
||||
211
docs/superpowers/strategy/v028plus-roadmap.md
Normal file
211
docs/superpowers/strategy/v028plus-roadmap.md
Normal file
@@ -0,0 +1,211 @@
|
||||
# v0.2.8+ Roadmap — F17~F25 cut 분할 + 우선순위
|
||||
|
||||
**작성일:** 2026-05-09
|
||||
**저자:** 김태현
|
||||
**선행 문서:**
|
||||
|
||||
- `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` (F17~F25 raw + chore 아이콘)
|
||||
- `docs/superpowers/v024-backlog.md` (잔여 23건 — v0.2.6 cut 후 deferred)
|
||||
- `docs/superpowers/strategy/strategy.md` (심리학 전략)
|
||||
|
||||
**목적:** v0.2.7 release 후 dogfood 9건 누적 + chore 1건 의 cut sequencing + 우선순위 + dependency 결정. v0.2.8 brainstorm 진입 직전 alignment 문서.
|
||||
|
||||
---
|
||||
|
||||
## 1. 항목 요약
|
||||
|
||||
| ID | 제목 | scope | 분류 |
|
||||
|---|---|---|---|
|
||||
| F17 | 휴지통 의미 분기 (완료/보관/버림) | 1주 (옵션 C 보관함만 별도) | 데이터 모델 |
|
||||
| F18 | 메모 이동 시 사유 입력 | 1일 (F17 묶음) | 데이터 모델 |
|
||||
| F19 | 획기적 recall (search/context/AI/회고/spaced/자연어) | A 단독 3-4일 / 묶음 1-2주 | UX 본질 |
|
||||
| F20 | 기존 메모 raw_text 수정 (load-bearing invariant 재검토) | 옵션 B 3-4일 | 데이터 모델 |
|
||||
| F21 | 다기기 git-based sync (양방향 + Configure + conflict) | 1-2주 | 인프라 |
|
||||
| F22 | NoteCard 이미지 회색 placeholder bug | 1-2일 | 명확한 bug |
|
||||
| F23 | 로컬 LLM 활성화 옵션 (Ollama-less 모드) | 3-4일 | 환경 대응 |
|
||||
| F24 | 이미지 멀티모달 vision AI | 1주 (F22 prerequisite) | AI 확장 |
|
||||
| F25 | 사이드바 + 메모 저장소 리스트 | 옵션 결정 후 1-3주 | UI 큰 변화 |
|
||||
| chore | 앱 아이콘 SVG → ICO/ICNS/PNG + builder 통합 | 0.5일 | release polish |
|
||||
|
||||
---
|
||||
|
||||
## 2. Dependency Graph
|
||||
|
||||
```dot
|
||||
digraph G {
|
||||
rankdir=LR;
|
||||
F22 -> F24 [label="prerequisite (이미지 렌더 → vision 결과 surface)"];
|
||||
F17 -> F18 [label="conceptual 강한 결합 (status + reason)"];
|
||||
F17 -> F19 [label="status 분기 데이터가 recall 입력"];
|
||||
F20 -> F21 [label="user_edited_text 가 sync 충돌 정책 입력"];
|
||||
F23 -> F19 [label="Ollama-less 시 recall 단순화 (tag 부재)"];
|
||||
F23 -> F17 [label="raw-only 모드에서 status 자동 분류 무력"];
|
||||
F25 -> F17 [label="저장소 + status + tag 분기 layer 정합 필요"];
|
||||
chore [shape=box, style=filled];
|
||||
F22 [shape=box, style=filled];
|
||||
chore -> "v0.2.8";
|
||||
F22 -> "v0.2.8";
|
||||
}
|
||||
```
|
||||
|
||||
**핵심 prerequisite chain:**
|
||||
|
||||
- F22 → F24 (이미지 보여야 vision 결과 surface 의미)
|
||||
- F20 → F21 (sync 충돌 정책 = `user_edited_text` 우선순위)
|
||||
- F17 + F23 → F19 (recall 알고리즘 입력은 status / Ollama-less 영향)
|
||||
|
||||
**독립 항목 (다른 항목 영향 받지 않음):**
|
||||
|
||||
- F22 (bug fix)
|
||||
- chore (icon)
|
||||
|
||||
---
|
||||
|
||||
## 3. Cut 분할 + 버전 매핑
|
||||
|
||||
### Cut A — v0.2.8 (1주 미만, 빠른 polish)
|
||||
|
||||
**테마:** dogfood UX 마찰 + release polish
|
||||
|
||||
| 항목 | scope |
|
||||
|---|---|
|
||||
| F22 (이미지 렌더링 fix) | 1-2일 — `inkling-media://` custom protocol + `<img>` |
|
||||
| chore (앱 아이콘) | 0.5일 — SVG → ICO/ICNS/PNG 다중 size + electron-builder config |
|
||||
|
||||
**합 2-3일.** 명확한 작업, 빠른 release. 의사결정 X (기술 detail 만).
|
||||
|
||||
### Cut B — v0.2.9 (2주, 데이터 모델 정비 1차)
|
||||
|
||||
**테마:** 휴지통의 의미 분기 + 사유 + Ollama-less
|
||||
|
||||
| 항목 | scope |
|
||||
|---|---|
|
||||
| F17 (status — 옵션 C 보관함만 별도) | 1주 — `archived_at` 컬럼 + UI 탭 + 마이그레이션 |
|
||||
| F18 (사유 입력 — preset + 자유 텍스트) | 1일 (F17 묶음) |
|
||||
| F23 (Ollama-less 토글) | 3-4일 — ai_status='disabled' enum + capture skip + UI fallback |
|
||||
|
||||
**합 1.5-2주.** F17/F18 같은 데이터 모델 변경 cut 안에 함께. F23 의 raw-only 모드가 F17 status 와 같은 schema 영역이라 효율.
|
||||
|
||||
**의사결정 필요 (brainstorm 단계)**:
|
||||
|
||||
- F17 옵션 A/B/C 중 — C 추천 (보관함만 별도) 가 가장 균형
|
||||
- F18 preset 항목 명세 ("완료" / "급하지 않음" / "잘못 적음" / "기타")
|
||||
- F23 ON↔OFF 전환 정책 (B1 추천 — 잔류)
|
||||
|
||||
### Cut C — v0.2.10 (1주, raw_text invariant)
|
||||
|
||||
**테마:** F20 단독 — load-bearing invariant 재검토
|
||||
|
||||
| 항목 | scope |
|
||||
|---|---|
|
||||
| F20 (raw_text 수정 — 옵션 B `user_edited_text`) | 3-4일 |
|
||||
|
||||
**합 1주.** Cut C 단독 cut 인 이유 = invariant 정책 변경 자체가 의사결정 큰 작업. 별도 PR 로 review focus 보장. 후속 Cut D (sync) 의 prerequisite.
|
||||
|
||||
**의사결정 필요**:
|
||||
|
||||
- 옵션 A (raw_text 직접 수정 + 원본 lost) vs B (`user_edited_text` 분기) — B 추천
|
||||
- AI 재실행 시 input — raw_text vs user_edited_text 우선순위
|
||||
|
||||
### Cut D — v0.2.11 (1.5-2주, recall 1차)
|
||||
|
||||
**테마:** F19 — search 진입 + 회고 view
|
||||
|
||||
| 항목 | scope |
|
||||
|---|---|
|
||||
| F19 옵션 A (FTS5 free text search) | 3-4일 |
|
||||
| F19 옵션 D (회고 view) | 1주 |
|
||||
|
||||
**합 1.5-2주.** F19 의 6 옵션 중 가장 작은 + 가치 큰 둘 (search + 회고). B/C/E/F 는 v0.3+ deferred.
|
||||
|
||||
**의사결정 필요**:
|
||||
|
||||
- search box 위치 (header / 사이드바 — F25 결정 영향)
|
||||
- 회고 view 트리거 (수동 라우트 / 월요일 자동 banner)
|
||||
|
||||
### Cut E — v0.3.0 (2주, 다기기 sync)
|
||||
|
||||
**테마:** F21 — 양방향 sync + Configure UI
|
||||
|
||||
| 항목 | scope |
|
||||
|---|---|
|
||||
| F21 옵션 A (양방향 sync — fetch+rebase+import) | 1주 |
|
||||
| F21 옵션 B (Configure UI) | 3-4일 |
|
||||
| F21 옵션 C (conflict UI) | 0.5주 |
|
||||
|
||||
**합 2주.** F20 의 user_edited_text 가 conflict 정책 입력 — 따라서 Cut C 후. v0.3.0 = MINOR bump (semver 엄밀히도 minor — 새 feature 큰 영역).
|
||||
|
||||
### Cut F — v0.3.1 (1-1.5주, 멀티모달 vision)
|
||||
|
||||
**테마:** F24 — Ollama vision 모델 활용
|
||||
|
||||
| 항목 | scope |
|
||||
|---|---|
|
||||
| F24 (capability detection + 멀티모달 prompt + InferenceProvider 확장) | 1주 |
|
||||
|
||||
**합 1주.** F22 prerequisite 충족 (Cut A) 이므로 진행 가능. F23 (Ollama-less) OFF 시 자동 OFF.
|
||||
|
||||
### Cut G — v0.3.2 (1-3주, 사이드바 + 저장소)
|
||||
|
||||
**테마:** F25 — UI 큰 변화
|
||||
|
||||
| 항목 | scope |
|
||||
|---|---|
|
||||
| F25 옵션 A (다중 profile) | 2-3주 — 큰 refactor |
|
||||
| F25 옵션 B (notebook_id) | 1주 |
|
||||
| F25 옵션 C (다중 sync remote) | 0.5주 |
|
||||
|
||||
**의사결정 필요 (직접 사용자 의도 확인)**:
|
||||
|
||||
- "메모 저장소" = 다중 DB 분리 (A) / 카테고리 폴더 (B) / sync remote (C) 어느 의미인가
|
||||
|
||||
---
|
||||
|
||||
## 4. 우선순위 + 시간선 추정
|
||||
|
||||
```
|
||||
2026-05-09 ~ 2026-05-15 Cut A (v0.2.8) ✦ 빠른 polish
|
||||
2026-05-15 ~ 2026-05-29 Cut B (v0.2.9) ✦ 데이터 모델 정비
|
||||
2026-05-29 ~ 2026-06-05 Cut C (v0.2.10) ✦ invariant 변경
|
||||
2026-06-05 ~ 2026-06-19 Cut D (v0.2.11) ✦ recall 1차
|
||||
2026-06-19 ~ 2026-07-03 Cut E (v0.3.0) ✦ 다기기 sync
|
||||
2026-07-03 ~ 2026-07-10 Cut F (v0.3.1) ✦ 멀티모달
|
||||
2026-07-10 ~ 2026-07-31 Cut G (v0.3.2) ✦ 사이드바 + 저장소
|
||||
```
|
||||
|
||||
**총 약 12주.** 본인 dogfood 2주 완주 종료 조건 (v0.4 slice §1.3) 은 Cut B 종료 시점 도달. 그 후 Cut C-G 는 외부 확장 영역.
|
||||
|
||||
---
|
||||
|
||||
## 5. Risk + Open Questions
|
||||
|
||||
| ID | 질문 |
|
||||
|---|---|
|
||||
| F17 | A/B/C 중 결정 — dogfood 1주 측정 후? |
|
||||
| F18 | preset 항목 정확 명세 |
|
||||
| F19 | recall 6 옵션 중 cut D 에 A+D 외 추가 여부 |
|
||||
| F20 | invariant 폐기 (옵션 A) 충분 vs B (`user_edited_text`) 분기 — B 균형 추천 |
|
||||
| F21 | conflict 처리 default (rebase / merge / 사용자 prompt) |
|
||||
| F23 | default ON / OFF — 본인 LAN Ollama 가정 시 ON, 외부 user 첫 실행 OFF? |
|
||||
| F24 | vision 모델 default 추천 (한국어 + 이미지) — dogfood 검증 필요 |
|
||||
| F25 | "메모 저장소" 정의 (A/B/C) — 직접 사용자 확인 |
|
||||
|
||||
---
|
||||
|
||||
## 6. v0.2.8 brainstorm 진입 시 결정 사항
|
||||
|
||||
Cut A (v0.2.8) 는 의사결정 거의 없는 작업이라 brainstorm 가벼움. 그러나 절차상 진입.
|
||||
|
||||
**Cut A brainstorm focus:**
|
||||
|
||||
1. F22 — `inkling-media://` custom protocol 디테일 (path traversal 검사 / fallback / thumbnail vs full-size)
|
||||
2. chore — 아이콘 size 매트릭스 (16/32/64/128/256/512/1024) + electron-builder config (`build.win.icon`/`build.mac.icon`/`build.linux.icon`)
|
||||
3. v0.2.8 release notes 초안
|
||||
|
||||
이후 Cut B brainstorm 은 F17 옵션 결정 + F18 preset + F23 정책 등 의사결정 多. 별도 brainstorm 세션.
|
||||
|
||||
---
|
||||
|
||||
## 7. 변경 이력
|
||||
|
||||
- 2026-05-09: 작성. F17~F25 + chore 9+1 entry triage. Cut A~G 분할.
|
||||
3428
package-lock.json
generated
3428
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
39
package.json
39
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "inkling",
|
||||
"version": "0.2.7",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"description": "Inkling — local-first 한 줄 보관 도구",
|
||||
"author": "altair823 <dlsrks0734@gmail.com>",
|
||||
@@ -30,7 +30,9 @@
|
||||
"predist:mac": "npm run rebuild:electron && npm run build",
|
||||
"dist:mac": "electron-builder --mac --arm64",
|
||||
"predist:linux": "npm run rebuild:electron && npm run build",
|
||||
"dist:linux": "electron-builder --linux --x64"
|
||||
"dist:linux": "electron-builder --linux --x64",
|
||||
"build:icons:png": "node scripts/svg-to-png.mjs assets/icon.svg build/icon-source.png 1024",
|
||||
"build:icons": "npm run build:icons:png && electron-icon-builder --input=build/icon-source.png --output=build --flatten && node scripts/finalize-icons.mjs"
|
||||
},
|
||||
"build": {
|
||||
"appId": "xyz.altair823.inkling",
|
||||
@@ -44,8 +46,14 @@
|
||||
"**/*.node"
|
||||
],
|
||||
"win": {
|
||||
"icon": "build/icon.ico",
|
||||
"target": [
|
||||
{ "target": "nsis", "arch": ["x64"] }
|
||||
{
|
||||
"target": "nsis",
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"nsis": {
|
||||
@@ -56,16 +64,33 @@
|
||||
"shortcutName": "Inkling"
|
||||
},
|
||||
"mac": {
|
||||
"icon": "build/icon.icns",
|
||||
"target": [
|
||||
{ "target": "dmg", "arch": ["arm64"] }
|
||||
{
|
||||
"target": "dmg",
|
||||
"arch": [
|
||||
"arm64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"category": "public.app-category.productivity",
|
||||
"identity": null
|
||||
},
|
||||
"linux": {
|
||||
"icon": "build/icon.png",
|
||||
"target": [
|
||||
{ "target": "AppImage", "arch": ["x64"] },
|
||||
{ "target": "deb", "arch": ["x64"] }
|
||||
{
|
||||
"target": "AppImage",
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "deb",
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"category": "Utility",
|
||||
"synopsis": "로컬 메모 캡처 + AI 태그",
|
||||
@@ -92,8 +117,10 @@
|
||||
"@vitejs/plugin-react": "5.1.4",
|
||||
"electron": "41.3.0",
|
||||
"electron-builder": "26.8.1",
|
||||
"electron-icon-builder": "^2.0.1",
|
||||
"electron-vite": "5.0.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"sharp": "^0.34.5",
|
||||
"typescript": "6.0.3",
|
||||
"undici": "8.1.0",
|
||||
"vite": "7.3.2",
|
||||
|
||||
35
scripts/finalize-icons.mjs
Normal file
35
scripts/finalize-icons.mjs
Normal file
@@ -0,0 +1,35 @@
|
||||
import { copyFileSync, renameSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
// electron-icon-builder --flatten 은 build/icons/ 안에 icon.ico, icon.icns, <size>x<size>.png
|
||||
// 들을 만든다. electron-builder 는 build/icon.ico, build/icon.icns, build/icon.png 를
|
||||
// 기대 — 정규 위치로 옮긴다.
|
||||
const buildDir = 'build';
|
||||
const iconsDir = join(buildDir, 'icons');
|
||||
|
||||
const moves = [
|
||||
['icon.ico', 'icon.ico'],
|
||||
['icon.icns', 'icon.icns'],
|
||||
];
|
||||
|
||||
for (const [src, dest] of moves) {
|
||||
const from = join(iconsDir, src);
|
||||
const to = join(buildDir, dest);
|
||||
if (existsSync(from)) {
|
||||
renameSync(from, to);
|
||||
console.log(`Moved: ${from} -> ${to}`);
|
||||
} else {
|
||||
console.error(`MISSING: ${from}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const png1024 = join(iconsDir, '1024x1024.png');
|
||||
const pngOut = join(buildDir, 'icon.png');
|
||||
if (existsSync(png1024)) {
|
||||
copyFileSync(png1024, pngOut);
|
||||
console.log(`Copied: ${png1024} -> ${pngOut}`);
|
||||
} else {
|
||||
console.error(`MISSING: ${png1024}`);
|
||||
process.exit(1);
|
||||
}
|
||||
14
scripts/svg-to-png.mjs
Normal file
14
scripts/svg-to-png.mjs
Normal file
@@ -0,0 +1,14 @@
|
||||
import sharp from 'sharp';
|
||||
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
const [, , input, output, size = '1024'] = process.argv;
|
||||
if (!input || !output) {
|
||||
console.error('Usage: svg-to-png.mjs <input.svg> <output.png> [size]');
|
||||
process.exit(1);
|
||||
}
|
||||
mkdirSync(dirname(output), { recursive: true });
|
||||
const svg = readFileSync(input);
|
||||
const png = await sharp(svg).resize(Number(size), Number(size)).png().toBuffer();
|
||||
writeFileSync(output, png);
|
||||
console.log(`OK: ${output} (${size}x${size})`);
|
||||
@@ -16,4 +16,10 @@ export interface InferenceProvider {
|
||||
healthCheck(): Promise<HealthResult>;
|
||||
/** v0.2.3.1 — 외부에서 in-flight generate 강제 중단. ProviderHolder.replace 시 사용. */
|
||||
abort?: () => void;
|
||||
/**
|
||||
* v0.2.9 Cut B Task 9 — raw JSON 응답 호출. classifyStatus 같은 자체 prompt 호출용.
|
||||
* Ollama `/api/generate` 의 raw `response` 문자열을 그대로 반환한다 (보통 JSON 문자열).
|
||||
* 미구현 provider 는 undefined; classifyStatus 는 그 경우 안전 fallback 으로 동작.
|
||||
*/
|
||||
generateRaw?: (prompt: string) => Promise<string>;
|
||||
}
|
||||
|
||||
@@ -66,6 +66,39 @@ export class LocalOllamaProvider implements InferenceProvider {
|
||||
this.abortController?.abort();
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.9 Cut B Task 9 — raw JSON 호출 (classifyStatus 등 자체 prompt 용).
|
||||
* `format: 'json'` + `stream: false` 로 Ollama 가 valid JSON 문자열을 반환하도록 강제.
|
||||
* abortController / timeout 은 generate() 와 동일 패턴.
|
||||
*/
|
||||
async generateRaw(prompt: string): Promise<string> {
|
||||
this.abortController = new AbortController();
|
||||
const timer = setTimeout(() => this.abortController?.abort(), this.timeoutMs);
|
||||
try {
|
||||
const res = await request(`${this.endpoint}/api/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: this.model,
|
||||
prompt,
|
||||
format: 'json',
|
||||
stream: false,
|
||||
options: { temperature: this.temperature, num_predict: this.numPredict }
|
||||
}),
|
||||
signal: this.abortController.signal
|
||||
});
|
||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||
throw new Error(`ollama http ${res.statusCode}`);
|
||||
}
|
||||
const body = (await res.body.json()) as { response?: string };
|
||||
if (!body.response) throw new Error('missing response field');
|
||||
return body.response;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
this.abortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<HealthResult> {
|
||||
try {
|
||||
const res = await request(`${this.endpoint}/api/tags`, { method: 'GET' });
|
||||
|
||||
83
src/main/ai/classifyStatus.ts
Normal file
83
src/main/ai/classifyStatus.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import type { InferenceProvider } from './InferenceProvider.js';
|
||||
import type { NoteStatus } from '@shared/types';
|
||||
|
||||
export interface ClassifyStatusInput {
|
||||
provider: InferenceProvider;
|
||||
rawText: string;
|
||||
summary: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface ClassifyStatusOutput {
|
||||
recommended: NoteStatus;
|
||||
rationale: string;
|
||||
}
|
||||
|
||||
const VALID: readonly NoteStatus[] = ['completed', 'archived', 'trashed'];
|
||||
|
||||
const PROMPT_TEMPLATE = `다음 메모를 분류하세요.
|
||||
가능한 status:
|
||||
- completed: 작업이 끝났고 더 이상 행동 불필요
|
||||
- archived: 장기 보관 — 회수 가능, 지금은 보지 않음
|
||||
- trashed: 불필요, 의미 없는 메모
|
||||
|
||||
JSON 출력만 하세요: { "recommended": "completed|archived|trashed", "rationale": "<한 문장 한국어>" }
|
||||
|
||||
메모 본문:
|
||||
{{rawText}}
|
||||
|
||||
메모 요약:
|
||||
{{summary}}
|
||||
|
||||
사용자 이동 사유:
|
||||
{{reason}}`;
|
||||
|
||||
const FALLBACK: ClassifyStatusOutput = {
|
||||
recommended: 'archived',
|
||||
rationale: '판단 실패 — 안전하게 보관 추천'
|
||||
};
|
||||
|
||||
/**
|
||||
* v0.2.9 Cut B Task 9 — AI 자동 분류 (status 추천).
|
||||
*
|
||||
* provider.generateRaw 가 있으면 raw JSON 응답 사용, 없으면 generate() 재사용 시도
|
||||
* (그 경우 응답 형태 불일치로 보통 fallback). 에러/parse 실패 시 'archived' 안전 default
|
||||
* (사용자 데이터 보존 우선).
|
||||
*/
|
||||
export async function classifyStatus(
|
||||
input: ClassifyStatusInput
|
||||
): Promise<ClassifyStatusOutput> {
|
||||
const prompt = PROMPT_TEMPLATE
|
||||
.replace('{{rawText}}', input.rawText.length > 0 ? input.rawText : '(빈 메모)')
|
||||
.replace('{{summary}}', input.summary.length > 0 ? input.summary : '(요약 없음)')
|
||||
.replace('{{reason}}', input.reason.length > 0 ? input.reason : '(사유 없음)');
|
||||
|
||||
let rawJson: string;
|
||||
try {
|
||||
if (typeof input.provider.generateRaw === 'function') {
|
||||
rawJson = await input.provider.generateRaw(prompt);
|
||||
} else {
|
||||
// 호환 경로 — provider.generate 가 raw 응답을 노출하지 않으므로 안전 fallback.
|
||||
return FALLBACK;
|
||||
}
|
||||
} catch {
|
||||
return FALLBACK;
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(rawJson);
|
||||
} catch {
|
||||
return FALLBACK;
|
||||
}
|
||||
|
||||
if (typeof parsed !== 'object' || parsed === null) return FALLBACK;
|
||||
const obj = parsed as { recommended?: unknown; rationale?: unknown };
|
||||
if (typeof obj.recommended !== 'string' || !VALID.includes(obj.recommended as NoteStatus)) {
|
||||
return FALLBACK;
|
||||
}
|
||||
return {
|
||||
recommended: obj.recommended as NoteStatus,
|
||||
rationale: typeof obj.rationale === 'string' ? obj.rationale : ''
|
||||
};
|
||||
}
|
||||
@@ -2,8 +2,12 @@ import type Database from 'better-sqlite3';
|
||||
import * as m001 from './m001_initial.js';
|
||||
import * as m002 from './m002_due_date.js';
|
||||
import * as m003 from './m003_soft_delete.js';
|
||||
import * as m004 from './m004_status.js';
|
||||
import * as m005 from './m005_ai_disabled.js';
|
||||
import * as m006 from './m006_revisions.js';
|
||||
import * as m007 from './m007_fts.js';
|
||||
|
||||
const migrations = [m001, m002, m003];
|
||||
const migrations = [m001, m002, m003, m004, m005, m006, m007];
|
||||
|
||||
export function latestVersion(): number {
|
||||
return migrations[migrations.length - 1]!.version;
|
||||
|
||||
18
src/main/db/migrations/m004_status.ts
Normal file
18
src/main/db/migrations/m004_status.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// v4: status 4분기 (active/completed/archived/trashed) + 사유 + status_changed_at.
|
||||
// 기존 deleted_at != NULL 노트는 status='trashed' 로 migrate. deleted_at 컬럼은
|
||||
// backward compat 위해 잔류 (status='trashed' 와 동기화). 향후 cut 에서 제거 가능.
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
export const version = 4;
|
||||
|
||||
export function up(db: Database.Database): void {
|
||||
db.exec(`
|
||||
ALTER TABLE notes ADD COLUMN status TEXT NOT NULL DEFAULT 'active';
|
||||
ALTER TABLE notes ADD COLUMN status_changed_at TEXT;
|
||||
ALTER TABLE notes ADD COLUMN move_reason TEXT;
|
||||
`);
|
||||
db.prepare(
|
||||
`UPDATE notes SET status='trashed', status_changed_at=deleted_at
|
||||
WHERE deleted_at IS NOT NULL`
|
||||
).run();
|
||||
}
|
||||
65
src/main/db/migrations/m005_ai_disabled.ts
Normal file
65
src/main/db/migrations/m005_ai_disabled.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
// v5: ai_status enum 에 'disabled' 추가 (v0.2.9 Cut B). settings.ai_enabled=false 일 때
|
||||
// CaptureService 가 새 노트를 ai_status='disabled' 로 insert + pending_jobs enqueue skip.
|
||||
//
|
||||
// SQLite 는 ALTER COLUMN ... CHECK 미지원 → table recreate 패턴.
|
||||
// 외래키 (note_tags / media / pending_jobs) 는 notes.id 를 참조 + ON DELETE CASCADE 라
|
||||
// FK off + DROP/RENAME 시 데이터 보존 위해 새 테이블 생성 → INSERT SELECT → DROP old → RENAME new.
|
||||
// PRAGMA foreign_keys=OFF 안에서 single transaction (runMigrations 가 transaction 으로 감쌈).
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
export const version = 5;
|
||||
|
||||
export function up(db: Database.Database): void {
|
||||
// 기존 인덱스/CHECK 제약을 그대로 유지하되 ai_status 만 'disabled' 추가.
|
||||
db.exec(`
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE notes_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
raw_text TEXT NOT NULL,
|
||||
ai_title TEXT,
|
||||
ai_summary TEXT,
|
||||
ai_status TEXT NOT NULL
|
||||
CHECK (ai_status IN ('pending','done','failed','disabled')),
|
||||
ai_error TEXT,
|
||||
ai_provider TEXT,
|
||||
ai_generated_at TEXT,
|
||||
title_edited_by_user INTEGER NOT NULL DEFAULT 0
|
||||
CHECK (title_edited_by_user IN (0,1)),
|
||||
summary_edited_by_user INTEGER NOT NULL DEFAULT 0
|
||||
CHECK (summary_edited_by_user IN (0,1)),
|
||||
user_intent TEXT,
|
||||
intent_prompted_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
due_date TEXT,
|
||||
due_date_edited_by_user INTEGER NOT NULL DEFAULT 0
|
||||
CHECK (due_date_edited_by_user IN (0,1)),
|
||||
deleted_at TEXT,
|
||||
last_recalled_at TEXT,
|
||||
recall_dismissed_at TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
status_changed_at TEXT,
|
||||
move_reason TEXT
|
||||
);
|
||||
INSERT INTO notes_new (
|
||||
id, raw_text, ai_title, ai_summary, ai_status, ai_error, ai_provider, ai_generated_at,
|
||||
title_edited_by_user, summary_edited_by_user, user_intent, intent_prompted_at,
|
||||
created_at, updated_at, due_date, due_date_edited_by_user,
|
||||
deleted_at, last_recalled_at, recall_dismissed_at,
|
||||
status, status_changed_at, move_reason
|
||||
)
|
||||
SELECT
|
||||
id, raw_text, ai_title, ai_summary, ai_status, ai_error, ai_provider, ai_generated_at,
|
||||
title_edited_by_user, summary_edited_by_user, user_intent, intent_prompted_at,
|
||||
created_at, updated_at, due_date, due_date_edited_by_user,
|
||||
deleted_at, last_recalled_at, recall_dismissed_at,
|
||||
status, status_changed_at, move_reason
|
||||
FROM notes;
|
||||
DROP TABLE notes;
|
||||
ALTER TABLE notes_new RENAME TO notes;
|
||||
CREATE INDEX idx_notes_created_at ON notes(created_at DESC);
|
||||
CREATE INDEX idx_notes_ai_status ON notes(ai_status);
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_deleted_at ON notes(deleted_at);
|
||||
PRAGMA foreign_keys=ON;
|
||||
`);
|
||||
}
|
||||
23
src/main/db/migrations/m006_revisions.ts
Normal file
23
src/main/db/migrations/m006_revisions.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// v6: note_revisions 테이블 + 기존 notes 의 raw_text 를 edited_by='capture' revision 으로 backfill.
|
||||
// FK ON DELETE CASCADE — notes 영구 삭제 시 revision 도 함께 삭제.
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
export const version = 6;
|
||||
|
||||
export function up(db: Database.Database): void {
|
||||
db.exec(`
|
||||
CREATE TABLE note_revisions (
|
||||
rev_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
note_id TEXT NOT NULL,
|
||||
raw_text TEXT NOT NULL,
|
||||
edited_at TEXT NOT NULL,
|
||||
edited_by TEXT NOT NULL DEFAULT 'user'
|
||||
CHECK (edited_by IN ('user','capture')),
|
||||
FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX idx_note_revisions_note_id ON note_revisions(note_id, edited_at DESC);
|
||||
|
||||
INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by)
|
||||
SELECT id, raw_text, created_at, 'capture' FROM notes;
|
||||
`);
|
||||
}
|
||||
48
src/main/db/migrations/m007_fts.ts
Normal file
48
src/main/db/migrations/m007_fts.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
// v7: notes_fts FTS5 virtual table + trigger 3개 + 기존 notes (status != 'trashed') backfill.
|
||||
// raw_text/ai_title/ai_summary 는 trigger 자동 sync. tags 는 note_tags JOIN 결과를
|
||||
// NoteRepository 의 명시 헬퍼 (rebuildFtsTagsForNote) 로 갱신 — Cut D 의 single write path.
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
export const version = 7;
|
||||
|
||||
export function up(db: Database.Database): void {
|
||||
db.exec(`
|
||||
CREATE VIRTUAL TABLE notes_fts USING fts5(
|
||||
note_id UNINDEXED,
|
||||
raw_text,
|
||||
ai_title,
|
||||
ai_summary,
|
||||
tags,
|
||||
tokenize='unicode61'
|
||||
);
|
||||
|
||||
CREATE TRIGGER notes_fts_ai AFTER INSERT ON notes BEGIN
|
||||
INSERT INTO notes_fts (note_id, raw_text, ai_title, ai_summary, tags)
|
||||
VALUES (NEW.id, NEW.raw_text, COALESCE(NEW.ai_title, ''), COALESCE(NEW.ai_summary, ''), '');
|
||||
END;
|
||||
|
||||
CREATE TRIGGER notes_fts_ad AFTER DELETE ON notes BEGIN
|
||||
DELETE FROM notes_fts WHERE note_id = OLD.id;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER notes_fts_au AFTER UPDATE ON notes BEGIN
|
||||
UPDATE notes_fts
|
||||
SET raw_text = NEW.raw_text,
|
||||
ai_title = COALESCE(NEW.ai_title, ''),
|
||||
ai_summary = COALESCE(NEW.ai_summary, '')
|
||||
WHERE note_id = NEW.id;
|
||||
END;
|
||||
|
||||
INSERT INTO notes_fts (note_id, raw_text, ai_title, ai_summary, tags)
|
||||
SELECT
|
||||
n.id,
|
||||
n.raw_text,
|
||||
COALESCE(n.ai_title, ''),
|
||||
COALESCE(n.ai_summary, ''),
|
||||
COALESCE((SELECT GROUP_CONCAT(t.name, ' ')
|
||||
FROM note_tags nt JOIN tags t ON t.id = nt.tag_id
|
||||
WHERE nt.note_id = n.id), '')
|
||||
FROM notes n
|
||||
WHERE n.status != 'trashed';
|
||||
`);
|
||||
}
|
||||
@@ -30,14 +30,21 @@ import { BackupService } from './services/BackupService.js';
|
||||
import { ExportService } from './services/ExportService.js';
|
||||
import { ImportService } from './services/ImportService.js';
|
||||
import { SyncService } from './services/SyncService.js';
|
||||
import { SyncTimer } from './services/SyncTimer.js';
|
||||
import { TelemetryService } from './services/TelemetryService.js';
|
||||
import { SettingsService } from './services/SettingsService.js';
|
||||
import { collectAutostartState } from './services/AutostartDiagnostic.js';
|
||||
import { registerSchemesAsPrivileged, registerInklingMediaProtocol } from './protocol/inklingMedia.js';
|
||||
import { DEFAULT_OLLAMA_ENDPOINT, DEFAULT_OLLAMA_MODEL } from '../shared/constants.js';
|
||||
|
||||
const HIDDEN_ARG = '--hidden';
|
||||
const startedHidden = process.argv.includes(HIDDEN_ARG);
|
||||
|
||||
// v0.2.8 Cut A — `inkling-media://` custom protocol 스킴은 app.whenReady() 전에
|
||||
// privileged 등록 필수 (Electron 표준). 이미지 asset 을 main process 가 직접
|
||||
// 서빙해 file:// hack 없이 작동.
|
||||
registerSchemesAsPrivileged();
|
||||
|
||||
// CRITICAL — single-instance lock + hidden-flag 전달 (v0.2.6 #46).
|
||||
// 두 번째 .exe 가 hidden 으로 spawn 됐다면 (autostart) 첫 instance 의 inbox 창
|
||||
// 띄우지 않음 — 사용자가 명시적으로 클릭한 게 아니므로.
|
||||
@@ -73,6 +80,9 @@ app.whenReady().then(async () => {
|
||||
|
||||
const paths = resolveProfilePaths('default');
|
||||
|
||||
// v0.2.8 Cut A — `inkling-media://` request handler 등록 (profileDir 결정 후).
|
||||
registerInklingMediaProtocol(paths.profileDir);
|
||||
|
||||
const telemetry = new TelemetryService(join(paths.profileDir, 'telemetry'), () => new Date(), 14, { silent: true });
|
||||
void telemetry.cleanupOldFiles()
|
||||
.then((r) => logger.info('telemetry.cleanup', { removed: r.removed.length }))
|
||||
@@ -113,6 +123,8 @@ app.whenReady().then(async () => {
|
||||
const provider = new LocalOllamaProvider({ endpoint: resolvedEndpoint, model: resolvedModel });
|
||||
const providerHolder = new ProviderHolder(provider);
|
||||
const health = new HealthChecker(providerHolder, {
|
||||
// v0.2.9 Cut B Task 14 — AI 비활성 시 health polling skip (Ollama 미설치 환경 무영향).
|
||||
isAiEnabled: () => settingsSvc.isAiEnabled(),
|
||||
onUpdate: (status) => {
|
||||
logger.info('ai.health', { ...status } as Record<string, unknown>);
|
||||
pushOllamaStatus(getInboxWindow, status);
|
||||
@@ -150,13 +162,17 @@ app.whenReady().then(async () => {
|
||||
const capture = new CaptureService(repo, store, {
|
||||
enqueue: (id) => worker.enqueue(id),
|
||||
celebrate: (id) => notify.celebrate(id),
|
||||
telemetry
|
||||
telemetry,
|
||||
settings: settingsSvc
|
||||
});
|
||||
|
||||
registerCaptureApi(capture, getQuickCaptureWindow);
|
||||
registerInboxApi({
|
||||
repo, continuity, capture, health, intent,
|
||||
getInboxWindow, settings: settingsSvc, providerHolder
|
||||
getInboxWindow, settings: settingsSvc, providerHolder,
|
||||
paths: { profileDir: paths.profileDir },
|
||||
// v0.2.9 Cut B Task 16 — disabled 메모 일괄 재투입 시 in-memory queue 갱신.
|
||||
enqueue: (id) => worker.enqueue(id)
|
||||
});
|
||||
// registerSettingsApi 는 backup / exportSvc / importSvc / syncSvc / telemetry 가
|
||||
// 생성된 뒤에 호출 (Task 10) — 아래 BackupService/ExportService/... 초기화 직후로 이동.
|
||||
@@ -181,7 +197,8 @@ app.whenReady().then(async () => {
|
||||
|
||||
const exportSvc = new ExportService(repo, store);
|
||||
const importSvc = new ImportService(repo, store);
|
||||
const syncSvc = new SyncService(paths.profileDir, exportSvc);
|
||||
const syncSvc = new SyncService(paths.profileDir, exportSvc, importSvc);
|
||||
const syncTimer = new SyncTimer(syncSvc, settingsSvc);
|
||||
|
||||
const backup = new BackupService(db, join(paths.profileDir, 'backups'));
|
||||
void backup.runDaily()
|
||||
@@ -191,14 +208,18 @@ app.whenReady().then(async () => {
|
||||
// v0.2.7 Task 10 — 설정 페이지 IPC (autostart + backup/export/import/sync/telemetry).
|
||||
// backup / exportSvc / importSvc / syncSvc / telemetry 가 모두 준비된 뒤 등록.
|
||||
registerSettingsApi({
|
||||
backup, exportSvc, importSvc, syncSvc, telemetry, getInboxWindow
|
||||
backup, exportSvc, importSvc, syncSvc, telemetry, settings: settingsSvc, getInboxWindow,
|
||||
syncTimer
|
||||
});
|
||||
|
||||
void syncTimer.start();
|
||||
|
||||
let backupOnQuitDone = false;
|
||||
let trayInterval: NodeJS.Timeout | null = null;
|
||||
app.on('before-quit', (e) => {
|
||||
// 모든 cleanup 한 곳에 통합 — sync (idempotent) → async backup chain.
|
||||
health.stop();
|
||||
syncTimer.stop();
|
||||
if (trayInterval !== null) {
|
||||
clearInterval(trayInterval);
|
||||
trayInterval = null;
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import electron from 'electron';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
const { ipcMain, dialog } = electron;
|
||||
const { ipcMain, dialog, shell } = electron;
|
||||
import { join, normalize, sep } from 'node:path';
|
||||
import type { NoteRepository } from '../repository/NoteRepository.js';
|
||||
import type { ContinuityService } from '../services/ContinuityService.js';
|
||||
import type { CaptureService } from '../services/CaptureService.js';
|
||||
import type { HealthChecker } from '../services/HealthChecker.js';
|
||||
import type { IntentService } from '../services/IntentService.js';
|
||||
import type { Note } from '@shared/types';
|
||||
import type { Note, NoteStatus } from '@shared/types';
|
||||
import type { HealthResult } from '../ai/InferenceProvider.js';
|
||||
import { LocalOllamaProvider } from '../ai/LocalOllamaProvider.js';
|
||||
import { classifyStatus } from '../ai/classifyStatus.js';
|
||||
import type { SettingsService } from '../services/SettingsService.js';
|
||||
import type { ProviderHolder } from '../ai/ProviderHolder.js';
|
||||
|
||||
@@ -21,6 +23,12 @@ export interface InboxIpcDeps {
|
||||
getInboxWindow: () => BrowserWindow | null;
|
||||
settings: SettingsService;
|
||||
providerHolder: ProviderHolder;
|
||||
// v0.2.8 Cut A — `inbox:open-media` 의 path traversal 검사 baseline.
|
||||
paths: { profileDir: string };
|
||||
// v0.2.9 Cut B Task 16 — disabled 메모 일괄 처리 시 in-memory worker queue 갱신.
|
||||
// 미주입 시 fire-and-forget skip (다음 launch 의 loadFromDb 가 처리). 본 hook 은
|
||||
// AiWorker 인스턴스 직접 주입을 피해 IPC 모듈이 worker import 를 갖지 않도록 분리.
|
||||
enqueue?: (noteId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function registerInboxApi(deps: InboxIpcDeps): void {
|
||||
@@ -153,6 +161,98 @@ export function registerInboxApi(deps: InboxIpcDeps): void {
|
||||
return s.ollama ?? null;
|
||||
});
|
||||
|
||||
// v0.2.8 Cut A — 첨부 이미지 클릭 시 OS 기본 뷰어로 열기 (Task 3).
|
||||
// path traversal 검사는 inkling-media:// protocol handler 와 동일한 패턴 (Task 1).
|
||||
ipcMain.handle('inbox:open-media', async (_e, relPath: string) => {
|
||||
if (typeof relPath !== 'string' || relPath.length === 0) {
|
||||
return { ok: false as const, reason: 'invalid path' as const };
|
||||
}
|
||||
const profileDir = deps.paths.profileDir;
|
||||
const mediaRoot = join(profileDir, 'media');
|
||||
const target = normalize(join(profileDir, relPath));
|
||||
if (!target.startsWith(mediaRoot + sep) && target !== mediaRoot) {
|
||||
return { ok: false as const, reason: 'invalid path' as const };
|
||||
}
|
||||
await shell.openPath(target);
|
||||
return { ok: true as const };
|
||||
});
|
||||
|
||||
// v0.2.9 Cut B Task 4 — status 별 노트 목록.
|
||||
ipcMain.handle(
|
||||
'inbox:list-by-status',
|
||||
(_e, status: NoteStatus, opts: { limit?: number } = {}) => {
|
||||
const VALID: readonly NoteStatus[] = ['active', 'completed', 'archived', 'trashed'];
|
||||
if (!VALID.includes(status)) return [] as Note[];
|
||||
return deps.repo.listByStatus(status, opts);
|
||||
}
|
||||
);
|
||||
|
||||
// v0.2.9 Cut B Task 4 — 4 status counts (헤더 4탭 badge).
|
||||
ipcMain.handle('inbox:counts-by-status', () => ({
|
||||
active: deps.repo.countByStatus('active'),
|
||||
completed: deps.repo.countByStatus('completed'),
|
||||
archived: deps.repo.countByStatus('archived'),
|
||||
trashed: deps.repo.countByStatus('trashed')
|
||||
}));
|
||||
|
||||
// v0.2.9 Cut B Task 8 — status 4분기 직접 전이 (사유 포함).
|
||||
// Modal 의 "완료/보관/휴지통" 버튼 path. backward compat 동기화는
|
||||
// NoteRepository.setStatus 내부에서 처리 (deleted_at sync).
|
||||
ipcMain.handle(
|
||||
'inbox:set-status',
|
||||
async (_e, id: string, status: NoteStatus, reason: string | null) => {
|
||||
const VALID: readonly NoteStatus[] = ['active', 'completed', 'archived', 'trashed'];
|
||||
if (!VALID.includes(status)) {
|
||||
return { ok: false as const, reason: 'invalid status' as const };
|
||||
}
|
||||
deps.repo.setStatus(id, status, reason);
|
||||
return { ok: true as const };
|
||||
}
|
||||
);
|
||||
|
||||
// v0.2.9 Cut B Task 9 — AI 자동 분류 (status 추천).
|
||||
// Ollama provider.generateRaw 호출 + JSON 응답 파싱. 에러/실패 시 archived fallback
|
||||
// (사용자 데이터 보존 우선). 자세한 prompt + parse 로직은 src/main/ai/classifyStatus.ts.
|
||||
ipcMain.handle('ai:classify-status', async (_e, id: string, reason: string) => {
|
||||
const note = deps.repo.findById(id);
|
||||
if (note === null) {
|
||||
return {
|
||||
recommended: 'archived' as const,
|
||||
rationale: '메모를 찾을 수 없음 — 안전하게 보관 추천'
|
||||
};
|
||||
}
|
||||
const provider = deps.providerHolder.get();
|
||||
return classifyStatus({
|
||||
provider,
|
||||
rawText: note.rawText,
|
||||
summary: note.aiSummary ?? '',
|
||||
reason: typeof reason === 'string' ? reason : ''
|
||||
});
|
||||
});
|
||||
|
||||
// v0.2.9 Cut B Task 16 — disabled 메모 (ai_enabled OFF 시기 캡처) 일괄 재투입.
|
||||
// OFF→ON 전환 후 사용자가 "지금 모두 처리" 버튼 클릭 path. repo.requeueDisabled 가
|
||||
// ai_status='pending' + pending_jobs row 보장, worker.enqueue 가 in-memory queue 갱신.
|
||||
ipcMain.handle('inbox:enqueue-disabled', async () => {
|
||||
// requeue 전 대상 id 수집 — UPDATE 가 status 바꾸므로 select 후 update 필요 없이
|
||||
// requeueDisabled 가 처리한 다음 pending_jobs 에서 다시 가져와 enqueue.
|
||||
const targets = deps.repo.getAllPendingJobs().map((j) => j.noteId);
|
||||
const before = new Set(targets);
|
||||
const count = deps.repo.requeueDisabled();
|
||||
if (count > 0 && deps.enqueue) {
|
||||
const after = deps.repo.getAllPendingJobs();
|
||||
// requeue 직후 새로 들어온 pending_jobs row 만 enqueue (기존 row 는 이미 in-memory queue 에).
|
||||
for (const j of after) {
|
||||
if (!before.has(j.noteId)) {
|
||||
await deps.enqueue(j.noteId);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { count };
|
||||
});
|
||||
|
||||
ipcMain.handle('inbox:get-disabled-count', () => deps.repo.countByAiStatus('disabled'));
|
||||
|
||||
ipcMain.handle('inbox:saveOllamaSettings', async (_e, value: { endpoint: string; model: string }) => {
|
||||
// 검증: 새 인스턴스로 healthCheck
|
||||
const trial = new LocalOllamaProvider({ endpoint: value.endpoint, model: value.model });
|
||||
@@ -169,6 +269,49 @@ export function registerInboxApi(deps: InboxIpcDeps): void {
|
||||
await deps.health.runOnce();
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
// v0.2.10 Cut C — raw_text 가변 + revision 보존.
|
||||
// updateRawText: 빈 문자열 reject (trim 후 length===0). 그 외엔 그대로 (newline/space 보존).
|
||||
// listRevisions: 그대로 반환 (camelCase 이미 hydrate 됨).
|
||||
// restoreRevision: repo throw → { ok: false } (UI 가 에러 표시).
|
||||
ipcMain.handle('inbox:update-raw-text', async (_e, id: string, newText: string) => {
|
||||
if (typeof newText !== 'string' || newText.trim().length === 0) {
|
||||
return { ok: false as const, reason: 'empty' as const };
|
||||
}
|
||||
deps.repo.updateRawText(id, newText);
|
||||
return { ok: true as const };
|
||||
});
|
||||
|
||||
ipcMain.handle('inbox:list-revisions', (_e, id: string) => deps.repo.listRevisions(id));
|
||||
|
||||
ipcMain.handle('inbox:restore-revision', async (_e, id: string, revId: number) => {
|
||||
try {
|
||||
deps.repo.restoreRevision(id, revId);
|
||||
return { ok: true as const };
|
||||
} catch (e) {
|
||||
return { ok: false as const, reason: (e as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
// v0.2.11 Cut D — FTS5 검색 + 회고 aggregate.
|
||||
ipcMain.handle(
|
||||
'inbox:search',
|
||||
(_e, query: string, opts: { limit?: number; status?: NoteStatus } = {}) =>
|
||||
deps.repo.search(query, opts)
|
||||
);
|
||||
|
||||
ipcMain.handle('inbox:review-aggregate', (_e, period: 'daily' | 'weekly' | 'monthly') => {
|
||||
const VALID = ['daily', 'weekly', 'monthly'] as const;
|
||||
if (!(VALID as readonly string[]).includes(period)) {
|
||||
return {
|
||||
totalCount: 0,
|
||||
recentNotes: [],
|
||||
tagCounts: [],
|
||||
dueProgress: { total: 0, passed: 0, pending: 0 }
|
||||
};
|
||||
}
|
||||
return deps.repo.reviewAggregate(period);
|
||||
});
|
||||
}
|
||||
|
||||
export function pushNoteUpdated(getWin: () => BrowserWindow | null, note: Note): void {
|
||||
|
||||
@@ -7,7 +7,10 @@ import type { BackupService } from '../services/BackupService.js';
|
||||
import type { ExportService } from '../services/ExportService.js';
|
||||
import type { ImportService } from '../services/ImportService.js';
|
||||
import type { SyncService } from '../services/SyncService.js';
|
||||
import { GitClient } from '../services/GitClient.js';
|
||||
import type { TelemetryService } from '../services/TelemetryService.js';
|
||||
import type { SettingsService } from '../services/SettingsService.js';
|
||||
import type { SyncTimer } from '../services/SyncTimer.js';
|
||||
import { collectAutostartState } from '../services/AutostartDiagnostic.js';
|
||||
import { getInboxWindow as getInboxWindowSingleton } from '../windows/inboxWindow.js';
|
||||
|
||||
@@ -33,7 +36,9 @@ export interface SettingsIpcDeps {
|
||||
importSvc: ImportService;
|
||||
syncSvc: SyncService;
|
||||
telemetry: TelemetryService;
|
||||
settings: SettingsService;
|
||||
getInboxWindow: () => BrowserWindow | null;
|
||||
syncTimer?: SyncTimer;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,7 +93,37 @@ export function registerSettingsApi(deps?: SettingsIpcDeps): void {
|
||||
});
|
||||
|
||||
if (!deps) return;
|
||||
const { backup, exportSvc, importSvc, syncSvc, telemetry, getInboxWindow } = deps;
|
||||
const { backup, exportSvc, importSvc, syncSvc, telemetry, settings, getInboxWindow } = deps;
|
||||
|
||||
// v0.2.9 Cut B Task 12 — settings read + AI/onboarding 토글.
|
||||
// 첫 launch 시 OnboardingWizard 분기 (App.tsx) 와 SettingsPage 의 ai_enabled 토글 통합.
|
||||
ipcMain.handle('settings:get', async () => settings.getAll());
|
||||
|
||||
ipcMain.handle('settings:set-ai-enabled', async (_e, enabled: boolean) => {
|
||||
await settings.setAiEnabled(enabled);
|
||||
return { ok: true as const };
|
||||
});
|
||||
|
||||
ipcMain.handle('settings:set-onboarding-completed', async (_e, completed: boolean) => {
|
||||
await settings.setOnboardingCompleted(completed);
|
||||
return { ok: true as const };
|
||||
});
|
||||
|
||||
ipcMain.handle('settings:set-sync-auto-enabled', async (_e, value: boolean) => {
|
||||
await deps.settings.setAutoSyncEnabled(value);
|
||||
await deps.syncTimer?.reconfigure();
|
||||
return { ok: true as const };
|
||||
});
|
||||
|
||||
ipcMain.handle('settings:set-sync-interval-min', async (_e, value: number) => {
|
||||
try {
|
||||
await deps.settings.setSyncIntervalMin(value);
|
||||
await deps.syncTimer?.reconfigure();
|
||||
return { ok: true as const };
|
||||
} catch (e) {
|
||||
return { ok: false as const, reason: (e as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('settings:run-backup', async () => {
|
||||
try {
|
||||
@@ -223,7 +258,7 @@ export function registerSettingsApi(deps?: SettingsIpcDeps): void {
|
||||
return { ok: true } as const;
|
||||
}
|
||||
if (r.changed) {
|
||||
logger.info('sync.done', { sha: r.sha, pushed: r.pushed });
|
||||
logger.info('sync.done', { sha: r.localSha, pushed: r.pushed });
|
||||
new Notification({ title: 'Inkling', body: '동기화 완료', silent: true }).show();
|
||||
} else {
|
||||
new Notification({ title: 'Inkling', body: '변경 사항 없음', silent: true }).show();
|
||||
@@ -265,4 +300,82 @@ export function registerSettingsApi(deps?: SettingsIpcDeps): void {
|
||||
}
|
||||
return { ok: true } as const;
|
||||
});
|
||||
|
||||
// v0.3.0 Cut E — sync IPC.
|
||||
|
||||
// settings:configure-sync — URL 저장 + git init + remote add (없으면).
|
||||
// null URL → 저장만 (init 안 함). 빈 문자열도 null 처리.
|
||||
ipcMain.handle('settings:configure-sync', async (_e, url: string | null) => {
|
||||
const trimmed = typeof url === 'string' ? url.trim() : '';
|
||||
const finalUrl = trimmed.length === 0 ? null : trimmed;
|
||||
|
||||
try {
|
||||
await deps.settings.setSyncRepoUrl(finalUrl);
|
||||
} catch (e) {
|
||||
return { ok: false as const, reason: `persist failed: ${(e as Error).message}` };
|
||||
}
|
||||
|
||||
if (finalUrl === null) {
|
||||
await deps.syncTimer?.reconfigure();
|
||||
return { ok: true as const };
|
||||
}
|
||||
|
||||
// git init + remote add origin
|
||||
const syncDir = deps.syncSvc.getSyncDir();
|
||||
const git = new GitClient(syncDir);
|
||||
|
||||
if (!(await git.isRepo())) {
|
||||
const init = await git.run(['init']);
|
||||
if (init.exitCode !== 0) {
|
||||
return { ok: false as const, reason: `git init failed: ${init.stderr}` };
|
||||
}
|
||||
}
|
||||
if (!(await git.hasRemote())) {
|
||||
const add = await git.run(['remote', 'add', 'origin', finalUrl]);
|
||||
if (add.exitCode !== 0) {
|
||||
return { ok: false as const, reason: `remote add failed: ${add.stderr}` };
|
||||
}
|
||||
} else {
|
||||
// remote exists — update URL
|
||||
const set = await git.run(['remote', 'set-url', 'origin', finalUrl]);
|
||||
if (set.exitCode !== 0) {
|
||||
return { ok: false as const, reason: `remote set-url failed: ${set.stderr}` };
|
||||
}
|
||||
}
|
||||
await deps.syncTimer?.reconfigure();
|
||||
return { ok: true as const };
|
||||
});
|
||||
|
||||
// settings:test-sync-connection — git ls-remote 결과
|
||||
ipcMain.handle('settings:test-sync-connection', async () => {
|
||||
const syncDir = deps.syncSvc.getSyncDir();
|
||||
const git = new GitClient(syncDir);
|
||||
if (!(await git.isRepo())) return { ok: false as const, reason: 'not_initialized' };
|
||||
const r = await git.run(['ls-remote', 'origin']);
|
||||
if (r.exitCode !== 0) return { ok: false as const, reason: r.stderr || 'connection failed' };
|
||||
return { ok: true as const };
|
||||
});
|
||||
|
||||
// sync:list-conflicts — SyncService 캐시 결과
|
||||
ipcMain.handle('sync:list-conflicts', () => deps.syncSvc.listConflicts());
|
||||
|
||||
// sync:resolve-conflict — local/remote 2 choice. path = git index conflict 경로.
|
||||
ipcMain.handle('sync:resolve-conflict', async (_e, path: string, choice: 'local' | 'remote') => {
|
||||
if (choice !== 'local' && choice !== 'remote') {
|
||||
return { ok: false as const, reason: 'invalid choice' };
|
||||
}
|
||||
return deps.syncSvc.resolveConflict(path, choice);
|
||||
});
|
||||
|
||||
// sync:get-status — lastAt + lastResult + nextAt 계산
|
||||
ipcMain.handle('sync:get-status', async () => {
|
||||
const last = deps.syncSvc.getLastStatus();
|
||||
let nextAt: string | null = null;
|
||||
if (await deps.settings.isAutoSyncEnabled()) {
|
||||
const intervalMin = await deps.settings.getSyncIntervalMin();
|
||||
const baseMs = last.lastAt ? new Date(last.lastAt).getTime() : Date.now();
|
||||
nextAt = new Date(baseMs + intervalMin * 60 * 1000).toISOString();
|
||||
}
|
||||
return { lastAt: last.lastAt, lastResult: last.lastResult, nextAt };
|
||||
});
|
||||
}
|
||||
|
||||
54
src/main/protocol/inklingMedia.ts
Normal file
54
src/main/protocol/inklingMedia.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import electron from 'electron';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join, normalize, sep, extname } from 'node:path';
|
||||
|
||||
const { protocol } = electron;
|
||||
|
||||
export function registerSchemesAsPrivileged(): void {
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{ scheme: 'inkling-media', privileges: { secure: true, supportFetchAPI: true, stream: true } }
|
||||
]);
|
||||
}
|
||||
|
||||
export function inferMime(filename: string): string {
|
||||
const ext = extname(filename).toLowerCase();
|
||||
switch (ext) {
|
||||
case '.png': return 'image/png';
|
||||
case '.jpg':
|
||||
case '.jpeg': return 'image/jpeg';
|
||||
case '.gif': return 'image/gif';
|
||||
case '.webp': return 'image/webp';
|
||||
default: return 'application/octet-stream';
|
||||
}
|
||||
}
|
||||
|
||||
export function registerInklingMediaProtocol(profileDir: string): void {
|
||||
const mediaRoot = join(profileDir, 'media');
|
||||
protocol.handle('inkling-media', async (req) => {
|
||||
// Raw URL 에서 `..` 세그먼트 (URL-encoded 포함) 검출 — `new URL()` 이 normalize 하기 전에 차단.
|
||||
const rawLower = req.url.toLowerCase();
|
||||
if (
|
||||
rawLower.includes('/../') ||
|
||||
rawLower.endsWith('/..') ||
|
||||
rawLower.includes('/%2e%2e/') ||
|
||||
rawLower.endsWith('/%2e%2e')
|
||||
) {
|
||||
return new Response(null, { status: 403 });
|
||||
}
|
||||
const url = new URL(req.url);
|
||||
// inkling-media://media/<noteId>/<file> → host='media', pathname='/<noteId>/<file>'
|
||||
const relPath = decodeURIComponent(`${url.host}${url.pathname}`);
|
||||
const target = normalize(join(profileDir, relPath));
|
||||
if (!target.startsWith(mediaRoot + sep) && target !== mediaRoot) {
|
||||
return new Response(null, { status: 403 });
|
||||
}
|
||||
try {
|
||||
const data = await readFile(target);
|
||||
return new Response(new Uint8Array(data), {
|
||||
headers: { 'content-type': inferMime(target) }
|
||||
});
|
||||
} catch {
|
||||
return new Response(null, { status: 404 });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,9 +1,17 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
import { v7 as uuidv7, v4 as uuidv4 } from 'uuid';
|
||||
import type { Note, NoteMedia, NoteTag } from '@shared/types';
|
||||
import type { AiStatus, Note, NoteMedia, NoteRevision, NoteStatus, NoteTag } from '@shared/types';
|
||||
import { kstTodayIso } from '../../shared/util/kstDate.js';
|
||||
import { sanitizeFtsQuery, computeCutoff, type ReviewPeriod } from './ftsHelpers.js';
|
||||
|
||||
export interface CreateNoteInput { rawText: string; }
|
||||
export interface CreateNoteInput {
|
||||
rawText: string;
|
||||
/**
|
||||
* v0.2.9 Cut B — settings.ai_enabled=false 일 때 'disabled' 로 insert + pending_jobs skip.
|
||||
* 미지정 시 기존 'pending' default + pending_jobs enqueue (backward compat).
|
||||
*/
|
||||
aiStatus?: AiStatus;
|
||||
}
|
||||
|
||||
export interface NewMediaRow {
|
||||
noteId: string;
|
||||
@@ -15,7 +23,10 @@ export interface NewMediaRow {
|
||||
|
||||
export interface ImportNoteInput {
|
||||
/** Proposed id from the export file. May be replaced if it collides with
|
||||
* an existing row whose `raw_text` differs (raw_text invariant guard). */
|
||||
* an existing row whose `raw_text` differs — fork-on-conflict so a single
|
||||
* id never resolves to two distinct historical baselines (v0.2.10 Cut C
|
||||
* changed `raw_text 불변` policy → `raw_text 가변` + revision history; the
|
||||
* baseline distinction is now preserved per-id, edit history per-note). */
|
||||
id: string;
|
||||
rawText: string;
|
||||
createdAt: string;
|
||||
@@ -40,6 +51,29 @@ export interface ImportNoteResult {
|
||||
status: ImportNoteStatus;
|
||||
}
|
||||
|
||||
export interface UpsertFromSyncInput {
|
||||
id: string;
|
||||
rawText: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
aiTitle: string | null;
|
||||
aiSummary: string | null;
|
||||
titleEditedByUser: boolean;
|
||||
summaryEditedByUser: boolean;
|
||||
aiProvider: string | null;
|
||||
aiGeneratedAt: string | null;
|
||||
userIntent: string | null;
|
||||
intentPromptedAt: string | null;
|
||||
tags: { name: string; source: 'ai' | 'user' }[];
|
||||
status: NoteStatus;
|
||||
statusChangedAt: string | null;
|
||||
moveReason: string | null;
|
||||
dueDate: string | null;
|
||||
dueDateEditedByUser: boolean;
|
||||
}
|
||||
|
||||
export type UpsertFromSyncStatus = 'inserted' | 'updated' | 'skipped';
|
||||
|
||||
const KEBAB_CASE_RE = /^[a-z0-9-]+$/;
|
||||
|
||||
export class NoteRepository {
|
||||
@@ -48,15 +82,23 @@ export class NoteRepository {
|
||||
create(input: CreateNoteInput): { id: string } {
|
||||
const id = uuidv7();
|
||||
const now = new Date().toISOString();
|
||||
const aiStatus: AiStatus = input.aiStatus ?? 'pending';
|
||||
const tx = this.db.transaction(() => {
|
||||
this.db
|
||||
.prepare(`INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at)
|
||||
VALUES (?, ?, 'pending', ?, ?)`)
|
||||
.run(id, input.rawText, now, now);
|
||||
VALUES (?, ?, ?, ?, ?)`)
|
||||
.run(id, input.rawText, aiStatus, now, now);
|
||||
this.db
|
||||
.prepare(`INSERT INTO pending_jobs (note_id, attempts, next_run_at)
|
||||
VALUES (?, 0, ?)`)
|
||||
.run(id, now);
|
||||
.prepare(`INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by)
|
||||
VALUES (?, ?, ?, 'capture')`)
|
||||
.run(id, input.rawText, now);
|
||||
// pending_jobs 는 'pending' 일 때만 생성 — 'disabled' 노트는 worker 가 처리 안 함.
|
||||
if (aiStatus === 'pending') {
|
||||
this.db
|
||||
.prepare(`INSERT INTO pending_jobs (note_id, attempts, next_run_at)
|
||||
VALUES (?, 0, ?)`)
|
||||
.run(id, now);
|
||||
}
|
||||
});
|
||||
tx();
|
||||
return { id };
|
||||
@@ -143,6 +185,7 @@ export class NoteRepository {
|
||||
linkTag.run(id, tagRow.id);
|
||||
}
|
||||
this.db.prepare(`DELETE FROM pending_jobs WHERE note_id=?`).run(id);
|
||||
this.rebuildFtsTagsForNote(id);
|
||||
});
|
||||
tx();
|
||||
}
|
||||
@@ -205,6 +248,50 @@ export class NoteRepository {
|
||||
return { ids };
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.9 Cut B Task 16 — 모든 ai_status='disabled' 노트를 'pending' 으로 reset 하고
|
||||
* pending_jobs 재투입. 사용자가 settings.ai_enabled OFF→ON 전환 후 "지금 모두 처리"
|
||||
* 버튼을 누른 path. 단일 transaction. 호출자가 `now` 주입 가능 (테스트성).
|
||||
*
|
||||
* INSERT OR IGNORE — race 안전 (이미 pending_jobs row 존재 시 skip).
|
||||
* 반환값 = 처리된 노트 수 (UI 가 "N건 처리됨" 토스트 등 표시용).
|
||||
*/
|
||||
requeueDisabled(now: Date = new Date()): number {
|
||||
const tx = this.db.transaction(() => {
|
||||
const ts = now.toISOString();
|
||||
const targets = this.db
|
||||
.prepare(`SELECT id FROM notes WHERE ai_status='disabled'`)
|
||||
.all() as Array<{ id: string }>;
|
||||
for (const { id } of targets) {
|
||||
this.db
|
||||
.prepare(`UPDATE notes SET ai_status='pending', updated_at=? WHERE id=?`)
|
||||
.run(ts, id);
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)`
|
||||
)
|
||||
.run(id, ts);
|
||||
}
|
||||
return targets.length;
|
||||
});
|
||||
return tx();
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.9 Cut B Task 16 — ai_status 별 row count.
|
||||
* 설정 페이지의 "원문만 저장된 메모 N건" 표기용 (status='disabled' 카운트).
|
||||
* deleted_at 필터 없음 — disabled 메모도 trash 갈 수 있는데 사용자 의도는
|
||||
* "AI 처리할 게 얼마나 남았나?" 라 trashed 까지 포함되면 안 됨. → deleted_at IS NULL 추가.
|
||||
*/
|
||||
countByAiStatus(status: AiStatus): number {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS c FROM notes WHERE ai_status=? AND deleted_at IS NULL`
|
||||
)
|
||||
.get(status) as { c: number };
|
||||
return row.c;
|
||||
}
|
||||
|
||||
/**
|
||||
* pending_jobs 의 next_run_at + last_error 만 갱신, attempts 변경 없음.
|
||||
* v0.2.3 #2 — unreachable/timeout 무한 retry 시 사용 (incrementJobAttempt 와 별도 경로).
|
||||
@@ -328,6 +415,7 @@ export class NoteRepository {
|
||||
const row = getOrInsert.get(t) as { id: number };
|
||||
link.run(id, row.id);
|
||||
}
|
||||
this.rebuildFtsTagsForNote(id);
|
||||
}
|
||||
});
|
||||
tx();
|
||||
@@ -410,25 +498,245 @@ export class NoteRepository {
|
||||
.run(now, id);
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.10 Cut C — 사용자가 raw_text 정정. notes.raw_text 갱신 + note_revisions 에
|
||||
* edited_by='user' 새 row INSERT. 단일 transaction. 호출자 `now` 주입 가능 (테스트성).
|
||||
*
|
||||
* 옛 raw_text 는 backfill (m006) 으로 capture revision 에 이미 보존됨.
|
||||
*/
|
||||
updateRawText(id: string, newText: string, now: Date = new Date()): void {
|
||||
const ts = now.toISOString();
|
||||
const tx = this.db.transaction(() => {
|
||||
this.db
|
||||
.prepare(`UPDATE notes SET raw_text=?, updated_at=? WHERE id=?`)
|
||||
.run(newText, ts, id);
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by)
|
||||
VALUES (?, ?, ?, 'user')`
|
||||
)
|
||||
.run(id, newText, ts);
|
||||
});
|
||||
tx();
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.10 Cut C — 노트의 모든 revision (capture + user) 을 최신순 반환.
|
||||
* NoteCard 의 "이력" modal 에서 사용. edited_at DESC + rev_id DESC tiebreak.
|
||||
*/
|
||||
listRevisions(id: string): NoteRevision[] {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT rev_id, note_id, raw_text, edited_at, edited_by
|
||||
FROM note_revisions
|
||||
WHERE note_id = ?
|
||||
ORDER BY edited_at DESC, rev_id DESC`
|
||||
)
|
||||
.all(id) as Array<{
|
||||
rev_id: number;
|
||||
note_id: string;
|
||||
raw_text: string;
|
||||
edited_at: string;
|
||||
edited_by: 'user' | 'capture';
|
||||
}>;
|
||||
return rows.map((r) => ({
|
||||
revId: r.rev_id,
|
||||
noteId: r.note_id,
|
||||
rawText: r.raw_text,
|
||||
editedAt: r.edited_at,
|
||||
editedBy: r.edited_by
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.10 Cut C — 옛 revision 의 raw_text 를 latest 로 복원. chain 끊지 않고
|
||||
* 새 user revision 으로 INSERT (linear history 유지). revId 가 해당 note 의 것이
|
||||
* 아니면 throw — restore 대상 잘못 매칭 방지.
|
||||
*/
|
||||
restoreRevision(id: string, revId: number, now: Date = new Date()): void {
|
||||
const rev = this.db
|
||||
.prepare(`SELECT raw_text FROM note_revisions WHERE rev_id=? AND note_id=?`)
|
||||
.get(revId, id) as { raw_text: string } | undefined;
|
||||
if (!rev) throw new Error(`revision ${revId} not found for note ${id}`);
|
||||
this.updateRawText(id, rev.raw_text, now);
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.9 Cut B — 노트 status 4분기 전이 (active/completed/archived/trashed).
|
||||
* status + status_changed_at + move_reason + updated_at 갱신 + deleted_at
|
||||
* backward-compat 동기화 (status='trashed' → deleted_at=ts, 그 외 → NULL).
|
||||
*
|
||||
* 단일 transaction. 호출자가 `now` 주입 가능 (테스트성).
|
||||
*/
|
||||
setStatus(
|
||||
id: string,
|
||||
status: NoteStatus,
|
||||
reason: string | null,
|
||||
now: Date = new Date()
|
||||
): void {
|
||||
const tx = this.db.transaction(() => {
|
||||
const ts = now.toISOString();
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE notes
|
||||
SET status = ?,
|
||||
move_reason = ?,
|
||||
status_changed_at = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run(status, reason, ts, ts, id);
|
||||
// backward compat: deleted_at 컬럼은 m004 이후로도 status='trashed' 와 동기화.
|
||||
if (status === 'trashed') {
|
||||
this.db.prepare(`UPDATE notes SET deleted_at = ? WHERE id = ?`).run(ts, id);
|
||||
} else {
|
||||
this.db.prepare(`UPDATE notes SET deleted_at = NULL WHERE id = ?`).run(id);
|
||||
}
|
||||
});
|
||||
tx();
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.9 Cut B Task 4 — status 별 row count. 4탭 헤더 badge 용.
|
||||
* tags/media hydrate 없음 (cheap path, listByStatus 와 별도).
|
||||
*/
|
||||
countByStatus(status: NoteStatus): number {
|
||||
const row = this.db
|
||||
.prepare(`SELECT COUNT(*) AS c FROM notes WHERE status = ?`)
|
||||
.get(status) as { c: number };
|
||||
return row.c;
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.9 Cut B — status 별 노트 목록. status_changed_at DESC (최근 전이 우선),
|
||||
* NULL 은 created_at fallback. limit cap 200 (list/listTrashed 와 동일).
|
||||
*/
|
||||
listByStatus(status: NoteStatus, opts: { limit?: number } = {}): Note[] {
|
||||
const limit = Math.max(1, Math.min(200, opts.limit ?? 200));
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM notes
|
||||
WHERE status = ?
|
||||
ORDER BY COALESCE(status_changed_at, created_at) DESC, id DESC
|
||||
LIMIT ?`
|
||||
)
|
||||
.all(status, limit) as Record<string, unknown>[];
|
||||
return rows.map((r) => this.hydrate(r));
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.11 Cut D — FTS5 검색. notes_fts MATCH + rank 정렬 + 기본 trashed 제외.
|
||||
* 빈/공백 query → []. multi-token 은 implicit AND. FTS5 special chars 는 sanitize.
|
||||
*/
|
||||
search(query: string, opts: { limit?: number; status?: NoteStatus } = {}): Note[] {
|
||||
const sanitized = sanitizeFtsQuery(query);
|
||||
if (sanitized.length === 0) return [];
|
||||
const limit = Math.max(1, Math.min(200, opts.limit ?? 50));
|
||||
const statusClause = opts.status ? `AND n.status = ?` : `AND n.status != 'trashed'`;
|
||||
const sql = `
|
||||
SELECT n.* FROM notes n
|
||||
JOIN notes_fts f ON n.id = f.note_id
|
||||
WHERE notes_fts MATCH ? ${statusClause}
|
||||
ORDER BY rank
|
||||
LIMIT ?
|
||||
`;
|
||||
const args: unknown[] = opts.status ? [sanitized, opts.status, limit] : [sanitized, limit];
|
||||
const rows = this.db.prepare(sql).all(...args) as Record<string, unknown>[];
|
||||
return rows.map((r) => this.hydrate(r));
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.11 Cut D — 회고 view aggregate. period 별 KST 자정 cutoff 이후 노트
|
||||
* (status != 'trashed') 의 totalCount / recentNotes(50) / tagCounts(DESC) /
|
||||
* dueProgress(passed/pending KST today 기준).
|
||||
*/
|
||||
reviewAggregate(period: ReviewPeriod, now: Date = new Date()): {
|
||||
totalCount: number;
|
||||
recentNotes: Note[];
|
||||
tagCounts: Array<{ tag: string; count: number }>;
|
||||
dueProgress: { total: number; passed: number; pending: number };
|
||||
} {
|
||||
const cutoff = computeCutoff(period, now);
|
||||
const todayIso = kstTodayIso(now);
|
||||
|
||||
const totalCount = (this.db
|
||||
.prepare(`SELECT COUNT(*) AS c FROM notes WHERE created_at >= ? AND status != 'trashed'`)
|
||||
.get(cutoff) as { c: number }).c;
|
||||
|
||||
const recentRows = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM notes
|
||||
WHERE created_at >= ? AND status != 'trashed'
|
||||
ORDER BY created_at DESC, id DESC LIMIT 50`
|
||||
)
|
||||
.all(cutoff) as Record<string, unknown>[];
|
||||
const recentNotes = recentRows.map((r) => this.hydrate(r));
|
||||
|
||||
const tagCounts = this.db
|
||||
.prepare(
|
||||
`SELECT t.name AS tag, COUNT(*) AS count
|
||||
FROM note_tags nt
|
||||
JOIN notes n ON n.id = nt.note_id
|
||||
JOIN tags t ON t.id = nt.tag_id
|
||||
WHERE n.created_at >= ? AND n.status != 'trashed'
|
||||
GROUP BY t.id
|
||||
ORDER BY count DESC, t.name ASC`
|
||||
)
|
||||
.all(cutoff) as Array<{ tag: string; count: number }>;
|
||||
|
||||
const dueRow = this.db
|
||||
.prepare(
|
||||
`SELECT
|
||||
COUNT(*) AS total,
|
||||
SUM(CASE WHEN due_date < ? THEN 1 ELSE 0 END) AS passed,
|
||||
SUM(CASE WHEN due_date >= ? THEN 1 ELSE 0 END) AS pending
|
||||
FROM notes
|
||||
WHERE created_at >= ?
|
||||
AND status != 'trashed'
|
||||
AND due_date IS NOT NULL`
|
||||
)
|
||||
.get(todayIso, todayIso, cutoff) as { total: number; passed: number | null; pending: number | null };
|
||||
const dueProgress = {
|
||||
total: dueRow.total,
|
||||
passed: dueRow.passed ?? 0,
|
||||
pending: dueRow.pending ?? 0
|
||||
};
|
||||
|
||||
return { totalCount, recentNotes, tagCounts, dueProgress };
|
||||
}
|
||||
|
||||
/**
|
||||
* 휴지통에서 active 로 복원. setStatus('active') 로 status + deleted_at 동기화 +
|
||||
* v0.2.6 #10 round 1 fix 보존 (ai_status='failed' / 'pending' 시 pending_jobs 재투입).
|
||||
*/
|
||||
restoreNote(id: string): void {
|
||||
const tx = this.db.transaction(() => {
|
||||
const before = this.db.prepare(`SELECT ai_status FROM notes WHERE id = ?`).get(id) as { ai_status: string } | undefined;
|
||||
const before = this.db
|
||||
.prepare(`SELECT ai_status FROM notes WHERE id = ?`)
|
||||
.get(id) as { ai_status: string } | undefined;
|
||||
// setStatus('active', null) — reason clear + deleted_at NULL + updated_at 갱신.
|
||||
this.setStatus(id, 'active', null);
|
||||
const now = new Date().toISOString();
|
||||
this.db.prepare(`UPDATE notes SET deleted_at = NULL, updated_at = ? WHERE id = ?`).run(now, id);
|
||||
|
||||
// v0.2.6 #10 — failed 노트 restore 시 pending 으로 reset + pending_jobs 재생성
|
||||
if (before?.ai_status === 'failed') {
|
||||
this.db.prepare(
|
||||
`UPDATE notes SET ai_status='pending', ai_error=NULL, updated_at=? WHERE id=?`
|
||||
).run(now, id);
|
||||
this.db.prepare(
|
||||
`INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)`
|
||||
).run(id, now);
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE notes SET ai_status='pending', ai_error=NULL, updated_at=? WHERE id=?`
|
||||
)
|
||||
.run(now, id);
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)`
|
||||
)
|
||||
.run(id, now);
|
||||
} else if (before?.ai_status === 'pending') {
|
||||
// pending 인 채로 trash 됐다면 pending_jobs 도 미정상 상태일 수 있음 — 재생성 (idempotent)
|
||||
this.db.prepare(
|
||||
`INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)`
|
||||
).run(id, now);
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)`
|
||||
)
|
||||
.run(id, now);
|
||||
}
|
||||
// done 노트는 재처리 안 함 (이미 결과 있음)
|
||||
});
|
||||
@@ -483,11 +791,22 @@ export class NoteRepository {
|
||||
|
||||
/**
|
||||
* Import a note from an external source (F5 export tree).
|
||||
* Conflict policy:
|
||||
* Conflict policy (fork-on-id-collision):
|
||||
* - id missing in DB → INSERT (status: 'inserted')
|
||||
* - id present + raw_text identical → no-op (status: 'skipped')
|
||||
* - id present + raw_text differs → INSERT under fresh uuidv7
|
||||
* to preserve the raw_text-immutable invariant (status: 'forked')
|
||||
* - id present + raw_text differs → INSERT under fresh uuidv7 so the same id
|
||||
* never points at two different baselines (status: 'forked'). v0.2.10 Cut C
|
||||
* relaxed the `raw_text 불변` policy → `raw_text 가변 + note_revisions 보존`,
|
||||
* but per-id baseline distinction is still required for sync determinism.
|
||||
*
|
||||
* v0.2.10 Cut C — INSERT/fork 시 동일 transaction 안에서 note_revisions 에
|
||||
* 'capture' 첫 revision INSERT (createdAt = edited_at). 미수행 시 first user
|
||||
* edit 직후 import 시점 본문이 history 에서 사라지는 회귀 (final review 발견).
|
||||
*
|
||||
* v0.2.11 Cut D — INSERT/fork 시 tags 추가 후 rebuildFtsTagsForNote(finalId)
|
||||
* 호출 — m007 trigger 가 빈 tags='' 로 FTS row 만들고, note_tags INSERT 만으로는
|
||||
* notes_fts.tags 갱신 안 됨. 미수행 시 import 한 노트가 tag keyword 검색에서
|
||||
* 매칭 안 되는 회귀 (final review 발견).
|
||||
*
|
||||
* deletedAt merge (v0.2.3 #4, spec §8.2): source/dest 중 IS NOT NULL 우선
|
||||
* (삭제 보존). skip 케이스에서 source NN + dest NULL 일 때만 dest 갱신.
|
||||
@@ -538,6 +857,12 @@ export class NoteRepository {
|
||||
input.createdAt,
|
||||
input.updatedAt
|
||||
);
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by)
|
||||
VALUES (?, ?, ?, 'capture')`
|
||||
)
|
||||
.run(finalId, input.rawText, input.createdAt);
|
||||
if (input.tags.length > 0) {
|
||||
const getOrInsertTag = this.db.prepare(
|
||||
`INSERT INTO tags(name) VALUES(?) ON CONFLICT(name) DO UPDATE SET name=name RETURNING id`
|
||||
@@ -553,12 +878,151 @@ export class NoteRepository {
|
||||
if (t.source === 'ai') linkAi.run(finalId, row.id);
|
||||
else linkUser.run(finalId, row.id);
|
||||
}
|
||||
// v0.2.11 Cut D — note_tags 변경 후 notes_fts.tags 동기화 (single write path).
|
||||
this.rebuildFtsTagsForNote(finalId);
|
||||
}
|
||||
});
|
||||
tx();
|
||||
return { id: finalId, status };
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.3.0 Cut E — sync 전용 upsert. 기존 importNote 의 fork-on-id-collision 정책은
|
||||
* sync 에 부적합 (양 기기 raw_text 가 다를 때마다 fork → 노트 갯수 무한 증가).
|
||||
*
|
||||
* 3 분기:
|
||||
* - id 없음 → INSERT (capture revision + tags FTS sync)
|
||||
* - id 있음 + raw_text 동일 → source.updatedAt 가 더 최신일 때만 metadata 갱신
|
||||
* - id 있음 + raw_text 다름 → source 가 더 최신이면 updateRawText (new user revision),
|
||||
* local 이 더 최신이면 skip
|
||||
*
|
||||
* tags 변경 시 rebuildFtsTagsForNote 호출 — Cut D single write path 재사용.
|
||||
* raw_text 변경 시 updateRawText 호출 — Cut C single write path 재사용.
|
||||
*/
|
||||
upsertFromSync(input: UpsertFromSyncInput): { id: string; status: UpsertFromSyncStatus } {
|
||||
const existing = this.db
|
||||
.prepare(`SELECT raw_text, updated_at, status FROM notes WHERE id=?`)
|
||||
.get(input.id) as { raw_text: string; updated_at: string; status: NoteStatus } | undefined;
|
||||
|
||||
if (!existing) {
|
||||
// INSERT path
|
||||
const tx = this.db.transaction(() => {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO notes
|
||||
(id, raw_text, ai_title, ai_summary, ai_status, ai_provider, ai_generated_at,
|
||||
title_edited_by_user, summary_edited_by_user,
|
||||
user_intent, intent_prompted_at,
|
||||
created_at, updated_at,
|
||||
due_date, due_date_edited_by_user,
|
||||
status, status_changed_at, move_reason)
|
||||
VALUES (?, ?, ?, ?, 'done', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
input.id,
|
||||
input.rawText,
|
||||
input.aiTitle,
|
||||
input.aiSummary,
|
||||
input.aiProvider,
|
||||
input.aiGeneratedAt,
|
||||
input.titleEditedByUser ? 1 : 0,
|
||||
input.summaryEditedByUser ? 1 : 0,
|
||||
input.userIntent,
|
||||
input.intentPromptedAt,
|
||||
input.createdAt,
|
||||
input.updatedAt,
|
||||
input.dueDate,
|
||||
input.dueDateEditedByUser ? 1 : 0,
|
||||
input.status,
|
||||
input.statusChangedAt,
|
||||
input.moveReason
|
||||
);
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by)
|
||||
VALUES (?, ?, ?, 'capture')`
|
||||
)
|
||||
.run(input.id, input.rawText, input.createdAt);
|
||||
if (input.tags.length > 0) {
|
||||
const getOrInsertTag = this.db.prepare(
|
||||
`INSERT INTO tags(name) VALUES(?) ON CONFLICT(name) DO UPDATE SET name=name RETURNING id`
|
||||
);
|
||||
const linkAi = this.db.prepare(
|
||||
`INSERT OR IGNORE INTO note_tags(note_id, tag_id, source) VALUES(?, ?, 'ai')`
|
||||
);
|
||||
const linkUser = this.db.prepare(
|
||||
`INSERT OR IGNORE INTO note_tags(note_id, tag_id, source) VALUES(?, ?, 'user')`
|
||||
);
|
||||
for (const t of input.tags) {
|
||||
const row = getOrInsertTag.get(t.name) as { id: number };
|
||||
if (t.source === 'ai') linkAi.run(input.id, row.id);
|
||||
else linkUser.run(input.id, row.id);
|
||||
}
|
||||
this.rebuildFtsTagsForNote(input.id);
|
||||
}
|
||||
});
|
||||
tx();
|
||||
return { id: input.id, status: 'inserted' };
|
||||
}
|
||||
|
||||
if (input.updatedAt <= existing.updated_at) {
|
||||
return { id: input.id, status: 'skipped' };
|
||||
}
|
||||
|
||||
if (existing.raw_text !== input.rawText) {
|
||||
this.updateRawText(input.id, input.rawText, new Date(input.updatedAt));
|
||||
}
|
||||
|
||||
const tx = this.db.transaction(() => {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE notes
|
||||
SET ai_title = CASE WHEN title_edited_by_user = 1 THEN ai_title ELSE ? END,
|
||||
ai_summary = CASE WHEN summary_edited_by_user = 1 THEN ai_summary ELSE ? END,
|
||||
ai_provider = ?,
|
||||
ai_generated_at = ?,
|
||||
due_date = CASE WHEN due_date_edited_by_user = 1 THEN due_date ELSE ? END,
|
||||
status = ?,
|
||||
status_changed_at = ?,
|
||||
move_reason = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run(
|
||||
input.aiTitle,
|
||||
input.aiSummary,
|
||||
input.aiProvider,
|
||||
input.aiGeneratedAt,
|
||||
input.dueDate,
|
||||
input.status,
|
||||
input.statusChangedAt,
|
||||
input.moveReason,
|
||||
input.updatedAt,
|
||||
input.id
|
||||
);
|
||||
this.db.prepare(`DELETE FROM note_tags WHERE note_id=?`).run(input.id);
|
||||
if (input.tags.length > 0) {
|
||||
const getOrInsertTag = this.db.prepare(
|
||||
`INSERT INTO tags(name) VALUES(?) ON CONFLICT(name) DO UPDATE SET name=name RETURNING id`
|
||||
);
|
||||
const linkAi = this.db.prepare(
|
||||
`INSERT OR IGNORE INTO note_tags(note_id, tag_id, source) VALUES(?, ?, 'ai')`
|
||||
);
|
||||
const linkUser = this.db.prepare(
|
||||
`INSERT OR IGNORE INTO note_tags(note_id, tag_id, source) VALUES(?, ?, 'user')`
|
||||
);
|
||||
for (const t of input.tags) {
|
||||
const row = getOrInsertTag.get(t.name) as { id: number };
|
||||
if (t.source === 'ai') linkAi.run(input.id, row.id);
|
||||
else linkUser.run(input.id, row.id);
|
||||
}
|
||||
}
|
||||
this.rebuildFtsTagsForNote(input.id);
|
||||
});
|
||||
tx();
|
||||
return { id: input.id, status: 'updated' };
|
||||
}
|
||||
|
||||
getPendingCount(): number {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
@@ -638,6 +1102,23 @@ export class NoteRepository {
|
||||
.run(nextRunAt, lastError.slice(0, 500), noteId);
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.11 Cut D — note_tags 변경 후 notes_fts.tags 컬럼 (csv) 재구성.
|
||||
* 단일 write path 패턴: tags 변경하는 모든 메서드가 같은 transaction 끝에서 호출.
|
||||
*/
|
||||
private rebuildFtsTagsForNote(noteId: string): void {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
`SELECT COALESCE(GROUP_CONCAT(t.name, ' '), '') AS csv
|
||||
FROM note_tags nt JOIN tags t ON t.id = nt.tag_id
|
||||
WHERE nt.note_id = ?`
|
||||
)
|
||||
.get(noteId) as { csv: string };
|
||||
this.db
|
||||
.prepare(`UPDATE notes_fts SET tags = ? WHERE note_id = ?`)
|
||||
.run(row.csv, noteId);
|
||||
}
|
||||
|
||||
private hydrate(row: Record<string, unknown>): Note {
|
||||
const tags = this.db
|
||||
.prepare(
|
||||
@@ -656,7 +1137,7 @@ export class NoteRepository {
|
||||
rawText: row.raw_text as string,
|
||||
aiTitle: row.ai_title as string | null,
|
||||
aiSummary: row.ai_summary as string | null,
|
||||
aiStatus: row.ai_status as 'pending' | 'done' | 'failed',
|
||||
aiStatus: row.ai_status as AiStatus,
|
||||
aiError: row.ai_error as string | null,
|
||||
aiProvider: row.ai_provider as string | null,
|
||||
aiGeneratedAt: row.ai_generated_at as string | null,
|
||||
@@ -669,6 +1150,9 @@ export class NoteRepository {
|
||||
deletedAt: (row.deleted_at as string | null) ?? null,
|
||||
lastRecalledAt: (row.last_recalled_at as string | null) ?? null,
|
||||
recallDismissedAt: (row.recall_dismissed_at as string | null) ?? null,
|
||||
status: ((row.status as NoteStatus | undefined) ?? 'active'),
|
||||
statusChangedAt: (row.status_changed_at as string | null) ?? null,
|
||||
moveReason: (row.move_reason as string | null) ?? null,
|
||||
createdAt: row.created_at as string,
|
||||
updatedAt: row.updated_at as string,
|
||||
tags: tags as NoteTag[],
|
||||
|
||||
32
src/main/repository/ftsHelpers.ts
Normal file
32
src/main/repository/ftsHelpers.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* v0.2.11 Cut D — FTS5 검색 + 회고 view 의 순수 함수 헬퍼.
|
||||
*/
|
||||
|
||||
const FTS5_SPECIAL_CHARS_RE = /["*():]/g;
|
||||
const WS_COLLAPSE_RE = /\s+/g;
|
||||
|
||||
/**
|
||||
* FTS5 MATCH 쿼리에 안전한 형태로 변환. " * ( ) : 제거 + 공백 정리.
|
||||
* 다중 토큰은 그대로 두어 FTS5 implicit AND 활용.
|
||||
*/
|
||||
export function sanitizeFtsQuery(input: string): string {
|
||||
return input.replace(FTS5_SPECIAL_CHARS_RE, ' ').replace(WS_COLLAPSE_RE, ' ').trim();
|
||||
}
|
||||
|
||||
export type ReviewPeriod = 'daily' | 'weekly' | 'monthly';
|
||||
|
||||
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* 회고 cutoff = period 시작점의 KST 자정 (UTC ISO).
|
||||
* daily = 오늘 0시, weekly = 7일 전 0시, monthly = 30일 전 0시.
|
||||
*/
|
||||
export function computeCutoff(period: ReviewPeriod, now: Date): string {
|
||||
const kstNow = new Date(now.getTime() + KST_OFFSET_MS);
|
||||
const y = kstNow.getUTCFullYear();
|
||||
const m = kstNow.getUTCMonth();
|
||||
const d = kstNow.getUTCDate();
|
||||
const todayMidKstUtc = Date.UTC(y, m, d) - KST_OFFSET_MS;
|
||||
const days = period === 'daily' ? 0 : period === 'weekly' ? 7 : 30;
|
||||
return new Date(todayMidKstUtc - days * 24 * 60 * 60 * 1000).toISOString();
|
||||
}
|
||||
@@ -2,6 +2,14 @@ import type { NoteRepository } from '../repository/NoteRepository.js';
|
||||
import type { MediaStore } from './MediaStore.js';
|
||||
import type { Note } from '@shared/types';
|
||||
|
||||
/**
|
||||
* v0.2.9 Cut B — CaptureService 가 ai_enabled 를 조회할 때만 의존하는 좁은 인터페이스.
|
||||
* SettingsService 직접 의존을 피해 테스트 mock 이 단순해짐 (entire SettingsService 면 불필요).
|
||||
*/
|
||||
export interface AiEnabledSource {
|
||||
isAiEnabled(): Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface TelemetryEmitter {
|
||||
emit(input:
|
||||
| { kind: 'capture'; payload: { noteId: string; rawTextLength: number; hasMedia: boolean } }
|
||||
@@ -23,6 +31,9 @@ export interface CaptureDeps {
|
||||
enqueue: (noteId: string) => Promise<void>;
|
||||
celebrate: (noteId: string) => void;
|
||||
telemetry?: TelemetryEmitter;
|
||||
// v0.2.9 Cut B — settings.ai_enabled=false 면 새 노트는 ai_status='disabled' + enqueue skip.
|
||||
// 미주입 시 기존 동작 (항상 enabled) 보존 — 기존 caller 무영향.
|
||||
settings?: AiEnabledSource;
|
||||
}
|
||||
|
||||
export interface SubmitInput {
|
||||
@@ -44,7 +55,12 @@ export class CaptureService {
|
||||
if (trimmed.length === 0 && input.images.length === 0) {
|
||||
throw new Error('empty submission');
|
||||
}
|
||||
const { id } = this.repo.create({ rawText: input.text });
|
||||
// v0.2.9 Cut B — settings 미주입 시 기본 enabled (backward compat).
|
||||
const aiEnabled = this.deps.settings ? await this.deps.settings.isAiEnabled() : true;
|
||||
const { id } = this.repo.create({
|
||||
rawText: input.text,
|
||||
aiStatus: aiEnabled ? 'pending' : 'disabled'
|
||||
});
|
||||
if (input.images.length > 0) {
|
||||
const rows = [];
|
||||
for (const img of input.images) {
|
||||
@@ -70,7 +86,9 @@ export class CaptureService {
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
await this.deps.enqueue(id);
|
||||
if (aiEnabled) {
|
||||
await this.deps.enqueue(id);
|
||||
}
|
||||
this.deps.celebrate(id);
|
||||
return { noteId: id };
|
||||
}
|
||||
|
||||
@@ -64,6 +64,11 @@ function noteToExportNote(n: Note): ExportNote {
|
||||
aiGeneratedAt: n.aiGeneratedAt,
|
||||
userIntent: n.userIntent,
|
||||
intentPromptedAt: n.intentPromptedAt,
|
||||
status: n.status,
|
||||
statusChangedAt: n.statusChangedAt,
|
||||
moveReason: n.moveReason,
|
||||
dueDate: n.dueDate,
|
||||
dueDateEditedByUser: n.dueDateEditedByUser,
|
||||
tags: n.tags.map((t) => ({ name: t.name, source: t.source })),
|
||||
media: n.media.map((m, idx) => ({
|
||||
rel: `media/${n.id.slice(0, 8)}__${idx + 1}.${inferExt(m.mime)}`,
|
||||
|
||||
@@ -89,4 +89,33 @@ export class GitClient {
|
||||
if (r.exitCode !== 0) throw new Error(`git rev-parse failed: ${r.stderr}`);
|
||||
return r.stdout.trim();
|
||||
}
|
||||
|
||||
async fetch(remote: string = 'origin'): Promise<GitExecResult> {
|
||||
return this.run(['fetch', remote]);
|
||||
}
|
||||
|
||||
async rebaseOnto(ref: string): Promise<GitExecResult> {
|
||||
return this.run(['rebase', ref]);
|
||||
}
|
||||
|
||||
async rebaseAbort(): Promise<GitExecResult> {
|
||||
return this.run(['rebase', '--abort']);
|
||||
}
|
||||
|
||||
async hasUncommittedChanges(): Promise<boolean> {
|
||||
const r = await this.run(['status', '--porcelain']);
|
||||
return r.stdout.trim().length > 0;
|
||||
}
|
||||
|
||||
/** ref (branch, tag, remote branch) 존재 여부 확인. `git rev-parse --verify`. */
|
||||
async refExists(ref: string): Promise<boolean> {
|
||||
const r = await this.run(['rev-parse', '--verify', ref]);
|
||||
return r.exitCode === 0;
|
||||
}
|
||||
|
||||
/** rebase conflict 시 conflict 마킹된 파일 list. `git diff --name-only --diff-filter=U`. */
|
||||
async listConflicts(): Promise<string[]> {
|
||||
const r = await this.run(['diff', '--name-only', '--diff-filter=U']);
|
||||
return r.stdout.split('\n').map((s) => s.trim()).filter((s) => s.length > 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,9 @@ export interface HealthCheckerOptions {
|
||||
onUpdate?: (status: HealthResult) => void;
|
||||
onTelemetry?: (event: HealthTelemetryEvent) => void;
|
||||
now?: () => number;
|
||||
// v0.2.9 Cut B Task 14 — settings.ai_enabled=false 면 polling skip.
|
||||
// 미설정 시 항상 enabled (backward-compat).
|
||||
isAiEnabled?: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
const DEFAULT_INTERVAL_MS = 60_000;
|
||||
@@ -72,8 +75,22 @@ export class HealthChecker {
|
||||
|
||||
start(): void {
|
||||
if (this.timer !== null) return;
|
||||
void this.runOnce();
|
||||
this.timer = setInterval(() => { void this.runOnce(); }, this.intervalMs);
|
||||
void this.tickIfEnabled();
|
||||
this.timer = setInterval(() => { void this.tickIfEnabled(); }, this.intervalMs);
|
||||
}
|
||||
|
||||
// v0.2.9 Cut B Task 14 — polling tick. settings.ai_enabled=false 면 skip.
|
||||
// 수동 runOnce({ manual: true }) 는 이 게이트와 무관하게 항상 실행 (사용자 의도).
|
||||
private async tickIfEnabled(): Promise<void> {
|
||||
if (this.opts.isAiEnabled !== undefined) {
|
||||
try {
|
||||
const enabled = await this.opts.isAiEnabled();
|
||||
if (!enabled) return;
|
||||
} catch {
|
||||
// settings 로드 실패 시 안전 측면 — polling 진행 (기존 동작 유지).
|
||||
}
|
||||
}
|
||||
await this.runOnce();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
|
||||
@@ -130,6 +130,37 @@ export class ImportService {
|
||||
};
|
||||
}
|
||||
|
||||
async applySyncFromDir(dir: string): Promise<{ changedCount: number }> {
|
||||
const files = await this.scanNotes(dir);
|
||||
let changedCount = 0;
|
||||
for (const f of files) {
|
||||
const content = await readFile(f, 'utf8');
|
||||
const parsed = parseExportNote(content);
|
||||
const r = this.repo.upsertFromSync({
|
||||
id: parsed.id,
|
||||
rawText: parsed.rawText,
|
||||
createdAt: parsed.createdAt,
|
||||
updatedAt: parsed.updatedAt,
|
||||
aiTitle: parsed.aiTitle,
|
||||
aiSummary: parsed.aiSummary,
|
||||
titleEditedByUser: parsed.titleEditedByUser,
|
||||
summaryEditedByUser: parsed.summaryEditedByUser,
|
||||
aiProvider: parsed.aiProvider,
|
||||
aiGeneratedAt: parsed.aiGeneratedAt,
|
||||
userIntent: parsed.userIntent,
|
||||
intentPromptedAt: parsed.intentPromptedAt,
|
||||
tags: parsed.tags,
|
||||
status: parsed.status,
|
||||
statusChangedAt: parsed.statusChangedAt,
|
||||
moveReason: parsed.moveReason,
|
||||
dueDate: parsed.dueDate,
|
||||
dueDateEditedByUser: parsed.dueDateEditedByUser
|
||||
});
|
||||
if (r.status !== 'skipped') changedCount += 1;
|
||||
}
|
||||
return { changedCount };
|
||||
}
|
||||
|
||||
private async scanNotes(sourceDir: string): Promise<string[]> {
|
||||
const notesDir = join(sourceDir, 'notes');
|
||||
let entries: string[];
|
||||
|
||||
@@ -8,7 +8,16 @@ const OllamaSettingsSchema = z.object({
|
||||
}).strict();
|
||||
|
||||
const SettingsSchema = z.object({
|
||||
ollama: OllamaSettingsSchema.optional()
|
||||
ollama: OllamaSettingsSchema.optional(),
|
||||
// v0.2.9 Cut B — AI-less mode toggle. 기존 settings 파일에 없으면 isAiEnabled() 가
|
||||
// true 로 fallback (기본 enabled). zod default 는 file 이 존재 + 키 부재일 때만 적용 —
|
||||
// load() 의 file-missing 분기에선 cache={} 라 isAiEnabled() 의 fallback 이 작동.
|
||||
ai_enabled: z.boolean().optional(),
|
||||
onboarding_completed: z.boolean().optional(),
|
||||
// v0.3.0 Cut E — 양방향 git sync 설정. 모두 optional — 미구성 시 sync 비활성.
|
||||
sync_repo_url: z.string().nullable().optional(),
|
||||
sync_auto_enabled: z.boolean().optional(),
|
||||
sync_interval_min: z.number().int().min(5).optional()
|
||||
}).strict();
|
||||
|
||||
export type Settings = z.infer<typeof SettingsSchema>;
|
||||
@@ -34,10 +43,91 @@ export class SettingsService {
|
||||
return this.cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.9 Cut B Task 12 — settings:get IPC 핸들러용 read-only accessor.
|
||||
* 첫 launch onboarding 분기에서 onboarding_completed 키 확인.
|
||||
*/
|
||||
async getAll(): Promise<Settings> {
|
||||
return this.load();
|
||||
}
|
||||
|
||||
async setOllama(value: OllamaSettings): Promise<void> {
|
||||
const validated = OllamaSettingsSchema.parse(value);
|
||||
const current = await this.load();
|
||||
const next: Settings = { ...current, ollama: validated };
|
||||
await this.persist(next);
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.9 Cut B — AI-less mode 의 기본값은 enabled (true). 기존 settings 파일을
|
||||
* 가진 사용자 (ai_enabled 키 부재) 도 무영향.
|
||||
*/
|
||||
async isAiEnabled(): Promise<boolean> {
|
||||
const s = await this.load();
|
||||
return s.ai_enabled ?? true;
|
||||
}
|
||||
|
||||
async setAiEnabled(value: boolean): Promise<void> {
|
||||
const current = await this.load();
|
||||
const next: Settings = { ...current, ai_enabled: value };
|
||||
await this.persist(next);
|
||||
}
|
||||
|
||||
/** v0.2.9 Cut B — 첫 실행 onboarding completion 표지. 기본 false. */
|
||||
async isOnboardingCompleted(): Promise<boolean> {
|
||||
const s = await this.load();
|
||||
return s.onboarding_completed ?? false;
|
||||
}
|
||||
|
||||
async setOnboardingCompleted(value: boolean): Promise<void> {
|
||||
const current = await this.load();
|
||||
const next: Settings = { ...current, onboarding_completed: value };
|
||||
await this.persist(next);
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.3.0 Cut E — sync 저장소 URL. null/빈 문자열 = sync 비활성. 본 메서드는 값만 저장,
|
||||
* git init/remote add 는 별도 호출자 (settings:configure-sync IPC) 가 담당.
|
||||
*/
|
||||
async getSyncRepoUrl(): Promise<string | null> {
|
||||
const s = await this.load();
|
||||
return s.sync_repo_url ?? null;
|
||||
}
|
||||
|
||||
async setSyncRepoUrl(value: string | null): Promise<void> {
|
||||
const current = await this.load();
|
||||
const next: Settings = { ...current, sync_repo_url: value };
|
||||
await this.persist(next);
|
||||
}
|
||||
|
||||
/** v0.3.0 Cut E — 자동 주기 sync 활성. configured 일 때만 의미 있음. 기본 true. */
|
||||
async isAutoSyncEnabled(): Promise<boolean> {
|
||||
const s = await this.load();
|
||||
return s.sync_auto_enabled ?? true;
|
||||
}
|
||||
|
||||
async setAutoSyncEnabled(value: boolean): Promise<void> {
|
||||
const current = await this.load();
|
||||
const next: Settings = { ...current, sync_auto_enabled: value };
|
||||
await this.persist(next);
|
||||
}
|
||||
|
||||
/** v0.3.0 Cut E — 자동 주기 sync interval (분). 기본 30, min 5. */
|
||||
async getSyncIntervalMin(): Promise<number> {
|
||||
const s = await this.load();
|
||||
return s.sync_interval_min ?? 30;
|
||||
}
|
||||
|
||||
async setSyncIntervalMin(value: number): Promise<void> {
|
||||
if (!Number.isInteger(value) || value < 5) {
|
||||
throw new Error(`sync_interval_min must be an integer >= 5 (got ${value})`);
|
||||
}
|
||||
const current = await this.load();
|
||||
const next: Settings = { ...current, sync_interval_min: value };
|
||||
await this.persist(next);
|
||||
}
|
||||
|
||||
private async persist(next: Settings): Promise<void> {
|
||||
await mkdir(dirname(this.filePath), { recursive: true });
|
||||
const tmpPath = this.filePath + '.tmp';
|
||||
await writeFile(tmpPath, JSON.stringify(next, null, 2), 'utf8');
|
||||
|
||||
@@ -1,22 +1,41 @@
|
||||
import { join } from 'node:path';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import type { ExportService } from './ExportService.js';
|
||||
import type { ImportService } from './ImportService.js';
|
||||
import { GitClient } from './GitClient.js';
|
||||
|
||||
/**
|
||||
* Cut E final review fix: 'noteId' was misleading — F5 export filenames are
|
||||
* `<date>-<id8>-<slug>.md` (composeFilename), not `<uuid>.md`. The git checkout /
|
||||
* resolve operations use the FULL relative path (e.g., `notes/2026-05-09-abc12345-회의.md`).
|
||||
* `path` matches what we actually pass to `git checkout --ours/theirs`.
|
||||
*/
|
||||
export interface SyncConflict {
|
||||
path: string;
|
||||
localText: string;
|
||||
remoteText: string;
|
||||
}
|
||||
|
||||
export interface SyncStatus {
|
||||
ok: boolean;
|
||||
reason?: string; // why the sync was skipped or failed
|
||||
changed?: boolean; // true if a new commit was created
|
||||
sha?: string | null;
|
||||
reason?: string;
|
||||
changed?: boolean;
|
||||
localSha?: string | null;
|
||||
pushed?: boolean;
|
||||
importedCount?: number;
|
||||
conflicts?: SyncConflict[];
|
||||
}
|
||||
|
||||
export class SyncService {
|
||||
private syncDir: string;
|
||||
private lastConflicts: SyncConflict[] = [];
|
||||
private lastResult: SyncStatus | null = null;
|
||||
private lastAt: string | null = null;
|
||||
|
||||
constructor(
|
||||
private profileDir: string,
|
||||
private exportSvc: ExportService,
|
||||
private importSvc: ImportService,
|
||||
private now: () => Date = () => new Date()
|
||||
) {
|
||||
this.syncDir = join(profileDir, 'sync');
|
||||
@@ -33,31 +52,151 @@ export class SyncService {
|
||||
return true;
|
||||
}
|
||||
|
||||
getLastStatus(): { lastAt: string | null; lastResult: SyncStatus | null } {
|
||||
return { lastAt: this.lastAt, lastResult: this.lastResult };
|
||||
}
|
||||
|
||||
listConflicts(): SyncConflict[] {
|
||||
return this.lastConflicts;
|
||||
}
|
||||
|
||||
async sync(): Promise<SyncStatus> {
|
||||
if (!(await this.isConfigured())) {
|
||||
return { ok: false, reason: 'not_configured' };
|
||||
const result = await this.runSync();
|
||||
this.lastResult = result;
|
||||
this.lastAt = this.now().toISOString();
|
||||
if (result.reason === 'conflict' && result.conflicts) {
|
||||
this.lastConflicts = result.conflicts;
|
||||
} else if (result.ok) {
|
||||
this.lastConflicts = [];
|
||||
}
|
||||
// 1. Re-export the full tree into syncDir (idempotent).
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.3.0 Cut E — conflict 해결. local/remote 2 choice (both deferred to v0.3.1+).
|
||||
* 사용자가 ConflictModal 에서 선택 → IPC → 본 메서드. 각 conflict 의 path 별 호출.
|
||||
*
|
||||
* - 'local' = 내 것 사용 (origin 변경 폐기) → git checkout --ours
|
||||
* - 'remote' = 원격 사용 → git checkout --theirs + applySyncFromDir (local DB 갱신)
|
||||
*
|
||||
* 모든 conflict 해결 후 rebase --continue 가 성공 → push.
|
||||
* UI 가 여러 conflict 를 loop 호출하면 마지막 호출에서 push 까지 완료.
|
||||
*
|
||||
* Cut E final review fix: 파라미터를 path 로 변경 (옛 noteId 는 export filename slug,
|
||||
* UUID 아님 — 혼동 회피).
|
||||
*/
|
||||
async resolveConflict(
|
||||
path: string,
|
||||
choice: 'local' | 'remote'
|
||||
): Promise<{ ok: true } | { ok: false; reason: string }> {
|
||||
const git = new GitClient(this.syncDir);
|
||||
const flag = choice === 'local' ? '--ours' : '--theirs';
|
||||
|
||||
const checkout = await git.run(['checkout', flag, path]);
|
||||
if (checkout.exitCode !== 0) {
|
||||
return { ok: false, reason: `checkout failed: ${checkout.stderr}` };
|
||||
}
|
||||
|
||||
await git.addAll();
|
||||
|
||||
const cont = await git.run(['rebase', '--continue']);
|
||||
if (cont.exitCode !== 0) {
|
||||
// Likely other unresolved files — UI will call resolveConflict for them.
|
||||
return { ok: false, reason: `rebase --continue failed: ${cont.stderr}` };
|
||||
}
|
||||
|
||||
if (choice === 'remote') {
|
||||
try {
|
||||
await this.importSvc.applySyncFromDir(this.syncDir);
|
||||
} catch (e) {
|
||||
return { ok: false, reason: `re-import failed: ${(e as Error).message}` };
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await git.push();
|
||||
} catch (e) {
|
||||
return { ok: false, reason: `push failed: ${(e as Error).message}` };
|
||||
}
|
||||
|
||||
// Remove this path from cached conflicts list
|
||||
this.lastConflicts = this.lastConflicts.filter((c) => c.path !== path);
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
private async runSync(): Promise<SyncStatus> {
|
||||
if (!(await this.isConfigured())) return { ok: false, reason: 'not_configured' };
|
||||
|
||||
const git = new GitClient(this.syncDir);
|
||||
|
||||
// 1. local export
|
||||
try {
|
||||
await mkdir(this.syncDir, { recursive: true });
|
||||
await this.exportSvc.export(this.syncDir, { includeMedia: true });
|
||||
} catch (e) {
|
||||
return { ok: false, reason: `export failed: ${(e as Error).message}` };
|
||||
}
|
||||
// 2. git add + commit + push
|
||||
const git = new GitClient(this.syncDir);
|
||||
|
||||
// 2. local commit (only if changed)
|
||||
let localSha: string | null = null;
|
||||
let localChanged = false;
|
||||
try {
|
||||
await git.addAll();
|
||||
const ts = this.now().toISOString();
|
||||
const message = `chore(notes): sync ${ts}`;
|
||||
const commit = await git.commit(message);
|
||||
if (!commit.changed) {
|
||||
return { ok: true, changed: false, pushed: false };
|
||||
localChanged = await git.hasUncommittedChanges();
|
||||
if (localChanged) {
|
||||
const c = await git.commit(`chore(notes): sync ${this.now().toISOString()}`);
|
||||
localSha = c.sha;
|
||||
}
|
||||
await git.push();
|
||||
return { ok: true, changed: true, sha: commit.sha, pushed: true };
|
||||
} catch (e) {
|
||||
return { ok: false, reason: (e as Error).message };
|
||||
return { ok: false, reason: `local commit failed: ${(e as Error).message}` };
|
||||
}
|
||||
|
||||
// 3. fetch
|
||||
const fetchR = await git.fetch();
|
||||
if (fetchR.exitCode !== 0) return { ok: false, reason: `fetch failed: ${fetchR.stderr}` };
|
||||
|
||||
// 4. rebase — skip if origin/main doesn't exist yet (first-push, empty remote)
|
||||
const hasOriginMain = await git.refExists('origin/main');
|
||||
if (hasOriginMain) {
|
||||
const rebaseR = await git.rebaseOnto('origin/main');
|
||||
if (rebaseR.exitCode !== 0) {
|
||||
const files = await git.listConflicts();
|
||||
// Cut E final review fix — populate localText/remoteText from rebase index
|
||||
// BEFORE aborting. `git show :2:<path>` = ours (local during rebase),
|
||||
// `:3:<path>` = theirs (remote being applied). UI shows side-by-side diff.
|
||||
const conflicts: SyncConflict[] = [];
|
||||
for (const path of files) {
|
||||
const ours = await git.run(['show', `:2:${path}`]);
|
||||
const theirs = await git.run(['show', `:3:${path}`]);
|
||||
conflicts.push({
|
||||
path,
|
||||
localText: ours.exitCode === 0 ? ours.stdout : '',
|
||||
remoteText: theirs.exitCode === 0 ? theirs.stdout : ''
|
||||
});
|
||||
}
|
||||
await git.rebaseAbort();
|
||||
return { ok: false, reason: 'conflict', conflicts };
|
||||
}
|
||||
}
|
||||
|
||||
// 5. re-import
|
||||
let importedCount = 0;
|
||||
try {
|
||||
const r = await this.importSvc.applySyncFromDir(this.syncDir);
|
||||
importedCount = r.changedCount;
|
||||
} catch (e) {
|
||||
return { ok: false, reason: `re-import failed: ${(e as Error).message}` };
|
||||
}
|
||||
|
||||
// 6. push
|
||||
try {
|
||||
await git.push();
|
||||
} catch (e) {
|
||||
return { ok: false, reason: `push failed: ${(e as Error).message}` };
|
||||
}
|
||||
|
||||
return { ok: true, changed: localChanged || importedCount > 0, localSha, importedCount, pushed: true };
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
49
src/main/services/SyncTimer.ts
Normal file
49
src/main/services/SyncTimer.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { SyncService } from './SyncService.js';
|
||||
import type { SettingsService } from './SettingsService.js';
|
||||
|
||||
/**
|
||||
* v0.3.0 Cut E — 자동 주기 sync timer.
|
||||
*
|
||||
* - start: settings 의 auto enabled + repo URL 모두 갖춰져야 시작
|
||||
* - reconfigure: settings 변경 시 stop + start (새 interval 적용)
|
||||
* - stop: clearInterval (idempotent)
|
||||
*
|
||||
* sync 결과는 무시 (interval mode = silent). conflict 발생 시 다음 manual sync /
|
||||
* 충돌 UI 진입 시 처리됨 — 사용자가 settings 페이지의 SyncSection 에서 확인 가능.
|
||||
*/
|
||||
export class SyncTimer {
|
||||
private handle: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(
|
||||
private syncSvc: SyncService,
|
||||
private settings: SettingsService
|
||||
) {}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.handle !== null) return; // idempotent
|
||||
const enabled = await this.settings.isAutoSyncEnabled();
|
||||
if (!enabled) return;
|
||||
const url = await this.settings.getSyncRepoUrl();
|
||||
if (url === null || url.trim().length === 0) return;
|
||||
const intervalMin = await this.settings.getSyncIntervalMin();
|
||||
const ms = Math.max(5, intervalMin) * 60 * 1000;
|
||||
this.handle = setInterval(() => {
|
||||
void this.syncSvc.sync().catch(() => {
|
||||
// silent — interval mode 의 실패는 다음 attempt 또는 사용자 manual 호출이 처리
|
||||
});
|
||||
}, ms);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.handle !== null) {
|
||||
clearInterval(this.handle);
|
||||
this.handle = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** settings 변경 시 호출 — 현재 interval stop 후 새 값으로 start. */
|
||||
async reconfigure(): Promise<void> {
|
||||
this.stop();
|
||||
await this.start();
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,13 @@ export interface ExportNote {
|
||||
aiGeneratedAt: string | null;
|
||||
userIntent: string | null;
|
||||
intentPromptedAt: string | null;
|
||||
// v0.3.0 Cut E — Cut B (status), Cut C (dueDate via m002), and dueDate user-edited flag
|
||||
// need to round-trip through F5 export and Cut E sync.
|
||||
status: 'active' | 'completed' | 'archived' | 'trashed';
|
||||
statusChangedAt: string | null;
|
||||
moveReason: string | null;
|
||||
dueDate: string | null;
|
||||
dueDateEditedByUser: boolean;
|
||||
tags: ExportNoteTag[];
|
||||
media: ExportNoteMedia[];
|
||||
}
|
||||
@@ -155,6 +162,18 @@ export function composeFrontmatter(note: ExportNote): string {
|
||||
lines.push(`ai_generated_at: ${note.aiGeneratedAt}`);
|
||||
}
|
||||
|
||||
lines.push(`status: ${note.status}`);
|
||||
if (note.statusChangedAt !== null) {
|
||||
lines.push(`status_changed_at: ${note.statusChangedAt}`);
|
||||
}
|
||||
if (note.moveReason !== null) {
|
||||
lines.push(`move_reason: ${formatScalar(note.moveReason)}`);
|
||||
}
|
||||
if (note.dueDate !== null) {
|
||||
lines.push(`due_date: ${note.dueDate}`);
|
||||
lines.push(`due_date_source: ${note.dueDateEditedByUser ? 'user' : 'ai'}`);
|
||||
}
|
||||
|
||||
if (note.media.length > 0) {
|
||||
lines.push('images:');
|
||||
for (const m of note.media) {
|
||||
|
||||
@@ -34,6 +34,13 @@ export interface ParsedNote {
|
||||
userIntent: string | null;
|
||||
intentPromptedAt: string | null;
|
||||
deletedAt: string | null; // 신규 v0.2.3 #4
|
||||
// v0.3.0 Cut E — round-trip status / due_date / move_reason from frontmatter.
|
||||
// Default to 'active' / null / false when absent (older exports pre-Cut E).
|
||||
status: 'active' | 'completed' | 'archived' | 'trashed';
|
||||
statusChangedAt: string | null;
|
||||
moveReason: string | null;
|
||||
dueDate: string | null;
|
||||
dueDateEditedByUser: boolean;
|
||||
tags: ParsedNoteTag[];
|
||||
images: ParsedNoteImage[];
|
||||
exportVersion: number;
|
||||
@@ -335,6 +342,13 @@ export function parseExportNote(markdown: string): ParsedNote {
|
||||
const versionRaw = get('inkling_export_version');
|
||||
const exportVersion = versionRaw === null ? 0 : Number.parseInt(versionRaw, 10) || 0;
|
||||
|
||||
const statusRaw = get('status');
|
||||
const validStatuses = ['active', 'completed', 'archived', 'trashed'] as const;
|
||||
const status = (validStatuses as readonly string[]).includes(statusRaw ?? 'active')
|
||||
? ((statusRaw ?? 'active') as ParsedNote['status'])
|
||||
: 'active';
|
||||
const dueDateSource = get('due_date_source');
|
||||
|
||||
return {
|
||||
id,
|
||||
createdAt,
|
||||
@@ -349,6 +363,11 @@ export function parseExportNote(markdown: string): ParsedNote {
|
||||
userIntent: get('user_intent'),
|
||||
intentPromptedAt: get('intent_prompted_at'),
|
||||
deletedAt: get('deleted_at'),
|
||||
status,
|
||||
statusChangedAt: get('status_changed_at'),
|
||||
moveReason: get('move_reason'),
|
||||
dueDate: get('due_date'),
|
||||
dueDateEditedByUser: dueDateSource === 'user',
|
||||
tags: fm.tags,
|
||||
images: fm.images,
|
||||
exportVersion
|
||||
|
||||
@@ -66,6 +66,37 @@ const api: InklingApi = {
|
||||
getAppInfo: () => ipcRenderer.invoke('settings:get-app-info'),
|
||||
openProfileDir: () => ipcRenderer.invoke('settings:open-profile-dir'),
|
||||
copyAppInfo: () => ipcRenderer.invoke('settings:copy-app-info'),
|
||||
// v0.2.8 Cut A — 첨부 이미지를 OS 기본 뷰어로 열기 (Task 3).
|
||||
openMedia: (relPath: string) => ipcRenderer.invoke('inbox:open-media', relPath),
|
||||
// v0.2.9 Cut B Task 4 — status 별 list + counts.
|
||||
listByStatus: (status, opts) => ipcRenderer.invoke('inbox:list-by-status', status, opts ?? {}),
|
||||
countsByStatus: () => ipcRenderer.invoke('inbox:counts-by-status'),
|
||||
// v0.2.9 Cut B Task 8 — 4분기 status 전이 + AI 자동 분류 추천.
|
||||
setStatus: (id, status, reason) => ipcRenderer.invoke('inbox:set-status', id, status, reason),
|
||||
classifyStatus: (id, reason) => ipcRenderer.invoke('ai:classify-status', id, reason),
|
||||
// v0.2.9 Cut B Task 12 — settings read + AI/onboarding 토글 (첫 launch wizard 분기 포함).
|
||||
getSettings: () => ipcRenderer.invoke('settings:get'),
|
||||
setAiEnabled: (enabled: boolean) => ipcRenderer.invoke('settings:set-ai-enabled', enabled),
|
||||
setOnboardingCompleted: (completed: boolean) => ipcRenderer.invoke('settings:set-onboarding-completed', completed),
|
||||
// v0.2.9 Cut B Task 16 — disabled 메모 재투입 + count.
|
||||
enqueueDisabled: () => ipcRenderer.invoke('inbox:enqueue-disabled'),
|
||||
getDisabledCount: () => ipcRenderer.invoke('inbox:get-disabled-count'),
|
||||
// v0.2.10 Cut C — raw_text 가변 + revision 보존.
|
||||
updateRawText: (noteId: string, newText: string) => ipcRenderer.invoke('inbox:update-raw-text', noteId, newText),
|
||||
listRevisions: (noteId: string) => ipcRenderer.invoke('inbox:list-revisions', noteId),
|
||||
restoreRevision: (noteId: string, revId: number) => ipcRenderer.invoke('inbox:restore-revision', noteId, revId),
|
||||
// v0.2.11 Cut D — search + 회고 aggregate.
|
||||
search: (query, opts) => ipcRenderer.invoke('inbox:search', query, opts ?? {}),
|
||||
reviewAggregate: (period) => ipcRenderer.invoke('inbox:review-aggregate', period),
|
||||
// v0.3.0 Cut E — 양방향 sync.
|
||||
configureSync: (url: string | null) => ipcRenderer.invoke('settings:configure-sync', url),
|
||||
testSyncConnection: () => ipcRenderer.invoke('settings:test-sync-connection'),
|
||||
listConflicts: () => ipcRenderer.invoke('sync:list-conflicts'),
|
||||
resolveConflict: (path: string, choice: 'local' | 'remote') =>
|
||||
ipcRenderer.invoke('sync:resolve-conflict', path, choice),
|
||||
getSyncStatus: () => ipcRenderer.invoke('sync:get-status'),
|
||||
setSyncAutoEnabled: (value: boolean) => ipcRenderer.invoke('settings:set-sync-auto-enabled', value),
|
||||
setSyncIntervalMin: (value: number) => ipcRenderer.invoke('settings:set-sync-interval-min', value),
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -13,6 +13,10 @@ import { ExpiryBanner } from './components/ExpiryBanner.js';
|
||||
import { FailedBanner } from './components/FailedBanner.js';
|
||||
import { RecallBanner } from './components/RecallBanner.js';
|
||||
import { SettingsPage } from './components/SettingsPage.js';
|
||||
import { OnboardingWizard } from './components/OnboardingWizard.js';
|
||||
import { SearchBox } from './components/SearchBox.js';
|
||||
import { ReviewView } from './components/ReviewView.js';
|
||||
import type { InboxView } from './store.js';
|
||||
|
||||
export function App(): React.ReactElement {
|
||||
const {
|
||||
@@ -23,7 +27,26 @@ export function App(): React.ReactElement {
|
||||
} = useInbox();
|
||||
const showSettings = useInbox((s) => s.showSettings);
|
||||
const setShowSettings = useInbox((s) => s.setShowSettings);
|
||||
// v0.2.9 Cut B Task 5 — 4탭 (Inbox/완료/보관/휴지통).
|
||||
const view = useInbox((s) => s.view);
|
||||
const counts = useInbox((s) => s.counts);
|
||||
const setView = useInbox((s) => s.setView);
|
||||
const searchResults = useInbox((s) => s.searchResults);
|
||||
const [recoveryDismissed, setRecoveryDismissed] = useState(isRecoveryDismissedToday());
|
||||
// v0.2.9 Cut B Task 12 — 첫 launch onboarding 분기. null = 로딩, true = 표시, false = 미표시.
|
||||
const [showOnboarding, setShowOnboarding] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const settings = await inboxApi.getSettings();
|
||||
setShowOnboarding(!settings.onboarding_completed);
|
||||
} catch {
|
||||
// 안전한 fallback — settings 읽기 실패 시 wizard 미표시 (기존 사용자 무영향).
|
||||
setShowOnboarding(false);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadInitial();
|
||||
@@ -35,15 +58,8 @@ export function App(): React.ReactElement {
|
||||
useInbox.setState({ ollamaStatus: status });
|
||||
});
|
||||
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();
|
||||
}
|
||||
// v0.2.9 Cut B Task 4 — setView 가 mirror state (showTrash/showSettings) 동기 갱신.
|
||||
useInbox.getState().setView(view);
|
||||
});
|
||||
const onFocus = () => { void refreshMeta(); };
|
||||
window.addEventListener('focus', onFocus);
|
||||
@@ -52,10 +68,18 @@ export function App(): React.ReactElement {
|
||||
// deps array 에 추가 불필요. mount 시 1회 구독 + unmount 시 해제.
|
||||
}, [loadInitial, refreshMeta, upsertNote]);
|
||||
|
||||
if (showOnboarding === null) return <></>;
|
||||
if (showOnboarding) return <OnboardingWizard onClose={() => setShowOnboarding(false)} />;
|
||||
|
||||
if (view === 'review-daily') return <ReviewView period="daily" />;
|
||||
if (view === 'review-weekly') return <ReviewView period="weekly" />;
|
||||
if (view === 'review-monthly') return <ReviewView period="monthly" />;
|
||||
|
||||
if (showSettings) return <SettingsPage />;
|
||||
|
||||
const showRecovery = continuity.showRecoveryToast && !recoveryDismissed;
|
||||
const filtered = selectFilteredNotes({ notes, tagFilter });
|
||||
const displayed = searchResults !== null ? searchResults : filtered;
|
||||
|
||||
const tabBtnStyle = (active: boolean): React.CSSProperties => ({
|
||||
background: active ? '#0a4b80' : 'transparent',
|
||||
@@ -72,21 +96,39 @@ export function App(): React.ReactElement {
|
||||
<div className="header">
|
||||
<h1 style={{ fontSize: 18, margin: 0 }}>Inkling</h1>
|
||||
<div style={{ display: 'flex', gap: 6, marginLeft: 12 }}>
|
||||
<button
|
||||
onClick={() => { if (showTrash) void toggleShowTrash(); }}
|
||||
aria-pressed={!showTrash}
|
||||
style={tabBtnStyle(!showTrash)}
|
||||
>
|
||||
Inbox({notes.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { if (!showTrash) void toggleShowTrash(); }}
|
||||
aria-pressed={showTrash}
|
||||
style={tabBtnStyle(showTrash)}
|
||||
>
|
||||
휴지통({trashCount})
|
||||
</button>
|
||||
{(
|
||||
[
|
||||
{ key: 'inbox', label: 'Inbox', count: counts.active },
|
||||
{ key: 'completed', label: '완료', count: counts.completed },
|
||||
{ key: 'archived', label: '보관', count: counts.archived },
|
||||
{ key: 'trash', label: '휴지통', count: counts.trashed }
|
||||
] as const
|
||||
).map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setView(t.key)}
|
||||
aria-pressed={view === t.key}
|
||||
style={tabBtnStyle(view === t.key)}
|
||||
>
|
||||
{t.label}({t.count})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<select
|
||||
aria-label="회고 기간"
|
||||
value={view.startsWith('review-') ? view.replace('review-', '') : ''}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
if (v === 'daily' || v === 'weekly' || v === 'monthly') setView(`review-${v}` as InboxView);
|
||||
}}
|
||||
style={{ marginLeft: 8, fontSize: 12, padding: '4px 6px', border: '1px solid #0a4b80', borderRadius: 4, color: '#0a4b80', background: 'transparent' }}
|
||||
>
|
||||
<option value="">📅 회고…</option>
|
||||
<option value="daily">일간</option>
|
||||
<option value="weekly">주간</option>
|
||||
<option value="monthly">월간</option>
|
||||
</select>
|
||||
<SearchBox />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 2, marginLeft: 'auto' }}>
|
||||
<ContinuityBadge />
|
||||
<IdentityCounter />
|
||||
@@ -137,12 +179,14 @@ export function App(): React.ReactElement {
|
||||
)}
|
||||
{loading && notes.length === 0 ? (
|
||||
<div className="empty">불러오는 중…</div>
|
||||
) : searchResults !== null && displayed.length === 0 ? (
|
||||
<div className="empty">검색 결과가 없습니다.</div>
|
||||
) : notes.length === 0 ? (
|
||||
<div className="empty">머릿속에 떠다니는 한 줄을 적어보세요. <code>Ctrl+Shift+J</code></div>
|
||||
) : filtered.length === 0 ? (
|
||||
) : displayed.length === 0 ? (
|
||||
<div className="empty">이 태그의 노트가 없습니다.</div>
|
||||
) : (
|
||||
filtered.map((n) => (
|
||||
displayed.map((n) => (
|
||||
<NoteCard
|
||||
key={n.id} note={n} mode="inbox"
|
||||
onDeleted={() => removeNote(n.id)}
|
||||
|
||||
112
src/renderer/inbox/components/ConflictModal.tsx
Normal file
112
src/renderer/inbox/components/ConflictModal.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import type { SyncConflict } from '@shared/types';
|
||||
import { inboxApi } from '../api.js';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onResolved: () => void;
|
||||
}
|
||||
|
||||
const overlayStyle: React.CSSProperties = {
|
||||
position: 'fixed', top: 0, left: 0, width: '100vw', height: '100vh',
|
||||
background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'center', zIndex: 100
|
||||
};
|
||||
|
||||
const modalStyle: React.CSSProperties = {
|
||||
background: '#fff', borderRadius: 8, padding: 20, width: 600,
|
||||
maxHeight: '70vh', overflow: 'auto', boxShadow: '0 4px 16px rgba(0,0,0,0.2)'
|
||||
};
|
||||
|
||||
const rowStyle: React.CSSProperties = {
|
||||
border: '1px solid #eee', borderRadius: 6, padding: 10, marginTop: 8
|
||||
};
|
||||
|
||||
export function ConflictModal({ onClose, onResolved }: Props): React.ReactElement {
|
||||
const [conflicts, setConflicts] = useState<SyncConflict[]>([]);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const c = await inboxApi.listConflicts();
|
||||
if (!cancelled) setConflicts(c);
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
async function onChoose(path: string, choice: 'local' | 'remote') {
|
||||
setBusy(path);
|
||||
setError(null);
|
||||
const r = await inboxApi.resolveConflict(path, choice);
|
||||
setBusy(null);
|
||||
if (!r.ok) {
|
||||
setError(`해결 실패: ${r.reason}`);
|
||||
return;
|
||||
}
|
||||
const next = conflicts.filter((c) => c.path !== path);
|
||||
setConflicts(next);
|
||||
if (next.length === 0) {
|
||||
onResolved();
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={overlayStyle} onClick={onClose}>
|
||||
<div style={modalStyle} onClick={(e) => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h3 style={{ margin: 0, fontSize: 16 }}>충돌 ({conflicts.length}건)</h3>
|
||||
<button onClick={onClose} aria-label="닫기" style={{ background: 'none', border: 'none', fontSize: 18, cursor: 'pointer', color: '#888' }}>×</button>
|
||||
</div>
|
||||
{error !== null && <div style={{ marginTop: 10, fontSize: 12, color: '#c93030' }}>{error}</div>}
|
||||
{conflicts.map((c) => (
|
||||
<div key={c.path} style={rowStyle}>
|
||||
<div style={{ fontSize: 12, color: '#888', marginBottom: 6 }}>{c.path}</div>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 11, color: '#666', marginBottom: 4 }}>내 기기</div>
|
||||
<pre style={preStyle()}>{c.localText || '(미리보기 없음)'}</pre>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 11, color: '#666', marginBottom: 4 }}>다른 기기</div>
|
||||
<pre style={preStyle()}>{c.remoteText || '(미리보기 없음)'}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: 8, display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={() => { void onChoose(c.path, 'local'); }}
|
||||
disabled={busy === c.path}
|
||||
style={chooseBtnStyle('#0a4b80')}
|
||||
>
|
||||
{busy === c.path ? '처리 중…' : '내 것 사용'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { void onChoose(c.path, 'remote'); }}
|
||||
disabled={busy === c.path}
|
||||
style={chooseBtnStyle('#236b1a')}
|
||||
>
|
||||
{busy === c.path ? '처리 중…' : '원격 사용'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function preStyle(): React.CSSProperties {
|
||||
return {
|
||||
margin: 0, whiteSpace: 'pre-wrap', fontSize: 11, color: '#444',
|
||||
background: '#fafafa', padding: 6, borderRadius: 3, maxHeight: 120, overflow: 'auto'
|
||||
};
|
||||
}
|
||||
|
||||
function chooseBtnStyle(color: string): React.CSSProperties {
|
||||
return {
|
||||
background: 'none', border: `1px solid ${color}`, color, cursor: 'pointer',
|
||||
fontSize: 12, padding: '4px 10px', borderRadius: 4
|
||||
};
|
||||
}
|
||||
@@ -3,8 +3,11 @@ import { useInbox } from '../store.js';
|
||||
import { Banner } from './Banner.js';
|
||||
|
||||
export function FailedBanner(): React.ReactElement | null {
|
||||
const aiEnabled = useInbox((s) => s.ai_enabled);
|
||||
const count = useInbox((s) => s.failedCount);
|
||||
const retryAllFailed = useInbox((s) => s.retryAllFailed);
|
||||
// v0.2.9 Cut B Task 14 — AI-less mode 에서는 banner 자체 비활성.
|
||||
if (!aiEnabled) return null;
|
||||
if (count === 0) return null;
|
||||
return (
|
||||
<Banner severity="error">
|
||||
|
||||
150
src/renderer/inbox/components/MoveStatusModal.tsx
Normal file
150
src/renderer/inbox/components/MoveStatusModal.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import React, { useState } from 'react';
|
||||
import { inboxApi } from '../api.js';
|
||||
import type { NoteStatus } from '@shared/types';
|
||||
|
||||
interface Props {
|
||||
noteId: string;
|
||||
rawText: string;
|
||||
summary: string;
|
||||
onClose: () => void;
|
||||
onMoved: (status: NoteStatus, reason: string | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.9 Cut B Task 7 — 메모 이동 Modal.
|
||||
*
|
||||
* 사유 입력 + 3 status 버튼 (완료/보관/휴지통) + AI 자동 분류.
|
||||
*/
|
||||
export function MoveStatusModal({
|
||||
noteId,
|
||||
onClose,
|
||||
onMoved
|
||||
}: Props): React.ReactElement {
|
||||
const [reason, setReason] = useState('');
|
||||
const [recommendation, setRecommendation] = useState<{
|
||||
status: NoteStatus;
|
||||
rationale: string;
|
||||
} | null>(null);
|
||||
const [classifying, setClassifying] = useState(false);
|
||||
|
||||
async function move(status: NoteStatus): Promise<void> {
|
||||
const trimmedReason = reason.trim() === '' ? null : reason.trim();
|
||||
await inboxApi.setStatus(noteId, status, trimmedReason);
|
||||
onMoved(status, trimmedReason);
|
||||
}
|
||||
|
||||
async function classify(): Promise<void> {
|
||||
setClassifying(true);
|
||||
setRecommendation(null);
|
||||
try {
|
||||
const r = await inboxApi.classifyStatus(noteId, reason);
|
||||
setRecommendation({ status: r.recommended, rationale: r.rationale });
|
||||
} finally {
|
||||
setClassifying(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label="이동"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0,0,0,0.4)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 100
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: '#fff',
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
minWidth: 400,
|
||||
maxWidth: 520
|
||||
}}
|
||||
>
|
||||
<h2 style={{ fontSize: 16, margin: '0 0 12px' }}>메모 이동</h2>
|
||||
<textarea
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="이동 사유 (선택사항)"
|
||||
rows={2}
|
||||
style={{ width: '100%', padding: 6, fontSize: 13, boxSizing: 'border-box' }}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
marginTop: 8,
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<button onClick={() => void classify()} disabled={classifying}>
|
||||
{classifying ? '분류 중...' : 'AI 자동 분류'}
|
||||
</button>
|
||||
<button onClick={() => void move('completed')}>완료</button>
|
||||
<button onClick={() => void move('archived')}>보관</button>
|
||||
<button onClick={() => void move('trashed')}>휴지통</button>
|
||||
<button onClick={onClose} style={{ marginLeft: 'auto' }}>
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
{recommendation !== null && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 12,
|
||||
padding: 8,
|
||||
background: '#f0f8ff',
|
||||
borderRadius: 4,
|
||||
fontSize: 12
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
AI 추천: <strong>{statusLabel(recommendation.status)}</strong>
|
||||
</div>
|
||||
<div style={{ marginTop: 4 }}>이유: {recommendation.rationale}</div>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<button onClick={() => void move(recommendation.status)}>
|
||||
확정 ({statusLabel(recommendation.status)})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function statusLabel(s: NoteStatus): string {
|
||||
switch (s) {
|
||||
case 'active':
|
||||
return '활성';
|
||||
case 'completed':
|
||||
return '완료';
|
||||
case 'archived':
|
||||
return '보관';
|
||||
case 'trashed':
|
||||
return '휴지통';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* status 의 한글 라벨에 적절한 조사를 붙여 반환. 받침 있으면 "으로", 없으면 "로".
|
||||
* 예: '완료로' / '보관으로' / '휴지통으로' / '활성으로'.
|
||||
*/
|
||||
export function statusLabelWithParticle(s: NoteStatus): string {
|
||||
const label = statusLabel(s);
|
||||
const last = label.charCodeAt(label.length - 1);
|
||||
// 한글 syllable block 외 → "로" default
|
||||
if (last < 0xAC00 || last > 0xD7A3) return `${label}로`;
|
||||
const jongseong = (last - 0xAC00) % 28;
|
||||
return jongseong === 0 ? `${label}로` : `${label}으로`;
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import React, { useState } from 'react';
|
||||
import type { Note } from '@shared/types';
|
||||
import type { Note, NoteStatus } from '@shared/types';
|
||||
import { inboxApi } from '../api.js';
|
||||
import { useInbox } from '../store.js';
|
||||
import { EditableField } from './EditableField.js';
|
||||
import { IntentBanner } from './IntentBanner.js';
|
||||
import { pushTagUndo } from './TagUndoToast.js';
|
||||
import { MoveStatusModal, statusLabelWithParticle } from './MoveStatusModal.js';
|
||||
import { RevisionHistoryModal } from './RevisionHistoryModal.js';
|
||||
|
||||
interface Props {
|
||||
note: Note;
|
||||
@@ -109,19 +111,26 @@ function DueDateBadge({
|
||||
|
||||
export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore, onPermanentDelete }: Props): React.ReactElement {
|
||||
const isTrash = mode === 'trash';
|
||||
// v0.2.9 Cut B Task 13 — ai_status='disabled' 노트는 raw_text 가 1차 정보. 원문 펼침 default 켬.
|
||||
const [rawOpen, setRawOpen] = useState(note.aiStatus !== 'done');
|
||||
const [local, setLocal] = useState(note);
|
||||
const isAiDisabled = local.aiStatus === 'disabled';
|
||||
const fallbackTitle = local.rawText.split('\n')[0]?.slice(0, 60) || '(빈 메모)';
|
||||
// v0.2.9 Cut B Task 6 — 이동 메뉴 dropdown + MoveStatusModal target.
|
||||
const [moveTarget, setMoveTarget] = useState<NoteStatus | null>(null);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [editingRaw, setEditingRaw] = useState(false);
|
||||
const [draftRaw, setDraftRaw] = useState('');
|
||||
const [showRevisions, setShowRevisions] = useState(false);
|
||||
|
||||
const possibleTargets: NoteStatus[] = (
|
||||
['active', 'completed', 'archived', 'trashed'] as NoteStatus[]
|
||||
).filter((s) => s !== local.status);
|
||||
|
||||
React.useEffect(() => { setLocal(note); }, [note]);
|
||||
|
||||
const formatted = new Date(note.createdAt).toLocaleString('ko-KR');
|
||||
|
||||
async function handleDelete() {
|
||||
if (!window.confirm('이 기억을 버릴까요? 되돌릴 수 없습니다.')) return;
|
||||
await inboxApi.deleteNote(note.id);
|
||||
onDeleted?.();
|
||||
}
|
||||
|
||||
async function saveTitle(next: string) {
|
||||
await inboxApi.updateAiFields(note.id, { title: next });
|
||||
const updated = { ...local, aiTitle: next, titleEditedByUser: true };
|
||||
@@ -145,6 +154,17 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
|
||||
setLocal(updated); onUpdated(updated);
|
||||
}
|
||||
|
||||
async function saveRaw() {
|
||||
const next = draftRaw;
|
||||
if (next.trim().length === 0) return;
|
||||
const r = await inboxApi.updateRawText(note.id, next);
|
||||
if (!r.ok) return;
|
||||
const updated = { ...local, rawText: next, updatedAt: new Date().toISOString() };
|
||||
setLocal(updated);
|
||||
onUpdated(updated);
|
||||
setEditingRaw(false);
|
||||
}
|
||||
|
||||
async function removeTag(tagName: string) {
|
||||
const removed = local.tags.find((t) => t.name === tagName);
|
||||
const nextTagNames = local.tags.filter((t) => t.name !== tagName).map((t) => t.name);
|
||||
@@ -209,6 +229,13 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
|
||||
정리 보류 — 원문은 안전합니다
|
||||
</div>
|
||||
)}
|
||||
{/* v0.2.9 Cut B Task 13 — ai_status='disabled': raw_text 첫 줄 fallback title.
|
||||
summary/tags 는 hide. 원문은 아래 "원문 보기" 영역에서 항상 표시. */}
|
||||
{isAiDisabled && (
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 600 }}>{fallbackTitle}</h3>
|
||||
</div>
|
||||
)}
|
||||
{local.aiStatus === 'done' && (
|
||||
<>
|
||||
{isTrash ? (
|
||||
@@ -332,9 +359,24 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
|
||||
)}
|
||||
|
||||
{local.media.length > 0 && (
|
||||
<div style={{ marginTop: 10, display: 'flex', gap: 6 }}>
|
||||
<div style={{ marginTop: 10, display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{local.media.map((m) => (
|
||||
<div key={m.id} style={{ width: 48, height: 48, background: '#eee', borderRadius: 4 }} title={m.relPath} />
|
||||
// alt="" — decorative (relPath 는 사용자 의미 X). title 이 hover tooltip.
|
||||
<img
|
||||
key={m.id}
|
||||
src={`inkling-media://${m.relPath}`}
|
||||
alt=""
|
||||
title={m.relPath}
|
||||
onClick={() => { void inboxApi.openMedia(m.relPath); }}
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
objectFit: 'cover',
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
border: '1px solid #e0e0e0'
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -344,40 +386,156 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
|
||||
{rawOpen ? '▾ 원문 접기' : '▸ 원문 보기'}
|
||||
</button>
|
||||
{rawOpen && (
|
||||
<pre style={{ marginTop: 6, whiteSpace: 'pre-wrap', fontSize: 12, color: '#555', background: '#fafafa', padding: 8, borderRadius: 4 }}>
|
||||
{local.rawText}
|
||||
</pre>
|
||||
<div style={{ marginTop: 6 }}>
|
||||
{editingRaw ? (
|
||||
<div>
|
||||
<textarea
|
||||
aria-label="원문 편집"
|
||||
value={draftRaw}
|
||||
onChange={(e) => setDraftRaw(e.target.value)}
|
||||
style={{ width: '100%', minHeight: 80, fontSize: 12, fontFamily: 'inherit', padding: 8, border: '1px solid #ddd', borderRadius: 4, boxSizing: 'border-box' }}
|
||||
/>
|
||||
<div style={{ marginTop: 4, display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
|
||||
<button onClick={() => setEditingRaw(false)} style={{ background: 'none', border: '1px solid #ccc', color: '#444', cursor: 'pointer', fontSize: 12, padding: '3px 10px', borderRadius: 4 }}>취소</button>
|
||||
<button onClick={() => { void saveRaw(); }} style={{ background: '#0a4b80', border: 'none', color: '#fff', cursor: 'pointer', fontSize: 12, padding: '3px 10px', borderRadius: 4 }}>저장</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', fontSize: 12, color: '#555', background: '#fafafa', padding: 8, borderRadius: 4 }}>
|
||||
{local.rawText}
|
||||
</pre>
|
||||
<div style={{ marginTop: 4, display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
|
||||
<button onClick={() => setShowRevisions(true)} style={{ background: 'none', border: 'none', color: '#0a4b80', cursor: 'pointer', fontSize: 12, padding: 0 }}>이력</button>
|
||||
<button onClick={() => { setDraftRaw(local.rawText); setEditingRaw(true); }} style={{ background: 'none', border: '1px solid #ccc', color: '#444', cursor: 'pointer', fontSize: 12, padding: '3px 10px', borderRadius: 4 }}>편집</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 10, textAlign: 'right' }}>
|
||||
{isTrash ? (
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
{/* v0.2.9 Cut B Task 6 — 모든 view 공통 "이동 ▾" dropdown.
|
||||
현재 status 와 다른 3개 목적지만 표시. */}
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button
|
||||
onClick={onRestore}
|
||||
onClick={() => setMenuOpen((o) => !o)}
|
||||
aria-label="이동"
|
||||
style={{
|
||||
background: 'none', border: '1px solid #0a4b80', color: '#0a4b80',
|
||||
cursor: 'pointer', fontSize: 12, padding: '4px 10px', borderRadius: 4
|
||||
background: 'none',
|
||||
border: '1px solid #ccc',
|
||||
color: '#444',
|
||||
cursor: 'pointer',
|
||||
fontSize: 12,
|
||||
padding: '4px 10px',
|
||||
borderRadius: 4
|
||||
}}
|
||||
>
|
||||
🔄 복구
|
||||
</button>
|
||||
<button
|
||||
onClick={onPermanentDelete}
|
||||
style={{
|
||||
background: 'none', border: '1px solid #c93030', color: '#c93030',
|
||||
cursor: 'pointer', fontSize: 12, padding: '4px 10px', borderRadius: 4
|
||||
}}
|
||||
>
|
||||
🗑 영구 삭제
|
||||
이동 ▾
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: '100%',
|
||||
marginTop: 2,
|
||||
background: '#fff',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: 4,
|
||||
padding: 4,
|
||||
zIndex: 10,
|
||||
minWidth: 140,
|
||||
boxShadow: '0 2px 6px rgba(0,0,0,0.08)'
|
||||
}}
|
||||
>
|
||||
{possibleTargets.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => {
|
||||
setMoveTarget(t);
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
textAlign: 'left',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: '6px 8px',
|
||||
fontSize: 12,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{statusLabelWithParticle(t)} 이동
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => void handleDelete()} style={{ background: 'none', border: 'none', color: '#c93030', cursor: 'pointer', fontSize: 12 }}>
|
||||
🗑 삭제
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* trash mode 만 영구 삭제 + 복구 보존 (휴지통 단독 액션). */}
|
||||
{isTrash && (
|
||||
<>
|
||||
<button
|
||||
onClick={onRestore}
|
||||
style={{
|
||||
background: 'none', border: '1px solid #0a4b80', color: '#0a4b80',
|
||||
cursor: 'pointer', fontSize: 12, padding: '4px 10px', borderRadius: 4
|
||||
}}
|
||||
>
|
||||
🔄 복구
|
||||
</button>
|
||||
<button
|
||||
onClick={onPermanentDelete}
|
||||
style={{
|
||||
background: 'none', border: '1px solid #c93030', color: '#c93030',
|
||||
cursor: 'pointer', fontSize: 12, padding: '4px 10px', borderRadius: 4
|
||||
}}
|
||||
>
|
||||
🗑 영구 삭제
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{moveTarget !== null && (
|
||||
<MoveStatusModal
|
||||
noteId={local.id}
|
||||
rawText={local.rawText}
|
||||
summary={local.aiSummary ?? ''}
|
||||
onClose={() => setMoveTarget(null)}
|
||||
onMoved={(newStatus, reason) => {
|
||||
const updated = { ...local, status: newStatus, moveReason: reason };
|
||||
setLocal(updated);
|
||||
onUpdated(updated);
|
||||
// inbox/trash mode 의 list 가 status 별로 필터되므로 onDeleted (제거) 도 호출.
|
||||
if (newStatus !== local.status) onDeleted?.();
|
||||
setMoveTarget(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showRevisions && (
|
||||
<RevisionHistoryModal
|
||||
noteId={local.id}
|
||||
onClose={() => setShowRevisions(false)}
|
||||
onRestored={(newRawText) => {
|
||||
const updated = { ...local, rawText: newRawText, updatedAt: new Date().toISOString() };
|
||||
setLocal(updated);
|
||||
onUpdated(updated);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,8 +7,11 @@ interface OllamaBannerProps {
|
||||
}
|
||||
|
||||
export function OllamaBanner({ onOpenSettings }: OllamaBannerProps = {}): React.ReactElement | null {
|
||||
const aiEnabled = useInbox((s) => s.ai_enabled);
|
||||
const status = useInbox((s) => s.ollamaStatus);
|
||||
const recheckOllama = useInbox((s) => s.recheckOllama);
|
||||
// v0.2.9 Cut B Task 14 — AI-less mode 에서는 banner 자체 비활성.
|
||||
if (!aiEnabled) return null;
|
||||
if (status.ok) return null;
|
||||
const isMissing = status.reason?.includes('not installed');
|
||||
const message = isMissing
|
||||
|
||||
42
src/renderer/inbox/components/OnboardingWizard.tsx
Normal file
42
src/renderer/inbox/components/OnboardingWizard.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import { inboxApi } from '../api.js';
|
||||
|
||||
/**
|
||||
* v0.2.9 Cut B Task 11 — 첫 launch onboarding 위저드.
|
||||
*
|
||||
* 3 옵션 (AI 사용 / 원문만 / 나중에) 중 하나를 선택. AI 옵션 (true/false) 은
|
||||
* setAiEnabled 로 settings 에 저장, 모든 옵션은 setOnboardingCompleted(true) 로
|
||||
* 두 번째 launch 부터 미노출. "나중에" 는 ai_enabled 기본값 (true) 유지 — 사용자
|
||||
* 가 SettingsPage 에서 추후 선택 가능.
|
||||
*/
|
||||
export function OnboardingWizard({ onClose }: { onClose: () => void }): React.ReactElement {
|
||||
async function choose(aiEnabled: boolean | null): Promise<void> {
|
||||
if (aiEnabled !== null) await inboxApi.setAiEnabled(aiEnabled);
|
||||
await inboxApi.setOnboardingCompleted(true);
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<div role="dialog" aria-label="시작 안내" style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000
|
||||
}}>
|
||||
<div style={{ background: '#fff', padding: 24, borderRadius: 8, maxWidth: 520 }}>
|
||||
<h2 style={{ margin: '0 0 12px' }}>Inkling 사용 시작</h2>
|
||||
<p style={{ fontSize: 14, lineHeight: 1.6, marginBottom: 12 }}>
|
||||
Inkling 은 로컬 LLM (Ollama) 으로 메모를 자동 정리합니다.
|
||||
Ollama 가 설치되어 있고 한국어 지원 모델 (gemma3, gemma2 등) 이 pull 되어 있어야 최적의 경험이 가능합니다.
|
||||
</p>
|
||||
<p style={{ fontSize: 13, marginBottom: 16 }}>
|
||||
설치 가이드:
|
||||
<a href="https://ollama.com/download" target="_blank" rel="noopener noreferrer">ollama.com/download</a>
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<button onClick={() => choose(true)}>AI 자동 처리 사용 (Ollama 필요)</button>
|
||||
<button onClick={() => choose(false)}>원문만 저장 (AI 처리 안 함)</button>
|
||||
<button onClick={() => choose(null)} style={{ marginTop: 4 }}>나중에 설정</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
56
src/renderer/inbox/components/ReviewView.tsx
Normal file
56
src/renderer/inbox/components/ReviewView.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import React from 'react';
|
||||
import { useInbox } from '../store.js';
|
||||
import { NoteCard } from './NoteCard.js';
|
||||
|
||||
interface Props {
|
||||
period: 'daily' | 'weekly' | 'monthly';
|
||||
}
|
||||
|
||||
const periodLabel: Record<Props['period'], string> = {
|
||||
daily: '일간',
|
||||
weekly: '주간',
|
||||
monthly: '월간'
|
||||
};
|
||||
|
||||
export function ReviewView({ period }: Props): React.ReactElement {
|
||||
const reviewData = useInbox((s) => s.reviewData);
|
||||
if (!reviewData) {
|
||||
return <div style={{ padding: 16, fontSize: 13, color: '#666' }}>불러오는 중…</div>;
|
||||
}
|
||||
const max = reviewData.tagCounts[0]?.count ?? 1;
|
||||
return (
|
||||
<div style={{ padding: 16 }}>
|
||||
<h2 style={{ fontSize: 18, margin: 0 }}>{periodLabel[period]} 회고</h2>
|
||||
<div style={{ marginTop: 8, fontSize: 13, color: '#444' }}>
|
||||
총 {reviewData.totalCount}건
|
||||
</div>
|
||||
<section style={{ marginTop: 16 }}>
|
||||
<h3 style={{ fontSize: 14, marginBottom: 4 }}>태그 분포</h3>
|
||||
{reviewData.tagCounts.length === 0 && (
|
||||
<div style={{ fontSize: 12, color: '#888' }}>태그 없음</div>
|
||||
)}
|
||||
{reviewData.tagCounts.slice(0, 10).map((t) => (
|
||||
<div key={t.tag} style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 2 }}>
|
||||
<span style={{ fontSize: 12, width: 80 }}>{t.tag}</span>
|
||||
<div style={{ flex: 1, background: '#eee', height: 8, borderRadius: 2 }}>
|
||||
<div style={{ width: `${(t.count / max) * 100}%`, background: '#4ec5b8', height: 8, borderRadius: 2 }} />
|
||||
</div>
|
||||
<span style={{ fontSize: 12, color: '#666', width: 30, textAlign: 'right' }}>{t.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
<section style={{ marginTop: 16 }}>
|
||||
<h3 style={{ fontSize: 14, marginBottom: 4 }}>마감 진행</h3>
|
||||
<div style={{ fontSize: 13, color: '#444' }}>
|
||||
완료 {reviewData.dueProgress.passed} / {reviewData.dueProgress.total} · 대기 {reviewData.dueProgress.pending}
|
||||
</div>
|
||||
</section>
|
||||
<section style={{ marginTop: 16 }}>
|
||||
<h3 style={{ fontSize: 14, marginBottom: 4 }}>최근 노트 ({reviewData.recentNotes.length})</h3>
|
||||
{reviewData.recentNotes.map((n) => (
|
||||
<NoteCard key={n.id} note={n} mode="inbox" onUpdated={() => {}} />
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
95
src/renderer/inbox/components/RevisionHistoryModal.tsx
Normal file
95
src/renderer/inbox/components/RevisionHistoryModal.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import type { NoteRevision } from '@shared/types';
|
||||
import { inboxApi } from '../api.js';
|
||||
|
||||
interface Props {
|
||||
noteId: string;
|
||||
onClose: () => void;
|
||||
/** 회수 성공 후 부모 (NoteCard) 가 local rawText 를 갱신하도록 통지. */
|
||||
onRestored: (newRawText: string) => void;
|
||||
}
|
||||
|
||||
const overlayStyle: React.CSSProperties = {
|
||||
position: 'fixed', top: 0, left: 0, width: '100vw', height: '100vh',
|
||||
background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'center', zIndex: 100
|
||||
};
|
||||
|
||||
const modalStyle: React.CSSProperties = {
|
||||
background: '#fff', borderRadius: 8, padding: 20, width: 520,
|
||||
maxHeight: '70vh', overflow: 'auto', boxShadow: '0 4px 16px rgba(0,0,0,0.2)'
|
||||
};
|
||||
|
||||
const rowStyle: React.CSSProperties = {
|
||||
border: '1px solid #eee', borderRadius: 6, padding: 10, marginTop: 8
|
||||
};
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleString('ko-KR');
|
||||
}
|
||||
|
||||
function editedByLabel(by: 'user' | 'capture'): string {
|
||||
return by === 'capture' ? '캡처' : '사용자';
|
||||
}
|
||||
|
||||
export function RevisionHistoryModal({ noteId, onClose, onRestored }: Props): React.ReactElement {
|
||||
const [revs, setRevs] = useState<NoteRevision[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const r = await inboxApi.listRevisions(noteId);
|
||||
if (!cancelled) setRevs(r);
|
||||
} catch (e) {
|
||||
if (!cancelled) setError((e as Error).message);
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [noteId]);
|
||||
|
||||
async function onRestore(rev: NoteRevision) {
|
||||
if (!window.confirm('이 버전으로 되돌릴까요? 현재 본문도 이력에 보존됩니다.')) return;
|
||||
const r = await inboxApi.restoreRevision(noteId, rev.revId);
|
||||
if (!r.ok) {
|
||||
setError(r.reason ?? '복원 실패');
|
||||
return;
|
||||
}
|
||||
onRestored(rev.rawText);
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={overlayStyle} onClick={onClose}>
|
||||
<div style={modalStyle} onClick={(e) => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h3 style={{ margin: 0, fontSize: 16 }}>이력 ({revs.length}건)</h3>
|
||||
<button onClick={onClose} aria-label="닫기" style={{ background: 'none', border: 'none', fontSize: 18, cursor: 'pointer', color: '#888' }}>×</button>
|
||||
</div>
|
||||
{loading && <div style={{ marginTop: 10, fontSize: 12, color: '#888' }}>불러오는 중…</div>}
|
||||
{error !== null && <div style={{ marginTop: 10, fontSize: 12, color: '#c93030' }}>{error}</div>}
|
||||
{!loading && revs.map((rev) => (
|
||||
<div key={rev.revId} style={rowStyle}>
|
||||
<div style={{ fontSize: 11, color: '#888', display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span>{formatDate(rev.editedAt)} · {editedByLabel(rev.editedBy)}</span>
|
||||
<button
|
||||
onClick={() => { void onRestore(rev); }}
|
||||
aria-label="회수"
|
||||
style={{ background: 'none', border: '1px solid #0a4b80', color: '#0a4b80', cursor: 'pointer', fontSize: 11, padding: '2px 8px', borderRadius: 3 }}
|
||||
>
|
||||
회수
|
||||
</button>
|
||||
</div>
|
||||
<pre style={{ margin: '6px 0 0 0', whiteSpace: 'pre-wrap', fontSize: 12, color: '#444', background: '#fafafa', padding: 6, borderRadius: 3 }}>
|
||||
{rev.rawText}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
src/renderer/inbox/components/SearchBox.tsx
Normal file
34
src/renderer/inbox/components/SearchBox.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useInbox } from '../store.js';
|
||||
|
||||
export function SearchBox(): React.ReactElement {
|
||||
const [draft, setDraft] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(() => {
|
||||
const trimmed = draft.trim();
|
||||
if (trimmed.length === 0) useInbox.getState().clearSearch();
|
||||
else void useInbox.getState().searchNotes(trimmed);
|
||||
}, 200);
|
||||
return () => clearTimeout(handle);
|
||||
}, [draft]);
|
||||
|
||||
return (
|
||||
<input
|
||||
type="search"
|
||||
role="searchbox"
|
||||
placeholder="검색…"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
aria-label="노트 검색"
|
||||
style={{
|
||||
marginLeft: 12,
|
||||
padding: '4px 8px',
|
||||
fontSize: 12,
|
||||
border: '1px solid #bbb',
|
||||
borderRadius: 4,
|
||||
width: 200
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { AiProviderSection } from './settings/AiProviderSection.js';
|
||||
import { AutostartSection } from './settings/AutostartSection.js';
|
||||
import { BackupSection } from './settings/BackupSection.js';
|
||||
import { InfoSection } from './settings/InfoSection.js';
|
||||
import { SyncSection } from './settings/SyncSection.js';
|
||||
|
||||
export function SettingsPage(): React.ReactElement {
|
||||
const setShowSettings = useInbox((s) => s.setShowSettings);
|
||||
@@ -40,6 +41,10 @@ export function SettingsPage(): React.ReactElement {
|
||||
<h2 style={{ fontSize: 14, marginBottom: 8 }}>정보</h2>
|
||||
<InfoSection />
|
||||
</section>
|
||||
<section style={{ marginBottom: 24 }}>
|
||||
<h2 style={{ fontSize: 14, marginBottom: 8 }}>동기화</h2>
|
||||
<SyncSection />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@ export function AiProviderSection(): React.ReactElement {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saveResult, setSaveResult] = useState<string | null>(null);
|
||||
const [recheckResult, setRecheckResult] = useState<string | null>(null);
|
||||
// v0.2.9 Cut B Task 15-16: AI 자동 처리 토글 + disabled 메모 일괄 처리.
|
||||
const [aiEnabled, setAiEnabledState] = useState<boolean | null>(null);
|
||||
const [disabledCount, setDisabledCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
@@ -18,9 +21,32 @@ export function AiProviderSection(): React.ReactElement {
|
||||
setEndpoint(s.endpoint);
|
||||
setModel(s.model);
|
||||
}
|
||||
const settings = await inboxApi.getSettings();
|
||||
const enabled = settings.ai_enabled ?? true;
|
||||
setAiEnabledState(enabled);
|
||||
if (enabled) {
|
||||
const c = await inboxApi.getDisabledCount();
|
||||
setDisabledCount(c);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
async function onToggleAi(checked: boolean): Promise<void> {
|
||||
await inboxApi.setAiEnabled(checked);
|
||||
setAiEnabledState(checked);
|
||||
if (checked) {
|
||||
const c = await inboxApi.getDisabledCount();
|
||||
setDisabledCount(c);
|
||||
} else {
|
||||
setDisabledCount(0);
|
||||
}
|
||||
}
|
||||
|
||||
async function onProcessDisabled(): Promise<void> {
|
||||
await inboxApi.enqueueDisabled();
|
||||
setDisabledCount(0);
|
||||
}
|
||||
|
||||
async function onSave(): Promise<void> {
|
||||
const r = endpointSchema.safeParse(endpoint);
|
||||
if (!r.success) {
|
||||
@@ -51,6 +77,46 @@ export function AiProviderSection(): React.ReactElement {
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* v0.2.9 Cut B Task 15 — AI 자동 처리 토글 (가장 위, 스위치 의미가 가장 큰 결정) */}
|
||||
{aiEnabled !== null && (
|
||||
<label style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 12, fontSize: 13 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={aiEnabled}
|
||||
onChange={(e) => void onToggleAi(e.target.checked)}
|
||||
/>
|
||||
AI 자동 처리 사용
|
||||
</label>
|
||||
)}
|
||||
{aiEnabled === false && (
|
||||
<p style={{ fontSize: 12, color: '#666', marginBottom: 12 }}>
|
||||
원문만 저장 모드. 메모의 제목/요약/태그가 자동 생성되지 않습니다.<br />
|
||||
<a href="https://ollama.com/download" target="_blank" rel="noopener noreferrer">
|
||||
Ollama 설치 가이드
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
{/* v0.2.9 Cut B Task 16 — ON 전환 후 disabled 메모 일괄 처리 prompt */}
|
||||
{aiEnabled === true && disabledCount > 0 && (
|
||||
<div style={{ padding: 8, background: '#fffbe5', borderRadius: 4, marginBottom: 12, fontSize: 13 }}>
|
||||
원문만 저장된 메모 {disabledCount}건이 있습니다.
|
||||
<button
|
||||
onClick={() => void onProcessDisabled()}
|
||||
style={{
|
||||
marginLeft: 8,
|
||||
background: '#0a4b80',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 4,
|
||||
padding: '4px 10px',
|
||||
fontSize: 12,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
지금 모두 처리
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<label style={{ display: 'block', marginBottom: 8, fontSize: 12, color: '#666' }}>
|
||||
Endpoint
|
||||
<input
|
||||
|
||||
150
src/renderer/inbox/components/settings/SyncSection.tsx
Normal file
150
src/renderer/inbox/components/settings/SyncSection.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { inboxApi } from '../../api.js';
|
||||
import type { SyncStatusSnapshot } from '@shared/types';
|
||||
import { ConflictModal } from '../ConflictModal.js';
|
||||
|
||||
export function SyncSection(): React.ReactElement {
|
||||
const [url, setUrl] = useState('');
|
||||
const [draftUrl, setDraftUrl] = useState('');
|
||||
const [autoEnabled, setAutoEnabled] = useState(true);
|
||||
const [intervalMin, setIntervalMin] = useState(30);
|
||||
const [status, setStatus] = useState<SyncStatusSnapshot | null>(null);
|
||||
const [busy, setBusy] = useState<'save' | 'test' | 'sync' | null>(null);
|
||||
const [feedback, setFeedback] = useState<string | null>(null);
|
||||
const [showConflict, setShowConflict] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
const s = await inboxApi.getSettings();
|
||||
const u = s.sync_repo_url ?? '';
|
||||
setUrl(u);
|
||||
setDraftUrl(u);
|
||||
setAutoEnabled(s.sync_auto_enabled ?? true);
|
||||
setIntervalMin(s.sync_interval_min ?? 30);
|
||||
setStatus(await inboxApi.getSyncStatus());
|
||||
})();
|
||||
}, []);
|
||||
|
||||
async function onSaveUrl() {
|
||||
setBusy('save');
|
||||
setFeedback(null);
|
||||
const r = await inboxApi.configureSync(draftUrl.trim() === '' ? null : draftUrl.trim());
|
||||
setBusy(null);
|
||||
if (r.ok) {
|
||||
setUrl(draftUrl.trim());
|
||||
setFeedback('저장되었습니다');
|
||||
} else {
|
||||
setFeedback(`저장 실패: ${r.reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function onTestConnection() {
|
||||
setBusy('test');
|
||||
setFeedback(null);
|
||||
const r = await inboxApi.testSyncConnection();
|
||||
setBusy(null);
|
||||
setFeedback(r.ok ? '연결 성공' : `연결 실패: ${r.reason}`);
|
||||
}
|
||||
|
||||
async function onToggleAuto(next: boolean) {
|
||||
await inboxApi.setSyncAutoEnabled(next);
|
||||
setAutoEnabled(next);
|
||||
}
|
||||
|
||||
async function onChangeInterval(value: number) {
|
||||
if (!Number.isInteger(value) || value < 5) return;
|
||||
const r = await inboxApi.setSyncIntervalMin(value);
|
||||
if (r.ok) setIntervalMin(value);
|
||||
}
|
||||
|
||||
const conflictCount = status?.lastResult?.conflicts?.length ?? 0;
|
||||
|
||||
return (
|
||||
<section style={{ marginTop: 24 }}>
|
||||
<h3 style={{ fontSize: 14, marginBottom: 8 }}>동기화 저장소</h3>
|
||||
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: 8 }}>
|
||||
<input
|
||||
type="text"
|
||||
aria-label="저장소 URL"
|
||||
placeholder="git@host:user/inkling-notes.git"
|
||||
value={draftUrl}
|
||||
onChange={(e) => setDraftUrl(e.target.value)}
|
||||
style={{ flex: 1, fontSize: 12, padding: '4px 8px', border: '1px solid #ccc', borderRadius: 4 }}
|
||||
/>
|
||||
<button onClick={() => { void onSaveUrl(); }} disabled={busy !== null} style={btnStyle()}>
|
||||
{busy === 'save' ? '저장 중…' : '저장'}
|
||||
</button>
|
||||
<button onClick={() => { void onTestConnection(); }} disabled={busy !== null || url.trim() === ''} style={btnStyle()}>
|
||||
{busy === 'test' ? '확인 중…' : '연결 테스트'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{feedback !== null && (
|
||||
<div style={{ fontSize: 12, color: '#444', marginBottom: 8 }}>{feedback}</div>
|
||||
)}
|
||||
|
||||
{url.trim() !== '' && (
|
||||
<>
|
||||
<div style={{ fontSize: 12, color: '#666', marginBottom: 8 }}>
|
||||
마지막 sync: {status?.lastAt ?? '없음'} {status?.lastResult?.ok === false && status?.lastResult?.reason !== 'conflict' && (
|
||||
<span style={{ color: '#a55' }}> ({status.lastResult.reason})</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, marginBottom: 6 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoEnabled}
|
||||
onChange={(e) => { void onToggleAuto(e.target.checked); }}
|
||||
/>
|
||||
자동 sync 사용
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, marginBottom: 8 }}>
|
||||
interval:
|
||||
<input
|
||||
type="number"
|
||||
aria-label="sync interval (분)"
|
||||
min={5}
|
||||
value={intervalMin}
|
||||
onChange={(e) => { void onChangeInterval(Number.parseInt(e.target.value, 10)); }}
|
||||
disabled={!autoEnabled}
|
||||
style={{ width: 60, fontSize: 12, padding: '2px 4px' }}
|
||||
/>
|
||||
분
|
||||
</label>
|
||||
|
||||
{conflictCount > 0 && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<button onClick={() => setShowConflict(true)} style={btnStyle()}>
|
||||
충돌 해결… ({conflictCount}건)
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showConflict && (
|
||||
<ConflictModal
|
||||
onClose={() => setShowConflict(false)}
|
||||
onResolved={async () => {
|
||||
setStatus(await inboxApi.getSyncStatus());
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function btnStyle(): React.CSSProperties {
|
||||
return {
|
||||
background: '#0a4b80',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: 12,
|
||||
padding: '4px 10px',
|
||||
borderRadius: 4
|
||||
};
|
||||
}
|
||||
@@ -1,16 +1,32 @@
|
||||
import { create } from 'zustand';
|
||||
import type { Note, WeeklyContinuity } from '@shared/types';
|
||||
import type { Note, ReviewAggregate, WeeklyContinuity } from '@shared/types';
|
||||
import { inboxApi } from './api.js';
|
||||
import { nextKstMidnightMs } from '@shared/util/kstDate.js';
|
||||
|
||||
export { selectFilteredNotes } from './selectFilteredNotes.js';
|
||||
|
||||
// v0.2.9 Cut B Task 4 — 4탭 view enum + settings.
|
||||
// 'inbox' = active, 'completed'/'archived' = NoteStatus 그대로, 'trash' = trashed (mirror), 'settings' = SettingsPage.
|
||||
export type InboxView =
|
||||
| 'inbox' | 'completed' | 'archived' | 'trash' | 'settings'
|
||||
| 'review-daily' | 'review-weekly' | 'review-monthly';
|
||||
|
||||
export interface InboxCounts {
|
||||
active: number;
|
||||
completed: number;
|
||||
archived: number;
|
||||
trashed: number;
|
||||
}
|
||||
|
||||
interface InboxState {
|
||||
notes: Note[];
|
||||
trashNotes: Note[];
|
||||
trashCount: number;
|
||||
showTrash: boolean;
|
||||
showSettings: boolean;
|
||||
// v0.2.9 Cut B Task 4 — view enum + counts. showTrash/showSettings 는 mirror 로 잠시 잔류.
|
||||
view: InboxView;
|
||||
counts: InboxCounts;
|
||||
continuity: WeeklyContinuity;
|
||||
pendingCount: number;
|
||||
ollamaStatus: { ok: boolean; reason?: string };
|
||||
@@ -22,12 +38,21 @@ interface InboxState {
|
||||
failedCount: number;
|
||||
recallCandidate: Note | null;
|
||||
recallSnoozeUntilMs: number | null;
|
||||
// v0.2.9 Cut B Task 14 — AI 비활성 모드에서는 OllamaBanner/FailedBanner render skip.
|
||||
// 기본 true (기존 사용자 무영향). loadInitial / refreshMeta 가 settings 로드.
|
||||
ai_enabled: boolean;
|
||||
// v0.2.11 Cut D — FTS5 search + review aggregate state.
|
||||
searchQuery: string;
|
||||
searchResults: Note[] | null; // null = 검색 안 한 상태
|
||||
reviewData: ReviewAggregate | null;
|
||||
loadInitial: () => Promise<void>;
|
||||
refreshMeta: () => Promise<void>;
|
||||
upsertNote: (note: Note) => void;
|
||||
removeNote: (id: string) => void;
|
||||
setTagFilter: (tag: string | null) => void;
|
||||
setShowSettings: (open: boolean) => void;
|
||||
setView: (view: InboxView) => void;
|
||||
loadByView: (view: 'completed' | 'archived' | 'trash') => Promise<void>;
|
||||
toggleShowTrash: () => Promise<void>;
|
||||
loadTrash: () => Promise<void>;
|
||||
restoreNote: (id: string) => Promise<void>;
|
||||
@@ -42,6 +67,11 @@ interface InboxState {
|
||||
openRecall: (id: string) => Promise<void>;
|
||||
dismissRecallNote: (id: string) => Promise<void>;
|
||||
snoozeRecall: () => Promise<void>;
|
||||
// v0.2.11 Cut D — search + review actions.
|
||||
setSearchQuery: (q: string) => void;
|
||||
searchNotes: (q: string) => Promise<void>;
|
||||
clearSearch: () => void;
|
||||
loadReview: (period: 'daily' | 'weekly' | 'monthly') => Promise<void>;
|
||||
}
|
||||
|
||||
const emptyContinuity: WeeklyContinuity = {
|
||||
@@ -55,6 +85,8 @@ export const useInbox = create<InboxState>((set, get) => ({
|
||||
trashCount: 0,
|
||||
showTrash: false,
|
||||
showSettings: false,
|
||||
view: 'inbox',
|
||||
counts: { active: 0, completed: 0, archived: 0, trashed: 0 },
|
||||
continuity: emptyContinuity,
|
||||
pendingCount: 0,
|
||||
ollamaStatus: { ok: true },
|
||||
@@ -66,9 +98,13 @@ export const useInbox = create<InboxState>((set, get) => ({
|
||||
failedCount: 0,
|
||||
recallCandidate: null,
|
||||
recallSnoozeUntilMs: null,
|
||||
ai_enabled: true,
|
||||
searchQuery: '',
|
||||
searchResults: null,
|
||||
reviewData: null,
|
||||
async loadInitial() {
|
||||
set({ loading: true });
|
||||
const [notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate] = await Promise.all([
|
||||
const [notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, settings] = await Promise.all([
|
||||
inboxApi.listNotes({ limit: 50 }),
|
||||
inboxApi.getContinuity(),
|
||||
inboxApi.getPendingCount(),
|
||||
@@ -77,12 +113,14 @@ export const useInbox = create<InboxState>((set, get) => ({
|
||||
inboxApi.getTrashCount(),
|
||||
inboxApi.listExpired(),
|
||||
inboxApi.getFailedCount(),
|
||||
inboxApi.listRecallCandidate()
|
||||
inboxApi.listRecallCandidate(),
|
||||
inboxApi.countsByStatus(),
|
||||
inboxApi.getSettings()
|
||||
]);
|
||||
set({ notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, loading: false });
|
||||
set({ notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, ai_enabled: settings.ai_enabled ?? true, loading: false });
|
||||
},
|
||||
async refreshMeta() {
|
||||
const [continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate] = await Promise.all([
|
||||
const [continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, settings] = await Promise.all([
|
||||
inboxApi.getContinuity(),
|
||||
inboxApi.getPendingCount(),
|
||||
inboxApi.getOllamaStatus(),
|
||||
@@ -90,9 +128,11 @@ export const useInbox = create<InboxState>((set, get) => ({
|
||||
inboxApi.getTrashCount(),
|
||||
inboxApi.listExpired(),
|
||||
inboxApi.getFailedCount(),
|
||||
inboxApi.listRecallCandidate()
|
||||
inboxApi.listRecallCandidate(),
|
||||
inboxApi.countsByStatus(),
|
||||
inboxApi.getSettings()
|
||||
]);
|
||||
set({ continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate });
|
||||
set({ continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, ai_enabled: settings.ai_enabled ?? true });
|
||||
},
|
||||
upsertNote(note) {
|
||||
// trashCount 는 server-authoritative. trashNotes 가 cache-loaded (showTrash=true) 일
|
||||
@@ -138,7 +178,33 @@ export const useInbox = create<InboxState>((set, get) => ({
|
||||
set({ tagFilter: tag });
|
||||
},
|
||||
setShowSettings(open) {
|
||||
set({ showSettings: open });
|
||||
// backward-compat — setView 로 위임. mirror state (view, showTrash, showSettings) 동기 갱신.
|
||||
if (open) get().setView('settings');
|
||||
else get().setView('inbox');
|
||||
},
|
||||
setView(view) {
|
||||
set({
|
||||
view,
|
||||
showTrash: view === 'trash',
|
||||
showSettings: view === 'settings'
|
||||
});
|
||||
// settings/inbox 외 status view 면 해당 status fetch.
|
||||
if (view === 'completed' || view === 'archived' || view === 'trash') {
|
||||
void get().loadByView(view);
|
||||
}
|
||||
// v0.2.11 Cut D — review-* view 진입 시 aggregate 로드.
|
||||
if (view === 'review-daily') void get().loadReview('daily');
|
||||
if (view === 'review-weekly') void get().loadReview('weekly');
|
||||
if (view === 'review-monthly') void get().loadReview('monthly');
|
||||
},
|
||||
async loadByView(view) {
|
||||
const status = view === 'trash' ? 'trashed' : view;
|
||||
const notes = await inboxApi.listByStatus(status, { limit: 200 });
|
||||
if (view === 'trash') {
|
||||
set({ trashNotes: notes, trashCount: notes.length });
|
||||
} else {
|
||||
set({ notes });
|
||||
}
|
||||
},
|
||||
async toggleShowTrash() {
|
||||
const next = !get().showTrash;
|
||||
@@ -221,5 +287,30 @@ export const useInbox = create<InboxState>((set, get) => ({
|
||||
if (candidate) {
|
||||
await inboxApi.emitRecallSnoozed(candidate.id);
|
||||
}
|
||||
},
|
||||
// v0.2.11 Cut D — FTS5 search + review aggregate actions.
|
||||
setSearchQuery(q) {
|
||||
set({ searchQuery: q });
|
||||
if (q.trim().length === 0) set({ searchResults: null });
|
||||
},
|
||||
async searchNotes(q) {
|
||||
if (q.trim().length === 0) {
|
||||
set({ searchResults: null });
|
||||
return;
|
||||
}
|
||||
const view = get().view;
|
||||
// 회고/설정 view 일 때는 status filter 무의미 → 그대로 전체 검색
|
||||
const status = view === 'completed' || view === 'archived' || view === 'trash'
|
||||
? (view === 'trash' ? 'trashed' : view)
|
||||
: view === 'inbox' ? 'active' : undefined;
|
||||
const r = await inboxApi.search(q, status ? { status } : {});
|
||||
set({ searchResults: r });
|
||||
},
|
||||
clearSearch() {
|
||||
set({ searchQuery: '', searchResults: null });
|
||||
},
|
||||
async loadReview(period) {
|
||||
const data = await inboxApi.reviewAggregate(period);
|
||||
set({ reviewData: data });
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -11,13 +11,60 @@ export interface NoteMedia {
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
export type AiStatus = 'pending' | 'done' | 'failed';
|
||||
export type AiStatus = 'pending' | 'done' | 'failed' | 'disabled';
|
||||
|
||||
// v0.2.9 Cut B — 노트 status 4분기 (사용자 액션). m004 마이그레이션 + setStatus.
|
||||
export type NoteStatus = 'active' | 'completed' | 'archived' | 'trashed';
|
||||
|
||||
export interface NoteTag {
|
||||
name: string;
|
||||
source: 'ai' | 'user';
|
||||
}
|
||||
|
||||
// v0.2.10 Cut C — note_revisions 테이블 row.
|
||||
// 'capture' = 최초 캡처 시점, 'user' = 사용자가 raw_text 정정한 시점.
|
||||
export interface NoteRevision {
|
||||
revId: number;
|
||||
noteId: string;
|
||||
rawText: string;
|
||||
editedAt: string;
|
||||
editedBy: 'user' | 'capture';
|
||||
}
|
||||
|
||||
// v0.2.11 Cut D — 회고 view aggregate.
|
||||
export type ReviewPeriod = 'daily' | 'weekly' | 'monthly';
|
||||
export interface ReviewAggregate {
|
||||
totalCount: number;
|
||||
recentNotes: Note[];
|
||||
tagCounts: Array<{ tag: string; count: number }>;
|
||||
dueProgress: { total: number; passed: number; pending: number };
|
||||
}
|
||||
|
||||
// v0.3.0 Cut E — 양방향 sync 결과 + conflict.
|
||||
// `path` = git index 의 conflict 파일 상대경로 (예: 'notes/2026-05-09-abc12345-회의.md').
|
||||
// F5 export 의 filename 은 date-id8-slug 패턴 — UUID 가 아니라 path 가 맞는 식별자.
|
||||
export interface SyncConflict {
|
||||
path: string;
|
||||
localText: string;
|
||||
remoteText: string;
|
||||
}
|
||||
|
||||
export interface SyncStatus {
|
||||
ok: boolean;
|
||||
reason?: string;
|
||||
changed?: boolean;
|
||||
localSha?: string | null;
|
||||
pushed?: boolean;
|
||||
importedCount?: number;
|
||||
conflicts?: SyncConflict[];
|
||||
}
|
||||
|
||||
export interface SyncStatusSnapshot {
|
||||
lastAt: string | null;
|
||||
lastResult: SyncStatus | null;
|
||||
nextAt: string | null;
|
||||
}
|
||||
|
||||
export interface Note {
|
||||
id: string;
|
||||
rawText: string;
|
||||
@@ -37,6 +84,10 @@ export interface Note {
|
||||
deletedAt: string | null;
|
||||
lastRecalledAt: string | null;
|
||||
recallDismissedAt: string | null;
|
||||
// 신규 v4 (v0.2.9 Cut B):
|
||||
status: NoteStatus;
|
||||
statusChangedAt: string | null;
|
||||
moveReason: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
tags: NoteTag[];
|
||||
@@ -126,6 +177,47 @@ export interface InboxApi {
|
||||
}>;
|
||||
openProfileDir(): Promise<void>;
|
||||
copyAppInfo(): Promise<void>;
|
||||
// v0.2.8 Cut A — 첨부 이미지를 OS 기본 뷰어로 열기 (Task 3).
|
||||
openMedia(relPath: string): Promise<{ ok: true } | { ok: false; reason: string }>;
|
||||
// v0.2.9 Cut B Task 4 — status 별 노트 목록 + status 별 count.
|
||||
listByStatus(status: NoteStatus, opts?: { limit?: number }): Promise<Note[]>;
|
||||
countsByStatus(): Promise<{ active: number; completed: number; archived: number; trashed: number }>;
|
||||
// v0.2.9 Cut B Task 8 — 4분기 status 전이 + AI 자동 분류 추천.
|
||||
setStatus(
|
||||
id: string,
|
||||
status: NoteStatus,
|
||||
reason: string | null
|
||||
): Promise<{ ok: true } | { ok: false; reason: string }>;
|
||||
classifyStatus(id: string, reason: string): Promise<{ recommended: NoteStatus; rationale: string }>;
|
||||
// v0.2.9 Cut B Task 12 — settings read + AI/onboarding 토글.
|
||||
getSettings(): Promise<{
|
||||
ollama?: { endpoint: string; model: string };
|
||||
ai_enabled?: boolean;
|
||||
onboarding_completed?: boolean;
|
||||
sync_repo_url?: string | null;
|
||||
sync_auto_enabled?: boolean;
|
||||
sync_interval_min?: number;
|
||||
}>;
|
||||
setAiEnabled(enabled: boolean): Promise<{ ok: true }>;
|
||||
setOnboardingCompleted(completed: boolean): Promise<{ ok: true }>;
|
||||
// v0.2.9 Cut B Task 16 — ai_status='disabled' 메모 재투입 (사용자가 ai_enabled OFF→ON 전환 시).
|
||||
enqueueDisabled(): Promise<{ count: number }>;
|
||||
getDisabledCount(): Promise<number>;
|
||||
// v0.2.10 Cut C — raw_text 가변 + revision 보존.
|
||||
updateRawText(noteId: string, newText: string): Promise<{ ok: true } | { ok: false; reason: string }>;
|
||||
listRevisions(noteId: string): Promise<NoteRevision[]>;
|
||||
restoreRevision(noteId: string, revId: number): Promise<{ ok: true } | { ok: false; reason: string }>;
|
||||
// v0.2.11 Cut D — FTS5 search + 회고 aggregate.
|
||||
search(query: string, opts?: { limit?: number; status?: NoteStatus }): Promise<Note[]>;
|
||||
reviewAggregate(period: ReviewPeriod): Promise<ReviewAggregate>;
|
||||
// v0.3.0 Cut E — 양방향 sync.
|
||||
configureSync(url: string | null): Promise<{ ok: true } | { ok: false; reason: string }>;
|
||||
testSyncConnection(): Promise<{ ok: true } | { ok: false; reason: string }>;
|
||||
listConflicts(): Promise<SyncConflict[]>;
|
||||
resolveConflict(path: string, choice: 'local' | 'remote'): Promise<{ ok: true } | { ok: false; reason: string }>;
|
||||
getSyncStatus(): Promise<SyncStatusSnapshot>;
|
||||
setSyncAutoEnabled(enabled: boolean): Promise<{ ok: true }>;
|
||||
setSyncIntervalMin(value: number): Promise<{ ok: true } | { ok: false; reason: string }>;
|
||||
}
|
||||
|
||||
export interface InklingApi {
|
||||
|
||||
@@ -25,6 +25,11 @@ test('inbox shell shows v0.2 empty state', async () => {
|
||||
if ((await w.title()) === 'Inkling') { inbox = w; break; }
|
||||
}
|
||||
await inbox.waitForLoadState('load');
|
||||
// v0.2.9 Cut B: 첫 launch 시 OnboardingWizard 표시 — "나중에 설정" 으로 dismiss 후 inbox 진입.
|
||||
const dismissOnboarding = inbox.getByRole('button', { name: /나중에 설정/ });
|
||||
if (await dismissOnboarding.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await dismissOnboarding.click();
|
||||
}
|
||||
await expect(inbox.getByRole('heading', { name: 'Inkling' })).toBeVisible();
|
||||
await expect(inbox.getByText('머릿속에 떠다니는 한 줄을 적어보세요.')).toBeVisible();
|
||||
await app.close();
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
|
||||
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
|
||||
|
||||
vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
inboxApi: {
|
||||
loadOllamaSettings: vi.fn(async () => ({ endpoint: 'http://localhost:11434', model: 'gemma2:2b' })),
|
||||
saveOllamaSettings: vi.fn(async () => ({ ok: true })),
|
||||
ollamaRecheck: vi.fn(async () => ({ ok: true }))
|
||||
ollamaRecheck: vi.fn(async () => ({ ok: true })),
|
||||
getSettings: vi.fn(async () => ({ ai_enabled: true })),
|
||||
setAiEnabled: vi.fn(async () => ({ ok: true })),
|
||||
getDisabledCount: vi.fn(async () => 0),
|
||||
enqueueDisabled: vi.fn(async () => ({ count: 0 }))
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -41,4 +45,53 @@ describe('AiProviderSection', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /지금 재확인/ }));
|
||||
expect(inboxApi.ollamaRecheck).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// v0.2.9 Cut B Task 15 — AI 자동 처리 토글 + OFF 안내문.
|
||||
it('renders AI 자동 처리 toggle (default true)', async () => {
|
||||
const { inboxApi } = await import('../../src/renderer/inbox/api.js');
|
||||
vi.mocked(inboxApi.getSettings).mockResolvedValue({ ai_enabled: true } as never);
|
||||
render(<AiProviderSection />);
|
||||
const toggle = await screen.findByLabelText(/AI 자동 처리 사용/);
|
||||
expect((toggle as HTMLInputElement).checked).toBe(true);
|
||||
});
|
||||
|
||||
it('toggling calls setAiEnabled', async () => {
|
||||
const { inboxApi } = await import('../../src/renderer/inbox/api.js');
|
||||
vi.mocked(inboxApi.getSettings).mockResolvedValue({ ai_enabled: true } as never);
|
||||
vi.mocked(inboxApi.setAiEnabled).mockResolvedValue({ ok: true } as never);
|
||||
render(<AiProviderSection />);
|
||||
const toggle = await screen.findByLabelText(/AI 자동 처리 사용/);
|
||||
fireEvent.click(toggle);
|
||||
await waitFor(() => expect(inboxApi.setAiEnabled).toHaveBeenCalledWith(false));
|
||||
});
|
||||
|
||||
it('shows OFF state explanation when ai_enabled=false', async () => {
|
||||
const { inboxApi } = await import('../../src/renderer/inbox/api.js');
|
||||
vi.mocked(inboxApi.getSettings).mockResolvedValue({ ai_enabled: false } as never);
|
||||
render(<AiProviderSection />);
|
||||
await screen.findByLabelText(/AI 자동 처리 사용/);
|
||||
expect(screen.getByText(/원문만 저장 모드/)).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: /ollama\.com|설치/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// v0.2.9 Cut B Task 16 — ON 전환 후 disabled 메모 처리 prompt + 버튼.
|
||||
it('shows disabled count + 처리 버튼 when ai_enabled=true and disabledCount > 0', async () => {
|
||||
const { inboxApi } = await import('../../src/renderer/inbox/api.js');
|
||||
vi.mocked(inboxApi.getSettings).mockResolvedValue({ ai_enabled: true } as never);
|
||||
vi.mocked(inboxApi.getDisabledCount).mockResolvedValue(5);
|
||||
render(<AiProviderSection />);
|
||||
await screen.findByText(/5건/);
|
||||
expect(screen.getByRole('button', { name: /지금 모두 처리/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking 처리 버튼 calls enqueueDisabled', async () => {
|
||||
const { inboxApi } = await import('../../src/renderer/inbox/api.js');
|
||||
vi.mocked(inboxApi.getSettings).mockResolvedValue({ ai_enabled: true } as never);
|
||||
vi.mocked(inboxApi.getDisabledCount).mockResolvedValue(3);
|
||||
vi.mocked(inboxApi.enqueueDisabled).mockResolvedValue({ count: 3 } as never);
|
||||
render(<AiProviderSection />);
|
||||
await screen.findByText(/3건/);
|
||||
fireEvent.click(screen.getByRole('button', { name: /지금 모두 처리/ }));
|
||||
await waitFor(() => expect(inboxApi.enqueueDisabled).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,8 @@ import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/re
|
||||
vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
inboxApi: {
|
||||
listNotes: vi.fn(async () => []),
|
||||
listByStatus: vi.fn(async () => []),
|
||||
countsByStatus: vi.fn(async () => ({ active: 0, completed: 0, archived: 0, trashed: 0 })),
|
||||
getContinuity: vi.fn(async () => ({
|
||||
weekStart: '', weekCount: 0, weekTarget: 7,
|
||||
consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null
|
||||
@@ -47,7 +49,20 @@ vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
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)
|
||||
copyAppInfo: vi.fn(async () => undefined),
|
||||
// v0.2.9 Cut B Task 12 — onboarding wizard 분기. default 는 onboarding_completed=true 라 wizard 미표시.
|
||||
getSettings: vi.fn(async () => ({ onboarding_completed: true })),
|
||||
setAiEnabled: vi.fn(async () => ({ ok: true as const })),
|
||||
setOnboardingCompleted: vi.fn(async () => ({ ok: true as const })),
|
||||
// v0.2.9 Cut B Task 16 — AiProviderSection 가 SettingsPage 렌더 시 호출.
|
||||
getDisabledCount: vi.fn(async () => 0),
|
||||
enqueueDisabled: vi.fn(async () => ({ count: 0 })),
|
||||
// v0.3.0 Cut E — SyncSection 이 SettingsPage 에 마운트되어 호출.
|
||||
getSyncStatus: vi.fn(async () => ({ lastAt: null, lastResult: null, nextAt: null })),
|
||||
setSyncAutoEnabled: vi.fn(async () => ({ ok: true as const })),
|
||||
setSyncIntervalMin: vi.fn(async () => ({ ok: true as const })),
|
||||
configureSync: vi.fn(async () => ({ ok: true as const })),
|
||||
testSyncConnection: vi.fn(async () => ({ ok: true as const }))
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -58,7 +73,12 @@ import { inboxApi } from '../../src/renderer/inbox/api.js';
|
||||
describe('App — settings view', () => {
|
||||
beforeEach(() => {
|
||||
cleanup();
|
||||
useInbox.setState({ showSettings: false, notes: [], trashNotes: [], trashCount: 0 });
|
||||
useInbox.setState({
|
||||
view: 'inbox',
|
||||
counts: { active: 0, completed: 0, archived: 0, trashed: 0 },
|
||||
showSettings: false, showTrash: false,
|
||||
notes: [], trashNotes: [], trashCount: 0
|
||||
});
|
||||
});
|
||||
|
||||
it('renders SettingsPage when showSettings=true', async () => {
|
||||
@@ -89,3 +109,69 @@ describe('App — settings view', () => {
|
||||
await waitFor(() => expect(useInbox.getState().showSettings).toBe(true));
|
||||
});
|
||||
});
|
||||
|
||||
describe('App header — 4 tabs', () => {
|
||||
beforeEach(() => {
|
||||
cleanup();
|
||||
useInbox.setState({
|
||||
view: 'inbox',
|
||||
counts: { active: 5, completed: 3, archived: 2, trashed: 1 },
|
||||
notes: [], trashNotes: [], trashCount: 0,
|
||||
showTrash: false, showSettings: false
|
||||
});
|
||||
// loadInitial 이 비동기로 counts 를 덮어씀 — onboarding wizard async gate (Task 12) 도입
|
||||
// 후 render 가 await 후 발생하므로 mock 의 countsByStatus 가 테스트 기대값을 반환하도록 갱신.
|
||||
vi.mocked(inboxApi.countsByStatus).mockResolvedValue({ active: 5, completed: 3, archived: 2, trashed: 1 });
|
||||
});
|
||||
|
||||
it('renders 4 tabs with counts', async () => {
|
||||
render(<App />);
|
||||
expect(await screen.findByRole('button', { name: /Inbox\(5\)/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /완료\(3\)/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /보관\(2\)/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /휴지통\(1\)/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking 완료 tab sets view=completed', async () => {
|
||||
render(<App />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: /완료/ }));
|
||||
expect(useInbox.getState().view).toBe('completed');
|
||||
});
|
||||
|
||||
it('aria-pressed reflects current view', async () => {
|
||||
useInbox.setState({ view: 'archived' });
|
||||
render(<App />);
|
||||
const archivedBtn = await screen.findByRole('button', { name: /보관/ });
|
||||
expect(archivedBtn.getAttribute('aria-pressed')).toBe('true');
|
||||
const inboxBtn = screen.getByRole('button', { name: /Inbox/ });
|
||||
expect(inboxBtn.getAttribute('aria-pressed')).toBe('false');
|
||||
});
|
||||
});
|
||||
|
||||
describe('App — onboarding wizard', () => {
|
||||
beforeEach(() => {
|
||||
cleanup();
|
||||
useInbox.setState({
|
||||
view: 'inbox',
|
||||
counts: { active: 0, completed: 0, archived: 0, trashed: 0 },
|
||||
showSettings: false, showTrash: false,
|
||||
notes: [], trashNotes: [], trashCount: 0
|
||||
});
|
||||
// 각 테스트가 getSettings 의 default mock 을 직접 override.
|
||||
vi.mocked(inboxApi.getSettings).mockResolvedValue({ onboarding_completed: true });
|
||||
});
|
||||
|
||||
it('renders OnboardingWizard when onboarding_completed=false', async () => {
|
||||
vi.mocked(inboxApi.getSettings).mockResolvedValue({ onboarding_completed: false });
|
||||
render(<App />);
|
||||
await screen.findByText(/Inkling 사용 시작/);
|
||||
expect(screen.getByRole('dialog', { name: /시작 안내/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render OnboardingWizard when onboarding_completed=true', async () => {
|
||||
vi.mocked(inboxApi.getSettings).mockResolvedValue({ onboarding_completed: true });
|
||||
render(<App />);
|
||||
await screen.findByRole('button', { name: /Inbox/ });
|
||||
expect(screen.queryByText(/Inkling 사용 시작/)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -420,6 +420,51 @@ describe('CaptureService.retryAllFailed', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('CaptureService ai_enabled toggle (v0.2.9 Cut B)', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
let store: MediaStore;
|
||||
let tmp: string;
|
||||
let enqueued: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
repo = new NoteRepository(db);
|
||||
tmp = mkdtempSync(join(tmpdir(), 'inkling-aitoggle-'));
|
||||
store = new MediaStore(tmp);
|
||||
enqueued = [];
|
||||
});
|
||||
|
||||
it('ai_enabled=false → ai_status=disabled, no enqueue, no pending_jobs row', async () => {
|
||||
const settings = { isAiEnabled: async () => false };
|
||||
const svc = new CaptureService(repo, store, {
|
||||
enqueue: async (id) => { enqueued.push(id); },
|
||||
celebrate: () => {},
|
||||
settings
|
||||
});
|
||||
const { noteId } = await svc.submit({ text: 'no-ai', images: [] });
|
||||
expect(repo.findById(noteId)?.aiStatus).toBe('disabled');
|
||||
expect(enqueued).toEqual([]);
|
||||
const row = db.prepare('SELECT note_id FROM pending_jobs WHERE note_id=?').get(noteId);
|
||||
expect(row).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ai_enabled=true → default pending + enqueue (parity with no settings dep)', async () => {
|
||||
const settings = { isAiEnabled: async () => true };
|
||||
const svc = new CaptureService(repo, store, {
|
||||
enqueue: async (id) => { enqueued.push(id); },
|
||||
celebrate: () => {},
|
||||
settings
|
||||
});
|
||||
const { noteId } = await svc.submit({ text: 'with-ai', images: [] });
|
||||
expect(repo.findById(noteId)?.aiStatus).toBe('pending');
|
||||
expect(enqueued).toEqual([noteId]);
|
||||
const row = db.prepare('SELECT note_id FROM pending_jobs WHERE note_id=?').get(noteId);
|
||||
expect(row).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CaptureService recall methods (v0.2.3 #6)', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
|
||||
61
tests/unit/ConflictModal.test.tsx
Normal file
61
tests/unit/ConflictModal.test.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
const { mockListConflicts, mockResolveConflict } = vi.hoisted(() => ({
|
||||
mockListConflicts: vi.fn(),
|
||||
mockResolveConflict: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
inboxApi: { listConflicts: mockListConflicts, resolveConflict: mockResolveConflict }
|
||||
}));
|
||||
|
||||
import { ConflictModal } from '../../src/renderer/inbox/components/ConflictModal';
|
||||
|
||||
describe('ConflictModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cleanup();
|
||||
mockListConflicts.mockResolvedValue([
|
||||
{ path: 'notes/n1.md', localText: 'local A', remoteText: 'remote A' },
|
||||
{ path: 'notes/n2.md', localText: 'local B', remoteText: 'remote B' }
|
||||
]);
|
||||
mockResolveConflict.mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
it('open 시 listConflicts 호출 + 양 conflict preview 표시', async () => {
|
||||
render(<ConflictModal onClose={() => {}} onResolved={() => {}} />);
|
||||
await waitFor(() => screen.getByText(/local A/));
|
||||
expect(screen.getByText(/local A/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/remote A/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/local B/)).toBeInTheDocument();
|
||||
// path 가 표시됨 (Cut E final review fix — noteId → path)
|
||||
expect(screen.getByText('notes/n1.md')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('내 것 사용 클릭 → resolveConflict(path, "local") 호출', async () => {
|
||||
render(<ConflictModal onClose={() => {}} onResolved={() => {}} />);
|
||||
await waitFor(() => screen.getByText(/local A/));
|
||||
const buttons = screen.getAllByRole('button', { name: /내 것 사용/ });
|
||||
fireEvent.click(buttons[0]!);
|
||||
await waitFor(() => {
|
||||
expect(mockResolveConflict).toHaveBeenCalledWith('notes/n1.md', 'local');
|
||||
});
|
||||
});
|
||||
|
||||
it('마지막 conflict 해결 → onResolved + onClose 호출', async () => {
|
||||
mockListConflicts.mockResolvedValueOnce([{ path: 'notes/n1.md', localText: 'a', remoteText: 'b' }]);
|
||||
const onResolved = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
render(<ConflictModal onClose={onClose} onResolved={onResolved} />);
|
||||
await waitFor(() => screen.getByRole('button', { name: /원격 사용/ }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /원격 사용/ }));
|
||||
await waitFor(() => {
|
||||
expect(onResolved).toHaveBeenCalled();
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
46
tests/unit/FailedBanner.test.tsx
Normal file
46
tests/unit/FailedBanner.test.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { render, cleanup } from '@testing-library/react';
|
||||
|
||||
vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
inboxApi: {
|
||||
retryAllFailed: vi.fn(async () => {})
|
||||
}
|
||||
}));
|
||||
|
||||
import { FailedBanner } from '../../src/renderer/inbox/components/FailedBanner';
|
||||
import { useInbox } from '../../src/renderer/inbox/store';
|
||||
|
||||
describe('FailedBanner — ai_enabled (v0.2.9 Cut B Task 14)', () => {
|
||||
beforeEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('renders nothing when ai_enabled=false (even with failedCount > 0)', () => {
|
||||
useInbox.setState({
|
||||
ai_enabled: false,
|
||||
failedCount: 3
|
||||
} as Partial<ReturnType<typeof useInbox.getState>>);
|
||||
const { container } = render(<FailedBanner />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('renders nothing when ai_enabled=true and failedCount=0', () => {
|
||||
useInbox.setState({
|
||||
ai_enabled: true,
|
||||
failedCount: 0
|
||||
} as Partial<ReturnType<typeof useInbox.getState>>);
|
||||
const { container } = render(<FailedBanner />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('renders banner when ai_enabled=true and failedCount > 0', () => {
|
||||
useInbox.setState({
|
||||
ai_enabled: true,
|
||||
failedCount: 5
|
||||
} as Partial<ReturnType<typeof useInbox.getState>>);
|
||||
const { container } = render(<FailedBanner />);
|
||||
expect(container).not.toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
51
tests/unit/GitClient.fetch.test.ts
Normal file
51
tests/unit/GitClient.fetch.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { GitClient } from '../../src/main/services/GitClient.js';
|
||||
|
||||
describe('GitClient — fetch / rebase / conflict 메서드', () => {
|
||||
let client: GitClient;
|
||||
let runSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
client = new GitClient('/tmp/sync');
|
||||
runSpy = vi.spyOn(client, 'run');
|
||||
});
|
||||
|
||||
it('fetch — git fetch origin 호출', async () => {
|
||||
runSpy.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 });
|
||||
const r = await client.fetch();
|
||||
expect(runSpy).toHaveBeenCalledWith(['fetch', 'origin']);
|
||||
expect(r.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it('rebaseOnto — git rebase origin/main', async () => {
|
||||
runSpy.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 });
|
||||
const r = await client.rebaseOnto('origin/main');
|
||||
expect(runSpy).toHaveBeenCalledWith(['rebase', 'origin/main']);
|
||||
expect(r.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it('rebaseAbort — git rebase --abort', async () => {
|
||||
runSpy.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 });
|
||||
await client.rebaseAbort();
|
||||
expect(runSpy).toHaveBeenCalledWith(['rebase', '--abort']);
|
||||
});
|
||||
|
||||
it('hasUncommittedChanges — git status --porcelain 의 출력 있으면 true', async () => {
|
||||
runSpy.mockResolvedValueOnce({ stdout: ' M notes/abc.md\n', stderr: '', exitCode: 0 });
|
||||
expect(await client.hasUncommittedChanges()).toBe(true);
|
||||
|
||||
runSpy.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 });
|
||||
expect(await client.hasUncommittedChanges()).toBe(false);
|
||||
});
|
||||
|
||||
it('listConflicts — git diff --name-only --diff-filter=U 결과 파싱', async () => {
|
||||
runSpy.mockResolvedValueOnce({
|
||||
stdout: 'notes/aaa.md\nnotes/bbb.md\n',
|
||||
stderr: '',
|
||||
exitCode: 0
|
||||
});
|
||||
const r = await client.listConflicts();
|
||||
expect(runSpy).toHaveBeenCalledWith(['diff', '--name-only', '--diff-filter=U']);
|
||||
expect(r).toEqual(['notes/aaa.md', 'notes/bbb.md']);
|
||||
});
|
||||
});
|
||||
@@ -117,3 +117,48 @@ describe('HealthChecker — delta transitions + telemetry', () => {
|
||||
expect(events).toEqual([{ kind: 'ollama_recheck_manual' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('HealthChecker — ai_enabled gate (v0.2.9 Cut B Task 14)', () => {
|
||||
beforeEach(() => { vi.useFakeTimers(); });
|
||||
afterEach(() => { vi.useRealTimers(); });
|
||||
|
||||
it('isAiEnabled=false 면 start() polling 이 healthCheck 호출 skip', async () => {
|
||||
const provider = new FakeProvider();
|
||||
provider.results = [{ ok: true }];
|
||||
const hc = new HealthChecker(new ProviderHolder(provider), {
|
||||
intervalMs: 1000,
|
||||
isAiEnabled: async () => false
|
||||
});
|
||||
hc.start();
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
// 즉시 + 2 tick = 0회 — AI 비활성으로 모든 polling skip.
|
||||
expect((provider as any).idx).toBe(0);
|
||||
hc.stop();
|
||||
});
|
||||
|
||||
it('isAiEnabled=true 면 polling 정상 (gate 통과)', async () => {
|
||||
const provider = new FakeProvider();
|
||||
provider.results = [{ ok: true }, { ok: true }];
|
||||
const hc = new HealthChecker(new ProviderHolder(provider), {
|
||||
intervalMs: 1000,
|
||||
isAiEnabled: async () => true
|
||||
});
|
||||
hc.start();
|
||||
// start() 의 즉시 tick 이 microtask 에서 isAiEnabled 를 await 함 → flush 필요.
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect((provider as any).idx).toBeGreaterThanOrEqual(2);
|
||||
hc.stop();
|
||||
});
|
||||
|
||||
it('isAiEnabled=false 여도 manual runOnce 는 항상 실행 (사용자 의도)', async () => {
|
||||
const provider = new FakeProvider();
|
||||
provider.results = [{ ok: true }];
|
||||
const hc = new HealthChecker(new ProviderHolder(provider), {
|
||||
isAiEnabled: async () => false
|
||||
});
|
||||
await hc.runOnce({ manual: true });
|
||||
expect((provider as any).idx).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
106
tests/unit/ImportService.applySyncFromDir.test.ts
Normal file
106
tests/unit/ImportService.applySyncFromDir.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import Database from 'better-sqlite3';
|
||||
import { mkdtemp, writeFile, mkdir, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { runMigrations } from '@main/db/migrations/index.js';
|
||||
import { NoteRepository } from '@main/repository/NoteRepository.js';
|
||||
import { ImportService } from '@main/services/ImportService.js';
|
||||
import { MediaStore } from '@main/services/MediaStore.js';
|
||||
|
||||
describe('ImportService.applySyncFromDir', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
let svc: ImportService;
|
||||
let workDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = new Database(':memory:');
|
||||
db.pragma('foreign_keys = ON');
|
||||
runMigrations(db);
|
||||
repo = new NoteRepository(db);
|
||||
workDir = await mkdtemp(join(tmpdir(), 'inkling-sync-'));
|
||||
const mediaStore = new MediaStore(workDir);
|
||||
svc = new ImportService(repo, mediaStore);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
db.close();
|
||||
await rm(workDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('inserts new notes and reports changedCount', async () => {
|
||||
const notesDir = join(workDir, 'notes');
|
||||
await mkdir(notesDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(notesDir, 'a.md'),
|
||||
`---\nid: 00000000-0000-0000-0000-000000000001\ncreated_at: 2026-05-09T00:00:00Z\nupdated_at: 2026-05-10T00:00:00Z\ntitle: title\ntitle_source: ai\nsummary: summary\nsummary_source: ai\nstatus: active\ninkling_export_version: 1\n---\n\n# title\n\n> summary\n\nbody\n`
|
||||
);
|
||||
const r = await svc.applySyncFromDir(workDir);
|
||||
expect(r.changedCount).toBe(1);
|
||||
const note = repo.findById('00000000-0000-0000-0000-000000000001');
|
||||
expect(note?.rawText).toBe('body');
|
||||
});
|
||||
|
||||
it('skips unchanged notes (no changedCount increment)', async () => {
|
||||
const created = repo.create({ rawText: 'body' });
|
||||
db.prepare(`UPDATE notes SET updated_at=? WHERE id=?`).run('2026-05-15T00:00:00Z', created.id);
|
||||
const notesDir = join(workDir, 'notes');
|
||||
await mkdir(notesDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(notesDir, 'a.md'),
|
||||
`---\nid: ${created.id}\ncreated_at: 2026-05-09T00:00:00Z\nupdated_at: 2026-05-10T00:00:00Z\ntitle: t\ntitle_source: ai\nsummary: s\nsummary_source: ai\nstatus: active\ninkling_export_version: 1\n---\n\n# t\n\n> s\n\nbody\n`
|
||||
);
|
||||
const r = await svc.applySyncFromDir(workDir);
|
||||
expect(r.changedCount).toBe(0);
|
||||
});
|
||||
|
||||
it('returns changedCount=0 for an empty notes directory', async () => {
|
||||
const notesDir = join(workDir, 'notes');
|
||||
await mkdir(notesDir, { recursive: true });
|
||||
const r = await svc.applySyncFromDir(workDir);
|
||||
expect(r.changedCount).toBe(0);
|
||||
});
|
||||
|
||||
it('updates a note when source updatedAt is newer', async () => {
|
||||
const created = repo.create({ rawText: 'old body' });
|
||||
db.prepare(`UPDATE notes SET updated_at=? WHERE id=?`).run('2026-05-01T00:00:00Z', created.id);
|
||||
const notesDir = join(workDir, 'notes');
|
||||
await mkdir(notesDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(notesDir, 'a.md'),
|
||||
`---\nid: ${created.id}\ncreated_at: 2026-05-09T00:00:00Z\nupdated_at: 2026-05-10T00:00:00Z\ntitle: t\ntitle_source: ai\nsummary: s\nsummary_source: ai\nstatus: active\ninkling_export_version: 1\n---\n\n# t\n\n> s\n\nnew body\n`
|
||||
);
|
||||
const r = await svc.applySyncFromDir(workDir);
|
||||
expect(r.changedCount).toBe(1);
|
||||
const note = repo.findById(created.id);
|
||||
expect(note?.rawText).toBe('new body');
|
||||
});
|
||||
|
||||
it('preserves status field from frontmatter', async () => {
|
||||
const notesDir = join(workDir, 'notes');
|
||||
await mkdir(notesDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(notesDir, 'a.md'),
|
||||
`---\nid: 00000000-0000-0000-0000-000000000002\ncreated_at: 2026-05-09T00:00:00Z\nupdated_at: 2026-05-10T00:00:00Z\ntitle: t\ntitle_source: ai\nsummary: s\nsummary_source: ai\nstatus: archived\nstatus_changed_at: 2026-05-08T00:00:00Z\nmove_reason: done\ninkling_export_version: 1\n---\n\n# t\n\n> s\n\nbody\n`
|
||||
);
|
||||
await svc.applySyncFromDir(workDir);
|
||||
const note = repo.findById('00000000-0000-0000-0000-000000000002');
|
||||
expect(note?.status).toBe('archived');
|
||||
expect(note?.statusChangedAt).toBe('2026-05-08T00:00:00Z');
|
||||
expect(note?.moveReason).toBe('done');
|
||||
});
|
||||
|
||||
it('preserves dueDate from frontmatter', async () => {
|
||||
const notesDir = join(workDir, 'notes');
|
||||
await mkdir(notesDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(notesDir, 'a.md'),
|
||||
`---\nid: 00000000-0000-0000-0000-000000000003\ncreated_at: 2026-05-09T00:00:00Z\nupdated_at: 2026-05-10T00:00:00Z\ntitle: t\ntitle_source: ai\nsummary: s\nsummary_source: ai\nstatus: active\ndue_date: 2026-06-01\ndue_date_source: user\ninkling_export_version: 1\n---\n\n# t\n\n> s\n\nbody\n`
|
||||
);
|
||||
await svc.applySyncFromDir(workDir);
|
||||
const note = repo.findById('00000000-0000-0000-0000-000000000003');
|
||||
expect(note?.dueDate).toBe('2026-06-01');
|
||||
expect(note?.dueDateEditedByUser).toBe(true);
|
||||
});
|
||||
});
|
||||
98
tests/unit/MoveStatusModal.test.tsx
Normal file
98
tests/unit/MoveStatusModal.test.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
|
||||
|
||||
const { mockSetStatus, mockClassify } = vi.hoisted(() => ({
|
||||
mockSetStatus: vi.fn(async () => ({ ok: true as const })),
|
||||
mockClassify: vi.fn(async () => ({
|
||||
recommended: 'completed' as const,
|
||||
rationale: '결재 끝'
|
||||
}))
|
||||
}));
|
||||
|
||||
vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
inboxApi: {
|
||||
setStatus: mockSetStatus,
|
||||
classifyStatus: mockClassify
|
||||
}
|
||||
}));
|
||||
|
||||
import { MoveStatusModal } from '../../src/renderer/inbox/components/MoveStatusModal';
|
||||
|
||||
describe('MoveStatusModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('renders reason textarea + 4 buttons + AI classify button', () => {
|
||||
render(
|
||||
<MoveStatusModal
|
||||
noteId="n1"
|
||||
rawText="t"
|
||||
summary=""
|
||||
onClose={vi.fn()}
|
||||
onMoved={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '완료' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '보관' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '휴지통' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /AI 자동 분류/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking 완료 calls setStatus with reason', async () => {
|
||||
const onMoved = vi.fn();
|
||||
render(
|
||||
<MoveStatusModal
|
||||
noteId="n1"
|
||||
rawText="t"
|
||||
summary=""
|
||||
onClose={vi.fn()}
|
||||
onMoved={onMoved}
|
||||
/>
|
||||
);
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: '결재 끝' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '완료' }));
|
||||
await waitFor(() => {
|
||||
expect(mockSetStatus).toHaveBeenCalledWith('n1', 'completed', '결재 끝');
|
||||
expect(onMoved).toHaveBeenCalledWith('completed', '결재 끝');
|
||||
});
|
||||
});
|
||||
|
||||
it('AI 자동 분류 → recommendation 표시 + 확정 → setStatus', async () => {
|
||||
const onMoved = vi.fn();
|
||||
render(
|
||||
<MoveStatusModal
|
||||
noteId="n1"
|
||||
rawText="t"
|
||||
summary=""
|
||||
onClose={vi.fn()}
|
||||
onMoved={onMoved}
|
||||
/>
|
||||
);
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: '결재 끝' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /AI 자동 분류/ }));
|
||||
await screen.findByText(/AI 추천/);
|
||||
expect(screen.getByText(/이유: 결재 끝/)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: /확정/ }));
|
||||
await waitFor(() => expect(onMoved).toHaveBeenCalledWith('completed', '결재 끝'));
|
||||
});
|
||||
|
||||
it('빈 사유 → null reason 전달', async () => {
|
||||
const onMoved = vi.fn();
|
||||
render(
|
||||
<MoveStatusModal
|
||||
noteId="n1"
|
||||
rawText="t"
|
||||
summary=""
|
||||
onClose={vi.fn()}
|
||||
onMoved={onMoved}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '보관' }));
|
||||
await waitFor(() => expect(mockSetStatus).toHaveBeenCalledWith('n1', 'archived', null));
|
||||
});
|
||||
});
|
||||
188
tests/unit/NoteCard.test.tsx
Normal file
188
tests/unit/NoteCard.test.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
|
||||
import type { Note } from '@shared/types';
|
||||
|
||||
const { mockOpenMedia, mockSetStatus, mockClassify, mockUpdateRawText } = vi.hoisted(() => ({
|
||||
mockOpenMedia: vi.fn(async () => ({ ok: true })),
|
||||
mockSetStatus: vi.fn(async () => ({ ok: true as const })),
|
||||
mockClassify: vi.fn(async () => ({
|
||||
recommended: 'archived' as const,
|
||||
rationale: 'stub'
|
||||
})),
|
||||
mockUpdateRawText: vi.fn(async () => ({ ok: true as const }))
|
||||
}));
|
||||
|
||||
vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
inboxApi: {
|
||||
openMedia: mockOpenMedia,
|
||||
deleteNote: vi.fn(),
|
||||
restoreNote: vi.fn(),
|
||||
permanentDeleteNote: vi.fn(),
|
||||
updateAiFields: vi.fn(),
|
||||
setDueDate: vi.fn(),
|
||||
setIntent: vi.fn(),
|
||||
dismissIntent: vi.fn(),
|
||||
setStatus: mockSetStatus,
|
||||
classifyStatus: mockClassify,
|
||||
updateRawText: mockUpdateRawText,
|
||||
listRevisions: vi.fn(async () => []),
|
||||
restoreRevision: vi.fn(async () => ({ ok: true as const }))
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('../../src/renderer/inbox/store.js', () => ({
|
||||
useInbox: Object.assign(
|
||||
() => ({}),
|
||||
{ getState: () => ({ setTagFilter: vi.fn() }) }
|
||||
)
|
||||
}));
|
||||
|
||||
import { NoteCard } from '../../src/renderer/inbox/components/NoteCard';
|
||||
|
||||
const baseNote: Note = {
|
||||
id: 'n1',
|
||||
rawText: 'test',
|
||||
aiTitle: 'T',
|
||||
aiSummary: 'S',
|
||||
aiStatus: 'done',
|
||||
aiError: null,
|
||||
aiProvider: null,
|
||||
aiGeneratedAt: '2026-05-09T00:00:00Z',
|
||||
titleEditedByUser: false,
|
||||
summaryEditedByUser: false,
|
||||
userIntent: null,
|
||||
intentPromptedAt: '2026-05-09T00:00:00Z',
|
||||
dueDate: null,
|
||||
dueDateEditedByUser: false,
|
||||
deletedAt: null,
|
||||
lastRecalledAt: null,
|
||||
recallDismissedAt: null,
|
||||
status: 'active',
|
||||
statusChangedAt: null,
|
||||
moveReason: null,
|
||||
createdAt: '2026-05-09T00:00:00Z',
|
||||
updatedAt: '2026-05-09T00:00:00Z',
|
||||
tags: [],
|
||||
media: [
|
||||
{ id: 'm1', kind: 'image', relPath: 'media/n1/img1.png', mime: 'image/png', bytes: 100 },
|
||||
{ id: 'm2', kind: 'image', relPath: 'media/n1/img2.jpg', mime: 'image/jpeg', bytes: 200 }
|
||||
]
|
||||
};
|
||||
|
||||
describe('NoteCard — image rendering', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('renders <img> for each media item', () => {
|
||||
render(<NoteCard note={baseNote} onUpdated={() => {}} mode="inbox" />);
|
||||
const imgs = screen.getAllByRole('presentation');
|
||||
expect(imgs).toHaveLength(2);
|
||||
expect(imgs[0]?.getAttribute('src')).toBe('inkling-media://media/n1/img1.png');
|
||||
expect(imgs[1]?.getAttribute('src')).toBe('inkling-media://media/n1/img2.jpg');
|
||||
});
|
||||
|
||||
it('clicking <img> calls inboxApi.openMedia', () => {
|
||||
render(<NoteCard note={baseNote} onUpdated={() => {}} mode="inbox" />);
|
||||
const first = screen.getAllByRole('presentation')[0];
|
||||
if (first === undefined) throw new Error('expected at least one img');
|
||||
fireEvent.click(first);
|
||||
expect(mockOpenMedia).toHaveBeenCalledWith('media/n1/img1.png');
|
||||
});
|
||||
});
|
||||
|
||||
describe('NoteCard — ai_status=disabled fallback (v0.2.9 Cut B Task 13)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('ai_status=disabled: title fallback to raw_text first line, hide summary/tags', () => {
|
||||
const disabledNote: Note = {
|
||||
...baseNote,
|
||||
aiStatus: 'disabled',
|
||||
aiTitle: null,
|
||||
aiSummary: 'should-not-show',
|
||||
tags: [{ name: 't1', source: 'user' }],
|
||||
rawText: '첫 줄 본문\n둘째 줄 본문'
|
||||
};
|
||||
render(<NoteCard note={disabledNote} mode="inbox" onUpdated={vi.fn()} />);
|
||||
expect(screen.getByText('첫 줄 본문')).toBeInTheDocument();
|
||||
expect(screen.queryByText('should-not-show')).toBeNull();
|
||||
expect(screen.queryByText('t1')).toBeNull();
|
||||
});
|
||||
|
||||
it('ai_status=disabled: empty raw → "(빈 메모)" fallback', () => {
|
||||
const disabledNote: Note = {
|
||||
...baseNote,
|
||||
aiStatus: 'disabled',
|
||||
aiTitle: null,
|
||||
rawText: ''
|
||||
};
|
||||
render(<NoteCard note={disabledNote} mode="inbox" onUpdated={vi.fn()} />);
|
||||
expect(screen.getByText('(빈 메모)')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('NoteCard — 이동 메뉴 (v0.2.9 Cut B Task 6)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('이동 ▾ 클릭 → 현재 status 외 3개 목적지 메뉴 표시', () => {
|
||||
// baseNote.status = 'active' → 완료/보관/휴지통 만 표시
|
||||
render(<NoteCard note={baseNote} onUpdated={() => {}} mode="inbox" />);
|
||||
fireEvent.click(screen.getByRole('button', { name: '이동' }));
|
||||
expect(screen.getByRole('button', { name: '완료로 이동' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '보관으로 이동' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '휴지통으로 이동' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: '활성으로 이동' })).toBeNull();
|
||||
});
|
||||
|
||||
it('메뉴 항목 클릭 → MoveStatusModal 열림 + 확정 시 setStatus 호출', async () => {
|
||||
const onUpdated = vi.fn();
|
||||
render(<NoteCard note={baseNote} onUpdated={onUpdated} mode="inbox" />);
|
||||
fireEvent.click(screen.getByRole('button', { name: '이동' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '완료로 이동' }));
|
||||
// Modal 의 dialog role 등장
|
||||
expect(screen.getByRole('dialog', { name: '이동' })).toBeInTheDocument();
|
||||
// Modal 내부의 "완료" 버튼 클릭 → setStatus
|
||||
fireEvent.click(screen.getByRole('button', { name: '완료' }));
|
||||
await waitFor(() => {
|
||||
expect(mockSetStatus).toHaveBeenCalledWith('n1', 'completed', null);
|
||||
expect(onUpdated).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('NoteCard — raw_text editing', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('원문 편집: textarea 저장 → updateRawText 호출 + 로컬 raw 갱신', async () => {
|
||||
const onUpdated = vi.fn();
|
||||
render(<NoteCard note={{ ...baseNote, rawText: 'old' }} onUpdated={onUpdated} mode="inbox" />);
|
||||
// 원문 펼침
|
||||
fireEvent.click(screen.getByRole('button', { name: /원문/ }));
|
||||
// 편집 진입
|
||||
fireEvent.click(screen.getByRole('button', { name: '편집' }));
|
||||
const ta = screen.getByRole('textbox', { name: /원문 편집/ }) as HTMLTextAreaElement;
|
||||
fireEvent.change(ta, { target: { value: 'new' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '저장' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateRawText).toHaveBeenCalledWith('n1', 'new');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(onUpdated).toHaveBeenCalled();
|
||||
});
|
||||
const last = onUpdated.mock.calls.at(-1)![0] as { rawText: string };
|
||||
expect(last.rawText).toBe('new');
|
||||
});
|
||||
});
|
||||
72
tests/unit/NoteRepository.reviewAggregate.test.ts
Normal file
72
tests/unit/NoteRepository.reviewAggregate.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import Database from 'better-sqlite3';
|
||||
import { runMigrations } from '../../src/main/db/migrations/index.js';
|
||||
import { NoteRepository } from '../../src/main/repository/NoteRepository.js';
|
||||
|
||||
describe('NoteRepository.reviewAggregate', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
db.pragma('foreign_keys = ON');
|
||||
runMigrations(db);
|
||||
repo = new NoteRepository(db);
|
||||
});
|
||||
|
||||
afterEach(() => { db.close(); });
|
||||
|
||||
it('daily — 오늘 KST 자정 이후 노트만 카운트', () => {
|
||||
const now = new Date('2026-05-10T05:00:00Z'); // KST 14:00
|
||||
db.prepare(`INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at, status)
|
||||
VALUES (?, ?, 'done', ?, ?, 'active')`).run('today', '오늘 메모', '2026-05-10T00:30:00Z', '2026-05-10T00:30:00Z');
|
||||
db.prepare(`INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at, status)
|
||||
VALUES (?, ?, 'done', ?, ?, 'active')`).run('yesterday', '어제 메모', '2026-05-09T10:00:00Z', '2026-05-09T10:00:00Z');
|
||||
const r = repo.reviewAggregate('daily', now);
|
||||
expect(r.totalCount).toBe(1);
|
||||
expect(r.recentNotes).toHaveLength(1);
|
||||
expect(r.recentNotes[0]!.id).toBe('today');
|
||||
});
|
||||
|
||||
it('weekly — 7일 전 KST 자정 이후', () => {
|
||||
const now = new Date('2026-05-10T05:00:00Z');
|
||||
db.prepare(`INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at, status)
|
||||
VALUES (?, ?, 'done', ?, ?, 'active')`).run('5dago', '5일 전', '2026-05-05T00:00:00Z', '2026-05-05T00:00:00Z');
|
||||
db.prepare(`INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at, status)
|
||||
VALUES (?, ?, 'done', ?, ?, 'active')`).run('10dago', '10일 전', '2026-04-30T00:00:00Z', '2026-04-30T00:00:00Z');
|
||||
const r = repo.reviewAggregate('weekly', now);
|
||||
expect(r.totalCount).toBe(1);
|
||||
});
|
||||
|
||||
it('trashed 제외', () => {
|
||||
const now = new Date('2026-05-10T05:00:00Z');
|
||||
const a = repo.create({ rawText: '활성' });
|
||||
const b = repo.create({ rawText: '버린' });
|
||||
repo.setStatus(b.id, 'trashed', null);
|
||||
const r = repo.reviewAggregate('monthly', now);
|
||||
expect(r.recentNotes.map((n) => n.id)).toContain(a.id);
|
||||
expect(r.recentNotes.map((n) => n.id)).not.toContain(b.id);
|
||||
});
|
||||
|
||||
it('tagCounts — period 안 노트의 태그만 DESC', () => {
|
||||
const now = new Date('2026-05-10T05:00:00Z');
|
||||
const a = repo.create({ rawText: 'a' });
|
||||
const b = repo.create({ rawText: 'b' });
|
||||
repo.updateAiResult(a.id, { title: 't', summary: 's', tags: ['x', 'y'], provider: 'p' });
|
||||
repo.updateAiResult(b.id, { title: 't', summary: 's', tags: ['x'], provider: 'p' });
|
||||
const r = repo.reviewAggregate('monthly', now);
|
||||
expect(r.tagCounts[0]).toEqual({ tag: 'x', count: 2 });
|
||||
expect(r.tagCounts[1]).toEqual({ tag: 'y', count: 1 });
|
||||
});
|
||||
|
||||
it('dueProgress — passed / pending KST today 기준', () => {
|
||||
const now = new Date('2026-05-10T05:00:00Z');
|
||||
const a = repo.create({ rawText: 'a' });
|
||||
const b = repo.create({ rawText: 'b' });
|
||||
repo.create({ rawText: 'c' }); // due 없음 → 카운트 X
|
||||
repo.setDueDate(a.id, '2026-05-01'); // passed
|
||||
repo.setDueDate(b.id, '2026-05-15'); // pending
|
||||
const r = repo.reviewAggregate('monthly', now);
|
||||
expect(r.dueProgress).toEqual({ total: 2, passed: 1, pending: 1 });
|
||||
});
|
||||
});
|
||||
57
tests/unit/NoteRepository.search.test.ts
Normal file
57
tests/unit/NoteRepository.search.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import Database from 'better-sqlite3';
|
||||
import { runMigrations } from '../../src/main/db/migrations/index.js';
|
||||
import { NoteRepository } from '../../src/main/repository/NoteRepository.js';
|
||||
|
||||
describe('NoteRepository.search — FTS5', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
db.pragma('foreign_keys = ON');
|
||||
runMigrations(db);
|
||||
repo = new NoteRepository(db);
|
||||
const a = repo.create({ rawText: '오늘 월요일 회의 정리' });
|
||||
repo.updateAiResult(a.id, { title: '회의록', summary: '월요일', tags: ['기획', '회의'], provider: 'p' });
|
||||
const b = repo.create({ rawText: '결재 요청 본문' });
|
||||
repo.updateAiResult(b.id, { title: '결재', summary: '요청서', tags: ['결재'], provider: 'p' });
|
||||
const c = repo.create({ rawText: '버려진 메모' });
|
||||
repo.setStatus(c.id, 'trashed', null);
|
||||
});
|
||||
|
||||
afterEach(() => { db.close(); });
|
||||
|
||||
it('빈 query → 빈 배열', () => {
|
||||
expect(repo.search('')).toEqual([]);
|
||||
expect(repo.search(' ')).toEqual([]);
|
||||
});
|
||||
|
||||
it('keyword 매칭 → hydrated Note', () => {
|
||||
const r = repo.search('월요일');
|
||||
expect(r.length).toBeGreaterThan(0);
|
||||
const titles = r.map((n) => n.aiTitle);
|
||||
expect(titles).toContain('회의록');
|
||||
});
|
||||
|
||||
it('multi-token implicit AND', () => {
|
||||
const r1 = repo.search('회의 월요일');
|
||||
expect(r1.length).toBeGreaterThan(0);
|
||||
const r2 = repo.search('회의 결재'); // 동시 매칭 노트 없음
|
||||
expect(r2).toEqual([]);
|
||||
});
|
||||
|
||||
it('default 는 trashed 제외', () => {
|
||||
const r = repo.search('버려진');
|
||||
expect(r).toEqual([]);
|
||||
});
|
||||
|
||||
it('status filter 명시 시 해당 status 만', () => {
|
||||
const r = repo.search('버려진', { status: 'trashed' });
|
||||
expect(r.length).toBe(1);
|
||||
});
|
||||
|
||||
it('FTS5 special char 안전 처리', () => {
|
||||
expect(() => repo.search('"회의*" (월요일):')).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -852,3 +852,308 @@ describe('NoteRepository — failed retry helpers', () => {
|
||||
expect(repo.getTagIdByName('nothere')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('NoteRepository — setStatus + listByStatus', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
repo = new NoteRepository(db);
|
||||
});
|
||||
|
||||
it('setStatus updates status + reason + status_changed_at + updated_at', () => {
|
||||
const { id } = repo.create({ rawText: 'test' });
|
||||
repo.setStatus(id, 'completed', '결재 끝', new Date('2026-05-10T00:00:00.000Z'));
|
||||
const note = repo.findById(id)!;
|
||||
expect(note.status).toBe('completed');
|
||||
expect(note.moveReason).toBe('결재 끝');
|
||||
expect(note.statusChangedAt).toBe('2026-05-10T00:00:00.000Z');
|
||||
expect(note.updatedAt).toBe('2026-05-10T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('setStatus accepts null reason', () => {
|
||||
const { id } = repo.create({ rawText: 'test' });
|
||||
repo.setStatus(id, 'archived', null, new Date('2026-05-10T00:00:00.000Z'));
|
||||
const note = repo.findById(id)!;
|
||||
expect(note.status).toBe('archived');
|
||||
expect(note.moveReason).toBeNull();
|
||||
});
|
||||
|
||||
it('setStatus default now uses Date.now()', () => {
|
||||
const { id } = repo.create({ rawText: 'test' });
|
||||
const before = Date.now();
|
||||
repo.setStatus(id, 'completed', null);
|
||||
const after = Date.now();
|
||||
const note = repo.findById(id)!;
|
||||
const ts = new Date(note.statusChangedAt!).getTime();
|
||||
expect(ts).toBeGreaterThanOrEqual(before);
|
||||
expect(ts).toBeLessThanOrEqual(after);
|
||||
});
|
||||
|
||||
it('listByStatus filters correctly', () => {
|
||||
const idA = repo.create({ rawText: 'a' }).id;
|
||||
const idB = repo.create({ rawText: 'b' }).id;
|
||||
repo.setStatus(idB, 'archived', null, new Date('2026-05-10T00:00:00.000Z'));
|
||||
|
||||
const active = repo.listByStatus('active', { limit: 10 });
|
||||
const archived = repo.listByStatus('archived', { limit: 10 });
|
||||
expect(active.map((n) => n.id)).toContain(idA);
|
||||
expect(active.map((n) => n.id)).not.toContain(idB);
|
||||
expect(archived.map((n) => n.id)).toContain(idB);
|
||||
expect(archived.map((n) => n.id)).not.toContain(idA);
|
||||
});
|
||||
|
||||
it('listByStatus orders by status_changed_at DESC (NULL falls back to created_at)', () => {
|
||||
const a = repo.create({ rawText: 'a' }).id;
|
||||
const b = repo.create({ rawText: 'b' }).id;
|
||||
const c = repo.create({ rawText: 'c' }).id;
|
||||
repo.setStatus(a, 'completed', null, new Date('2026-05-10T00:00:00.000Z'));
|
||||
repo.setStatus(b, 'completed', null, new Date('2026-05-12T00:00:00.000Z'));
|
||||
repo.setStatus(c, 'completed', null, new Date('2026-05-11T00:00:00.000Z'));
|
||||
const r = repo.listByStatus('completed', { limit: 10 });
|
||||
expect(r.map((n) => n.id)).toEqual([b, c, a]);
|
||||
});
|
||||
|
||||
it('listByStatus respects limit (cap 200)', () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const id = repo.create({ rawText: `n${i}` }).id;
|
||||
repo.setStatus(id, 'archived', null, new Date(`2026-05-${10 + i}T00:00:00.000Z`));
|
||||
}
|
||||
expect(repo.listByStatus('archived', { limit: 3 })).toHaveLength(3);
|
||||
expect(repo.listByStatus('archived', { limit: 100 })).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('listByStatus default limit 200', () => {
|
||||
repo.create({ rawText: 'a' });
|
||||
expect(repo.listByStatus('active')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('setStatus("trashed") syncs deleted_at (backward compat)', () => {
|
||||
const { id } = repo.create({ rawText: 't' });
|
||||
repo.setStatus(id, 'trashed', null, new Date('2026-05-15T00:00:00.000Z'));
|
||||
const row = db.prepare(`SELECT deleted_at FROM notes WHERE id=?`).get(id) as {
|
||||
deleted_at: string;
|
||||
};
|
||||
expect(row.deleted_at).toBe('2026-05-15T00:00:00.000Z');
|
||||
expect(repo.findById(id)!.deletedAt).toBe('2026-05-15T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('setStatus("active") clears deleted_at (restore from trash)', () => {
|
||||
const { id } = repo.create({ rawText: 'r' });
|
||||
repo.setStatus(id, 'trashed', null, new Date('2026-05-15T00:00:00.000Z'));
|
||||
repo.setStatus(id, 'active', null, new Date('2026-05-16T00:00:00.000Z'));
|
||||
const row = db.prepare(`SELECT deleted_at FROM notes WHERE id=?`).get(id) as {
|
||||
deleted_at: string | null;
|
||||
};
|
||||
expect(row.deleted_at).toBeNull();
|
||||
expect(repo.findById(id)!.deletedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('setStatus("completed"/"archived") also clears deleted_at', () => {
|
||||
const { id } = repo.create({ rawText: 'r' });
|
||||
repo.setStatus(id, 'trashed', null, new Date('2026-05-15T00:00:00.000Z'));
|
||||
repo.setStatus(id, 'completed', null, new Date('2026-05-16T00:00:00.000Z'));
|
||||
expect(repo.findById(id)!.deletedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('newly created note hydrates as status=active', () => {
|
||||
const { id } = repo.create({ rawText: 'fresh' });
|
||||
const note = repo.findById(id)!;
|
||||
expect(note.status).toBe('active');
|
||||
expect(note.statusChangedAt).toBeNull();
|
||||
expect(note.moveReason).toBeNull();
|
||||
});
|
||||
|
||||
it('countByStatus returns accurate count per status', () => {
|
||||
const a = repo.create({ rawText: 'a' }).id; // active
|
||||
repo.create({ rawText: 'b' }); // active
|
||||
const c = repo.create({ rawText: 'c' }).id;
|
||||
repo.setStatus(c, 'completed', null, new Date('2026-05-10T00:00:00.000Z'));
|
||||
const d = repo.create({ rawText: 'd' }).id;
|
||||
repo.setStatus(d, 'archived', null, new Date('2026-05-10T00:00:00.000Z'));
|
||||
const e = repo.create({ rawText: 'e' }).id;
|
||||
repo.setStatus(e, 'trashed', null, new Date('2026-05-10T00:00:00.000Z'));
|
||||
|
||||
expect(repo.countByStatus('active')).toBe(2);
|
||||
expect(repo.countByStatus('completed')).toBe(1);
|
||||
expect(repo.countByStatus('archived')).toBe(1);
|
||||
expect(repo.countByStatus('trashed')).toBe(1);
|
||||
// sanity — a 가 여전히 active.
|
||||
expect(repo.findById(a)!.status).toBe('active');
|
||||
});
|
||||
|
||||
it('restoreNote sets status=active + clears moveReason', () => {
|
||||
const { id } = repo.create({ rawText: 'r' });
|
||||
repo.setStatus(id, 'trashed', '실수', new Date('2026-05-15T00:00:00.000Z'));
|
||||
expect(repo.findById(id)!.status).toBe('trashed');
|
||||
expect(repo.findById(id)!.moveReason).toBe('실수');
|
||||
|
||||
repo.restoreNote(id);
|
||||
|
||||
const after = repo.findById(id)!;
|
||||
expect(after.status).toBe('active');
|
||||
expect(after.moveReason).toBeNull();
|
||||
expect(after.deletedAt).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// v0.2.9 Cut B Task 16 — settings.ai_enabled OFF→ON 전환 시 disabled 메모 일괄 재투입.
|
||||
describe('NoteRepository.requeueDisabled', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
repo = new NoteRepository(db);
|
||||
});
|
||||
|
||||
it('changes ai_status="disabled" → "pending" + INSERT pending_jobs', () => {
|
||||
const { id } = repo.create({ rawText: 't', aiStatus: 'disabled' });
|
||||
const count = repo.requeueDisabled(new Date('2026-05-09T00:00:00Z'));
|
||||
expect(count).toBe(1);
|
||||
const note = repo.findById(id);
|
||||
expect(note?.aiStatus).toBe('pending');
|
||||
const job = db.prepare(`SELECT * FROM pending_jobs WHERE note_id=?`).get(id);
|
||||
expect(job).toBeDefined();
|
||||
});
|
||||
|
||||
it('does not affect non-disabled notes', () => {
|
||||
const idP = repo.create({ rawText: 'p', aiStatus: 'pending' }).id;
|
||||
const idC = repo.create({ rawText: 'c' }).id;
|
||||
repo.updateAiResult(idC, { title: 't', summary: 'a\nb\nc', tags: [], provider: 'p' });
|
||||
repo.requeueDisabled(new Date());
|
||||
expect(repo.findById(idP)?.aiStatus).toBe('pending');
|
||||
expect(repo.findById(idC)?.aiStatus).toBe('done');
|
||||
});
|
||||
|
||||
it('returns 0 when no disabled notes', () => {
|
||||
const count = repo.requeueDisabled(new Date());
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NoteRepository.countByAiStatus', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
repo = new NoteRepository(db);
|
||||
});
|
||||
|
||||
it('returns count per ai_status', () => {
|
||||
repo.create({ rawText: 'a', aiStatus: 'disabled' });
|
||||
repo.create({ rawText: 'b', aiStatus: 'disabled' });
|
||||
repo.create({ rawText: 'c', aiStatus: 'pending' });
|
||||
expect(repo.countByAiStatus('disabled')).toBe(2);
|
||||
expect(repo.countByAiStatus('pending')).toBe(1);
|
||||
expect(repo.countByAiStatus('done')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NoteRepository — note_revisions', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
repo = new NoteRepository(db);
|
||||
});
|
||||
|
||||
it('create() 가 첫 revision (edited_by=capture) 을 INSERT 한다', () => {
|
||||
const { id } = repo.create({ rawText: 'hello' });
|
||||
const rows = db
|
||||
.prepare(`SELECT raw_text, edited_by FROM note_revisions WHERE note_id=?`)
|
||||
.all(id) as Array<{ raw_text: string; edited_by: string }>;
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toEqual({ raw_text: 'hello', edited_by: 'capture' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('NoteRepository — notes_fts tags sync (v0.2.11 Cut D)', () => {
|
||||
let db: Database.Database;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
});
|
||||
|
||||
it('updateAiResult 후 notes_fts.tags 가 csv 로 sync', () => {
|
||||
const repo = new NoteRepository(db);
|
||||
const { id } = repo.create({ rawText: '회의 본문' });
|
||||
repo.updateAiResult(id, { title: '제목', summary: '요약', tags: ['기획', '회의'], provider: 'p' });
|
||||
const row = db
|
||||
.prepare(`SELECT tags FROM notes_fts WHERE note_id=?`)
|
||||
.get(id) as { tags: string };
|
||||
expect(row.tags.split(' ').sort()).toEqual(['기획', '회의']);
|
||||
});
|
||||
|
||||
it('updateUserAiFields tags 갱신 후 notes_fts.tags 동기', () => {
|
||||
const repo = new NoteRepository(db);
|
||||
const { id } = repo.create({ rawText: '본문' });
|
||||
repo.updateAiResult(id, { title: 't', summary: 's', tags: ['old'], provider: 'p' });
|
||||
repo.updateUserAiFields(id, { tags: ['new1', 'new2'] });
|
||||
const row = db
|
||||
.prepare(`SELECT tags FROM notes_fts WHERE note_id=?`)
|
||||
.get(id) as { tags: string };
|
||||
expect(row.tags.split(' ').sort()).toEqual(['new1', 'new2']);
|
||||
});
|
||||
|
||||
it('importNote insert path: notes_fts.tags 가 csv 로 sync (final review fix)', () => {
|
||||
const repo = new NoteRepository(db);
|
||||
const r = repo.importNote({
|
||||
id: '00000000-0000-0000-0000-000000000010',
|
||||
rawText: 'imported with tags',
|
||||
createdAt: '2026-04-01T00:00:00Z',
|
||||
updatedAt: '2026-04-01T00:00:00Z',
|
||||
aiTitle: 'imported title',
|
||||
aiSummary: 'imported summary',
|
||||
titleEditedByUser: false,
|
||||
summaryEditedByUser: false,
|
||||
aiProvider: 'p',
|
||||
aiGeneratedAt: '2026-04-01T00:00:00Z',
|
||||
userIntent: null,
|
||||
intentPromptedAt: null,
|
||||
tags: [
|
||||
{ name: '기획', source: 'ai' },
|
||||
{ name: '회의', source: 'user' }
|
||||
]
|
||||
});
|
||||
expect(r.status).toBe('inserted');
|
||||
const row = db
|
||||
.prepare(`SELECT tags FROM notes_fts WHERE note_id=?`)
|
||||
.get(r.id) as { tags: string };
|
||||
expect(row.tags.split(' ').sort()).toEqual(['기획', '회의']);
|
||||
});
|
||||
|
||||
it('importNote fork path: forked 노트의 notes_fts.tags 동기 (final review fix)', () => {
|
||||
const repo = new NoteRepository(db);
|
||||
const existing = repo.create({ rawText: 'v1' });
|
||||
const r = repo.importNote({
|
||||
id: existing.id,
|
||||
rawText: 'imported v2 with tags',
|
||||
createdAt: '2026-04-01T00:00:00Z',
|
||||
updatedAt: '2026-04-01T00:00:00Z',
|
||||
aiTitle: null,
|
||||
aiSummary: null,
|
||||
titleEditedByUser: false,
|
||||
summaryEditedByUser: false,
|
||||
aiProvider: null,
|
||||
aiGeneratedAt: null,
|
||||
userIntent: null,
|
||||
intentPromptedAt: null,
|
||||
tags: [{ name: '결재', source: 'user' }]
|
||||
});
|
||||
expect(r.status).toBe('forked');
|
||||
expect(r.id).not.toBe(existing.id);
|
||||
const row = db
|
||||
.prepare(`SELECT tags FROM notes_fts WHERE note_id=?`)
|
||||
.get(r.id) as { tags: string };
|
||||
expect(row.tags).toBe('결재');
|
||||
});
|
||||
});
|
||||
|
||||
98
tests/unit/NoteRepository.upsertFromSync.test.ts
Normal file
98
tests/unit/NoteRepository.upsertFromSync.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import Database from 'better-sqlite3';
|
||||
import { runMigrations } from '../../src/main/db/migrations/index.js';
|
||||
import { NoteRepository } from '../../src/main/repository/NoteRepository.js';
|
||||
|
||||
const baseInput = {
|
||||
id: '00000000-0000-0000-0000-000000000001',
|
||||
rawText: 'sync 본문',
|
||||
createdAt: '2026-05-09T00:00:00Z',
|
||||
updatedAt: '2026-05-10T00:00:00Z',
|
||||
aiTitle: 'sync 제목',
|
||||
aiSummary: 'sync 요약',
|
||||
titleEditedByUser: false,
|
||||
summaryEditedByUser: false,
|
||||
aiProvider: 'p',
|
||||
aiGeneratedAt: '2026-05-10T00:00:00Z',
|
||||
userIntent: null,
|
||||
intentPromptedAt: null,
|
||||
tags: [{ name: '동기', source: 'user' as const }],
|
||||
status: 'active' as const,
|
||||
statusChangedAt: null,
|
||||
moveReason: null,
|
||||
dueDate: null,
|
||||
dueDateEditedByUser: false
|
||||
};
|
||||
|
||||
describe('NoteRepository.upsertFromSync', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
db.pragma('foreign_keys = ON');
|
||||
runMigrations(db);
|
||||
repo = new NoteRepository(db);
|
||||
});
|
||||
|
||||
afterEach(() => { db.close(); });
|
||||
|
||||
it('id 없음 → INSERT (status=inserted) + capture revision + tags FTS sync', () => {
|
||||
const r = repo.upsertFromSync(baseInput);
|
||||
expect(r.status).toBe('inserted');
|
||||
expect(r.id).toBe(baseInput.id);
|
||||
const note = repo.findById(baseInput.id);
|
||||
expect(note?.rawText).toBe('sync 본문');
|
||||
expect(note?.aiTitle).toBe('sync 제목');
|
||||
const revs = repo.listRevisions(baseInput.id);
|
||||
expect(revs).toHaveLength(1);
|
||||
expect(revs[0]!.editedBy).toBe('capture');
|
||||
const fts = db.prepare(`SELECT tags FROM notes_fts WHERE note_id=?`).get(baseInput.id) as { tags: string };
|
||||
expect(fts.tags).toBe('동기');
|
||||
});
|
||||
|
||||
it('id 있음 + raw_text 동일 + source 더 최신 → metadata 갱신 (status=updated)', () => {
|
||||
const created = repo.create({ rawText: 'sync 본문' });
|
||||
repo.updateAiResult(created.id, { title: '옛 제목', summary: '옛 요약', tags: ['old'], provider: 'p' });
|
||||
db.prepare(`UPDATE notes SET updated_at=? WHERE id=?`).run('2026-05-08T00:00:00Z', created.id);
|
||||
const r = repo.upsertFromSync({ ...baseInput, id: created.id });
|
||||
expect(r.status).toBe('updated');
|
||||
const note = repo.findById(created.id);
|
||||
expect(note?.aiTitle).toBe('sync 제목');
|
||||
expect(note?.tags.map((t) => t.name)).toEqual(['동기']);
|
||||
const fts = db.prepare(`SELECT tags FROM notes_fts WHERE note_id=?`).get(created.id) as { tags: string };
|
||||
expect(fts.tags).toBe('동기');
|
||||
});
|
||||
|
||||
it('id 있음 + raw_text 동일 + source 더 옛 → skip (status=skipped)', () => {
|
||||
const created = repo.create({ rawText: 'sync 본문' });
|
||||
repo.updateAiResult(created.id, { title: '신선한 제목', summary: 'fresh', tags: ['x'], provider: 'p' });
|
||||
db.prepare(`UPDATE notes SET updated_at=? WHERE id=?`).run('2026-05-12T00:00:00Z', created.id);
|
||||
const r = repo.upsertFromSync({ ...baseInput, id: created.id, updatedAt: '2026-05-10T00:00:00Z' });
|
||||
expect(r.status).toBe('skipped');
|
||||
const note = repo.findById(created.id);
|
||||
expect(note?.aiTitle).toBe('신선한 제목');
|
||||
});
|
||||
|
||||
it('id 있음 + raw_text 다름 + source 더 최신 → updateRawText (status=updated) + new user revision', () => {
|
||||
const created = repo.create({ rawText: 'old text' });
|
||||
db.prepare(`UPDATE notes SET updated_at=? WHERE id=?`).run('2026-05-08T00:00:00Z', created.id);
|
||||
const r = repo.upsertFromSync({ ...baseInput, id: created.id, rawText: 'new sync text' });
|
||||
expect(r.status).toBe('updated');
|
||||
const note = repo.findById(created.id);
|
||||
expect(note?.rawText).toBe('new sync text');
|
||||
const revs = repo.listRevisions(created.id);
|
||||
expect(revs).toHaveLength(2); // capture (old) + user (new)
|
||||
expect(revs[0]!.editedBy).toBe('user');
|
||||
expect(revs[0]!.rawText).toBe('new sync text');
|
||||
});
|
||||
|
||||
it('id 있음 + raw_text 다름 + source 더 옛 → skip', () => {
|
||||
const created = repo.create({ rawText: 'local fresh' });
|
||||
db.prepare(`UPDATE notes SET updated_at=? WHERE id=?`).run('2026-05-15T00:00:00Z', created.id);
|
||||
const r = repo.upsertFromSync({ ...baseInput, id: created.id, rawText: 'old sync text', updatedAt: '2026-05-10T00:00:00Z' });
|
||||
expect(r.status).toBe('skipped');
|
||||
const note = repo.findById(created.id);
|
||||
expect(note?.rawText).toBe('local fresh');
|
||||
});
|
||||
});
|
||||
171
tests/unit/NoteRevisions.test.ts
Normal file
171
tests/unit/NoteRevisions.test.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import Database from 'better-sqlite3';
|
||||
import { runMigrations } from '../../src/main/db/migrations/index.js';
|
||||
import { NoteRepository } from '../../src/main/repository/NoteRepository.js';
|
||||
|
||||
describe('NoteRepository — note_revisions', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
db.pragma('foreign_keys = ON');
|
||||
runMigrations(db);
|
||||
repo = new NoteRepository(db);
|
||||
});
|
||||
|
||||
afterEach(() => { db.close(); });
|
||||
|
||||
describe('updateRawText', () => {
|
||||
it('notes.raw_text 갱신 + 새 user revision INSERT (single transaction)', () => {
|
||||
const { id } = repo.create({ rawText: 'v1' });
|
||||
const t = new Date('2026-05-10T00:00:00Z');
|
||||
repo.updateRawText(id, 'v2', t);
|
||||
|
||||
const note = db.prepare(`SELECT raw_text, updated_at FROM notes WHERE id=?`).get(id) as {
|
||||
raw_text: string;
|
||||
updated_at: string;
|
||||
};
|
||||
expect(note.raw_text).toBe('v2');
|
||||
expect(note.updated_at).toBe('2026-05-10T00:00:00.000Z');
|
||||
|
||||
const revs = db
|
||||
.prepare(`SELECT raw_text, edited_by, edited_at FROM note_revisions WHERE note_id=? ORDER BY rev_id ASC`)
|
||||
.all(id) as Array<{ raw_text: string; edited_by: string; edited_at: string }>;
|
||||
expect(revs).toHaveLength(2); // capture + user
|
||||
expect(revs.at(0)!.edited_by).toBe('capture');
|
||||
expect(revs.at(0)!.raw_text).toBe('v1');
|
||||
expect(revs.at(1)!.edited_by).toBe('user');
|
||||
expect(revs.at(1)!.raw_text).toBe('v2');
|
||||
expect(revs.at(1)!.edited_at).toBe('2026-05-10T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('atomic: 두 번 호출 시 두 revision 모두 누적 (chain history)', () => {
|
||||
const { id } = repo.create({ rawText: 'v1' });
|
||||
repo.updateRawText(id, 'v2', new Date('2026-05-10T00:00:00Z'));
|
||||
repo.updateRawText(id, 'v3', new Date('2026-05-11T00:00:00Z'));
|
||||
const revs = db
|
||||
.prepare(`SELECT raw_text FROM note_revisions WHERE note_id=? ORDER BY rev_id ASC`)
|
||||
.all(id) as Array<{ raw_text: string }>;
|
||||
expect(revs.map((r) => r.raw_text)).toEqual(['v1', 'v2', 'v3']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listRevisions', () => {
|
||||
it('DESC 순서 + edited_by + camelCase hydrate', () => {
|
||||
const { id } = repo.create({ rawText: 'v1' });
|
||||
repo.updateRawText(id, 'v2', new Date('2026-05-10T00:00:00Z'));
|
||||
repo.updateRawText(id, 'v3', new Date('2026-05-11T00:00:00Z'));
|
||||
|
||||
const revs = repo.listRevisions(id);
|
||||
expect(revs).toHaveLength(3);
|
||||
expect(revs.at(0)!.rawText).toBe('v3');
|
||||
expect(revs.at(0)!.editedBy).toBe('user');
|
||||
expect(revs.at(1)!.rawText).toBe('v2');
|
||||
expect(revs.at(1)!.editedBy).toBe('user');
|
||||
expect(revs.at(2)!.rawText).toBe('v1');
|
||||
expect(revs.at(2)!.editedBy).toBe('capture');
|
||||
expect(typeof revs.at(0)!.revId).toBe('number');
|
||||
expect(revs.at(0)!.noteId).toBe(id);
|
||||
expect(revs.at(0)!.editedAt).toBe('2026-05-11T00:00:00.000Z');
|
||||
});
|
||||
});
|
||||
|
||||
describe('restoreRevision', () => {
|
||||
it('옛 raw_text 를 새 user revision 으로 INSERT + notes.raw_text 갱신', () => {
|
||||
const { id } = repo.create({ rawText: 'v1' });
|
||||
repo.updateRawText(id, 'v2', new Date('2026-05-10T00:00:00Z'));
|
||||
repo.updateRawText(id, 'v3', new Date('2026-05-11T00:00:00Z'));
|
||||
|
||||
const revs = repo.listRevisions(id);
|
||||
const v1 = revs.find((r) => r.rawText === 'v1');
|
||||
expect(v1).toBeDefined();
|
||||
|
||||
repo.restoreRevision(id, v1!.revId, new Date('2026-05-12T00:00:00Z'));
|
||||
|
||||
const note = db.prepare(`SELECT raw_text FROM notes WHERE id=?`).get(id) as { raw_text: string };
|
||||
expect(note.raw_text).toBe('v1');
|
||||
|
||||
const after = repo.listRevisions(id);
|
||||
expect(after).toHaveLength(4); // v1(capture) + v2 + v3 + v1 restored (user)
|
||||
expect(after.at(0)!.rawText).toBe('v1');
|
||||
expect(after.at(0)!.editedBy).toBe('user');
|
||||
expect(after.at(0)!.editedAt).toBe('2026-05-12T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('존재하지 않는 revId 는 throw', () => {
|
||||
const { id } = repo.create({ rawText: 'v1' });
|
||||
expect(() => repo.restoreRevision(id, 999_999, new Date())).toThrow(/not found/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AiWorker source 회귀', () => {
|
||||
it('updateRawText 후 findById 가 latest raw_text 반환 (옛 revision 미노출)', () => {
|
||||
const { id } = repo.create({ rawText: 'v1' });
|
||||
repo.updateRawText(id, 'v2 corrected', new Date('2026-05-10T00:00:00Z'));
|
||||
const note = repo.findById(id);
|
||||
expect(note?.rawText).toBe('v2 corrected');
|
||||
});
|
||||
});
|
||||
|
||||
describe('importNote — capture revision 생성 (final review 보강)', () => {
|
||||
it('insert path: imported note 가 capture revision (createdAt = edited_at) 을 함께 갖는다', () => {
|
||||
const r = repo.importNote({
|
||||
id: '00000000-0000-0000-0000-000000000001',
|
||||
rawText: 'imported text',
|
||||
createdAt: '2026-04-01T00:00:00Z',
|
||||
updatedAt: '2026-04-02T00:00:00Z',
|
||||
aiTitle: 't',
|
||||
aiSummary: 's',
|
||||
titleEditedByUser: false,
|
||||
summaryEditedByUser: false,
|
||||
aiProvider: 'p',
|
||||
aiGeneratedAt: '2026-04-02T00:00:00Z',
|
||||
userIntent: null,
|
||||
intentPromptedAt: null,
|
||||
tags: []
|
||||
});
|
||||
expect(r.status).toBe('inserted');
|
||||
|
||||
const revs = repo.listRevisions(r.id);
|
||||
expect(revs).toHaveLength(1);
|
||||
expect(revs[0]!.rawText).toBe('imported text');
|
||||
expect(revs[0]!.editedBy).toBe('capture');
|
||||
expect(revs[0]!.editedAt).toBe('2026-04-01T00:00:00Z');
|
||||
});
|
||||
|
||||
it('fork path: id 충돌 시 fresh uuidv7 + 새 capture revision (옛 노트 revision 보존)', () => {
|
||||
// 기존 노트 (capture 'v1' revision 자동 생성됨)
|
||||
const existing = repo.create({ rawText: 'v1' });
|
||||
// 동일 id 로 다른 raw_text 를 import → fork
|
||||
const r = repo.importNote({
|
||||
id: existing.id,
|
||||
rawText: 'imported v2',
|
||||
createdAt: '2026-04-01T00:00:00Z',
|
||||
updatedAt: '2026-04-02T00:00:00Z',
|
||||
aiTitle: null,
|
||||
aiSummary: null,
|
||||
titleEditedByUser: false,
|
||||
summaryEditedByUser: false,
|
||||
aiProvider: null,
|
||||
aiGeneratedAt: null,
|
||||
userIntent: null,
|
||||
intentPromptedAt: null,
|
||||
tags: []
|
||||
});
|
||||
expect(r.status).toBe('forked');
|
||||
expect(r.id).not.toBe(existing.id);
|
||||
|
||||
// forked 노트에 capture revision
|
||||
const forkRevs = repo.listRevisions(r.id);
|
||||
expect(forkRevs).toHaveLength(1);
|
||||
expect(forkRevs[0]!.rawText).toBe('imported v2');
|
||||
expect(forkRevs[0]!.editedBy).toBe('capture');
|
||||
|
||||
// 기존 노트의 revision 은 그대로 보존
|
||||
const existingRevs = repo.listRevisions(existing.id);
|
||||
expect(existingRevs).toHaveLength(1);
|
||||
expect(existingRevs[0]!.rawText).toBe('v1');
|
||||
});
|
||||
});
|
||||
});
|
||||
46
tests/unit/OllamaBanner.test.tsx
Normal file
46
tests/unit/OllamaBanner.test.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { render, cleanup } from '@testing-library/react';
|
||||
|
||||
vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
inboxApi: {
|
||||
ollamaRecheck: vi.fn(async () => ({ ok: true }))
|
||||
}
|
||||
}));
|
||||
|
||||
import { OllamaBanner } from '../../src/renderer/inbox/components/OllamaBanner';
|
||||
import { useInbox } from '../../src/renderer/inbox/store';
|
||||
|
||||
describe('OllamaBanner — ai_enabled (v0.2.9 Cut B Task 14)', () => {
|
||||
beforeEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('renders nothing when ai_enabled=false (even if ollama unreachable)', () => {
|
||||
useInbox.setState({
|
||||
ai_enabled: false,
|
||||
ollamaStatus: { ok: false, reason: 'unreachable' }
|
||||
} as Partial<ReturnType<typeof useInbox.getState>>);
|
||||
const { container } = render(<OllamaBanner />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('renders nothing when ai_enabled=true and ollama ok', () => {
|
||||
useInbox.setState({
|
||||
ai_enabled: true,
|
||||
ollamaStatus: { ok: true }
|
||||
} as Partial<ReturnType<typeof useInbox.getState>>);
|
||||
const { container } = render(<OllamaBanner />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('renders banner when ai_enabled=true and ollama not ok', () => {
|
||||
useInbox.setState({
|
||||
ai_enabled: true,
|
||||
ollamaStatus: { ok: false, reason: 'unreachable' }
|
||||
} as Partial<ReturnType<typeof useInbox.getState>>);
|
||||
const { container } = render(<OllamaBanner />);
|
||||
expect(container).not.toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
58
tests/unit/OnboardingWizard.test.tsx
Normal file
58
tests/unit/OnboardingWizard.test.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
|
||||
|
||||
const { mockSetAi, mockSetCompleted } = vi.hoisted(() => ({
|
||||
mockSetAi: vi.fn(async () => ({ ok: true as const })),
|
||||
mockSetCompleted: vi.fn(async () => ({ ok: true as const }))
|
||||
}));
|
||||
vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
inboxApi: { setAiEnabled: mockSetAi, setOnboardingCompleted: mockSetCompleted }
|
||||
}));
|
||||
|
||||
import { OnboardingWizard } from '../../src/renderer/inbox/components/OnboardingWizard';
|
||||
|
||||
describe('OnboardingWizard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('renders 3 buttons + 설치 가이드 link', () => {
|
||||
render(<OnboardingWizard onClose={vi.fn()} />);
|
||||
expect(screen.getByRole('button', { name: /AI 자동 처리 사용/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /원문만 저장/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /나중에 설정/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: /ollama\.com/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('"AI 사용" → setAiEnabled(true) + setOnboardingCompleted(true) + onClose', async () => {
|
||||
const onClose = vi.fn();
|
||||
render(<OnboardingWizard onClose={onClose} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /AI 자동 처리 사용/ }));
|
||||
await waitFor(() => {
|
||||
expect(mockSetAi).toHaveBeenCalledWith(true);
|
||||
expect(mockSetCompleted).toHaveBeenCalledWith(true);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('"원문만" → setAiEnabled(false) + setOnboardingCompleted(true)', async () => {
|
||||
render(<OnboardingWizard onClose={vi.fn()} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /원문만 저장/ }));
|
||||
await waitFor(() => {
|
||||
expect(mockSetAi).toHaveBeenCalledWith(false);
|
||||
expect(mockSetCompleted).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('"나중에" → setOnboardingCompleted(true) only (no setAiEnabled)', async () => {
|
||||
render(<OnboardingWizard onClose={vi.fn()} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /나중에 설정/ }));
|
||||
await waitFor(() => {
|
||||
expect(mockSetCompleted).toHaveBeenCalledWith(true);
|
||||
expect(mockSetAi).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
64
tests/unit/ReviewView.test.tsx
Normal file
64
tests/unit/ReviewView.test.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { render, screen, cleanup } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
const baseState = {
|
||||
reviewData: {
|
||||
totalCount: 12,
|
||||
recentNotes: [],
|
||||
tagCounts: [{ tag: '회의', count: 5 }, { tag: '결재', count: 3 }],
|
||||
dueProgress: { total: 10, passed: 4, pending: 6 }
|
||||
}
|
||||
};
|
||||
|
||||
vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
inboxApi: {
|
||||
openMedia: vi.fn(),
|
||||
deleteNote: vi.fn(),
|
||||
restoreNote: vi.fn(),
|
||||
permanentDeleteNote: vi.fn(),
|
||||
updateAiFields: vi.fn(),
|
||||
setDueDate: vi.fn(),
|
||||
setIntent: vi.fn(),
|
||||
dismissIntent: vi.fn(),
|
||||
setStatus: vi.fn(async () => ({ ok: true as const })),
|
||||
classifyStatus: vi.fn(async () => ({ recommended: 'archived' as const, rationale: 'stub' })),
|
||||
updateRawText: vi.fn(async () => ({ ok: true as const })),
|
||||
listRevisions: vi.fn(async () => []),
|
||||
getRevision: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('../../src/renderer/inbox/store.js', () => ({
|
||||
useInbox: Object.assign(
|
||||
(selector?: (s: typeof baseState) => unknown) => (selector ? selector(baseState) : baseState),
|
||||
{ getState: () => baseState }
|
||||
)
|
||||
}));
|
||||
|
||||
import { ReviewView } from '../../src/renderer/inbox/components/ReviewView';
|
||||
|
||||
describe('ReviewView', () => {
|
||||
beforeEach(() => { cleanup(); });
|
||||
|
||||
it('daily — 라벨 + totalCount + tagBar + dueProgress 렌더', () => {
|
||||
render(<ReviewView period="daily" />);
|
||||
expect(screen.getByText(/일간/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/총.*12건/)).toBeInTheDocument();
|
||||
expect(screen.getByText('회의')).toBeInTheDocument();
|
||||
expect(screen.getByText('결재')).toBeInTheDocument();
|
||||
expect(screen.getByText(/4.*\/.*10/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('weekly — 라벨 weekly', () => {
|
||||
render(<ReviewView period="weekly" />);
|
||||
expect(screen.getByText(/주간/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('monthly — 라벨 monthly', () => {
|
||||
render(<ReviewView period="monthly" />);
|
||||
expect(screen.getByText(/월간/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
64
tests/unit/RevisionHistoryModal.test.tsx
Normal file
64
tests/unit/RevisionHistoryModal.test.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
const { mockListRevisions, mockRestoreRevision } = vi.hoisted(() => ({
|
||||
mockListRevisions: vi.fn(),
|
||||
mockRestoreRevision: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
inboxApi: {
|
||||
listRevisions: mockListRevisions,
|
||||
restoreRevision: mockRestoreRevision
|
||||
}
|
||||
}));
|
||||
|
||||
import { RevisionHistoryModal } from '../../src/renderer/inbox/components/RevisionHistoryModal';
|
||||
|
||||
describe('RevisionHistoryModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cleanup();
|
||||
mockListRevisions.mockResolvedValue([
|
||||
{ revId: 3, noteId: 'a', rawText: 'v3', editedAt: '2026-05-11T00:00:00Z', editedBy: 'user' },
|
||||
{ revId: 2, noteId: 'a', rawText: 'v2', editedAt: '2026-05-10T00:00:00Z', editedBy: 'user' },
|
||||
{ revId: 1, noteId: 'a', rawText: 'v1', editedAt: '2026-05-01T00:00:00Z', editedBy: 'capture' }
|
||||
]);
|
||||
mockRestoreRevision.mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
it('open 시 listRevisions 호출 + 목록 표시 (capture/user 라벨)', async () => {
|
||||
render(<RevisionHistoryModal noteId="a" onClose={() => {}} onRestored={() => {}} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('v3')).toBeInTheDocument();
|
||||
expect(screen.getByText('v2')).toBeInTheDocument();
|
||||
expect(screen.getByText('v1')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText(/캡처/)).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/사용자/).length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('회수 클릭 → confirm OK → restoreRevision + onRestored 호출 + onClose', async () => {
|
||||
const onRestored = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
render(<RevisionHistoryModal noteId="a" onClose={onClose} onRestored={onRestored} />);
|
||||
await waitFor(() => screen.getByText('v1'));
|
||||
|
||||
const buttons = screen.getAllByRole('button', { name: /회수/ });
|
||||
// last button = oldest (v1)
|
||||
const lastButton = buttons[buttons.length - 1];
|
||||
if (lastButton === undefined) throw new Error('no 회수 button');
|
||||
fireEvent.click(lastButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRestoreRevision).toHaveBeenCalledWith('a', 1);
|
||||
});
|
||||
expect(onRestored).toHaveBeenCalledWith('v1');
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
47
tests/unit/SearchBox.test.tsx
Normal file
47
tests/unit/SearchBox.test.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
const { mockSearchNotes, mockClearSearch } = vi.hoisted(() => ({
|
||||
mockSearchNotes: vi.fn(),
|
||||
mockClearSearch: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('../../src/renderer/inbox/store.js', () => ({
|
||||
useInbox: Object.assign(
|
||||
(selector?: (s: { searchQuery: string }) => unknown) => {
|
||||
const state = { searchQuery: '' };
|
||||
return selector ? selector(state) : state;
|
||||
},
|
||||
{ getState: () => ({ searchNotes: mockSearchNotes, clearSearch: mockClearSearch }) }
|
||||
)
|
||||
}));
|
||||
|
||||
import { SearchBox } from '../../src/renderer/inbox/components/SearchBox';
|
||||
|
||||
describe('SearchBox', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cleanup();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
it('타이핑 → 200ms debounce 후 searchNotes 호출', () => {
|
||||
render(<SearchBox />);
|
||||
const input = screen.getByRole('searchbox');
|
||||
fireEvent.change(input, { target: { value: '회의' } });
|
||||
expect(mockSearchNotes).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(mockSearchNotes).toHaveBeenCalledWith('회의');
|
||||
});
|
||||
|
||||
it('빈 값 → clearSearch 호출', () => {
|
||||
render(<SearchBox />);
|
||||
const input = screen.getByRole('searchbox');
|
||||
fireEvent.change(input, { target: { value: '' } });
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(mockClearSearch).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -40,7 +40,19 @@ vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
profileDir: '/tmp/Inkling'
|
||||
})),
|
||||
openProfileDir: vi.fn(async () => undefined),
|
||||
copyAppInfo: vi.fn(async () => undefined)
|
||||
copyAppInfo: vi.fn(async () => undefined),
|
||||
// v0.2.9 Cut B Task 15-16 — AiProviderSection 의 토글 + disabled 메모 prompt.
|
||||
getSettings: vi.fn(async () => ({ ai_enabled: true, onboarding_completed: true })),
|
||||
setAiEnabled: vi.fn(async () => ({ ok: true as const })),
|
||||
setOnboardingCompleted: vi.fn(async () => ({ ok: true as const })),
|
||||
getDisabledCount: vi.fn(async () => 0),
|
||||
enqueueDisabled: vi.fn(async () => ({ count: 0 })),
|
||||
// v0.3.0 Cut E — SyncSection 이 SettingsPage 에 마운트되어 호출.
|
||||
getSyncStatus: vi.fn(async () => ({ lastAt: null, lastResult: null, nextAt: null })),
|
||||
setSyncAutoEnabled: vi.fn(async () => ({ ok: true as const })),
|
||||
setSyncIntervalMin: vi.fn(async () => ({ ok: true as const })),
|
||||
configureSync: vi.fn(async () => ({ ok: true as const })),
|
||||
testSyncConnection: vi.fn(async () => ({ ok: true as const }))
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -58,12 +70,13 @@ describe('SettingsPage', () => {
|
||||
expect(screen.getByRole('button', { name: /돌아가기/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders 4 section headings', () => {
|
||||
it('renders 5 section headings', () => {
|
||||
render(<SettingsPage />);
|
||||
expect(screen.getByText('AI 제공자')).toBeInTheDocument();
|
||||
expect(screen.getByText('자동 실행')).toBeInTheDocument();
|
||||
expect(screen.getByText('백업 / 복원')).toBeInTheDocument();
|
||||
expect(screen.getByText('정보')).toBeInTheDocument();
|
||||
expect(screen.getByText('동기화')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking "← 돌아가기" sets showSettings to false', () => {
|
||||
|
||||
@@ -54,4 +54,40 @@ describe('SettingsService', () => {
|
||||
expect(existsSync(join(dir, 'settings.json.tmp'))).toBe(false);
|
||||
expect(existsSync(join(dir, 'settings.json'))).toBe(true);
|
||||
});
|
||||
|
||||
describe('v0.3.0 Cut E — sync settings', () => {
|
||||
it('getSyncRepoUrl() defaults to null', async () => {
|
||||
expect(await svc.getSyncRepoUrl()).toBeNull();
|
||||
});
|
||||
|
||||
it('setSyncRepoUrl() / getSyncRepoUrl() round-trip', async () => {
|
||||
await svc.setSyncRepoUrl('git@gitea.example:user/notes.git');
|
||||
expect(await svc.getSyncRepoUrl()).toBe('git@gitea.example:user/notes.git');
|
||||
// setting null clears
|
||||
await svc.setSyncRepoUrl(null);
|
||||
expect(await svc.getSyncRepoUrl()).toBeNull();
|
||||
});
|
||||
|
||||
it('isAutoSyncEnabled() defaults to true', async () => {
|
||||
expect(await svc.isAutoSyncEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('setAutoSyncEnabled() persists', async () => {
|
||||
await svc.setAutoSyncEnabled(false);
|
||||
expect(await svc.isAutoSyncEnabled()).toBe(false);
|
||||
await svc.setAutoSyncEnabled(true);
|
||||
expect(await svc.isAutoSyncEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('getSyncIntervalMin() defaults to 30', async () => {
|
||||
expect(await svc.getSyncIntervalMin()).toBe(30);
|
||||
});
|
||||
|
||||
it('setSyncIntervalMin() persists + rejects values < 5 / non-integer', async () => {
|
||||
await svc.setSyncIntervalMin(15);
|
||||
expect(await svc.getSyncIntervalMin()).toBe(15);
|
||||
await expect(svc.setSyncIntervalMin(3)).rejects.toThrow();
|
||||
await expect(svc.setSyncIntervalMin(10.5)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
75
tests/unit/SyncSection.test.tsx
Normal file
75
tests/unit/SyncSection.test.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
const { mockGetSettings, mockConfigureSync, mockTestSyncConnection, mockGetSyncStatus, mockSetAuto, mockSetInterval } = vi.hoisted(() => ({
|
||||
mockGetSettings: vi.fn(async () => ({ sync_repo_url: '', sync_auto_enabled: true, sync_interval_min: 30 })),
|
||||
mockConfigureSync: vi.fn(async () => ({ ok: true as const })),
|
||||
mockTestSyncConnection: vi.fn(async () => ({ ok: true as const })),
|
||||
mockGetSyncStatus: vi.fn(async () => ({ lastAt: null, lastResult: null, nextAt: null })),
|
||||
mockSetAuto: vi.fn(async () => ({ ok: true as const })),
|
||||
mockSetInterval: vi.fn(async () => ({ ok: true as const }))
|
||||
}));
|
||||
|
||||
vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
inboxApi: {
|
||||
getSettings: mockGetSettings,
|
||||
configureSync: mockConfigureSync,
|
||||
testSyncConnection: mockTestSyncConnection,
|
||||
getSyncStatus: mockGetSyncStatus,
|
||||
setSyncAutoEnabled: mockSetAuto,
|
||||
setSyncIntervalMin: mockSetInterval
|
||||
}
|
||||
}));
|
||||
|
||||
// ConflictModal is imported by SyncSection — mock it to avoid needing listConflicts
|
||||
vi.mock('../../src/renderer/inbox/components/ConflictModal.js', () => ({
|
||||
ConflictModal: () => null
|
||||
}));
|
||||
|
||||
import { SyncSection } from '../../src/renderer/inbox/components/settings/SyncSection';
|
||||
|
||||
describe('SyncSection', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cleanup();
|
||||
mockGetSettings.mockResolvedValue({ sync_repo_url: '', sync_auto_enabled: true, sync_interval_min: 30 });
|
||||
mockGetSyncStatus.mockResolvedValue({ lastAt: null, lastResult: null, nextAt: null });
|
||||
});
|
||||
|
||||
it('빈 URL — 저장/연결 테스트 버튼 + 자동 sync 옵션 hide', async () => {
|
||||
render(<SyncSection />);
|
||||
await waitFor(() => screen.getByRole('button', { name: /저장/ }));
|
||||
expect(screen.queryByText(/자동 sync/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('URL 입력 + 저장 → configureSync 호출 + 자동 sync 옵션 표시', async () => {
|
||||
mockGetSettings.mockResolvedValueOnce({ sync_repo_url: 'git@host:u/r.git', sync_auto_enabled: true, sync_interval_min: 30 });
|
||||
render(<SyncSection />);
|
||||
await waitFor(() => screen.getByText(/자동 sync/));
|
||||
expect(screen.getByText(/자동 sync/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('연결 테스트 클릭 → testSyncConnection 호출 + 결과 표시', async () => {
|
||||
mockGetSettings.mockResolvedValueOnce({ sync_repo_url: 'git@host:u/r.git', sync_auto_enabled: true, sync_interval_min: 30 });
|
||||
render(<SyncSection />);
|
||||
await waitFor(() => screen.getByRole('button', { name: /연결 테스트/ }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /연결 테스트/ }));
|
||||
await waitFor(() => {
|
||||
expect(mockTestSyncConnection).toHaveBeenCalled();
|
||||
expect(screen.getByText(/연결 성공/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('자동 sync 토글 → setSyncAutoEnabled 호출', async () => {
|
||||
mockGetSettings.mockResolvedValueOnce({ sync_repo_url: 'git@host:u/r.git', sync_auto_enabled: true, sync_interval_min: 30 });
|
||||
render(<SyncSection />);
|
||||
await waitFor(() => screen.getByLabelText(/자동 sync/));
|
||||
fireEvent.click(screen.getByLabelText(/자동 sync/));
|
||||
await waitFor(() => {
|
||||
expect(mockSetAuto).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
107
tests/unit/SyncService.bidirectional.test.ts
Normal file
107
tests/unit/SyncService.bidirectional.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { SyncService } from '../../src/main/services/SyncService.js';
|
||||
|
||||
vi.mock('../../src/main/services/GitClient.js');
|
||||
import { GitClient } from '../../src/main/services/GitClient.js';
|
||||
|
||||
describe('SyncService.sync — 양방향', () => {
|
||||
let svc: SyncService;
|
||||
let exportSvc: { export: ReturnType<typeof vi.fn> };
|
||||
let importSvc: { applySyncFromDir: ReturnType<typeof vi.fn> };
|
||||
let gitInstance: {
|
||||
isRepo: ReturnType<typeof vi.fn>;
|
||||
hasRemote: ReturnType<typeof vi.fn>;
|
||||
addAll: ReturnType<typeof vi.fn>;
|
||||
hasUncommittedChanges: ReturnType<typeof vi.fn>;
|
||||
commit: ReturnType<typeof vi.fn>;
|
||||
fetch: ReturnType<typeof vi.fn>;
|
||||
refExists: ReturnType<typeof vi.fn>;
|
||||
rebaseOnto: ReturnType<typeof vi.fn>;
|
||||
rebaseAbort: ReturnType<typeof vi.fn>;
|
||||
listConflicts: ReturnType<typeof vi.fn>;
|
||||
push: ReturnType<typeof vi.fn>;
|
||||
run: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
exportSvc = { export: vi.fn(async () => {}) };
|
||||
importSvc = { applySyncFromDir: vi.fn(async () => ({ changedCount: 0 })) };
|
||||
gitInstance = {
|
||||
isRepo: vi.fn(async () => true),
|
||||
hasRemote: vi.fn(async () => true),
|
||||
addAll: vi.fn(async () => {}),
|
||||
hasUncommittedChanges: vi.fn(async () => true),
|
||||
commit: vi.fn(async () => ({ changed: true, sha: 'abc' })),
|
||||
fetch: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })),
|
||||
refExists: vi.fn(async () => true),
|
||||
rebaseOnto: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })),
|
||||
rebaseAbort: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })),
|
||||
listConflicts: vi.fn(async () => []),
|
||||
push: vi.fn(async () => {}),
|
||||
run: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 }))
|
||||
};
|
||||
(GitClient as unknown as ReturnType<typeof vi.fn>).mockImplementation(function () { return gitInstance; });
|
||||
svc = new SyncService(
|
||||
'/tmp/profile',
|
||||
exportSvc as unknown as never,
|
||||
importSvc as unknown as never
|
||||
);
|
||||
});
|
||||
|
||||
it('happy path — 6단계 모두 호출, ok:true', async () => {
|
||||
const r = await svc.sync();
|
||||
expect(exportSvc.export).toHaveBeenCalled();
|
||||
expect(gitInstance.addAll).toHaveBeenCalled();
|
||||
expect(gitInstance.commit).toHaveBeenCalled();
|
||||
expect(gitInstance.fetch).toHaveBeenCalled();
|
||||
expect(gitInstance.rebaseOnto).toHaveBeenCalledWith('origin/main');
|
||||
expect(importSvc.applySyncFromDir).toHaveBeenCalled();
|
||||
expect(gitInstance.push).toHaveBeenCalled();
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.pushed).toBe(true);
|
||||
});
|
||||
|
||||
it('local 변경 없음 → commit skip + 다음 단계 진행', async () => {
|
||||
gitInstance.hasUncommittedChanges.mockResolvedValueOnce(false);
|
||||
const r = await svc.sync();
|
||||
expect(gitInstance.commit).not.toHaveBeenCalled();
|
||||
expect(gitInstance.fetch).toHaveBeenCalled();
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('rebase 실패 → abort + reason=conflict + conflicts 포함 (path + localText/remoteText)', async () => {
|
||||
gitInstance.rebaseOnto.mockResolvedValueOnce({ stdout: '', stderr: 'CONFLICT', exitCode: 1 });
|
||||
gitInstance.listConflicts.mockResolvedValueOnce(['notes/aaa.md', 'notes/bbb.md']);
|
||||
// Cut E final review fix — runSync calls git.run(['show', ':2:path']) and ':3:path'
|
||||
// for each conflict. Mock returns ours/theirs text per call.
|
||||
gitInstance.run
|
||||
.mockResolvedValueOnce({ stdout: 'aaa local', stderr: '', exitCode: 0 }) // :2:notes/aaa.md
|
||||
.mockResolvedValueOnce({ stdout: 'aaa remote', stderr: '', exitCode: 0 }) // :3:notes/aaa.md
|
||||
.mockResolvedValueOnce({ stdout: 'bbb local', stderr: '', exitCode: 0 }) // :2:notes/bbb.md
|
||||
.mockResolvedValueOnce({ stdout: 'bbb remote', stderr: '', exitCode: 0 }); // :3:notes/bbb.md
|
||||
const r = await svc.sync();
|
||||
expect(gitInstance.rebaseAbort).toHaveBeenCalled();
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toBe('conflict');
|
||||
expect(r.conflicts).toEqual([
|
||||
{ path: 'notes/aaa.md', localText: 'aaa local', remoteText: 'aaa remote' },
|
||||
{ path: 'notes/bbb.md', localText: 'bbb local', remoteText: 'bbb remote' }
|
||||
]);
|
||||
expect(gitInstance.push).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fetch 실패 → reason 반환', async () => {
|
||||
gitInstance.fetch.mockResolvedValueOnce({ stdout: '', stderr: 'no network', exitCode: 1 });
|
||||
const r = await svc.sync();
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toContain('fetch failed');
|
||||
expect(gitInstance.rebaseOnto).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('not configured → ok:false + reason=not_configured', async () => {
|
||||
gitInstance.isRepo.mockResolvedValueOnce(false);
|
||||
const r = await svc.sync();
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toBe('not_configured');
|
||||
});
|
||||
});
|
||||
60
tests/unit/SyncService.resolveConflict.test.ts
Normal file
60
tests/unit/SyncService.resolveConflict.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { SyncService } from '../../src/main/services/SyncService.js';
|
||||
|
||||
vi.mock('../../src/main/services/GitClient.js');
|
||||
import { GitClient } from '../../src/main/services/GitClient.js';
|
||||
|
||||
describe('SyncService.resolveConflict', () => {
|
||||
let svc: SyncService;
|
||||
let importSvc: { applySyncFromDir: ReturnType<typeof vi.fn> };
|
||||
let gitInstance: {
|
||||
run: ReturnType<typeof vi.fn>;
|
||||
addAll: ReturnType<typeof vi.fn>;
|
||||
push: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
importSvc = { applySyncFromDir: vi.fn(async () => ({ changedCount: 0 })) };
|
||||
gitInstance = {
|
||||
run: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })),
|
||||
addAll: vi.fn(async () => {}),
|
||||
push: vi.fn(async () => {})
|
||||
};
|
||||
(GitClient as unknown as ReturnType<typeof vi.fn>).mockImplementation(function () { return gitInstance; });
|
||||
svc = new SyncService('/tmp', {} as never, importSvc as never);
|
||||
});
|
||||
|
||||
it('local 선택 → checkout --ours + add + rebase --continue + push', async () => {
|
||||
const r = await svc.resolveConflict('notes/note-id.md', 'local');
|
||||
expect(gitInstance.run).toHaveBeenCalledWith(['checkout', '--ours', 'notes/note-id.md']);
|
||||
expect(gitInstance.run).toHaveBeenCalledWith(['rebase', '--continue']);
|
||||
expect(gitInstance.push).toHaveBeenCalled();
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('remote 선택 → checkout --theirs + add + rebase --continue + applySyncFromDir + push', async () => {
|
||||
const r = await svc.resolveConflict('notes/note-id.md', 'remote');
|
||||
expect(gitInstance.run).toHaveBeenCalledWith(['checkout', '--theirs', 'notes/note-id.md']);
|
||||
expect(importSvc.applySyncFromDir).toHaveBeenCalled();
|
||||
expect(gitInstance.push).toHaveBeenCalled();
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('checkout 실패 → ok:false + reason 반환', async () => {
|
||||
gitInstance.run.mockResolvedValueOnce({ stdout: '', stderr: 'fail', exitCode: 1 });
|
||||
const r = await svc.resolveConflict('notes/note-id.md', 'local');
|
||||
expect(r.ok).toBe(false);
|
||||
expect((r as { reason: string }).reason).toContain('checkout failed');
|
||||
expect(gitInstance.push).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rebase --continue 실패 (다른 파일 미해결) → ok:false', async () => {
|
||||
gitInstance.run
|
||||
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }) // checkout
|
||||
.mockResolvedValueOnce({ stdout: '', stderr: 'still unresolved', exitCode: 1 }); // rebase --continue
|
||||
const r = await svc.resolveConflict('notes/note-id.md', 'local');
|
||||
expect(r.ok).toBe(false);
|
||||
expect((r as { reason: string }).reason).toContain('rebase --continue failed');
|
||||
expect(gitInstance.push).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import { runMigrations } from '@main/db/migrations/index.js';
|
||||
import { NoteRepository } from '@main/repository/NoteRepository.js';
|
||||
import { MediaStore } from '@main/services/MediaStore.js';
|
||||
import { ExportService } from '@main/services/ExportService.js';
|
||||
import { ImportService } from '@main/services/ImportService.js';
|
||||
import { SyncService } from '@main/services/SyncService.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
@@ -47,6 +48,7 @@ describe('SyncService', () => {
|
||||
let repo: NoteRepository;
|
||||
let mediaStore: MediaStore;
|
||||
let exportSvc: ExportService;
|
||||
let importSvc: ImportService;
|
||||
let svc: SyncService;
|
||||
let remoteDir: string | null = null;
|
||||
let prevEnv: NodeJS.ProcessEnv;
|
||||
@@ -73,7 +75,8 @@ describe('SyncService', () => {
|
||||
repo = new NoteRepository(db);
|
||||
mediaStore = new MediaStore(profileDir);
|
||||
exportSvc = new ExportService(repo, mediaStore, () => new Date('2026-04-26T12:00:00Z'));
|
||||
svc = new SyncService(profileDir, exportSvc, () => new Date('2026-04-26T12:00:00Z'));
|
||||
importSvc = new ImportService(repo, mediaStore);
|
||||
svc = new SyncService(profileDir, exportSvc, importSvc, () => new Date('2026-04-26T12:00:00Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -110,7 +113,7 @@ describe('SyncService', () => {
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.changed).toBe(true);
|
||||
expect(r.pushed).toBe(true);
|
||||
expect(r.sha).toMatch(/^[0-9a-f]{40}$/);
|
||||
expect(r.localSha).toMatch(/^[0-9a-f]{40}$/);
|
||||
expect(existsSync(join(svc.getSyncDir(), 'manifest.json'))).toBe(true);
|
||||
expect(existsSync(join(svc.getSyncDir(), 'notes'))).toBe(true);
|
||||
expect(existsSync(join(svc.getSyncDir(), 'index.jsonl'))).toBe(true);
|
||||
@@ -122,10 +125,11 @@ describe('SyncService', () => {
|
||||
const first = await svc.sync();
|
||||
expect(first.ok).toBe(true);
|
||||
expect(first.changed).toBe(true);
|
||||
// Re-sync without DB change. With fixed now() → identical files → git sees no change.
|
||||
// Re-sync without DB change. With fixed now() → identical files → git sees no local change.
|
||||
// New bidirectional flow: always does fetch+rebase+re-import+push.
|
||||
const second = await svc.sync();
|
||||
expect(second.ok).toBe(true);
|
||||
expect(second.changed).toBe(false);
|
||||
expect(second.pushed).toBe(false);
|
||||
expect(second.changed).toBe(false); // no local commit + importedCount=0
|
||||
expect(second.pushed).toBe(true); // push always runs on success
|
||||
});
|
||||
});
|
||||
|
||||
72
tests/unit/SyncTimer.test.ts
Normal file
72
tests/unit/SyncTimer.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { SyncTimer } from '../../src/main/services/SyncTimer.js';
|
||||
|
||||
describe('SyncTimer', () => {
|
||||
let syncSvc: { sync: ReturnType<typeof vi.fn> };
|
||||
let settings: {
|
||||
isAutoSyncEnabled: ReturnType<typeof vi.fn>;
|
||||
getSyncIntervalMin: ReturnType<typeof vi.fn>;
|
||||
getSyncRepoUrl: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let timer: SyncTimer;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
syncSvc = { sync: vi.fn(async () => ({ ok: true })) };
|
||||
settings = {
|
||||
isAutoSyncEnabled: vi.fn(async () => true),
|
||||
getSyncIntervalMin: vi.fn(async () => 5),
|
||||
getSyncRepoUrl: vi.fn(async () => 'git@host:u/r.git')
|
||||
};
|
||||
timer = new SyncTimer(syncSvc as never, settings as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
timer.stop();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('start — interval 마다 syncSvc.sync 호출', async () => {
|
||||
await timer.start();
|
||||
expect(syncSvc.sync).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(5 * 60 * 1000);
|
||||
expect(syncSvc.sync).toHaveBeenCalledTimes(1);
|
||||
await vi.advanceTimersByTimeAsync(5 * 60 * 1000);
|
||||
expect(syncSvc.sync).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('auto disabled → 시작 안 함 (sync 0회)', async () => {
|
||||
settings.isAutoSyncEnabled.mockResolvedValueOnce(false);
|
||||
await timer.start();
|
||||
await vi.advanceTimersByTimeAsync(60 * 60 * 1000);
|
||||
expect(syncSvc.sync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('repo URL 미설정 → 시작 안 함', async () => {
|
||||
settings.getSyncRepoUrl.mockResolvedValueOnce(null);
|
||||
await timer.start();
|
||||
await vi.advanceTimersByTimeAsync(60 * 60 * 1000);
|
||||
expect(syncSvc.sync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reconfigure — stop + 새 interval 로 start', async () => {
|
||||
await timer.start();
|
||||
await vi.advanceTimersByTimeAsync(5 * 60 * 1000);
|
||||
expect(syncSvc.sync).toHaveBeenCalledTimes(1);
|
||||
|
||||
settings.getSyncIntervalMin.mockResolvedValueOnce(10);
|
||||
await timer.reconfigure();
|
||||
await vi.advanceTimersByTimeAsync(5 * 60 * 1000);
|
||||
// not enough time for new interval — still 1 call
|
||||
expect(syncSvc.sync).toHaveBeenCalledTimes(1);
|
||||
await vi.advanceTimersByTimeAsync(5 * 60 * 1000);
|
||||
expect(syncSvc.sync).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('stop — 호출 후 더 이상 sync 발생 안 함', async () => {
|
||||
await timer.start();
|
||||
timer.stop();
|
||||
await vi.advanceTimersByTimeAsync(60 * 60 * 1000);
|
||||
expect(syncSvc.sync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
109
tests/unit/classifyStatus.test.ts
Normal file
109
tests/unit/classifyStatus.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { classifyStatus } from '../../src/main/ai/classifyStatus';
|
||||
import type { InferenceProvider } from '../../src/main/ai/InferenceProvider';
|
||||
|
||||
function makeProvider(generateRaw?: (p: string) => Promise<string>): InferenceProvider {
|
||||
return {
|
||||
name: 'mock',
|
||||
generate: vi.fn(async () => {
|
||||
throw new Error('not used');
|
||||
}),
|
||||
healthCheck: vi.fn(async () => ({ ok: true })),
|
||||
...(generateRaw !== undefined ? { generateRaw } : {})
|
||||
} as InferenceProvider;
|
||||
}
|
||||
|
||||
describe('classifyStatus', () => {
|
||||
it('parses recommended status and rationale from valid AI response', async () => {
|
||||
const provider = makeProvider(
|
||||
vi.fn(async () => '{"recommended":"completed","rationale":"처리됨"}')
|
||||
);
|
||||
const r = await classifyStatus({
|
||||
provider,
|
||||
rawText: 't',
|
||||
summary: '',
|
||||
reason: '결재 끝'
|
||||
});
|
||||
expect(r.recommended).toBe('completed');
|
||||
expect(r.rationale).toBe('처리됨');
|
||||
});
|
||||
|
||||
it('falls back to archived on parse failure (invalid JSON)', async () => {
|
||||
const provider = makeProvider(vi.fn(async () => 'not json'));
|
||||
const r = await classifyStatus({
|
||||
provider,
|
||||
rawText: 't',
|
||||
summary: '',
|
||||
reason: 'r'
|
||||
});
|
||||
expect(r.recommended).toBe('archived');
|
||||
expect(r.rationale).toMatch(/판단 실패|보관/);
|
||||
});
|
||||
|
||||
it('falls back to archived on invalid status value', async () => {
|
||||
const provider = makeProvider(
|
||||
vi.fn(async () => '{"recommended":"unknown","rationale":"x"}')
|
||||
);
|
||||
const r = await classifyStatus({
|
||||
provider,
|
||||
rawText: 't',
|
||||
summary: '',
|
||||
reason: 'r'
|
||||
});
|
||||
expect(r.recommended).toBe('archived');
|
||||
});
|
||||
|
||||
it('handles provider throw', async () => {
|
||||
const provider = makeProvider(
|
||||
vi.fn(async () => {
|
||||
throw new Error('network');
|
||||
})
|
||||
);
|
||||
const r = await classifyStatus({
|
||||
provider,
|
||||
rawText: 't',
|
||||
summary: '',
|
||||
reason: 'r'
|
||||
});
|
||||
expect(r.recommended).toBe('archived');
|
||||
expect(r.rationale).toMatch(/판단 실패|보관/);
|
||||
});
|
||||
|
||||
it('falls back when provider lacks generateRaw method', async () => {
|
||||
const provider = makeProvider();
|
||||
const r = await classifyStatus({
|
||||
provider,
|
||||
rawText: 't',
|
||||
summary: '',
|
||||
reason: 'r'
|
||||
});
|
||||
expect(r.recommended).toBe('archived');
|
||||
expect(r.rationale).toMatch(/판단 실패|보관/);
|
||||
});
|
||||
|
||||
it('substitutes empty inputs with placeholder text in prompt', async () => {
|
||||
const generateRaw = vi.fn(
|
||||
async (_p: string) => '{"recommended":"archived","rationale":"ok"}'
|
||||
);
|
||||
const provider = makeProvider(generateRaw);
|
||||
await classifyStatus({ provider, rawText: '', summary: '', reason: '' });
|
||||
const prompt = generateRaw.mock.calls[0]?.[0] ?? '';
|
||||
expect(prompt).toContain('(빈 메모)');
|
||||
expect(prompt).toContain('(요약 없음)');
|
||||
expect(prompt).toContain('(사유 없음)');
|
||||
});
|
||||
|
||||
it('rationale defaults to empty string when missing/non-string', async () => {
|
||||
const provider = makeProvider(
|
||||
vi.fn(async () => '{"recommended":"completed"}')
|
||||
);
|
||||
const r = await classifyStatus({
|
||||
provider,
|
||||
rawText: 't',
|
||||
summary: '',
|
||||
reason: 'r'
|
||||
});
|
||||
expect(r.recommended).toBe('completed');
|
||||
expect(r.rationale).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,11 @@ const baseNote: ExportNote = {
|
||||
aiGeneratedAt: '2026-04-25T14:23:34.000Z',
|
||||
userIntent: null,
|
||||
intentPromptedAt: null,
|
||||
status: 'active',
|
||||
statusChangedAt: null,
|
||||
moveReason: null,
|
||||
dueDate: null,
|
||||
dueDateEditedByUser: false,
|
||||
tags: [{ name: 'pr', source: 'ai' }, { name: 'review', source: 'user' }],
|
||||
media: []
|
||||
};
|
||||
@@ -122,6 +127,54 @@ describe('composeFrontmatter', () => {
|
||||
expect(fm).toContain('mime: image/png');
|
||||
expect(fm).toContain('bytes: 1234');
|
||||
});
|
||||
|
||||
it('always emits status: active for a default note', () => {
|
||||
const fm = composeFrontmatter(baseNote);
|
||||
expect(fm).toContain('status: active');
|
||||
});
|
||||
|
||||
it('emits due_date and due_date_source together when dueDate present', () => {
|
||||
const fm = composeFrontmatter({ ...baseNote, dueDate: '2026-06-01', dueDateEditedByUser: true });
|
||||
expect(fm).toContain('due_date: 2026-06-01');
|
||||
expect(fm).toContain('due_date_source: user');
|
||||
});
|
||||
|
||||
it('emits due_date_source: ai when dueDateEditedByUser is false', () => {
|
||||
const fm = composeFrontmatter({ ...baseNote, dueDate: '2026-06-01', dueDateEditedByUser: false });
|
||||
expect(fm).toContain('due_date: 2026-06-01');
|
||||
expect(fm).toContain('due_date_source: ai');
|
||||
});
|
||||
|
||||
it('omits due_date and due_date_source when dueDate is null', () => {
|
||||
const fm = composeFrontmatter(baseNote);
|
||||
expect(fm).not.toContain('due_date:');
|
||||
expect(fm).not.toContain('due_date_source:');
|
||||
});
|
||||
|
||||
it('emits move_reason when present', () => {
|
||||
const fm = composeFrontmatter({ ...baseNote, status: 'archived', moveReason: 'done for now' });
|
||||
expect(fm).toContain('status: archived');
|
||||
expect(fm).toContain('move_reason: done for now');
|
||||
});
|
||||
|
||||
it('emits status_changed_at when present', () => {
|
||||
const fm = composeFrontmatter({ ...baseNote, statusChangedAt: '2026-05-01T00:00:00Z' });
|
||||
expect(fm).toContain('status_changed_at: 2026-05-01T00:00:00Z');
|
||||
});
|
||||
|
||||
it('status/due_date/move_reason fields appear before images: in frontmatter', () => {
|
||||
const fm = composeFrontmatter({
|
||||
...baseNote,
|
||||
dueDate: '2026-06-01',
|
||||
dueDateEditedByUser: false,
|
||||
media: [{ rel: 'media/014a3b9c__1.png', mime: 'image/png', bytes: 1 }]
|
||||
});
|
||||
const statusPos = fm.indexOf('status:');
|
||||
const imagesPos = fm.indexOf('images:');
|
||||
expect(statusPos).toBeGreaterThan(-1);
|
||||
expect(imagesPos).toBeGreaterThan(-1);
|
||||
expect(statusPos).toBeLessThan(imagesPos);
|
||||
});
|
||||
});
|
||||
|
||||
describe('composeMarkdown', () => {
|
||||
|
||||
34
tests/unit/ftsHelpers.test.ts
Normal file
34
tests/unit/ftsHelpers.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { sanitizeFtsQuery, computeCutoff } from '../../src/main/repository/ftsHelpers.js';
|
||||
|
||||
describe('sanitizeFtsQuery', () => {
|
||||
it('strips FTS5 special chars', () => {
|
||||
expect(sanitizeFtsQuery('"기획" *회의*')).toBe('기획 회의');
|
||||
expect(sanitizeFtsQuery('foo: (bar)')).toBe('foo bar');
|
||||
});
|
||||
it('keeps Korean + alphanumeric tokens', () => {
|
||||
expect(sanitizeFtsQuery('회의 결재 v2')).toBe('회의 결재 v2');
|
||||
});
|
||||
it('collapses whitespace', () => {
|
||||
expect(sanitizeFtsQuery(' 회의 ')).toBe('회의');
|
||||
});
|
||||
it('returns empty string for whitespace-only', () => {
|
||||
expect(sanitizeFtsQuery(' ')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeCutoff', () => {
|
||||
// KST = UTC+9. KST 자정 = UTC 전날 15:00.
|
||||
it('daily — KST 오늘 자정 ISO', () => {
|
||||
const now = new Date('2026-05-10T05:30:00Z'); // KST 14:30
|
||||
expect(computeCutoff('daily', now)).toBe('2026-05-09T15:00:00.000Z');
|
||||
});
|
||||
it('weekly — 7일 전 KST 자정', () => {
|
||||
const now = new Date('2026-05-10T05:30:00Z');
|
||||
expect(computeCutoff('weekly', now)).toBe('2026-05-02T15:00:00.000Z');
|
||||
});
|
||||
it('monthly — 30일 전 KST 자정', () => {
|
||||
const now = new Date('2026-05-10T05:30:00Z');
|
||||
expect(computeCutoff('monthly', now)).toBe('2026-04-09T15:00:00.000Z');
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,11 @@ const baseNote: ExportNote = {
|
||||
aiGeneratedAt: '2026-04-25T14:23:34.000Z',
|
||||
userIntent: null,
|
||||
intentPromptedAt: null,
|
||||
status: 'active',
|
||||
statusChangedAt: null,
|
||||
moveReason: null,
|
||||
dueDate: null,
|
||||
dueDateEditedByUser: false,
|
||||
tags: [{ name: 'pr', source: 'ai' }, { name: 'review', source: 'user' }],
|
||||
media: []
|
||||
};
|
||||
@@ -180,6 +185,66 @@ describe('parseExportNote — provenance', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseExportNote — status/dueDate/moveReason round-trip (v0.3.0 Cut E)', () => {
|
||||
it('round-trips status=active (default)', () => {
|
||||
const md = composeMarkdown(baseNote);
|
||||
const parsed = parseExportNote(md);
|
||||
expect(parsed.status).toBe('active');
|
||||
expect(parsed.statusChangedAt).toBeNull();
|
||||
expect(parsed.moveReason).toBeNull();
|
||||
expect(parsed.dueDate).toBeNull();
|
||||
expect(parsed.dueDateEditedByUser).toBe(false);
|
||||
});
|
||||
|
||||
it('round-trips status=archived with statusChangedAt and moveReason', () => {
|
||||
const note: ExportNote = {
|
||||
...baseNote,
|
||||
status: 'archived',
|
||||
statusChangedAt: '2026-05-01T10:00:00Z',
|
||||
moveReason: 'project done'
|
||||
};
|
||||
const md = composeMarkdown(note);
|
||||
const parsed = parseExportNote(md);
|
||||
expect(parsed.status).toBe('archived');
|
||||
expect(parsed.statusChangedAt).toBe('2026-05-01T10:00:00Z');
|
||||
expect(parsed.moveReason).toBe('project done');
|
||||
});
|
||||
|
||||
it('round-trips dueDate with dueDateEditedByUser=true', () => {
|
||||
const note: ExportNote = {
|
||||
...baseNote,
|
||||
dueDate: '2026-06-15',
|
||||
dueDateEditedByUser: true
|
||||
};
|
||||
const md = composeMarkdown(note);
|
||||
const parsed = parseExportNote(md);
|
||||
expect(parsed.dueDate).toBe('2026-06-15');
|
||||
expect(parsed.dueDateEditedByUser).toBe(true);
|
||||
});
|
||||
|
||||
it('round-trips dueDate with dueDateEditedByUser=false (ai source)', () => {
|
||||
const note: ExportNote = {
|
||||
...baseNote,
|
||||
dueDate: '2026-07-01',
|
||||
dueDateEditedByUser: false
|
||||
};
|
||||
const md = composeMarkdown(note);
|
||||
const parsed = parseExportNote(md);
|
||||
expect(parsed.dueDate).toBe('2026-07-01');
|
||||
expect(parsed.dueDateEditedByUser).toBe(false);
|
||||
});
|
||||
|
||||
it('defaults to status=active for older exports without status field', () => {
|
||||
// Simulate a pre-Cut E export that has no status line
|
||||
const md = `---\nid: 014a3b9c-1234-7890-abcd-000000000001\ncreated_at: 2026-04-25T14:23:11.000Z\nupdated_at: 2026-04-25T14:24:02.000Z\ntitle: t\ntitle_source: ai\nsummary: s\nsummary_source: ai\ninkling_export_version: 1\n---\n\n# t\n\n> s\n\nbody\n`;
|
||||
const parsed = parseExportNote(md);
|
||||
expect(parsed.status).toBe('active');
|
||||
expect(parsed.dueDate).toBeNull();
|
||||
expect(parsed.moveReason).toBeNull();
|
||||
expect(parsed.dueDateEditedByUser).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseExportNote — edge cases', () => {
|
||||
it('preserves user_intent when present', () => {
|
||||
const md = composeMarkdown({
|
||||
|
||||
62
tests/unit/inboxApi-openMedia.test.ts
Normal file
62
tests/unit/inboxApi-openMedia.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const { handlers, mockOpenPath } = vi.hoisted(() => ({
|
||||
handlers: {} as Record<string, (...args: unknown[]) => unknown>,
|
||||
mockOpenPath: vi.fn(async () => '')
|
||||
}));
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
default: {
|
||||
ipcMain: {
|
||||
handle: (ch: string, fn: (...args: unknown[]) => unknown) => {
|
||||
handlers[ch] = fn;
|
||||
}
|
||||
},
|
||||
dialog: {},
|
||||
shell: { openPath: mockOpenPath }
|
||||
}
|
||||
}));
|
||||
|
||||
import { registerInboxApi } from '../../src/main/ipc/inboxApi';
|
||||
|
||||
function makeDeps(profileDir: string): Parameters<typeof registerInboxApi>[0] {
|
||||
// Minimal stub — `inbox:open-media` 핸들러는 deps.paths.profileDir 만 참조.
|
||||
return {
|
||||
repo: {} as never,
|
||||
continuity: {} as never,
|
||||
capture: {} as never,
|
||||
health: {} as never,
|
||||
intent: {} as never,
|
||||
getInboxWindow: () => null,
|
||||
settings: {} as never,
|
||||
providerHolder: {} as never,
|
||||
paths: { profileDir }
|
||||
};
|
||||
}
|
||||
|
||||
describe('inbox:open-media IPC', () => {
|
||||
beforeEach(() => {
|
||||
Object.keys(handlers).forEach((k) => delete handlers[k]);
|
||||
mockOpenPath.mockClear();
|
||||
});
|
||||
|
||||
it('opens valid relPath with shell.openPath', async () => {
|
||||
registerInboxApi(makeDeps('/profile'));
|
||||
const handler = handlers['inbox:open-media'];
|
||||
if (handler === undefined) throw new Error('handler not registered');
|
||||
const r = await handler(null, 'media/note1/img.png');
|
||||
expect(r).toEqual({ ok: true });
|
||||
expect(mockOpenPath).toHaveBeenCalledWith(join('/profile', 'media', 'note1', 'img.png'));
|
||||
});
|
||||
|
||||
it('rejects path traversal with reason "invalid path"', async () => {
|
||||
registerInboxApi(makeDeps('/profile'));
|
||||
const handler = handlers['inbox:open-media'];
|
||||
if (handler === undefined) throw new Error('handler not registered');
|
||||
const r = await handler(null, '../etc/passwd') as { ok: boolean; reason?: string };
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toBe('invalid path');
|
||||
expect(mockOpenPath).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
99
tests/unit/inboxApi-revisions.test.ts
Normal file
99
tests/unit/inboxApi-revisions.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
default: {
|
||||
ipcMain: { handle: vi.fn() }
|
||||
}
|
||||
}));
|
||||
|
||||
import electron from 'electron';
|
||||
import { registerInboxApi } from '../../src/main/ipc/inboxApi.js';
|
||||
import type { InboxIpcDeps } from '../../src/main/ipc/inboxApi.js';
|
||||
|
||||
function getHandler(channel: string): (...args: unknown[]) => unknown {
|
||||
const handle = (electron.ipcMain as unknown as { handle: ReturnType<typeof vi.fn> }).handle;
|
||||
const call = handle.mock.calls.find((c) => c[0] === channel);
|
||||
if (!call) throw new Error(`channel ${channel} not registered`);
|
||||
return call[1] as (...args: unknown[]) => unknown;
|
||||
}
|
||||
|
||||
function makeDeps(overrides: Partial<InboxIpcDeps> = {}): InboxIpcDeps {
|
||||
const repo = {
|
||||
updateRawText: vi.fn(),
|
||||
listRevisions: vi.fn(() => []),
|
||||
restoreRevision: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
list: vi.fn(),
|
||||
listByStatus: vi.fn(),
|
||||
countByStatus: vi.fn(() => 0),
|
||||
countByAiStatus: vi.fn(() => 0),
|
||||
countTrashed: vi.fn(() => 0),
|
||||
countFailed: vi.fn(() => 0),
|
||||
listTrashed: vi.fn(() => []),
|
||||
setStatus: vi.fn(),
|
||||
requeueDisabled: vi.fn(() => 0),
|
||||
getAllPendingJobs: vi.fn(() => []),
|
||||
getPendingCount: vi.fn(() => 0),
|
||||
countToday: vi.fn(() => 0)
|
||||
} as unknown as InboxIpcDeps['repo'];
|
||||
return {
|
||||
repo,
|
||||
continuity: { get: vi.fn() } as unknown as InboxIpcDeps['continuity'],
|
||||
capture: {} as InboxIpcDeps['capture'],
|
||||
health: {} as InboxIpcDeps['health'],
|
||||
intent: {} as InboxIpcDeps['intent'],
|
||||
getInboxWindow: () => null,
|
||||
settings: {} as InboxIpcDeps['settings'],
|
||||
providerHolder: {} as InboxIpcDeps['providerHolder'],
|
||||
paths: { profileDir: '/tmp' },
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('inboxApi revisions IPC', () => {
|
||||
beforeEach(() => {
|
||||
(electron.ipcMain as unknown as { handle: ReturnType<typeof vi.fn> }).handle.mockClear();
|
||||
});
|
||||
|
||||
it('inbox:update-raw-text — repo.updateRawText 호출 + ok:true', async () => {
|
||||
const deps = makeDeps();
|
||||
registerInboxApi(deps);
|
||||
const h = getHandler('inbox:update-raw-text');
|
||||
const r = await h({}, 'note-1', 'new text');
|
||||
expect(deps.repo.updateRawText).toHaveBeenCalledWith('note-1', 'new text');
|
||||
expect(r).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('inbox:update-raw-text — 빈 문자열 reject', async () => {
|
||||
const deps = makeDeps();
|
||||
registerInboxApi(deps);
|
||||
const h = getHandler('inbox:update-raw-text');
|
||||
const r = await h({}, 'note-1', ' ');
|
||||
expect(deps.repo.updateRawText).not.toHaveBeenCalled();
|
||||
expect(r).toEqual({ ok: false, reason: 'empty' });
|
||||
});
|
||||
|
||||
it('inbox:list-revisions — repo.listRevisions 결과 반환', async () => {
|
||||
const deps = makeDeps();
|
||||
(deps.repo.listRevisions as ReturnType<typeof vi.fn>).mockReturnValue([
|
||||
{ revId: 1, noteId: 'a', rawText: 'v1', editedAt: 't1', editedBy: 'capture' }
|
||||
]);
|
||||
registerInboxApi(deps);
|
||||
const h = getHandler('inbox:list-revisions');
|
||||
const r = await h({}, 'a');
|
||||
expect(r).toEqual([
|
||||
{ revId: 1, noteId: 'a', rawText: 'v1', editedAt: 't1', editedBy: 'capture' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('inbox:restore-revision — repo throw 시 ok:false', async () => {
|
||||
const deps = makeDeps();
|
||||
(deps.repo.restoreRevision as ReturnType<typeof vi.fn>).mockImplementation(() => {
|
||||
throw new Error('revision 99 not found for note a');
|
||||
});
|
||||
registerInboxApi(deps);
|
||||
const h = getHandler('inbox:restore-revision');
|
||||
const r = await h({}, 'a', 99);
|
||||
expect(r).toEqual({ ok: false, reason: 'revision 99 not found for note a' });
|
||||
});
|
||||
});
|
||||
84
tests/unit/inboxApi-search-review.test.ts
Normal file
84
tests/unit/inboxApi-search-review.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
vi.mock('electron', () => ({ default: { ipcMain: { handle: vi.fn() } } }));
|
||||
import electron from 'electron';
|
||||
import { registerInboxApi } from '../../src/main/ipc/inboxApi.js';
|
||||
import type { InboxIpcDeps } from '../../src/main/ipc/inboxApi.js';
|
||||
|
||||
function getHandler(channel: string): (...args: unknown[]) => unknown {
|
||||
const handle = (electron.ipcMain as unknown as { handle: ReturnType<typeof vi.fn> }).handle;
|
||||
const call = handle.mock.calls.find((c) => c[0] === channel);
|
||||
if (!call) throw new Error(`channel ${channel} not registered`);
|
||||
return call[1] as (...args: unknown[]) => unknown;
|
||||
}
|
||||
|
||||
function makeDeps(overrides: Partial<InboxIpcDeps> = {}): InboxIpcDeps {
|
||||
const repo = {
|
||||
search: vi.fn(() => []),
|
||||
reviewAggregate: vi.fn(() => ({ totalCount: 0, recentNotes: [], tagCounts: [], dueProgress: { total: 0, passed: 0, pending: 0 } })),
|
||||
list: vi.fn(),
|
||||
listByStatus: vi.fn(),
|
||||
countByStatus: vi.fn(() => 0),
|
||||
countByAiStatus: vi.fn(() => 0),
|
||||
countTrashed: vi.fn(() => 0),
|
||||
countFailed: vi.fn(() => 0),
|
||||
listTrashed: vi.fn(() => []),
|
||||
setStatus: vi.fn(),
|
||||
requeueDisabled: vi.fn(() => 0),
|
||||
getAllPendingJobs: vi.fn(() => []),
|
||||
getPendingCount: vi.fn(() => 0),
|
||||
countToday: vi.fn(() => 0),
|
||||
findById: vi.fn(),
|
||||
listRevisions: vi.fn(() => []),
|
||||
restoreRevision: vi.fn(),
|
||||
updateRawText: vi.fn()
|
||||
} as unknown as InboxIpcDeps['repo'];
|
||||
return {
|
||||
repo,
|
||||
continuity: { get: vi.fn() } as unknown as InboxIpcDeps['continuity'],
|
||||
capture: {} as InboxIpcDeps['capture'],
|
||||
health: {} as InboxIpcDeps['health'],
|
||||
intent: {} as InboxIpcDeps['intent'],
|
||||
getInboxWindow: () => null,
|
||||
settings: {} as InboxIpcDeps['settings'],
|
||||
providerHolder: {} as InboxIpcDeps['providerHolder'],
|
||||
paths: { profileDir: '/tmp' },
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('inboxApi search/review IPC', () => {
|
||||
beforeEach(() => {
|
||||
(electron.ipcMain as unknown as { handle: ReturnType<typeof vi.fn> }).handle.mockClear();
|
||||
});
|
||||
|
||||
it('inbox:search — repo.search 호출 결과 반환', async () => {
|
||||
const deps = makeDeps();
|
||||
(deps.repo.search as ReturnType<typeof vi.fn>).mockReturnValue([{ id: 'a' }]);
|
||||
registerInboxApi(deps);
|
||||
const h = getHandler('inbox:search');
|
||||
const r = await h({}, '회의', { status: 'active', limit: 10 });
|
||||
expect(deps.repo.search).toHaveBeenCalledWith('회의', { status: 'active', limit: 10 });
|
||||
expect(r).toEqual([{ id: 'a' }]);
|
||||
});
|
||||
|
||||
it('inbox:review-aggregate — repo.reviewAggregate 호출 결과 반환', async () => {
|
||||
const deps = makeDeps();
|
||||
const fake = { totalCount: 5, recentNotes: [], tagCounts: [{ tag: 'x', count: 2 }], dueProgress: { total: 1, passed: 1, pending: 0 } };
|
||||
(deps.repo.reviewAggregate as ReturnType<typeof vi.fn>).mockReturnValue(fake);
|
||||
registerInboxApi(deps);
|
||||
const h = getHandler('inbox:review-aggregate');
|
||||
const r = await h({}, 'weekly');
|
||||
expect(deps.repo.reviewAggregate).toHaveBeenCalledWith('weekly');
|
||||
expect(r).toEqual(fake);
|
||||
});
|
||||
|
||||
it('inbox:review-aggregate — 잘못된 period reject', async () => {
|
||||
const deps = makeDeps();
|
||||
registerInboxApi(deps);
|
||||
const h = getHandler('inbox:review-aggregate');
|
||||
const r = await h({}, 'yearly');
|
||||
expect(deps.repo.reviewAggregate).not.toHaveBeenCalled();
|
||||
expect(r).toMatchObject({ totalCount: 0 });
|
||||
});
|
||||
});
|
||||
148
tests/unit/inboxApi-setStatus.test.ts
Normal file
148
tests/unit/inboxApi-setStatus.test.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { handlers, mockSetStatus, mockFindById, mockGenerateRaw } = vi.hoisted(() => ({
|
||||
handlers: {} as Record<string, (...args: unknown[]) => unknown>,
|
||||
mockSetStatus: vi.fn(),
|
||||
mockFindById: vi.fn(),
|
||||
mockGenerateRaw: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
default: {
|
||||
ipcMain: {
|
||||
handle: (ch: string, fn: (...args: unknown[]) => unknown) => {
|
||||
handlers[ch] = fn;
|
||||
}
|
||||
},
|
||||
dialog: {},
|
||||
shell: {}
|
||||
}
|
||||
}));
|
||||
|
||||
import { registerInboxApi } from '../../src/main/ipc/inboxApi';
|
||||
|
||||
function makeDeps(): Parameters<typeof registerInboxApi>[0] {
|
||||
// Minimal stub — `inbox:set-status` 핸들러는 deps.repo.setStatus 만 참조.
|
||||
// `ai:classify-status` 는 deps.repo.findById + deps.providerHolder.get() 사용.
|
||||
const provider = {
|
||||
name: 'mock',
|
||||
generate: vi.fn(),
|
||||
healthCheck: vi.fn(async () => ({ ok: true })),
|
||||
generateRaw: mockGenerateRaw
|
||||
};
|
||||
return {
|
||||
repo: {
|
||||
setStatus: mockSetStatus,
|
||||
findById: mockFindById,
|
||||
list: vi.fn(),
|
||||
listByStatus: vi.fn(),
|
||||
countByStatus: vi.fn(() => 0)
|
||||
} as never,
|
||||
continuity: {} as never,
|
||||
capture: {} as never,
|
||||
health: {} as never,
|
||||
intent: {} as never,
|
||||
getInboxWindow: () => null,
|
||||
settings: {} as never,
|
||||
providerHolder: { get: () => provider } as never,
|
||||
paths: { profileDir: '/profile' }
|
||||
};
|
||||
}
|
||||
|
||||
describe('inbox:set-status IPC', () => {
|
||||
beforeEach(() => {
|
||||
Object.keys(handlers).forEach((k) => delete handlers[k]);
|
||||
mockSetStatus.mockReset();
|
||||
});
|
||||
|
||||
it('forwards valid status + reason to repo.setStatus', async () => {
|
||||
registerInboxApi(makeDeps());
|
||||
const handler = handlers['inbox:set-status'];
|
||||
if (handler === undefined) throw new Error('handler not registered');
|
||||
const r = await handler(null, 'n1', 'completed', '결재 끝');
|
||||
expect(r).toEqual({ ok: true });
|
||||
expect(mockSetStatus).toHaveBeenCalledWith('n1', 'completed', '결재 끝');
|
||||
});
|
||||
|
||||
it('forwards null reason as-is', async () => {
|
||||
registerInboxApi(makeDeps());
|
||||
const handler = handlers['inbox:set-status'];
|
||||
if (handler === undefined) throw new Error('handler not registered');
|
||||
const r = await handler(null, 'n1', 'archived', null);
|
||||
expect(r).toEqual({ ok: true });
|
||||
expect(mockSetStatus).toHaveBeenCalledWith('n1', 'archived', null);
|
||||
});
|
||||
|
||||
it('rejects invalid status without calling repo', async () => {
|
||||
registerInboxApi(makeDeps());
|
||||
const handler = handlers['inbox:set-status'];
|
||||
if (handler === undefined) throw new Error('handler not registered');
|
||||
const r = (await handler(null, 'n1', 'invalid', null)) as { ok: boolean; reason?: string };
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toBe('invalid status');
|
||||
expect(mockSetStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ai:classify-status IPC', () => {
|
||||
beforeEach(() => {
|
||||
Object.keys(handlers).forEach((k) => delete handlers[k]);
|
||||
mockFindById.mockReset();
|
||||
mockGenerateRaw.mockReset();
|
||||
});
|
||||
|
||||
it('uses classifyStatus with note rawText/summary', async () => {
|
||||
mockFindById.mockReturnValue({
|
||||
id: 'n1',
|
||||
rawText: 'meeting notes',
|
||||
aiSummary: 's'
|
||||
});
|
||||
mockGenerateRaw.mockResolvedValue(
|
||||
'{"recommended":"completed","rationale":"끝남"}'
|
||||
);
|
||||
registerInboxApi(makeDeps());
|
||||
const handler = handlers['ai:classify-status'];
|
||||
if (handler === undefined) throw new Error('handler not registered');
|
||||
const r = (await handler(null, 'n1', '결재')) as {
|
||||
recommended: string;
|
||||
rationale: string;
|
||||
};
|
||||
expect(r.recommended).toBe('completed');
|
||||
expect(r.rationale).toBe('끝남');
|
||||
// prompt 에 rawText / summary / reason 포함
|
||||
const prompt = mockGenerateRaw.mock.calls[0]?.[0] as string;
|
||||
expect(prompt).toContain('meeting notes');
|
||||
expect(prompt).toContain('결재');
|
||||
});
|
||||
|
||||
it('returns archived fallback when note not found', async () => {
|
||||
mockFindById.mockReturnValue(null);
|
||||
registerInboxApi(makeDeps());
|
||||
const handler = handlers['ai:classify-status'];
|
||||
if (handler === undefined) throw new Error('handler not registered');
|
||||
const r = (await handler(null, 'missing', '결재')) as {
|
||||
recommended: string;
|
||||
rationale: string;
|
||||
};
|
||||
expect(r.recommended).toBe('archived');
|
||||
expect(r.rationale.length).toBeGreaterThan(0);
|
||||
expect(mockGenerateRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns archived fallback when AI throws', async () => {
|
||||
mockFindById.mockReturnValue({
|
||||
id: 'n1',
|
||||
rawText: 't',
|
||||
aiSummary: null
|
||||
});
|
||||
mockGenerateRaw.mockRejectedValue(new Error('network'));
|
||||
registerInboxApi(makeDeps());
|
||||
const handler = handlers['ai:classify-status'];
|
||||
if (handler === undefined) throw new Error('handler not registered');
|
||||
const r = (await handler(null, 'n1', 'r')) as {
|
||||
recommended: string;
|
||||
rationale: string;
|
||||
};
|
||||
expect(r.recommended).toBe('archived');
|
||||
});
|
||||
});
|
||||
74
tests/unit/inklingMedia.test.ts
Normal file
74
tests/unit/inklingMedia.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { join, sep } from 'node:path';
|
||||
|
||||
const { mockReadFile, mockHandle, mockRegisterSchemes } = vi.hoisted(() => ({
|
||||
mockReadFile: vi.fn(),
|
||||
mockHandle: vi.fn(),
|
||||
mockRegisterSchemes: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
readFile: mockReadFile
|
||||
}));
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
default: {
|
||||
protocol: {
|
||||
registerSchemesAsPrivileged: mockRegisterSchemes,
|
||||
handle: mockHandle
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
import { registerInklingMediaProtocol, inferMime } from '../../src/main/protocol/inklingMedia';
|
||||
|
||||
describe('inferMime', () => {
|
||||
it('returns image/png for .png', () => { expect(inferMime('foo.png')).toBe('image/png'); });
|
||||
it('returns image/jpeg for .jpg and .jpeg', () => {
|
||||
expect(inferMime('foo.jpg')).toBe('image/jpeg');
|
||||
expect(inferMime('foo.jpeg')).toBe('image/jpeg');
|
||||
});
|
||||
it('returns image/gif for .gif', () => { expect(inferMime('foo.gif')).toBe('image/gif'); });
|
||||
it('returns image/webp for .webp', () => { expect(inferMime('foo.webp')).toBe('image/webp'); });
|
||||
it('returns application/octet-stream for unknown', () => { expect(inferMime('foo.xyz')).toBe('application/octet-stream'); });
|
||||
});
|
||||
|
||||
describe('inkling-media protocol handler', () => {
|
||||
beforeEach(() => { vi.clearAllMocks(); });
|
||||
|
||||
function getHandler(profileDir: string): (req: { url: string }) => Promise<Response> {
|
||||
registerInklingMediaProtocol(profileDir);
|
||||
const call = mockHandle.mock.calls[0];
|
||||
if (!call) throw new Error('protocol.handle not called');
|
||||
return call[1] as (req: { url: string }) => Promise<Response>;
|
||||
}
|
||||
|
||||
// 실 운영 (Electron protocol.handle) 에서는 req.url 이 raw 문자열로 전달되지만,
|
||||
// vitest 의 `new Request()` constructor 는 url 을 즉시 normalize (`/../` 제거) 함.
|
||||
// 따라서 traversal 검사 로직이 raw URL 단계에서 작동하는지 검증하려면
|
||||
// raw url 을 보존한 minimal mock 을 직접 전달.
|
||||
function rawReq(url: string): { url: string } { return { url }; }
|
||||
|
||||
it('serves valid file with correct mime', async () => {
|
||||
mockReadFile.mockResolvedValueOnce(Buffer.from([1, 2, 3]));
|
||||
const handler = getHandler('/profile');
|
||||
const res = await handler(rawReq('inkling-media://media/note1/img.png'));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('content-type')).toBe('image/png');
|
||||
expect(mockReadFile).toHaveBeenCalledWith(join('/profile', 'media', 'note1', 'img.png'));
|
||||
});
|
||||
|
||||
it('returns 403 on path traversal attempt', async () => {
|
||||
const handler = getHandler('/profile');
|
||||
const res = await handler(rawReq('inkling-media://media/../etc/passwd'));
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockReadFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 404 when file missing', async () => {
|
||||
mockReadFile.mockRejectedValueOnce(new Error('ENOENT'));
|
||||
const handler = getHandler('/profile');
|
||||
const res = await handler(rawReq('inkling-media://media/note1/missing.png'));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user