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.
266 lines
7.5 KiB
TypeScript
266 lines
7.5 KiB
TypeScript
// src/main/services/HistoryService.ts
|
|
// SQLite 기반 전사/명령 이력 저장. 설계서 01/03 IHistoryService 구현.
|
|
|
|
import { eq, desc, like, and, sql, count } from 'drizzle-orm'
|
|
import { getDatabase } from '../db'
|
|
import { history, stats } from '../db/schema'
|
|
import type { History, NewHistory } from '../db/schema'
|
|
import { getLogger } from './LoggerService'
|
|
import { getCloudSyncService } from './CloudSyncService'
|
|
import type {
|
|
HistoryEntry,
|
|
HistoryQueryParams,
|
|
HistoryPage,
|
|
HistorySearchParams,
|
|
StatsSummary
|
|
} from '@d3ro/core/types'
|
|
|
|
const logger = getLogger('HistoryService')
|
|
|
|
class HistoryService {
|
|
create(input: Omit<NewHistory, 'id' | 'createdAt' | 'updatedAt'>): HistoryEntry {
|
|
const db = getDatabase()
|
|
const now = Date.now()
|
|
const id = crypto.randomUUID()
|
|
|
|
const entry: NewHistory = {
|
|
id,
|
|
...input,
|
|
createdAt: now,
|
|
updatedAt: now
|
|
}
|
|
|
|
db.insert(history).values(entry).run()
|
|
|
|
// stats 싱글톤 업데이트
|
|
this._updateStats(input.duration ?? 0, input.wordCount ?? 0)
|
|
|
|
logger.info(`History entry created: ${id}`)
|
|
|
|
// Phase 3.3: 로그인 상태면 자동 push (fire-and-forget, 로컬 write는 차단하지 않음)
|
|
void getCloudSyncService().pushOne('history', id)
|
|
|
|
// 비동기로 LLM 타이틀 자동 생성 (fire-and-forget)
|
|
this.generateTitle(id).catch(() => { /* ignore */ })
|
|
|
|
return this._toEntry(entry as History)
|
|
}
|
|
|
|
getById(id: string): HistoryEntry | null {
|
|
const db = getDatabase()
|
|
const row = db.select().from(history).where(eq(history.id, id)).get()
|
|
return row ? this._toEntry(row) : null
|
|
}
|
|
|
|
list(params: HistoryQueryParams): HistoryPage {
|
|
const db = getDatabase()
|
|
const { page, pageSize } = params
|
|
|
|
const totalResult = db.select({ count: count() }).from(history).get()
|
|
const total = totalResult?.count ?? 0
|
|
|
|
const entries = db
|
|
.select()
|
|
.from(history)
|
|
.orderBy(desc(history.createdAt))
|
|
.limit(pageSize)
|
|
.offset(page * pageSize)
|
|
.all()
|
|
|
|
return {
|
|
entries: entries.map((e) => this._toEntry(e)),
|
|
total,
|
|
page,
|
|
pageSize,
|
|
totalPages: Math.ceil(total / pageSize)
|
|
}
|
|
}
|
|
|
|
search(params: HistorySearchParams): HistoryPage {
|
|
const db = getDatabase()
|
|
const { query, page, pageSize } = params
|
|
const pattern = `%${query}%`
|
|
|
|
const conditions = and(
|
|
like(history.originalText, pattern)
|
|
)
|
|
|
|
const totalResult = db
|
|
.select({ count: count() })
|
|
.from(history)
|
|
.where(conditions)
|
|
.get()
|
|
const total = totalResult?.count ?? 0
|
|
|
|
const entries = db
|
|
.select()
|
|
.from(history)
|
|
.where(conditions)
|
|
.orderBy(desc(history.createdAt))
|
|
.limit(pageSize)
|
|
.offset(page * pageSize)
|
|
.all()
|
|
|
|
return {
|
|
entries: entries.map((e) => this._toEntry(e)),
|
|
total,
|
|
page,
|
|
pageSize,
|
|
totalPages: Math.ceil(total / pageSize)
|
|
}
|
|
}
|
|
|
|
delete(id: string): boolean {
|
|
const db = getDatabase()
|
|
const result = db.delete(history).where(eq(history.id, id)).run()
|
|
return result.changes > 0
|
|
}
|
|
|
|
deleteAll(): void {
|
|
const db = getDatabase()
|
|
db.delete(history).run()
|
|
logger.info('All history entries deleted')
|
|
}
|
|
|
|
getStats(): StatsSummary {
|
|
const db = getDatabase()
|
|
const row = db.select().from(stats).where(eq(stats.id, 1)).get()
|
|
|
|
const todayStart = new Date()
|
|
todayStart.setHours(0, 0, 0, 0)
|
|
const todayMs = todayStart.getTime()
|
|
|
|
const todayResult = db
|
|
.select({
|
|
sessions: count(),
|
|
duration: sql<number>`COALESCE(SUM(duration), 0)`,
|
|
words: sql<number>`COALESCE(SUM(word_count), 0)`
|
|
})
|
|
.from(history)
|
|
.where(
|
|
and(
|
|
sql`created_at >= ${todayMs}`,
|
|
eq(history.status, 'completed')
|
|
)
|
|
)
|
|
.get()
|
|
|
|
return {
|
|
totalRecordingTimeMs: (row?.totalDuration ?? 0) * 1000,
|
|
totalWordCount: row?.totalWords ?? 0,
|
|
totalSessionCount: row?.sessionCount ?? 0,
|
|
todayRecordingTimeMs: (todayResult?.duration ?? 0) * 1000,
|
|
todayWordCount: todayResult?.words ?? 0,
|
|
todaySessionCount: todayResult?.sessions ?? 0,
|
|
streakDays: row?.streakDays ?? 0
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 보존 정책에 따라 오래된 항목 정리 (30일 초과)
|
|
*/
|
|
runRetentionCleanup(): number {
|
|
const db = getDatabase()
|
|
const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000
|
|
const result = db.delete(history).where(sql`created_at < ${cutoff}`).run()
|
|
if (result.changes > 0) {
|
|
logger.info(`Retention cleanup: ${result.changes} old entries removed`)
|
|
}
|
|
return result.changes
|
|
}
|
|
|
|
dispose(): void {
|
|
logger.info('HistoryService disposed')
|
|
}
|
|
|
|
private _updateStats(duration: number, wordCount: number): void {
|
|
const db = getDatabase()
|
|
const now = Date.now()
|
|
|
|
db.update(stats)
|
|
.set({
|
|
totalDuration: sql`total_duration + ${duration}`,
|
|
totalWords: sql`total_words + ${wordCount}`,
|
|
sessionCount: sql`session_count + 1`,
|
|
lastSessionAt: now,
|
|
lastUpdated: now
|
|
})
|
|
.where(eq(stats.id, 1))
|
|
.run()
|
|
}
|
|
|
|
/** 전사 완료 후 LLM으로 자동 타이틀 생성 */
|
|
async generateTitle(id: string): Promise<string | null> {
|
|
const entry = this.getById(id)
|
|
if (!entry) return null
|
|
|
|
const text = entry.polishedText || entry.originalText
|
|
if (!text || text.length < 10) return null
|
|
|
|
try {
|
|
const { getPremiumLLMService } = await import('./PremiumLLMService')
|
|
const llm = getPremiumLLMService()
|
|
const result = await llm.generate(
|
|
text.slice(0, 2000),
|
|
{
|
|
systemPrompt: '다음 텍스트의 핵심 내용을 10단어 이내의 짧은 제목으로 만들어주세요. 제목만 출력하세요. 따옴표나 마침표를 붙이지 마세요.',
|
|
temperature: 0.3,
|
|
maxTokens: 50,
|
|
},
|
|
)
|
|
const title = result.text.trim().replace(/^["']|["']$/g, '').slice(0, 80)
|
|
if (title) {
|
|
const db = getDatabase()
|
|
db.update(history).set({ title, updatedAt: Date.now() }).where(eq(history.id, id)).run()
|
|
logger.info(`Auto title generated: ${id} → "${title}"`)
|
|
// Phase 3.3: 타이틀 업데이트 후 자동 push
|
|
void getCloudSyncService().pushOne('history', id)
|
|
return title
|
|
}
|
|
} catch (err) {
|
|
logger.warn(`Auto title generation failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
}
|
|
return null
|
|
}
|
|
|
|
private _toEntry(row: History): HistoryEntry {
|
|
return {
|
|
id: row.id,
|
|
title: row.title ?? null,
|
|
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 ?? null,
|
|
}
|
|
}
|
|
}
|
|
|
|
let instance: HistoryService | null = null
|
|
|
|
export function getHistoryService(): HistoryService {
|
|
if (!instance) {
|
|
instance = new HistoryService()
|
|
}
|
|
return instance
|
|
}
|
|
|
|
export function resetHistoryServiceForTests(): void {
|
|
instance = null
|
|
}
|