// 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 private _session: TemplateSessionInfo | null = null constructor() { super() this._store = new Store({ 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 }