// src/main/services/MemoService.ts // Phase 10.3: 음성 메모 태그 시스템. 히스토리 항목에 태그를 부착하고 태그별 검색/내보내기를 지원한다. import { eq, and, desc, count, sql } from 'drizzle-orm' import { app } from 'electron' import path from 'path' import fs from 'fs' import { getDatabase } from '../db' import { memoTags, history } from '../db/schema' import type { MemoTagRow } from '../db/schema' import { getLogger } from './LoggerService' import { D3ROError, ErrorCode } from '@d3ro/core/errors' import type { MemoTag, TagCount, SearchByTagParams, ExportMemoParams, HistoryEntry, HistoryPage } from '@d3ro/core/types' const logger = getLogger('MemoService') class MemoService { /** * 특정 히스토리 항목에 부착된 태그 목록을 조회한다. */ getTagsForEntry(historyId: string): MemoTag[] { const db = getDatabase() const rows = db .select() .from(memoTags) .where(eq(memoTags.historyId, historyId)) .orderBy(desc(memoTags.createdAt)) .all() return rows.map((r) => this._toMemoTag(r)) } /** * 히스토리 항목에 태그를 추가한다. * 동일 historyId+tag 조합이 이미 존재하면 MemoTagDuplicate 에러를 던진다. */ addTag(historyId: string, tag: string): MemoTag { const db = getDatabase() const normalizedTag = tag.trim().toLowerCase() // 중복 검사 const existing = db .select() .from(memoTags) .where(and(eq(memoTags.historyId, historyId), eq(memoTags.tag, normalizedTag))) .get() if (existing) { throw new D3ROError( ErrorCode.MemoTagDuplicate, `Tag "${normalizedTag}" already exists for history ${historyId}` ) } const id = crypto.randomUUID() const now = Date.now() db.insert(memoTags) .values({ id, historyId, tag: normalizedTag, createdAt: now }) .run() logger.info(`Tag added: "${normalizedTag}" → history ${historyId}`) return { id, historyId, tag: normalizedTag, createdAt: now } } /** * 히스토리 항목에서 태그를 제거한다. * 존재하지 않는 태그이면 MemoTagNotFound 에러를 던진다. */ removeTag(historyId: string, tag: string): void { const db = getDatabase() const normalizedTag = tag.trim().toLowerCase() const result = db .delete(memoTags) .where(and(eq(memoTags.historyId, historyId), eq(memoTags.tag, normalizedTag))) .run() if (result.changes === 0) { throw new D3ROError( ErrorCode.MemoTagNotFound, `Tag "${normalizedTag}" not found for history ${historyId}` ) } logger.info(`Tag removed: "${normalizedTag}" from history ${historyId}`) } /** * 전체 태그 목록을 사용 횟수 내림차순으로 반환한다. */ getAllTags(): TagCount[] { const db = getDatabase() const rows = db .select({ tag: memoTags.tag, count: count() }) .from(memoTags) .groupBy(memoTags.tag) .orderBy(desc(count())) .all() return rows.map((r) => ({ tag: r.tag, count: r.count })) } /** * 특정 태그가 부착된 히스토리 항목을 페이지네이션으로 조회한다. */ searchByTag(params: SearchByTagParams): HistoryPage { const db = getDatabase() const { tag, page, pageSize } = params const normalizedTag = tag.trim().toLowerCase() const totalResult = db .select({ count: count() }) .from(memoTags) .innerJoin(history, eq(memoTags.historyId, history.id)) .where(eq(memoTags.tag, normalizedTag)) .get() const total = totalResult?.count ?? 0 const rows = db .select({ id: history.id, originalText: history.originalText, polishedText: history.polishedText, focusedApp: history.focusedApp, focusedAppName: history.focusedAppName, focusedAppWindowTitle: history.focusedAppWindowTitle, mode: history.mode, status: history.status, errorCode: history.errorCode, audioLocalPath: history.audioLocalPath, duration: history.duration, detectedLanguage: history.detectedLanguage, micDevice: history.micDevice, wordCount: history.wordCount, sttModel: history.sttModel, llmModel: history.llmModel, sttLatencyMs: history.sttLatencyMs, llmLatencyMs: history.llmLatencyMs, createdAt: history.createdAt, updatedAt: history.updatedAt, appVersion: history.appVersion }) .from(memoTags) .innerJoin(history, eq(memoTags.historyId, history.id)) .where(eq(memoTags.tag, normalizedTag)) .orderBy(desc(history.createdAt)) .limit(pageSize) .offset(page * pageSize) .all() return { entries: rows.map((r) => this._toHistoryEntry(r)), total, page, pageSize, totalPages: Math.ceil(total / pageSize) } } /** * 태그+날짜 기준으로 그룹핑된 마크다운 파일을 생성하고 파일 경로를 반환한다. */ exportMarkdown(params: ExportMemoParams): string { const db = getDatabase() // 태그별 히스토리 조회 let tagFilter = params.tag ? eq(memoTags.tag, params.tag.trim().toLowerCase()) : undefined const dateConditions: ReturnType[] = [] if (params.from) { const fromMs = new Date(params.from).getTime() dateConditions.push(sql`${history.createdAt} >= ${fromMs}`) } if (params.to) { const toMs = new Date(params.to).getTime() dateConditions.push(sql`${history.createdAt} <= ${toMs}`) } const conditions = [tagFilter, ...dateConditions].filter( (c): c is NonNullable => c !== undefined ) const whereClause = conditions.length > 0 ? and(...conditions) : undefined const rows = db .select({ tag: memoTags.tag, originalText: history.originalText, polishedText: history.polishedText, createdAt: history.createdAt, duration: history.duration }) .from(memoTags) .innerJoin(history, eq(memoTags.historyId, history.id)) .where(whereClause) .orderBy(memoTags.tag, desc(history.createdAt)) .all() // 태그별 → 날짜별 그룹핑 const grouped = new Map>() for (const row of rows) { const dateKey = new Date(row.createdAt).toISOString().split('T')[0] if (!grouped.has(row.tag)) { grouped.set(row.tag, new Map()) } const dateMap = grouped.get(row.tag)! if (!dateMap.has(dateKey)) { dateMap.set(dateKey, []) } dateMap.get(dateKey)!.push(row) } // 마크다운 생성 const lines: string[] = [] const exportDate = new Date().toISOString().split('T')[0] lines.push(`# Voice Memo Export — ${exportDate}`) lines.push('') if (grouped.size === 0) { lines.push('No memo entries found.') } for (const [tag, dateMap] of grouped) { lines.push(`## #${tag}`) lines.push('') for (const [date, entries] of dateMap) { lines.push(`### ${date}`) lines.push('') for (const entry of entries) { const time = new Date(entry.createdAt).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' }) const text = entry.polishedText ?? entry.originalText const durationSec = Math.round(entry.duration) lines.push(`- **${time}** (${durationSec}s): ${text}`) } lines.push('') } } // 파일 저장 const exportDir = path.join(app.getPath('userData'), 'exports') if (!fs.existsSync(exportDir)) { fs.mkdirSync(exportDir, { recursive: true }) } const timestamp = Date.now() const tagSuffix = params.tag ? `_${params.tag}` : '' const filePath = path.join(exportDir, `memo${tagSuffix}_${timestamp}.md`) try { fs.writeFileSync(filePath, lines.join('\n'), 'utf-8') logger.info(`Memo exported to: ${filePath}`) return filePath } catch (err) { throw new D3ROError( ErrorCode.MemoExportFailed, `Failed to export memo: ${err instanceof Error ? err.message : String(err)}` ) } } dispose(): void { logger.info('MemoService disposed') } private _toMemoTag(row: MemoTagRow): MemoTag { return { id: row.id, historyId: row.historyId, tag: row.tag, createdAt: row.createdAt } } private _toHistoryEntry(row: { id: string originalText: string polishedText: string | null focusedApp: string | null focusedAppName: string | null focusedAppWindowTitle: string | null mode: string status: string errorCode: string | null audioLocalPath: string | null duration: number detectedLanguage: string | null micDevice: string | null wordCount: number sttModel: string | null llmModel: string | null sttLatencyMs: number | null llmLatencyMs: number | null createdAt: number updatedAt: number appVersion: string }): HistoryEntry { return { id: row.id, originalText: row.originalText, polishedText: row.polishedText, focusedApp: row.focusedApp, focusedAppName: row.focusedAppName, focusedAppWindowTitle: row.focusedAppWindowTitle, mode: row.mode as HistoryEntry['mode'], status: row.status as HistoryEntry['status'], errorCode: row.errorCode, audioLocalPath: row.audioLocalPath, duration: row.duration, detectedLanguage: row.detectedLanguage, micDevice: row.micDevice, wordCount: row.wordCount, sttModel: row.sttModel, llmModel: row.llmModel, sttLatencyMs: row.sttLatencyMs, llmLatencyMs: row.llmLatencyMs, createdAt: row.createdAt, updatedAt: row.updatedAt, appVersion: row.appVersion } } } let instance: MemoService | null = null export function getMemoService(): MemoService { if (!instance) { instance = new MemoService() } return instance }