From 38a54a83b81d3f4b5fc9369eca9c1bf464f74af1 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 25 Apr 2026 12:11:43 +0900 Subject: [PATCH] feat(capture): CaptureService with enqueue + celebrate hooks Task 14 of the slice plan. Orchestrates a Quick Capture submit: trims/validates input, creates the note row + pending_jobs row in one repo.create transaction, persists each pasted PNG via MediaStore (relPath stored in media table), then awaits the injected enqueue() (AiWorker.enqueue at runtime) and fires celebrate() (NotificationService.celebrate). deleteNote drops the db row (cascading note_tags / media / pending_jobs) and removes the on-disk media directory afterwards. Verification: `npx vitest run tests/unit/CaptureService.test.ts` 4 passed. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/services/CaptureService.ts | 51 ++++++++++++++++++++++++ tests/unit/CaptureService.test.ts | 60 +++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 src/main/services/CaptureService.ts create mode 100644 tests/unit/CaptureService.test.ts diff --git a/src/main/services/CaptureService.ts b/src/main/services/CaptureService.ts new file mode 100644 index 0000000..dc78465 --- /dev/null +++ b/src/main/services/CaptureService.ts @@ -0,0 +1,51 @@ +import type { NoteRepository } from '../repository/NoteRepository.js'; +import type { MediaStore } from './MediaStore.js'; + +export interface CaptureDeps { + enqueue: (noteId: string) => Promise; + celebrate: (noteId: string) => void; +} + +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); + this.deps.celebrate(id); + return { noteId: id }; + } + + async deleteNote(noteId: string): Promise { + this.repo.delete(noteId); + await this.store.deleteNoteDirectory(noteId); + } +} diff --git a/tests/unit/CaptureService.test.ts b/tests/unit/CaptureService.test.ts new file mode 100644 index 0000000..52fd902 --- /dev/null +++ b/tests/unit/CaptureService.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, beforeEach } 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 celebrated: 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 = []; + celebrated = []; + svc = new CaptureService(repo, store, { + enqueue: async (id) => { enqueued.push(id); }, + celebrate: (id) => { celebrated.push(id); } + }); + }); + + it('persists text-only and triggers enqueue + celebrate', async () => { + const { noteId } = await svc.submit({ text: '안녕', images: [] }); + expect(repo.findById(noteId)?.rawText).toBe('안녕'); + expect(enqueued).toEqual([noteId]); + expect(celebrated).toEqual([noteId]); + }); + + it('saves images under media/{noteId}/', 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', async () => { + await expect(svc.submit({ text: ' ', images: [] })).rejects.toThrow(/empty/i); + expect(celebrated).toHaveLength(0); + }); + + it('deleteNote removes db row + media dir', 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(); + }); +});