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.
308 lines
9.7 KiB
TypeScript
308 lines
9.7 KiB
TypeScript
// src/main/services/DictationTemplateService.ts
|
|
// Phase 12.3: 딕테이션 템플릿 서비스
|
|
// 템플릿 CRUD + 세션 상태 머신 (필드별 음성 입력)
|
|
|
|
import { EventEmitter } from 'events'
|
|
import Store from 'electron-store'
|
|
import { getLogger } from './LoggerService'
|
|
import { getMainWindow } from '../windows/WindowManager'
|
|
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
|
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
|
import type {
|
|
DictationTemplate,
|
|
TemplateSessionInfo,
|
|
TemplateFieldCompletedEvent,
|
|
TemplateSessionCompletedEvent,
|
|
CreateTemplateParams,
|
|
UpdateTemplateParams,
|
|
} from '@d3ro/core/types'
|
|
|
|
const logger = getLogger('DictationTemplateService')
|
|
|
|
// ── 프리셋 템플릿 ──
|
|
const BUILTIN_TEMPLATES: DictationTemplate[] = [
|
|
{
|
|
id: 'builtin-email',
|
|
name: 'Email',
|
|
description: 'Email template with recipient, subject, and body',
|
|
fields: [
|
|
{ id: 'recipient', name: 'recipient', label: 'Recipient', promptText: 'Who is this email for?', required: true, maxDurationSec: 15 },
|
|
{ id: 'subject', name: 'subject', label: 'Subject', promptText: 'What is the subject?', required: true, maxDurationSec: 15 },
|
|
{ id: 'body', name: 'body', label: 'Body', promptText: 'Please dictate the email body.', required: true, maxDurationSec: 120 },
|
|
],
|
|
outputFormat: 'To: {{recipient}}\nSubject: {{subject}}\n\n{{body}}',
|
|
isBuiltin: true,
|
|
createdAt: Date.now(),
|
|
updatedAt: Date.now(),
|
|
},
|
|
{
|
|
id: 'builtin-meeting-notes',
|
|
name: 'Meeting Notes',
|
|
description: 'Meeting notes template',
|
|
fields: [
|
|
{ id: 'title', name: 'title', label: 'Title', promptText: 'What is the meeting title?', required: true, maxDurationSec: 15 },
|
|
{ id: 'attendees', name: 'attendees', label: 'Attendees', promptText: 'Who attended?', required: false, maxDurationSec: 30 },
|
|
{ id: 'agenda', name: 'agenda', label: 'Agenda', promptText: 'What was discussed?', required: true, maxDurationSec: 120 },
|
|
{ id: 'decisions', name: 'decisions', label: 'Decisions', promptText: 'What decisions were made?', required: false, maxDurationSec: 60 },
|
|
],
|
|
outputFormat: '# {{title}}\n\nAttendees: {{attendees}}\n\n## Agenda\n{{agenda}}\n\n## Decisions\n{{decisions}}',
|
|
isBuiltin: true,
|
|
createdAt: Date.now(),
|
|
updatedAt: Date.now(),
|
|
},
|
|
{
|
|
id: 'builtin-report',
|
|
name: 'Report',
|
|
description: 'Simple report template',
|
|
fields: [
|
|
{ id: 'title', name: 'title', label: 'Title', promptText: 'Report title?', required: true, maxDurationSec: 15 },
|
|
{ id: 'summary', name: 'summary', label: 'Summary', promptText: 'Summarize the key points.', required: true, maxDurationSec: 60 },
|
|
{ id: 'details', name: 'details', label: 'Details', promptText: 'Provide the details.', required: true, maxDurationSec: 180 },
|
|
],
|
|
outputFormat: '# {{title}}\n\n## Summary\n{{summary}}\n\n## Details\n{{details}}',
|
|
isBuiltin: true,
|
|
createdAt: Date.now(),
|
|
updatedAt: Date.now(),
|
|
},
|
|
]
|
|
|
|
type TemplateStoreSchema = {
|
|
templates: DictationTemplate[]
|
|
}
|
|
|
|
class DictationTemplateService extends EventEmitter {
|
|
private _store: Store<TemplateStoreSchema>
|
|
private _session: TemplateSessionInfo | null = null
|
|
|
|
constructor() {
|
|
super()
|
|
this._store = new Store<TemplateStoreSchema>({
|
|
name: 'dictation-templates',
|
|
defaults: {
|
|
templates: [...BUILTIN_TEMPLATES],
|
|
},
|
|
})
|
|
|
|
// 프리셋이 없으면 추가
|
|
this._ensureBuiltins()
|
|
}
|
|
|
|
// ── CRUD ──
|
|
|
|
getAll(): DictationTemplate[] {
|
|
return this._store.get('templates', [])
|
|
}
|
|
|
|
getById(id: string): DictationTemplate | null {
|
|
const templates = this.getAll()
|
|
return templates.find((t) => t.id === id) ?? null
|
|
}
|
|
|
|
create(params: CreateTemplateParams): DictationTemplate {
|
|
const template: DictationTemplate = {
|
|
id: crypto.randomUUID(),
|
|
name: params.name,
|
|
description: params.description,
|
|
fields: params.fields,
|
|
outputFormat: params.outputFormat,
|
|
isBuiltin: false,
|
|
createdAt: Date.now(),
|
|
updatedAt: Date.now(),
|
|
}
|
|
|
|
const templates = this.getAll()
|
|
templates.push(template)
|
|
this._store.set('templates', templates)
|
|
|
|
return template
|
|
}
|
|
|
|
update(params: UpdateTemplateParams): DictationTemplate {
|
|
const templates = this.getAll()
|
|
const idx = templates.findIndex((t) => t.id === params.id)
|
|
if (idx === -1) {
|
|
throw new D3ROError(ErrorCode.TemplateNotFound, `Template not found: ${params.id}`)
|
|
}
|
|
|
|
const existing = templates[idx]
|
|
const updated: DictationTemplate = {
|
|
...existing,
|
|
...(params.name !== undefined && { name: params.name }),
|
|
...(params.description !== undefined && { description: params.description }),
|
|
...(params.fields !== undefined && { fields: params.fields }),
|
|
...(params.outputFormat !== undefined && { outputFormat: params.outputFormat }),
|
|
updatedAt: Date.now(),
|
|
}
|
|
|
|
templates[idx] = updated
|
|
this._store.set('templates', templates)
|
|
return updated
|
|
}
|
|
|
|
delete(id: string): void {
|
|
const templates = this.getAll()
|
|
const template = templates.find((t) => t.id === id)
|
|
if (!template) {
|
|
throw new D3ROError(ErrorCode.TemplateNotFound, `Template not found: ${id}`)
|
|
}
|
|
if (template.isBuiltin) {
|
|
throw new D3ROError(ErrorCode.TemplateInvalidFormat, 'Cannot delete builtin template')
|
|
}
|
|
|
|
this._store.set(
|
|
'templates',
|
|
templates.filter((t) => t.id !== id),
|
|
)
|
|
}
|
|
|
|
// ── 세션 관리 ──
|
|
|
|
getSessionState(): TemplateSessionInfo | null {
|
|
return this._session
|
|
}
|
|
|
|
startSession(templateId: string): void {
|
|
if (this._session) {
|
|
throw new D3ROError(ErrorCode.TemplateSessionAlreadyActive, 'Template session already active')
|
|
}
|
|
|
|
const template = this.getById(templateId)
|
|
if (!template) {
|
|
throw new D3ROError(ErrorCode.TemplateNotFound, `Template not found: ${templateId}`)
|
|
}
|
|
|
|
if (template.fields.length === 0) {
|
|
throw new D3ROError(ErrorCode.TemplateInvalidFormat, 'Template has no fields')
|
|
}
|
|
|
|
this._session = {
|
|
templateId,
|
|
templateName: template.name,
|
|
state: 'field-prompting',
|
|
currentFieldIndex: 0,
|
|
totalFields: template.fields.length,
|
|
currentField: template.fields[0],
|
|
fieldValues: {},
|
|
}
|
|
|
|
this._emitSessionState()
|
|
logger.info(`Template session started: ${template.name}`)
|
|
}
|
|
|
|
setFieldValue(fieldId: string, value: string): void {
|
|
if (!this._session) {
|
|
throw new D3ROError(ErrorCode.TemplateSessionNotActive, 'No active template session')
|
|
}
|
|
|
|
const template = this.getById(this._session.templateId)
|
|
if (!template) {
|
|
throw new D3ROError(ErrorCode.TemplateNotFound, 'Session template not found')
|
|
}
|
|
|
|
this._session.fieldValues[fieldId] = value
|
|
|
|
const currentField = this._session.currentField
|
|
const nextIndex = this._session.currentFieldIndex + 1
|
|
const nextField = nextIndex < template.fields.length ? template.fields[nextIndex] : null
|
|
|
|
// 필드 완료 이벤트
|
|
const fieldEvent: TemplateFieldCompletedEvent = {
|
|
fieldId,
|
|
fieldName: currentField?.name ?? fieldId,
|
|
value,
|
|
nextField,
|
|
}
|
|
this._sendToRenderer(IPC_CHANNELS.DICTATION_TEMPLATE.FIELD_COMPLETED, fieldEvent)
|
|
this.emit('field-completed', fieldEvent)
|
|
|
|
if (nextField) {
|
|
// 다음 필드로 이동
|
|
this._session.currentFieldIndex = nextIndex
|
|
this._session.currentField = nextField
|
|
this._session.state = 'field-prompting'
|
|
this._emitSessionState()
|
|
} else {
|
|
// 모든 필드 완료 → 출력 생성
|
|
this._completeSession(template)
|
|
}
|
|
}
|
|
|
|
cancelSession(): void {
|
|
if (!this._session) return
|
|
logger.info('Template session cancelled')
|
|
this._session = null
|
|
this._emitSessionState()
|
|
}
|
|
|
|
private _completeSession(template: DictationTemplate): void {
|
|
if (!this._session) return
|
|
|
|
this._session.state = 'completing'
|
|
this._emitSessionState()
|
|
|
|
// 출력 포맷 적용 (mustache-like 치환)
|
|
let output = template.outputFormat
|
|
for (const [key, value] of Object.entries(this._session.fieldValues)) {
|
|
output = output.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), value)
|
|
}
|
|
// 미입력 필드 자리표시자 제거
|
|
output = output.replace(/\{\{[^}]+\}\}/g, '')
|
|
|
|
const completedEvent: TemplateSessionCompletedEvent = {
|
|
templateId: template.id,
|
|
outputText: output.trim(),
|
|
fieldValues: { ...this._session.fieldValues },
|
|
}
|
|
|
|
this._sendToRenderer(IPC_CHANNELS.DICTATION_TEMPLATE.SESSION_COMPLETED, completedEvent)
|
|
this.emit('session-completed', completedEvent)
|
|
|
|
this._session = null
|
|
logger.info(`Template session completed: ${template.name}`)
|
|
}
|
|
|
|
private _emitSessionState(): void {
|
|
this._sendToRenderer(IPC_CHANNELS.DICTATION_TEMPLATE.SESSION_STATE_CHANGED, this._session)
|
|
this.emit('session-state-changed', this._session)
|
|
}
|
|
|
|
private _sendToRenderer(channel: string, data: unknown): void {
|
|
try {
|
|
const mainWindow = getMainWindow()
|
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
|
mainWindow.webContents.send(channel, data)
|
|
}
|
|
} catch {
|
|
// 윈도우 없으면 무시
|
|
}
|
|
}
|
|
|
|
private _ensureBuiltins(): void {
|
|
const templates = this.getAll()
|
|
for (const builtin of BUILTIN_TEMPLATES) {
|
|
if (!templates.find((t) => t.id === builtin.id)) {
|
|
templates.push(builtin)
|
|
}
|
|
}
|
|
this._store.set('templates', templates)
|
|
}
|
|
|
|
dispose(): void {
|
|
this.cancelSession()
|
|
this.removeAllListeners()
|
|
}
|
|
}
|
|
|
|
// ── 싱글톤 ──
|
|
let instance: DictationTemplateService | null = null
|
|
|
|
export function getDictationTemplateService(): DictationTemplateService {
|
|
if (!instance) {
|
|
instance = new DictationTemplateService()
|
|
}
|
|
return instance
|
|
}
|
|
|
|
export function resetDictationTemplateServiceForTests(): void {
|
|
instance = null
|
|
}
|