m1 — spec §5.3 dialog 버튼 순서를 impl 패턴 (`['옮기기','취소'], defaultId=1, cancelId=1`) 으로 보정. project 의 permanentDelete/emptyTrash 와 일관 (위험 액션은 default focus = 취소). m2 — telemetryEvents.test.ts 에 `expired_batch_trash` 의 extra-field 회귀 가드 추가. `expired_banner_shown` 과 대칭 (privacy invariant). m3 — ExpiryBanner.InnerProps.candidates 타입을 narrow subset → `Note` 로 통일. v0.2.4 에서 Note 타입 진화 시 silent drift 방지. m4 — onTrash 의 `void trashExpiredBatch(ids)` → `.catch((e) => console.warn(...))` 로 Promise rejection 가시화. (project-wide error toast 도입은 v0.2.4 backlog 유지) n1 — 24h+ 앱 켜둔 상태에서 snooze 자동 만료. `setTimeout(snoozeUntilMs - now)` 으로 자정 KST 시점에 force re-render. (refreshMeta trigger 의존 제거) n2 — CaptureService.listExpired 의 dedup signature reset on empty 의도 주석 1줄. future maintainer 위해. n3 (`as any[]`) 은 repo 전체 hydrate 패턴 — 단독 fix 시 inconsistency. v0.2.4 backlog #22 로 합산. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
163 lines
5.8 KiB
TypeScript
163 lines
5.8 KiB
TypeScript
import type { NoteRepository } from '../repository/NoteRepository.js';
|
|
import type { MediaStore } from './MediaStore.js';
|
|
import type { Note } from '@shared/types';
|
|
|
|
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 } }
|
|
): Promise<void>;
|
|
}
|
|
|
|
export interface CaptureDeps {
|
|
enqueue: (noteId: string) => Promise<void>;
|
|
celebrate: (noteId: string) => void;
|
|
telemetry?: TelemetryEmitter;
|
|
}
|
|
|
|
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');
|
|
}
|
|
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);
|
|
}
|
|
if (this.deps.telemetry) {
|
|
await this.deps.telemetry.emit({
|
|
kind: 'capture',
|
|
payload: {
|
|
noteId: id,
|
|
rawTextLength: input.text.length,
|
|
hasMedia: input.images.length > 0
|
|
}
|
|
}).catch(() => {});
|
|
}
|
|
await this.deps.enqueue(id);
|
|
this.deps.celebrate(id);
|
|
return { noteId: id };
|
|
}
|
|
|
|
async deleteNote(noteId: string): Promise<void> {
|
|
// 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<void> {
|
|
// 이미 active 인 노트는 telemetry emit skip — restore/trash ratio 오염 방지.
|
|
const note = this.repo.findById(noteId);
|
|
if (!note || note.deletedAt === null) return;
|
|
this.repo.restore(noteId);
|
|
if (this.deps.telemetry) {
|
|
await this.deps.telemetry.emit({ kind: 'restore', payload: { noteId } }).catch(() => {});
|
|
}
|
|
}
|
|
|
|
async permanentDeleteNote(noteId: string): Promise<void> {
|
|
// 존재하지 않는 노트는 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<Note[]> {
|
|
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;
|
|
}
|
|
}
|