SaaS [9] — 실증 A/B/C/D 전부 통과, 로컬→클라우드 push 최초 성공(pushed=1). ## Bug 4: 로컬 nanoid PK vs Supabase UUID PK 불일치 - 증상: Push history failed: invalid input syntax for type uuid: "fvy6bIzr..." - 원인: 로컬 drizzle schema는 text PK + nanoid() 생성, Supabase는 uuid PK. 빅뱅 사이클 내내 push가 한 번도 성공한 적 없었음 (지난 pushed=0은 데이터 0건이라서). - 픽스: 로컬을 UUID로 통일 (근본 해결, 땜질 금지). 14개 서비스 20곳 nanoid() → crypto.randomUUID() 일괄 교체. nanoid 의존성 + electron.vite.config exclude 제거. drizzle schema는 text PK 그대로 유지 (SQLite는 UUID 문자열 저장 가능). ## Bug 5: supabase_realtime publication 누락 - 증상: 로그인 직후 Realtime 채널 상태: TIMED_OUT - 원인: initial_schema.sql이 transcripts 테이블만 publication에 추가. 데스크톱이 구독하는 meetings/history/dictionary는 누락 → postgres_changes 흐르지 않음. - 픽스: 20260411000002_realtime_publication.sql 신규. pg_publication_tables 카탈로그 체크 + 조건부 ADD TABLE (meetings/meeting_memos/ meeting_documents/history/dictionary 5개). supabase db push 적용. ## Bug 6: persistSession:false에서 realtime.setAuth 자동 전파 안 됨 (부분 픽스) - 픽스: CloudSyncService.startRealtime()에 client.realtime.setAuth(access_token) 명시 호출 (채널 구성 이전). - ⚠️ Bug 5+6 적용 후에도 Realtime 여전히 TIMED_OUT. 후속 조사 필요. 블로커 아님 — 주기 pull + Phase 3.3 auto push로 최종 일관성 유지. ## 실증 결과 - A 세션 자동 복원: Restored session for yunchan8804@gmail.com → DB 재오픈 - B push 경로: HistoryService created 56a767ac-... → Sync complete pushed=1 errors=0 - C 로그아웃 복귀: Realtime 종료 → users/_local/d3ro.db 복귀 → local mode - D 재로그인 복원: 실증 A의 restore 경로와 동일, 같은 uuid DB 파일 보존 - E 웹 크로스 디바이스: Phase 3.3 이후로 지연 (Realtime 이슈 별건) 검증: desktop tsc --noEmit ✅, dev 재기동 ✅, push 최초 성공 ✅
363 lines
10 KiB
TypeScript
363 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()
|
|
|
|
// 중복 검사
|
|
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<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
|
|
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
|
|
}
|