From 2e69f598bcbe7865def0c8fe9855308b40581a1a Mon Sep 17 00:00:00 2001 From: th-kim0823 Date: Mon, 11 May 2026 17:43:46 +0900 Subject: [PATCH] =?UTF-8?q?chore(release):=20v0.3.9=20=E2=80=94=20AI=20?= =?UTF-8?q?=ED=9D=90=EB=A6=84=20unblock=20UI=20+=20FTS5=20escape?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit audit edge case 3건: - pending 노트 "건너뛰기" 버튼 (cancelPending: pending → disabled + jobs DELETE) - failed 노트 per-note "재시도" 버튼 (retryOneFailed: failed → pending + enqueue) - FTS5 sanitize regex 확장 (backtick/dash/caret 추가) 동시 편집 race 는 EditableField guard 가 이미 처리 (수정 불필요). Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 24 +++++++++++++ package-lock.json | 4 +-- package.json | 2 +- src/main/ipc/inboxApi.ts | 19 ++++++++++ src/main/repository/NoteRepository.ts | 41 ++++++++++++++++++++++ src/main/repository/ftsHelpers.ts | 7 ++-- src/main/services/CaptureService.ts | 18 ++++++++++ src/preload/index.ts | 2 ++ src/renderer/inbox/components/NoteCard.tsx | 39 +++++++++++++++++--- src/shared/types.ts | 3 ++ tests/unit/NoteRepository.test.ts | 35 ++++++++++++++++++ tests/unit/ftsHelpers.test.ts | 6 ++++ 12 files changed, 191 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a63f9f..8302f3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,30 @@ 본 파일은 Inkling 의 버전별 사용자 영향 변경 사항을 기록한다. 형식은 [Keep a Changelog](https://keepachangelog.com/) 를 느슨하게 따른다. +## [0.3.9] — 2026-05-11 + +v0.3.8 audit 의 미수정 edge case 3건 완료. AI 처리 흐름의 사용자 unblock path + FTS5 query 안전성. + +### 수정 + +- **`ai_status='pending'` 노트 cancel UI 부재 (P1).** Ollama 끊김 / 무한 pending 상태에서 사용자가 빠져나오는 path 가 없었음. NoteCard 의 pending 표시 옆에 "건너뛰기" 버튼 추가 → `inboxApi.cancelPending(id)` → `repo.cancelPending`: `ai_status='disabled'` + `pending_jobs` DELETE. raw_text 는 보존. pushNoteUpdated emit 으로 renderer 자동 sync. +- **`ai_status='failed'` 노트 per-note 재시도 UI 부재 (P2).** 이전엔 FailedBanner 의 일괄 재시도만 가능. NoteCard 의 failed 표시 옆에 "재시도" 버튼 추가 → `inboxApi.retryOneFailed(id)` → `repo.retryOneFailed`: failed → pending + `pending_jobs` INSERT + worker enqueue. pushNoteUpdated emit. +- **FTS5 query escape 불완전 (P2).** `sanitizeFtsQuery` 의 special chars regex 가 `["*():]` 만 처리 → backtick/dash/caret 미escape 로 일부 입력이 FTS5 parser throw 야기. `["*():`^\-]` 로 확장. 한국어 사용자가 의도 없이 입력할 가능성 높은 punctuation 까지 안전 처리. + +### 미수정 (의도) + +- **동시 편집 race (P2).** EditableField 가 이미 `editing=true` 중 value prop 변경을 무시하는 guard 보유. 사용자 입력은 보존됨 (last-write-wins). 추가 코드 불필요. + +### 게이트 + +- 단위 745 → **750 PASS** (+5: repo retryOneFailed 2 + cancelPending 2 + FTS sanitize 1) +- typecheck 0 errors (src) +- 신규 npm dependency 0 + +### 업그레이드 + +v0.3.8 인스톨러 위에 v0.3.9 인스톨러를 같은 위치에 실행하면 in-place 업그레이드. 데이터/마이그레이션 변경 없음. + ## [0.3.8] — 2026-05-11 전수 audit 후 발견된 사용자 상호작용 hole 8건 일괄 hotfix. 핵심은 (1) push-based status 동기화 root fix, (2) modal Escape affordance 통일, (3) IPC 실패 resilience. diff --git a/package-lock.json b/package-lock.json index 10c42ca..33fa91f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "inkling", - "version": "0.3.8", + "version": "0.3.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "inkling", - "version": "0.3.8", + "version": "0.3.9", "dependencies": { "better-sqlite3": "12.9.0", "electron-log": "5.2.0", diff --git a/package.json b/package.json index f8feb75..a1da093 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "inkling", - "version": "0.3.8", + "version": "0.3.9", "private": true, "description": "Inkling — local-first 한 줄 보관 도구", "author": "altair823 ", diff --git a/src/main/ipc/inboxApi.ts b/src/main/ipc/inboxApi.ts index 8d02ebd..80c80e4 100644 --- a/src/main/ipc/inboxApi.ts +++ b/src/main/ipc/inboxApi.ts @@ -150,6 +150,25 @@ export function registerInboxApi(deps: InboxIpcDeps): void { ipcMain.handle('inbox:retryAllFailed', async () => deps.capture.retryAllFailed()); ipcMain.handle('inbox:failedCount', () => deps.repo.countFailed()); + // v0.3.9 — per-note retry/cancel. failed/pending 사용자 막힘 해소 path. + // status 변경 후 pushNoteUpdated 로 renderer counts/list 자동 sync. + ipcMain.handle('inbox:retry-one-failed', async (_e, id: string) => { + const r = await deps.capture.retryOneFailed(id); + if (r.ok) { + const updated = deps.repo.findById(id); + if (updated !== null) pushNoteUpdated(deps.getInboxWindow, updated); + } + return r; + }); + ipcMain.handle('inbox:cancel-pending', (_e, id: string) => { + const r = deps.capture.cancelPending(id); + if (r.ok) { + const updated = deps.repo.findById(id); + if (updated !== null) pushNoteUpdated(deps.getInboxWindow, updated); + } + return r; + }); + ipcMain.handle('inbox:listRecallCandidate', () => deps.capture.listRecallCandidate()); ipcMain.handle('inbox:markRecallOpened', (_e, id: string) => deps.capture.markRecallOpened(id)); ipcMain.handle('inbox:dismissRecall', (_e, id: string) => deps.capture.dismissRecall(id)); diff --git a/src/main/repository/NoteRepository.ts b/src/main/repository/NoteRepository.ts index 5d73883..97e99ab 100644 --- a/src/main/repository/NoteRepository.ts +++ b/src/main/repository/NoteRepository.ts @@ -248,6 +248,47 @@ export class NoteRepository { return { ids }; } + /** + * v0.3.9 — 단일 failed 노트 재시도. retryAllFailed 의 per-row 로직 동일. + * NoteCard 의 per-note "재시도" 버튼 path. failed 가 아닌 status 면 no-op. + */ + retryOneFailed(id: string, now: string): { ok: boolean } { + const row = this.db + .prepare(`SELECT ai_status FROM notes WHERE id=? AND deleted_at IS NULL`) + .get(id) as { ai_status: string } | undefined; + if (!row || row.ai_status !== 'failed') return { ok: false }; + const tx = this.db.transaction(() => { + this.db + .prepare(`UPDATE notes SET ai_status='pending', ai_error=NULL, updated_at=? WHERE id=?`) + .run(now, id); + this.db + .prepare(`INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)`) + .run(id, now); + }); + tx(); + return { ok: true }; + } + + /** + * v0.3.9 — pending 노트의 AI 처리 cancel. ai_status='disabled' 로 전환 + pending_jobs 삭제. + * raw_text 는 보존. 사용자가 무한 pending (Ollama 끊김 등) 에서 빠져나오는 path. + * pending 외 status 면 no-op. + */ + cancelPending(id: string, now: string): { ok: boolean } { + const row = this.db + .prepare(`SELECT ai_status FROM notes WHERE id=? AND deleted_at IS NULL`) + .get(id) as { ai_status: string } | undefined; + if (!row || row.ai_status !== 'pending') return { ok: false }; + const tx = this.db.transaction(() => { + this.db + .prepare(`UPDATE notes SET ai_status='disabled', ai_error=NULL, updated_at=? WHERE id=?`) + .run(now, id); + this.db.prepare(`DELETE FROM pending_jobs WHERE note_id=?`).run(id); + }); + tx(); + return { ok: true }; + } + /** * v0.2.9 Cut B Task 16 — 모든 ai_status='disabled' 노트를 'pending' 으로 reset 하고 * pending_jobs 재투입. 사용자가 settings.ai_enabled OFF→ON 전환 후 "지금 모두 처리" diff --git a/src/main/repository/ftsHelpers.ts b/src/main/repository/ftsHelpers.ts index f81ac5d..c747b3c 100644 --- a/src/main/repository/ftsHelpers.ts +++ b/src/main/repository/ftsHelpers.ts @@ -4,11 +4,14 @@ import { KST_OFFSET_MS } from '../../shared/util/kstDate.js'; -const FTS5_SPECIAL_CHARS_RE = /["*():]/g; +// FTS5 special chars: " * ( ) : 외에도 - (NOT 연산자), ^ (column prefix), ` (escape), +// AND/OR/NOT keyword 도 query parser 가 special 처리. v0.3.9 — backtick/dash/caret 추가. +// 한국어 사용자가 의도 없이 입력할 가능성 가장 높은 punctuation 까지 sanitize. +const FTS5_SPECIAL_CHARS_RE = /["*():`^\-]/g; const WS_COLLAPSE_RE = /\s+/g; /** - * FTS5 MATCH 쿼리에 안전한 형태로 변환. " * ( ) : 제거 + 공백 정리. + * FTS5 MATCH 쿼리에 안전한 형태로 변환. special chars 공백 치환 + 공백 정리. * 다중 토큰은 그대로 두어 FTS5 implicit AND 활용. */ export function sanitizeFtsQuery(input: string): string { diff --git a/src/main/services/CaptureService.ts b/src/main/services/CaptureService.ts index 0ed33d7..ceca28f 100644 --- a/src/main/services/CaptureService.ts +++ b/src/main/services/CaptureService.ts @@ -207,6 +207,24 @@ export class CaptureService { return { count: ids.length }; } + /** + * v0.3.9 — 단일 failed 노트 재시도. NoteCard 의 per-note "재시도" 버튼 path. + * repo.retryOneFailed 후 worker.enqueue 재투입. + */ + async retryOneFailed(id: string): Promise<{ ok: boolean }> { + const r = this.repo.retryOneFailed(id, new Date().toISOString()); + if (r.ok) await this.deps.enqueue(id); + return r; + } + + /** + * v0.3.9 — pending 노트 cancel. ai_status='disabled' + pending_jobs 삭제. + * 사용자가 무한 pending (Ollama 끊김 등) 에서 빠져나오는 path. + */ + cancelPending(id: string): { ok: boolean } { + return this.repo.cancelPending(id, new Date().toISOString()); + } + /** v0.2.3 #6 — 회상 후보 1건 fetch. */ async listRecallCandidate(): Promise { return this.repo.findRecallCandidate(); diff --git a/src/preload/index.ts b/src/preload/index.ts index ce7be91..81a46a1 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -40,6 +40,8 @@ const api: InklingApi = { }, retryAllFailed: () => ipcRenderer.invoke('inbox:retryAllFailed'), getFailedCount: () => ipcRenderer.invoke('inbox:failedCount'), + retryOneFailed: (id: string) => ipcRenderer.invoke('inbox:retry-one-failed', id), + cancelPending: (id: string) => ipcRenderer.invoke('inbox:cancel-pending', id), listRecallCandidate: () => ipcRenderer.invoke('inbox:listRecallCandidate'), markRecallOpened: (id: string) => ipcRenderer.invoke('inbox:markRecallOpened', id), dismissRecall: (id: string) => ipcRenderer.invoke('inbox:dismissRecall', id), diff --git a/src/renderer/inbox/components/NoteCard.tsx b/src/renderer/inbox/components/NoteCard.tsx index ef6280b..d02e4fc 100644 --- a/src/renderer/inbox/components/NoteCard.tsx +++ b/src/renderer/inbox/components/NoteCard.tsx @@ -215,13 +215,44 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore )} {local.aiStatus === 'pending' && ( -
- Inkling이 정리하는 중… +
+
+ Inkling이 정리하는 중… +
+ {/* v0.3.9 — pending cancel UI. Ollama 끊김 / 무한 pending 시 사용자 unblock path. */} +
)} {local.aiStatus === 'failed' && ( -
- 정리 보류 — 원문은 안전합니다 +
+
+ 정리 보류 — 원문은 안전합니다 +
+ {/* v0.3.9 — per-note 재시도 UI. FailedBanner 의 일괄 재시도와 별개. */} +
)} {/* v0.2.9 Cut B Task 13 — ai_status='disabled': raw_text 첫 줄 fallback title. diff --git a/src/shared/types.ts b/src/shared/types.ts index 848b14a..9d1df4f 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -149,6 +149,9 @@ export interface InboxApi { onOllamaStatus(cb: (status: { ok: boolean; reason?: string }) => void): () => void; retryAllFailed(): Promise<{ count: number }>; getFailedCount(): Promise; + // v0.3.9 — per-note retry/cancel. failed/pending 노트의 사용자 unblock path. + retryOneFailed(id: string): Promise<{ ok: boolean }>; + cancelPending(id: string): Promise<{ ok: boolean }>; listRecallCandidate(): Promise; markRecallOpened(id: string): Promise<{ note: Note }>; dismissRecall(id: string): Promise<{ note: Note }>; diff --git a/tests/unit/NoteRepository.test.ts b/tests/unit/NoteRepository.test.ts index 2542e68..46df306 100644 --- a/tests/unit/NoteRepository.test.ts +++ b/tests/unit/NoteRepository.test.ts @@ -770,6 +770,41 @@ describe('NoteRepository — failed retry helpers', () => { expect(jobs[0]!.nextRunAt).toBe('2026-04-30T00:00:00.000Z'); }); + it('v0.3.9 — retryOneFailed: failed → pending + pending_jobs INSERT', () => { + const a = makeFailed('a'); + const b = makeFailed('b'); + const r = repo.retryOneFailed(a, '2026-05-01T12:00:00.000Z'); + expect(r).toEqual({ ok: true }); + expect(repo.findById(a)!.aiStatus).toBe('pending'); + expect(repo.findById(a)!.aiError).toBeNull(); + expect(repo.findById(b)!.aiStatus).toBe('failed'); // 다른 노트 영향 없음 + const jobs = repo.getAllPendingJobs(); + expect(jobs.find((j) => j.noteId === a)).toBeDefined(); + }); + + it('v0.3.9 — retryOneFailed: non-failed status 면 no-op', () => { + const { id } = repo.create({ rawText: 'pending note' }); + const r = repo.retryOneFailed(id, '2026-05-01T12:00:00.000Z'); + expect(r).toEqual({ ok: false }); + }); + + it('v0.3.9 — cancelPending: pending → disabled + pending_jobs DELETE', () => { + const { id } = repo.create({ rawText: 'x' }); // ai_status=pending + expect(repo.findById(id)!.aiStatus).toBe('pending'); + const r = repo.cancelPending(id, '2026-05-01T12:00:00.000Z'); + expect(r).toEqual({ ok: true }); + expect(repo.findById(id)!.aiStatus).toBe('disabled'); + const jobs = repo.getAllPendingJobs().filter((j) => j.noteId === id); + expect(jobs).toHaveLength(0); + }); + + it('v0.3.9 — cancelPending: non-pending status 면 no-op', () => { + const id = makeFailed('a'); + const r = repo.cancelPending(id, '2026-05-01T12:00:00.000Z'); + expect(r).toEqual({ ok: false }); + expect(repo.findById(id)!.aiStatus).toBe('failed'); // 변경 없음 + }); + it('setNextRunAt — attempts 변경 없이 next_run_at + last_error 갱신', () => { const { id } = repo.create({ rawText: 'x' }); repo.incrementJobAttempt(id, '2026-05-01T11:00:00.000Z', 'first error'); diff --git a/tests/unit/ftsHelpers.test.ts b/tests/unit/ftsHelpers.test.ts index daf1f9b..487e82a 100644 --- a/tests/unit/ftsHelpers.test.ts +++ b/tests/unit/ftsHelpers.test.ts @@ -15,6 +15,12 @@ describe('sanitizeFtsQuery', () => { it('returns empty string for whitespace-only', () => { expect(sanitizeFtsQuery(' ')).toBe(''); }); + it('v0.3.9 — dash/caret/backtick 추가 sanitize', () => { + expect(sanitizeFtsQuery('key-value')).toBe('key value'); + expect(sanitizeFtsQuery('^prefix')).toBe('prefix'); + expect(sanitizeFtsQuery('back`tick')).toBe('back tick'); + expect(sanitizeFtsQuery('-NOT')).toBe('NOT'); + }); }); describe('computeCutoff', () => {