Merge pull request 'v0.2.9 Cut B — status 4분기 + 사유 + Ollama-less (F17/F18/F23)' (#27) from worktree-v029-cut-b-status-reason-ailess into main
Reviewed-on: #27
This commit was merged in pull request #27.
This commit is contained in:
@@ -1306,9 +1306,9 @@ app.on('activate', () => {
|
||||
- Risk: Windows 사용자 흐름 변경 — 트레이 한 클릭으로 끝나던 동작이 inbox 열기 → 설정 → 항목 클릭 으로 늘어남. 단, 빈도 낮은 동작 (Ollama 설정 변경, 백업 등) 만 이동하고 자주 쓰는 캡처/보관함 은 트레이 잔류 → 체감 마찰 ↓ 예상.
|
||||
|
||||
---
|
||||
## F17. 휴지통의 의미 혼재 — 완료/보관과 버림 구분 (🌱 raw — v0.2.8 후보, 큰 design 결정)
|
||||
## F17. 휴지통의 의미 혼재 — 완료/보관과 버림 구분 (🚀 promoted → docs/superpowers/specs/2026-05-09-v029-cut-b-design.md)
|
||||
|
||||
**진행 상태:** 🌱 raw — 본인 dogfood 발견. F18 (사유 입력) 와 강하게 연관. v0.2.8 brainstorm 시 함께 triage.
|
||||
**진행 상태:** 🚀 promoted → v0.2.9 Cut B. status 4분기 (active/completed/archived/trashed) + AI 자동 분류 버튼 + 자유 텍스트 사유.
|
||||
|
||||
**발견:** 2026-05-09 v0.2.7 release 후 본인 dogfood.
|
||||
|
||||
@@ -1366,9 +1366,9 @@ app.on('activate', () => {
|
||||
|
||||
---
|
||||
|
||||
## F18. 메모 휴지통/보관 이동 시 사유 입력 (🌱 raw — v0.2.8 후보, F17 와 묶음)
|
||||
## F18. 메모 휴지통/보관 이동 시 사유 입력 (🚀 promoted → docs/superpowers/specs/2026-05-09-v029-cut-b-design.md)
|
||||
|
||||
**진행 상태:** 🌱 raw — F17 과 강한 연관. v0.2.8 brainstorm 시 함께 triage.
|
||||
**진행 상태:** 🚀 promoted → v0.2.9 Cut B. notes.move_reason 자유 텍스트 컬럼 + MoveStatusModal 사유 입력.
|
||||
|
||||
**발견:** 2026-05-09 v0.2.7 release 후 본인 dogfood.
|
||||
|
||||
@@ -1710,9 +1710,9 @@ app.on('activate', () => {
|
||||
- v0.2.8 narrow scope 에 포함 가치 (1-2일 작업).
|
||||
|
||||
---
|
||||
## F23. 로컬 LLM 활성화 옵션 (Ollama-less 모드) (🌱 raw — v0.2.8 후보, 큰 영향)
|
||||
## F23. 로컬 LLM 활성화 옵션 (Ollama-less 모드) (🚀 promoted → docs/superpowers/specs/2026-05-09-v029-cut-b-design.md)
|
||||
|
||||
**진행 상태:** 🌱 raw — Ollama 의존성 옵션화. v0.2.8 brainstorm 시 cut. F17/F19 와 연관.
|
||||
**진행 상태:** 🚀 promoted → v0.2.9 Cut B. ai_status='disabled' enum + Onboarding wizard + 설정 토글 + Banner/HealthChecker 비활성 + requeueDisabled.
|
||||
|
||||
**발견:** 2026-05-09 v0.2.7 release 후 본인 dogfood. 사용자 표현: "Ollama 를 쓰지 못하는 환경을 위해 로컬 llm 활성화 옵션을 만들고, Ollama 를 안 쓰는 경우 그냥 원문만 저장하고 보여주도록".
|
||||
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "inkling",
|
||||
"version": "0.2.7",
|
||||
"version": "0.2.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "inkling",
|
||||
"version": "0.2.7",
|
||||
"version": "0.2.8",
|
||||
"dependencies": {
|
||||
"better-sqlite3": "12.9.0",
|
||||
"electron-log": "5.2.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "inkling",
|
||||
"version": "0.2.8",
|
||||
"version": "0.2.9",
|
||||
"private": true,
|
||||
"description": "Inkling — local-first 한 줄 보관 도구",
|
||||
"author": "altair823 <dlsrks0734@gmail.com>",
|
||||
|
||||
@@ -16,4 +16,10 @@ export interface InferenceProvider {
|
||||
healthCheck(): Promise<HealthResult>;
|
||||
/** v0.2.3.1 — 외부에서 in-flight generate 강제 중단. ProviderHolder.replace 시 사용. */
|
||||
abort?: () => void;
|
||||
/**
|
||||
* v0.2.9 Cut B Task 9 — raw JSON 응답 호출. classifyStatus 같은 자체 prompt 호출용.
|
||||
* Ollama `/api/generate` 의 raw `response` 문자열을 그대로 반환한다 (보통 JSON 문자열).
|
||||
* 미구현 provider 는 undefined; classifyStatus 는 그 경우 안전 fallback 으로 동작.
|
||||
*/
|
||||
generateRaw?: (prompt: string) => Promise<string>;
|
||||
}
|
||||
|
||||
@@ -66,6 +66,39 @@ export class LocalOllamaProvider implements InferenceProvider {
|
||||
this.abortController?.abort();
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.9 Cut B Task 9 — raw JSON 호출 (classifyStatus 등 자체 prompt 용).
|
||||
* `format: 'json'` + `stream: false` 로 Ollama 가 valid JSON 문자열을 반환하도록 강제.
|
||||
* abortController / timeout 은 generate() 와 동일 패턴.
|
||||
*/
|
||||
async generateRaw(prompt: string): Promise<string> {
|
||||
this.abortController = new AbortController();
|
||||
const timer = setTimeout(() => this.abortController?.abort(), this.timeoutMs);
|
||||
try {
|
||||
const res = await request(`${this.endpoint}/api/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: this.model,
|
||||
prompt,
|
||||
format: 'json',
|
||||
stream: false,
|
||||
options: { temperature: this.temperature, num_predict: this.numPredict }
|
||||
}),
|
||||
signal: this.abortController.signal
|
||||
});
|
||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||
throw new Error(`ollama http ${res.statusCode}`);
|
||||
}
|
||||
const body = (await res.body.json()) as { response?: string };
|
||||
if (!body.response) throw new Error('missing response field');
|
||||
return body.response;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
this.abortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<HealthResult> {
|
||||
try {
|
||||
const res = await request(`${this.endpoint}/api/tags`, { method: 'GET' });
|
||||
|
||||
83
src/main/ai/classifyStatus.ts
Normal file
83
src/main/ai/classifyStatus.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import type { InferenceProvider } from './InferenceProvider.js';
|
||||
import type { NoteStatus } from '@shared/types';
|
||||
|
||||
export interface ClassifyStatusInput {
|
||||
provider: InferenceProvider;
|
||||
rawText: string;
|
||||
summary: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface ClassifyStatusOutput {
|
||||
recommended: NoteStatus;
|
||||
rationale: string;
|
||||
}
|
||||
|
||||
const VALID: readonly NoteStatus[] = ['completed', 'archived', 'trashed'];
|
||||
|
||||
const PROMPT_TEMPLATE = `다음 메모를 분류하세요.
|
||||
가능한 status:
|
||||
- completed: 작업이 끝났고 더 이상 행동 불필요
|
||||
- archived: 장기 보관 — 회수 가능, 지금은 보지 않음
|
||||
- trashed: 불필요, 의미 없는 메모
|
||||
|
||||
JSON 출력만 하세요: { "recommended": "completed|archived|trashed", "rationale": "<한 문장 한국어>" }
|
||||
|
||||
메모 본문:
|
||||
{{rawText}}
|
||||
|
||||
메모 요약:
|
||||
{{summary}}
|
||||
|
||||
사용자 이동 사유:
|
||||
{{reason}}`;
|
||||
|
||||
const FALLBACK: ClassifyStatusOutput = {
|
||||
recommended: 'archived',
|
||||
rationale: '판단 실패 — 안전하게 보관 추천'
|
||||
};
|
||||
|
||||
/**
|
||||
* v0.2.9 Cut B Task 9 — AI 자동 분류 (status 추천).
|
||||
*
|
||||
* provider.generateRaw 가 있으면 raw JSON 응답 사용, 없으면 generate() 재사용 시도
|
||||
* (그 경우 응답 형태 불일치로 보통 fallback). 에러/parse 실패 시 'archived' 안전 default
|
||||
* (사용자 데이터 보존 우선).
|
||||
*/
|
||||
export async function classifyStatus(
|
||||
input: ClassifyStatusInput
|
||||
): Promise<ClassifyStatusOutput> {
|
||||
const prompt = PROMPT_TEMPLATE
|
||||
.replace('{{rawText}}', input.rawText.length > 0 ? input.rawText : '(빈 메모)')
|
||||
.replace('{{summary}}', input.summary.length > 0 ? input.summary : '(요약 없음)')
|
||||
.replace('{{reason}}', input.reason.length > 0 ? input.reason : '(사유 없음)');
|
||||
|
||||
let rawJson: string;
|
||||
try {
|
||||
if (typeof input.provider.generateRaw === 'function') {
|
||||
rawJson = await input.provider.generateRaw(prompt);
|
||||
} else {
|
||||
// 호환 경로 — provider.generate 가 raw 응답을 노출하지 않으므로 안전 fallback.
|
||||
return FALLBACK;
|
||||
}
|
||||
} catch {
|
||||
return FALLBACK;
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(rawJson);
|
||||
} catch {
|
||||
return FALLBACK;
|
||||
}
|
||||
|
||||
if (typeof parsed !== 'object' || parsed === null) return FALLBACK;
|
||||
const obj = parsed as { recommended?: unknown; rationale?: unknown };
|
||||
if (typeof obj.recommended !== 'string' || !VALID.includes(obj.recommended as NoteStatus)) {
|
||||
return FALLBACK;
|
||||
}
|
||||
return {
|
||||
recommended: obj.recommended as NoteStatus,
|
||||
rationale: typeof obj.rationale === 'string' ? obj.rationale : ''
|
||||
};
|
||||
}
|
||||
@@ -2,8 +2,10 @@ import type Database from 'better-sqlite3';
|
||||
import * as m001 from './m001_initial.js';
|
||||
import * as m002 from './m002_due_date.js';
|
||||
import * as m003 from './m003_soft_delete.js';
|
||||
import * as m004 from './m004_status.js';
|
||||
import * as m005 from './m005_ai_disabled.js';
|
||||
|
||||
const migrations = [m001, m002, m003];
|
||||
const migrations = [m001, m002, m003, m004, m005];
|
||||
|
||||
export function latestVersion(): number {
|
||||
return migrations[migrations.length - 1]!.version;
|
||||
|
||||
18
src/main/db/migrations/m004_status.ts
Normal file
18
src/main/db/migrations/m004_status.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// v4: status 4분기 (active/completed/archived/trashed) + 사유 + status_changed_at.
|
||||
// 기존 deleted_at != NULL 노트는 status='trashed' 로 migrate. deleted_at 컬럼은
|
||||
// backward compat 위해 잔류 (status='trashed' 와 동기화). 향후 cut 에서 제거 가능.
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
export const version = 4;
|
||||
|
||||
export function up(db: Database.Database): void {
|
||||
db.exec(`
|
||||
ALTER TABLE notes ADD COLUMN status TEXT NOT NULL DEFAULT 'active';
|
||||
ALTER TABLE notes ADD COLUMN status_changed_at TEXT;
|
||||
ALTER TABLE notes ADD COLUMN move_reason TEXT;
|
||||
`);
|
||||
db.prepare(
|
||||
`UPDATE notes SET status='trashed', status_changed_at=deleted_at
|
||||
WHERE deleted_at IS NOT NULL`
|
||||
).run();
|
||||
}
|
||||
65
src/main/db/migrations/m005_ai_disabled.ts
Normal file
65
src/main/db/migrations/m005_ai_disabled.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
// v5: ai_status enum 에 'disabled' 추가 (v0.2.9 Cut B). settings.ai_enabled=false 일 때
|
||||
// CaptureService 가 새 노트를 ai_status='disabled' 로 insert + pending_jobs enqueue skip.
|
||||
//
|
||||
// SQLite 는 ALTER COLUMN ... CHECK 미지원 → table recreate 패턴.
|
||||
// 외래키 (note_tags / media / pending_jobs) 는 notes.id 를 참조 + ON DELETE CASCADE 라
|
||||
// FK off + DROP/RENAME 시 데이터 보존 위해 새 테이블 생성 → INSERT SELECT → DROP old → RENAME new.
|
||||
// PRAGMA foreign_keys=OFF 안에서 single transaction (runMigrations 가 transaction 으로 감쌈).
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
export const version = 5;
|
||||
|
||||
export function up(db: Database.Database): void {
|
||||
// 기존 인덱스/CHECK 제약을 그대로 유지하되 ai_status 만 'disabled' 추가.
|
||||
db.exec(`
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE notes_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
raw_text TEXT NOT NULL,
|
||||
ai_title TEXT,
|
||||
ai_summary TEXT,
|
||||
ai_status TEXT NOT NULL
|
||||
CHECK (ai_status IN ('pending','done','failed','disabled')),
|
||||
ai_error TEXT,
|
||||
ai_provider TEXT,
|
||||
ai_generated_at TEXT,
|
||||
title_edited_by_user INTEGER NOT NULL DEFAULT 0
|
||||
CHECK (title_edited_by_user IN (0,1)),
|
||||
summary_edited_by_user INTEGER NOT NULL DEFAULT 0
|
||||
CHECK (summary_edited_by_user IN (0,1)),
|
||||
user_intent TEXT,
|
||||
intent_prompted_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
due_date TEXT,
|
||||
due_date_edited_by_user INTEGER NOT NULL DEFAULT 0
|
||||
CHECK (due_date_edited_by_user IN (0,1)),
|
||||
deleted_at TEXT,
|
||||
last_recalled_at TEXT,
|
||||
recall_dismissed_at TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
status_changed_at TEXT,
|
||||
move_reason TEXT
|
||||
);
|
||||
INSERT INTO notes_new (
|
||||
id, raw_text, ai_title, ai_summary, ai_status, ai_error, ai_provider, ai_generated_at,
|
||||
title_edited_by_user, summary_edited_by_user, user_intent, intent_prompted_at,
|
||||
created_at, updated_at, due_date, due_date_edited_by_user,
|
||||
deleted_at, last_recalled_at, recall_dismissed_at,
|
||||
status, status_changed_at, move_reason
|
||||
)
|
||||
SELECT
|
||||
id, raw_text, ai_title, ai_summary, ai_status, ai_error, ai_provider, ai_generated_at,
|
||||
title_edited_by_user, summary_edited_by_user, user_intent, intent_prompted_at,
|
||||
created_at, updated_at, due_date, due_date_edited_by_user,
|
||||
deleted_at, last_recalled_at, recall_dismissed_at,
|
||||
status, status_changed_at, move_reason
|
||||
FROM notes;
|
||||
DROP TABLE notes;
|
||||
ALTER TABLE notes_new RENAME TO notes;
|
||||
CREATE INDEX idx_notes_created_at ON notes(created_at DESC);
|
||||
CREATE INDEX idx_notes_ai_status ON notes(ai_status);
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_deleted_at ON notes(deleted_at);
|
||||
PRAGMA foreign_keys=ON;
|
||||
`);
|
||||
}
|
||||
@@ -122,6 +122,8 @@ app.whenReady().then(async () => {
|
||||
const provider = new LocalOllamaProvider({ endpoint: resolvedEndpoint, model: resolvedModel });
|
||||
const providerHolder = new ProviderHolder(provider);
|
||||
const health = new HealthChecker(providerHolder, {
|
||||
// v0.2.9 Cut B Task 14 — AI 비활성 시 health polling skip (Ollama 미설치 환경 무영향).
|
||||
isAiEnabled: () => settingsSvc.isAiEnabled(),
|
||||
onUpdate: (status) => {
|
||||
logger.info('ai.health', { ...status } as Record<string, unknown>);
|
||||
pushOllamaStatus(getInboxWindow, status);
|
||||
@@ -159,14 +161,17 @@ app.whenReady().then(async () => {
|
||||
const capture = new CaptureService(repo, store, {
|
||||
enqueue: (id) => worker.enqueue(id),
|
||||
celebrate: (id) => notify.celebrate(id),
|
||||
telemetry
|
||||
telemetry,
|
||||
settings: settingsSvc
|
||||
});
|
||||
|
||||
registerCaptureApi(capture, getQuickCaptureWindow);
|
||||
registerInboxApi({
|
||||
repo, continuity, capture, health, intent,
|
||||
getInboxWindow, settings: settingsSvc, providerHolder,
|
||||
paths: { profileDir: paths.profileDir }
|
||||
paths: { profileDir: paths.profileDir },
|
||||
// v0.2.9 Cut B Task 16 — disabled 메모 일괄 재투입 시 in-memory queue 갱신.
|
||||
enqueue: (id) => worker.enqueue(id)
|
||||
});
|
||||
// registerSettingsApi 는 backup / exportSvc / importSvc / syncSvc / telemetry 가
|
||||
// 생성된 뒤에 호출 (Task 10) — 아래 BackupService/ExportService/... 초기화 직후로 이동.
|
||||
@@ -201,7 +206,7 @@ app.whenReady().then(async () => {
|
||||
// v0.2.7 Task 10 — 설정 페이지 IPC (autostart + backup/export/import/sync/telemetry).
|
||||
// backup / exportSvc / importSvc / syncSvc / telemetry 가 모두 준비된 뒤 등록.
|
||||
registerSettingsApi({
|
||||
backup, exportSvc, importSvc, syncSvc, telemetry, getInboxWindow
|
||||
backup, exportSvc, importSvc, syncSvc, telemetry, settings: settingsSvc, getInboxWindow
|
||||
});
|
||||
|
||||
let backupOnQuitDone = false;
|
||||
|
||||
@@ -7,9 +7,10 @@ import type { ContinuityService } from '../services/ContinuityService.js';
|
||||
import type { CaptureService } from '../services/CaptureService.js';
|
||||
import type { HealthChecker } from '../services/HealthChecker.js';
|
||||
import type { IntentService } from '../services/IntentService.js';
|
||||
import type { Note } from '@shared/types';
|
||||
import type { Note, NoteStatus } from '@shared/types';
|
||||
import type { HealthResult } from '../ai/InferenceProvider.js';
|
||||
import { LocalOllamaProvider } from '../ai/LocalOllamaProvider.js';
|
||||
import { classifyStatus } from '../ai/classifyStatus.js';
|
||||
import type { SettingsService } from '../services/SettingsService.js';
|
||||
import type { ProviderHolder } from '../ai/ProviderHolder.js';
|
||||
|
||||
@@ -24,6 +25,10 @@ export interface InboxIpcDeps {
|
||||
providerHolder: ProviderHolder;
|
||||
// v0.2.8 Cut A — `inbox:open-media` 의 path traversal 검사 baseline.
|
||||
paths: { profileDir: string };
|
||||
// v0.2.9 Cut B Task 16 — disabled 메모 일괄 처리 시 in-memory worker queue 갱신.
|
||||
// 미주입 시 fire-and-forget skip (다음 launch 의 loadFromDb 가 처리). 본 hook 은
|
||||
// AiWorker 인스턴스 직접 주입을 피해 IPC 모듈이 worker import 를 갖지 않도록 분리.
|
||||
enqueue?: (noteId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function registerInboxApi(deps: InboxIpcDeps): void {
|
||||
@@ -172,6 +177,82 @@ export function registerInboxApi(deps: InboxIpcDeps): void {
|
||||
return { ok: true as const };
|
||||
});
|
||||
|
||||
// v0.2.9 Cut B Task 4 — status 별 노트 목록.
|
||||
ipcMain.handle(
|
||||
'inbox:list-by-status',
|
||||
(_e, status: NoteStatus, opts: { limit?: number } = {}) => {
|
||||
const VALID: readonly NoteStatus[] = ['active', 'completed', 'archived', 'trashed'];
|
||||
if (!VALID.includes(status)) return [] as Note[];
|
||||
return deps.repo.listByStatus(status, opts);
|
||||
}
|
||||
);
|
||||
|
||||
// v0.2.9 Cut B Task 4 — 4 status counts (헤더 4탭 badge).
|
||||
ipcMain.handle('inbox:counts-by-status', () => ({
|
||||
active: deps.repo.countByStatus('active'),
|
||||
completed: deps.repo.countByStatus('completed'),
|
||||
archived: deps.repo.countByStatus('archived'),
|
||||
trashed: deps.repo.countByStatus('trashed')
|
||||
}));
|
||||
|
||||
// v0.2.9 Cut B Task 8 — status 4분기 직접 전이 (사유 포함).
|
||||
// Modal 의 "완료/보관/휴지통" 버튼 path. backward compat 동기화는
|
||||
// NoteRepository.setStatus 내부에서 처리 (deleted_at sync).
|
||||
ipcMain.handle(
|
||||
'inbox:set-status',
|
||||
async (_e, id: string, status: NoteStatus, reason: string | null) => {
|
||||
const VALID: readonly NoteStatus[] = ['active', 'completed', 'archived', 'trashed'];
|
||||
if (!VALID.includes(status)) {
|
||||
return { ok: false as const, reason: 'invalid status' as const };
|
||||
}
|
||||
deps.repo.setStatus(id, status, reason);
|
||||
return { ok: true as const };
|
||||
}
|
||||
);
|
||||
|
||||
// v0.2.9 Cut B Task 9 — AI 자동 분류 (status 추천).
|
||||
// Ollama provider.generateRaw 호출 + JSON 응답 파싱. 에러/실패 시 archived fallback
|
||||
// (사용자 데이터 보존 우선). 자세한 prompt + parse 로직은 src/main/ai/classifyStatus.ts.
|
||||
ipcMain.handle('ai:classify-status', async (_e, id: string, reason: string) => {
|
||||
const note = deps.repo.findById(id);
|
||||
if (note === null) {
|
||||
return {
|
||||
recommended: 'archived' as const,
|
||||
rationale: '메모를 찾을 수 없음 — 안전하게 보관 추천'
|
||||
};
|
||||
}
|
||||
const provider = deps.providerHolder.get();
|
||||
return classifyStatus({
|
||||
provider,
|
||||
rawText: note.rawText,
|
||||
summary: note.aiSummary ?? '',
|
||||
reason: typeof reason === 'string' ? reason : ''
|
||||
});
|
||||
});
|
||||
|
||||
// v0.2.9 Cut B Task 16 — disabled 메모 (ai_enabled OFF 시기 캡처) 일괄 재투입.
|
||||
// OFF→ON 전환 후 사용자가 "지금 모두 처리" 버튼 클릭 path. repo.requeueDisabled 가
|
||||
// ai_status='pending' + pending_jobs row 보장, worker.enqueue 가 in-memory queue 갱신.
|
||||
ipcMain.handle('inbox:enqueue-disabled', async () => {
|
||||
// requeue 전 대상 id 수집 — UPDATE 가 status 바꾸므로 select 후 update 필요 없이
|
||||
// requeueDisabled 가 처리한 다음 pending_jobs 에서 다시 가져와 enqueue.
|
||||
const targets = deps.repo.getAllPendingJobs().map((j) => j.noteId);
|
||||
const before = new Set(targets);
|
||||
const count = deps.repo.requeueDisabled();
|
||||
if (count > 0 && deps.enqueue) {
|
||||
const after = deps.repo.getAllPendingJobs();
|
||||
// requeue 직후 새로 들어온 pending_jobs row 만 enqueue (기존 row 는 이미 in-memory queue 에).
|
||||
for (const j of after) {
|
||||
if (!before.has(j.noteId)) {
|
||||
await deps.enqueue(j.noteId);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { count };
|
||||
});
|
||||
|
||||
ipcMain.handle('inbox:get-disabled-count', () => deps.repo.countByAiStatus('disabled'));
|
||||
|
||||
ipcMain.handle('inbox:saveOllamaSettings', async (_e, value: { endpoint: string; model: string }) => {
|
||||
// 검증: 새 인스턴스로 healthCheck
|
||||
const trial = new LocalOllamaProvider({ endpoint: value.endpoint, model: value.model });
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { ExportService } from '../services/ExportService.js';
|
||||
import type { ImportService } from '../services/ImportService.js';
|
||||
import type { SyncService } from '../services/SyncService.js';
|
||||
import type { TelemetryService } from '../services/TelemetryService.js';
|
||||
import type { SettingsService } from '../services/SettingsService.js';
|
||||
import { collectAutostartState } from '../services/AutostartDiagnostic.js';
|
||||
import { getInboxWindow as getInboxWindowSingleton } from '../windows/inboxWindow.js';
|
||||
|
||||
@@ -33,6 +34,7 @@ export interface SettingsIpcDeps {
|
||||
importSvc: ImportService;
|
||||
syncSvc: SyncService;
|
||||
telemetry: TelemetryService;
|
||||
settings: SettingsService;
|
||||
getInboxWindow: () => BrowserWindow | null;
|
||||
}
|
||||
|
||||
@@ -88,7 +90,21 @@ export function registerSettingsApi(deps?: SettingsIpcDeps): void {
|
||||
});
|
||||
|
||||
if (!deps) return;
|
||||
const { backup, exportSvc, importSvc, syncSvc, telemetry, getInboxWindow } = deps;
|
||||
const { backup, exportSvc, importSvc, syncSvc, telemetry, settings, getInboxWindow } = deps;
|
||||
|
||||
// v0.2.9 Cut B Task 12 — settings read + AI/onboarding 토글.
|
||||
// 첫 launch 시 OnboardingWizard 분기 (App.tsx) 와 SettingsPage 의 ai_enabled 토글 통합.
|
||||
ipcMain.handle('settings:get', async () => settings.getAll());
|
||||
|
||||
ipcMain.handle('settings:set-ai-enabled', async (_e, enabled: boolean) => {
|
||||
await settings.setAiEnabled(enabled);
|
||||
return { ok: true as const };
|
||||
});
|
||||
|
||||
ipcMain.handle('settings:set-onboarding-completed', async (_e, completed: boolean) => {
|
||||
await settings.setOnboardingCompleted(completed);
|
||||
return { ok: true as const };
|
||||
});
|
||||
|
||||
ipcMain.handle('settings:run-backup', async () => {
|
||||
try {
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
import { v7 as uuidv7, v4 as uuidv4 } from 'uuid';
|
||||
import type { Note, NoteMedia, NoteTag } from '@shared/types';
|
||||
import type { AiStatus, Note, NoteMedia, NoteStatus, NoteTag } from '@shared/types';
|
||||
import { kstTodayIso } from '../../shared/util/kstDate.js';
|
||||
|
||||
export interface CreateNoteInput { rawText: string; }
|
||||
export interface CreateNoteInput {
|
||||
rawText: string;
|
||||
/**
|
||||
* v0.2.9 Cut B — settings.ai_enabled=false 일 때 'disabled' 로 insert + pending_jobs skip.
|
||||
* 미지정 시 기존 'pending' default + pending_jobs enqueue (backward compat).
|
||||
*/
|
||||
aiStatus?: AiStatus;
|
||||
}
|
||||
|
||||
export interface NewMediaRow {
|
||||
noteId: string;
|
||||
@@ -48,15 +55,19 @@ export class NoteRepository {
|
||||
create(input: CreateNoteInput): { id: string } {
|
||||
const id = uuidv7();
|
||||
const now = new Date().toISOString();
|
||||
const aiStatus: AiStatus = input.aiStatus ?? 'pending';
|
||||
const tx = this.db.transaction(() => {
|
||||
this.db
|
||||
.prepare(`INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at)
|
||||
VALUES (?, ?, 'pending', ?, ?)`)
|
||||
.run(id, input.rawText, now, now);
|
||||
this.db
|
||||
.prepare(`INSERT INTO pending_jobs (note_id, attempts, next_run_at)
|
||||
VALUES (?, 0, ?)`)
|
||||
.run(id, now);
|
||||
VALUES (?, ?, ?, ?, ?)`)
|
||||
.run(id, input.rawText, aiStatus, now, now);
|
||||
// pending_jobs 는 'pending' 일 때만 생성 — 'disabled' 노트는 worker 가 처리 안 함.
|
||||
if (aiStatus === 'pending') {
|
||||
this.db
|
||||
.prepare(`INSERT INTO pending_jobs (note_id, attempts, next_run_at)
|
||||
VALUES (?, 0, ?)`)
|
||||
.run(id, now);
|
||||
}
|
||||
});
|
||||
tx();
|
||||
return { id };
|
||||
@@ -205,6 +216,50 @@ export class NoteRepository {
|
||||
return { ids };
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.9 Cut B Task 16 — 모든 ai_status='disabled' 노트를 'pending' 으로 reset 하고
|
||||
* pending_jobs 재투입. 사용자가 settings.ai_enabled OFF→ON 전환 후 "지금 모두 처리"
|
||||
* 버튼을 누른 path. 단일 transaction. 호출자가 `now` 주입 가능 (테스트성).
|
||||
*
|
||||
* INSERT OR IGNORE — race 안전 (이미 pending_jobs row 존재 시 skip).
|
||||
* 반환값 = 처리된 노트 수 (UI 가 "N건 처리됨" 토스트 등 표시용).
|
||||
*/
|
||||
requeueDisabled(now: Date = new Date()): number {
|
||||
const tx = this.db.transaction(() => {
|
||||
const ts = now.toISOString();
|
||||
const targets = this.db
|
||||
.prepare(`SELECT id FROM notes WHERE ai_status='disabled'`)
|
||||
.all() as Array<{ id: string }>;
|
||||
for (const { id } of targets) {
|
||||
this.db
|
||||
.prepare(`UPDATE notes SET ai_status='pending', updated_at=? WHERE id=?`)
|
||||
.run(ts, id);
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)`
|
||||
)
|
||||
.run(id, ts);
|
||||
}
|
||||
return targets.length;
|
||||
});
|
||||
return tx();
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.9 Cut B Task 16 — ai_status 별 row count.
|
||||
* 설정 페이지의 "원문만 저장된 메모 N건" 표기용 (status='disabled' 카운트).
|
||||
* deleted_at 필터 없음 — disabled 메모도 trash 갈 수 있는데 사용자 의도는
|
||||
* "AI 처리할 게 얼마나 남았나?" 라 trashed 까지 포함되면 안 됨. → deleted_at IS NULL 추가.
|
||||
*/
|
||||
countByAiStatus(status: AiStatus): number {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS c FROM notes WHERE ai_status=? AND deleted_at IS NULL`
|
||||
)
|
||||
.get(status) as { c: number };
|
||||
return row.c;
|
||||
}
|
||||
|
||||
/**
|
||||
* pending_jobs 의 next_run_at + last_error 만 갱신, attempts 변경 없음.
|
||||
* v0.2.3 #2 — unreachable/timeout 무한 retry 시 사용 (incrementJobAttempt 와 별도 경로).
|
||||
@@ -410,25 +465,101 @@ export class NoteRepository {
|
||||
.run(now, id);
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.9 Cut B — 노트 status 4분기 전이 (active/completed/archived/trashed).
|
||||
* status + status_changed_at + move_reason + updated_at 갱신 + deleted_at
|
||||
* backward-compat 동기화 (status='trashed' → deleted_at=ts, 그 외 → NULL).
|
||||
*
|
||||
* 단일 transaction. 호출자가 `now` 주입 가능 (테스트성).
|
||||
*/
|
||||
setStatus(
|
||||
id: string,
|
||||
status: NoteStatus,
|
||||
reason: string | null,
|
||||
now: Date = new Date()
|
||||
): void {
|
||||
const tx = this.db.transaction(() => {
|
||||
const ts = now.toISOString();
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE notes
|
||||
SET status = ?,
|
||||
move_reason = ?,
|
||||
status_changed_at = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run(status, reason, ts, ts, id);
|
||||
// backward compat: deleted_at 컬럼은 m004 이후로도 status='trashed' 와 동기화.
|
||||
if (status === 'trashed') {
|
||||
this.db.prepare(`UPDATE notes SET deleted_at = ? WHERE id = ?`).run(ts, id);
|
||||
} else {
|
||||
this.db.prepare(`UPDATE notes SET deleted_at = NULL WHERE id = ?`).run(id);
|
||||
}
|
||||
});
|
||||
tx();
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.9 Cut B Task 4 — status 별 row count. 4탭 헤더 badge 용.
|
||||
* tags/media hydrate 없음 (cheap path, listByStatus 와 별도).
|
||||
*/
|
||||
countByStatus(status: NoteStatus): number {
|
||||
const row = this.db
|
||||
.prepare(`SELECT COUNT(*) AS c FROM notes WHERE status = ?`)
|
||||
.get(status) as { c: number };
|
||||
return row.c;
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.9 Cut B — status 별 노트 목록. status_changed_at DESC (최근 전이 우선),
|
||||
* NULL 은 created_at fallback. limit cap 200 (list/listTrashed 와 동일).
|
||||
*/
|
||||
listByStatus(status: NoteStatus, opts: { limit?: number } = {}): Note[] {
|
||||
const limit = Math.max(1, Math.min(200, opts.limit ?? 200));
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM notes
|
||||
WHERE status = ?
|
||||
ORDER BY COALESCE(status_changed_at, created_at) DESC, id DESC
|
||||
LIMIT ?`
|
||||
)
|
||||
.all(status, limit) as Record<string, unknown>[];
|
||||
return rows.map((r) => this.hydrate(r));
|
||||
}
|
||||
|
||||
/**
|
||||
* 휴지통에서 active 로 복원. setStatus('active') 로 status + deleted_at 동기화 +
|
||||
* v0.2.6 #10 round 1 fix 보존 (ai_status='failed' / 'pending' 시 pending_jobs 재투입).
|
||||
*/
|
||||
restoreNote(id: string): void {
|
||||
const tx = this.db.transaction(() => {
|
||||
const before = this.db.prepare(`SELECT ai_status FROM notes WHERE id = ?`).get(id) as { ai_status: string } | undefined;
|
||||
const before = this.db
|
||||
.prepare(`SELECT ai_status FROM notes WHERE id = ?`)
|
||||
.get(id) as { ai_status: string } | undefined;
|
||||
// setStatus('active', null) — reason clear + deleted_at NULL + updated_at 갱신.
|
||||
this.setStatus(id, 'active', null);
|
||||
const now = new Date().toISOString();
|
||||
this.db.prepare(`UPDATE notes SET deleted_at = NULL, updated_at = ? WHERE id = ?`).run(now, id);
|
||||
|
||||
// v0.2.6 #10 — failed 노트 restore 시 pending 으로 reset + pending_jobs 재생성
|
||||
if (before?.ai_status === 'failed') {
|
||||
this.db.prepare(
|
||||
`UPDATE notes SET ai_status='pending', ai_error=NULL, updated_at=? WHERE id=?`
|
||||
).run(now, id);
|
||||
this.db.prepare(
|
||||
`INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)`
|
||||
).run(id, now);
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE notes SET ai_status='pending', ai_error=NULL, updated_at=? WHERE id=?`
|
||||
)
|
||||
.run(now, id);
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)`
|
||||
)
|
||||
.run(id, now);
|
||||
} else if (before?.ai_status === 'pending') {
|
||||
// pending 인 채로 trash 됐다면 pending_jobs 도 미정상 상태일 수 있음 — 재생성 (idempotent)
|
||||
this.db.prepare(
|
||||
`INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)`
|
||||
).run(id, now);
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)`
|
||||
)
|
||||
.run(id, now);
|
||||
}
|
||||
// done 노트는 재처리 안 함 (이미 결과 있음)
|
||||
});
|
||||
@@ -656,7 +787,7 @@ export class NoteRepository {
|
||||
rawText: row.raw_text as string,
|
||||
aiTitle: row.ai_title as string | null,
|
||||
aiSummary: row.ai_summary as string | null,
|
||||
aiStatus: row.ai_status as 'pending' | 'done' | 'failed',
|
||||
aiStatus: row.ai_status as AiStatus,
|
||||
aiError: row.ai_error as string | null,
|
||||
aiProvider: row.ai_provider as string | null,
|
||||
aiGeneratedAt: row.ai_generated_at as string | null,
|
||||
@@ -669,6 +800,9 @@ export class NoteRepository {
|
||||
deletedAt: (row.deleted_at as string | null) ?? null,
|
||||
lastRecalledAt: (row.last_recalled_at as string | null) ?? null,
|
||||
recallDismissedAt: (row.recall_dismissed_at as string | null) ?? null,
|
||||
status: ((row.status as NoteStatus | undefined) ?? 'active'),
|
||||
statusChangedAt: (row.status_changed_at as string | null) ?? null,
|
||||
moveReason: (row.move_reason as string | null) ?? null,
|
||||
createdAt: row.created_at as string,
|
||||
updatedAt: row.updated_at as string,
|
||||
tags: tags as NoteTag[],
|
||||
|
||||
@@ -2,6 +2,14 @@ import type { NoteRepository } from '../repository/NoteRepository.js';
|
||||
import type { MediaStore } from './MediaStore.js';
|
||||
import type { Note } from '@shared/types';
|
||||
|
||||
/**
|
||||
* v0.2.9 Cut B — CaptureService 가 ai_enabled 를 조회할 때만 의존하는 좁은 인터페이스.
|
||||
* SettingsService 직접 의존을 피해 테스트 mock 이 단순해짐 (entire SettingsService 면 불필요).
|
||||
*/
|
||||
export interface AiEnabledSource {
|
||||
isAiEnabled(): Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface TelemetryEmitter {
|
||||
emit(input:
|
||||
| { kind: 'capture'; payload: { noteId: string; rawTextLength: number; hasMedia: boolean } }
|
||||
@@ -23,6 +31,9 @@ export interface CaptureDeps {
|
||||
enqueue: (noteId: string) => Promise<void>;
|
||||
celebrate: (noteId: string) => void;
|
||||
telemetry?: TelemetryEmitter;
|
||||
// v0.2.9 Cut B — settings.ai_enabled=false 면 새 노트는 ai_status='disabled' + enqueue skip.
|
||||
// 미주입 시 기존 동작 (항상 enabled) 보존 — 기존 caller 무영향.
|
||||
settings?: AiEnabledSource;
|
||||
}
|
||||
|
||||
export interface SubmitInput {
|
||||
@@ -44,7 +55,12 @@ export class CaptureService {
|
||||
if (trimmed.length === 0 && input.images.length === 0) {
|
||||
throw new Error('empty submission');
|
||||
}
|
||||
const { id } = this.repo.create({ rawText: input.text });
|
||||
// v0.2.9 Cut B — settings 미주입 시 기본 enabled (backward compat).
|
||||
const aiEnabled = this.deps.settings ? await this.deps.settings.isAiEnabled() : true;
|
||||
const { id } = this.repo.create({
|
||||
rawText: input.text,
|
||||
aiStatus: aiEnabled ? 'pending' : 'disabled'
|
||||
});
|
||||
if (input.images.length > 0) {
|
||||
const rows = [];
|
||||
for (const img of input.images) {
|
||||
@@ -70,7 +86,9 @@ export class CaptureService {
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
await this.deps.enqueue(id);
|
||||
if (aiEnabled) {
|
||||
await this.deps.enqueue(id);
|
||||
}
|
||||
this.deps.celebrate(id);
|
||||
return { noteId: id };
|
||||
}
|
||||
|
||||
@@ -11,6 +11,9 @@ export interface HealthCheckerOptions {
|
||||
onUpdate?: (status: HealthResult) => void;
|
||||
onTelemetry?: (event: HealthTelemetryEvent) => void;
|
||||
now?: () => number;
|
||||
// v0.2.9 Cut B Task 14 — settings.ai_enabled=false 면 polling skip.
|
||||
// 미설정 시 항상 enabled (backward-compat).
|
||||
isAiEnabled?: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
const DEFAULT_INTERVAL_MS = 60_000;
|
||||
@@ -72,8 +75,22 @@ export class HealthChecker {
|
||||
|
||||
start(): void {
|
||||
if (this.timer !== null) return;
|
||||
void this.runOnce();
|
||||
this.timer = setInterval(() => { void this.runOnce(); }, this.intervalMs);
|
||||
void this.tickIfEnabled();
|
||||
this.timer = setInterval(() => { void this.tickIfEnabled(); }, this.intervalMs);
|
||||
}
|
||||
|
||||
// v0.2.9 Cut B Task 14 — polling tick. settings.ai_enabled=false 면 skip.
|
||||
// 수동 runOnce({ manual: true }) 는 이 게이트와 무관하게 항상 실행 (사용자 의도).
|
||||
private async tickIfEnabled(): Promise<void> {
|
||||
if (this.opts.isAiEnabled !== undefined) {
|
||||
try {
|
||||
const enabled = await this.opts.isAiEnabled();
|
||||
if (!enabled) return;
|
||||
} catch {
|
||||
// settings 로드 실패 시 안전 측면 — polling 진행 (기존 동작 유지).
|
||||
}
|
||||
}
|
||||
await this.runOnce();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
|
||||
@@ -8,7 +8,12 @@ const OllamaSettingsSchema = z.object({
|
||||
}).strict();
|
||||
|
||||
const SettingsSchema = z.object({
|
||||
ollama: OllamaSettingsSchema.optional()
|
||||
ollama: OllamaSettingsSchema.optional(),
|
||||
// v0.2.9 Cut B — AI-less mode toggle. 기존 settings 파일에 없으면 isAiEnabled() 가
|
||||
// true 로 fallback (기본 enabled). zod default 는 file 이 존재 + 키 부재일 때만 적용 —
|
||||
// load() 의 file-missing 분기에선 cache={} 라 isAiEnabled() 의 fallback 이 작동.
|
||||
ai_enabled: z.boolean().optional(),
|
||||
onboarding_completed: z.boolean().optional()
|
||||
}).strict();
|
||||
|
||||
export type Settings = z.infer<typeof SettingsSchema>;
|
||||
@@ -34,10 +39,49 @@ export class SettingsService {
|
||||
return this.cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.9 Cut B Task 12 — settings:get IPC 핸들러용 read-only accessor.
|
||||
* 첫 launch onboarding 분기에서 onboarding_completed 키 확인.
|
||||
*/
|
||||
async getAll(): Promise<Settings> {
|
||||
return this.load();
|
||||
}
|
||||
|
||||
async setOllama(value: OllamaSettings): Promise<void> {
|
||||
const validated = OllamaSettingsSchema.parse(value);
|
||||
const current = await this.load();
|
||||
const next: Settings = { ...current, ollama: validated };
|
||||
await this.persist(next);
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.9 Cut B — AI-less mode 의 기본값은 enabled (true). 기존 settings 파일을
|
||||
* 가진 사용자 (ai_enabled 키 부재) 도 무영향.
|
||||
*/
|
||||
async isAiEnabled(): Promise<boolean> {
|
||||
const s = await this.load();
|
||||
return s.ai_enabled ?? true;
|
||||
}
|
||||
|
||||
async setAiEnabled(value: boolean): Promise<void> {
|
||||
const current = await this.load();
|
||||
const next: Settings = { ...current, ai_enabled: value };
|
||||
await this.persist(next);
|
||||
}
|
||||
|
||||
/** v0.2.9 Cut B — 첫 실행 onboarding completion 표지. 기본 false. */
|
||||
async isOnboardingCompleted(): Promise<boolean> {
|
||||
const s = await this.load();
|
||||
return s.onboarding_completed ?? false;
|
||||
}
|
||||
|
||||
async setOnboardingCompleted(value: boolean): Promise<void> {
|
||||
const current = await this.load();
|
||||
const next: Settings = { ...current, onboarding_completed: value };
|
||||
await this.persist(next);
|
||||
}
|
||||
|
||||
private async persist(next: Settings): Promise<void> {
|
||||
await mkdir(dirname(this.filePath), { recursive: true });
|
||||
const tmpPath = this.filePath + '.tmp';
|
||||
await writeFile(tmpPath, JSON.stringify(next, null, 2), 'utf8');
|
||||
|
||||
@@ -68,6 +68,19 @@ const api: InklingApi = {
|
||||
copyAppInfo: () => ipcRenderer.invoke('settings:copy-app-info'),
|
||||
// v0.2.8 Cut A — 첨부 이미지를 OS 기본 뷰어로 열기 (Task 3).
|
||||
openMedia: (relPath: string) => ipcRenderer.invoke('inbox:open-media', relPath),
|
||||
// v0.2.9 Cut B Task 4 — status 별 list + counts.
|
||||
listByStatus: (status, opts) => ipcRenderer.invoke('inbox:list-by-status', status, opts ?? {}),
|
||||
countsByStatus: () => ipcRenderer.invoke('inbox:counts-by-status'),
|
||||
// v0.2.9 Cut B Task 8 — 4분기 status 전이 + AI 자동 분류 추천.
|
||||
setStatus: (id, status, reason) => ipcRenderer.invoke('inbox:set-status', id, status, reason),
|
||||
classifyStatus: (id, reason) => ipcRenderer.invoke('ai:classify-status', id, reason),
|
||||
// v0.2.9 Cut B Task 12 — settings read + AI/onboarding 토글 (첫 launch wizard 분기 포함).
|
||||
getSettings: () => ipcRenderer.invoke('settings:get'),
|
||||
setAiEnabled: (enabled: boolean) => ipcRenderer.invoke('settings:set-ai-enabled', enabled),
|
||||
setOnboardingCompleted: (completed: boolean) => ipcRenderer.invoke('settings:set-onboarding-completed', completed),
|
||||
// v0.2.9 Cut B Task 16 — disabled 메모 재투입 + count.
|
||||
enqueueDisabled: () => ipcRenderer.invoke('inbox:enqueue-disabled'),
|
||||
getDisabledCount: () => ipcRenderer.invoke('inbox:get-disabled-count'),
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { ExpiryBanner } from './components/ExpiryBanner.js';
|
||||
import { FailedBanner } from './components/FailedBanner.js';
|
||||
import { RecallBanner } from './components/RecallBanner.js';
|
||||
import { SettingsPage } from './components/SettingsPage.js';
|
||||
import { OnboardingWizard } from './components/OnboardingWizard.js';
|
||||
|
||||
export function App(): React.ReactElement {
|
||||
const {
|
||||
@@ -23,7 +24,25 @@ export function App(): React.ReactElement {
|
||||
} = useInbox();
|
||||
const showSettings = useInbox((s) => s.showSettings);
|
||||
const setShowSettings = useInbox((s) => s.setShowSettings);
|
||||
// v0.2.9 Cut B Task 5 — 4탭 (Inbox/완료/보관/휴지통).
|
||||
const view = useInbox((s) => s.view);
|
||||
const counts = useInbox((s) => s.counts);
|
||||
const setView = useInbox((s) => s.setView);
|
||||
const [recoveryDismissed, setRecoveryDismissed] = useState(isRecoveryDismissedToday());
|
||||
// v0.2.9 Cut B Task 12 — 첫 launch onboarding 분기. null = 로딩, true = 표시, false = 미표시.
|
||||
const [showOnboarding, setShowOnboarding] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const settings = await inboxApi.getSettings();
|
||||
setShowOnboarding(!settings.onboarding_completed);
|
||||
} catch {
|
||||
// 안전한 fallback — settings 읽기 실패 시 wizard 미표시 (기존 사용자 무영향).
|
||||
setShowOnboarding(false);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadInitial();
|
||||
@@ -35,15 +54,8 @@ export function App(): React.ReactElement {
|
||||
useInbox.setState({ ollamaStatus: status });
|
||||
});
|
||||
const unsubNav = inboxApi.onNavigate((view) => {
|
||||
if (view === 'settings') {
|
||||
useInbox.getState().setShowSettings(true);
|
||||
} else if (view === 'inbox') {
|
||||
useInbox.getState().setShowSettings(false);
|
||||
if (useInbox.getState().showTrash) void useInbox.getState().toggleShowTrash();
|
||||
} else if (view === 'trash') {
|
||||
useInbox.getState().setShowSettings(false);
|
||||
if (!useInbox.getState().showTrash) void useInbox.getState().toggleShowTrash();
|
||||
}
|
||||
// v0.2.9 Cut B Task 4 — setView 가 mirror state (showTrash/showSettings) 동기 갱신.
|
||||
useInbox.getState().setView(view);
|
||||
});
|
||||
const onFocus = () => { void refreshMeta(); };
|
||||
window.addEventListener('focus', onFocus);
|
||||
@@ -52,6 +64,9 @@ export function App(): React.ReactElement {
|
||||
// deps array 에 추가 불필요. mount 시 1회 구독 + unmount 시 해제.
|
||||
}, [loadInitial, refreshMeta, upsertNote]);
|
||||
|
||||
if (showOnboarding === null) return <></>;
|
||||
if (showOnboarding) return <OnboardingWizard onClose={() => setShowOnboarding(false)} />;
|
||||
|
||||
if (showSettings) return <SettingsPage />;
|
||||
|
||||
const showRecovery = continuity.showRecoveryToast && !recoveryDismissed;
|
||||
@@ -72,20 +87,23 @@ export function App(): React.ReactElement {
|
||||
<div className="header">
|
||||
<h1 style={{ fontSize: 18, margin: 0 }}>Inkling</h1>
|
||||
<div style={{ display: 'flex', gap: 6, marginLeft: 12 }}>
|
||||
<button
|
||||
onClick={() => { if (showTrash) void toggleShowTrash(); }}
|
||||
aria-pressed={!showTrash}
|
||||
style={tabBtnStyle(!showTrash)}
|
||||
>
|
||||
Inbox({notes.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { if (!showTrash) void toggleShowTrash(); }}
|
||||
aria-pressed={showTrash}
|
||||
style={tabBtnStyle(showTrash)}
|
||||
>
|
||||
휴지통({trashCount})
|
||||
</button>
|
||||
{(
|
||||
[
|
||||
{ key: 'inbox', label: 'Inbox', count: counts.active },
|
||||
{ key: 'completed', label: '완료', count: counts.completed },
|
||||
{ key: 'archived', label: '보관', count: counts.archived },
|
||||
{ key: 'trash', label: '휴지통', count: counts.trashed }
|
||||
] as const
|
||||
).map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setView(t.key)}
|
||||
aria-pressed={view === t.key}
|
||||
style={tabBtnStyle(view === t.key)}
|
||||
>
|
||||
{t.label}({t.count})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 2, marginLeft: 'auto' }}>
|
||||
<ContinuityBadge />
|
||||
|
||||
@@ -3,8 +3,11 @@ import { useInbox } from '../store.js';
|
||||
import { Banner } from './Banner.js';
|
||||
|
||||
export function FailedBanner(): React.ReactElement | null {
|
||||
const aiEnabled = useInbox((s) => s.ai_enabled);
|
||||
const count = useInbox((s) => s.failedCount);
|
||||
const retryAllFailed = useInbox((s) => s.retryAllFailed);
|
||||
// v0.2.9 Cut B Task 14 — AI-less mode 에서는 banner 자체 비활성.
|
||||
if (!aiEnabled) return null;
|
||||
if (count === 0) return null;
|
||||
return (
|
||||
<Banner severity="error">
|
||||
|
||||
150
src/renderer/inbox/components/MoveStatusModal.tsx
Normal file
150
src/renderer/inbox/components/MoveStatusModal.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import React, { useState } from 'react';
|
||||
import { inboxApi } from '../api.js';
|
||||
import type { NoteStatus } from '@shared/types';
|
||||
|
||||
interface Props {
|
||||
noteId: string;
|
||||
rawText: string;
|
||||
summary: string;
|
||||
onClose: () => void;
|
||||
onMoved: (status: NoteStatus, reason: string | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.9 Cut B Task 7 — 메모 이동 Modal.
|
||||
*
|
||||
* 사유 입력 + 3 status 버튼 (완료/보관/휴지통) + AI 자동 분류.
|
||||
*/
|
||||
export function MoveStatusModal({
|
||||
noteId,
|
||||
onClose,
|
||||
onMoved
|
||||
}: Props): React.ReactElement {
|
||||
const [reason, setReason] = useState('');
|
||||
const [recommendation, setRecommendation] = useState<{
|
||||
status: NoteStatus;
|
||||
rationale: string;
|
||||
} | null>(null);
|
||||
const [classifying, setClassifying] = useState(false);
|
||||
|
||||
async function move(status: NoteStatus): Promise<void> {
|
||||
const trimmedReason = reason.trim() === '' ? null : reason.trim();
|
||||
await inboxApi.setStatus(noteId, status, trimmedReason);
|
||||
onMoved(status, trimmedReason);
|
||||
}
|
||||
|
||||
async function classify(): Promise<void> {
|
||||
setClassifying(true);
|
||||
setRecommendation(null);
|
||||
try {
|
||||
const r = await inboxApi.classifyStatus(noteId, reason);
|
||||
setRecommendation({ status: r.recommended, rationale: r.rationale });
|
||||
} finally {
|
||||
setClassifying(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label="이동"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0,0,0,0.4)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 100
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: '#fff',
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
minWidth: 400,
|
||||
maxWidth: 520
|
||||
}}
|
||||
>
|
||||
<h2 style={{ fontSize: 16, margin: '0 0 12px' }}>메모 이동</h2>
|
||||
<textarea
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="이동 사유 (선택사항)"
|
||||
rows={2}
|
||||
style={{ width: '100%', padding: 6, fontSize: 13, boxSizing: 'border-box' }}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
marginTop: 8,
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<button onClick={() => void classify()} disabled={classifying}>
|
||||
{classifying ? '분류 중...' : 'AI 자동 분류'}
|
||||
</button>
|
||||
<button onClick={() => void move('completed')}>완료</button>
|
||||
<button onClick={() => void move('archived')}>보관</button>
|
||||
<button onClick={() => void move('trashed')}>휴지통</button>
|
||||
<button onClick={onClose} style={{ marginLeft: 'auto' }}>
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
{recommendation !== null && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 12,
|
||||
padding: 8,
|
||||
background: '#f0f8ff',
|
||||
borderRadius: 4,
|
||||
fontSize: 12
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
AI 추천: <strong>{statusLabel(recommendation.status)}</strong>
|
||||
</div>
|
||||
<div style={{ marginTop: 4 }}>이유: {recommendation.rationale}</div>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<button onClick={() => void move(recommendation.status)}>
|
||||
확정 ({statusLabel(recommendation.status)})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function statusLabel(s: NoteStatus): string {
|
||||
switch (s) {
|
||||
case 'active':
|
||||
return '활성';
|
||||
case 'completed':
|
||||
return '완료';
|
||||
case 'archived':
|
||||
return '보관';
|
||||
case 'trashed':
|
||||
return '휴지통';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* status 의 한글 라벨에 적절한 조사를 붙여 반환. 받침 있으면 "으로", 없으면 "로".
|
||||
* 예: '완료로' / '보관으로' / '휴지통으로' / '활성으로'.
|
||||
*/
|
||||
export function statusLabelWithParticle(s: NoteStatus): string {
|
||||
const label = statusLabel(s);
|
||||
const last = label.charCodeAt(label.length - 1);
|
||||
// 한글 syllable block 외 → "로" default
|
||||
if (last < 0xAC00 || last > 0xD7A3) return `${label}로`;
|
||||
const jongseong = (last - 0xAC00) % 28;
|
||||
return jongseong === 0 ? `${label}로` : `${label}으로`;
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { useState } from 'react';
|
||||
import type { Note } from '@shared/types';
|
||||
import type { Note, NoteStatus } from '@shared/types';
|
||||
import { inboxApi } from '../api.js';
|
||||
import { useInbox } from '../store.js';
|
||||
import { EditableField } from './EditableField.js';
|
||||
import { IntentBanner } from './IntentBanner.js';
|
||||
import { pushTagUndo } from './TagUndoToast.js';
|
||||
import { MoveStatusModal, statusLabelWithParticle } from './MoveStatusModal.js';
|
||||
|
||||
interface Props {
|
||||
note: Note;
|
||||
@@ -109,19 +110,23 @@ function DueDateBadge({
|
||||
|
||||
export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore, onPermanentDelete }: Props): React.ReactElement {
|
||||
const isTrash = mode === 'trash';
|
||||
// v0.2.9 Cut B Task 13 — ai_status='disabled' 노트는 raw_text 가 1차 정보. 원문 펼침 default 켬.
|
||||
const [rawOpen, setRawOpen] = useState(note.aiStatus !== 'done');
|
||||
const [local, setLocal] = useState(note);
|
||||
const isAiDisabled = local.aiStatus === 'disabled';
|
||||
const fallbackTitle = local.rawText.split('\n')[0]?.slice(0, 60) || '(빈 메모)';
|
||||
// v0.2.9 Cut B Task 6 — 이동 메뉴 dropdown + MoveStatusModal target.
|
||||
const [moveTarget, setMoveTarget] = useState<NoteStatus | null>(null);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
const possibleTargets: NoteStatus[] = (
|
||||
['active', 'completed', 'archived', 'trashed'] as NoteStatus[]
|
||||
).filter((s) => s !== local.status);
|
||||
|
||||
React.useEffect(() => { setLocal(note); }, [note]);
|
||||
|
||||
const formatted = new Date(note.createdAt).toLocaleString('ko-KR');
|
||||
|
||||
async function handleDelete() {
|
||||
if (!window.confirm('이 기억을 버릴까요? 되돌릴 수 없습니다.')) return;
|
||||
await inboxApi.deleteNote(note.id);
|
||||
onDeleted?.();
|
||||
}
|
||||
|
||||
async function saveTitle(next: string) {
|
||||
await inboxApi.updateAiFields(note.id, { title: next });
|
||||
const updated = { ...local, aiTitle: next, titleEditedByUser: true };
|
||||
@@ -209,6 +214,13 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
|
||||
정리 보류 — 원문은 안전합니다
|
||||
</div>
|
||||
)}
|
||||
{/* v0.2.9 Cut B Task 13 — ai_status='disabled': raw_text 첫 줄 fallback title.
|
||||
summary/tags 는 hide. 원문은 아래 "원문 보기" 영역에서 항상 표시. */}
|
||||
{isAiDisabled && (
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 600 }}>{fallbackTitle}</h3>
|
||||
</div>
|
||||
)}
|
||||
{local.aiStatus === 'done' && (
|
||||
<>
|
||||
{isTrash ? (
|
||||
@@ -366,33 +378,115 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 10, textAlign: 'right' }}>
|
||||
{isTrash ? (
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
{/* v0.2.9 Cut B Task 6 — 모든 view 공통 "이동 ▾" dropdown.
|
||||
현재 status 와 다른 3개 목적지만 표시. */}
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button
|
||||
onClick={onRestore}
|
||||
onClick={() => setMenuOpen((o) => !o)}
|
||||
aria-label="이동"
|
||||
style={{
|
||||
background: 'none', border: '1px solid #0a4b80', color: '#0a4b80',
|
||||
cursor: 'pointer', fontSize: 12, padding: '4px 10px', borderRadius: 4
|
||||
background: 'none',
|
||||
border: '1px solid #ccc',
|
||||
color: '#444',
|
||||
cursor: 'pointer',
|
||||
fontSize: 12,
|
||||
padding: '4px 10px',
|
||||
borderRadius: 4
|
||||
}}
|
||||
>
|
||||
🔄 복구
|
||||
</button>
|
||||
<button
|
||||
onClick={onPermanentDelete}
|
||||
style={{
|
||||
background: 'none', border: '1px solid #c93030', color: '#c93030',
|
||||
cursor: 'pointer', fontSize: 12, padding: '4px 10px', borderRadius: 4
|
||||
}}
|
||||
>
|
||||
🗑 영구 삭제
|
||||
이동 ▾
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: '100%',
|
||||
marginTop: 2,
|
||||
background: '#fff',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: 4,
|
||||
padding: 4,
|
||||
zIndex: 10,
|
||||
minWidth: 140,
|
||||
boxShadow: '0 2px 6px rgba(0,0,0,0.08)'
|
||||
}}
|
||||
>
|
||||
{possibleTargets.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => {
|
||||
setMoveTarget(t);
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
textAlign: 'left',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: '6px 8px',
|
||||
fontSize: 12,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{statusLabelWithParticle(t)} 이동
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => void handleDelete()} style={{ background: 'none', border: 'none', color: '#c93030', cursor: 'pointer', fontSize: 12 }}>
|
||||
🗑 삭제
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* trash mode 만 영구 삭제 + 복구 보존 (휴지통 단독 액션). */}
|
||||
{isTrash && (
|
||||
<>
|
||||
<button
|
||||
onClick={onRestore}
|
||||
style={{
|
||||
background: 'none', border: '1px solid #0a4b80', color: '#0a4b80',
|
||||
cursor: 'pointer', fontSize: 12, padding: '4px 10px', borderRadius: 4
|
||||
}}
|
||||
>
|
||||
🔄 복구
|
||||
</button>
|
||||
<button
|
||||
onClick={onPermanentDelete}
|
||||
style={{
|
||||
background: 'none', border: '1px solid #c93030', color: '#c93030',
|
||||
cursor: 'pointer', fontSize: 12, padding: '4px 10px', borderRadius: 4
|
||||
}}
|
||||
>
|
||||
🗑 영구 삭제
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{moveTarget !== null && (
|
||||
<MoveStatusModal
|
||||
noteId={local.id}
|
||||
rawText={local.rawText}
|
||||
summary={local.aiSummary ?? ''}
|
||||
onClose={() => setMoveTarget(null)}
|
||||
onMoved={(newStatus, reason) => {
|
||||
const updated = { ...local, status: newStatus, moveReason: reason };
|
||||
setLocal(updated);
|
||||
onUpdated(updated);
|
||||
// inbox/trash mode 의 list 가 status 별로 필터되므로 onDeleted (제거) 도 호출.
|
||||
if (newStatus !== local.status) onDeleted?.();
|
||||
setMoveTarget(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,8 +7,11 @@ interface OllamaBannerProps {
|
||||
}
|
||||
|
||||
export function OllamaBanner({ onOpenSettings }: OllamaBannerProps = {}): React.ReactElement | null {
|
||||
const aiEnabled = useInbox((s) => s.ai_enabled);
|
||||
const status = useInbox((s) => s.ollamaStatus);
|
||||
const recheckOllama = useInbox((s) => s.recheckOllama);
|
||||
// v0.2.9 Cut B Task 14 — AI-less mode 에서는 banner 자체 비활성.
|
||||
if (!aiEnabled) return null;
|
||||
if (status.ok) return null;
|
||||
const isMissing = status.reason?.includes('not installed');
|
||||
const message = isMissing
|
||||
|
||||
42
src/renderer/inbox/components/OnboardingWizard.tsx
Normal file
42
src/renderer/inbox/components/OnboardingWizard.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import { inboxApi } from '../api.js';
|
||||
|
||||
/**
|
||||
* v0.2.9 Cut B Task 11 — 첫 launch onboarding 위저드.
|
||||
*
|
||||
* 3 옵션 (AI 사용 / 원문만 / 나중에) 중 하나를 선택. AI 옵션 (true/false) 은
|
||||
* setAiEnabled 로 settings 에 저장, 모든 옵션은 setOnboardingCompleted(true) 로
|
||||
* 두 번째 launch 부터 미노출. "나중에" 는 ai_enabled 기본값 (true) 유지 — 사용자
|
||||
* 가 SettingsPage 에서 추후 선택 가능.
|
||||
*/
|
||||
export function OnboardingWizard({ onClose }: { onClose: () => void }): React.ReactElement {
|
||||
async function choose(aiEnabled: boolean | null): Promise<void> {
|
||||
if (aiEnabled !== null) await inboxApi.setAiEnabled(aiEnabled);
|
||||
await inboxApi.setOnboardingCompleted(true);
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<div role="dialog" aria-label="시작 안내" style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000
|
||||
}}>
|
||||
<div style={{ background: '#fff', padding: 24, borderRadius: 8, maxWidth: 520 }}>
|
||||
<h2 style={{ margin: '0 0 12px' }}>Inkling 사용 시작</h2>
|
||||
<p style={{ fontSize: 14, lineHeight: 1.6, marginBottom: 12 }}>
|
||||
Inkling 은 로컬 LLM (Ollama) 으로 메모를 자동 정리합니다.
|
||||
Ollama 가 설치되어 있고 한국어 지원 모델 (gemma3, gemma2 등) 이 pull 되어 있어야 최적의 경험이 가능합니다.
|
||||
</p>
|
||||
<p style={{ fontSize: 13, marginBottom: 16 }}>
|
||||
설치 가이드:
|
||||
<a href="https://ollama.com/download" target="_blank" rel="noopener noreferrer">ollama.com/download</a>
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<button onClick={() => choose(true)}>AI 자동 처리 사용 (Ollama 필요)</button>
|
||||
<button onClick={() => choose(false)}>원문만 저장 (AI 처리 안 함)</button>
|
||||
<button onClick={() => choose(null)} style={{ marginTop: 4 }}>나중에 설정</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,9 @@ export function AiProviderSection(): React.ReactElement {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saveResult, setSaveResult] = useState<string | null>(null);
|
||||
const [recheckResult, setRecheckResult] = useState<string | null>(null);
|
||||
// v0.2.9 Cut B Task 15-16: AI 자동 처리 토글 + disabled 메모 일괄 처리.
|
||||
const [aiEnabled, setAiEnabledState] = useState<boolean | null>(null);
|
||||
const [disabledCount, setDisabledCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
@@ -18,9 +21,32 @@ export function AiProviderSection(): React.ReactElement {
|
||||
setEndpoint(s.endpoint);
|
||||
setModel(s.model);
|
||||
}
|
||||
const settings = await inboxApi.getSettings();
|
||||
const enabled = settings.ai_enabled ?? true;
|
||||
setAiEnabledState(enabled);
|
||||
if (enabled) {
|
||||
const c = await inboxApi.getDisabledCount();
|
||||
setDisabledCount(c);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
async function onToggleAi(checked: boolean): Promise<void> {
|
||||
await inboxApi.setAiEnabled(checked);
|
||||
setAiEnabledState(checked);
|
||||
if (checked) {
|
||||
const c = await inboxApi.getDisabledCount();
|
||||
setDisabledCount(c);
|
||||
} else {
|
||||
setDisabledCount(0);
|
||||
}
|
||||
}
|
||||
|
||||
async function onProcessDisabled(): Promise<void> {
|
||||
await inboxApi.enqueueDisabled();
|
||||
setDisabledCount(0);
|
||||
}
|
||||
|
||||
async function onSave(): Promise<void> {
|
||||
const r = endpointSchema.safeParse(endpoint);
|
||||
if (!r.success) {
|
||||
@@ -51,6 +77,46 @@ export function AiProviderSection(): React.ReactElement {
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* v0.2.9 Cut B Task 15 — AI 자동 처리 토글 (가장 위, 스위치 의미가 가장 큰 결정) */}
|
||||
{aiEnabled !== null && (
|
||||
<label style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 12, fontSize: 13 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={aiEnabled}
|
||||
onChange={(e) => void onToggleAi(e.target.checked)}
|
||||
/>
|
||||
AI 자동 처리 사용
|
||||
</label>
|
||||
)}
|
||||
{aiEnabled === false && (
|
||||
<p style={{ fontSize: 12, color: '#666', marginBottom: 12 }}>
|
||||
원문만 저장 모드. 메모의 제목/요약/태그가 자동 생성되지 않습니다.<br />
|
||||
<a href="https://ollama.com/download" target="_blank" rel="noopener noreferrer">
|
||||
Ollama 설치 가이드
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
{/* v0.2.9 Cut B Task 16 — ON 전환 후 disabled 메모 일괄 처리 prompt */}
|
||||
{aiEnabled === true && disabledCount > 0 && (
|
||||
<div style={{ padding: 8, background: '#fffbe5', borderRadius: 4, marginBottom: 12, fontSize: 13 }}>
|
||||
원문만 저장된 메모 {disabledCount}건이 있습니다.
|
||||
<button
|
||||
onClick={() => void onProcessDisabled()}
|
||||
style={{
|
||||
marginLeft: 8,
|
||||
background: '#0a4b80',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 4,
|
||||
padding: '4px 10px',
|
||||
fontSize: 12,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
지금 모두 처리
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<label style={{ display: 'block', marginBottom: 8, fontSize: 12, color: '#666' }}>
|
||||
Endpoint
|
||||
<input
|
||||
|
||||
@@ -5,12 +5,26 @@ import { nextKstMidnightMs } from '@shared/util/kstDate.js';
|
||||
|
||||
export { selectFilteredNotes } from './selectFilteredNotes.js';
|
||||
|
||||
// v0.2.9 Cut B Task 4 — 4탭 view enum + settings.
|
||||
// 'inbox' = active, 'completed'/'archived' = NoteStatus 그대로, 'trash' = trashed (mirror), 'settings' = SettingsPage.
|
||||
export type InboxView = 'inbox' | 'completed' | 'archived' | 'trash' | 'settings';
|
||||
|
||||
export interface InboxCounts {
|
||||
active: number;
|
||||
completed: number;
|
||||
archived: number;
|
||||
trashed: number;
|
||||
}
|
||||
|
||||
interface InboxState {
|
||||
notes: Note[];
|
||||
trashNotes: Note[];
|
||||
trashCount: number;
|
||||
showTrash: boolean;
|
||||
showSettings: boolean;
|
||||
// v0.2.9 Cut B Task 4 — view enum + counts. showTrash/showSettings 는 mirror 로 잠시 잔류.
|
||||
view: InboxView;
|
||||
counts: InboxCounts;
|
||||
continuity: WeeklyContinuity;
|
||||
pendingCount: number;
|
||||
ollamaStatus: { ok: boolean; reason?: string };
|
||||
@@ -22,12 +36,17 @@ interface InboxState {
|
||||
failedCount: number;
|
||||
recallCandidate: Note | null;
|
||||
recallSnoozeUntilMs: number | null;
|
||||
// v0.2.9 Cut B Task 14 — AI 비활성 모드에서는 OllamaBanner/FailedBanner render skip.
|
||||
// 기본 true (기존 사용자 무영향). loadInitial / refreshMeta 가 settings 로드.
|
||||
ai_enabled: boolean;
|
||||
loadInitial: () => Promise<void>;
|
||||
refreshMeta: () => Promise<void>;
|
||||
upsertNote: (note: Note) => void;
|
||||
removeNote: (id: string) => void;
|
||||
setTagFilter: (tag: string | null) => void;
|
||||
setShowSettings: (open: boolean) => void;
|
||||
setView: (view: InboxView) => void;
|
||||
loadByView: (view: 'completed' | 'archived' | 'trash') => Promise<void>;
|
||||
toggleShowTrash: () => Promise<void>;
|
||||
loadTrash: () => Promise<void>;
|
||||
restoreNote: (id: string) => Promise<void>;
|
||||
@@ -55,6 +74,8 @@ export const useInbox = create<InboxState>((set, get) => ({
|
||||
trashCount: 0,
|
||||
showTrash: false,
|
||||
showSettings: false,
|
||||
view: 'inbox',
|
||||
counts: { active: 0, completed: 0, archived: 0, trashed: 0 },
|
||||
continuity: emptyContinuity,
|
||||
pendingCount: 0,
|
||||
ollamaStatus: { ok: true },
|
||||
@@ -66,9 +87,10 @@ export const useInbox = create<InboxState>((set, get) => ({
|
||||
failedCount: 0,
|
||||
recallCandidate: null,
|
||||
recallSnoozeUntilMs: null,
|
||||
ai_enabled: true,
|
||||
async loadInitial() {
|
||||
set({ loading: true });
|
||||
const [notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate] = await Promise.all([
|
||||
const [notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, settings] = await Promise.all([
|
||||
inboxApi.listNotes({ limit: 50 }),
|
||||
inboxApi.getContinuity(),
|
||||
inboxApi.getPendingCount(),
|
||||
@@ -77,12 +99,14 @@ export const useInbox = create<InboxState>((set, get) => ({
|
||||
inboxApi.getTrashCount(),
|
||||
inboxApi.listExpired(),
|
||||
inboxApi.getFailedCount(),
|
||||
inboxApi.listRecallCandidate()
|
||||
inboxApi.listRecallCandidate(),
|
||||
inboxApi.countsByStatus(),
|
||||
inboxApi.getSettings()
|
||||
]);
|
||||
set({ notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, loading: false });
|
||||
set({ notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, ai_enabled: settings.ai_enabled ?? true, loading: false });
|
||||
},
|
||||
async refreshMeta() {
|
||||
const [continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate] = await Promise.all([
|
||||
const [continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, settings] = await Promise.all([
|
||||
inboxApi.getContinuity(),
|
||||
inboxApi.getPendingCount(),
|
||||
inboxApi.getOllamaStatus(),
|
||||
@@ -90,9 +114,11 @@ export const useInbox = create<InboxState>((set, get) => ({
|
||||
inboxApi.getTrashCount(),
|
||||
inboxApi.listExpired(),
|
||||
inboxApi.getFailedCount(),
|
||||
inboxApi.listRecallCandidate()
|
||||
inboxApi.listRecallCandidate(),
|
||||
inboxApi.countsByStatus(),
|
||||
inboxApi.getSettings()
|
||||
]);
|
||||
set({ continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate });
|
||||
set({ continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, ai_enabled: settings.ai_enabled ?? true });
|
||||
},
|
||||
upsertNote(note) {
|
||||
// trashCount 는 server-authoritative. trashNotes 가 cache-loaded (showTrash=true) 일
|
||||
@@ -138,7 +164,29 @@ export const useInbox = create<InboxState>((set, get) => ({
|
||||
set({ tagFilter: tag });
|
||||
},
|
||||
setShowSettings(open) {
|
||||
set({ showSettings: open });
|
||||
// backward-compat — setView 로 위임. mirror state (view, showTrash, showSettings) 동기 갱신.
|
||||
if (open) get().setView('settings');
|
||||
else get().setView('inbox');
|
||||
},
|
||||
setView(view) {
|
||||
set({
|
||||
view,
|
||||
showTrash: view === 'trash',
|
||||
showSettings: view === 'settings'
|
||||
});
|
||||
// settings/inbox 외 status view 면 해당 status fetch.
|
||||
if (view === 'completed' || view === 'archived' || view === 'trash') {
|
||||
void get().loadByView(view);
|
||||
}
|
||||
},
|
||||
async loadByView(view) {
|
||||
const status = view === 'trash' ? 'trashed' : view;
|
||||
const notes = await inboxApi.listByStatus(status, { limit: 200 });
|
||||
if (view === 'trash') {
|
||||
set({ trashNotes: notes, trashCount: notes.length });
|
||||
} else {
|
||||
set({ notes });
|
||||
}
|
||||
},
|
||||
async toggleShowTrash() {
|
||||
const next = !get().showTrash;
|
||||
|
||||
@@ -11,7 +11,10 @@ export interface NoteMedia {
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
export type AiStatus = 'pending' | 'done' | 'failed';
|
||||
export type AiStatus = 'pending' | 'done' | 'failed' | 'disabled';
|
||||
|
||||
// v0.2.9 Cut B — 노트 status 4분기 (사용자 액션). m004 마이그레이션 + setStatus.
|
||||
export type NoteStatus = 'active' | 'completed' | 'archived' | 'trashed';
|
||||
|
||||
export interface NoteTag {
|
||||
name: string;
|
||||
@@ -37,6 +40,10 @@ export interface Note {
|
||||
deletedAt: string | null;
|
||||
lastRecalledAt: string | null;
|
||||
recallDismissedAt: string | null;
|
||||
// 신규 v4 (v0.2.9 Cut B):
|
||||
status: NoteStatus;
|
||||
statusChangedAt: string | null;
|
||||
moveReason: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
tags: NoteTag[];
|
||||
@@ -128,6 +135,27 @@ export interface InboxApi {
|
||||
copyAppInfo(): Promise<void>;
|
||||
// v0.2.8 Cut A — 첨부 이미지를 OS 기본 뷰어로 열기 (Task 3).
|
||||
openMedia(relPath: string): Promise<{ ok: true } | { ok: false; reason: string }>;
|
||||
// v0.2.9 Cut B Task 4 — status 별 노트 목록 + status 별 count.
|
||||
listByStatus(status: NoteStatus, opts?: { limit?: number }): Promise<Note[]>;
|
||||
countsByStatus(): Promise<{ active: number; completed: number; archived: number; trashed: number }>;
|
||||
// v0.2.9 Cut B Task 8 — 4분기 status 전이 + AI 자동 분류 추천.
|
||||
setStatus(
|
||||
id: string,
|
||||
status: NoteStatus,
|
||||
reason: string | null
|
||||
): Promise<{ ok: true } | { ok: false; reason: string }>;
|
||||
classifyStatus(id: string, reason: string): Promise<{ recommended: NoteStatus; rationale: string }>;
|
||||
// v0.2.9 Cut B Task 12 — settings read + AI/onboarding 토글.
|
||||
getSettings(): Promise<{
|
||||
ollama?: { endpoint: string; model: string };
|
||||
ai_enabled?: boolean;
|
||||
onboarding_completed?: boolean;
|
||||
}>;
|
||||
setAiEnabled(enabled: boolean): Promise<{ ok: true }>;
|
||||
setOnboardingCompleted(completed: boolean): Promise<{ ok: true }>;
|
||||
// v0.2.9 Cut B Task 16 — ai_status='disabled' 메모 재투입 (사용자가 ai_enabled OFF→ON 전환 시).
|
||||
enqueueDisabled(): Promise<{ count: number }>;
|
||||
getDisabledCount(): Promise<number>;
|
||||
}
|
||||
|
||||
export interface InklingApi {
|
||||
|
||||
@@ -25,6 +25,11 @@ test('inbox shell shows v0.2 empty state', async () => {
|
||||
if ((await w.title()) === 'Inkling') { inbox = w; break; }
|
||||
}
|
||||
await inbox.waitForLoadState('load');
|
||||
// v0.2.9 Cut B: 첫 launch 시 OnboardingWizard 표시 — "나중에 설정" 으로 dismiss 후 inbox 진입.
|
||||
const dismissOnboarding = inbox.getByRole('button', { name: /나중에 설정/ });
|
||||
if (await dismissOnboarding.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await dismissOnboarding.click();
|
||||
}
|
||||
await expect(inbox.getByRole('heading', { name: 'Inkling' })).toBeVisible();
|
||||
await expect(inbox.getByText('머릿속에 떠다니는 한 줄을 적어보세요.')).toBeVisible();
|
||||
await app.close();
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
|
||||
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
|
||||
|
||||
vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
inboxApi: {
|
||||
loadOllamaSettings: vi.fn(async () => ({ endpoint: 'http://localhost:11434', model: 'gemma2:2b' })),
|
||||
saveOllamaSettings: vi.fn(async () => ({ ok: true })),
|
||||
ollamaRecheck: vi.fn(async () => ({ ok: true }))
|
||||
ollamaRecheck: vi.fn(async () => ({ ok: true })),
|
||||
getSettings: vi.fn(async () => ({ ai_enabled: true })),
|
||||
setAiEnabled: vi.fn(async () => ({ ok: true })),
|
||||
getDisabledCount: vi.fn(async () => 0),
|
||||
enqueueDisabled: vi.fn(async () => ({ count: 0 }))
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -41,4 +45,53 @@ describe('AiProviderSection', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /지금 재확인/ }));
|
||||
expect(inboxApi.ollamaRecheck).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// v0.2.9 Cut B Task 15 — AI 자동 처리 토글 + OFF 안내문.
|
||||
it('renders AI 자동 처리 toggle (default true)', async () => {
|
||||
const { inboxApi } = await import('../../src/renderer/inbox/api.js');
|
||||
vi.mocked(inboxApi.getSettings).mockResolvedValue({ ai_enabled: true } as never);
|
||||
render(<AiProviderSection />);
|
||||
const toggle = await screen.findByLabelText(/AI 자동 처리 사용/);
|
||||
expect((toggle as HTMLInputElement).checked).toBe(true);
|
||||
});
|
||||
|
||||
it('toggling calls setAiEnabled', async () => {
|
||||
const { inboxApi } = await import('../../src/renderer/inbox/api.js');
|
||||
vi.mocked(inboxApi.getSettings).mockResolvedValue({ ai_enabled: true } as never);
|
||||
vi.mocked(inboxApi.setAiEnabled).mockResolvedValue({ ok: true } as never);
|
||||
render(<AiProviderSection />);
|
||||
const toggle = await screen.findByLabelText(/AI 자동 처리 사용/);
|
||||
fireEvent.click(toggle);
|
||||
await waitFor(() => expect(inboxApi.setAiEnabled).toHaveBeenCalledWith(false));
|
||||
});
|
||||
|
||||
it('shows OFF state explanation when ai_enabled=false', async () => {
|
||||
const { inboxApi } = await import('../../src/renderer/inbox/api.js');
|
||||
vi.mocked(inboxApi.getSettings).mockResolvedValue({ ai_enabled: false } as never);
|
||||
render(<AiProviderSection />);
|
||||
await screen.findByLabelText(/AI 자동 처리 사용/);
|
||||
expect(screen.getByText(/원문만 저장 모드/)).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: /ollama\.com|설치/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// v0.2.9 Cut B Task 16 — ON 전환 후 disabled 메모 처리 prompt + 버튼.
|
||||
it('shows disabled count + 처리 버튼 when ai_enabled=true and disabledCount > 0', async () => {
|
||||
const { inboxApi } = await import('../../src/renderer/inbox/api.js');
|
||||
vi.mocked(inboxApi.getSettings).mockResolvedValue({ ai_enabled: true } as never);
|
||||
vi.mocked(inboxApi.getDisabledCount).mockResolvedValue(5);
|
||||
render(<AiProviderSection />);
|
||||
await screen.findByText(/5건/);
|
||||
expect(screen.getByRole('button', { name: /지금 모두 처리/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking 처리 버튼 calls enqueueDisabled', async () => {
|
||||
const { inboxApi } = await import('../../src/renderer/inbox/api.js');
|
||||
vi.mocked(inboxApi.getSettings).mockResolvedValue({ ai_enabled: true } as never);
|
||||
vi.mocked(inboxApi.getDisabledCount).mockResolvedValue(3);
|
||||
vi.mocked(inboxApi.enqueueDisabled).mockResolvedValue({ count: 3 } as never);
|
||||
render(<AiProviderSection />);
|
||||
await screen.findByText(/3건/);
|
||||
fireEvent.click(screen.getByRole('button', { name: /지금 모두 처리/ }));
|
||||
await waitFor(() => expect(inboxApi.enqueueDisabled).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,8 @@ import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/re
|
||||
vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
inboxApi: {
|
||||
listNotes: vi.fn(async () => []),
|
||||
listByStatus: vi.fn(async () => []),
|
||||
countsByStatus: vi.fn(async () => ({ active: 0, completed: 0, archived: 0, trashed: 0 })),
|
||||
getContinuity: vi.fn(async () => ({
|
||||
weekStart: '', weekCount: 0, weekTarget: 7,
|
||||
consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null
|
||||
@@ -47,7 +49,14 @@ vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
runExportTelemetry: vi.fn(async () => ({ ok: true })),
|
||||
getAppInfo: vi.fn(async () => ({ version: '0.2.7', electron: '?', node: '?', os: '?', profileDir: '?' })),
|
||||
openProfileDir: vi.fn(async () => undefined),
|
||||
copyAppInfo: vi.fn(async () => undefined)
|
||||
copyAppInfo: vi.fn(async () => undefined),
|
||||
// v0.2.9 Cut B Task 12 — onboarding wizard 분기. default 는 onboarding_completed=true 라 wizard 미표시.
|
||||
getSettings: vi.fn(async () => ({ onboarding_completed: true })),
|
||||
setAiEnabled: vi.fn(async () => ({ ok: true as const })),
|
||||
setOnboardingCompleted: vi.fn(async () => ({ ok: true as const })),
|
||||
// v0.2.9 Cut B Task 16 — AiProviderSection 가 SettingsPage 렌더 시 호출.
|
||||
getDisabledCount: vi.fn(async () => 0),
|
||||
enqueueDisabled: vi.fn(async () => ({ count: 0 }))
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -58,7 +67,12 @@ import { inboxApi } from '../../src/renderer/inbox/api.js';
|
||||
describe('App — settings view', () => {
|
||||
beforeEach(() => {
|
||||
cleanup();
|
||||
useInbox.setState({ showSettings: false, notes: [], trashNotes: [], trashCount: 0 });
|
||||
useInbox.setState({
|
||||
view: 'inbox',
|
||||
counts: { active: 0, completed: 0, archived: 0, trashed: 0 },
|
||||
showSettings: false, showTrash: false,
|
||||
notes: [], trashNotes: [], trashCount: 0
|
||||
});
|
||||
});
|
||||
|
||||
it('renders SettingsPage when showSettings=true', async () => {
|
||||
@@ -89,3 +103,69 @@ describe('App — settings view', () => {
|
||||
await waitFor(() => expect(useInbox.getState().showSettings).toBe(true));
|
||||
});
|
||||
});
|
||||
|
||||
describe('App header — 4 tabs', () => {
|
||||
beforeEach(() => {
|
||||
cleanup();
|
||||
useInbox.setState({
|
||||
view: 'inbox',
|
||||
counts: { active: 5, completed: 3, archived: 2, trashed: 1 },
|
||||
notes: [], trashNotes: [], trashCount: 0,
|
||||
showTrash: false, showSettings: false
|
||||
});
|
||||
// loadInitial 이 비동기로 counts 를 덮어씀 — onboarding wizard async gate (Task 12) 도입
|
||||
// 후 render 가 await 후 발생하므로 mock 의 countsByStatus 가 테스트 기대값을 반환하도록 갱신.
|
||||
vi.mocked(inboxApi.countsByStatus).mockResolvedValue({ active: 5, completed: 3, archived: 2, trashed: 1 });
|
||||
});
|
||||
|
||||
it('renders 4 tabs with counts', async () => {
|
||||
render(<App />);
|
||||
expect(await screen.findByRole('button', { name: /Inbox\(5\)/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /완료\(3\)/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /보관\(2\)/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /휴지통\(1\)/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking 완료 tab sets view=completed', async () => {
|
||||
render(<App />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: /완료/ }));
|
||||
expect(useInbox.getState().view).toBe('completed');
|
||||
});
|
||||
|
||||
it('aria-pressed reflects current view', async () => {
|
||||
useInbox.setState({ view: 'archived' });
|
||||
render(<App />);
|
||||
const archivedBtn = await screen.findByRole('button', { name: /보관/ });
|
||||
expect(archivedBtn.getAttribute('aria-pressed')).toBe('true');
|
||||
const inboxBtn = screen.getByRole('button', { name: /Inbox/ });
|
||||
expect(inboxBtn.getAttribute('aria-pressed')).toBe('false');
|
||||
});
|
||||
});
|
||||
|
||||
describe('App — onboarding wizard', () => {
|
||||
beforeEach(() => {
|
||||
cleanup();
|
||||
useInbox.setState({
|
||||
view: 'inbox',
|
||||
counts: { active: 0, completed: 0, archived: 0, trashed: 0 },
|
||||
showSettings: false, showTrash: false,
|
||||
notes: [], trashNotes: [], trashCount: 0
|
||||
});
|
||||
// 각 테스트가 getSettings 의 default mock 을 직접 override.
|
||||
vi.mocked(inboxApi.getSettings).mockResolvedValue({ onboarding_completed: true });
|
||||
});
|
||||
|
||||
it('renders OnboardingWizard when onboarding_completed=false', async () => {
|
||||
vi.mocked(inboxApi.getSettings).mockResolvedValue({ onboarding_completed: false });
|
||||
render(<App />);
|
||||
await screen.findByText(/Inkling 사용 시작/);
|
||||
expect(screen.getByRole('dialog', { name: /시작 안내/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render OnboardingWizard when onboarding_completed=true', async () => {
|
||||
vi.mocked(inboxApi.getSettings).mockResolvedValue({ onboarding_completed: true });
|
||||
render(<App />);
|
||||
await screen.findByRole('button', { name: /Inbox/ });
|
||||
expect(screen.queryByText(/Inkling 사용 시작/)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -420,6 +420,51 @@ describe('CaptureService.retryAllFailed', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('CaptureService ai_enabled toggle (v0.2.9 Cut B)', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
let store: MediaStore;
|
||||
let tmp: string;
|
||||
let enqueued: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
repo = new NoteRepository(db);
|
||||
tmp = mkdtempSync(join(tmpdir(), 'inkling-aitoggle-'));
|
||||
store = new MediaStore(tmp);
|
||||
enqueued = [];
|
||||
});
|
||||
|
||||
it('ai_enabled=false → ai_status=disabled, no enqueue, no pending_jobs row', async () => {
|
||||
const settings = { isAiEnabled: async () => false };
|
||||
const svc = new CaptureService(repo, store, {
|
||||
enqueue: async (id) => { enqueued.push(id); },
|
||||
celebrate: () => {},
|
||||
settings
|
||||
});
|
||||
const { noteId } = await svc.submit({ text: 'no-ai', images: [] });
|
||||
expect(repo.findById(noteId)?.aiStatus).toBe('disabled');
|
||||
expect(enqueued).toEqual([]);
|
||||
const row = db.prepare('SELECT note_id FROM pending_jobs WHERE note_id=?').get(noteId);
|
||||
expect(row).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ai_enabled=true → default pending + enqueue (parity with no settings dep)', async () => {
|
||||
const settings = { isAiEnabled: async () => true };
|
||||
const svc = new CaptureService(repo, store, {
|
||||
enqueue: async (id) => { enqueued.push(id); },
|
||||
celebrate: () => {},
|
||||
settings
|
||||
});
|
||||
const { noteId } = await svc.submit({ text: 'with-ai', images: [] });
|
||||
expect(repo.findById(noteId)?.aiStatus).toBe('pending');
|
||||
expect(enqueued).toEqual([noteId]);
|
||||
const row = db.prepare('SELECT note_id FROM pending_jobs WHERE note_id=?').get(noteId);
|
||||
expect(row).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CaptureService recall methods (v0.2.3 #6)', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
|
||||
46
tests/unit/FailedBanner.test.tsx
Normal file
46
tests/unit/FailedBanner.test.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { render, cleanup } from '@testing-library/react';
|
||||
|
||||
vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
inboxApi: {
|
||||
retryAllFailed: vi.fn(async () => {})
|
||||
}
|
||||
}));
|
||||
|
||||
import { FailedBanner } from '../../src/renderer/inbox/components/FailedBanner';
|
||||
import { useInbox } from '../../src/renderer/inbox/store';
|
||||
|
||||
describe('FailedBanner — ai_enabled (v0.2.9 Cut B Task 14)', () => {
|
||||
beforeEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('renders nothing when ai_enabled=false (even with failedCount > 0)', () => {
|
||||
useInbox.setState({
|
||||
ai_enabled: false,
|
||||
failedCount: 3
|
||||
} as Partial<ReturnType<typeof useInbox.getState>>);
|
||||
const { container } = render(<FailedBanner />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('renders nothing when ai_enabled=true and failedCount=0', () => {
|
||||
useInbox.setState({
|
||||
ai_enabled: true,
|
||||
failedCount: 0
|
||||
} as Partial<ReturnType<typeof useInbox.getState>>);
|
||||
const { container } = render(<FailedBanner />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('renders banner when ai_enabled=true and failedCount > 0', () => {
|
||||
useInbox.setState({
|
||||
ai_enabled: true,
|
||||
failedCount: 5
|
||||
} as Partial<ReturnType<typeof useInbox.getState>>);
|
||||
const { container } = render(<FailedBanner />);
|
||||
expect(container).not.toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
@@ -117,3 +117,48 @@ describe('HealthChecker — delta transitions + telemetry', () => {
|
||||
expect(events).toEqual([{ kind: 'ollama_recheck_manual' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('HealthChecker — ai_enabled gate (v0.2.9 Cut B Task 14)', () => {
|
||||
beforeEach(() => { vi.useFakeTimers(); });
|
||||
afterEach(() => { vi.useRealTimers(); });
|
||||
|
||||
it('isAiEnabled=false 면 start() polling 이 healthCheck 호출 skip', async () => {
|
||||
const provider = new FakeProvider();
|
||||
provider.results = [{ ok: true }];
|
||||
const hc = new HealthChecker(new ProviderHolder(provider), {
|
||||
intervalMs: 1000,
|
||||
isAiEnabled: async () => false
|
||||
});
|
||||
hc.start();
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
// 즉시 + 2 tick = 0회 — AI 비활성으로 모든 polling skip.
|
||||
expect((provider as any).idx).toBe(0);
|
||||
hc.stop();
|
||||
});
|
||||
|
||||
it('isAiEnabled=true 면 polling 정상 (gate 통과)', async () => {
|
||||
const provider = new FakeProvider();
|
||||
provider.results = [{ ok: true }, { ok: true }];
|
||||
const hc = new HealthChecker(new ProviderHolder(provider), {
|
||||
intervalMs: 1000,
|
||||
isAiEnabled: async () => true
|
||||
});
|
||||
hc.start();
|
||||
// start() 의 즉시 tick 이 microtask 에서 isAiEnabled 를 await 함 → flush 필요.
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect((provider as any).idx).toBeGreaterThanOrEqual(2);
|
||||
hc.stop();
|
||||
});
|
||||
|
||||
it('isAiEnabled=false 여도 manual runOnce 는 항상 실행 (사용자 의도)', async () => {
|
||||
const provider = new FakeProvider();
|
||||
provider.results = [{ ok: true }];
|
||||
const hc = new HealthChecker(new ProviderHolder(provider), {
|
||||
isAiEnabled: async () => false
|
||||
});
|
||||
await hc.runOnce({ manual: true });
|
||||
expect((provider as any).idx).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
98
tests/unit/MoveStatusModal.test.tsx
Normal file
98
tests/unit/MoveStatusModal.test.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
|
||||
|
||||
const { mockSetStatus, mockClassify } = vi.hoisted(() => ({
|
||||
mockSetStatus: vi.fn(async () => ({ ok: true as const })),
|
||||
mockClassify: vi.fn(async () => ({
|
||||
recommended: 'completed' as const,
|
||||
rationale: '결재 끝'
|
||||
}))
|
||||
}));
|
||||
|
||||
vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
inboxApi: {
|
||||
setStatus: mockSetStatus,
|
||||
classifyStatus: mockClassify
|
||||
}
|
||||
}));
|
||||
|
||||
import { MoveStatusModal } from '../../src/renderer/inbox/components/MoveStatusModal';
|
||||
|
||||
describe('MoveStatusModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('renders reason textarea + 4 buttons + AI classify button', () => {
|
||||
render(
|
||||
<MoveStatusModal
|
||||
noteId="n1"
|
||||
rawText="t"
|
||||
summary=""
|
||||
onClose={vi.fn()}
|
||||
onMoved={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '완료' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '보관' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '휴지통' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /AI 자동 분류/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking 완료 calls setStatus with reason', async () => {
|
||||
const onMoved = vi.fn();
|
||||
render(
|
||||
<MoveStatusModal
|
||||
noteId="n1"
|
||||
rawText="t"
|
||||
summary=""
|
||||
onClose={vi.fn()}
|
||||
onMoved={onMoved}
|
||||
/>
|
||||
);
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: '결재 끝' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '완료' }));
|
||||
await waitFor(() => {
|
||||
expect(mockSetStatus).toHaveBeenCalledWith('n1', 'completed', '결재 끝');
|
||||
expect(onMoved).toHaveBeenCalledWith('completed', '결재 끝');
|
||||
});
|
||||
});
|
||||
|
||||
it('AI 자동 분류 → recommendation 표시 + 확정 → setStatus', async () => {
|
||||
const onMoved = vi.fn();
|
||||
render(
|
||||
<MoveStatusModal
|
||||
noteId="n1"
|
||||
rawText="t"
|
||||
summary=""
|
||||
onClose={vi.fn()}
|
||||
onMoved={onMoved}
|
||||
/>
|
||||
);
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: '결재 끝' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /AI 자동 분류/ }));
|
||||
await screen.findByText(/AI 추천/);
|
||||
expect(screen.getByText(/이유: 결재 끝/)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: /확정/ }));
|
||||
await waitFor(() => expect(onMoved).toHaveBeenCalledWith('completed', '결재 끝'));
|
||||
});
|
||||
|
||||
it('빈 사유 → null reason 전달', async () => {
|
||||
const onMoved = vi.fn();
|
||||
render(
|
||||
<MoveStatusModal
|
||||
noteId="n1"
|
||||
rawText="t"
|
||||
summary=""
|
||||
onClose={vi.fn()}
|
||||
onMoved={onMoved}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '보관' }));
|
||||
await waitFor(() => expect(mockSetStatus).toHaveBeenCalledWith('n1', 'archived', null));
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,16 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
|
||||
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
|
||||
import type { Note } from '@shared/types';
|
||||
|
||||
const { mockOpenMedia } = vi.hoisted(() => ({
|
||||
mockOpenMedia: vi.fn(async () => ({ ok: true }))
|
||||
const { mockOpenMedia, mockSetStatus, mockClassify } = 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'
|
||||
}))
|
||||
}));
|
||||
|
||||
vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
@@ -17,7 +22,9 @@ vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
updateAiFields: vi.fn(),
|
||||
setDueDate: vi.fn(),
|
||||
setIntent: vi.fn(),
|
||||
dismissIntent: vi.fn()
|
||||
dismissIntent: vi.fn(),
|
||||
setStatus: mockSetStatus,
|
||||
classifyStatus: mockClassify
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -48,6 +55,9 @@ const baseNote: Note = {
|
||||
deletedAt: null,
|
||||
lastRecalledAt: null,
|
||||
recallDismissedAt: null,
|
||||
status: 'active',
|
||||
statusChangedAt: null,
|
||||
moveReason: null,
|
||||
createdAt: '2026-05-09T00:00:00Z',
|
||||
updatedAt: '2026-05-09T00:00:00Z',
|
||||
tags: [],
|
||||
@@ -79,3 +89,68 @@ describe('NoteCard — image rendering', () => {
|
||||
expect(mockOpenMedia).toHaveBeenCalledWith('media/n1/img1.png');
|
||||
});
|
||||
});
|
||||
|
||||
describe('NoteCard — ai_status=disabled fallback (v0.2.9 Cut B Task 13)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('ai_status=disabled: title fallback to raw_text first line, hide summary/tags', () => {
|
||||
const disabledNote: Note = {
|
||||
...baseNote,
|
||||
aiStatus: 'disabled',
|
||||
aiTitle: null,
|
||||
aiSummary: 'should-not-show',
|
||||
tags: [{ name: 't1', source: 'user' }],
|
||||
rawText: '첫 줄 본문\n둘째 줄 본문'
|
||||
};
|
||||
render(<NoteCard note={disabledNote} mode="inbox" onUpdated={vi.fn()} />);
|
||||
expect(screen.getByText('첫 줄 본문')).toBeInTheDocument();
|
||||
expect(screen.queryByText('should-not-show')).toBeNull();
|
||||
expect(screen.queryByText('t1')).toBeNull();
|
||||
});
|
||||
|
||||
it('ai_status=disabled: empty raw → "(빈 메모)" fallback', () => {
|
||||
const disabledNote: Note = {
|
||||
...baseNote,
|
||||
aiStatus: 'disabled',
|
||||
aiTitle: null,
|
||||
rawText: ''
|
||||
};
|
||||
render(<NoteCard note={disabledNote} mode="inbox" onUpdated={vi.fn()} />);
|
||||
expect(screen.getByText('(빈 메모)')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('NoteCard — 이동 메뉴 (v0.2.9 Cut B Task 6)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('이동 ▾ 클릭 → 현재 status 외 3개 목적지 메뉴 표시', () => {
|
||||
// baseNote.status = 'active' → 완료/보관/휴지통 만 표시
|
||||
render(<NoteCard note={baseNote} onUpdated={() => {}} mode="inbox" />);
|
||||
fireEvent.click(screen.getByRole('button', { name: '이동' }));
|
||||
expect(screen.getByRole('button', { name: '완료로 이동' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '보관으로 이동' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '휴지통으로 이동' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: '활성으로 이동' })).toBeNull();
|
||||
});
|
||||
|
||||
it('메뉴 항목 클릭 → MoveStatusModal 열림 + 확정 시 setStatus 호출', async () => {
|
||||
const onUpdated = vi.fn();
|
||||
render(<NoteCard note={baseNote} onUpdated={onUpdated} mode="inbox" />);
|
||||
fireEvent.click(screen.getByRole('button', { name: '이동' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '완료로 이동' }));
|
||||
// Modal 의 dialog role 등장
|
||||
expect(screen.getByRole('dialog', { name: '이동' })).toBeInTheDocument();
|
||||
// Modal 내부의 "완료" 버튼 클릭 → setStatus
|
||||
fireEvent.click(screen.getByRole('button', { name: '완료' }));
|
||||
await waitFor(() => {
|
||||
expect(mockSetStatus).toHaveBeenCalledWith('n1', 'completed', null);
|
||||
expect(onUpdated).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -852,3 +852,205 @@ describe('NoteRepository — failed retry helpers', () => {
|
||||
expect(repo.getTagIdByName('nothere')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('NoteRepository — setStatus + listByStatus', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
repo = new NoteRepository(db);
|
||||
});
|
||||
|
||||
it('setStatus updates status + reason + status_changed_at + updated_at', () => {
|
||||
const { id } = repo.create({ rawText: 'test' });
|
||||
repo.setStatus(id, 'completed', '결재 끝', new Date('2026-05-10T00:00:00.000Z'));
|
||||
const note = repo.findById(id)!;
|
||||
expect(note.status).toBe('completed');
|
||||
expect(note.moveReason).toBe('결재 끝');
|
||||
expect(note.statusChangedAt).toBe('2026-05-10T00:00:00.000Z');
|
||||
expect(note.updatedAt).toBe('2026-05-10T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('setStatus accepts null reason', () => {
|
||||
const { id } = repo.create({ rawText: 'test' });
|
||||
repo.setStatus(id, 'archived', null, new Date('2026-05-10T00:00:00.000Z'));
|
||||
const note = repo.findById(id)!;
|
||||
expect(note.status).toBe('archived');
|
||||
expect(note.moveReason).toBeNull();
|
||||
});
|
||||
|
||||
it('setStatus default now uses Date.now()', () => {
|
||||
const { id } = repo.create({ rawText: 'test' });
|
||||
const before = Date.now();
|
||||
repo.setStatus(id, 'completed', null);
|
||||
const after = Date.now();
|
||||
const note = repo.findById(id)!;
|
||||
const ts = new Date(note.statusChangedAt!).getTime();
|
||||
expect(ts).toBeGreaterThanOrEqual(before);
|
||||
expect(ts).toBeLessThanOrEqual(after);
|
||||
});
|
||||
|
||||
it('listByStatus filters correctly', () => {
|
||||
const idA = repo.create({ rawText: 'a' }).id;
|
||||
const idB = repo.create({ rawText: 'b' }).id;
|
||||
repo.setStatus(idB, 'archived', null, new Date('2026-05-10T00:00:00.000Z'));
|
||||
|
||||
const active = repo.listByStatus('active', { limit: 10 });
|
||||
const archived = repo.listByStatus('archived', { limit: 10 });
|
||||
expect(active.map((n) => n.id)).toContain(idA);
|
||||
expect(active.map((n) => n.id)).not.toContain(idB);
|
||||
expect(archived.map((n) => n.id)).toContain(idB);
|
||||
expect(archived.map((n) => n.id)).not.toContain(idA);
|
||||
});
|
||||
|
||||
it('listByStatus orders by status_changed_at DESC (NULL falls back to created_at)', () => {
|
||||
const a = repo.create({ rawText: 'a' }).id;
|
||||
const b = repo.create({ rawText: 'b' }).id;
|
||||
const c = repo.create({ rawText: 'c' }).id;
|
||||
repo.setStatus(a, 'completed', null, new Date('2026-05-10T00:00:00.000Z'));
|
||||
repo.setStatus(b, 'completed', null, new Date('2026-05-12T00:00:00.000Z'));
|
||||
repo.setStatus(c, 'completed', null, new Date('2026-05-11T00:00:00.000Z'));
|
||||
const r = repo.listByStatus('completed', { limit: 10 });
|
||||
expect(r.map((n) => n.id)).toEqual([b, c, a]);
|
||||
});
|
||||
|
||||
it('listByStatus respects limit (cap 200)', () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const id = repo.create({ rawText: `n${i}` }).id;
|
||||
repo.setStatus(id, 'archived', null, new Date(`2026-05-${10 + i}T00:00:00.000Z`));
|
||||
}
|
||||
expect(repo.listByStatus('archived', { limit: 3 })).toHaveLength(3);
|
||||
expect(repo.listByStatus('archived', { limit: 100 })).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('listByStatus default limit 200', () => {
|
||||
repo.create({ rawText: 'a' });
|
||||
expect(repo.listByStatus('active')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('setStatus("trashed") syncs deleted_at (backward compat)', () => {
|
||||
const { id } = repo.create({ rawText: 't' });
|
||||
repo.setStatus(id, 'trashed', null, new Date('2026-05-15T00:00:00.000Z'));
|
||||
const row = db.prepare(`SELECT deleted_at FROM notes WHERE id=?`).get(id) as {
|
||||
deleted_at: string;
|
||||
};
|
||||
expect(row.deleted_at).toBe('2026-05-15T00:00:00.000Z');
|
||||
expect(repo.findById(id)!.deletedAt).toBe('2026-05-15T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('setStatus("active") clears deleted_at (restore from trash)', () => {
|
||||
const { id } = repo.create({ rawText: 'r' });
|
||||
repo.setStatus(id, 'trashed', null, new Date('2026-05-15T00:00:00.000Z'));
|
||||
repo.setStatus(id, 'active', null, new Date('2026-05-16T00:00:00.000Z'));
|
||||
const row = db.prepare(`SELECT deleted_at FROM notes WHERE id=?`).get(id) as {
|
||||
deleted_at: string | null;
|
||||
};
|
||||
expect(row.deleted_at).toBeNull();
|
||||
expect(repo.findById(id)!.deletedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('setStatus("completed"/"archived") also clears deleted_at', () => {
|
||||
const { id } = repo.create({ rawText: 'r' });
|
||||
repo.setStatus(id, 'trashed', null, new Date('2026-05-15T00:00:00.000Z'));
|
||||
repo.setStatus(id, 'completed', null, new Date('2026-05-16T00:00:00.000Z'));
|
||||
expect(repo.findById(id)!.deletedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('newly created note hydrates as status=active', () => {
|
||||
const { id } = repo.create({ rawText: 'fresh' });
|
||||
const note = repo.findById(id)!;
|
||||
expect(note.status).toBe('active');
|
||||
expect(note.statusChangedAt).toBeNull();
|
||||
expect(note.moveReason).toBeNull();
|
||||
});
|
||||
|
||||
it('countByStatus returns accurate count per status', () => {
|
||||
const a = repo.create({ rawText: 'a' }).id; // active
|
||||
repo.create({ rawText: 'b' }); // active
|
||||
const c = repo.create({ rawText: 'c' }).id;
|
||||
repo.setStatus(c, 'completed', null, new Date('2026-05-10T00:00:00.000Z'));
|
||||
const d = repo.create({ rawText: 'd' }).id;
|
||||
repo.setStatus(d, 'archived', null, new Date('2026-05-10T00:00:00.000Z'));
|
||||
const e = repo.create({ rawText: 'e' }).id;
|
||||
repo.setStatus(e, 'trashed', null, new Date('2026-05-10T00:00:00.000Z'));
|
||||
|
||||
expect(repo.countByStatus('active')).toBe(2);
|
||||
expect(repo.countByStatus('completed')).toBe(1);
|
||||
expect(repo.countByStatus('archived')).toBe(1);
|
||||
expect(repo.countByStatus('trashed')).toBe(1);
|
||||
// sanity — a 가 여전히 active.
|
||||
expect(repo.findById(a)!.status).toBe('active');
|
||||
});
|
||||
|
||||
it('restoreNote sets status=active + clears moveReason', () => {
|
||||
const { id } = repo.create({ rawText: 'r' });
|
||||
repo.setStatus(id, 'trashed', '실수', new Date('2026-05-15T00:00:00.000Z'));
|
||||
expect(repo.findById(id)!.status).toBe('trashed');
|
||||
expect(repo.findById(id)!.moveReason).toBe('실수');
|
||||
|
||||
repo.restoreNote(id);
|
||||
|
||||
const after = repo.findById(id)!;
|
||||
expect(after.status).toBe('active');
|
||||
expect(after.moveReason).toBeNull();
|
||||
expect(after.deletedAt).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// v0.2.9 Cut B Task 16 — settings.ai_enabled OFF→ON 전환 시 disabled 메모 일괄 재투입.
|
||||
describe('NoteRepository.requeueDisabled', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
repo = new NoteRepository(db);
|
||||
});
|
||||
|
||||
it('changes ai_status="disabled" → "pending" + INSERT pending_jobs', () => {
|
||||
const { id } = repo.create({ rawText: 't', aiStatus: 'disabled' });
|
||||
const count = repo.requeueDisabled(new Date('2026-05-09T00:00:00Z'));
|
||||
expect(count).toBe(1);
|
||||
const note = repo.findById(id);
|
||||
expect(note?.aiStatus).toBe('pending');
|
||||
const job = db.prepare(`SELECT * FROM pending_jobs WHERE note_id=?`).get(id);
|
||||
expect(job).toBeDefined();
|
||||
});
|
||||
|
||||
it('does not affect non-disabled notes', () => {
|
||||
const idP = repo.create({ rawText: 'p', aiStatus: 'pending' }).id;
|
||||
const idC = repo.create({ rawText: 'c' }).id;
|
||||
repo.updateAiResult(idC, { title: 't', summary: 'a\nb\nc', tags: [], provider: 'p' });
|
||||
repo.requeueDisabled(new Date());
|
||||
expect(repo.findById(idP)?.aiStatus).toBe('pending');
|
||||
expect(repo.findById(idC)?.aiStatus).toBe('done');
|
||||
});
|
||||
|
||||
it('returns 0 when no disabled notes', () => {
|
||||
const count = repo.requeueDisabled(new Date());
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NoteRepository.countByAiStatus', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
repo = new NoteRepository(db);
|
||||
});
|
||||
|
||||
it('returns count per ai_status', () => {
|
||||
repo.create({ rawText: 'a', aiStatus: 'disabled' });
|
||||
repo.create({ rawText: 'b', aiStatus: 'disabled' });
|
||||
repo.create({ rawText: 'c', aiStatus: 'pending' });
|
||||
expect(repo.countByAiStatus('disabled')).toBe(2);
|
||||
expect(repo.countByAiStatus('pending')).toBe(1);
|
||||
expect(repo.countByAiStatus('done')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
46
tests/unit/OllamaBanner.test.tsx
Normal file
46
tests/unit/OllamaBanner.test.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { render, cleanup } from '@testing-library/react';
|
||||
|
||||
vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
inboxApi: {
|
||||
ollamaRecheck: vi.fn(async () => ({ ok: true }))
|
||||
}
|
||||
}));
|
||||
|
||||
import { OllamaBanner } from '../../src/renderer/inbox/components/OllamaBanner';
|
||||
import { useInbox } from '../../src/renderer/inbox/store';
|
||||
|
||||
describe('OllamaBanner — ai_enabled (v0.2.9 Cut B Task 14)', () => {
|
||||
beforeEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('renders nothing when ai_enabled=false (even if ollama unreachable)', () => {
|
||||
useInbox.setState({
|
||||
ai_enabled: false,
|
||||
ollamaStatus: { ok: false, reason: 'unreachable' }
|
||||
} as Partial<ReturnType<typeof useInbox.getState>>);
|
||||
const { container } = render(<OllamaBanner />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('renders nothing when ai_enabled=true and ollama ok', () => {
|
||||
useInbox.setState({
|
||||
ai_enabled: true,
|
||||
ollamaStatus: { ok: true }
|
||||
} as Partial<ReturnType<typeof useInbox.getState>>);
|
||||
const { container } = render(<OllamaBanner />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('renders banner when ai_enabled=true and ollama not ok', () => {
|
||||
useInbox.setState({
|
||||
ai_enabled: true,
|
||||
ollamaStatus: { ok: false, reason: 'unreachable' }
|
||||
} as Partial<ReturnType<typeof useInbox.getState>>);
|
||||
const { container } = render(<OllamaBanner />);
|
||||
expect(container).not.toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
58
tests/unit/OnboardingWizard.test.tsx
Normal file
58
tests/unit/OnboardingWizard.test.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
|
||||
|
||||
const { mockSetAi, mockSetCompleted } = vi.hoisted(() => ({
|
||||
mockSetAi: vi.fn(async () => ({ ok: true as const })),
|
||||
mockSetCompleted: vi.fn(async () => ({ ok: true as const }))
|
||||
}));
|
||||
vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
inboxApi: { setAiEnabled: mockSetAi, setOnboardingCompleted: mockSetCompleted }
|
||||
}));
|
||||
|
||||
import { OnboardingWizard } from '../../src/renderer/inbox/components/OnboardingWizard';
|
||||
|
||||
describe('OnboardingWizard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('renders 3 buttons + 설치 가이드 link', () => {
|
||||
render(<OnboardingWizard onClose={vi.fn()} />);
|
||||
expect(screen.getByRole('button', { name: /AI 자동 처리 사용/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /원문만 저장/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /나중에 설정/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: /ollama\.com/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('"AI 사용" → setAiEnabled(true) + setOnboardingCompleted(true) + onClose', async () => {
|
||||
const onClose = vi.fn();
|
||||
render(<OnboardingWizard onClose={onClose} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /AI 자동 처리 사용/ }));
|
||||
await waitFor(() => {
|
||||
expect(mockSetAi).toHaveBeenCalledWith(true);
|
||||
expect(mockSetCompleted).toHaveBeenCalledWith(true);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('"원문만" → setAiEnabled(false) + setOnboardingCompleted(true)', async () => {
|
||||
render(<OnboardingWizard onClose={vi.fn()} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /원문만 저장/ }));
|
||||
await waitFor(() => {
|
||||
expect(mockSetAi).toHaveBeenCalledWith(false);
|
||||
expect(mockSetCompleted).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('"나중에" → setOnboardingCompleted(true) only (no setAiEnabled)', async () => {
|
||||
render(<OnboardingWizard onClose={vi.fn()} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /나중에 설정/ }));
|
||||
await waitFor(() => {
|
||||
expect(mockSetCompleted).toHaveBeenCalledWith(true);
|
||||
expect(mockSetAi).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -40,7 +40,13 @@ vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
profileDir: '/tmp/Inkling'
|
||||
})),
|
||||
openProfileDir: vi.fn(async () => undefined),
|
||||
copyAppInfo: vi.fn(async () => undefined)
|
||||
copyAppInfo: vi.fn(async () => undefined),
|
||||
// v0.2.9 Cut B Task 15-16 — AiProviderSection 의 토글 + disabled 메모 prompt.
|
||||
getSettings: vi.fn(async () => ({ ai_enabled: true, onboarding_completed: true })),
|
||||
setAiEnabled: vi.fn(async () => ({ ok: true as const })),
|
||||
setOnboardingCompleted: vi.fn(async () => ({ ok: true as const })),
|
||||
getDisabledCount: vi.fn(async () => 0),
|
||||
enqueueDisabled: vi.fn(async () => ({ count: 0 }))
|
||||
}
|
||||
}));
|
||||
|
||||
|
||||
109
tests/unit/classifyStatus.test.ts
Normal file
109
tests/unit/classifyStatus.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { classifyStatus } from '../../src/main/ai/classifyStatus';
|
||||
import type { InferenceProvider } from '../../src/main/ai/InferenceProvider';
|
||||
|
||||
function makeProvider(generateRaw?: (p: string) => Promise<string>): InferenceProvider {
|
||||
return {
|
||||
name: 'mock',
|
||||
generate: vi.fn(async () => {
|
||||
throw new Error('not used');
|
||||
}),
|
||||
healthCheck: vi.fn(async () => ({ ok: true })),
|
||||
...(generateRaw !== undefined ? { generateRaw } : {})
|
||||
} as InferenceProvider;
|
||||
}
|
||||
|
||||
describe('classifyStatus', () => {
|
||||
it('parses recommended status and rationale from valid AI response', async () => {
|
||||
const provider = makeProvider(
|
||||
vi.fn(async () => '{"recommended":"completed","rationale":"처리됨"}')
|
||||
);
|
||||
const r = await classifyStatus({
|
||||
provider,
|
||||
rawText: 't',
|
||||
summary: '',
|
||||
reason: '결재 끝'
|
||||
});
|
||||
expect(r.recommended).toBe('completed');
|
||||
expect(r.rationale).toBe('처리됨');
|
||||
});
|
||||
|
||||
it('falls back to archived on parse failure (invalid JSON)', async () => {
|
||||
const provider = makeProvider(vi.fn(async () => 'not json'));
|
||||
const r = await classifyStatus({
|
||||
provider,
|
||||
rawText: 't',
|
||||
summary: '',
|
||||
reason: 'r'
|
||||
});
|
||||
expect(r.recommended).toBe('archived');
|
||||
expect(r.rationale).toMatch(/판단 실패|보관/);
|
||||
});
|
||||
|
||||
it('falls back to archived on invalid status value', async () => {
|
||||
const provider = makeProvider(
|
||||
vi.fn(async () => '{"recommended":"unknown","rationale":"x"}')
|
||||
);
|
||||
const r = await classifyStatus({
|
||||
provider,
|
||||
rawText: 't',
|
||||
summary: '',
|
||||
reason: 'r'
|
||||
});
|
||||
expect(r.recommended).toBe('archived');
|
||||
});
|
||||
|
||||
it('handles provider throw', async () => {
|
||||
const provider = makeProvider(
|
||||
vi.fn(async () => {
|
||||
throw new Error('network');
|
||||
})
|
||||
);
|
||||
const r = await classifyStatus({
|
||||
provider,
|
||||
rawText: 't',
|
||||
summary: '',
|
||||
reason: 'r'
|
||||
});
|
||||
expect(r.recommended).toBe('archived');
|
||||
expect(r.rationale).toMatch(/판단 실패|보관/);
|
||||
});
|
||||
|
||||
it('falls back when provider lacks generateRaw method', async () => {
|
||||
const provider = makeProvider();
|
||||
const r = await classifyStatus({
|
||||
provider,
|
||||
rawText: 't',
|
||||
summary: '',
|
||||
reason: 'r'
|
||||
});
|
||||
expect(r.recommended).toBe('archived');
|
||||
expect(r.rationale).toMatch(/판단 실패|보관/);
|
||||
});
|
||||
|
||||
it('substitutes empty inputs with placeholder text in prompt', async () => {
|
||||
const generateRaw = vi.fn(
|
||||
async (_p: string) => '{"recommended":"archived","rationale":"ok"}'
|
||||
);
|
||||
const provider = makeProvider(generateRaw);
|
||||
await classifyStatus({ provider, rawText: '', summary: '', reason: '' });
|
||||
const prompt = generateRaw.mock.calls[0]?.[0] ?? '';
|
||||
expect(prompt).toContain('(빈 메모)');
|
||||
expect(prompt).toContain('(요약 없음)');
|
||||
expect(prompt).toContain('(사유 없음)');
|
||||
});
|
||||
|
||||
it('rationale defaults to empty string when missing/non-string', async () => {
|
||||
const provider = makeProvider(
|
||||
vi.fn(async () => '{"recommended":"completed"}')
|
||||
);
|
||||
const r = await classifyStatus({
|
||||
provider,
|
||||
rawText: 't',
|
||||
summary: '',
|
||||
reason: 'r'
|
||||
});
|
||||
expect(r.recommended).toBe('completed');
|
||||
expect(r.rationale).toBe('');
|
||||
});
|
||||
});
|
||||
148
tests/unit/inboxApi-setStatus.test.ts
Normal file
148
tests/unit/inboxApi-setStatus.test.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { handlers, mockSetStatus, mockFindById, mockGenerateRaw } = vi.hoisted(() => ({
|
||||
handlers: {} as Record<string, (...args: unknown[]) => unknown>,
|
||||
mockSetStatus: vi.fn(),
|
||||
mockFindById: vi.fn(),
|
||||
mockGenerateRaw: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
default: {
|
||||
ipcMain: {
|
||||
handle: (ch: string, fn: (...args: unknown[]) => unknown) => {
|
||||
handlers[ch] = fn;
|
||||
}
|
||||
},
|
||||
dialog: {},
|
||||
shell: {}
|
||||
}
|
||||
}));
|
||||
|
||||
import { registerInboxApi } from '../../src/main/ipc/inboxApi';
|
||||
|
||||
function makeDeps(): Parameters<typeof registerInboxApi>[0] {
|
||||
// Minimal stub — `inbox:set-status` 핸들러는 deps.repo.setStatus 만 참조.
|
||||
// `ai:classify-status` 는 deps.repo.findById + deps.providerHolder.get() 사용.
|
||||
const provider = {
|
||||
name: 'mock',
|
||||
generate: vi.fn(),
|
||||
healthCheck: vi.fn(async () => ({ ok: true })),
|
||||
generateRaw: mockGenerateRaw
|
||||
};
|
||||
return {
|
||||
repo: {
|
||||
setStatus: mockSetStatus,
|
||||
findById: mockFindById,
|
||||
list: vi.fn(),
|
||||
listByStatus: vi.fn(),
|
||||
countByStatus: vi.fn(() => 0)
|
||||
} as never,
|
||||
continuity: {} as never,
|
||||
capture: {} as never,
|
||||
health: {} as never,
|
||||
intent: {} as never,
|
||||
getInboxWindow: () => null,
|
||||
settings: {} as never,
|
||||
providerHolder: { get: () => provider } as never,
|
||||
paths: { profileDir: '/profile' }
|
||||
};
|
||||
}
|
||||
|
||||
describe('inbox:set-status IPC', () => {
|
||||
beforeEach(() => {
|
||||
Object.keys(handlers).forEach((k) => delete handlers[k]);
|
||||
mockSetStatus.mockReset();
|
||||
});
|
||||
|
||||
it('forwards valid status + reason to repo.setStatus', async () => {
|
||||
registerInboxApi(makeDeps());
|
||||
const handler = handlers['inbox:set-status'];
|
||||
if (handler === undefined) throw new Error('handler not registered');
|
||||
const r = await handler(null, 'n1', 'completed', '결재 끝');
|
||||
expect(r).toEqual({ ok: true });
|
||||
expect(mockSetStatus).toHaveBeenCalledWith('n1', 'completed', '결재 끝');
|
||||
});
|
||||
|
||||
it('forwards null reason as-is', async () => {
|
||||
registerInboxApi(makeDeps());
|
||||
const handler = handlers['inbox:set-status'];
|
||||
if (handler === undefined) throw new Error('handler not registered');
|
||||
const r = await handler(null, 'n1', 'archived', null);
|
||||
expect(r).toEqual({ ok: true });
|
||||
expect(mockSetStatus).toHaveBeenCalledWith('n1', 'archived', null);
|
||||
});
|
||||
|
||||
it('rejects invalid status without calling repo', async () => {
|
||||
registerInboxApi(makeDeps());
|
||||
const handler = handlers['inbox:set-status'];
|
||||
if (handler === undefined) throw new Error('handler not registered');
|
||||
const r = (await handler(null, 'n1', 'invalid', null)) as { ok: boolean; reason?: string };
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toBe('invalid status');
|
||||
expect(mockSetStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ai:classify-status IPC', () => {
|
||||
beforeEach(() => {
|
||||
Object.keys(handlers).forEach((k) => delete handlers[k]);
|
||||
mockFindById.mockReset();
|
||||
mockGenerateRaw.mockReset();
|
||||
});
|
||||
|
||||
it('uses classifyStatus with note rawText/summary', async () => {
|
||||
mockFindById.mockReturnValue({
|
||||
id: 'n1',
|
||||
rawText: 'meeting notes',
|
||||
aiSummary: 's'
|
||||
});
|
||||
mockGenerateRaw.mockResolvedValue(
|
||||
'{"recommended":"completed","rationale":"끝남"}'
|
||||
);
|
||||
registerInboxApi(makeDeps());
|
||||
const handler = handlers['ai:classify-status'];
|
||||
if (handler === undefined) throw new Error('handler not registered');
|
||||
const r = (await handler(null, 'n1', '결재')) as {
|
||||
recommended: string;
|
||||
rationale: string;
|
||||
};
|
||||
expect(r.recommended).toBe('completed');
|
||||
expect(r.rationale).toBe('끝남');
|
||||
// prompt 에 rawText / summary / reason 포함
|
||||
const prompt = mockGenerateRaw.mock.calls[0]?.[0] as string;
|
||||
expect(prompt).toContain('meeting notes');
|
||||
expect(prompt).toContain('결재');
|
||||
});
|
||||
|
||||
it('returns archived fallback when note not found', async () => {
|
||||
mockFindById.mockReturnValue(null);
|
||||
registerInboxApi(makeDeps());
|
||||
const handler = handlers['ai:classify-status'];
|
||||
if (handler === undefined) throw new Error('handler not registered');
|
||||
const r = (await handler(null, 'missing', '결재')) as {
|
||||
recommended: string;
|
||||
rationale: string;
|
||||
};
|
||||
expect(r.recommended).toBe('archived');
|
||||
expect(r.rationale.length).toBeGreaterThan(0);
|
||||
expect(mockGenerateRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns archived fallback when AI throws', async () => {
|
||||
mockFindById.mockReturnValue({
|
||||
id: 'n1',
|
||||
rawText: 't',
|
||||
aiSummary: null
|
||||
});
|
||||
mockGenerateRaw.mockRejectedValue(new Error('network'));
|
||||
registerInboxApi(makeDeps());
|
||||
const handler = handlers['ai:classify-status'];
|
||||
if (handler === undefined) throw new Error('handler not registered');
|
||||
const r = (await handler(null, 'n1', 'r')) as {
|
||||
recommended: string;
|
||||
rationale: string;
|
||||
};
|
||||
expect(r.recommended).toBe('archived');
|
||||
});
|
||||
});
|
||||
80
tests/unit/m004-migration.test.ts
Normal file
80
tests/unit/m004-migration.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import Database from 'better-sqlite3';
|
||||
import { up } from '../../src/main/db/migrations/m004_status.js';
|
||||
|
||||
describe('m004 migration — status column', () => {
|
||||
let db: Database.Database;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
// m003 baseline (notes 테이블 with deleted_at, real schema 따름)
|
||||
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')),
|
||||
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
|
||||
);
|
||||
INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at, deleted_at)
|
||||
VALUES ('a', 't1', 'done', '2026-05-01T00:00:00Z', '2026-05-01T00:00:00Z', NULL),
|
||||
('b', 't2', 'done', '2026-05-01T00:00:00Z', '2026-05-01T00:00:00Z', '2026-05-08T00:00:00Z');
|
||||
`);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('adds status / status_changed_at / move_reason columns', () => {
|
||||
up(db);
|
||||
const cols = db.prepare(`PRAGMA table_info(notes)`).all() as Array<{ name: string }>;
|
||||
const names = cols.map((c) => c.name);
|
||||
expect(names).toContain('status');
|
||||
expect(names).toContain('status_changed_at');
|
||||
expect(names).toContain('move_reason');
|
||||
});
|
||||
|
||||
it('default status="active" for non-deleted notes', () => {
|
||||
up(db);
|
||||
const a = db.prepare(`SELECT status FROM notes WHERE id=?`).get('a') as { status: string };
|
||||
expect(a.status).toBe('active');
|
||||
});
|
||||
|
||||
it('migrates deleted_at != NULL to status="trashed" + status_changed_at', () => {
|
||||
up(db);
|
||||
const b = db
|
||||
.prepare(`SELECT status, status_changed_at FROM notes WHERE id=?`)
|
||||
.get('b') as { status: string; status_changed_at: string };
|
||||
expect(b.status).toBe('trashed');
|
||||
expect(b.status_changed_at).toBe('2026-05-08T00:00:00Z');
|
||||
});
|
||||
|
||||
it('move_reason NULL by default', () => {
|
||||
up(db);
|
||||
const a = db.prepare(`SELECT move_reason FROM notes WHERE id=?`).get('a') as {
|
||||
move_reason: string | null;
|
||||
};
|
||||
expect(a.move_reason).toBeNull();
|
||||
});
|
||||
|
||||
it('version exported as 4', async () => {
|
||||
const mod = await import('../../src/main/db/migrations/m004_status.js');
|
||||
expect(mod.version).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -51,11 +51,11 @@ describe('migration v3 — soft delete columns', () => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('user_version reaches 3', () => {
|
||||
it('user_version reaches latest (5)', () => {
|
||||
const db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
const row = db.prepare('PRAGMA user_version').get() as { user_version: number };
|
||||
expect(row.user_version).toBe(3);
|
||||
expect(row.user_version).toBe(5);
|
||||
db.close();
|
||||
});
|
||||
|
||||
@@ -73,3 +73,47 @@ describe('migration v3 — soft delete columns', () => {
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('migration v5 — ai_status disabled enum', () => {
|
||||
it("CHECK constraint accepts 'disabled'", () => {
|
||||
const db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
expect(() => {
|
||||
db.prepare(
|
||||
`INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at)
|
||||
VALUES ('d1', 't', 'disabled', '2026-05-01T00:00:00Z', '2026-05-01T00:00:00Z')`
|
||||
).run();
|
||||
}).not.toThrow();
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('preserves existing notes (status, due_date, deleted_at, recall fields)', () => {
|
||||
// m004 까지만 적용된 상태에서 데이터 insert 후 m005 까지 마이그레이션 → 데이터 보존 확인.
|
||||
// runMigrations 가 user_version 으로 idempotent 라 한 번에 5 까지 가지만,
|
||||
// 본 테스트는 single runMigrations 후 m004 시점에 가까운 row 를 넣고 cols 확인.
|
||||
const db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
db.prepare(
|
||||
`INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at, status, due_date, deleted_at)
|
||||
VALUES ('p1', 'old', 'done', '2026-04-01T00:00:00Z', '2026-04-01T00:00:00Z', 'archived', '2026-05-10', NULL)`
|
||||
).run();
|
||||
const row = db.prepare('SELECT status, due_date, ai_status FROM notes WHERE id=?').get('p1') as any;
|
||||
expect(row.status).toBe('archived');
|
||||
expect(row.due_date).toBe('2026-05-10');
|
||||
expect(row.ai_status).toBe('done');
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('preserves idx_notes_ai_status + idx_notes_created_at + idx_notes_deleted_at', () => {
|
||||
const db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
const indexes = db
|
||||
.prepare(`SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='notes'`)
|
||||
.all() as Array<{ name: string }>;
|
||||
const names = indexes.map((i) => i.name);
|
||||
expect(names).toContain('idx_notes_ai_status');
|
||||
expect(names).toContain('idx_notes_created_at');
|
||||
expect(names).toContain('idx_notes_deleted_at');
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,6 +32,7 @@ const noteStub = (id: string): Note => ({
|
||||
userIntent: null, intentPromptedAt: null,
|
||||
dueDate: '2026-04-20', dueDateEditedByUser: false,
|
||||
deletedAt: null, lastRecalledAt: null, recallDismissedAt: null,
|
||||
status: 'active', statusChangedAt: null, moveReason: null,
|
||||
createdAt: '2026-05-01T00:00:00Z', updatedAt: '2026-05-01T00:00:00Z',
|
||||
tags: [], media: []
|
||||
});
|
||||
|
||||
@@ -37,7 +37,8 @@ const note = (id: string): Note => ({
|
||||
dueDate: null, dueDateEditedByUser: false,
|
||||
userIntent: null, intentPromptedAt: null,
|
||||
createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
|
||||
deletedAt: null, lastRecalledAt: null, recallDismissedAt: null
|
||||
deletedAt: null, lastRecalledAt: null, recallDismissedAt: null,
|
||||
status: 'active', statusChangedAt: null, moveReason: null
|
||||
});
|
||||
|
||||
describe('store recall actions', () => {
|
||||
|
||||
@@ -21,6 +21,9 @@ function sample(id: string, tags: string[]): Note {
|
||||
deletedAt: null,
|
||||
lastRecalledAt: null,
|
||||
recallDismissedAt: null,
|
||||
status: 'active',
|
||||
statusChangedAt: null,
|
||||
moveReason: null,
|
||||
createdAt: '2026-04-26T00:00:00Z',
|
||||
updatedAt: '2026-04-26T00:00:00Z',
|
||||
tags: tags.map((name) => ({ name, source: 'ai' as const })),
|
||||
|
||||
@@ -30,6 +30,7 @@ const noteStub = (id: string, deletedAt: string | null = null): Note => ({
|
||||
userIntent: null, intentPromptedAt: null,
|
||||
dueDate: null, dueDateEditedByUser: false,
|
||||
deletedAt, lastRecalledAt: null, recallDismissedAt: null,
|
||||
status: deletedAt ? 'trashed' : 'active', statusChangedAt: deletedAt, moveReason: null,
|
||||
createdAt: '2026-05-01T00:00:00Z', updatedAt: '2026-05-01T00:00:00Z',
|
||||
tags: [], media: []
|
||||
});
|
||||
|
||||
86
tests/unit/store.view.test.ts
Normal file
86
tests/unit/store.view.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import type { Note } from '@shared/types';
|
||||
|
||||
const mockApi = {
|
||||
listNotes: vi.fn(async () => [] as Note[]),
|
||||
listTrash: vi.fn(async () => [] as Note[]),
|
||||
listByStatus: vi.fn(async () => [] as Note[]),
|
||||
countsByStatus: vi.fn(async () => ({ active: 0, completed: 0, archived: 0, trashed: 0 })),
|
||||
getTrashCount: vi.fn(async () => 0),
|
||||
getContinuity: vi.fn(async () => ({
|
||||
weekStart: '', weekCount: 0, weekTarget: 7,
|
||||
consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null
|
||||
})),
|
||||
getPendingCount: vi.fn(async () => 0),
|
||||
getOllamaStatus: vi.fn(async () => ({ ok: true })),
|
||||
getTodayCount: vi.fn(async () => 0),
|
||||
getFailedCount: vi.fn(async () => 0),
|
||||
listExpired: vi.fn(async () => [] as Note[]),
|
||||
listRecallCandidate: vi.fn(async () => null),
|
||||
restoreNote: vi.fn(async () => {}),
|
||||
permanentDeleteNote: vi.fn(async () => ({ confirmed: true })),
|
||||
emptyTrash: vi.fn(async () => ({ confirmed: true, count: 0 })),
|
||||
trashExpiredBatch: vi.fn(async () => ({ confirmed: true, trashedCount: 0 })),
|
||||
onNoteUpdated: vi.fn(() => () => {}),
|
||||
updateAiFields: vi.fn(async () => {}),
|
||||
setDueDate: vi.fn(async () => {}),
|
||||
setIntent: vi.fn(async () => {}),
|
||||
dismissIntent: vi.fn(async () => {}),
|
||||
ollamaRecheck: vi.fn(async () => ({ ok: true })),
|
||||
retryAllFailed: vi.fn(async () => {}),
|
||||
markRecallOpened: vi.fn(async () => {}),
|
||||
dismissRecall: vi.fn(async () => {}),
|
||||
emitRecallSnoozed: vi.fn(async () => {})
|
||||
};
|
||||
|
||||
vi.mock('../../src/renderer/inbox/api.js', () => ({ inboxApi: mockApi }));
|
||||
|
||||
describe('inbox store — view enum', () => {
|
||||
beforeEach(async () => {
|
||||
const { useInbox } = await import('../../src/renderer/inbox/store.js');
|
||||
useInbox.setState({
|
||||
view: 'inbox',
|
||||
counts: { active: 0, completed: 0, archived: 0, trashed: 0 },
|
||||
notes: [], trashNotes: [], trashCount: 0,
|
||||
showTrash: false, showSettings: false,
|
||||
loading: false, tagFilter: null, pendingCount: 0, todayCount: 0,
|
||||
ollamaStatus: { ok: true },
|
||||
continuity: { weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null },
|
||||
expiredCandidates: [], expiredSnoozeUntilMs: null,
|
||||
failedCount: 0, recallCandidate: null, recallSnoozeUntilMs: null
|
||||
});
|
||||
Object.values(mockApi).forEach((fn) => 'mockClear' in fn && (fn as any).mockClear());
|
||||
});
|
||||
|
||||
it('initial view is inbox', async () => {
|
||||
const { useInbox } = await import('../../src/renderer/inbox/store.js');
|
||||
expect(useInbox.getState().view).toBe('inbox');
|
||||
});
|
||||
|
||||
it('setView changes view', async () => {
|
||||
const { useInbox } = await import('../../src/renderer/inbox/store.js');
|
||||
useInbox.getState().setView('completed');
|
||||
expect(useInbox.getState().view).toBe('completed');
|
||||
});
|
||||
|
||||
it('counts initialized to zero per status', async () => {
|
||||
const { useInbox } = await import('../../src/renderer/inbox/store.js');
|
||||
expect(useInbox.getState().counts).toEqual({ active: 0, completed: 0, archived: 0, trashed: 0 });
|
||||
});
|
||||
|
||||
it('backward-compat: showTrash mirrors view==="trash"', async () => {
|
||||
const { useInbox } = await import('../../src/renderer/inbox/store.js');
|
||||
useInbox.getState().setView('trash');
|
||||
expect(useInbox.getState().showTrash).toBe(true);
|
||||
useInbox.getState().setView('inbox');
|
||||
expect(useInbox.getState().showTrash).toBe(false);
|
||||
});
|
||||
|
||||
it('backward-compat: showSettings mirrors view==="settings"', async () => {
|
||||
const { useInbox } = await import('../../src/renderer/inbox/store.js');
|
||||
useInbox.getState().setView('settings');
|
||||
expect(useInbox.getState().showSettings).toBe(true);
|
||||
useInbox.getState().setView('inbox');
|
||||
expect(useInbox.getState().showSettings).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user