d3ro-voice/apps/desktop/src/main/services/MemoService.ts
Yun Chan 6ba25f53b7 fix(desktop): surface configuration and provider failures instead of hiding them
Several desktop paths quietly substituted defaults or partial results: a
config write could fall back to a throwaway in-memory store, speech provider
errors were absorbed into empty transcriptions, and meeting exports built
file names from raw titles.

Writes now fail explicitly when the store is unavailable, provider and model
failures reach the UI as errors, and export names pass through one
sanitizer. Settings, license, ad, and support surfaces use the shared theme
tokens, unused hotkey helpers are gone, and the package gains strict
node/renderer typecheck configs plus red-team e2e scenarios for these flows.
2026-09-16 23:23:58 +09:00

376 lines
10 KiB
TypeScript

// 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()
if (!normalizedTag) {
throw new D3ROError(ErrorCode.ConfigInvalidValue, 'Memo tag is empty')
}
// 중복 검사
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,
title: history.title,
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,
summaryText: history.summaryText
})
.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()
// 태그별 히스토리 조회
const tagFilter = params.tag
? eq(memoTags.tag, params.tag.trim().toLowerCase())
: undefined
const dateConditions: ReturnType<typeof sql>[] = []
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<typeof c> => 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<string, Map<string, typeof rows>>()
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
title: string | null
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
summaryText: string | null
}): HistoryEntry {
return {
id: row.id,
title: row.title,
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,
summaryText: row.summaryText
}
}
}
let instance: MemoService | null = null
export function resetMemoServiceForTests(): void {
instance = null
}
export function getMemoService(): MemoService {
if (!instance) {
instance = new MemoService()
}
return instance
}