From 4b25ba3b11c6d8b568b8f4c27fe61f3c74298a8b Mon Sep 17 00:00:00 2001 From: altair823 Date: Fri, 24 Apr 2026 19:26:00 +0000 Subject: [PATCH] Add vertical slice implementation plan 28-task TDD plan covering project bootstrap, data layer, AI pipeline, Quick Capture + Inbox UI, tray, media GC, and E2E smoke test. Each task is bite-sized with red/green/commit discipline. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-04-24-inkling-vertical-slice.md | 3759 +++++++++++++++++ 1 file changed, 3759 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-24-inkling-vertical-slice.md diff --git a/docs/superpowers/plans/2026-04-24-inkling-vertical-slice.md b/docs/superpowers/plans/2026-04-24-inkling-vertical-slice.md new file mode 100644 index 0000000..583fe93 --- /dev/null +++ b/docs/superpowers/plans/2026-04-24-inkling-vertical-slice.md @@ -0,0 +1,3759 @@ +# Inkling Vertical Slice Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement the Inkling Vertical Slice v0.1 — a Windows/macOS Electron desktop app that captures text + pasted images via a global hotkey, persists them to SQLite, asynchronously generates Korean title/summary/tags via a local Ollama (`gemma4:9b`) provider, and displays results in an Inbox with inline editing and a simple streak badge. + +**Architecture:** Electron main/renderer split with typed IPC. Main process hosts services (HotkeyService, CaptureService, NoteRepository, MediaStore, AiWorker, StreakService) around a single `better-sqlite3` database per profile. Renderer is React + Zustand with two windows (Inbox, QuickCapture). AI calls go through an `InferenceProvider` interface whose only slice implementation is `LocalOllamaProvider` (HTTP JSON to `localhost:11434`). + +**Tech Stack:** Node.js 24.15.0 LTS · Electron 41 · electron-vite 2 · React 19 · TypeScript 6 · Zustand 5 · better-sqlite3 12 · zod 4 · undici 8 (HTTP mocking) · Vitest 4 · @playwright/test 1.59 · electron-log 5. + +**Spec reference:** `docs/superpowers/specs/2026-04-24-inkling-vertical-slice-design.md` + +--- + +## File Structure + +``` +inkling/ +├── .nvmrc # already exists (24.15.0) +├── package.json # Task 1 +├── tsconfig.json # Task 1 +├── tsconfig.node.json # Task 1 +├── electron.vite.config.ts # Task 1 +├── vitest.config.ts # Task 1 +├── playwright.config.ts # Task 28 +├── .gitignore # Task 1 +├── src/ +│ ├── shared/ +│ │ └── types.ts # Task 3 shared IPC/domain types +│ ├── preload/ +│ │ └── index.ts # Task 3 contextBridge +│ ├── main/ +│ │ ├── index.ts # Task 2 entry, app lifecycle +│ │ ├── logger.ts # Task 4 +│ │ ├── paths.ts # Task 5 +│ │ ├── windows/ +│ │ │ ├── inboxWindow.ts # Task 2 +│ │ │ └── quickCaptureWindow.ts # Task 17 +│ │ ├── db/ +│ │ │ ├── index.ts # Task 6 connection factory +│ │ │ └── migrations/ +│ │ │ ├── index.ts # Task 6 runner +│ │ │ └── m001_initial.ts # Task 6 +│ │ ├── repository/ +│ │ │ └── NoteRepository.ts # Task 7 +│ │ ├── services/ +│ │ │ ├── MediaStore.ts # Task 8 +│ │ │ ├── StreakService.ts # Task 9 +│ │ │ ├── CaptureService.ts # Task 14 +│ │ │ ├── HotkeyService.ts # Task 16 +│ │ │ ├── HealthChecker.ts # Task 25 +│ │ │ └── MediaGc.ts # Task 27 +│ │ ├── ai/ +│ │ │ ├── schema.ts # Task 10 +│ │ │ ├── InferenceProvider.ts # Task 11 +│ │ │ ├── LocalOllamaProvider.ts # Task 12 +│ │ │ ├── prompt.ts # Task 10 +│ │ │ └── AiWorker.ts # Task 13 +│ │ ├── ipc/ +│ │ │ ├── captureApi.ts # Task 15 +│ │ │ └── inboxApi.ts # Task 19 +│ │ └── tray.ts # Task 26 +│ └── renderer/ +│ ├── inbox/ +│ │ ├── index.html # Task 20 +│ │ ├── main.tsx # Task 20 +│ │ ├── App.tsx # Task 20 +│ │ ├── store.ts # Task 20 +│ │ ├── api.ts # Task 20 preload api wrapper +│ │ └── components/ +│ │ ├── NoteCard.tsx # Task 21 +│ │ ├── EditableField.tsx # Task 22 +│ │ ├── StreakBadge.tsx # Task 24 +│ │ ├── PendingBanner.tsx # Task 24 +│ │ └── OllamaBanner.tsx # Task 25 +│ └── quickcapture/ +│ ├── index.html # Task 18 +│ ├── main.tsx # Task 18 +│ ├── App.tsx # Task 18 +│ └── api.ts # Task 18 +└── tests/ + ├── unit/ + │ ├── NoteRepository.test.ts # Task 7 + │ ├── MediaStore.test.ts # Task 8 + │ ├── StreakService.test.ts # Task 9 + │ ├── ai-schema.test.ts # Task 10 + │ ├── LocalOllamaProvider.test.ts # Task 12 + │ ├── AiWorker.test.ts # Task 13 + │ └── CaptureService.test.ts # Task 14 + ├── integration/ + │ └── ollama-golden.test.ts # Task 12 (opt-in) + └── e2e/ + └── smoke.spec.ts # Task 28 +``` + +--- + +## Task 1: Bootstrap project + +**Files:** +- Create: `package.json` +- Create: `tsconfig.json` +- Create: `tsconfig.node.json` +- Create: `electron.vite.config.ts` +- Create: `vitest.config.ts` +- Create: `.gitignore` + +- [ ] **Step 1: Initialize `package.json`** + +Write `package.json`: + +```json +{ + "name": "inkling", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "out/main/index.js", + "scripts": { + "dev": "electron-vite dev", + "build": "electron-vite build", + "start": "electron-vite preview", + "test": "vitest run", + "test:watch": "vitest", + "test:integration": "INKLING_INTEGRATION=1 vitest run tests/integration", + "test:e2e": "playwright test", + "typecheck": "tsc --noEmit" + } +} +``` + +- [ ] **Step 2: Install runtime and dev dependencies** + +Run: + +```bash +npm install --save-exact \ + electron@41.3.0 \ + react@19.2.5 react-dom@19.2.5 \ + zustand@5.0.12 \ + better-sqlite3@12.9.0 \ + zod@4.3.6 \ + electron-log@5.2.0 \ + uuid@11.0.3 + +npm install --save-exact --save-dev \ + typescript@6.0.3 \ + @types/node@24.0.0 \ + @types/react@19.0.0 @types/react-dom@19.0.0 \ + @types/better-sqlite3@7.6.11 \ + @types/uuid@11.0.0 \ + electron-vite@2.3.0 \ + vite@6.0.3 \ + @vitejs/plugin-react@4.3.4 \ + vitest@4.1.5 \ + undici@8.1.0 \ + @playwright/test@1.59.1 +``` + +If any version fails (not yet published), run `npm view version` and use the latest instead, then update §7.2 of the spec. + +- [ ] **Step 3: Create `tsconfig.json`** + +```json +{ + "compilerOptions": { + "target": "ES2023", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "noImplicitOverride": true, + "noUncheckedIndexedAccess": true, + "esModuleInterop": true, + "skipLibCheck": true, + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "isolatedModules": true, + "types": ["node"], + "baseUrl": ".", + "paths": { + "@shared/*": ["src/shared/*"], + "@main/*": ["src/main/*"] + } + }, + "include": ["src/**/*", "tests/**/*"], + "exclude": ["node_modules", "out", "dist"] +} +``` + +- [ ] **Step 4: Create `tsconfig.node.json`** + +```json +{ + "compilerOptions": { + "target": "ES2023", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["electron.vite.config.ts", "vitest.config.ts", "playwright.config.ts"] +} +``` + +- [ ] **Step 5: Create `electron.vite.config.ts`** + +```ts +import { defineConfig, externalizeDepsPlugin } from 'electron-vite'; +import react from '@vitejs/plugin-react'; +import { resolve } from 'node:path'; + +export default defineConfig({ + main: { + plugins: [externalizeDepsPlugin()], + build: { + rollupOptions: { + input: { index: resolve(__dirname, 'src/main/index.ts') } + } + }, + resolve: { + alias: { + '@shared': resolve(__dirname, 'src/shared'), + '@main': resolve(__dirname, 'src/main') + } + } + }, + preload: { + plugins: [externalizeDepsPlugin()], + build: { + rollupOptions: { + input: { index: resolve(__dirname, 'src/preload/index.ts') } + } + }, + resolve: { + alias: { '@shared': resolve(__dirname, 'src/shared') } + } + }, + renderer: { + plugins: [react()], + resolve: { + alias: { '@shared': resolve(__dirname, 'src/shared') } + }, + build: { + rollupOptions: { + input: { + inbox: resolve(__dirname, 'src/renderer/inbox/index.html'), + quickcapture: resolve(__dirname, 'src/renderer/quickcapture/index.html') + } + } + } + } +}); +``` + +- [ ] **Step 6: Create `vitest.config.ts`** + +```ts +import { defineConfig } from 'vitest/config'; +import { resolve } from 'node:path'; + +export default defineConfig({ + test: { + environment: 'node', + globals: false, + include: ['tests/unit/**/*.test.ts'], + exclude: ['tests/integration/**', 'tests/e2e/**'], + coverage: { + reporter: ['text', 'html'] + } + }, + resolve: { + alias: { + '@shared': resolve(__dirname, 'src/shared'), + '@main': resolve(__dirname, 'src/main') + } + } +}); +``` + +- [ ] **Step 7: Create `.gitignore`** + +``` +node_modules/ +out/ +dist/ +*.log +.vite/ +.DS_Store +coverage/ +playwright-report/ +test-results/ +``` + +- [ ] **Step 8: Verify toolchain** + +Run: +```bash +npx tsc --version +npx vitest --version +npx electron --version +``` +Expected: versions matching `package.json` (TypeScript 6.x, Vitest 4.x, Electron 41.x). + +- [ ] **Step 9: Commit** + +```bash +git add package.json package-lock.json tsconfig.json tsconfig.node.json electron.vite.config.ts vitest.config.ts .gitignore +git commit -m "chore: bootstrap inkling project with electron-vite, vitest, react, sqlite, zod" +``` + +--- + +## Task 2: Electron main entry + Inbox window shell + +**Files:** +- Create: `src/main/index.ts` +- Create: `src/main/windows/inboxWindow.ts` +- Create: `src/renderer/inbox/index.html` + +- [ ] **Step 1: Create placeholder Inbox HTML** + +Write `src/renderer/inbox/index.html`: + +```html + + + + + + Inkling + + +
+ + + +``` + +(`main.tsx` will be created in Task 20; electron-vite dev server tolerates the missing module by showing a build error, which is fine for this step. To make Task 2 runnable without Task 20, temporarily insert a minimal placeholder script tag — see Step 2.) + +- [ ] **Step 2: Replace the script tag with an inline placeholder until Task 20** + +Change the script line in `index.html` to: + +```html + +``` + +Task 20 will restore the module script. + +- [ ] **Step 3: Implement `inboxWindow.ts`** + +Write `src/main/windows/inboxWindow.ts`: + +```ts +import { BrowserWindow, app } from 'electron'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +let inboxWindow: BrowserWindow | null = null; + +const __dirname = fileURLToPath(new URL('.', import.meta.url)); + +export function getInboxWindow(): BrowserWindow | null { + return inboxWindow; +} + +export function createInboxWindow(): BrowserWindow { + if (inboxWindow && !inboxWindow.isDestroyed()) { + inboxWindow.show(); + inboxWindow.focus(); + return inboxWindow; + } + + inboxWindow = new BrowserWindow({ + width: 900, + height: 720, + show: false, + webPreferences: { + preload: join(__dirname, '../preload/index.js'), + contextIsolation: true, + nodeIntegration: false, + sandbox: false + } + }); + + if (process.env.ELECTRON_RENDERER_URL) { + inboxWindow.loadURL(`${process.env.ELECTRON_RENDERER_URL}/inbox/index.html`); + } else { + inboxWindow.loadFile(join(__dirname, '../renderer/inbox/index.html')); + } + + inboxWindow.on('close', (e) => { + if (!app.isQuitting) { + e.preventDefault(); + inboxWindow?.hide(); + } + }); + + inboxWindow.once('ready-to-show', () => { + inboxWindow?.show(); + }); + + return inboxWindow; +} +``` + +- [ ] **Step 4: Declare the custom `isQuitting` flag** + +Create `src/shared/types.ts` minimal first version: + +```ts +declare global { + namespace Electron { + interface App { + isQuitting?: boolean; + } + } +} + +export {}; +``` + +- [ ] **Step 5: Implement `main/index.ts`** + +Write `src/main/index.ts`: + +```ts +import { app, BrowserWindow } from 'electron'; +import '@shared/types'; +import { createInboxWindow } from './windows/inboxWindow.js'; + +app.whenReady().then(() => { + createInboxWindow(); + + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) { + createInboxWindow(); + } + }); +}); + +app.on('before-quit', () => { + app.isQuitting = true; +}); + +app.on('window-all-closed', () => { + // Keep the app running in the tray on non-macOS too (tray added in Task 26). + if (process.platform !== 'darwin') { + // Intentionally do NOT quit here. + } +}); +``` + +- [ ] **Step 6: Run dev and verify window opens** + +Run: `npm run dev` + +Expected: Electron launches, an Inbox window opens showing "Inkling Inbox (renderer pending)". Close the window; the process stays alive (no quit). + +Stop with `Ctrl+C`. + +- [ ] **Step 7: Commit** + +```bash +git add src/main/index.ts src/main/windows/inboxWindow.ts src/renderer/inbox/index.html src/shared/types.ts +git commit -m "feat(main): add Electron entry and Inbox window shell" +``` + +--- + +## Task 3: Preload typed bridge skeleton + +**Files:** +- Create: `src/preload/index.ts` +- Modify: `src/shared/types.ts` + +- [ ] **Step 1: Extend shared types with domain + IPC interfaces** + +Replace `src/shared/types.ts` with: + +```ts +declare global { + namespace Electron { + interface App { + isQuitting?: boolean; + } + } + + interface Window { + inkling: InklingApi; + } +} + +export interface NoteMedia { + id: string; + kind: 'image'; + relPath: string; + mime: string; + bytes: number; +} + +export type AiStatus = 'pending' | 'done' | 'failed'; + +export interface NoteTag { + name: string; + source: 'ai' | 'user'; +} + +export interface Note { + id: string; + rawText: string; + aiTitle: string | null; + aiSummary: string | null; + aiStatus: AiStatus; + aiError: string | null; + aiProvider: string | null; + aiGeneratedAt: string | null; + createdAt: string; + updatedAt: string; + tags: NoteTag[]; + media: NoteMedia[]; +} + +export interface StreakInfo { + current: number; + longest: number; +} + +export interface CaptureApi { + submit(payload: { text: string; images: ArrayBuffer[] }): Promise<{ noteId: string }>; + hide(): void; +} + +export interface InboxApi { + listNotes(opts: { limit: number; cursor?: string }): Promise; + updateAiFields( + noteId: string, + fields: { title?: string; summary?: string; tags?: string[] } + ): Promise; + deleteNote(noteId: string): Promise; + getStreak(): Promise; + getPendingCount(): Promise; + getOllamaStatus(): Promise<{ ok: boolean; reason?: string }>; + onNoteUpdated(cb: (note: Note) => void): () => void; +} + +export interface InklingApi { + capture: CaptureApi; + inbox: InboxApi; +} + +export {}; +``` + +- [ ] **Step 2: Implement the preload bridge** + +Write `src/preload/index.ts`: + +```ts +import { contextBridge, ipcRenderer } from 'electron'; +import type { InklingApi, Note } from '@shared/types'; + +const api: InklingApi = { + capture: { + submit: (payload) => ipcRenderer.invoke('capture:submit', payload), + hide: () => ipcRenderer.send('capture:hide') + }, + inbox: { + listNotes: (opts) => ipcRenderer.invoke('inbox:list', opts), + updateAiFields: (noteId, fields) => + ipcRenderer.invoke('inbox:updateAi', { noteId, fields }), + deleteNote: (noteId) => ipcRenderer.invoke('inbox:delete', noteId), + getStreak: () => ipcRenderer.invoke('inbox:streak'), + getPendingCount: () => ipcRenderer.invoke('inbox:pendingCount'), + getOllamaStatus: () => ipcRenderer.invoke('inbox:ollamaStatus'), + onNoteUpdated: (cb) => { + const listener = (_e: unknown, note: Note) => cb(note); + ipcRenderer.on('note:updated', listener); + return () => ipcRenderer.off('note:updated', listener); + } + } +}; + +contextBridge.exposeInMainWorld('inkling', api); +``` + +- [ ] **Step 3: Run typecheck** + +Run: `npm run typecheck` + +Expected: exit 0. + +- [ ] **Step 4: Commit** + +```bash +git add src/preload/index.ts src/shared/types.ts +git commit -m "feat(preload): expose typed inkling API bridge (capture + inbox)" +``` + +--- + +## Task 4: Logger with PII protection + +**Files:** +- Create: `src/main/logger.ts` + +- [ ] **Step 1: Implement the logger** + +Write `src/main/logger.ts`: + +```ts +import log from 'electron-log/main'; +import { app } from 'electron'; +import { join } from 'node:path'; +import { createHash } from 'node:crypto'; + +let initialized = false; + +export function initLogger(): void { + if (initialized) return; + const logDir = join(app.getPath('userData'), 'Inkling', 'logs'); + log.transports.file.resolvePathFn = () => + join(logDir, `main-${new Date().toISOString().slice(0, 10)}.log`); + log.transports.file.maxSize = 5 * 1024 * 1024; + log.transports.file.level = 'info'; + log.transports.console.level = process.env.INKLING_DEBUG === '1' ? 'debug' : 'info'; + initialized = true; +} + +export function hashPrefix(text: string): string { + return createHash('sha256').update(text).digest('hex').slice(0, 8); +} + +export const logger = { + info: (msg: string, meta?: Record) => log.info(msg, meta ?? {}), + warn: (msg: string, meta?: Record) => log.warn(msg, meta ?? {}), + error: (msg: string, meta?: Record) => log.error(msg, meta ?? {}), + debug: (msg: string, meta?: Record) => log.debug(msg, meta ?? {}) +}; +``` + +- [ ] **Step 2: Wire logger init in main entry** + +Modify `src/main/index.ts`, insert at the top of `app.whenReady().then(...)` body: + +```ts +import { initLogger, logger } from './logger.js'; +``` + +and inside `whenReady`: + +```ts +initLogger(); +logger.info('app.start', { platform: process.platform, version: app.getVersion() }); +``` + +- [ ] **Step 3: Run dev and verify log file appears** + +Run: `npm run dev`. After the window shows, quit the app (`Ctrl+C`). + +Check: `ls %APPDATA%\Inkling\logs\` on Windows or `ls ~/Library/Application\ Support/Inkling/logs/` on macOS shows `main-YYYY-MM-DD.log` with an `app.start` entry. + +- [ ] **Step 4: Commit** + +```bash +git add src/main/logger.ts src/main/index.ts +git commit -m "feat(logger): add electron-log with daily rotation and PII-safe helpers" +``` + +--- + +## Task 5: Paths utility + +**Files:** +- Create: `src/main/paths.ts` + +- [ ] **Step 1: Implement paths resolver** + +Write `src/main/paths.ts`: + +```ts +import { app } from 'electron'; +import { join } from 'node:path'; +import { mkdirSync } from 'node:fs'; + +export interface ProfilePaths { + profileDir: string; + dbFile: string; + mediaDir: string; +} + +export function resolveProfilePaths(profile = 'default'): ProfilePaths { + const root = join(app.getPath('userData'), 'Inkling', 'profiles', profile); + const profilePaths: ProfilePaths = { + profileDir: root, + dbFile: join(root, 'inkling.sqlite'), + mediaDir: join(root, 'media') + }; + mkdirSync(profilePaths.mediaDir, { recursive: true }); + return profilePaths; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/main/paths.ts +git commit -m "feat(paths): resolve per-profile data directory layout" +``` + +--- + +## Task 6: DB initialization + migration framework + +**Files:** +- Create: `src/main/db/index.ts` +- Create: `src/main/db/migrations/index.ts` +- Create: `src/main/db/migrations/m001_initial.ts` + +- [ ] **Step 1: Write the initial migration** + +Write `src/main/db/migrations/m001_initial.ts`: + +```ts +import type Database from 'better-sqlite3'; + +export const version = 1; + +export function up(db: Database.Database): void { + 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, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE INDEX idx_notes_created_at ON notes(created_at DESC); + CREATE INDEX idx_notes_ai_status ON notes(ai_status); + + CREATE TABLE tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE COLLATE NOCASE + ); + + CREATE TABLE note_tags ( + note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE, + tag_id INTEGER NOT NULL REFERENCES tags(id), + source TEXT NOT NULL CHECK (source IN ('ai','user')), + PRIMARY KEY (note_id, tag_id) + ); + + CREATE TABLE media ( + id TEXT PRIMARY KEY, + note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK (kind IN ('image')), + rel_path TEXT NOT NULL, + mime TEXT NOT NULL, + bytes INTEGER NOT NULL, + created_at TEXT NOT NULL + ); + CREATE INDEX idx_media_note_id ON media(note_id); + + CREATE TABLE pending_jobs ( + note_id TEXT PRIMARY KEY REFERENCES notes(id) ON DELETE CASCADE, + attempts INTEGER NOT NULL DEFAULT 0, + next_run_at TEXT NOT NULL, + last_error TEXT + ); + `); +} +``` + +- [ ] **Step 2: Write the migration runner** + +Write `src/main/db/migrations/index.ts`: + +```ts +import type Database from 'better-sqlite3'; +import * as m001 from './m001_initial.js'; + +const migrations = [m001]; + +export function runMigrations(db: Database.Database): void { + const currentRow = db.prepare('PRAGMA user_version').get() as { user_version: number }; + const current = currentRow.user_version ?? 0; + for (const m of migrations) { + if (m.version > current) { + const tx = db.transaction(() => { + m.up(db); + db.pragma(`user_version = ${m.version}`); + }); + tx(); + } + } +} +``` + +- [ ] **Step 3: Write the db connection factory** + +Write `src/main/db/index.ts`: + +```ts +import Database from 'better-sqlite3'; +import { runMigrations } from './migrations/index.js'; + +export function openDb(dbFile: string): Database.Database { + const db = new Database(dbFile); + db.pragma('journal_mode = WAL'); + db.pragma('foreign_keys = ON'); + runMigrations(db); + return db; +} +``` + +- [ ] **Step 4: Write a smoke test for the migration runner** + +Write `tests/unit/migrations.test.ts`: + +```ts +import { describe, it, expect } from 'vitest'; +import Database from 'better-sqlite3'; +import { runMigrations } from '@main/db/migrations/index.js'; + +describe('migrations', () => { + it('creates schema at version 1 from fresh db', () => { + const db = new Database(':memory:'); + runMigrations(db); + const ver = (db.prepare('PRAGMA user_version').get() as { user_version: number }).user_version; + expect(ver).toBe(1); + const tables = db + .prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") + .all() + .map((r: any) => r.name); + expect(tables).toEqual( + expect.arrayContaining(['notes', 'note_tags', 'tags', 'media', 'pending_jobs']) + ); + db.close(); + }); + + it('is idempotent', () => { + const db = new Database(':memory:'); + runMigrations(db); + runMigrations(db); + const ver = (db.prepare('PRAGMA user_version').get() as { user_version: number }).user_version; + expect(ver).toBe(1); + db.close(); + }); +}); +``` + +- [ ] **Step 5: Run the test** + +Run: `npx vitest run tests/unit/migrations.test.ts` + +Expected: 2 tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/main/db/ tests/unit/migrations.test.ts +git commit -m "feat(db): add sqlite migration framework with v1 initial schema" +``` + +--- + +## Task 7: NoteRepository + +**Files:** +- Create: `src/main/repository/NoteRepository.ts` +- Create: `tests/unit/NoteRepository.test.ts` + +- [ ] **Step 1: Write the failing test for create + findById** + +Write `tests/unit/NoteRepository.test.ts`: + +```ts +import { describe, it, expect, beforeEach } from 'vitest'; +import Database from 'better-sqlite3'; +import { runMigrations } from '@main/db/migrations/index.js'; +import { NoteRepository } from '@main/repository/NoteRepository.js'; + +function freshDb() { + const db = new Database(':memory:'); + runMigrations(db); + return db; +} + +describe('NoteRepository', () => { + let db: Database.Database; + let repo: NoteRepository; + + beforeEach(() => { + db = freshDb(); + repo = new NoteRepository(db); + }); + + it('creates a note with pending ai_status and enqueues a pending_job atomically', () => { + const { id } = repo.create({ rawText: '회의 메모 test' }); + const row = db.prepare('SELECT * FROM notes WHERE id=?').get(id) as any; + expect(row.raw_text).toBe('회의 메모 test'); + expect(row.ai_status).toBe('pending'); + const job = db.prepare('SELECT * FROM pending_jobs WHERE note_id=?').get(id); + expect(job).toBeDefined(); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `npx vitest run tests/unit/NoteRepository.test.ts` + +Expected: FAIL with "Cannot find module '@main/repository/NoteRepository.js'". + +- [ ] **Step 3: Implement `NoteRepository` (create + findById)** + +Write `src/main/repository/NoteRepository.ts`: + +```ts +import type Database from 'better-sqlite3'; +import { v7 as uuidv7, v4 as uuidv4 } from 'uuid'; +import type { Note, NoteMedia, NoteTag } from '@shared/types'; + +export interface CreateNoteInput { + rawText: string; +} + +export interface NewMediaRow { + noteId: string; + kind: 'image'; + relPath: string; + mime: string; + bytes: number; +} + +export class NoteRepository { + constructor(private db: Database.Database) {} + + create(input: CreateNoteInput): { id: string } { + const id = uuidv7(); + const now = new Date().toISOString(); + 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); + }); + tx(); + return { id }; + } + + insertMedia(rows: NewMediaRow[]): void { + if (rows.length === 0) return; + const now = new Date().toISOString(); + const stmt = this.db.prepare( + `INSERT INTO media (id, note_id, kind, rel_path, mime, bytes, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ); + const tx = this.db.transaction(() => { + for (const r of rows) { + stmt.run(uuidv4(), r.noteId, r.kind, r.relPath, r.mime, r.bytes, now); + } + }); + tx(); + } + + findById(id: string): Note | null { + const row = this.db.prepare('SELECT * FROM notes WHERE id=?').get(id) as any; + if (!row) return null; + return this.hydrate(row); + } + + list(opts: { limit: number; cursor?: string }): Note[] { + const limit = Math.max(1, Math.min(200, opts.limit)); + const rows = opts.cursor + ? (this.db + .prepare( + `SELECT * FROM notes WHERE created_at < ? ORDER BY created_at DESC LIMIT ?` + ) + .all(opts.cursor, limit) as any[]) + : (this.db + .prepare(`SELECT * FROM notes ORDER BY created_at DESC LIMIT ?`) + .all(limit) as any[]); + return rows.map((r) => this.hydrate(r)); + } + + updateAiResult( + id: string, + result: { title: string; summary: string; tags: string[]; provider: string } + ): void { + const now = new Date().toISOString(); + const tx = this.db.transaction(() => { + this.db + .prepare( + `UPDATE notes + SET ai_title=?, ai_summary=?, ai_status='done', + ai_provider=?, ai_generated_at=?, ai_error=NULL, updated_at=? + WHERE id=?` + ) + .run(result.title, result.summary, result.provider, now, now, id); + this.db + .prepare( + `DELETE FROM note_tags + WHERE note_id=? AND source='ai'` + ) + .run(id); + const getOrInsertTag = this.db.prepare( + `INSERT INTO tags(name) VALUES(?) ON CONFLICT(name) DO UPDATE SET name=name RETURNING id` + ); + const linkTag = this.db.prepare( + `INSERT OR IGNORE INTO note_tags(note_id, tag_id, source) VALUES(?, ?, 'ai')` + ); + for (const t of result.tags) { + const tagRow = getOrInsertTag.get(t) as { id: number }; + linkTag.run(id, tagRow.id); + } + this.db.prepare(`DELETE FROM pending_jobs WHERE note_id=?`).run(id); + }); + tx(); + } + + markAiFailed(id: string, error: string): void { + const now = new Date().toISOString(); + const tx = this.db.transaction(() => { + this.db + .prepare( + `UPDATE notes SET ai_status='failed', ai_error=?, updated_at=? WHERE id=?` + ) + .run(error.slice(0, 500), now, id); + this.db.prepare(`DELETE FROM pending_jobs WHERE note_id=?`).run(id); + }); + tx(); + } + + updateUserAiFields( + id: string, + fields: { title?: string; summary?: string; tags?: string[] } + ): void { + const now = new Date().toISOString(); + const tx = this.db.transaction(() => { + const updates: string[] = []; + const params: unknown[] = []; + if (fields.title !== undefined) { + updates.push('ai_title=?'); + params.push(fields.title); + } + if (fields.summary !== undefined) { + updates.push('ai_summary=?'); + params.push(fields.summary); + } + if (updates.length > 0) { + updates.push('updated_at=?'); + params.push(now); + params.push(id); + this.db.prepare(`UPDATE notes SET ${updates.join(', ')} WHERE id=?`).run(...params); + } + if (fields.tags !== undefined) { + this.db.prepare(`DELETE FROM note_tags WHERE note_id=?`).run(id); + const getOrInsert = this.db.prepare( + `INSERT INTO tags(name) VALUES(?) ON CONFLICT(name) DO UPDATE SET name=name RETURNING id` + ); + const link = this.db.prepare( + `INSERT OR IGNORE INTO note_tags(note_id, tag_id, source) VALUES(?, ?, 'user')` + ); + for (const t of fields.tags) { + const row = getOrInsert.get(t) as { id: number }; + link.run(id, row.id); + } + } + }); + tx(); + } + + delete(id: string): void { + this.db.prepare('DELETE FROM notes WHERE id=?').run(id); + } + + getPendingCount(): number { + const row = this.db + .prepare(`SELECT COUNT(*) AS c FROM notes WHERE ai_status='pending'`) + .get() as { c: number }; + return row.c; + } + + getAllPendingJobs(): Array<{ noteId: string; attempts: number; nextRunAt: string }> { + const rows = this.db + .prepare(`SELECT note_id, attempts, next_run_at FROM pending_jobs`) + .all() as any[]; + return rows.map((r) => ({ + noteId: r.note_id, + attempts: r.attempts, + nextRunAt: r.next_run_at + })); + } + + incrementJobAttempt(noteId: string, nextRunAt: string, lastError: string): void { + this.db + .prepare( + `UPDATE pending_jobs + SET attempts = attempts + 1, + next_run_at = ?, + last_error = ? + WHERE note_id = ?` + ) + .run(nextRunAt, lastError.slice(0, 500), noteId); + } + + private hydrate(row: any): Note { + const tags = this.db + .prepare( + `SELECT t.name, nt.source + FROM note_tags nt JOIN tags t ON t.id = nt.tag_id + WHERE nt.note_id = ? ORDER BY t.name` + ) + .all(row.id) as Array<{ name: string; source: 'ai' | 'user' }>; + const media = this.db + .prepare( + `SELECT id, kind, rel_path as relPath, mime, bytes FROM media WHERE note_id=?` + ) + .all(row.id) as NoteMedia[]; + return { + id: row.id, + rawText: row.raw_text, + aiTitle: row.ai_title, + aiSummary: row.ai_summary, + aiStatus: row.ai_status, + aiError: row.ai_error, + aiProvider: row.ai_provider, + aiGeneratedAt: row.ai_generated_at, + createdAt: row.created_at, + updatedAt: row.updated_at, + tags: tags as NoteTag[], + media + }; + } +} +``` + +- [ ] **Step 4: Run first test to verify pass** + +Run: `npx vitest run tests/unit/NoteRepository.test.ts` + +Expected: first test passes. + +- [ ] **Step 5: Add remaining tests** + +Append to `tests/unit/NoteRepository.test.ts`: + +```ts + it('findById returns note with empty tags and media arrays', () => { + const { id } = repo.create({ rawText: 'hello' }); + const note = repo.findById(id); + expect(note?.tags).toEqual([]); + expect(note?.media).toEqual([]); + }); + + it('updateAiResult marks done, stores fields, replaces ai tags, and removes pending job', () => { + const { id } = repo.create({ rawText: '원문' }); + repo.updateAiResult(id, { + title: '제목', + summary: '1줄\n2줄\n3줄', + tags: ['api-timeout', 'meeting'], + provider: 'local-ollama/gemma4:9b' + }); + const note = repo.findById(id)!; + expect(note.aiStatus).toBe('done'); + expect(note.aiTitle).toBe('제목'); + expect(note.tags.map((t) => t.name).sort()).toEqual(['api-timeout', 'meeting']); + expect(note.tags.every((t) => t.source === 'ai')).toBe(true); + const job = db.prepare('SELECT * FROM pending_jobs WHERE note_id=?').get(id); + expect(job).toBeUndefined(); + }); + + it('markAiFailed truncates error to 500 chars and removes pending job', () => { + const { id } = repo.create({ rawText: 'x' }); + const longErr = 'E'.repeat(600); + repo.markAiFailed(id, longErr); + const note = repo.findById(id)!; + expect(note.aiStatus).toBe('failed'); + expect(note.aiError?.length).toBe(500); + }); + + it('updateUserAiFields replaces user-sourced tags and updates fields', () => { + const { id } = repo.create({ rawText: 'x' }); + repo.updateAiResult(id, { + title: 'ai', + summary: 'a\nb\nc', + tags: ['ai-tag'], + provider: 'p' + }); + repo.updateUserAiFields(id, { title: '편집됨', tags: ['user-tag'] }); + const note = repo.findById(id)!; + expect(note.aiTitle).toBe('편집됨'); + expect(note.tags).toEqual([{ name: 'user-tag', source: 'user' }]); + }); + + it('delete cascades note_tags, media, and pending_jobs', () => { + const { id } = repo.create({ rawText: 'x' }); + repo.insertMedia([{ noteId: id, kind: 'image', relPath: 'm/x.png', mime: 'image/png', bytes: 10 }]); + repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['z'], provider: 'p' }); + repo.delete(id); + expect(repo.findById(id)).toBeNull(); + expect(db.prepare('SELECT COUNT(*) AS c FROM media').get()).toEqual({ c: 0 }); + expect(db.prepare('SELECT COUNT(*) AS c FROM note_tags').get()).toEqual({ c: 0 }); + }); + + it('list returns notes in descending created_at', () => { + const a = repo.create({ rawText: 'a' }).id; + const b = repo.create({ rawText: 'b' }).id; + const listed = repo.list({ limit: 10 }); + expect(listed.map((n) => n.id)).toEqual([b, a]); + }); + + it('getPendingCount counts pending notes', () => { + repo.create({ rawText: 'a' }); + const { id } = repo.create({ rawText: 'b' }); + expect(repo.getPendingCount()).toBe(2); + repo.markAiFailed(id, 'err'); + expect(repo.getPendingCount()).toBe(1); + }); + + it('incrementJobAttempt bumps attempts and records last_error', () => { + const { id } = repo.create({ rawText: 'x' }); + repo.incrementJobAttempt(id, new Date().toISOString(), 'boom'); + const row = db.prepare('SELECT * FROM pending_jobs WHERE note_id=?').get(id) as any; + expect(row.attempts).toBe(1); + expect(row.last_error).toBe('boom'); + }); +``` + +- [ ] **Step 6: Run all tests** + +Run: `npx vitest run tests/unit/NoteRepository.test.ts` + +Expected: all tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add src/main/repository/NoteRepository.ts tests/unit/NoteRepository.test.ts +git commit -m "feat(repo): add NoteRepository with create/list/update/delete and job queue ops" +``` + +--- + +## Task 8: MediaStore + +**Files:** +- Create: `src/main/services/MediaStore.ts` +- Create: `tests/unit/MediaStore.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Write `tests/unit/MediaStore.test.ts`: + +```ts +import { describe, it, expect, beforeEach } from 'vitest'; +import { mkdtempSync, rmSync, readFileSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { MediaStore } from '@main/services/MediaStore.js'; + +describe('MediaStore', () => { + let tmp: string; + let store: MediaStore; + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'inkling-media-')); + store = new MediaStore(tmp); + }); + + it('saves a png and returns a relative path under media/{noteId}/', async () => { + const bytes = Buffer.from('\x89PNG\r\n\x1a\n' + 'A'.repeat(100)); + const saved = await store.saveImage('note-123', bytes, 'image/png'); + expect(saved.relPath.startsWith('media/note-123/')).toBe(true); + expect(saved.relPath.endsWith('.png')).toBe(true); + expect(saved.mime).toBe('image/png'); + expect(saved.bytes).toBe(bytes.length); + const absolute = join(tmp, saved.relPath); + expect(existsSync(absolute)).toBe(true); + expect(readFileSync(absolute).equals(bytes)).toBe(true); + }); + + it('saves multiple images with unique filenames', async () => { + const bytes = Buffer.from('abc'); + const a = await store.saveImage('n', bytes, 'image/png'); + const b = await store.saveImage('n', bytes, 'image/png'); + expect(a.relPath).not.toBe(b.relPath); + }); + + it('deleteNoteDirectory removes the note directory', async () => { + const bytes = Buffer.from('abc'); + await store.saveImage('note-x', bytes, 'image/png'); + expect(existsSync(join(tmp, 'media/note-x'))).toBe(true); + await store.deleteNoteDirectory('note-x'); + expect(existsSync(join(tmp, 'media/note-x'))).toBe(false); + }); + + it('listNoteDirs returns directory names under media/', async () => { + await store.saveImage('alpha', Buffer.from('a'), 'image/png'); + await store.saveImage('beta', Buffer.from('b'), 'image/png'); + const dirs = await store.listNoteDirs(); + expect(dirs.sort()).toEqual(['alpha', 'beta']); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `npx vitest run tests/unit/MediaStore.test.ts` + +Expected: FAIL. + +- [ ] **Step 3: Implement `MediaStore`** + +Write `src/main/services/MediaStore.ts`: + +```ts +import { mkdir, writeFile, rm, readdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { v4 as uuidv4 } from 'uuid'; + +export interface SavedMedia { + relPath: string; + mime: string; + bytes: number; +} + +export class MediaStore { + constructor(private profileDir: string) {} + + async saveImage(noteId: string, bytes: Buffer, mime: string): Promise { + const dir = join(this.profileDir, 'media', noteId); + await mkdir(dir, { recursive: true }); + const ext = mime === 'image/png' ? 'png' : mime === 'image/jpeg' ? 'jpg' : 'bin'; + const filename = `${uuidv4()}.${ext}`; + const absolute = join(dir, filename); + await writeFile(absolute, bytes); + return { + relPath: `media/${noteId}/${filename}`, + mime, + bytes: bytes.length + }; + } + + async deleteNoteDirectory(noteId: string): Promise { + await rm(join(this.profileDir, 'media', noteId), { recursive: true, force: true }); + } + + async listNoteDirs(): Promise { + const mediaRoot = join(this.profileDir, 'media'); + try { + const entries = await readdir(mediaRoot, { withFileTypes: true }); + return entries.filter((e) => e.isDirectory()).map((e) => e.name); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw err; + } + } +} +``` + +- [ ] **Step 4: Run tests** + +Run: `npx vitest run tests/unit/MediaStore.test.ts` + +Expected: all pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/services/MediaStore.ts tests/unit/MediaStore.test.ts +git commit -m "feat(media): add MediaStore for image persistence and cleanup" +``` + +--- + +## Task 9: StreakService + +**Files:** +- Create: `src/main/services/StreakService.ts` +- Create: `tests/unit/StreakService.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Write `tests/unit/StreakService.test.ts`: + +```ts +import { describe, it, expect } from 'vitest'; +import Database from 'better-sqlite3'; +import { runMigrations } from '@main/db/migrations/index.js'; +import { StreakService } from '@main/services/StreakService.js'; + +function dbWithDates(isoDates: string[]): Database.Database { + const db = new Database(':memory:'); + runMigrations(db); + const insert = db.prepare( + `INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at) + VALUES (?, ?, 'pending', ?, ?)` + ); + for (const [i, d] of isoDates.entries()) { + insert.run(`n${i}`, 'x', d, d); + } + return db; +} + +describe('StreakService', () => { + it('returns zero streak for empty db', () => { + const db = new Database(':memory:'); + runMigrations(db); + const svc = new StreakService(db, () => new Date('2026-04-24T10:00:00Z')); + expect(svc.get()).toEqual({ current: 0, longest: 0 }); + }); + + it('counts consecutive days ending today', () => { + const db = dbWithDates([ + '2026-04-22T09:00:00Z', + '2026-04-23T10:00:00Z', + '2026-04-24T11:00:00Z' + ]); + const svc = new StreakService(db, () => new Date('2026-04-24T23:00:00Z')); + expect(svc.get()).toEqual({ current: 3, longest: 3 }); + }); + + it('resets current if today has no note', () => { + const db = dbWithDates(['2026-04-22T09:00:00Z', '2026-04-23T10:00:00Z']); + const svc = new StreakService(db, () => new Date('2026-04-25T09:00:00Z')); + expect(svc.get().current).toBe(0); + expect(svc.get().longest).toBe(2); + }); + + it('computes longest across gaps', () => { + const db = dbWithDates([ + '2026-04-01T00:00:00Z', + '2026-04-02T00:00:00Z', + '2026-04-03T00:00:00Z', + '2026-04-10T00:00:00Z' + ]); + const svc = new StreakService(db, () => new Date('2026-04-10T00:00:00Z')); + expect(svc.get()).toEqual({ current: 1, longest: 3 }); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `npx vitest run tests/unit/StreakService.test.ts` + +Expected: FAIL. + +- [ ] **Step 3: Implement `StreakService`** + +Write `src/main/services/StreakService.ts`: + +```ts +import type Database from 'better-sqlite3'; +import type { StreakInfo } from '@shared/types'; + +function toDateKey(d: Date): string { + return d.toISOString().slice(0, 10); +} + +function addDays(key: string, delta: number): string { + const d = new Date(key + 'T00:00:00Z'); + d.setUTCDate(d.getUTCDate() + delta); + return toDateKey(d); +} + +export class StreakService { + constructor( + private db: Database.Database, + private now: () => Date = () => new Date() + ) {} + + get(): StreakInfo { + const rows = this.db + .prepare( + `SELECT DISTINCT strftime('%Y-%m-%d', created_at) AS d FROM notes ORDER BY d ASC` + ) + .all() as Array<{ d: string }>; + const days = rows.map((r) => r.d); + if (days.length === 0) return { current: 0, longest: 0 }; + + let longest = 1; + let run = 1; + for (let i = 1; i < days.length; i++) { + const prev = days[i - 1]!; + const cur = days[i]!; + run = addDays(prev, 1) === cur ? run + 1 : 1; + if (run > longest) longest = run; + } + + const today = toDateKey(this.now()); + const set = new Set(days); + let current = 0; + if (set.has(today)) { + current = 1; + let cursor = today; + while (set.has(addDays(cursor, -1))) { + current += 1; + cursor = addDays(cursor, -1); + } + } + + return { current, longest }; + } +} +``` + +- [ ] **Step 4: Run tests** + +Run: `npx vitest run tests/unit/StreakService.test.ts` + +Expected: all pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/services/StreakService.ts tests/unit/StreakService.test.ts +git commit -m "feat(streak): compute current and longest streak from distinct note days" +``` + +--- + +## Task 10: AI schema + prompt template + +**Files:** +- Create: `src/main/ai/schema.ts` +- Create: `src/main/ai/prompt.ts` +- Create: `tests/unit/ai-schema.test.ts` + +- [ ] **Step 1: Write failing tests for schema validator** + +Write `tests/unit/ai-schema.test.ts`: + +```ts +import { describe, it, expect } from 'vitest'; +import { parseAiResponse } from '@main/ai/schema.js'; + +describe('parseAiResponse', () => { + it('accepts valid korean title, 3-line summary, kebab tags', () => { + const r = parseAiResponse({ + title: '회의 요약', + summary: '첫 줄\n둘째 줄\n셋째 줄', + tags: ['api-timeout', 'meeting'] + }); + expect(r.title).toBe('회의 요약'); + expect(r.summary.split('\n')).toHaveLength(3); + expect(r.tags).toEqual(['api-timeout', 'meeting']); + }); + + it('rejects when title has no korean characters', () => { + expect(() => + parseAiResponse({ + title: 'English only', + summary: 'a\nb\nc', + tags: [] + }) + ).toThrow(/korean/i); + }); + + it('normalizes summary to exactly 3 lines: pads when fewer', () => { + const r = parseAiResponse({ title: '제목', summary: '한 줄', tags: [] }); + expect(r.summary.split('\n')).toHaveLength(3); + }); + + it('normalizes summary to exactly 3 lines: trims when more', () => { + const r = parseAiResponse({ + title: '제목', + summary: 'a\nb\nc\nd\ne', + tags: [] + }); + const lines = r.summary.split('\n'); + expect(lines).toHaveLength(3); + expect(lines[0]).toBe('a'); + expect(lines[2]).toBe('c d e'); + }); + + it('filters invalid tags but keeps valid ones', () => { + const r = parseAiResponse({ + title: '제목', + summary: 'a\nb\nc', + tags: ['good-tag', 'BadCase', 'has space', 'ok2', ''] + }); + expect(r.tags).toEqual(['good-tag', 'ok2']); + }); + + it('caps tags to 3', () => { + const r = parseAiResponse({ + title: '제목', + summary: 'a\nb\nc', + tags: ['a', 'b', 'c', 'd', 'e'] + }); + expect(r.tags).toHaveLength(3); + }); + + it('rejects when input is not an object', () => { + expect(() => parseAiResponse('nope')).toThrow(); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `npx vitest run tests/unit/ai-schema.test.ts` + +Expected: FAIL. + +- [ ] **Step 3: Implement `ai/schema.ts`** + +Write `src/main/ai/schema.ts`: + +```ts +import { z } from 'zod'; + +const KOREAN_REGEX = /[가-힣]/; +const KEBAB_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/; + +const RawResponseSchema = z.object({ + title: z.string().trim().min(1).max(200), + summary: z.string().min(1), + tags: z.array(z.string()).default([]) +}); + +export interface AiResponse { + title: string; + summary: string; + tags: string[]; +} + +function normalizeSummary(raw: string): string { + const lines = raw + .split(/\r?\n/) + .map((l) => l.trim()) + .filter((l) => l.length > 0); + if (lines.length === 0) throw new Error('summary is empty'); + if (lines.length === 3) return lines.join('\n'); + if (lines.length < 3) { + while (lines.length < 3) lines.push(''); + return lines.join('\n'); + } + const head = lines.slice(0, 2); + const tail = lines.slice(2).join(' '); + return [...head, tail].join('\n'); +} + +export function parseAiResponse(raw: unknown): AiResponse { + const parsed = RawResponseSchema.parse(raw); + if (!KOREAN_REGEX.test(parsed.title)) { + throw new Error('title must contain Korean characters'); + } + const title = parsed.title.slice(0, 60); + const summary = normalizeSummary(parsed.summary); + const tags = parsed.tags.filter((t) => KEBAB_REGEX.test(t)).slice(0, 3); + return { title, summary, tags }; +} +``` + +- [ ] **Step 4: Run schema tests** + +Run: `npx vitest run tests/unit/ai-schema.test.ts` + +Expected: all pass. + +- [ ] **Step 5: Add the prompt template** + +Write `src/main/ai/prompt.ts`: + +```ts +export const PROMPT_VERSION = 1; + +export function buildPrompt(rawText: string): string { + return `You organize raw personal notes into structured metadata. + +Input note (raw text, may be fragmented, any language): +--- +${rawText} +--- + +Return a JSON object with EXACTLY these keys: +- "title": concise title in KOREAN (max 60 chars) +- "summary": 3-line summary in KOREAN. Each line max 120 chars. Lines separated by "\\n". +- "tags": array of 0 to 3 tags in lowercase kebab-case (English letters and digits only, e.g., "api-timeout", "weekly-retro"). Empty array if no clear tags. + +Rules: +- title and summary MUST be written in Korean regardless of input language. +- tags MUST be English kebab-case (for consistency across notes; easier to search/group). +- Do NOT invent facts not present in the input. +- Do NOT include markdown code fences or preamble. +- Return ONLY the JSON object.`; +} +``` + +- [ ] **Step 6: Commit** + +```bash +git add src/main/ai/schema.ts src/main/ai/prompt.ts tests/unit/ai-schema.test.ts +git commit -m "feat(ai): add zod schema validator and korean-first prompt template" +``` + +--- + +## Task 11: InferenceProvider interface + +**Files:** +- Create: `src/main/ai/InferenceProvider.ts` + +- [ ] **Step 1: Implement the interface** + +Write `src/main/ai/InferenceProvider.ts`: + +```ts +import type { AiResponse } from './schema.js'; + +export interface GenerateInput { + text: string; +} + +export interface HealthResult { + ok: boolean; + model?: string; + reason?: string; +} + +export interface InferenceProvider { + readonly name: string; + generate(input: GenerateInput): Promise; + healthCheck(): Promise; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/main/ai/InferenceProvider.ts +git commit -m "feat(ai): declare InferenceProvider interface" +``` + +--- + +## Task 12: LocalOllamaProvider with undici mock + +**Files:** +- Create: `src/main/ai/LocalOllamaProvider.ts` +- Create: `tests/unit/LocalOllamaProvider.test.ts` +- Create: `tests/integration/ollama-golden.test.ts` + +- [ ] **Step 1: Write failing unit tests using undici MockAgent** + +Write `tests/unit/LocalOllamaProvider.test.ts`: + +```ts +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { MockAgent, setGlobalDispatcher, getGlobalDispatcher } from 'undici'; +import { LocalOllamaProvider } from '@main/ai/LocalOllamaProvider.js'; + +describe('LocalOllamaProvider', () => { + let mock: MockAgent; + let originalDispatcher: ReturnType; + + beforeEach(() => { + originalDispatcher = getGlobalDispatcher(); + mock = new MockAgent(); + mock.disableNetConnect(); + setGlobalDispatcher(mock); + }); + + afterEach(async () => { + setGlobalDispatcher(originalDispatcher); + await mock.close(); + }); + + it('generate: parses Ollama JSON response and returns AiResponse', async () => { + const pool = mock.get('http://localhost:11434'); + pool.intercept({ path: '/api/generate', method: 'POST' }).reply(200, { + response: JSON.stringify({ + title: '회의 요약', + summary: '첫 줄\n둘째 줄\n셋째 줄', + tags: ['api-timeout'] + }) + }); + const provider = new LocalOllamaProvider(); + const result = await provider.generate({ text: 'input text' }); + expect(result.title).toBe('회의 요약'); + expect(result.tags).toEqual(['api-timeout']); + }); + + it('generate: throws when response is not JSON', async () => { + const pool = mock.get('http://localhost:11434'); + pool.intercept({ path: '/api/generate', method: 'POST' }).reply(200, { + response: 'not json' + }); + const provider = new LocalOllamaProvider(); + await expect(provider.generate({ text: 'x' })).rejects.toThrow(/json/i); + }); + + it('generate: aborts on timeout', async () => { + const pool = mock.get('http://localhost:11434'); + pool + .intercept({ path: '/api/generate', method: 'POST' }) + .reply(() => { + return new Promise((resolve) => + setTimeout(() => resolve({ statusCode: 200, data: '{}' }), 500) + ); + }); + const provider = new LocalOllamaProvider({ timeoutMs: 50 }); + await expect(provider.generate({ text: 'x' })).rejects.toThrow(); + }, 2000); + + it('healthCheck: ok=true when gemma4:9b is in tags list', async () => { + const pool = mock.get('http://localhost:11434'); + pool.intercept({ path: '/api/tags', method: 'GET' }).reply(200, { + models: [{ name: 'gemma4:9b' }, { name: 'other:latest' }] + }); + const provider = new LocalOllamaProvider(); + const h = await provider.healthCheck(); + expect(h.ok).toBe(true); + expect(h.model).toBe('gemma4:9b'); + }); + + it('healthCheck: ok=false when model missing', async () => { + const pool = mock.get('http://localhost:11434'); + pool.intercept({ path: '/api/tags', method: 'GET' }).reply(200, { + models: [{ name: 'other:latest' }] + }); + const provider = new LocalOllamaProvider(); + const h = await provider.healthCheck(); + expect(h.ok).toBe(false); + expect(h.reason).toMatch(/gemma4:9b/); + }); + + it('healthCheck: ok=false when endpoint unreachable', async () => { + const pool = mock.get('http://localhost:11434'); + pool + .intercept({ path: '/api/tags', method: 'GET' }) + .replyWithError(new Error('ECONNREFUSED')); + const provider = new LocalOllamaProvider(); + const h = await provider.healthCheck(); + expect(h.ok).toBe(false); + expect(h.reason).toMatch(/connect|refused|unreachable/i); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `npx vitest run tests/unit/LocalOllamaProvider.test.ts` + +Expected: FAIL. + +- [ ] **Step 3: Implement `LocalOllamaProvider`** + +Write `src/main/ai/LocalOllamaProvider.ts`: + +```ts +import { request } from 'undici'; +import { parseAiResponse, type AiResponse } from './schema.js'; +import { buildPrompt } from './prompt.js'; +import type { GenerateInput, HealthResult, InferenceProvider } from './InferenceProvider.js'; + +export interface LocalOllamaOptions { + endpoint?: string; + model?: string; + timeoutMs?: number; + temperature?: number; + numPredict?: number; +} + +export class LocalOllamaProvider implements InferenceProvider { + readonly name: string; + private endpoint: string; + private model: string; + private timeoutMs: number; + private temperature: number; + private numPredict: number; + + constructor(opts: LocalOllamaOptions = {}) { + this.endpoint = opts.endpoint ?? 'http://localhost:11434'; + this.model = opts.model ?? 'gemma4:9b'; + this.timeoutMs = opts.timeoutMs ?? 120_000; + this.temperature = opts.temperature ?? 0.2; + this.numPredict = opts.numPredict ?? 512; + this.name = `local-ollama/${this.model}`; + } + + async generate(input: GenerateInput): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.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: buildPrompt(input.text), + format: 'json', + stream: false, + options: { temperature: this.temperature, num_predict: this.numPredict } + }), + signal: controller.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'); + let parsed: unknown; + try { + parsed = JSON.parse(body.response); + } catch (err) { + throw new Error(`invalid json in response: ${String(err)}`); + } + return parseAiResponse(parsed); + } finally { + clearTimeout(timer); + } + } + + async healthCheck(): Promise { + try { + const res = await request(`${this.endpoint}/api/tags`, { method: 'GET' }); + if (res.statusCode !== 200) { + return { ok: false, reason: `tags http ${res.statusCode}` }; + } + const body = (await res.body.json()) as { models?: Array<{ name: string }> }; + const found = body.models?.some((m) => m.name === this.model); + return found + ? { ok: true, model: this.model } + : { ok: false, reason: `${this.model} not installed` }; + } catch (err) { + return { ok: false, reason: `unreachable: ${(err as Error).message}` }; + } + } +} +``` + +- [ ] **Step 4: Run unit tests** + +Run: `npx vitest run tests/unit/LocalOllamaProvider.test.ts` + +Expected: all pass. + +- [ ] **Step 5: Add opt-in integration golden test** + +Write `tests/integration/ollama-golden.test.ts`: + +```ts +import { describe, it, expect, beforeAll } from 'vitest'; +import { LocalOllamaProvider } from '@main/ai/LocalOllamaProvider.js'; + +const skip = process.env.INKLING_INTEGRATION !== '1'; + +describe.skipIf(skip)('LocalOllamaProvider integration', () => { + const provider = new LocalOllamaProvider(); + + beforeAll(async () => { + const h = await provider.healthCheck(); + if (!h.ok) throw new Error(`Ollama not ready: ${h.reason}`); + }); + + const cases = [ + '회의 중 A프로젝트 API 타임아웃 문제가 재발했다는 보고를 받음. 원인 아직 미상.', + 'Stack trace: java.net.SocketTimeoutException at com.inkling.Api.call ... retried 3 times.', + '오늘 점심 김치찌개 맛있었음. 오후에 디자인 미팅 있다.' + ]; + + it.each(cases)('generates Korean title + 3-line summary for case: %s', async (input) => { + const r = await provider.generate({ text: input }); + expect(/[가-힣]/.test(r.title)).toBe(true); + expect(r.summary.split('\n')).toHaveLength(3); + for (const t of r.tags) { + expect(t).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/); + } + }, 180_000); +}); +``` + +- [ ] **Step 6: Commit** + +```bash +git add src/main/ai/LocalOllamaProvider.ts tests/unit/LocalOllamaProvider.test.ts tests/integration/ollama-golden.test.ts +git commit -m "feat(ai): add LocalOllamaProvider with 120s timeout, health check, and integration test harness" +``` + +--- + +## Task 13: AiWorker + +**Files:** +- Create: `src/main/ai/AiWorker.ts` +- Create: `tests/unit/AiWorker.test.ts` + +- [ ] **Step 1: Write failing tests** + +Write `tests/unit/AiWorker.test.ts`: + +```ts +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import Database from 'better-sqlite3'; +import { runMigrations } from '@main/db/migrations/index.js'; +import { NoteRepository } from '@main/repository/NoteRepository.js'; +import { AiWorker } from '@main/ai/AiWorker.js'; +import type { InferenceProvider } from '@main/ai/InferenceProvider.js'; +import type { AiResponse } from '@main/ai/schema.js'; + +function makeProvider(overrides: Partial = {}): InferenceProvider { + return { + name: 'mock', + generate: vi.fn(async (): Promise => ({ + title: '제목', + summary: 'a\nb\nc', + tags: ['tag'] + })), + healthCheck: vi.fn(async () => ({ ok: true })), + ...overrides + } as InferenceProvider; +} + +describe('AiWorker', () => { + let db: Database.Database; + let repo: NoteRepository; + + beforeEach(() => { + db = new Database(':memory:'); + runMigrations(db); + repo = new NoteRepository(db); + }); + + it('processes a pending job and marks note done', async () => { + const { id } = repo.create({ rawText: 'x' }); + const provider = makeProvider(); + const updates: string[] = []; + const worker = new AiWorker(repo, provider, { + backoffsMs: [0, 0, 0], + onUpdate: (note) => updates.push(note.aiStatus) + }); + await worker.enqueue(id); + await worker.drain(); + expect(repo.findById(id)?.aiStatus).toBe('done'); + expect(updates).toContain('done'); + }); + + it('retries 3 times then marks failed', async () => { + const { id } = repo.create({ rawText: 'x' }); + const provider = makeProvider({ + generate: vi.fn(async () => { + throw new Error('boom'); + }) + }); + const worker = new AiWorker(repo, provider, { backoffsMs: [0, 0, 0] }); + await worker.enqueue(id); + await worker.drain(); + const note = repo.findById(id)!; + expect(note.aiStatus).toBe('failed'); + expect(note.aiError).toContain('boom'); + expect(provider.generate).toHaveBeenCalledTimes(3); + }); + + it('loadFromDb re-queues all pending jobs on startup', async () => { + const { id: a } = repo.create({ rawText: 'a' }); + const { id: b } = repo.create({ rawText: 'b' }); + const provider = makeProvider(); + const worker = new AiWorker(repo, provider, { backoffsMs: [0, 0, 0] }); + await worker.loadFromDb(); + await worker.drain(); + expect(repo.findById(a)?.aiStatus).toBe('done'); + expect(repo.findById(b)?.aiStatus).toBe('done'); + }); + + it('processes jobs sequentially (concurrency 1)', async () => { + const ids = [repo.create({ rawText: 'a' }).id, repo.create({ rawText: 'b' }).id]; + let running = 0; + let maxConcurrent = 0; + const provider = makeProvider({ + generate: vi.fn(async () => { + running++; + maxConcurrent = Math.max(maxConcurrent, running); + await new Promise((r) => setTimeout(r, 10)); + running--; + return { title: '제목', summary: 'a\nb\nc', tags: [] }; + }) + }); + const worker = new AiWorker(repo, provider, { backoffsMs: [0, 0, 0] }); + for (const id of ids) await worker.enqueue(id); + await worker.drain(); + expect(maxConcurrent).toBe(1); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `npx vitest run tests/unit/AiWorker.test.ts` + +Expected: FAIL. + +- [ ] **Step 3: Implement `AiWorker`** + +Write `src/main/ai/AiWorker.ts`: + +```ts +import type { NoteRepository } from '../repository/NoteRepository.js'; +import type { InferenceProvider } from './InferenceProvider.js'; +import type { Note } from '@shared/types'; + +export interface AiWorkerOptions { + backoffsMs?: number[]; + onUpdate?: (note: Note) => void; + logger?: { + info: (msg: string, meta?: Record) => void; + warn: (msg: string, meta?: Record) => void; + error: (msg: string, meta?: Record) => void; + }; +} + +interface Job { + noteId: string; + attempts: number; +} + +export class AiWorker { + private queue: Job[] = []; + private running = false; + private drainResolvers: Array<() => void> = []; + private backoffsMs: number[]; + private onUpdate?: (note: Note) => void; + private logger: NonNullable; + + constructor( + private repo: NoteRepository, + private provider: InferenceProvider, + opts: AiWorkerOptions = {} + ) { + this.backoffsMs = opts.backoffsMs ?? [0, 30_000, 120_000]; + this.onUpdate = opts.onUpdate; + this.logger = opts.logger ?? { + info: () => {}, + warn: () => {}, + error: () => {} + }; + } + + async enqueue(noteId: string): Promise { + this.queue.push({ noteId, attempts: 0 }); + this.kick(); + } + + async loadFromDb(): Promise { + const jobs = this.repo.getAllPendingJobs(); + for (const j of jobs) { + this.queue.push({ noteId: j.noteId, attempts: j.attempts }); + } + this.kick(); + } + + async drain(): Promise { + if (!this.running && this.queue.length === 0) return; + await new Promise((resolve) => { + this.drainResolvers.push(resolve); + this.kick(); + }); + } + + private kick(): void { + if (this.running) return; + if (this.queue.length === 0) { + this.resolveDrainers(); + return; + } + this.running = true; + void this.loop(); + } + + private async loop(): Promise { + try { + while (this.queue.length > 0) { + const job = this.queue.shift()!; + await this.processJob(job); + } + } finally { + this.running = false; + this.resolveDrainers(); + } + } + + private resolveDrainers(): void { + const r = this.drainResolvers.splice(0); + for (const fn of r) fn(); + } + + private async processJob(job: Job): Promise { + const maxAttempts = this.backoffsMs.length; + for (let attempt = job.attempts; attempt < maxAttempts; attempt++) { + try { + const note = this.repo.findById(job.noteId); + if (!note || note.aiStatus !== 'pending') return; + const res = await this.provider.generate({ text: note.rawText }); + this.repo.updateAiResult(job.noteId, { + title: res.title, + summary: res.summary, + tags: res.tags, + provider: this.provider.name + }); + this.logger.info('ai.done', { noteId: job.noteId, attempt }); + this.emitUpdate(job.noteId); + return; + } catch (err) { + const isLast = attempt === maxAttempts - 1; + const msg = (err as Error).message; + this.logger.warn('ai.retry', { noteId: job.noteId, attempt, err: msg }); + const nextRunAt = new Date(Date.now() + (this.backoffsMs[attempt + 1] ?? 0)).toISOString(); + this.repo.incrementJobAttempt(job.noteId, nextRunAt, msg); + if (isLast) { + this.repo.markAiFailed(job.noteId, msg); + this.logger.error('ai.failed', { noteId: job.noteId, err: msg }); + this.emitUpdate(job.noteId); + return; + } + await this.sleep(this.backoffsMs[attempt + 1] ?? 0); + } + } + } + + private emitUpdate(noteId: string): void { + if (!this.onUpdate) return; + const note = this.repo.findById(noteId); + if (note) this.onUpdate(note); + } + + private sleep(ms: number): Promise { + if (ms <= 0) return Promise.resolve(); + return new Promise((r) => setTimeout(r, ms)); + } +} +``` + +- [ ] **Step 4: Run tests** + +Run: `npx vitest run tests/unit/AiWorker.test.ts` + +Expected: all pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/ai/AiWorker.ts tests/unit/AiWorker.test.ts +git commit -m "feat(ai): add AiWorker with sequential queue, 3-attempt backoff, and on-update emitter" +``` + +--- + +## Task 14: CaptureService + +**Files:** +- Create: `src/main/services/CaptureService.ts` +- Create: `tests/unit/CaptureService.test.ts` + +- [ ] **Step 1: Write failing tests** + +Write `tests/unit/CaptureService.test.ts`: + +```ts +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import Database from 'better-sqlite3'; +import { runMigrations } from '@main/db/migrations/index.js'; +import { NoteRepository } from '@main/repository/NoteRepository.js'; +import { MediaStore } from '@main/services/MediaStore.js'; +import { CaptureService } from '@main/services/CaptureService.js'; + +describe('CaptureService', () => { + let db: Database.Database; + let repo: NoteRepository; + let store: MediaStore; + let tmp: string; + let enqueued: string[]; + let svc: CaptureService; + + beforeEach(() => { + db = new Database(':memory:'); + runMigrations(db); + repo = new NoteRepository(db); + tmp = mkdtempSync(join(tmpdir(), 'inkling-capture-')); + store = new MediaStore(tmp); + enqueued = []; + svc = new CaptureService(repo, store, { + enqueue: async (id) => { + enqueued.push(id); + } + }); + }); + + it('persists text-only note and enqueues ai job', async () => { + const { noteId } = await svc.submit({ text: '안녕', images: [] }); + expect(repo.findById(noteId)?.rawText).toBe('안녕'); + expect(enqueued).toEqual([noteId]); + }); + + it('saves images under media/{noteId}/ and records in db', async () => { + const img = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]).buffer; + const { noteId } = await svc.submit({ text: 'x', images: [img] }); + const note = repo.findById(noteId)!; + expect(note.media).toHaveLength(1); + expect(note.media[0].relPath.startsWith(`media/${noteId}/`)).toBe(true); + }); + + it('rejects empty submit (no text, no images)', async () => { + await expect(svc.submit({ text: ' ', images: [] })).rejects.toThrow(/empty/i); + }); + + it('deleteNote removes db row and media directory', async () => { + const img = new Uint8Array([0, 1, 2, 3]).buffer; + const { noteId } = await svc.submit({ text: 't', images: [img] }); + await svc.deleteNote(noteId); + expect(repo.findById(noteId)).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `npx vitest run tests/unit/CaptureService.test.ts` + +Expected: FAIL. + +- [ ] **Step 3: Implement `CaptureService`** + +Write `src/main/services/CaptureService.ts`: + +```ts +import type { NoteRepository } from '../repository/NoteRepository.js'; +import type { MediaStore } from './MediaStore.js'; + +export interface CaptureDeps { + enqueue: (noteId: string) => Promise; +} + +export interface SubmitInput { + text: string; + images: ArrayBuffer[]; +} + +export class CaptureService { + constructor( + private repo: NoteRepository, + private store: MediaStore, + private deps: CaptureDeps + ) {} + + async submit(input: SubmitInput): Promise<{ noteId: string }> { + const trimmed = input.text.trim(); + if (trimmed.length === 0 && input.images.length === 0) { + throw new Error('empty submission'); + } + const { id } = this.repo.create({ rawText: input.text }); + if (input.images.length > 0) { + const rows = []; + for (const img of input.images) { + const buf = Buffer.from(img); + const saved = await this.store.saveImage(id, buf, 'image/png'); + rows.push({ + noteId: id, + kind: 'image' as const, + relPath: saved.relPath, + mime: saved.mime, + bytes: saved.bytes + }); + } + this.repo.insertMedia(rows); + } + await this.deps.enqueue(id); + return { noteId: id }; + } + + async deleteNote(noteId: string): Promise { + this.repo.delete(noteId); + await this.store.deleteNoteDirectory(noteId); + } +} +``` + +- [ ] **Step 4: Run tests** + +Run: `npx vitest run tests/unit/CaptureService.test.ts` + +Expected: all pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/services/CaptureService.ts tests/unit/CaptureService.test.ts +git commit -m "feat(capture): add CaptureService orchestrating note + media persistence and ai enqueue" +``` + +--- + +## Task 15: IPC captureApi handlers + +**Files:** +- Create: `src/main/ipc/captureApi.ts` + +- [ ] **Step 1: Implement handlers** + +Write `src/main/ipc/captureApi.ts`: + +```ts +import { ipcMain } from 'electron'; +import type { CaptureService } from '../services/CaptureService.js'; +import type { BrowserWindow } from 'electron'; + +export function registerCaptureApi( + captureService: CaptureService, + getQuickCaptureWindow: () => BrowserWindow | null +): void { + ipcMain.handle('capture:submit', async (_e, payload: { text: string; images: ArrayBuffer[] }) => { + return captureService.submit(payload); + }); + + ipcMain.on('capture:hide', () => { + getQuickCaptureWindow()?.hide(); + }); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/main/ipc/captureApi.ts +git commit -m "feat(ipc): register capture:submit and capture:hide handlers" +``` + +--- + +## Task 16: HotkeyService + +**Files:** +- Create: `src/main/services/HotkeyService.ts` + +- [ ] **Step 1: Implement the hotkey service** + +Write `src/main/services/HotkeyService.ts`: + +```ts +import { globalShortcut } from 'electron'; + +export interface HotkeyBinding { + accelerator: string; + onTrigger: () => void; +} + +export class HotkeyService { + private registered: string[] = []; + + register(binding: HotkeyBinding): { ok: boolean; reason?: string } { + const accel = binding.accelerator; + if (globalShortcut.isRegistered(accel)) { + return { ok: false, reason: `${accel} already registered by another app` }; + } + const success = globalShortcut.register(accel, binding.onTrigger); + if (!success) { + return { ok: false, reason: `failed to register ${accel}` }; + } + this.registered.push(accel); + return { ok: true }; + } + + unregisterAll(): void { + for (const a of this.registered) globalShortcut.unregister(a); + this.registered = []; + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/main/services/HotkeyService.ts +git commit -m "feat(hotkey): add HotkeyService wrapping globalShortcut with conflict reporting" +``` + +--- + +## Task 17: QuickCaptureWindow + +**Files:** +- Create: `src/main/windows/quickCaptureWindow.ts` + +- [ ] **Step 1: Implement quickCaptureWindow** + +Write `src/main/windows/quickCaptureWindow.ts`: + +```ts +import { BrowserWindow, screen } from 'electron'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +let win: BrowserWindow | null = null; +const __dirname = fileURLToPath(new URL('.', import.meta.url)); + +export function getQuickCaptureWindow(): BrowserWindow | null { + return win; +} + +export function createQuickCaptureWindow(): BrowserWindow { + if (win && !win.isDestroyed()) return win; + + const primary = screen.getPrimaryDisplay(); + const W = 640; + const H = 280; + const x = Math.round((primary.workArea.width - W) / 2 + primary.workArea.x); + const y = Math.round((primary.workArea.height - H) / 3 + primary.workArea.y); + + win = new BrowserWindow({ + width: W, + height: H, + x, + y, + frame: false, + show: false, + alwaysOnTop: true, + skipTaskbar: true, + resizable: false, + webPreferences: { + preload: join(__dirname, '../preload/index.js'), + contextIsolation: true, + nodeIntegration: false, + sandbox: false + } + }); + + if (process.env.ELECTRON_RENDERER_URL) { + win.loadURL(`${process.env.ELECTRON_RENDERER_URL}/quickcapture/index.html`); + } else { + win.loadFile(join(__dirname, '../renderer/quickcapture/index.html')); + } + + win.on('blur', () => { + if (win?.isVisible()) win.hide(); + }); + + return win; +} + +export function showQuickCapture(): void { + const w = createQuickCaptureWindow(); + w.show(); + w.focus(); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/main/windows/quickCaptureWindow.ts +git commit -m "feat(window): add frameless QuickCapture window centered on primary display" +``` + +--- + +## Task 18: QuickCapture renderer UI + +**Files:** +- Create: `src/renderer/quickcapture/index.html` +- Create: `src/renderer/quickcapture/main.tsx` +- Create: `src/renderer/quickcapture/App.tsx` +- Create: `src/renderer/quickcapture/api.ts` + +- [ ] **Step 1: HTML entry** + +Write `src/renderer/quickcapture/index.html`: + +```html + + + + + + Inkling Capture + + + +
+ + + +``` + +- [ ] **Step 2: `api.ts` wrapper** + +Write `src/renderer/quickcapture/api.ts`: + +```ts +import type { CaptureApi } from '@shared/types'; + +export const captureApi: CaptureApi = window.inkling.capture; +``` + +- [ ] **Step 3: React main** + +Write `src/renderer/quickcapture/main.tsx`: + +```tsx +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import { App } from './App.js'; + +createRoot(document.getElementById('root')!).render(); +``` + +- [ ] **Step 4: React App with paste + submit** + +Write `src/renderer/quickcapture/App.tsx`: + +```tsx +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { captureApi } from './api.js'; + +interface PastedImage { + url: string; + buffer: ArrayBuffer; +} + +export function App(): React.ReactElement { + const [text, setText] = useState(''); + const [images, setImages] = useState([]); + const [err, setErr] = useState(null); + const ref = useRef(null); + + useEffect(() => { + ref.current?.focus(); + }, []); + + const submit = useCallback(async () => { + setErr(null); + const trimmed = text.trim(); + if (trimmed.length === 0 && images.length === 0) return; + try { + await captureApi.submit({ + text, + images: images.map((i) => i.buffer) + }); + setText(''); + setImages([]); + captureApi.hide(); + } catch (e) { + setErr((e as Error).message); + } + }, [text, images]); + + const cancel = useCallback(() => { + if (text.trim().length > 5) { + const ok = window.confirm('정말 버릴까요?'); + if (!ok) return; + } + setText(''); + setImages([]); + captureApi.hide(); + }, [text]); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + cancel(); + } else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + void submit(); + } + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [cancel, submit]); + + const onPaste = useCallback(async (e: React.ClipboardEvent) => { + const items = Array.from(e.clipboardData.items); + const imgs = items.filter((i) => i.type.startsWith('image/')); + if (imgs.length === 0) return; + e.preventDefault(); + for (const it of imgs) { + const blob = it.getAsFile(); + if (!blob) continue; + const buffer = await blob.arrayBuffer(); + const url = URL.createObjectURL(blob); + setImages((prev) => [...prev, { url, buffer }]); + } + }, []); + + return ( +
+