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; } export interface TelemetryEmitter { emit(input: | { kind: 'capture'; payload: { noteId: string; rawTextLength: number; hasMedia: boolean } } | { kind: 'trash'; payload: { noteId: string } } | { kind: 'restore'; payload: { noteId: string } } | { kind: 'permanent_delete'; payload: { noteId: string } } | { kind: 'empty_trash'; payload: { count: number } } | { kind: 'expired_banner_shown'; payload: { candidateCount: number } } | { kind: 'expired_batch_trash'; payload: { count: number } } | { kind: 'ai_retry_manual'; payload: { failedCount: number } } | { kind: 'recall_opened'; payload: { noteId: string } } | { kind: 'recall_dismissed'; payload: { noteId: string } } | { kind: 'recall_shown'; payload: { noteId: string; ageDays: number } } | { kind: 'recall_snoozed'; payload: { noteId: string } } ): Promise; } export interface CaptureDeps { enqueue: (noteId: string) => Promise; 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 { text: string; images: ArrayBuffer[]; } export class CaptureService { private lastExpiredShownSig: string | null = null; 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'); } // 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) { 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); } if (this.deps.telemetry) { await this.deps.telemetry.emit({ kind: 'capture', payload: { noteId: id, rawTextLength: input.text.length, hasMedia: input.images.length > 0 } }).catch(() => {}); } if (aiEnabled) { await this.deps.enqueue(id); } this.deps.celebrate(id); return { noteId: id }; } async deleteNote(noteId: string): Promise { // v0.2.3 #4: hard delete → soft delete. media 보존 (restore 시 필요). // 이미 trash 인 노트는 telemetry emit skip — restore/trash ratio 오염 방지. const note = this.repo.findById(noteId); if (!note || note.deletedAt !== null) return; this.repo.trash(noteId, new Date().toISOString()); if (this.deps.telemetry) { await this.deps.telemetry.emit({ kind: 'trash', payload: { noteId } }).catch(() => {}); } } async restoreNote(noteId: string): Promise { // 이미 active 인 노트는 telemetry emit skip — restore/trash ratio 오염 방지. const before = this.repo.findById(noteId); if (!before || before.deletedAt === null) return; // v0.2.6 #10 — production path: repo.restoreNote (ai_status reset + pending_jobs 재생성) this.repo.restoreNote(noteId); // v0.2.6 #10 — in-memory AiWorker queue 갱신: DB 갱신만으로는 다음 앱 실행 시까지 처리 X if (before.aiStatus === 'failed' || before.aiStatus === 'pending') { await this.deps.enqueue(noteId); } if (this.deps.telemetry) { await this.deps.telemetry.emit({ kind: 'restore', payload: { noteId } }).catch(() => {}); } } async permanentDeleteNote(noteId: string): Promise { // 존재하지 않는 노트는 emit skip — 메트릭 오염 방지. const note = this.repo.findById(noteId); if (!note) return; this.repo.permanentDelete(noteId); // best-effort media cleanup — disk 실패해도 telemetry/IPC 흐름은 그대로 (orphan dir // 은 future janitor 가 정리). emptyTrash 와 동일 패턴. try { await this.store.deleteNoteDirectory(noteId); } catch { /* best-effort */ } if (this.deps.telemetry) { await this.deps.telemetry.emit({ kind: 'permanent_delete', payload: { noteId } }).catch(() => {}); } } async emptyTrash(): Promise<{ count: number }> { const { noteIds } = this.repo.emptyTrash(); for (const id of noteIds) { try { await this.store.deleteNoteDirectory(id); } catch { /* best-effort */ } } if (this.deps.telemetry) { await this.deps.telemetry.emit({ kind: 'empty_trash', payload: { count: noteIds.length } }).catch(() => {}); } return { count: noteIds.length }; } /** * 만료 후보 (due_date < today KST, active, ai_status=done) 조회. * candidates 가 비지 않고 signature 가 직전과 다르면 expired_banner_shown 자동 emit. * v0.2.3 #5 spec §6.2 — dedup 위치 main 통합. */ async listExpired(now: Date = new Date()): Promise { const candidates = this.repo.findExpiredCandidates(now); if (candidates.length === 0) { // empty → reset sig 으로 의도적: 다시 후보가 차오르면 동일 set 이라도 1회 emit. // (사용자가 "오늘 그만" 후 새 만료 노트 들어와도 셀렉션 변화로 재인식) this.lastExpiredShownSig = null; return candidates; } const sig = `${candidates.length}:${candidates.slice(0, 3).map((n) => n.id).join('-')}`; if (sig !== this.lastExpiredShownSig) { this.lastExpiredShownSig = sig; if (this.deps.telemetry) { await this.deps.telemetry.emit({ kind: 'expired_banner_shown', payload: { candidateCount: candidates.length } }).catch(() => {}); } } return candidates; } /** * 만료 후보 일괄 trash. 빈 배열은 즉시 no-op. * 성공 시 expired_batch_trash 1회 emit (per-id trash emit 은 별도 발화 안 함 — * stats.md 에서 `trash` (단건) vs `expired_batch_trash` (배치) 분리 통계). */ async trashExpiredBatch(ids: string[]): Promise<{ trashedCount: number }> { if (ids.length === 0) return { trashedCount: 0 }; const r = this.repo.trashBatch(ids, new Date().toISOString()); if (this.deps.telemetry) { await this.deps.telemetry.emit({ kind: 'expired_batch_trash', payload: { count: r.trashedCount } }).catch(() => {}); } return r; } /** * 모든 ai_status='failed' (active) 노트를 'pending' 으로 reset + worker.enqueue 재투입. * 빈 결과는 telemetry emit 안 함 (failedCount ≥ 1 invariant). * v0.2.3 #2 retry-all manual trigger. */ async retryAllFailed(): Promise<{ count: number }> { const { ids } = this.repo.retryAllFailed(new Date().toISOString()); for (const id of ids) { await this.deps.enqueue(id); } if (ids.length > 0 && this.deps.telemetry) { await this.deps.telemetry.emit({ kind: 'ai_retry_manual', payload: { failedCount: ids.length } }).catch(() => {}); } return { count: ids.length }; } /** * v0.3.9 — 단일 failed 노트 재시도. NoteCard 의 per-note "재시도" 버튼 path. * repo.retryOneFailed 후 worker.enqueue 재투입. */ async retryOneFailed(id: string): Promise<{ ok: boolean }> { const r = this.repo.retryOneFailed(id, new Date().toISOString()); if (r.ok) await this.deps.enqueue(id); return r; } /** * v0.3.9 — pending 노트 cancel. ai_status='disabled' + pending_jobs 삭제. * 사용자가 무한 pending (Ollama 끊김 등) 에서 빠져나오는 path. */ cancelPending(id: string): { ok: boolean } { return this.repo.cancelPending(id, new Date().toISOString()); } /** v0.2.3 #6 — 회상 후보 1건 fetch. */ async listRecallCandidate(): Promise { return this.repo.findRecallCandidate(); } /** v0.2.3 #6 — 회상 "열어보기" 시 last_recalled_at 갱신 + recall_opened emit. */ async markRecallOpened(noteId: string): Promise<{ note: Note }> { if (!this.repo.findById(noteId)) throw new Error(`note not found: ${noteId}`); this.repo.markRecallOpened(noteId, new Date().toISOString()); if (this.deps.telemetry) { await this.deps.telemetry.emit({ kind: 'recall_opened', payload: { noteId } }).catch(() => {}); } return { note: this.repo.findById(noteId)! }; } /** v0.2.3 #6 — 회상 "더 이상" 시 recall_dismissed_at 갱신 + recall_dismissed emit. */ async dismissRecall(noteId: string): Promise<{ note: Note }> { this.repo.dismissRecall(noteId, new Date().toISOString()); if (this.deps.telemetry) { await this.deps.telemetry.emit({ kind: 'recall_dismissed', payload: { noteId } }).catch(() => {}); } return { note: this.repo.findById(noteId)! }; } /** v0.2.3 #6 — RecallBanner 첫 렌더 시 recall_shown emit (per-note 1회 제약은 renderer 가 보장). */ async emitRecallShown(noteId: string): Promise { const note = this.repo.findById(noteId); if (!note) return; const ageDays = this.computeAgeDays(note); if (this.deps.telemetry) { await this.deps.telemetry.emit({ kind: 'recall_shown', payload: { noteId, ageDays } }).catch(() => {}); } } /** v0.2.3 #6 — 사용자 "다음에" 클릭 시 recall_snoozed emit. */ async emitRecallSnoozed(noteId: string): Promise { if (this.deps.telemetry) { await this.deps.telemetry.emit({ kind: 'recall_snoozed', payload: { noteId } }).catch(() => {}); } } /** ageDays = (now - max(last_recalled_at, created_at)) / 86_400_000, floor. */ private computeAgeDays(note: Note): number { const ref = note.lastRecalledAt ?? note.createdAt; const refMs = new Date(ref).getTime(); const nowMs = Date.now(); return Math.max(0, Math.floor((nowMs - refMs) / 86_400_000)); } }