11 Commits

Author SHA1 Message Date
c4e7536086 Merge pull request 'v0.2.10 Cut C — raw_text 가변 + revision history (F20)' (#28) from worktree-v0210-cut-c-raw-text-revisions into main
Reviewed-on: #28
2026-05-09 14:52:26 +00:00
altair823
39b8d1e728 fix(v0210): importNote 가 capture revision 을 함께 INSERT (final review fix)
final code review 발견: F5 import 후 first user edit 시 import 시점 본문이
note_revisions 에 없어 history 에서 사라지는 회귀. importNote transaction 안
INSERT 추가 (createdAt = edited_at).

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 17:59:00 +09:00
21 changed files with 3977 additions and 22 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1507,9 +1507,9 @@ app.on('activate', () => {
---
## F20. 기존 메모 본문 (raw_text) 수정 가능성 (🌱 raw — v0.2.8 후보, **load-bearing invariant 재검토**)
## F20. 기존 메모 본문 (raw_text) 수정 가능성 (✅ promoted v0.2.10 Cut C — invariant 폐기)
**진행 상태:** 🌱 raw — 메모리 정책 `raw_text 불변` 재논의 트리거. v0.2.8 brainstorm 시 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.

View File

@@ -31,7 +31,9 @@
---
## 3. Schema 마이그레이션 (m005)
## 3. Schema 마이그레이션 (m006)
> 메모: 본 스펙 작성 시점에는 m005 로 예상했으나 Cut B (v0.2.9) 에서 m005 (ai_disabled CHECK relax) 가 선점됨 → 실제 번호는 **m006**.
```sql
CREATE TABLE note_revisions (
@@ -171,7 +173,7 @@ interface NoteRevision {
| 영역 | 단위 |
|---|---|
| m005 마이그레이션 | 기존 notes → revision backfill (edited_by='capture') |
| 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 갱신 |
@@ -179,7 +181,7 @@ interface NoteRevision {
| 이력 modal | revision 목록 표시 + 회수 클릭 → confirm + IPC |
| AiWorker input | current notes.raw_text 사용 (revision X) 회귀 |
**목표**: 단위 490 → 약 505 (+15), typecheck 0.
**목표**: 단위 548 → 약 567 (+19, m006 5 + create rev 1 + updateRawText 2 + listRevisions 1 + restoreRevision 2 + IPC 4 + NoteCard 편집 1 + RevisionHistoryModal 2 + findById 회귀 1), typecheck 0.
---

4
package-lock.json generated
View File

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

View File

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

View File

@@ -4,8 +4,9 @@ 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];
const migrations = [m001, m002, m003, m004, m005, m006];
export function latestVersion(): number {
return migrations[migrations.length - 1]!.version;

View File

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

View File

@@ -269,6 +269,29 @@ 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 };
}
});
}
export function pushNoteUpdated(getWin: () => BrowserWindow | null, note: Note): void {

View File

@@ -1,6 +1,6 @@
import type Database from 'better-sqlite3';
import { v7 as uuidv7, v4 as uuidv4 } from 'uuid';
import type { AiStatus, Note, NoteMedia, NoteStatus, NoteTag } from '@shared/types';
import type { AiStatus, Note, NoteMedia, NoteRevision, NoteStatus, NoteTag } from '@shared/types';
import { kstTodayIso } from '../../shared/util/kstDate.js';
export interface CreateNoteInput {
@@ -22,7 +22,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;
@@ -61,6 +64,10 @@ export class NoteRepository {
.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);
// pending_jobs 는 'pending' 일 때만 생성 — 'disabled' 노트는 worker 가 처리 안 함.
if (aiStatus === 'pending') {
this.db
@@ -465,6 +472,69 @@ 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
@@ -614,11 +684,17 @@ 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 발견).
*
* deletedAt merge (v0.2.3 #4, spec §8.2): source/dest 중 IS NOT NULL 우선
* (삭제 보존). skip 케이스에서 source NN + dest NULL 일 때만 dest 갱신.
@@ -669,6 +745,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`

View File

@@ -81,6 +81,10 @@ const api: InklingApi = {
// 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),
}
};

View File

@@ -6,6 +6,7 @@ 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;
@@ -118,6 +119,9 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
// 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[]
@@ -150,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);
@@ -371,9 +386,32 @@ 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>
@@ -487,6 +525,17 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
}}
/>
)}
{showRevisions && (
<RevisionHistoryModal
noteId={local.id}
onClose={() => setShowRevisions(false)}
onRestored={(newRawText) => {
const updated = { ...local, rawText: newRawText, updatedAt: new Date().toISOString() };
setLocal(updated);
onUpdated(updated);
}}
/>
)}
</div>
);
}

View File

@@ -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>
);
}

View File

@@ -21,6 +21,16 @@ export interface NoteTag {
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';
}
export interface Note {
id: string;
rawText: string;
@@ -156,6 +166,10 @@ export interface InboxApi {
// 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 }>;
}
export interface InklingApi {

View File

@@ -4,13 +4,14 @@ 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 } = vi.hoisted(() => ({
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', () => ({
@@ -24,7 +25,10 @@ vi.mock('../../src/renderer/inbox/api.js', () => ({
setIntent: vi.fn(),
dismissIntent: vi.fn(),
setStatus: mockSetStatus,
classifyStatus: mockClassify
classifyStatus: mockClassify,
updateRawText: mockUpdateRawText,
listRevisions: vi.fn(async () => []),
restoreRevision: vi.fn(async () => ({ ok: true as const }))
}
}));
@@ -154,3 +158,31 @@ describe('NoteCard — 이동 메뉴 (v0.2.9 Cut B Task 6)', () => {
});
});
});
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');
});
});

View File

@@ -1054,3 +1054,23 @@ describe('NoteRepository.countByAiStatus', () => {
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' });
});
});

View 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');
});
});
});

View 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();
});
});

View 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' });
});
});

View File

@@ -0,0 +1,95 @@
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');
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);
});
});

View File

@@ -51,11 +51,11 @@ describe('migration v3 — soft delete columns', () => {
db.close();
});
it('user_version reaches latest (5)', () => {
it('user_version reaches latest (6)', () => {
const db = new Database(':memory:');
runMigrations(db);
const row = db.prepare('PRAGMA user_version').get() as { user_version: number };
expect(row.user_version).toBe(5);
expect(row.user_version).toBe(6);
db.close();
});