feat(V2-1a): Monorepo 구조 전환 — apps/desktop으로 V1 이동
- npm workspaces 루트 (apps/*, packages/*) 세팅 - V1 전체를 apps/desktop/으로 git mv (src, resources, tests, sidecar, scripts, electron.vite.config.ts, electron-builder.yml, vitest.config.ts, tsconfig.node.json, tsconfig.web.json) - apps/desktop/package.json 신규 (name=@d3ro/desktop) - productName: 'd3ro-voice' 명시 — app.getName()을 고정하여 userData 경로 %APPDATA%\d3ro-voice\ 그대로 유지 (기존 DB/설정 연속성 보장) - 루트 package.json을 workspace 루트로 재구성, 공통 devDep만 유지 (typescript, eslint, prettier) - turbo.json, tsconfig.base.json 추가 (Turborepo 자체 설치는 별도 sub-phase) - memory/project_status.md 생성 (규칙 13) 검증: - npm run typecheck 통과 - npm run build 통과 (electron-vite main+preload+renderer) - npm run dev 실제 실행 → DB/핫키/Ollama 자동 실행 모두 정상
This commit is contained in:
parent
3a160b9032
commit
45a580878a
178 changed files with 214 additions and 0 deletions
|
|
@ -1,364 +0,0 @@
|
|||
// src/main/services/MemoService.ts
|
||||
// Phase 10.3: 음성 메모 태그 시스템. 히스토리 항목에 태그를 부착하고 태그별 검색/내보내기를 지원한다.
|
||||
|
||||
import { eq, and, desc, count, sql } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
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 '@shared/errors'
|
||||
import type {
|
||||
MemoTag,
|
||||
TagCount,
|
||||
SearchByTagParams,
|
||||
ExportMemoParams,
|
||||
HistoryEntry,
|
||||
HistoryPage
|
||||
} from '@shared/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 = nanoid()
|
||||
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue