Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f676c1638e | ||
|
|
2e69f598bc |
48
CHANGELOG.md
48
CHANGELOG.md
@@ -3,6 +3,54 @@
|
||||
본 파일은 Inkling 의 버전별 사용자 영향 변경 사항을 기록한다.
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/) 를 느슨하게 따른다.
|
||||
|
||||
## [0.3.10] — 2026-05-12
|
||||
|
||||
macOS fullscreen 환경에서 QuickCapture 핫키 (Cmd+Shift+J) 가 작동하지만 강제로 홈 데스크탑으로 Space 전환 후 표시되던 버그 fix.
|
||||
|
||||
### 수정
|
||||
|
||||
- **macOS fullscreen Space 위에 QuickCapture 표시 (P1).** 기본 BrowserWindow 는 첫 생성된 Space (홈 데스크탑) 에만 표시 → 사용자가 다른 앱 fullscreen 중에 핫키 누르면 macOS 가 강제로 Space 전환 → 사용자 흐름 단절. `quickCaptureWindow.ts` 에 darwin 분기 추가:
|
||||
- `type: 'panel'` — fullscreen Space 위에 floating 가능한 macOS native panel
|
||||
- `setAlwaysOnTop(true, 'screen-saver')` — fullscreen app 위에 띄울 수 있는 가장 높은 level
|
||||
- `setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true })` — 현재 Space (fullscreen 포함) 에 함께 표시, Space 전환 차단
|
||||
|
||||
Windows / Linux 동작은 변경 없음 (darwin 분기만).
|
||||
|
||||
### 게이트
|
||||
|
||||
- 단위 750 PASS (변경 없음 — main window 코드는 단위 테스트 대상 아님)
|
||||
- typecheck 0 errors (src)
|
||||
- 신규 npm dependency 0
|
||||
- macOS 사용자 수동 검증 완료
|
||||
|
||||
### 업그레이드
|
||||
|
||||
v0.3.9 인스톨러 위에 v0.3.10 인스톨러를 같은 위치에 실행하면 in-place 업그레이드. 데이터/마이그레이션 변경 없음.
|
||||
|
||||
## [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.
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "inkling",
|
||||
"version": "0.3.8",
|
||||
"version": "0.3.10",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "inkling",
|
||||
"version": "0.3.8",
|
||||
"version": "0.3.10",
|
||||
"dependencies": {
|
||||
"better-sqlite3": "12.9.0",
|
||||
"electron-log": "5.2.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "inkling",
|
||||
"version": "0.3.8",
|
||||
"version": "0.3.10",
|
||||
"private": true,
|
||||
"description": "Inkling — local-first 한 줄 보관 도구",
|
||||
"author": "altair823 <dlsrks0734@gmail.com>",
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 전환 후 "지금 모두 처리"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<Note | null> {
|
||||
return this.repo.findRecallCandidate();
|
||||
|
||||
@@ -16,16 +16,29 @@ export function createQuickCaptureWindow(): BrowserWindowType {
|
||||
const x = Math.round((primary.workArea.width - W) / 2 + primary.workArea.x);
|
||||
const y = Math.round((primary.workArea.height - H) / 3 + primary.workArea.y);
|
||||
|
||||
// v0.3.10 — macOS fullscreen Space 위에 quick capture 띄우기.
|
||||
// 기본 BrowserWindow 는 첫 생성된 Space (홈 데스크탑) 에만 표시되므로
|
||||
// 사용자가 다른 앱 fullscreen 중일 때 macOS 가 강제 Space 전환 → 사용자 경험 깨짐.
|
||||
// 'panel' 타입 + 'screen-saver' level + visibleOnFullScreen 조합으로 현재 Space 위에 overlay.
|
||||
const isMac = process.platform === 'darwin';
|
||||
win = new BrowserWindow({
|
||||
width: W, height: H, x, y,
|
||||
frame: false, show: false, alwaysOnTop: true,
|
||||
skipTaskbar: true, resizable: false,
|
||||
...(isMac ? { type: 'panel' as const } : {}),
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
contextIsolation: true, nodeIntegration: false, sandbox: false
|
||||
}
|
||||
});
|
||||
|
||||
if (isMac) {
|
||||
// 'screen-saver' level — fullscreen app 위에 띄울 수 있는 가장 높은 level.
|
||||
// visibleOnFullScreen: true — 현재 fullscreen Space 에 함께 표시 (Space 전환 안 함).
|
||||
win.setAlwaysOnTop(true, 'screen-saver');
|
||||
win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
|
||||
}
|
||||
|
||||
if (process.env.ELECTRON_RENDERER_URL) {
|
||||
win.loadURL(`${process.env.ELECTRON_RENDERER_URL}/quickcapture/index.html`);
|
||||
} else {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -215,13 +215,44 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
|
||||
)}
|
||||
|
||||
{local.aiStatus === 'pending' && (
|
||||
<div style={{ fontSize: 16, fontWeight: 600, color: '#666', marginTop: 4 }}>
|
||||
Inkling이 정리하는 중…
|
||||
<div style={{ marginTop: 4, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 600, color: '#666' }}>
|
||||
Inkling이 정리하는 중…
|
||||
</div>
|
||||
{/* v0.3.9 — pending cancel UI. Ollama 끊김 / 무한 pending 시 사용자 unblock path. */}
|
||||
<button
|
||||
onClick={async () => {
|
||||
await inboxApi.cancelPending(local.id);
|
||||
// push 기반 — onNoteUpdated 가 store 자동 갱신.
|
||||
}}
|
||||
style={{
|
||||
background: 'none', border: '1px solid #ccc', color: '#666',
|
||||
cursor: 'pointer', fontSize: 11, padding: '2px 8px', borderRadius: 4
|
||||
}}
|
||||
title="AI 자동 처리를 건너뛰고 원문만 보관"
|
||||
>
|
||||
건너뛰기
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{local.aiStatus === 'failed' && (
|
||||
<div title={local.aiError ?? ''} style={{ fontSize: 16, fontWeight: 600, color: '#a55', marginTop: 4 }}>
|
||||
정리 보류 — 원문은 안전합니다
|
||||
<div style={{ marginTop: 4, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div title={local.aiError ?? ''} style={{ fontSize: 16, fontWeight: 600, color: '#a55' }}>
|
||||
정리 보류 — 원문은 안전합니다
|
||||
</div>
|
||||
{/* v0.3.9 — per-note 재시도 UI. FailedBanner 의 일괄 재시도와 별개. */}
|
||||
<button
|
||||
onClick={async () => {
|
||||
await inboxApi.retryOneFailed(local.id);
|
||||
}}
|
||||
style={{
|
||||
background: 'none', border: '1px solid #a55', color: '#a55',
|
||||
cursor: 'pointer', fontSize: 11, padding: '2px 8px', borderRadius: 4
|
||||
}}
|
||||
title="이 노트만 AI 처리 재시도"
|
||||
>
|
||||
재시도
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* v0.2.9 Cut B Task 13 — ai_status='disabled': raw_text 첫 줄 fallback title.
|
||||
|
||||
@@ -149,6 +149,9 @@ export interface InboxApi {
|
||||
onOllamaStatus(cb: (status: { ok: boolean; reason?: string }) => void): () => void;
|
||||
retryAllFailed(): Promise<{ count: number }>;
|
||||
getFailedCount(): Promise<number>;
|
||||
// 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<Note | null>;
|
||||
markRecallOpened(id: string): Promise<{ note: Note }>;
|
||||
dismissRecall(id: string): Promise<{ note: Note }>;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user