diff --git a/docs/superpowers/plans/2026-05-09-v0210-cut-c-raw-text-revisions.md b/docs/superpowers/plans/2026-05-09-v0210-cut-c-raw-text-revisions.md new file mode 100644 index 0000000..6b4fe6d --- /dev/null +++ b/docs/superpowers/plans/2026-05-09-v0210-cut-c-raw-text-revisions.md @@ -0,0 +1,1314 @@ +# v0.2.10 Cut C — raw_text 수정 + revision history 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:** F20 — 사용자가 `notes.raw_text` 자유 편집 + 옛 버전 모두 보존 + 회수 가능. load-bearing invariant 변경 (`raw_text 불변` → `raw_text 가변 + revision 보존`). + +**Architecture:** 새 테이블 `note_revisions` 추가 (m006), 모든 raw_text 변경 시 새 revision row INSERT. `notes.raw_text` 는 latest 값 그대로 유지 (FTS5/AiWorker source 불변). 기존 노트는 m006 backfill 시 `edited_by='capture'` revision 생성. UI = NoteCard 의 "원문 보기" 영역에 "편집"/"이력" 버튼 추가, 이력은 modal. + +**Tech Stack:** SQLite (better-sqlite3 12.9, AUTOINCREMENT + FK ON DELETE CASCADE), Electron IPC, React 19 + zustand 5, vitest 4 + RTL. + +**선행 문서:** + +- `docs/superpowers/specs/2026-05-09-v0210-cut-c-design.md` — 본 plan 의 source spec +- `docs/superpowers/strategy/v028plus-roadmap.md` — Cut C 위치 +- `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` — F20 + +--- + +## File Structure + +**Create:** + +- `src/main/db/migrations/m006_revisions.ts` — `note_revisions` 테이블 + index + 기존 notes backfill (`edited_by='capture'`) +- `src/renderer/inbox/components/RevisionHistoryModal.tsx` — 이력 modal (rev 목록 + 회수 confirm) +- `tests/unit/m006-migration.test.ts` — 5 tests (cols / index / FK cascade / backfill / version=6) +- `tests/unit/NoteRevisions.test.ts` — repo 테스트 (insert/update/list/restore) +- `tests/unit/RevisionHistoryModal.test.tsx` — modal 테스트 + +**Modify:** + +- `src/main/db/migrations/index.ts` — m006 import + 배열 추가 +- `src/main/repository/NoteRepository.ts` — `create` 에 첫 revision INSERT, 신규 `updateRawText` / `listRevisions` / `restoreRevision` +- `src/main/ipc/inboxApi.ts` — 3 새 IPC handler +- `src/preload/index.ts` — 3 새 bridge 함수 +- `src/shared/types.ts` — `NoteRevision` 인터페이스 + `InboxApi` 3 메서드 시그니처 +- `src/renderer/inbox/components/NoteCard.tsx` — 원문 영역에 편집 textarea + "이력" 버튼 + modal toggle +- `src/renderer/inbox/api.ts` — re-export 만 (변경 없음, types 가 늘어 자동 노출) +- `tests/unit/NoteRepository.test.ts` — `create` 가 'capture' revision INSERT 함을 검증하는 test 1건 추가 +- `tests/unit/AiWorker.test.ts` — updateRawText 후 AiWorker.findById 가 latest raw_text 사용함을 회귀 검증 +- `tests/unit/NoteCard.test.tsx` — 편집 textarea save → IPC 호출 검증 +- `package.json` — version `0.2.9` → `0.2.10` +- `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` — F20 promoted 마킹 + Cut C 라벨 + +--- + +## 단위 목표 + +548 (v0.2.9) → 약 565 (+17), typecheck 0. + +--- + +## Task 1: m006 migration — `note_revisions` 테이블 + +**Files:** +- Create: `src/main/db/migrations/m006_revisions.ts` +- Create: `tests/unit/m006-migration.test.ts` +- Modify: `src/main/db/migrations/index.ts` + +`note_revisions` 는 `notes.id` 를 FK + ON DELETE CASCADE 로 참조 (영구 삭제 시 revision 도 삭제). `(note_id, edited_at DESC)` index 로 listRevisions 빠르게. backfill = 기존 모든 notes 의 raw_text 를 `edited_by='capture'` 로 INSERT (`created_at` 을 `edited_at` 으로). + +- [ ] **Step 1: failing test 작성** + +`tests/unit/m006-migration.test.ts`: + +```ts +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import Database from 'better-sqlite3'; +import { up } from '../../src/main/db/migrations/m006_revisions.js'; + +describe('m006 migration — note_revisions table', () => { + let db: Database.Database; + + beforeEach(() => { + db = new Database(':memory:'); + db.pragma('foreign_keys = ON'); + // m005 baseline (notes 테이블 — ai_status enum 'disabled' 포함) + db.exec(` + CREATE TABLE notes ( + id TEXT PRIMARY KEY, + raw_text TEXT NOT NULL, + ai_title TEXT, + ai_summary TEXT, + ai_status TEXT NOT NULL + CHECK (ai_status IN ('pending','done','failed','disabled')), + ai_error TEXT, + ai_provider TEXT, + ai_generated_at TEXT, + title_edited_by_user INTEGER NOT NULL DEFAULT 0, + summary_edited_by_user INTEGER NOT NULL DEFAULT 0, + user_intent TEXT, + intent_prompted_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + due_date TEXT, + due_date_edited_by_user INTEGER NOT NULL DEFAULT 0, + deleted_at TEXT, + last_recalled_at TEXT, + recall_dismissed_at TEXT, + status TEXT NOT NULL DEFAULT 'active', + status_changed_at TEXT, + move_reason TEXT + ); + INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at) + VALUES ('a', 'first text', 'done', '2026-05-01T00:00:00Z', '2026-05-01T00:00:00Z'), + ('b', 'second text', 'done', '2026-05-02T00:00:00Z', '2026-05-02T00:00:00Z'); + `); + }); + + afterEach(() => { db.close(); }); + + it('creates note_revisions table with required columns', () => { + up(db); + const cols = db.prepare(`PRAGMA table_info(note_revisions)`).all() as Array<{ name: string }>; + const names = cols.map((c) => c.name); + expect(names).toEqual( + expect.arrayContaining(['rev_id', 'note_id', 'raw_text', 'edited_at', 'edited_by']) + ); + }); + + it('creates idx_note_revisions_note_id index', () => { + up(db); + const idx = db.prepare(`PRAGMA index_list(note_revisions)`).all() as Array<{ name: string }>; + expect(idx.map((i) => i.name)).toContain('idx_note_revisions_note_id'); + }); + + it('cascades on note delete (FK ON DELETE CASCADE)', () => { + up(db); + db.prepare( + `INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by) + VALUES ('a', 'manual rev', '2026-05-03T00:00:00Z', 'user')` + ).run(); + db.prepare(`DELETE FROM notes WHERE id=?`).run('a'); + const rows = db.prepare(`SELECT * FROM note_revisions WHERE note_id=?`).all('a'); + expect(rows).toHaveLength(0); + }); + + it("backfills existing notes as edited_by='capture' revisions", () => { + up(db); + const rows = db + .prepare(`SELECT note_id, raw_text, edited_at, edited_by FROM note_revisions ORDER BY note_id`) + .all() as Array<{ note_id: string; raw_text: string; edited_at: string; edited_by: string }>; + expect(rows).toHaveLength(2); + expect(rows[0]).toEqual({ + note_id: 'a', + raw_text: 'first text', + edited_at: '2026-05-01T00:00:00Z', + edited_by: 'capture' + }); + expect(rows[1]).toEqual({ + note_id: 'b', + raw_text: 'second text', + edited_at: '2026-05-02T00:00:00Z', + edited_by: 'capture' + }); + }); + + it('exports version=6', async () => { + const mod = await import('../../src/main/db/migrations/m006_revisions.js'); + expect(mod.version).toBe(6); + }); +}); +``` + +- [ ] **Step 2: test FAIL 확인** + +Run: `npx vitest run tests/unit/m006-migration.test.ts` +Expected: FAIL — `m006_revisions.js` module not found. + +- [ ] **Step 3: m006 migration 구현** + +`src/main/db/migrations/m006_revisions.ts` (전체 내용): + +```ts +// 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; + `); +} +``` + +- [ ] **Step 4: index.ts 갱신** + +`src/main/db/migrations/index.ts` 의 import + 배열에 m006 추가: + +```ts +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'; + +const migrations = [m001, m002, m003, m004, m005, m006]; +``` + +- [ ] **Step 5: test PASS 확인** + +Run: `npx vitest run tests/unit/m006-migration.test.ts` +Expected: PASS — 5/5 tests. + +- [ ] **Step 6: typecheck** + +Run: `npm run typecheck` +Expected: 0 errors. + +- [ ] **Step 7: commit** + +```bash +git add src/main/db/migrations/m006_revisions.ts \ + src/main/db/migrations/index.ts \ + tests/unit/m006-migration.test.ts +git commit -m "feat(v0210): m006 migration — note_revisions 테이블 + capture backfill" +``` + +--- + +## Task 2: NoteRepository.create — 첫 revision INSERT (edited_by='capture') + +**Files:** +- Modify: `src/main/repository/NoteRepository.ts` +- Modify: `tests/unit/NoteRepository.test.ts` + +`create` 가 새 노트를 INSERT 하면서 동일 transaction 안에서 `note_revisions` 에 `edited_by='capture'` row 를 함께 INSERT. 새 노트의 첫 revision 은 항상 capture. + +- [ ] **Step 1: failing test 작성** + +`tests/unit/NoteRepository.test.ts` 의 적절한 describe 안에 추가: + +```ts +it('create() 가 첫 revision (edited_by=capture) 을 INSERT 한다', () => { + const repo = new NoteRepository(db); + 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' }); +}); +``` + +- [ ] **Step 2: test FAIL 확인** + +Run: `npx vitest run tests/unit/NoteRepository.test.ts -t "create() 가 첫 revision"` +Expected: FAIL — note_revisions empty. + +- [ ] **Step 3: NoteRepository.create 갱신** + +`src/main/repository/NoteRepository.ts` 의 `create` 메서드 transaction 본문에 INSERT 추가: + +```ts +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 (?, ?, ?, ?, ?)`) + .run(id, input.rawText, aiStatus, now, now); + this.db + .prepare(`INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by) + VALUES (?, ?, ?, 'capture')`) + .run(id, input.rawText, now); + if (aiStatus === 'pending') { + this.db + .prepare(`INSERT INTO pending_jobs (note_id, attempts, next_run_at) + VALUES (?, 0, ?)`) + .run(id, now); + } + }); + tx(); + return { id }; +} +``` + +- [ ] **Step 4: test PASS 확인** + +Run: `npx vitest run tests/unit/NoteRepository.test.ts` +Expected: 모든 NoteRepository test PASS (기존 + 신규 1). + +- [ ] **Step 5: commit** + +```bash +git add src/main/repository/NoteRepository.ts tests/unit/NoteRepository.test.ts +git commit -m "feat(v0210): NoteRepository.create 가 capture revision 을 함께 INSERT" +``` + +--- + +## Task 3: NoteRepository.updateRawText — raw_text 갱신 + 새 revision INSERT + +**Files:** +- Modify: `src/main/repository/NoteRepository.ts` +- Create: `tests/unit/NoteRevisions.test.ts` + +새 메서드. `notes.raw_text` 와 `notes.updated_at` 갱신 + `note_revisions` 에 `edited_by='user'` 새 row INSERT — 단일 transaction. + +- [ ] **Step 1: failing test 파일 생성** + +`tests/unit/NoteRevisions.test.ts`: + +```ts +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[0].edited_by).toBe('capture'); + expect(revs[0].raw_text).toBe('v1'); + expect(revs[1].edited_by).toBe('user'); + expect(revs[1].raw_text).toBe('v2'); + expect(revs[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']); + }); + }); +}); +``` + +- [ ] **Step 2: test FAIL 확인** + +Run: `npx vitest run tests/unit/NoteRevisions.test.ts` +Expected: FAIL — `repo.updateRawText is not a function`. + +- [ ] **Step 3: updateRawText 메서드 추가** + +`src/main/repository/NoteRepository.ts` 에 추가 (다른 update 메서드 근처 — 예: `setStatus` 위): + +```ts +/** + * 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(); +} +``` + +- [ ] **Step 4: test PASS 확인** + +Run: `npx vitest run tests/unit/NoteRevisions.test.ts` +Expected: PASS — 2/2 tests. + +- [ ] **Step 5: commit** + +```bash +git add src/main/repository/NoteRepository.ts tests/unit/NoteRevisions.test.ts +git commit -m "feat(v0210): NoteRepository.updateRawText — raw_text 갱신 + user revision INSERT" +``` + +--- + +## Task 4: NoteRepository.listRevisions — DESC 순서 + edited_by 정확 + +**Files:** +- Modify: `src/main/repository/NoteRepository.ts` +- Modify: `tests/unit/NoteRevisions.test.ts` + +조회 메서드. `note_revisions` 를 `edited_at DESC, rev_id DESC` 로 반환. 결과 = `NoteRevision[]` 형태로 hydrate. + +- [ ] **Step 1: failing test 추가** + +`tests/unit/NoteRevisions.test.ts` 의 describe 블록 안에 추가: + +```ts +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[0].rawText).toBe('v3'); + expect(revs[0].editedBy).toBe('user'); + expect(revs[1].rawText).toBe('v2'); + expect(revs[1].editedBy).toBe('user'); + expect(revs[2].rawText).toBe('v1'); + expect(revs[2].editedBy).toBe('capture'); + // hydrate 정확성 — revId/noteId/editedAt 모두 채워짐 + expect(typeof revs[0].revId).toBe('number'); + expect(revs[0].noteId).toBe(id); + expect(revs[0].editedAt).toBe('2026-05-11T00:00:00.000Z'); + }); +}); +``` + +- [ ] **Step 2: test FAIL 확인** + +Run: `npx vitest run tests/unit/NoteRevisions.test.ts -t "listRevisions"` +Expected: FAIL — `repo.listRevisions is not a function`. + +- [ ] **Step 3: listRevisions 메서드 추가 (+ NoteRevision import)** + +`src/main/repository/NoteRepository.ts` 상단 import 에 `NoteRevision` 추가 (Task 6 에서 정의 — 미리 import 만 하고 Task 6 에서 type 정의 시 동작): + +```ts +import type { AiStatus, Note, NoteMedia, NoteRevision, NoteStatus, NoteTag } from '@shared/types'; +``` + +다음 메서드를 `updateRawText` 아래에 추가: + +```ts +/** + * 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 + })); +} +``` + +- [ ] **Step 4: test FAIL 다시 확인 (이번엔 NoteRevision 타입 미정의로 typecheck 에러)** + +Run: `npx vitest run tests/unit/NoteRevisions.test.ts -t "listRevisions"` +Expected: FAIL — TypeScript / module 에러 (`NoteRevision` 타입 export 없음). Task 6 에서 처리. + +NOTE — vitest 가 type 검사를 ts 모듈 import 시점까지만 수행하므로 실제로는 runtime PASS 가능. typecheck 단계에서 문제 잡힘. + +Run: `npm run typecheck` +Expected: error TS2305 — '"@shared/types"' has no exported member 'NoteRevision'. + +- [ ] **Step 5: skip — Task 6 에서 NoteRevision 정의 추가 후 일괄 PASS 예상** + +본 task 는 commit 하지 않고 Task 5 까지 누적 후 한 번에 commit. 이유: NoteRevision type 정의 (Task 6) 가 나오기 전까지 typecheck red — 중간 commit 시 빌드 깨짐. + +진행만 하고 끝. + +--- + +## Task 5: NoteRepository.restoreRevision — 옛 raw_text 를 새 revision 으로 복원 + +**Files:** +- Modify: `src/main/repository/NoteRepository.ts` +- Modify: `tests/unit/NoteRevisions.test.ts` + +옛 revision 의 raw_text 를 latest 로 복원. 구현 = 해당 raw_text 를 `updateRawText` 로 호출 (chain 끊지 않고 새 user revision 으로 INSERT). 없으면 throw. + +- [ ] **Step 1: failing tests 추가** + +`tests/unit/NoteRevisions.test.ts` describe 블록에 추가: + +```ts +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')); + + // 첫 revision (v1) 의 rev_id 조회 + 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[0].rawText).toBe('v1'); + expect(after[0].editedBy).toBe('user'); + expect(after[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/); + }); +}); +``` + +- [ ] **Step 2: test FAIL 확인** + +Run: `npx vitest run tests/unit/NoteRevisions.test.ts -t "restoreRevision"` +Expected: FAIL — `repo.restoreRevision is not a function`. + +- [ ] **Step 3: restoreRevision 메서드 추가** + +`src/main/repository/NoteRepository.ts` 의 `listRevisions` 아래: + +```ts +/** + * 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); +} +``` + +- [ ] **Step 4: 모든 NoteRevisions.test PASS 확인 (단, NoteRevision 타입은 Task 6 까지 미정의)** + +Run: `npx vitest run tests/unit/NoteRevisions.test.ts` +Expected: tests PASS or FAIL based on NoteRevision type. If type-only — runtime PASS. typecheck 별도 — Task 6 까지 red 예상. + +- [ ] **Step 5: commit (Task 3-5 일괄)** + +이 시점에서 NoteRepository 의 3 메서드 (updateRawText / listRevisions / restoreRevision) 가 모두 구현됨. NoteRevision type 은 Task 6 에서. 본 commit 은 type 미정의로 typecheck red. → Task 6 까지 보류. + +대신 staged 상태로만 두지 말고 untracked-but-not-committed 로 보존. 명령: + +```bash +# 아직 commit 하지 않음 — Task 6 와 함께 ATOMIC commit 예정 +echo "Task 5 implementation complete; type definition pending in Task 6" +``` + +--- + +## Task 6: shared types — `NoteRevision` + `InboxApi` 3 메서드 + +**Files:** +- Modify: `src/shared/types.ts` + +타입 정의가 main(repo)/preload/renderer 모두 한 모듈에서 흐름. NoteRevision interface + InboxApi 메서드 3개 추가. + +- [ ] **Step 1: NoteRevision + InboxApi 메서드 시그니처 추가** + +`src/shared/types.ts` 의 `NoteTag` interface 아래에 추가: + +```ts +// 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'; +} +``` + +`InboxApi` interface 의 마지막 (getDisabledCount 다음) 에 추가: + +```ts + // v0.2.10 Cut C — raw_text 가변 + revision 보존. + updateRawText(noteId: string, newText: string): Promise<{ ok: true }>; + listRevisions(noteId: string): Promise; + restoreRevision(noteId: string, revId: number): Promise<{ ok: true } | { ok: false; reason: string }>; +``` + +- [ ] **Step 2: typecheck PASS 확인** + +Run: `npm run typecheck` +Expected: 0 errors. (Task 3-5 의 NoteRepository import + Task 4 의 listRevisions return 타입 모두 정상.) + +- [ ] **Step 3: 단위 테스트 일괄 PASS 확인** + +Run: `npx vitest run tests/unit/NoteRevisions.test.ts tests/unit/NoteRepository.test.ts tests/unit/m006-migration.test.ts` +Expected: 모두 PASS. + +- [ ] **Step 4: commit (Task 3-6 일괄)** + +```bash +git add src/main/repository/NoteRepository.ts \ + src/shared/types.ts \ + tests/unit/NoteRevisions.test.ts +git commit -m "feat(v0210): NoteRepository revision API + NoteRevision type + InboxApi 시그니처 + +- updateRawText: raw_text 갱신 + user revision INSERT (atomic) +- listRevisions: edited_at DESC 순 hydrate +- restoreRevision: 옛 raw_text 를 새 user revision 으로 복원 (chain 보존)" +``` + +--- + +## Task 7: IPC handlers — set / list / restore + +**Files:** +- Modify: `src/main/ipc/inboxApi.ts` +- Create: `tests/unit/inboxApi-revisions.test.ts` + +3개 ipc handler 추가. argument validation = 빈 문자열/공백 차단 (updateRawText 는 trim 후 length===0 이면 reject — 빈 raw_text 는 의미 없음). restoreRevision 은 repo throw 시 `{ ok: false }`. + +- [ ] **Step 1: failing test 작성** + +`tests/unit/inboxApi-revisions.test.ts`: + +```ts +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 }).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 { + 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 }).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).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).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' }); + }); +}); +``` + +- [ ] **Step 2: test FAIL 확인** + +Run: `npx vitest run tests/unit/inboxApi-revisions.test.ts` +Expected: FAIL — handler 미등록. + +- [ ] **Step 3: 3 handler 등록** + +`src/main/ipc/inboxApi.ts` 의 마지막 handler (`inbox:saveOllamaSettings`) 다음 / 함수 닫는 `}` 직전에 추가: + +```ts + // 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 }; + } + }); +``` + +- [ ] **Step 4: test PASS 확인** + +Run: `npx vitest run tests/unit/inboxApi-revisions.test.ts` +Expected: PASS — 4/4. + +- [ ] **Step 5: commit** + +```bash +git add src/main/ipc/inboxApi.ts tests/unit/inboxApi-revisions.test.ts +git commit -m "feat(v0210): inbox:{update-raw-text,list-revisions,restore-revision} IPC" +``` + +--- + +## Task 8: preload bridge + +**Files:** +- Modify: `src/preload/index.ts` + +Renderer 가 `window.inkling.inbox.{updateRawText,listRevisions,restoreRevision}` 로 호출. + +- [ ] **Step 1: 3 bridge 함수 추가** + +`src/preload/index.ts` 의 `getDisabledCount` 다음에 추가 (객체 닫기 `}` 직전): + +```ts + // 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), +``` + +- [ ] **Step 2: typecheck** + +Run: `npm run typecheck` +Expected: 0 errors. (Task 6 의 InboxApi 시그니처와 일치.) + +- [ ] **Step 3: commit** + +```bash +git add src/preload/index.ts +git commit -m "feat(v0210): preload bridge — updateRawText/listRevisions/restoreRevision" +``` + +--- + +## Task 9: NoteCard — 원문 영역 편집 UI + +**Files:** +- Modify: `src/renderer/inbox/components/NoteCard.tsx` +- Modify: `tests/unit/NoteCard.test.tsx` + +기존 `rawOpen` 펼침 안에서 textarea 편집 진입 + 저장/취소 버튼. 저장 시 `inboxApi.updateRawText` 호출 → local state 갱신 + onUpdated. + +- [ ] **Step 1: failing test 작성 — `tests/unit/NoteCard.test.tsx` 의 적절한 describe 안** + +(기존 테스트 파일 패턴 따름. test mocking 은 기존 NoteCard.test.tsx 가 이미 `vi.mock('../../src/renderer/inbox/api.js', ...)` 로 inboxApi 를 mock 하므로 그 패턴 그대로.) + +```ts +it('원문 편집: textarea 저장 → updateRawText 호출 + 로컬 raw 갱신', async () => { + const note = makeNote({ rawText: 'old' }); + const onUpdated = vi.fn(); + render(); + // 원문 펼침 + await userEvent.click(screen.getByRole('button', { name: /원문/ })); + // 편집 진입 + await userEvent.click(screen.getByRole('button', { name: '편집' })); + const ta = screen.getByRole('textbox', { name: /원문 편집/ }); + await userEvent.clear(ta); + await userEvent.type(ta, 'new'); + await userEvent.click(screen.getByRole('button', { name: '저장' })); + + expect(inboxApi.updateRawText).toHaveBeenCalledWith(note.id, 'new'); + await waitFor(() => { + expect(onUpdated).toHaveBeenCalled(); + }); + const last = onUpdated.mock.calls.at(-1)![0]; + expect(last.rawText).toBe('new'); +}); +``` + +NOTE — `makeNote` factory 는 기존 NoteCard.test.tsx 에 있다고 가정. 없으면 test 안에서 inline 정의. + +- [ ] **Step 2: api 모듈 mock 에 updateRawText 추가 (test setup)** + +`tests/unit/NoteCard.test.tsx` 상단 `vi.mock(...)` 블록에 updateRawText 가 함수로 등록되어 있는지 확인. 없으면 추가: + +```ts +vi.mock('../../src/renderer/inbox/api.js', () => ({ + inboxApi: { + // 기존 메서드 ... + updateRawText: vi.fn(), + listRevisions: vi.fn(() => Promise.resolve([])), + restoreRevision: vi.fn(() => Promise.resolve({ ok: true })) + } +})); +``` + +(기존 mock 의 메서드 목록을 보고 추가 항목만. 다른 테스트 깨뜨리지 않도록 `vi.fn()` 으로 안전.) + +- [ ] **Step 3: test FAIL 확인** + +Run: `npx vitest run tests/unit/NoteCard.test.tsx -t "원문 편집"` +Expected: FAIL — "편집" 버튼 미존재. + +- [ ] **Step 4: NoteCard 의 원문 영역에 편집 UI 추가** + +`src/renderer/inbox/components/NoteCard.tsx` 의 useState block (line 119 근처) 에 추가: + +```tsx +const [editingRaw, setEditingRaw] = useState(false); +const [draftRaw, setDraftRaw] = useState(''); +const [showRevisions, setShowRevisions] = useState(false); +``` + +(showRevisions 는 Task 10 에서 사용.) + +raw save handler 추가 (saveDueDate 근처): + +```tsx +async function saveRaw() { + const next = draftRaw; + if (next.trim().length === 0) return; // 빈 텍스트 reject — IPC 도 reject + 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); +} +``` + +기존 `rawOpen && (
...)` 블록 (line 374 근처) 을 다음으로 교체:
+
+```tsx
+{rawOpen && (
+  
+ {editingRaw ? ( +
+