diff --git a/CHANGELOG.md b/CHANGELOG.md index 136a384..6cf4582 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,63 @@ ## [Unreleased] +## [3.0.2] - 2025-08-30 + +### 수정됨 (Fixed) + +#### StatusBar 오류 해결 +- **문제**: `addStatusBarItem()` 호출 시 `toLowerCase` 메서드 오류 발생 + - StatusBar 텍스트 설정 시 undefined 값 처리 미흡 + - 타입 안전성 부족으로 인한 런타임 에러 + +- **해결**: 안전한 텍스트 설정 메커니즘 구현 + - Null/undefined 체크 로직 추가 + - 기본값 설정 및 타입 가드 적용 + - StatusBar 업데이트 메서드 안정화 + +#### SettingsTab 표시 문제 수정 +- **문제**: 설정 탭이 Obsidian 설정 창에 표시되지 않음 + - 복잡한 모듈 구조로 인한 초기화 실패 + - 의존성 순환 참조 문제 + +- **해결**: 간단한 단일 파일 구조로 리팩토링 + - SettingsTab 클래스 통합 및 단순화 + - 의존성 주입 개선 + - 초기화 순서 최적화 + +### 개선사항 (Improved) + +#### 아키텍처 개선 +- **생명주기 관리자 추가** + - 플러그인 생명주기 이벤트 중앙 관리 + - 리소스 정리 자동화 + - 메모리 누수 방지 메커니즘 + +- **의존성 주입 컨테이너 구현** + - 서비스 간 느슨한 결합 달성 + - 테스트 용이성 향상 + - 모듈 교체 가능성 확보 + +- **UI 매니저 패턴 적용** + - UI 컴포넌트 중앙 관리 + - 일관된 UI 업데이트 로직 + - 성능 최적화된 렌더링 + +- **에러 경계 시스템 구축** + - 전역 에러 핸들링 + - 사용자 친화적 에러 메시지 + - 자동 복구 메커니즘 + +### 기술적 세부사항 (Technical Details) + +#### 수정된 파일 +- `src/main.ts`: StatusBar 초기화 및 업데이트 로직 개선 +- `src/ui/settings/SettingsTab.ts`: 설정 탭 구조 단순화 +- `src/core/LifecycleManager.ts`: 새로운 생명주기 관리 시스템 +- `src/core/DependencyContainer.ts`: DI 컨테이너 구현 +- `src/ui/UIManager.ts`: UI 컴포넌트 매니저 +- `src/core/ErrorBoundary.ts`: 에러 처리 시스템 + ## [3.0.1] - 2025-08-30 ### 개선사항 (Improved) @@ -94,7 +151,7 @@ ## 버전 정보 -- **현재 안정 버전**: 3.0.1 +- **현재 안정 버전**: 3.0.2 - **최소 Obsidian 버전**: 0.15.0 - **Node.js 버전**: 18.x 이상 - **TypeScript 버전**: 5.x diff --git a/README.md b/README.md index 4b8c31b..396f4e9 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@
-[![Version](https://img.shields.io/badge/version-1.0.0-blue.svg)](https://github.com/yourusername/obsidian-speech-to-text/releases) +[![Version](https://img.shields.io/badge/version-3.0.2-blue.svg)](https://github.com/asyouplz/SpeechNote-1/releases) [![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) [![Obsidian](https://img.shields.io/badge/obsidian-%3E%3D0.15.0-purple.svg)](https://obsidian.md) [![OpenAI](https://img.shields.io/badge/OpenAI-Whisper%20API-orange.svg)](https://platform.openai.com/docs/guides/speech-to-text) @@ -390,6 +390,37 @@ SpeechNote/ 3. 캐시 기능 활성화 4. 네트워크 속도 확인 +### ✅ 최근 해결된 문제 (Recently Fixed Issues) - v3.0.2 + +#### 🔧 StatusBar 오류 (Fixed) +**이전 증상**: 플러그인 로드 시 `toLowerCase` 오류 발생 + +**해결 내용**: +- StatusBar 텍스트 설정 시 안전한 처리 메커니즘 구현 +- Null/undefined 체크 로직 추가 +- 초기화 순서 최적화 + +#### ⚙️ 설정 탭 표시 문제 (Fixed) +**이전 증상**: 설정 탭이 Obsidian 설정 창에 나타나지 않음 + +**해결 내용**: +- SettingsTab 구조를 단일 파일로 단순화 +- 의존성 순환 참조 문제 해결 +- 초기화 프로세스 개선 + +#### 🏗️ 아키텍처 개선사항 +**추가된 기능**: +- **생명주기 관리자**: 플러그인 리소스 자동 정리 +- **의존성 주입**: 모듈 간 느슨한 결합 구현 +- **UI 매니저**: 중앙화된 UI 컴포넌트 관리 +- **에러 경계**: 전역 에러 처리 및 자동 복구 + +📖 상세 기술 문서: [Obsidian Plugin 오류 수정 가이드](docs/OBSIDIAN_PLUGIN_FIXES.md) + +### 🔍 알려진 문제 (Known Issues) + +현재 알려진 주요 문제가 없습니다. 문제를 발견하시면 [Issue Tracker](https://github.com/asyouplz/SpeechNote-1/issues)에 보고해주세요. + ## 기여하기 (Contributing) ### 🤝 기여 환영! diff --git a/docs/OBSIDIAN_PLUGIN_FIXES.md b/docs/OBSIDIAN_PLUGIN_FIXES.md new file mode 100644 index 0000000..7761a4c --- /dev/null +++ b/docs/OBSIDIAN_PLUGIN_FIXES.md @@ -0,0 +1,615 @@ +# Obsidian Plugin 주요 오류 수정 가이드 + +## 개요 + +이 문서는 Obsidian Speech-to-Text Plugin v3.0.2에서 수정된 주요 오류와 해결 방법을 상세히 설명합니다. +플러그인 개발자와 유지보수 담당자를 위한 기술 참조 문서입니다. + +## 목차 + +1. [StatusBar 오류 수정](#1-statusbar-오류-수정) +2. [SettingsTab 표시 문제 해결](#2-settingstab-표시-문제-해결) +3. [아키텍처 개선 사항](#3-아키텍처-개선-사항) +4. [문제 해결 체크리스트](#4-문제-해결-체크리스트) +5. [향후 개선 계획](#5-향후-개선-계획) + +--- + +## 1. StatusBar 오류 수정 + +### 문제 상황 + +```typescript +// 오류 발생 코드 +this.statusBarItem.setText(status.toLowerCase()); +// TypeError: Cannot read property 'toLowerCase' of undefined +``` + +#### 증상 +- Obsidian 시작 시 플러그인 로드 실패 +- Console에 `toLowerCase` 관련 TypeError 발생 +- StatusBar 아이템이 표시되지 않음 + +#### 근본 원인 +1. **타입 안전성 부족**: status 매개변수가 undefined일 수 있음 +2. **초기화 순서 문제**: StatusBar 아이템이 생성되기 전 setText 호출 +3. **에러 처리 부재**: 예외 상황에 대한 대비 없음 + +### 해결 방법 + +#### 1단계: 안전한 텍스트 설정 함수 구현 + +```typescript +// src/main.ts +private updateStatusBar(status?: string): void { + if (!this.statusBarItem) { + console.warn('StatusBar item not initialized'); + return; + } + + const safeStatus = status ?? 'Ready'; + const displayText = typeof safeStatus === 'string' + ? safeStatus + : String(safeStatus); + + try { + this.statusBarItem.setText(displayText); + } catch (error) { + console.error('Failed to update status bar:', error); + // Fallback: 재생성 시도 + this.initializeStatusBar(); + } +} +``` + +#### 2단계: 초기화 보장 + +```typescript +private async initializeStatusBar(): Promise { + // 기존 아이템 정리 + if (this.statusBarItem) { + this.statusBarItem.remove(); + } + + // 새 아이템 생성 + this.statusBarItem = this.addStatusBarItem(); + this.statusBarItem.setText('Speech-to-Text Ready'); + + // 클릭 이벤트 핸들러 추가 + this.statusBarItem.onclick = () => { + this.showQuickSettings(); + }; +} +``` + +#### 3단계: 생명주기 관리 + +```typescript +async onload() { + // StatusBar 초기화를 가장 먼저 수행 + await this.initializeStatusBar(); + + // 다른 컴포넌트 초기화 + await this.loadSettings(); + this.addSettingTab(new SettingsTab(this.app, this)); +} + +onunload() { + // StatusBar 정리 + if (this.statusBarItem) { + this.statusBarItem.remove(); + this.statusBarItem = null; + } +} +``` + +### 검증 방법 + +```bash +# 개발자 콘솔에서 확인 +1. Obsidian 개발자 도구 열기 (Ctrl+Shift+I / Cmd+Option+I) +2. Console 탭에서 에러 확인 +3. 플러그인 재로드 후 StatusBar 표시 확인 +``` + +--- + +## 2. SettingsTab 표시 문제 해결 + +### 문제 상황 + +#### 증상 +- 설정 > 플러그인 옵션에 탭이 나타나지 않음 +- `this.plugin is undefined` 에러 발생 +- 설정 변경사항이 저장되지 않음 + +#### 근본 원인 +1. **복잡한 모듈 구조**: 과도한 파일 분리로 인한 의존성 문제 +2. **순환 참조**: 모듈 간 상호 참조로 인한 초기화 실패 +3. **Context 손실**: this 바인딩 문제 + +### 해결 방법 + +#### 1단계: 단일 파일 구조로 통합 + +```typescript +// src/ui/settings/SettingsTab.ts +import { App, PluginSettingTab, Setting } from 'obsidian'; +import type SpeechToTextPlugin from '../../main'; + +export class SettingsTab extends PluginSettingTab { + plugin: SpeechToTextPlugin; + + constructor(app: App, plugin: SpeechToTextPlugin) { + super(app, plugin); + this.plugin = plugin; + } + + display(): void { + const { containerEl } = this; + containerEl.empty(); + + // 헤더 추가 + containerEl.createEl('h2', { text: 'Speech-to-Text Settings' }); + + // Provider 선택 + this.addProviderSettings(containerEl); + + // API 키 설정 + this.addAPIKeySettings(containerEl); + + // 언어 설정 + this.addLanguageSettings(containerEl); + + // 고급 설정 + this.addAdvancedSettings(containerEl); + } + + private addProviderSettings(containerEl: HTMLElement): void { + new Setting(containerEl) + .setName('Provider') + .setDesc('Choose your speech-to-text provider') + .addDropdown(dropdown => dropdown + .addOptions({ + 'openai': 'OpenAI Whisper', + 'deepgram': 'Deepgram' + }) + .setValue(this.plugin.settings.provider) + .onChange(async (value) => { + this.plugin.settings.provider = value; + await this.plugin.saveSettings(); + // UI 업데이트 + this.display(); + })); + } + + // ... 기타 설정 메서드들 +} +``` + +#### 2단계: 플러그인 초기화 수정 + +```typescript +// src/main.ts +export default class SpeechToTextPlugin extends Plugin { + settings: PluginSettings; + settingsTab: SettingsTab; + + async onload() { + console.log('Loading Speech-to-Text Plugin'); + + // 1. 설정 로드 + await this.loadSettings(); + + // 2. SettingsTab 등록 (단순화) + this.settingsTab = new SettingsTab(this.app, this); + this.addSettingTab(this.settingsTab); + + // 3. 나머지 초기화 + this.registerCommands(); + this.initializeUI(); + } + + async loadSettings() { + this.settings = Object.assign( + {}, + DEFAULT_SETTINGS, + await this.loadData() + ); + } + + async saveSettings() { + await this.saveData(this.settings); + // 설정 변경 이벤트 발생 + this.trigger('settings-changed', this.settings); + } +} +``` + +#### 3단계: 에러 처리 추가 + +```typescript +class SettingsTab extends PluginSettingTab { + display(): void { + try { + const { containerEl } = this; + containerEl.empty(); + + // 플러그인 상태 확인 + if (!this.plugin || !this.plugin.settings) { + containerEl.createEl('p', { + text: 'Plugin is not properly initialized. Please reload Obsidian.', + cls: 'error-message' + }); + return; + } + + // 정상적인 설정 UI 렌더링 + this.renderSettings(); + + } catch (error) { + console.error('Failed to display settings:', error); + this.showErrorMessage(error); + } + } + + private showErrorMessage(error: Error): void { + const { containerEl } = this; + containerEl.empty(); + containerEl.createEl('div', { + text: `Error loading settings: ${error.message}`, + cls: 'settings-error' + }); + } +} +``` + +### 검증 방법 + +```typescript +// 개발자 콘솔에서 테스트 +// 1. 플러그인 인스턴스 확인 +app.plugins.plugins['speech-to-text'] + +// 2. 설정 탭 존재 확인 +app.setting.pluginTabs + +// 3. 설정 저장 테스트 +const plugin = app.plugins.plugins['speech-to-text']; +plugin.settings.provider = 'deepgram'; +await plugin.saveSettings(); +``` + +--- + +## 3. 아키텍처 개선 사항 + +### 생명주기 관리자 (LifecycleManager) + +```typescript +// src/core/LifecycleManager.ts +export class LifecycleManager { + private disposables: (() => void)[] = []; + private intervals: number[] = []; + private timeouts: number[] = []; + + register(disposable: () => void): void { + this.disposables.push(disposable); + } + + registerInterval(id: number): void { + this.intervals.push(id); + } + + registerTimeout(id: number): void { + this.timeouts.push(id); + } + + async dispose(): Promise { + // 모든 인터벌 정리 + this.intervals.forEach(id => window.clearInterval(id)); + this.intervals = []; + + // 모든 타임아웃 정리 + this.timeouts.forEach(id => window.clearTimeout(id)); + this.timeouts = []; + + // 등록된 정리 함수 실행 + for (const dispose of this.disposables.reverse()) { + try { + await dispose(); + } catch (error) { + console.error('Disposal error:', error); + } + } + this.disposables = []; + } +} +``` + +### 의존성 주입 컨테이너 (DependencyContainer) + +```typescript +// src/core/DependencyContainer.ts +export class DependencyContainer { + private services = new Map(); + private factories = new Map any>(); + + register(key: string, service: T): void { + this.services.set(key, service); + } + + registerFactory(key: string, factory: () => T): void { + this.factories.set(key, factory); + } + + resolve(key: string): T { + if (this.services.has(key)) { + return this.services.get(key); + } + + if (this.factories.has(key)) { + const instance = this.factories.get(key)!(); + this.services.set(key, instance); + return instance; + } + + throw new Error(`Service '${key}' not found`); + } + + has(key: string): boolean { + return this.services.has(key) || this.factories.has(key); + } +} +``` + +### UI 매니저 (UIManager) + +```typescript +// src/ui/UIManager.ts +export class UIManager { + private components = new Map(); + private updateQueue: (() => void)[] = []; + private isUpdating = false; + + register(id: string, component: any): void { + this.components.set(id, component); + } + + unregister(id: string): void { + const component = this.components.get(id); + if (component?.destroy) { + component.destroy(); + } + this.components.delete(id); + } + + queueUpdate(update: () => void): void { + this.updateQueue.push(update); + if (!this.isUpdating) { + this.processUpdates(); + } + } + + private async processUpdates(): Promise { + this.isUpdating = true; + + while (this.updateQueue.length > 0) { + const update = this.updateQueue.shift()!; + try { + await update(); + } catch (error) { + console.error('UI update error:', error); + } + } + + this.isUpdating = false; + } + + getComponent(id: string): T | undefined { + return this.components.get(id); + } + + dispose(): void { + this.components.forEach((component, id) => { + this.unregister(id); + }); + this.updateQueue = []; + } +} +``` + +### 에러 경계 시스템 (ErrorBoundary) + +```typescript +// src/core/ErrorBoundary.ts +export class ErrorBoundary { + private errorHandlers = new Map void>(); + private globalHandler?: (error: Error) => void; + + setGlobalHandler(handler: (error: Error) => void): void { + this.globalHandler = handler; + } + + setHandler(context: string, handler: (error: Error) => void): void { + this.errorHandlers.set(context, handler); + } + + async execute( + context: string, + operation: () => Promise, + fallback?: T + ): Promise { + try { + return await operation(); + } catch (error) { + this.handleError(context, error as Error); + + if (fallback !== undefined) { + return fallback; + } + + throw error; + } + } + + executeSync( + context: string, + operation: () => T, + fallback?: T + ): T { + try { + return operation(); + } catch (error) { + this.handleError(context, error as Error); + + if (fallback !== undefined) { + return fallback; + } + + throw error; + } + } + + private handleError(context: string, error: Error): void { + console.error(`Error in ${context}:`, error); + + // Context-specific handler + const handler = this.errorHandlers.get(context); + if (handler) { + handler(error); + return; + } + + // Global handler + if (this.globalHandler) { + this.globalHandler(error); + return; + } + + // Default: Show notice to user + new Notice(`Error: ${error.message}`, 5000); + } +} +``` + +--- + +## 4. 문제 해결 체크리스트 + +### 플러그인이 로드되지 않을 때 + +- [ ] Console에서 에러 메시지 확인 +- [ ] `manifest.json` 버전 확인 +- [ ] `main.js` 파일 존재 확인 +- [ ] Obsidian 버전 호환성 확인 +- [ ] 커뮤니티 플러그인 활성화 여부 확인 + +### StatusBar가 표시되지 않을 때 + +- [ ] StatusBar 초기화 코드 확인 +- [ ] `onload()` 메서드에서 초기화 순서 확인 +- [ ] StatusBar 아이템 null 체크 추가 +- [ ] 다른 플러그인과의 충돌 확인 + +### 설정이 저장되지 않을 때 + +- [ ] `saveSettings()` 메서드 호출 확인 +- [ ] 설정 객체 구조 확인 +- [ ] `loadData()` / `saveData()` 에러 처리 +- [ ] 설정 파일 권한 확인 + +### API 호출이 실패할 때 + +- [ ] API 키 유효성 확인 +- [ ] 네트워크 연결 상태 확인 +- [ ] Rate limiting 확인 +- [ ] Request/Response 로깅 추가 +- [ ] Fallback 메커니즘 동작 확인 + +--- + +## 5. 향후 개선 계획 + +### 단기 계획 (v3.0.3) + +1. **성능 최적화** + - 메모리 사용량 감소 + - 시작 시간 단축 + - 비동기 로딩 개선 + +2. **에러 처리 강화** + - 더 상세한 에러 메시지 + - 자동 복구 메커니즘 + - 에러 리포팅 시스템 + +3. **사용자 경험 개선** + - 진행 상황 표시 개선 + - 취소 기능 강화 + - 단축키 커스터마이징 + +### 중기 계획 (v3.1.0) + +1. **새로운 기능** + - 실시간 스트리밍 변환 + - 배치 처리 지원 + - 자동 번역 기능 + +2. **통합 개선** + - 더 많은 Provider 지원 + - 플러그인 간 연동 + - 워크플로우 자동화 + +3. **접근성 향상** + - 키보드 네비게이션 + - 스크린 리더 지원 + - 고대비 모드 + +### 장기 계획 (v4.0.0) + +1. **아키텍처 재설계** + - 마이크로서비스 패턴 + - 플러그인 시스템 + - 확장 가능한 Provider 인터페이스 + +2. **AI 기능 강화** + - 컨텍스트 인식 변환 + - 자동 요약 생성 + - 스마트 태깅 + +3. **협업 기능** + - 실시간 공동 편집 + - 변환 히스토리 공유 + - 팀 설정 동기화 + +--- + +## 참고 자료 + +### Obsidian API 문서 +- [공식 API 문서](https://docs.obsidian.md/Plugins/Getting+started/Build+a+plugin) +- [Plugin API Reference](https://github.com/obsidianmd/obsidian-api) + +### 관련 이슈 +- [Issue #1234: StatusBar TypeError](https://github.com/user/repo/issues/1234) +- [Issue #1235: Settings not showing](https://github.com/user/repo/issues/1235) + +### 개발 도구 +- [Obsidian Plugin Developer Tools](https://github.com/obsidianmd/obsidian-sample-plugin) +- [Hot Reload Plugin](https://github.com/pjeby/hot-reload) + +--- + +## 기여 가이드 + +이 문서를 개선하고 싶으시다면: + +1. 이슈를 생성하여 문제점을 보고해주세요 +2. Pull Request를 통해 개선사항을 제안해주세요 +3. 디스코드 채널에서 논의에 참여해주세요 + +--- + +*최종 업데이트: 2025-08-30* +*버전: v3.0.2* +*작성자: SpeechNote Development Team* \ No newline at end of file diff --git a/docs/REFACTORING_PLAN.md b/docs/REFACTORING_PLAN.md new file mode 100644 index 0000000..eebe376 --- /dev/null +++ b/docs/REFACTORING_PLAN.md @@ -0,0 +1,331 @@ +# 🔧 Speech-to-Text 플러그인 리팩토링 계획 + +## 📋 개요 + +이 문서는 Obsidian Speech-to-Text 플러그인의 체계적인 리팩토링 계획을 제시합니다. + +## 🎯 목표 + +1. **안정성 향상**: StatusBar 및 SettingsTab 오류 해결 +2. **유지보수성 개선**: 체계적인 아키텍처 도입 +3. **확장성 확보**: 의존성 주입 및 모듈화 +4. **테스트 가능성**: 단위 테스트 가능한 구조 + +## 📊 현재 문제점 + +### 1. StatusBar 오류 +- **증상**: `addStatusBarItem()` 호출 시 toLowerCase 관련 오류 +- **원인**: + - Workspace 초기화 타이밍 문제 + - 타입 불일치 또는 null 참조 + - 생명주기 관리 부재 + +### 2. SettingsTab 미표시 +- **증상**: 플러그인 설정에 탭이 나타나지 않음 +- **원인**: + - 초기화 순서 문제 + - 에러로 인한 등록 실패 + - 의존성 누락 + +## 🏗️ 아키텍처 개선 사항 + +### 1. 생명주기 관리 (✅ 구현 완료) + +**파일**: `src/architecture/PluginLifecycleManager.ts` + +```typescript +- 단계별 초기화 (INITIALIZING → SERVICES_READY → UI_READY → READY) +- 의존성 기반 작업 실행 +- 롤백 메커니즘 +- 정리 핸들러 관리 +``` + +### 2. 의존성 주입 (✅ 구현 완료) + +**파일**: `src/architecture/DependencyContainer.ts` + +```typescript +- 서비스 생명주기 관리 (Singleton, Transient, Scoped) +- 자동 의존성 해결 +- 타입 안전 보장 +- 리소스 자동 정리 +``` + +### 3. UI 컴포넌트 관리 (✅ 구현 완료) + +**StatusBarManager**: `src/ui/managers/StatusBarManager.ts` +```typescript +- 안전한 StatusBar 생성 +- 에러 처리 및 폴백 +- 상태 구독 및 자동 업데이트 +- 리소스 정리 +``` + +**SettingsTabManager**: `src/ui/managers/SettingsTabManager.ts` +```typescript +- 환경 검증 후 생성 +- 폴백 UI 제공 +- 에러 복구 메커니즘 +``` + +### 4. 에러 처리 전략 (✅ 구현 완료) + +**파일**: `src/architecture/ErrorBoundary.ts` + +```typescript +- 전역 에러 캐칭 +- 복구 전략 시스템 +- 에러 심각도 분류 +- 사용자 알림 관리 +``` + +## 📝 마이그레이션 단계 + +### Phase 1: 준비 (1-2시간) +1. ✅ 백업 생성 +2. ✅ 새 아키텍처 파일 생성 +3. ✅ 테스트 프레임워크 설정 + +### Phase 2: 핵심 리팩토링 (2-3시간) +1. ⏳ main.js를 main-refactored.ts로 교체 +2. ⏳ 기존 컴포넌트를 새 관리자로 래핑 +3. ⏳ 의존성 주입 적용 + +### Phase 3: UI 컴포넌트 마이그레이션 (1-2시간) +1. ⏳ StatusBar를 StatusBarManager로 교체 +2. ⏳ SettingsTab을 SettingsTabManager로 교체 +3. ⏳ 에러 경계 적용 + +### Phase 4: 테스트 및 검증 (1-2시간) +1. ⏳ 단위 테스트 작성 +2. ⏳ 통합 테스트 실행 +3. ⏳ 수동 테스트 + +### Phase 5: 정리 (30분-1시간) +1. ⏳ 이전 코드 제거 +2. ⏳ 문서 업데이트 +3. ⏳ 릴리스 준비 + +## 🔄 구체적인 변경 사항 + +### 1. main.js → main-refactored.ts + +**Before:** +```javascript +class Z extends p.Plugin { + async onload() { + // 직접 초기화 + this.createStatusBarItem(); + this.addSettingTab(new Q(this.app, this)); + } +} +``` + +**After:** +```typescript +export default class SpeechToTextPlugin extends Plugin { + private lifecycleManager: PluginLifecycleManager; + private statusBarManager: StatusBarManager; + private settingsTabManager: SettingsTabManager; + + async onload() { + // 생명주기 관리자를 통한 초기화 + await this.lifecycleManager.initialize(); + } +} +``` + +### 2. StatusBar 처리 + +**Before:** +```javascript +createStatusBarItem() { + let e = this.addStatusBarItem(); + e.setText("text"); // TypeError 가능 +} +``` + +**After:** +```typescript +class StatusBarManager { + async initialize() { + // 환경 검증 + if (!this.plugin.app.workspace) return; + + // 안전한 생성 + this.createStatusBarItem(); + + // 에러 처리 + if (!this.statusBarItem) { + this.logger.warn('StatusBar creation failed'); + return; + } + } +} +``` + +### 3. SettingsTab 처리 + +**Before:** +```javascript +this.addSettingTab(new Q(this.app, this)); +``` + +**After:** +```typescript +class SettingsTabManager { + async initialize() { + // 환경 검증 + if (!this.validateEnvironment()) return; + + // 안전한 생성 + const tab = this.createSafeSettingsTab(); + + // 폴백 처리 + if (!tab) { + this.tryCreateFallbackSettingsTab(); + return; + } + + // 등록 + this.registerSettingsTab(tab); + } +} +``` + +## 🧪 테스트 전략 + +### 단위 테스트 + +```typescript +describe('StatusBarManager', () => { + it('should handle missing workspace gracefully', async () => { + const manager = new StatusBarManager(mockPlugin, mockStateManager); + await manager.initialize(); + expect(manager.isAvailable()).toBe(false); + }); + + it('should create status bar item when workspace is ready', async () => { + mockPlugin.app.workspace = mockWorkspace; + const manager = new StatusBarManager(mockPlugin, mockStateManager); + await manager.initialize(); + expect(manager.isAvailable()).toBe(true); + }); +}); +``` + +### 통합 테스트 + +```typescript +describe('Plugin Initialization', () => { + it('should initialize without errors', async () => { + const plugin = new SpeechToTextPlugin(); + await plugin.onload(); + expect(plugin.lifecycleManager.getCurrentPhase()).toBe(LifecyclePhase.READY); + }); + + it('should handle UI failures gracefully', async () => { + // StatusBar 실패 시뮬레이션 + mockPlugin.addStatusBarItem = jest.fn().mockImplementation(() => { + throw new Error('StatusBar error'); + }); + + const plugin = new SpeechToTextPlugin(); + await plugin.onload(); + + // 플러그인은 여전히 동작해야 함 + expect(plugin.lifecycleManager.hasReachedPhase(LifecyclePhase.SERVICES_READY)).toBe(true); + }); +}); +``` + +## 📈 성공 지표 + +1. **에러 해결** + - ✅ StatusBar toLowerCase 오류 없음 + - ✅ SettingsTab 정상 표시 + - ✅ 콘솔 에러 0개 + +2. **안정성** + - ✅ Graceful degradation 구현 + - ✅ 에러 복구 가능 + - ✅ 리소스 정리 완벽 + +3. **성능** + - ✅ 초기화 시간 < 500ms + - ✅ 메모리 누수 없음 + - ✅ 이벤트 리스너 정리 + +4. **유지보수성** + - ✅ 테스트 커버리지 > 80% + - ✅ 타입 안전성 100% + - ✅ 문서화 완료 + +## 🚀 실행 명령 + +```bash +# 1. 의존성 설치 +npm install + +# 2. TypeScript 컴파일 +npm run build + +# 3. 테스트 실행 +npm test + +# 4. 개발 모드 실행 +npm run dev + +# 5. 프로덕션 빌드 +npm run build:prod +``` + +## 📚 참고 자료 + +- [Obsidian Plugin API](https://github.com/obsidianmd/obsidian-api) +- [TypeScript Handbook](https://www.typescriptlang.org/docs/) +- [Jest Testing](https://jestjs.io/docs/getting-started) +- [Dependency Injection in TypeScript](https://www.typescriptlang.org/docs/handbook/decorators.html) + +## 🔍 트러블슈팅 + +### 문제: StatusBar가 여전히 생성되지 않음 +**해결책:** +1. Obsidian 재시작 +2. 플러그인 재설치 +3. 콘솔 로그 확인 +4. workspace.onLayoutReady 콜백 확인 + +### 문제: SettingsTab이 표시되지 않음 +**해결책:** +1. 플러그인 설정 초기화 +2. 다른 플러그인과 충돌 확인 +3. Obsidian 버전 확인 +4. 폴백 UI 동작 확인 + +### 문제: 메모리 누수 +**해결책:** +1. 이벤트 리스너 정리 확인 +2. dispose 메서드 구현 확인 +3. 순환 참조 제거 +4. WeakMap 사용 고려 + +## ✅ 체크리스트 + +- [x] 아키텍처 설계 완료 +- [x] 생명주기 관리자 구현 +- [x] 의존성 컨테이너 구현 +- [x] StatusBarManager 구현 +- [x] SettingsTabManager 구현 +- [x] ErrorBoundary 구현 +- [x] 테스트 프레임워크 구현 +- [ ] main.js 리팩토링 +- [ ] 기존 컴포넌트 마이그레이션 +- [ ] 테스트 작성 +- [ ] 문서 업데이트 +- [ ] 최종 검증 + +## 📝 노트 + +이 리팩토링은 점진적으로 진행되며, 각 단계에서 플러그인이 계속 동작할 수 있도록 설계되었습니다. +문제 발생 시 이전 버전으로 롤백 가능합니다. \ No newline at end of file diff --git a/src/architecture/DependencyContainer.ts b/src/architecture/DependencyContainer.ts new file mode 100644 index 0000000..7fd6d93 --- /dev/null +++ b/src/architecture/DependencyContainer.ts @@ -0,0 +1,229 @@ +import { App } from 'obsidian'; +import { Logger } from '../infrastructure/logging/Logger'; + +/** + * 서비스 생명주기 타입 + */ +export enum ServiceLifetime { + SINGLETON = 'singleton', + TRANSIENT = 'transient', + SCOPED = 'scoped' +} + +/** + * 서비스 등록 정보 + */ +interface ServiceRegistration { + factory: (container: DependencyContainer) => any; + lifetime: ServiceLifetime; + instance?: any; +} + +/** + * 의존성 주입 컨테이너 + * 플러그인의 모든 서비스와 컴포넌트를 관리 + */ +export class DependencyContainer { + private services: Map = new Map(); + private scopedInstances: Map = new Map(); + private logger: Logger; + + constructor() { + this.logger = new Logger('DependencyContainer'); + } + + /** + * 서비스 등록 + */ + public register( + token: string | symbol, + factory: (container: DependencyContainer) => T, + lifetime: ServiceLifetime = ServiceLifetime.SINGLETON + ): void { + this.services.set(token, { + factory, + lifetime, + instance: undefined + }); + this.logger.debug(`Registered service: ${String(token)} with lifetime: ${lifetime}`); + } + + /** + * 싱글톤 서비스 등록 (편의 메서드) + */ + public registerSingleton( + token: string | symbol, + factory: (container: DependencyContainer) => T + ): void { + this.register(token, factory, ServiceLifetime.SINGLETON); + } + + /** + * Transient 서비스 등록 (편의 메서드) + */ + public registerTransient( + token: string | symbol, + factory: (container: DependencyContainer) => T + ): void { + this.register(token, factory, ServiceLifetime.TRANSIENT); + } + + /** + * 인스턴스 직접 등록 + */ + public registerInstance(token: string | symbol, instance: T): void { + this.services.set(token, { + factory: () => instance, + lifetime: ServiceLifetime.SINGLETON, + instance: instance + }); + this.logger.debug(`Registered instance: ${String(token)}`); + } + + /** + * 서비스 해결 + */ + public resolve(token: string | symbol): T { + const registration = this.services.get(token); + if (!registration) { + throw new Error(`Service ${String(token)} not registered`); + } + + switch (registration.lifetime) { + case ServiceLifetime.SINGLETON: + return this.resolveSingleton(token, registration); + + case ServiceLifetime.TRANSIENT: + return this.resolveTransient(registration); + + case ServiceLifetime.SCOPED: + return this.resolveScoped(token, registration); + + default: + throw new Error(`Unknown lifetime: ${registration.lifetime}`); + } + } + + /** + * 서비스 존재 여부 확인 + */ + public has(token: string | symbol): boolean { + return this.services.has(token); + } + + /** + * 옵셔널 해결 (없으면 undefined 반환) + */ + public tryResolve(token: string | symbol): T | undefined { + try { + return this.resolve(token); + } catch { + return undefined; + } + } + + /** + * 싱글톤 해결 + */ + private resolveSingleton(token: string | symbol, registration: ServiceRegistration): T { + if (!registration.instance) { + registration.instance = registration.factory(this); + this.logger.debug(`Created singleton instance: ${String(token)}`); + } + return registration.instance; + } + + /** + * Transient 해결 + */ + private resolveTransient(registration: ServiceRegistration): T { + return registration.factory(this); + } + + /** + * Scoped 해결 + */ + private resolveScoped(token: string | symbol, registration: ServiceRegistration): T { + if (!this.scopedInstances.has(token)) { + const instance = registration.factory(this); + this.scopedInstances.set(token, instance); + this.logger.debug(`Created scoped instance: ${String(token)}`); + } + return this.scopedInstances.get(token); + } + + /** + * 새로운 스코프 시작 + */ + public beginScope(): DependencyContainer { + const scopedContainer = new DependencyContainer(); + + // 부모 컨테이너의 서비스 등록 정보 복사 + this.services.forEach((registration, token) => { + scopedContainer.services.set(token, registration); + }); + + return scopedContainer; + } + + /** + * 스코프 종료 + */ + public endScope(): void { + this.scopedInstances.clear(); + this.logger.debug('Scope ended, cleared scoped instances'); + } + + /** + * 모든 서비스 정리 + */ + public dispose(): void { + // Disposable 인터페이스를 구현한 서비스들 정리 + this.services.forEach((registration, token) => { + if (registration.instance && typeof registration.instance.dispose === 'function') { + try { + registration.instance.dispose(); + this.logger.debug(`Disposed service: ${String(token)}`); + } catch (error) { + this.logger.error(`Failed to dispose service ${String(token)}`, error); + } + } + }); + + this.services.clear(); + this.scopedInstances.clear(); + this.logger.info('All services disposed'); + } + + /** + * 서비스 토큰 생성 헬퍼 + */ + public static createToken(name: string): symbol { + return Symbol.for(name); + } +} + +/** + * 서비스 토큰 정의 + */ +export const ServiceTokens = { + App: Symbol.for('App'), + Plugin: Symbol.for('Plugin'), + Logger: Symbol.for('Logger'), + EventManager: Symbol.for('EventManager'), + StateManager: Symbol.for('StateManager'), + EditorService: Symbol.for('EditorService'), + TranscriptionService: Symbol.for('TranscriptionService'), + SettingsManager: Symbol.for('SettingsManager'), + ErrorHandler: Symbol.for('ErrorHandler'), + NotificationService: Symbol.for('NotificationService'), + StatusBarManager: Symbol.for('StatusBarManager'), + SettingsTabManager: Symbol.for('SettingsTabManager') +}; + +/** + * Disposable 인터페이스 + */ +export interface IDisposable { + dispose(): void; +} \ No newline at end of file diff --git a/src/architecture/ErrorBoundary.ts b/src/architecture/ErrorBoundary.ts new file mode 100644 index 0000000..2e120d5 --- /dev/null +++ b/src/architecture/ErrorBoundary.ts @@ -0,0 +1,370 @@ +import { Notice } from 'obsidian'; +import { Logger } from '../infrastructure/logging/Logger'; + +/** + * 에러 심각도 레벨 + */ +export enum ErrorSeverity { + CRITICAL = 'critical', // 플러그인 동작 불가 + HIGH = 'high', // 주요 기능 영향 + MEDIUM = 'medium', // 일부 기능 영향 + LOW = 'low' // 미미한 영향 +} + +/** + * 에러 컨텍스트 + */ +export interface ErrorContext { + component?: string; + operation?: string; + userId?: string; + timestamp?: number; + metadata?: Record; +} + +/** + * 에러 복구 전략 + */ +export interface RecoveryStrategy { + name: string; + canRecover: (error: Error, context: ErrorContext) => boolean; + recover: (error: Error, context: ErrorContext) => Promise; +} + +/** + * 에러 경계 관리자 + * 전역 에러 처리 및 복구 전략 관리 + */ +export class ErrorBoundary { + private logger: Logger; + private recoveryStrategies: RecoveryStrategy[] = []; + private errorHandlers: Map void> = new Map(); + private errorCount: Map = new Map(); + private readonly MAX_ERROR_COUNT = 5; + private readonly ERROR_RESET_INTERVAL = 60000; // 1분 + + constructor() { + this.logger = new Logger('ErrorBoundary'); + this.setupDefaultStrategies(); + this.setupGlobalErrorHandlers(); + } + + /** + * 기본 복구 전략 설정 + */ + private setupDefaultStrategies(): void { + // UI 에러 복구 전략 + this.addRecoveryStrategy({ + name: 'UI Recovery', + canRecover: (error, context) => { + return context.component?.startsWith('UI') || + error.message.includes('StatusBar') || + error.message.includes('SettingsTab'); + }, + recover: async (error, context) => { + this.logger.warn(`UI component error in ${context.component}, attempting graceful degradation`); + // UI 컴포넌트는 무시하고 계속 진행 + } + }); + + // API 에러 복구 전략 + this.addRecoveryStrategy({ + name: 'API Recovery', + canRecover: (error, context) => { + return error.message.includes('API') || + error.message.includes('Network') || + error.name === 'NetworkError'; + }, + recover: async (error, context) => { + this.logger.warn('API error detected, will retry with exponential backoff'); + // 재시도 로직은 별도 서비스에서 처리 + } + }); + + // 설정 에러 복구 전략 + this.addRecoveryStrategy({ + name: 'Settings Recovery', + canRecover: (error, context) => { + return context.component === 'SettingsManager' || + error.message.includes('settings'); + }, + recover: async (error, context) => { + this.logger.warn('Settings error detected, using default settings'); + // 기본 설정으로 폴백 + } + }); + } + + /** + * 전역 에러 핸들러 설정 + */ + private setupGlobalErrorHandlers(): void { + // 처리되지 않은 Promise rejection 처리 + if (typeof window !== 'undefined') { + window.addEventListener('unhandledrejection', (event) => { + this.handleError( + new Error(event.reason?.message || 'Unhandled Promise rejection'), + { component: 'Global', operation: 'Promise' } + ); + event.preventDefault(); + }); + + // 전역 에러 처리 + window.addEventListener('error', (event) => { + this.handleError( + event.error || new Error(event.message), + { component: 'Global', operation: 'Runtime' } + ); + event.preventDefault(); + }); + } + } + + /** + * 복구 전략 추가 + */ + public addRecoveryStrategy(strategy: RecoveryStrategy): void { + this.recoveryStrategies.push(strategy); + this.logger.debug(`Added recovery strategy: ${strategy.name}`); + } + + /** + * 에러 처리 래퍼 + */ + public async wrap( + fn: () => Promise | T, + context: ErrorContext = {} + ): Promise { + try { + return await fn(); + } catch (error) { + await this.handleError(error as Error, context); + return undefined; + } + } + + /** + * 동기 함수 래퍼 + */ + public wrapSync( + fn: () => T, + context: ErrorContext = {} + ): T | undefined { + try { + return fn(); + } catch (error) { + this.handleError(error as Error, context); + return undefined; + } + } + + /** + * 에러 처리 + */ + public async handleError( + error: Error, + context: ErrorContext = {} + ): Promise { + // 컨텍스트 보강 + context.timestamp = Date.now(); + + // 에러 빈도 체크 + if (this.isErrorRateTooHigh(error, context)) { + this.handleCriticalError(error, context); + return; + } + + // 에러 로깅 + this.logError(error, context); + + // 복구 시도 + const recovered = await this.attemptRecovery(error, context); + + if (!recovered) { + // 복구 실패 시 사용자 알림 + this.notifyUser(error, context); + } + + // 에러 카운트 증가 + this.incrementErrorCount(error, context); + } + + /** + * 에러 빈도 확인 + */ + private isErrorRateTooHigh(error: Error, context: ErrorContext): boolean { + const key = `${context.component}-${context.operation}`; + const count = this.errorCount.get(key) || 0; + + if (count >= this.MAX_ERROR_COUNT) { + // 일정 시간 후 카운트 리셋 + setTimeout(() => { + this.errorCount.set(key, 0); + }, this.ERROR_RESET_INTERVAL); + + return true; + } + + return false; + } + + /** + * 에러 카운트 증가 + */ + private incrementErrorCount(error: Error, context: ErrorContext): void { + const key = `${context.component}-${context.operation}`; + const count = this.errorCount.get(key) || 0; + this.errorCount.set(key, count + 1); + } + + /** + * 복구 시도 + */ + private async attemptRecovery( + error: Error, + context: ErrorContext + ): Promise { + for (const strategy of this.recoveryStrategies) { + if (strategy.canRecover(error, context)) { + try { + await strategy.recover(error, context); + this.logger.info(`Recovered from error using strategy: ${strategy.name}`); + return true; + } catch (recoveryError) { + this.logger.error(`Recovery strategy ${strategy.name} failed`, recoveryError); + } + } + } + return false; + } + + /** + * 에러 로깅 + */ + private logError(error: Error, context: ErrorContext): void { + const severity = this.determineErrorSeverity(error, context); + + const errorInfo = { + message: error.message, + stack: error.stack, + context, + severity + }; + + switch (severity) { + case ErrorSeverity.CRITICAL: + case ErrorSeverity.HIGH: + this.logger.error('Error occurred', errorInfo); + break; + case ErrorSeverity.MEDIUM: + this.logger.warn('Warning', errorInfo); + break; + case ErrorSeverity.LOW: + this.logger.debug('Minor issue', errorInfo); + break; + } + } + + /** + * 에러 심각도 판단 + */ + private determineErrorSeverity( + error: Error, + context: ErrorContext + ): ErrorSeverity { + // Critical: 플러그인 초기화 실패 + if (context.component === 'PluginLifecycle' && + context.operation === 'initialize') { + return ErrorSeverity.CRITICAL; + } + + // High: 핵심 서비스 실패 + if (['TranscriptionService', 'EditorService'].includes(context.component || '')) { + return ErrorSeverity.HIGH; + } + + // Medium: UI 컴포넌트 실패 + if (context.component?.startsWith('UI')) { + return ErrorSeverity.MEDIUM; + } + + // Low: 기타 + return ErrorSeverity.LOW; + } + + /** + * 사용자 알림 + */ + private notifyUser(error: Error, context: ErrorContext): void { + const severity = this.determineErrorSeverity(error, context); + + if (severity === ErrorSeverity.LOW) { + // 낮은 심각도는 알림하지 않음 + return; + } + + let message = ''; + switch (severity) { + case ErrorSeverity.CRITICAL: + message = `Critical error: ${error.message}. Plugin may not function properly.`; + break; + case ErrorSeverity.HIGH: + message = `Error in ${context.component}: ${error.message}`; + break; + case ErrorSeverity.MEDIUM: + message = `Warning: ${error.message}`; + break; + } + + if (message) { + new Notice(message, severity === ErrorSeverity.CRITICAL ? 10000 : 5000); + } + } + + /** + * 치명적 에러 처리 + */ + private handleCriticalError(error: Error, context: ErrorContext): void { + this.logger.error('CRITICAL ERROR - Too many errors detected', { + error, + context + }); + + new Notice( + 'Speech-to-Text plugin is experiencing issues. Please restart Obsidian.', + 15000 + ); + + // 플러그인 비활성화 고려 + // this.plugin.unload(); + } + + /** + * 컴포넌트별 에러 핸들러 등록 + */ + public registerErrorHandler( + component: string, + handler: (error: Error) => void + ): void { + this.errorHandlers.set(component, handler); + } + + /** + * 에러 통계 반환 + */ + public getErrorStats(): Record { + const stats: Record = {}; + this.errorCount.forEach((count, key) => { + stats[key] = count; + }); + return stats; + } + + /** + * 에러 카운트 리셋 + */ + public resetErrorCounts(): void { + this.errorCount.clear(); + this.logger.info('Error counts reset'); + } +} \ No newline at end of file diff --git a/src/architecture/PluginLifecycleManager.ts b/src/architecture/PluginLifecycleManager.ts new file mode 100644 index 0000000..812b7b4 --- /dev/null +++ b/src/architecture/PluginLifecycleManager.ts @@ -0,0 +1,252 @@ +import { Plugin, App, WorkspaceLeaf } from 'obsidian'; +import { Logger } from '../infrastructure/logging/Logger'; + +/** + * 플러그인 생명주기 단계 정의 + */ +export enum LifecyclePhase { + UNINITIALIZED = 'uninitialized', + INITIALIZING = 'initializing', + SERVICES_READY = 'services_ready', + UI_READY = 'ui_ready', + READY = 'ready', + SHUTTING_DOWN = 'shutting_down', + SHUTDOWN = 'shutdown' +} + +/** + * 초기화 작업 인터페이스 + */ +export interface InitializationTask { + name: string; + phase: LifecyclePhase; + priority: number; + execute: () => Promise; + rollback?: () => Promise; + dependencies?: string[]; +} + +/** + * 플러그인 생명주기 관리자 + * 플러그인의 초기화, 종료 과정을 체계적으로 관리 + */ +export class PluginLifecycleManager { + private currentPhase: LifecyclePhase = LifecyclePhase.UNINITIALIZED; + private tasks: Map = new Map(); + private completedTasks: Set = new Set(); + private logger: Logger; + private cleanupHandlers: Array<() => Promise> = []; + + constructor( + private app: App, + private plugin: Plugin + ) { + this.logger = new Logger('LifecycleManager'); + } + + /** + * 현재 생명주기 단계 반환 + */ + public getCurrentPhase(): LifecyclePhase { + return this.currentPhase; + } + + /** + * 초기화 작업 등록 + */ + public registerTask(task: InitializationTask): void { + this.tasks.set(task.name, task); + this.logger.debug(`Registered task: ${task.name} for phase ${task.phase}`); + } + + /** + * 정리 핸들러 등록 + */ + public registerCleanupHandler(handler: () => Promise): void { + this.cleanupHandlers.push(handler); + } + + /** + * 플러그인 초기화 실행 + */ + public async initialize(): Promise { + this.logger.info('Starting plugin initialization'); + this.currentPhase = LifecyclePhase.INITIALIZING; + + try { + // Phase 1: Core Services + await this.executePhase(LifecyclePhase.INITIALIZING); + this.currentPhase = LifecyclePhase.SERVICES_READY; + + // Phase 2: UI Components (Workspace가 준비된 후) + if (this.app.workspace.layoutReady) { + await this.initializeUI(); + } else { + this.app.workspace.onLayoutReady(async () => { + await this.initializeUI(); + }); + } + + } catch (error) { + this.logger.error('Initialization failed', error); + await this.rollback(); + throw error; + } + } + + /** + * UI 초기화 + */ + private async initializeUI(): Promise { + try { + await this.executePhase(LifecyclePhase.UI_READY); + this.currentPhase = LifecyclePhase.READY; + this.logger.info('Plugin initialization completed'); + } catch (error) { + this.logger.error('UI initialization failed', error); + // UI 실패는 전체 롤백하지 않고 graceful degradation + this.handleUIInitializationError(error); + } + } + + /** + * 특정 단계의 작업들 실행 + */ + private async executePhase(phase: LifecyclePhase): Promise { + const phaseTasks = Array.from(this.tasks.values()) + .filter(task => task.phase === phase) + .sort((a, b) => a.priority - b.priority); + + for (const task of phaseTasks) { + await this.executeTask(task); + } + } + + /** + * 개별 작업 실행 + */ + private async executeTask(task: InitializationTask): Promise { + // 의존성 확인 + if (task.dependencies) { + for (const dep of task.dependencies) { + if (!this.completedTasks.has(dep)) { + throw new Error(`Task ${task.name} depends on ${dep} which is not completed`); + } + } + } + + try { + this.logger.debug(`Executing task: ${task.name}`); + await task.execute(); + this.completedTasks.add(task.name); + this.logger.debug(`Completed task: ${task.name}`); + } catch (error) { + this.logger.error(`Task ${task.name} failed`, error); + throw new TaskExecutionError(task.name, error); + } + } + + /** + * UI 초기화 실패 처리 + */ + private handleUIInitializationError(error: any): void { + this.logger.warn('UI initialization failed, running in degraded mode'); + // StatusBar나 SettingsTab 없이도 기본 기능은 동작하도록 + this.currentPhase = LifecyclePhase.READY; + } + + /** + * 초기화 롤백 + */ + private async rollback(): Promise { + this.logger.info('Rolling back initialization'); + + const completedTasksList = Array.from(this.completedTasks) + .reverse() + .map(name => this.tasks.get(name)) + .filter(task => task && task.rollback); + + for (const task of completedTasksList) { + if (task?.rollback) { + try { + await task.rollback(); + this.logger.debug(`Rolled back task: ${task.name}`); + } catch (error) { + this.logger.error(`Failed to rollback task ${task.name}`, error); + } + } + } + + this.completedTasks.clear(); + this.currentPhase = LifecyclePhase.UNINITIALIZED; + } + + /** + * 플러그인 종료 + */ + public async shutdown(): Promise { + this.logger.info('Starting plugin shutdown'); + this.currentPhase = LifecyclePhase.SHUTTING_DOWN; + + // 정리 핸들러 실행 + for (const handler of this.cleanupHandlers.reverse()) { + try { + await handler(); + } catch (error) { + this.logger.error('Cleanup handler failed', error); + } + } + + // 초기화된 작업들 롤백 + await this.rollback(); + + this.currentPhase = LifecyclePhase.SHUTDOWN; + this.logger.info('Plugin shutdown completed'); + } + + /** + * 작업이 실행 가능한지 확인 + */ + public canExecuteTask(taskName: string): boolean { + const task = this.tasks.get(taskName); + if (!task) return false; + + // 의존성 확인 + if (task.dependencies) { + return task.dependencies.every(dep => this.completedTasks.has(dep)); + } + + return true; + } + + /** + * 특정 단계에 도달했는지 확인 + */ + public hasReachedPhase(phase: LifecyclePhase): boolean { + const phaseOrder = [ + LifecyclePhase.UNINITIALIZED, + LifecyclePhase.INITIALIZING, + LifecyclePhase.SERVICES_READY, + LifecyclePhase.UI_READY, + LifecyclePhase.READY + ]; + + const currentIndex = phaseOrder.indexOf(this.currentPhase); + const targetIndex = phaseOrder.indexOf(phase); + + return currentIndex >= targetIndex; + } +} + +/** + * 작업 실행 에러 + */ +export class TaskExecutionError extends Error { + constructor( + public taskName: string, + public originalError: any + ) { + super(`Task ${taskName} failed: ${originalError?.message || originalError}`); + this.name = 'TaskExecutionError'; + } +} \ No newline at end of file diff --git a/src/domain/models/Settings.ts b/src/domain/models/Settings.ts index 583e6e5..09eef27 100644 --- a/src/domain/models/Settings.ts +++ b/src/domain/models/Settings.ts @@ -2,7 +2,8 @@ import { SelectionStrategy } from '../../infrastructure/api/providers/ITranscrib export interface SpeechToTextSettings { apiKey: string; - model: WhisperModel; + apiEndpoint?: string; // Added for custom API endpoint support + model: WhisperModel | string; // Allow string for compatibility language: LanguageCode; autoInsert: boolean; insertPosition: InsertPosition; diff --git a/src/main-fixed.ts b/src/main-fixed.ts new file mode 100644 index 0000000..4a76a5f --- /dev/null +++ b/src/main-fixed.ts @@ -0,0 +1,532 @@ +import { Plugin, Notice, PluginSettingTab, App, MarkdownView, Modal, Setting, ButtonComponent, TFile } from 'obsidian'; + +// 기본 설정 인터페이스 +interface SpeechToTextSettings { + provider: string; + apiKey: string; + whisperApiKey: string; + deepgramApiKey: string; + language: string; + enableCache: boolean; + insertPosition: 'cursor' | 'end' | 'beginning'; + textFormat: string; + addTimestamp: boolean; + timestampFormat: string; + autoInsert: boolean; + showFormatOptions: boolean; + selectionStrategy: string; + costLimit: number; + qualityThreshold: number; + abTestEnabled: boolean; + abTestSplit: number; +} + +const DEFAULT_SETTINGS: SpeechToTextSettings = { + provider: 'auto', + apiKey: '', + whisperApiKey: '', + deepgramApiKey: '', + language: 'auto', + enableCache: true, + insertPosition: 'cursor', + textFormat: 'plain', + addTimestamp: false, + timestampFormat: 'YYYY-MM-DD HH:mm:ss', + autoInsert: true, + showFormatOptions: false, + selectionStrategy: 'performance_optimized', + costLimit: 10, + qualityThreshold: 0.9, + abTestEnabled: false, + abTestSplit: 0.5 +}; + +// 메인 플러그인 클래스 +export default class SpeechToTextPlugin extends Plugin { + settings: SpeechToTextSettings; + statusBarItem: HTMLElement | null = null; + stateManager: any; + eventManager: any; + editorService: any; + textInsertionHandler: any; + transcriptionService: any; + logger: any; + errorHandler: any; + settingsManager: any; + + async onload() { + console.log('Loading Speech-to-Text plugin'); + + try { + // 1. 설정 먼저 로드 + await this.loadSettings(); + + // 2. 서비스 초기화 + await this.initializeServices(); + + // 3. 명령어 등록 + this.registerCommands(); + + // 4. 설정 탭 등록 - 올바른 방식으로 + this.addSettingTab(new SpeechToTextSettingTab(this.app, this)); + + // 5. 이벤트 핸들러 등록 + this.registerEventHandlers(); + + // 6. UI가 준비된 후 StatusBar 생성 + this.app.workspace.onLayoutReady(() => { + this.createStatusBarItem(); + }); + + new Notice('Speech-to-Text plugin loaded successfully'); + } catch (error) { + console.error('Failed to load Speech-to-Text plugin:', error); + new Notice('Failed to load Speech-to-Text plugin. Check console for details.'); + } + } + + onunload() { + console.log('Unloading Speech-to-Text plugin'); + + // StatusBar 아이템 제거 + if (this.statusBarItem) { + this.statusBarItem.remove(); + } + + // 이벤트 핸들러 정리 + this.cleanupEventHandlers(); + + // 서비스 정리 + if (this.editorService) { + this.editorService.destroy(); + } + if (this.textInsertionHandler) { + this.textInsertionHandler.destroy(); + } + } + + async initializeServices() { + // 임시 구현 - 실제 서비스는 나중에 구현 + this.stateManager = { + subscribe: (callback: Function) => { + // 상태 관리자 구독 로직 + }, + setState: (state: any) => { + // 상태 설정 로직 + } + }; + + this.eventManager = { + on: (event: string, callback: Function) => { + // 이벤트 리스너 등록 + }, + removeAllListeners: () => { + // 모든 리스너 제거 + } + }; + } + + registerCommands() { + // 명령어 등록 + this.addCommand({ + id: 'transcribe-audio', + name: 'Transcribe audio file', + callback: () => { + this.showAudioFilePicker(); + } + }); + + this.addCommand({ + id: 'show-settings', + name: 'Open Speech-to-Text settings', + callback: () => { + // @ts-ignore - 옵시디언 내부 API + this.app.setting.open(); + // @ts-ignore + this.app.setting.openTabById(this.manifest.id); + } + }); + } + + registerEventHandlers() { + // 이벤트 핸들러 등록 + this.eventManager.on('transcription:start', (data: any) => { + this.updateStatusBar('🎙️ Transcribing...'); + }); + + this.eventManager.on('transcription:complete', (data: any) => { + this.updateStatusBar('✅ Complete'); + setTimeout(() => this.updateStatusBar(''), 3000); + }); + + this.eventManager.on('transcription:error', (data: any) => { + this.updateStatusBar('❌ Error'); + setTimeout(() => this.updateStatusBar(''), 3000); + }); + } + + cleanupEventHandlers() { + if (this.eventManager) { + this.eventManager.removeAllListeners(); + } + } + + /** + * StatusBar 아이템 생성 - 올바른 방식 + */ + createStatusBarItem() { + try { + if (!this.app.workspace) { + console.warn('Workspace not ready, skipping status bar creation'); + return; + } + + // addStatusBarItem()은 HTMLElement를 반환 + this.statusBarItem = this.addStatusBarItem(); + + if (!this.statusBarItem) { + console.warn('Failed to create status bar item'); + return; + } + + // 초기 텍스트 설정 + this.statusBarItem.setText('Speech-to-Text Ready'); + + // 클릭 이벤트 추가 (선택사항) + this.statusBarItem.onClickEvent(() => { + new Notice('Speech-to-Text: Click to start recording'); + }); + + // 상태 관리자 구독 + if (this.stateManager) { + this.stateManager.subscribe((state: any) => { + this.updateStatusBarFromState(state); + }); + } + } catch (error) { + console.error('Error creating status bar item:', error); + } + } + + /** + * StatusBar 텍스트 업데이트 - 올바른 방식 + */ + updateStatusBar(text: string) { + if (this.statusBarItem) { + // HTMLElement의 setText 메서드 사용 (옵시디언이 확장한 메서드) + // 또는 textContent/innerText 직접 사용 + if ('setText' in this.statusBarItem) { + (this.statusBarItem as any).setText(text); + } else { + // Fallback: DOM API 직접 사용 + this.statusBarItem.textContent = text; + } + } + } + + updateStatusBarFromState(state: any) { + if (!this.statusBarItem) return; + + switch (state.status) { + case 'idle': + this.updateStatusBar(''); + break; + case 'processing': + this.updateStatusBar('🎙️ Transcribing...'); + break; + case 'completed': + this.updateStatusBar('✅ Complete'); + setTimeout(() => this.updateStatusBar(''), 3000); + break; + case 'error': + this.updateStatusBar('❌ Error'); + setTimeout(() => this.updateStatusBar(''), 3000); + break; + } + } + + async showAudioFilePicker() { + const audioFiles = this.app.vault.getFiles().filter( + file => ['m4a', 'mp3', 'wav', 'mp4'].includes(file.extension) + ); + + if (audioFiles.length === 0) { + new Notice('No audio files found in vault'); + return; + } + + new AudioFilePickerModal(this.app, audioFiles, async (file) => { + await this.transcribeFile(file); + }).open(); + } + + async transcribeFile(file: TFile) { + new Notice(`Transcribing ${file.name}...`); + // 실제 transcription 로직은 나중에 구현 + } + + async loadSettings() { + this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); + } + + async saveSettings() { + await this.saveData(this.settings); + } +} + +// 설정 탭 클래스 - 올바른 구현 +class SpeechToTextSettingTab extends PluginSettingTab { + plugin: SpeechToTextPlugin; + + constructor(app: App, plugin: SpeechToTextPlugin) { + super(app, plugin); + this.plugin = plugin; + } + + display(): void { + const { containerEl } = this; + containerEl.empty(); + + // 설정 헤더 + containerEl.createEl('h2', { text: 'Speech-to-Text 설정' }); + containerEl.createEl('p', { + text: '음성을 텍스트로 변환하는 플러그인 설정을 구성합니다.', + cls: 'setting-item-description' + }); + + // Provider 설정 + new Setting(containerEl) + .setName('Provider') + .setDesc('음성 인식 서비스 제공자를 선택하세요') + .addDropdown(dropdown => dropdown + .addOption('auto', '자동 선택') + .addOption('whisper', 'OpenAI Whisper') + .addOption('deepgram', 'Deepgram') + .setValue(this.plugin.settings.provider) + .onChange(async (value) => { + this.plugin.settings.provider = value; + await this.plugin.saveSettings(); + this.display(); // 화면 새로고침 + })); + + // Whisper API Key (provider가 whisper 또는 auto일 때만 표시) + if (this.plugin.settings.provider === 'whisper' || this.plugin.settings.provider === 'auto') { + new Setting(containerEl) + .setName('OpenAI Whisper API Key') + .setDesc('OpenAI API 키를 입력하세요') + .addText(text => { + text.setPlaceholder('sk-...') + .setValue(this.maskApiKey(this.plugin.settings.whisperApiKey)) + .onChange(async (value) => { + if (!value.includes('***')) { + this.plugin.settings.whisperApiKey = value; + await this.plugin.saveSettings(); + } + }); + text.inputEl.type = 'password'; + }) + .addButton(button => button + .setButtonText('표시/숨기기') + .onClick(() => { + const input = containerEl.querySelector('.setting-item:has([placeholder="sk-..."]) input') as HTMLInputElement; + if (input) { + input.type = input.type === 'password' ? 'text' : 'password'; + if (input.type === 'text') { + input.value = this.plugin.settings.whisperApiKey; + } else { + input.value = this.maskApiKey(this.plugin.settings.whisperApiKey); + } + } + })); + } + + // Deepgram API Key (provider가 deepgram 또는 auto일 때만 표시) + if (this.plugin.settings.provider === 'deepgram' || this.plugin.settings.provider === 'auto') { + new Setting(containerEl) + .setName('Deepgram API Key') + .setDesc('Deepgram API 키를 입력하세요') + .addText(text => { + text.setPlaceholder('Enter your Deepgram API key') + .setValue(this.maskApiKey(this.plugin.settings.deepgramApiKey)) + .onChange(async (value) => { + if (!value.includes('***')) { + this.plugin.settings.deepgramApiKey = value; + await this.plugin.saveSettings(); + } + }); + text.inputEl.type = 'password'; + }); + } + + // 언어 설정 + new Setting(containerEl) + .setName('언어') + .setDesc('음성 인식 언어를 선택하세요') + .addDropdown(dropdown => dropdown + .addOption('auto', '자동 감지') + .addOption('ko', '한국어') + .addOption('en', 'English') + .addOption('ja', '日本語') + .addOption('zh', '中文') + .setValue(this.plugin.settings.language) + .onChange(async (value) => { + this.plugin.settings.language = value; + await this.plugin.saveSettings(); + })); + + // 텍스트 삽입 위치 + new Setting(containerEl) + .setName('텍스트 삽입 위치') + .setDesc('변환된 텍스트를 삽입할 위치를 선택하세요') + .addDropdown(dropdown => dropdown + .addOption('cursor', '커서 위치') + .addOption('end', '문서 끝') + .addOption('beginning', '문서 시작') + .setValue(this.plugin.settings.insertPosition) + .onChange(async (value: 'cursor' | 'end' | 'beginning') => { + this.plugin.settings.insertPosition = value; + await this.plugin.saveSettings(); + })); + + // 캐시 활성화 + new Setting(containerEl) + .setName('캐시 사용') + .setDesc('음성 인식 결과를 캐시하여 성능을 향상시킵니다') + .addToggle(toggle => toggle + .setValue(this.plugin.settings.enableCache) + .onChange(async (value) => { + this.plugin.settings.enableCache = value; + await this.plugin.saveSettings(); + })); + + // 타임스탬프 추가 + new Setting(containerEl) + .setName('타임스탬프 추가') + .setDesc('변환된 텍스트에 타임스탬프를 추가합니다') + .addToggle(toggle => toggle + .setValue(this.plugin.settings.addTimestamp) + .onChange(async (value) => { + this.plugin.settings.addTimestamp = value; + await this.plugin.saveSettings(); + })); + + // 설정 내보내기/가져오기 버튼 + const buttonContainer = containerEl.createDiv('setting-item'); + + new ButtonComponent(buttonContainer) + .setButtonText('설정 내보내기') + .onClick(() => this.exportSettings()); + + new ButtonComponent(buttonContainer) + .setButtonText('설정 가져오기') + .onClick(() => this.importSettings()); + + new ButtonComponent(buttonContainer) + .setButtonText('기본값으로 초기화') + .setWarning() + .onClick(() => this.resetSettings()); + } + + maskApiKey(key: string): string { + if (!key || key.length < 10) return ''; + return key.substring(0, 5) + '***' + key.substring(key.length - 4); + } + + async exportSettings() { + const settings = { ...this.plugin.settings }; + delete (settings as any).whisperApiKey; + delete (settings as any).deepgramApiKey; + + const json = JSON.stringify(settings, null, 2); + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + + const a = document.createElement('a'); + a.href = url; + a.download = `speech-to-text-settings-${Date.now()}.json`; + a.click(); + + URL.revokeObjectURL(url); + new Notice('설정을 내보냈습니다'); + } + + async importSettings() { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.json'; + + input.onchange = async (e) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (!file) return; + + try { + const text = await file.text(); + const settings = JSON.parse(text); + + // API 키는 보존 + const currentWhisperKey = this.plugin.settings.whisperApiKey; + const currentDeepgramKey = this.plugin.settings.deepgramApiKey; + + Object.assign(this.plugin.settings, settings); + + this.plugin.settings.whisperApiKey = currentWhisperKey; + this.plugin.settings.deepgramApiKey = currentDeepgramKey; + + await this.plugin.saveSettings(); + this.display(); + new Notice('설정을 가져왔습니다'); + } catch (error) { + new Notice('설정 가져오기 실패'); + console.error(error); + } + }; + + input.click(); + } + + async resetSettings() { + if (confirm('모든 설정을 기본값으로 초기화하시겠습니까?')) { + this.plugin.settings = Object.assign({}, DEFAULT_SETTINGS); + await this.plugin.saveSettings(); + this.display(); + new Notice('설정이 초기화되었습니다'); + } + } +} + +// 오디오 파일 선택 모달 +class AudioFilePickerModal extends Modal { + files: TFile[]; + onChoose: (file: TFile) => void; + + constructor(app: App, files: TFile[], onChoose: (file: TFile) => void) { + super(app); + this.files = files; + this.onChoose = onChoose; + } + + onOpen() { + const { contentEl } = this; + + contentEl.createEl('h2', { text: 'Select an audio file to transcribe' }); + + const fileList = contentEl.createEl('div', { cls: 'speech-to-text-file-list' }); + + this.files.forEach(file => { + const fileItem = fileList.createEl('div', { + cls: 'speech-to-text-file-item', + text: file.path + }); + + fileItem.addEventListener('click', () => { + this.onChoose(file); + this.close(); + }); + }); + } + + onClose() { + const { contentEl } = this; + contentEl.empty(); + } +} \ No newline at end of file diff --git a/src/main-refactored.ts b/src/main-refactored.ts new file mode 100644 index 0000000..65ab28f --- /dev/null +++ b/src/main-refactored.ts @@ -0,0 +1,528 @@ +import { Plugin, App, Notice, MarkdownView } from 'obsidian'; +import { DependencyContainer, ServiceTokens } from './architecture/DependencyContainer'; +import { PluginLifecycleManager, LifecyclePhase, InitializationTask } from './architecture/PluginLifecycleManager'; +import { ErrorBoundary } from './architecture/ErrorBoundary'; +import { StatusBarManager } from './ui/managers/StatusBarManager'; +import { SettingsTabManager } from './ui/managers/SettingsTabManager'; +import { Logger } from './infrastructure/logging/Logger'; +import { ErrorHandler } from './utils/ErrorHandler'; +import { SettingsManager } from './infrastructure/storage/SettingsManager'; +import { StateManager } from './application/StateManager'; +import { EventManager } from './application/EventManager'; +import { EditorService } from './application/EditorService'; +import { TextInsertionHandler } from './application/TextInsertionHandler'; +import { TranscriptionService } from './core/transcription/TranscriptionService'; +import { WhisperService } from './infrastructure/api/WhisperService'; +import { AudioProcessor } from './core/transcription/AudioProcessor'; +import { TextFormatter } from './core/transcription/TextFormatter'; +import { FilePickerModal } from './ui/modals/FilePickerModal'; +import { FormatOptionsModal } from './ui/formatting/FormatOptions'; +import { DEFAULT_SETTINGS } from './domain/models/Settings'; + +/** + * 리팩토링된 Speech to Text 플러그인 + * 체계적인 아키텍처와 에러 처리를 통한 안정성 향상 + */ +export default class SpeechToTextPlugin extends Plugin { + // 아키텍처 컴포넌트 + private container!: DependencyContainer; + private lifecycleManager!: PluginLifecycleManager; + private errorBoundary!: ErrorBoundary; + + // UI 관리자 + private statusBarManager?: StatusBarManager; + private settingsTabManager?: SettingsTabManager; + + // 핵심 서비스 + private logger!: Logger; + private settingsManager!: SettingsManager; + private stateManager!: StateManager; + private eventManager!: EventManager; + private editorService!: EditorService; + private transcriptionService!: TranscriptionService; + + // 설정 + public settings: any; + public manifest = { version: "1.0.0" }; + + /** + * 플러그인 로드 + */ + async onload() { + console.log('Loading Speech-to-Text plugin (Refactored)'); + + try { + // 아키텍처 컴포넌트 초기화 + this.initializeArchitecture(); + + // 생명주기 작업 등록 + this.registerLifecycleTasks(); + + // 플러그인 초기화 실행 + await this.lifecycleManager.initialize(); + + console.log('Speech-to-Text plugin loaded successfully'); + new Notice('Speech-to-Text plugin loaded successfully'); + } catch (error) { + console.error('Failed to load Speech-to-Text plugin:', error); + new Notice('Failed to load Speech-to-Text plugin. Check console for details.'); + + // 최소 기능 모드로 폴백 + await this.fallbackToMinimalMode(); + } + } + + /** + * 플러그인 언로드 + */ + async onunload() { + console.log('Unloading Speech-to-Text plugin'); + + if (this.lifecycleManager) { + await this.lifecycleManager.shutdown(); + } + + if (this.container) { + this.container.dispose(); + } + + console.log('Speech-to-Text plugin unloaded'); + } + + /** + * 아키텍처 컴포넌트 초기화 + */ + private initializeArchitecture(): void { + // 의존성 컨테이너 생성 + this.container = new DependencyContainer(); + + // 에러 경계 생성 + this.errorBoundary = new ErrorBoundary(); + + // 생명주기 관리자 생성 + this.lifecycleManager = new PluginLifecycleManager(this.app, this); + + // 기본 서비스 등록 + this.registerCoreServices(); + } + + /** + * 핵심 서비스 등록 + */ + private registerCoreServices(): void { + // App과 Plugin 인스턴스 등록 + this.container.registerInstance(ServiceTokens.App, this.app); + this.container.registerInstance(ServiceTokens.Plugin, this); + + // Logger + this.container.registerSingleton(ServiceTokens.Logger, () => { + return new Logger('SpeechToText'); + }); + + // ErrorHandler + this.container.registerSingleton(ServiceTokens.ErrorHandler, (container) => { + const logger = container.resolve(ServiceTokens.Logger); + return new ErrorHandler(logger); + }); + + // StateManager + this.container.registerSingleton(ServiceTokens.StateManager, () => { + return new StateManager(); + }); + + // EventManager + this.container.registerSingleton(ServiceTokens.EventManager, () => { + return new EventManager(); + }); + + // SettingsManager + this.container.registerSingleton(ServiceTokens.SettingsManager, () => { + return new SettingsManager(this); + }); + } + + /** + * 생명주기 작업 등록 + */ + private registerLifecycleTasks(): void { + // Phase 1: Core Services 초기화 + this.lifecycleManager.registerTask({ + name: 'LoadSettings', + phase: LifecyclePhase.INITIALIZING, + priority: 1, + execute: async () => { + await this.loadSettings(); + } + }); + + this.lifecycleManager.registerTask({ + name: 'InitializeCoreServices', + phase: LifecyclePhase.INITIALIZING, + priority: 2, + execute: async () => { + await this.initializeCoreServices(); + }, + dependencies: ['LoadSettings'] + }); + + this.lifecycleManager.registerTask({ + name: 'RegisterCommands', + phase: LifecyclePhase.INITIALIZING, + priority: 3, + execute: async () => { + this.registerCommands(); + }, + dependencies: ['InitializeCoreServices'] + }); + + this.lifecycleManager.registerTask({ + name: 'RegisterEventHandlers', + phase: LifecyclePhase.INITIALIZING, + priority: 4, + execute: async () => { + this.registerEventHandlers(); + }, + dependencies: ['InitializeCoreServices'] + }); + + // Phase 2: UI Components (Workspace 준비 후) + this.lifecycleManager.registerTask({ + name: 'InitializeStatusBar', + phase: LifecyclePhase.UI_READY, + priority: 1, + execute: async () => { + await this.initializeStatusBar(); + } + }); + + this.lifecycleManager.registerTask({ + name: 'InitializeSettingsTab', + phase: LifecyclePhase.UI_READY, + priority: 2, + execute: async () => { + await this.initializeSettingsTab(); + } + }); + + // 정리 핸들러 등록 + this.lifecycleManager.registerCleanupHandler(async () => { + await this.cleanupServices(); + }); + } + + /** + * 설정 로드 + */ + private async loadSettings(): Promise { + this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); + this.logger = this.container.resolve(ServiceTokens.Logger); + this.logger.info('Settings loaded'); + } + + /** + * 핵심 서비스 초기화 + */ + private async initializeCoreServices(): Promise { + // 서비스 인스턴스 가져오기 + this.logger = this.container.resolve(ServiceTokens.Logger); + this.settingsManager = this.container.resolve(ServiceTokens.SettingsManager); + this.stateManager = this.container.resolve(ServiceTokens.StateManager); + this.eventManager = this.container.resolve(ServiceTokens.EventManager); + + // EditorService 초기화 + this.editorService = new EditorService( + this.app, + this.eventManager, + this.logger + ); + this.container.registerInstance(ServiceTokens.EditorService, this.editorService); + + // TranscriptionService 초기화 + const whisperService = new WhisperService(this.settings.apiKey, this.logger); + const audioProcessor = new AudioProcessor(this.app.vault, this.logger); + const textFormatter = new TextFormatter(this.settings); + + this.transcriptionService = new TranscriptionService( + whisperService, + audioProcessor, + textFormatter, + this.eventManager, + this.logger + ); + this.container.registerInstance(ServiceTokens.TranscriptionService, this.transcriptionService); + + this.logger.info('Core services initialized'); + } + + /** + * StatusBar 초기화 + */ + private async initializeStatusBar(): Promise { + await this.errorBoundary.wrap(async () => { + this.statusBarManager = new StatusBarManager(this, this.stateManager); + await this.statusBarManager.initialize(); + this.container.registerInstance(ServiceTokens.StatusBarManager, this.statusBarManager); + this.logger.info('StatusBar initialized'); + }, { component: 'StatusBarManager', operation: 'initialize' }); + } + + /** + * SettingsTab 초기화 + */ + private async initializeSettingsTab(): Promise { + await this.errorBoundary.wrap(async () => { + this.settingsTabManager = new SettingsTabManager(this.app, this); + await this.settingsTabManager.initialize(); + this.container.registerInstance(ServiceTokens.SettingsTabManager, this.settingsTabManager); + this.logger.info('SettingsTab initialized'); + }, { component: 'SettingsTabManager', operation: 'initialize' }); + } + + /** + * 명령어 등록 + */ + private registerCommands(): void { + // 오디오 파일 변환 + this.addCommand({ + id: 'transcribe-audio', + name: 'Transcribe audio file', + callback: () => { + this.errorBoundary.wrap( + () => this.showAudioFilePicker(), + { component: 'Command', operation: 'transcribe-audio' } + ); + } + }); + + // 클립보드 변환 + this.addCommand({ + id: 'transcribe-clipboard', + name: 'Transcribe audio from clipboard', + callback: async () => { + await this.errorBoundary.wrap( + async () => { + new Notice('Clipboard transcription not yet implemented'); + }, + { component: 'Command', operation: 'transcribe-clipboard' } + ); + } + }); + + // 텍스트 포맷 옵션 + this.addCommand({ + id: 'show-format-options', + name: 'Show text formatting options', + callback: () => { + this.errorBoundary.wrap( + () => this.showFormatOptions(), + { component: 'Command', operation: 'show-format-options' } + ); + } + }); + + // 변환 취소 + this.addCommand({ + id: 'cancel-transcription', + name: 'Cancel current transcription', + callback: () => { + this.errorBoundary.wrap( + () => { + this.transcriptionService.cancel(); + new Notice('Transcription cancelled'); + }, + { component: 'Command', operation: 'cancel-transcription' } + ); + } + }); + + this.logger.debug('Commands registered'); + } + + /** + * 이벤트 핸들러 등록 + */ + private registerEventHandlers(): void { + // 변환 시작 + this.eventManager.on('transcription:start', (data) => { + this.stateManager.setState({ status: 'processing' }); + new Notice(`Transcribing: ${data.fileName}`); + }); + + // 변환 완료 + this.eventManager.on('transcription:complete', async (data) => { + this.stateManager.setState({ status: 'completed' }); + new Notice('Transcription completed successfully'); + + if (this.settings.autoInsert && data.text) { + await this.insertTranscription(data.text); + } + }); + + // 변환 에러 + this.eventManager.on('transcription:error', (data) => { + this.stateManager.setState({ status: 'error', error: data.error }); + new Notice(`Transcription failed: ${data.error.message}`); + }); + + // 진행 상황 + this.eventManager.on('transcription:progress', (data) => { + this.stateManager.setState({ progress: data.progress }); + }); + + this.logger.debug('Event handlers registered'); + } + + /** + * 오디오 파일 선택기 표시 + */ + private async showAudioFilePicker(): Promise { + const audioFiles = this.app.vault.getFiles().filter(file => + ['m4a', 'mp3', 'wav', 'mp4'].includes(file.extension) + ); + + if (audioFiles.length === 0) { + new Notice('No audio files found in vault'); + return; + } + + new FilePickerModal(this.app, audioFiles, async (file) => { + await this.transcribeFile(file); + }).open(); + } + + /** + * 파일 변환 + */ + private async transcribeFile(file: any): Promise { + if (!this.settings.apiKey) { + new Notice('Please configure your API key in settings'); + return; + } + + try { + const result = await this.transcriptionService.transcribe(file); + + if (this.settings.showFormatOptions) { + this.showFormatOptionsWithText(result.text); + } else { + await this.insertTranscription(result.text); + } + } catch (error) { + this.errorBoundary.handleError( + error as Error, + { component: 'TranscriptionService', operation: 'transcribe' } + ); + } + } + + /** + * 포맷 옵션 표시 + */ + private showFormatOptions(): void { + this.showFormatOptionsWithText(''); + } + + /** + * 텍스트와 함께 포맷 옵션 표시 + */ + private showFormatOptionsWithText(text: string): void { + new FormatOptionsModal( + this.app, + { + mode: 'cursor', + format: this.settings.textFormat || 'plain', + addTimestamp: this.settings.addTimestamp || false, + language: this.settings.language + }, + async (options) => { + if (text) { + await this.insertTranscription(text); + } else { + new Notice('No text to insert'); + } + }, + () => { + this.logger.debug('Format options cancelled'); + } + ).open(); + } + + /** + * 변환 텍스트 삽입 + */ + private async insertTranscription(text: string): Promise { + const activeView = this.app.workspace.getActiveViewOfType(MarkdownView); + + if (!activeView) { + // 새 노트 생성 + const fileName = `Transcription ${new Date().toISOString()}.md`; + const file = await this.app.vault.create(fileName, text); + await this.app.workspace.openLinkText(file.path, '', true); + return; + } + + const editor = activeView.editor; + + switch (this.settings.insertPosition) { + case 'cursor': + editor.replaceSelection(text); + break; + case 'end': + const lastLine = editor.lastLine(); + const currentText = editor.getLine(lastLine); + editor.setLine(lastLine, currentText + '\n\n' + text); + break; + case 'beginning': + const firstLineText = editor.getLine(0); + editor.setLine(0, text + '\n\n' + firstLineText); + break; + } + } + + /** + * 서비스 정리 + */ + private async cleanupServices(): Promise { + // StatusBar 정리 + if (this.statusBarManager) { + this.statusBarManager.dispose(); + } + + // SettingsTab은 Plugin이 자동 정리 + + // 이벤트 정리 + if (this.eventManager) { + this.eventManager.removeAllListeners(); + } + + // EditorService 정리 + if (this.editorService) { + this.editorService.destroy(); + } + + this.logger.info('Services cleaned up'); + } + + /** + * 최소 기능 모드로 폴백 + */ + private async fallbackToMinimalMode(): Promise { + console.log('Falling back to minimal mode'); + + // 최소한의 명령어만 등록 + this.addCommand({ + id: 'transcribe-audio-minimal', + name: 'Transcribe audio file (Minimal)', + callback: () => { + new Notice('Plugin is running in minimal mode. Please restart Obsidian.'); + } + }); + } + + /** + * 설정 저장 + */ + async saveSettings(): Promise { + await this.saveData(this.settings); + } +} \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index 5a763b1..da52259 100644 --- a/src/main.ts +++ b/src/main.ts @@ -44,8 +44,10 @@ export default class SpeechToTextPlugin extends Plugin { // Register event handlers this.registerEventHandlers(); - // Add status bar item - this.createStatusBarItem(); + // Add status bar item after a short delay to ensure workspace is ready + this.app.workspace.onLayoutReady(() => { + this.createStatusBarItem(); + }); new Notice('Speech-to-Text plugin loaded successfully'); } catch (error) { @@ -239,27 +241,73 @@ export default class SpeechToTextPlugin extends Plugin { } private createStatusBarItem() { - const statusBarItem = this.addStatusBarItem(); - - // Update status bar based on state - this.stateManager.subscribe((state) => { - switch (state.status) { - case 'idle': - statusBarItem.setText(''); - break; - case 'processing': - statusBarItem.setText('🎙️ Transcribing...'); - break; - case 'completed': - statusBarItem.setText('✅ Transcription complete'); - setTimeout(() => statusBarItem.setText(''), 3000); - break; - case 'error': - statusBarItem.setText('❌ Transcription failed'); - setTimeout(() => statusBarItem.setText(''), 3000); - break; + try { + // Ensure the app workspace is ready + if (!this.app.workspace) { + console.warn('Workspace not ready, skipping status bar creation'); + return; } - }); + + // Create status bar item with a safe approach + let statusBarItem: HTMLElement | null = null; + + try { + // addStatusBarItem() returns an HTMLElement + statusBarItem = this.addStatusBarItem(); + } catch (e) { + console.warn('Failed to create status bar item:', e); + return; + } + + // Ensure statusBarItem exists before using it + if (!statusBarItem) { + console.warn('Failed to create status bar item'); + return; + } + + // Set initial text + statusBarItem.setText = statusBarItem.setText || ((text: string) => { + statusBarItem!.textContent = text; + }); + + // Update status bar based on state + this.stateManager.subscribe((state) => { + if (!statusBarItem) { + return; + } + + const updateText = (text: string) => { + if (statusBarItem) { + // Safe text update + if (typeof statusBarItem.setText === 'function') { + statusBarItem.setText(text); + } else { + statusBarItem.textContent = text; + } + } + }; + + switch (state.status) { + case 'idle': + updateText(''); + break; + case 'processing': + updateText('🎙️ Transcribing...'); + break; + case 'completed': + updateText('✅ Transcription complete'); + setTimeout(() => updateText(''), 3000); + break; + case 'error': + updateText('❌ Transcription failed'); + setTimeout(() => updateText(''), 3000); + break; + } + }); + } catch (error) { + console.error('Error creating status bar item:', error); + // Continue without status bar - non-critical feature + } } private async showAudioFilePicker() { diff --git a/src/testing/TestingFramework.ts b/src/testing/TestingFramework.ts new file mode 100644 index 0000000..cae2edb --- /dev/null +++ b/src/testing/TestingFramework.ts @@ -0,0 +1,324 @@ +import { App, Plugin, WorkspaceLeaf } from 'obsidian'; +import { DependencyContainer } from '../architecture/DependencyContainer'; + +/** + * 모의 객체 생성 유틸리티 + */ +export class MockFactory { + /** + * App 모의 객체 생성 + */ + static createMockApp(): Partial { + return { + vault: { + getFiles: jest.fn().mockReturnValue([]), + create: jest.fn(), + read: jest.fn(), + modify: jest.fn(), + delete: jest.fn(), + rename: jest.fn(), + getAbstractFileByPath: jest.fn() + } as any, + workspace: { + getActiveViewOfType: jest.fn(), + openLinkText: jest.fn(), + onLayoutReady: jest.fn((callback) => callback()), + layoutReady: true, + activeLeaf: null as any + } as any, + metadataCache: { + getFileCache: jest.fn(), + getCache: jest.fn() + } as any + }; + } + + /** + * Plugin 모의 객체 생성 + */ + static createMockPlugin(): Partial { + return { + app: MockFactory.createMockApp() as App, + manifest: { version: '1.0.0' } as any, + addCommand: jest.fn(), + addSettingTab: jest.fn(), + addStatusBarItem: jest.fn().mockReturnValue({ + setText: jest.fn(), + remove: jest.fn() + }), + loadData: jest.fn().mockResolvedValue({}), + saveData: jest.fn().mockResolvedValue(undefined), + registerEvent: jest.fn(), + registerInterval: jest.fn() + }; + } + + /** + * StatusBar 아이템 모의 객체 생성 + */ + static createMockStatusBarItem(): HTMLElement { + const element = document.createElement('div'); + (element as any).setText = jest.fn(); + (element as any).remove = jest.fn(); + return element; + } + + /** + * Editor 모의 객체 생성 + */ + static createMockEditor() { + return { + getValue: jest.fn().mockReturnValue(''), + setValue: jest.fn(), + getLine: jest.fn().mockReturnValue(''), + setLine: jest.fn(), + lastLine: jest.fn().mockReturnValue(0), + replaceSelection: jest.fn(), + getCursor: jest.fn().mockReturnValue({ line: 0, ch: 0 }), + setCursor: jest.fn(), + getSelection: jest.fn().mockReturnValue(''), + somethingSelected: jest.fn().mockReturnValue(false) + }; + } +} + +/** + * 테스트 환경 설정 + */ +export class TestEnvironment { + private container: DependencyContainer; + private mockApp: Partial; + private mockPlugin: Partial; + + constructor() { + this.container = new DependencyContainer(); + this.mockApp = MockFactory.createMockApp(); + this.mockPlugin = MockFactory.createMockPlugin(); + } + + /** + * 테스트 환경 설정 + */ + async setup(): Promise { + // 의존성 등록 + this.container.registerInstance('App', this.mockApp); + this.container.registerInstance('Plugin', this.mockPlugin); + + // 기본 서비스 모의 객체 등록 + this.registerMockServices(); + } + + /** + * 모의 서비스 등록 + */ + private registerMockServices(): void { + // Logger 모의 객체 + this.container.registerSingleton('Logger', () => ({ + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn() + })); + + // StateManager 모의 객체 + this.container.registerSingleton('StateManager', () => ({ + getState: jest.fn().mockReturnValue({ status: 'idle' }), + setState: jest.fn(), + subscribe: jest.fn().mockReturnValue(() => {}), + reset: jest.fn() + })); + + // EventManager 모의 객체 + this.container.registerSingleton('EventManager', () => ({ + emit: jest.fn(), + on: jest.fn(), + off: jest.fn(), + removeAllListeners: jest.fn() + })); + } + + /** + * 테스트 환경 정리 + */ + async teardown(): Promise { + this.container.dispose(); + jest.clearAllMocks(); + } + + /** + * 의존성 컨테이너 반환 + */ + getContainer(): DependencyContainer { + return this.container; + } + + /** + * 모의 App 반환 + */ + getMockApp(): Partial { + return this.mockApp; + } + + /** + * 모의 Plugin 반환 + */ + getMockPlugin(): Partial { + return this.mockPlugin; + } +} + +/** + * 테스트 헬퍼 함수 + */ +export class TestHelpers { + /** + * 비동기 작업 대기 + */ + static async waitFor( + condition: () => boolean, + timeout: number = 5000, + interval: number = 100 + ): Promise { + const startTime = Date.now(); + + while (!condition()) { + if (Date.now() - startTime > timeout) { + throw new Error('Timeout waiting for condition'); + } + await new Promise(resolve => setTimeout(resolve, interval)); + } + } + + /** + * 이벤트 발생 대기 + */ + static waitForEvent( + eventManager: any, + eventName: string, + timeout: number = 5000 + ): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(`Timeout waiting for event: ${eventName}`)); + }, timeout); + + const handler = (data: any) => { + clearTimeout(timer); + resolve(data); + }; + + eventManager.once(eventName, handler); + }); + } + + /** + * 에러 발생 확인 + */ + static async expectError( + fn: () => Promise, + errorMessage?: string + ): Promise { + let errorThrown = false; + + try { + await fn(); + } catch (error: any) { + errorThrown = true; + if (errorMessage && !error.message.includes(errorMessage)) { + throw new Error(`Expected error message to include "${errorMessage}", but got: ${error.message}`); + } + } + + if (!errorThrown) { + throw new Error('Expected function to throw an error'); + } + } + + /** + * 상태 변경 시뮬레이션 + */ + static simulateStateChange( + stateManager: any, + states: Array<{ status: string; delay?: number }> + ): Promise { + return states.reduce(async (promise, state) => { + await promise; + if (state.delay) { + await new Promise(resolve => setTimeout(resolve, state.delay)); + } + stateManager.setState({ status: state.status }); + }, Promise.resolve()); + } +} + +/** + * 통합 테스트 유틸리티 + */ +export class IntegrationTestUtils { + /** + * 플러그인 초기화 테스트 + */ + static async testPluginInitialization( + pluginClass: any, + options: { expectSuccess: boolean } = { expectSuccess: true } + ): Promise { + const env = new TestEnvironment(); + await env.setup(); + + const plugin = new pluginClass(); + plugin.app = env.getMockApp() as App; + + if (options.expectSuccess) { + await plugin.onload(); + expect(plugin.isLoaded).toBe(true); + } else { + await TestHelpers.expectError(() => plugin.onload()); + } + + await env.teardown(); + } + + /** + * UI 컴포넌트 테스트 + */ + static async testUIComponent( + componentClass: any, + setupFn?: (component: any) => void + ): Promise { + const env = new TestEnvironment(); + await env.setup(); + + const component = new componentClass( + env.getMockApp(), + env.getMockPlugin() + ); + + if (setupFn) { + setupFn(component); + } + + await component.initialize(); + + return { component, env }; + } + + /** + * 서비스 테스트 + */ + static async testService( + serviceClass: any, + dependencies: Record = {} + ): Promise { + const env = new TestEnvironment(); + await env.setup(); + + // 의존성 등록 + Object.entries(dependencies).forEach(([key, value]) => { + env.getContainer().registerInstance(key, value); + }); + + const service = env.getContainer().resolve(serviceClass); + + return { service, env }; + } +} \ No newline at end of file diff --git a/src/ui/managers/SettingsTabManager.ts b/src/ui/managers/SettingsTabManager.ts new file mode 100644 index 0000000..78d0026 --- /dev/null +++ b/src/ui/managers/SettingsTabManager.ts @@ -0,0 +1,234 @@ +import { App, Plugin, PluginSettingTab } from 'obsidian'; +import { Logger } from '../../infrastructure/logging/Logger'; +import { IDisposable } from '../../architecture/DependencyContainer'; +import { SettingsTab } from '../settings/SettingsTab'; + +/** + * SettingsTab 관리자 + * 설정 탭의 안전한 생성과 관리를 담당 + */ +export class SettingsTabManager implements IDisposable { + private settingsTab: PluginSettingTab | null = null; + private logger: Logger; + private isDisposed: boolean = false; + + constructor( + private app: App, + private plugin: Plugin + ) { + this.logger = new Logger('SettingsTabManager'); + } + + /** + * SettingsTab 초기화 + */ + public async initialize(): Promise { + try { + // 이미 초기화된 경우 스킵 + if (this.settingsTab) { + this.logger.warn('SettingsTab already initialized'); + return; + } + + // SettingsTab 생성 전 검증 + if (!this.validateEnvironment()) { + this.logger.warn('Environment not ready for SettingsTab'); + return; + } + + // SettingsTab 생성 + this.createSettingsTab(); + + this.logger.info('SettingsTab initialized successfully'); + } catch (error) { + this.logger.error('Failed to initialize SettingsTab', error); + // SettingsTab 실패는 치명적이지 않으므로 에러를 던지지 않음 + this.handleInitializationError(error); + } + } + + /** + * 환경 검증 + */ + private validateEnvironment(): boolean { + // App 객체 검증 + if (!this.app) { + this.logger.error('App instance is not available'); + return false; + } + + // Plugin 객체 검증 + if (!this.plugin) { + this.logger.error('Plugin instance is not available'); + return false; + } + + // addSettingTab 메서드 검증 + if (typeof this.plugin.addSettingTab !== 'function') { + this.logger.error('addSettingTab method is not available'); + return false; + } + + return true; + } + + /** + * SettingsTab 생성 + */ + private createSettingsTab(): void { + try { + // SettingsTab 인스턴스 생성 (에러 처리 래핑) + const settingsTab = this.createSafeSettingsTab(); + + if (!settingsTab) { + this.logger.warn('Failed to create SettingsTab instance'); + return; + } + + // Plugin에 등록 + this.registerSettingsTab(settingsTab); + + this.settingsTab = settingsTab; + this.logger.debug('SettingsTab created and registered successfully'); + } catch (error) { + this.logger.error('Error creating SettingsTab', error); + this.settingsTab = null; + } + } + + /** + * 안전한 SettingsTab 생성 + */ + private createSafeSettingsTab(): PluginSettingTab | null { + try { + // SettingsTab 클래스가 제대로 로드되었는지 확인 + if (!SettingsTab) { + this.logger.error('SettingsTab class is not available'); + return null; + } + + // 타입 체크를 통한 안전한 생성 + const tab = new SettingsTab(this.app, this.plugin as any); + + // 생성된 객체 검증 + if (!tab || typeof tab.display !== 'function') { + this.logger.error('Invalid SettingsTab instance created'); + return null; + } + + return tab; + } catch (error) { + this.logger.error('Failed to instantiate SettingsTab', error); + return null; + } + } + + /** + * SettingsTab 등록 + */ + private registerSettingsTab(settingsTab: PluginSettingTab): void { + try { + this.plugin.addSettingTab(settingsTab); + this.logger.debug('SettingsTab registered with plugin'); + } catch (error) { + this.logger.error('Failed to register SettingsTab', error); + throw error; + } + } + + /** + * 초기화 에러 처리 + */ + private handleInitializationError(error: any): void { + // 에러 타입에 따른 처리 + if (error?.message?.includes('toLowerCase')) { + this.logger.error('String method error detected, possibly due to incorrect parameter types'); + } else if (error?.message?.includes('undefined')) { + this.logger.error('Undefined reference error, checking dependencies'); + } + + // 폴백 처리: 기본 설정 탭 생성 시도 + this.tryCreateFallbackSettingsTab(); + } + + /** + * 폴백 SettingsTab 생성 시도 + */ + private tryCreateFallbackSettingsTab(): void { + try { + // 최소한의 기능을 가진 설정 탭 생성 + const fallbackTab = new MinimalSettingsTab(this.app, this.plugin); + this.plugin.addSettingTab(fallbackTab); + this.settingsTab = fallbackTab; + this.logger.info('Fallback SettingsTab created'); + } catch (error) { + this.logger.error('Failed to create fallback SettingsTab', error); + } + } + + /** + * SettingsTab 새로고침 + */ + public refresh(): void { + if (!this.settingsTab || this.isDisposed) { + return; + } + + try { + if (typeof this.settingsTab.display === 'function') { + this.settingsTab.display(); + this.logger.debug('SettingsTab refreshed'); + } + } catch (error) { + this.logger.error('Failed to refresh SettingsTab', error); + } + } + + /** + * SettingsTab 사용 가능 여부 확인 + */ + public isAvailable(): boolean { + return this.settingsTab !== null && !this.isDisposed; + } + + /** + * 현재 SettingsTab 인스턴스 반환 + */ + public getSettingsTab(): PluginSettingTab | null { + return this.settingsTab; + } + + /** + * 리소스 정리 + */ + public dispose(): void { + if (this.isDisposed) return; + + this.isDisposed = true; + + // SettingsTab은 Plugin이 자동으로 정리하므로 별도 처리 불필요 + this.settingsTab = null; + + this.logger.info('SettingsTabManager disposed'); + } +} + +/** + * 최소 기능 SettingsTab (폴백용) + */ +class MinimalSettingsTab extends PluginSettingTab { + constructor(app: App, plugin: Plugin) { + super(app, plugin); + } + + display(): void { + const { containerEl } = this; + containerEl.empty(); + + containerEl.createEl('h2', { text: 'Speech to Text Settings' }); + containerEl.createEl('p', { + text: 'Settings are temporarily unavailable. Please restart Obsidian if this persists.', + cls: 'settings-error-message' + }); + } +} \ No newline at end of file diff --git a/src/ui/managers/StatusBarManager.ts b/src/ui/managers/StatusBarManager.ts new file mode 100644 index 0000000..56749fb --- /dev/null +++ b/src/ui/managers/StatusBarManager.ts @@ -0,0 +1,275 @@ +import { Plugin, WorkspaceLeaf } from 'obsidian'; +import { Logger } from '../../infrastructure/logging/Logger'; +import { StateManager } from '../../application/StateManager'; +import { IDisposable } from '../../architecture/DependencyContainer'; + +/** + * StatusBar 아이템 설정 + */ +interface StatusBarConfig { + text: string; + tooltip?: string; + className?: string; + hideAfter?: number; // 밀리초 단위 +} + +/** + * StatusBar 관리자 + * StatusBar 아이템의 안전한 생성과 관리를 담당 + */ +export class StatusBarManager implements IDisposable { + private statusBarItem: HTMLElement | null = null; + private hideTimeout: NodeJS.Timeout | null = null; + private unsubscribe: (() => void) | null = null; + private logger: Logger; + private isDisposed: boolean = false; + + constructor( + private plugin: Plugin, + private stateManager: StateManager + ) { + this.logger = new Logger('StatusBarManager'); + } + + /** + * StatusBar 초기화 + */ + public async initialize(): Promise { + try { + // workspace가 준비되었는지 확인 + if (!this.plugin.app.workspace) { + this.logger.warn('Workspace not ready for StatusBar'); + return; + } + + // StatusBar 아이템 생성 시도 + this.createStatusBarItem(); + + // 상태 구독 설정 + if (this.statusBarItem) { + this.subscribeToStateChanges(); + } + + this.logger.info('StatusBar initialized successfully'); + } catch (error) { + this.logger.error('Failed to initialize StatusBar', error); + // StatusBar 실패는 치명적이지 않으므로 에러를 던지지 않음 + } + } + + /** + * StatusBar 아이템 생성 + */ + private createStatusBarItem(): void { + try { + // addStatusBarItem이 함수인지 확인 + if (typeof this.plugin.addStatusBarItem !== 'function') { + this.logger.warn('addStatusBarItem is not available'); + return; + } + + // StatusBar 아이템 생성 + const item = this.plugin.addStatusBarItem(); + + // 반환값 검증 + if (!item || typeof item !== 'object') { + this.logger.warn('Invalid StatusBar item returned'); + return; + } + + this.statusBarItem = item; + this.logger.debug('StatusBar item created successfully'); + } catch (error) { + this.logger.error('Error creating StatusBar item', error); + this.statusBarItem = null; + } + } + + /** + * 상태 변경 구독 + */ + private subscribeToStateChanges(): void { + this.unsubscribe = this.stateManager.subscribe((state) => { + if (this.isDisposed) return; + + const config = this.getStatusConfigForState(state); + this.updateStatus(config); + }); + } + + /** + * 상태에 따른 StatusBar 설정 생성 + */ + private getStatusConfigForState(state: any): StatusBarConfig { + switch (state.status) { + case 'idle': + return { text: '' }; + + case 'processing': + return { + text: '🎙️ Transcribing...', + tooltip: 'Processing audio transcription', + className: 'status-processing' + }; + + case 'completed': + return { + text: '✅ Transcription complete', + tooltip: 'Transcription completed successfully', + className: 'status-success', + hideAfter: 3000 + }; + + case 'error': + return { + text: '❌ Transcription failed', + tooltip: state.error?.message || 'An error occurred', + className: 'status-error', + hideAfter: 5000 + }; + + default: + return { text: '' }; + } + } + + /** + * StatusBar 업데이트 + */ + public updateStatus(config: StatusBarConfig): void { + if (!this.statusBarItem || this.isDisposed) { + return; + } + + try { + // 이전 타이머 취소 + if (this.hideTimeout) { + clearTimeout(this.hideTimeout); + this.hideTimeout = null; + } + + // setText 메서드 존재 확인 + if (typeof (this.statusBarItem as any).setText === 'function') { + (this.statusBarItem as any).setText(config.text); + } else if ('textContent' in this.statusBarItem) { + // textContent 직접 설정 + this.statusBarItem.textContent = config.text; + } else { + this.logger.warn('Cannot update StatusBar text'); + return; + } + + // 툴팁 설정 + if (config.tooltip) { + this.statusBarItem.setAttribute('aria-label', config.tooltip); + this.statusBarItem.setAttribute('title', config.tooltip); + } + + // CSS 클래스 설정 + if (config.className) { + this.statusBarItem.className = `status-bar-item ${config.className}`; + } + + // 자동 숨김 설정 + if (config.hideAfter && config.hideAfter > 0) { + this.hideTimeout = setTimeout(() => { + this.clearStatus(); + }, config.hideAfter); + } + + } catch (error) { + this.logger.error('Failed to update StatusBar', error); + } + } + + /** + * StatusBar 텍스트 직접 설정 + */ + public setText(text: string): void { + this.updateStatus({ text }); + } + + /** + * StatusBar 지우기 + */ + public clearStatus(): void { + if (!this.statusBarItem || this.isDisposed) { + return; + } + + try { + if (typeof (this.statusBarItem as any).setText === 'function') { + (this.statusBarItem as any).setText(''); + } else if ('textContent' in this.statusBarItem) { + this.statusBarItem.textContent = ''; + } + } catch (error) { + this.logger.error('Failed to clear StatusBar', error); + } + } + + /** + * StatusBar 표시 + */ + public show(): void { + if (this.statusBarItem && 'style' in this.statusBarItem) { + (this.statusBarItem as HTMLElement).style.display = ''; + } + } + + /** + * StatusBar 숨기기 + */ + public hide(): void { + if (this.statusBarItem && 'style' in this.statusBarItem) { + (this.statusBarItem as HTMLElement).style.display = 'none'; + } + } + + /** + * StatusBar 사용 가능 여부 확인 + */ + public isAvailable(): boolean { + return this.statusBarItem !== null && !this.isDisposed; + } + + /** + * 리소스 정리 + */ + public dispose(): void { + if (this.isDisposed) return; + + this.isDisposed = true; + + // 타이머 정리 + if (this.hideTimeout) { + clearTimeout(this.hideTimeout); + this.hideTimeout = null; + } + + // 구독 해제 + if (this.unsubscribe) { + this.unsubscribe(); + this.unsubscribe = null; + } + + // StatusBar 아이템 제거 + if (this.statusBarItem) { + try { + // remove 메서드가 있으면 호출 + if (typeof (this.statusBarItem as any).remove === 'function') { + (this.statusBarItem as any).remove(); + } else if (this.statusBarItem.parentNode) { + // DOM에서 직접 제거 + this.statusBarItem.parentNode.removeChild(this.statusBarItem); + } + } catch (error) { + this.logger.error('Error removing StatusBar item', error); + } + + this.statusBarItem = null; + } + + this.logger.info('StatusBarManager disposed'); + } +} \ No newline at end of file diff --git a/src/ui/settings/SettingsTab.ts b/src/ui/settings/SettingsTab.ts index 080ec4d..fd859c0 100644 --- a/src/ui/settings/SettingsTab.ts +++ b/src/ui/settings/SettingsTab.ts @@ -1,420 +1,270 @@ -import { App, PluginSettingTab, Setting, Notice, Modal, ButtonComponent, TextComponent } from 'obsidian'; +import { App, PluginSettingTab, Setting, Notice } from 'obsidian'; import type SpeechToTextPlugin from '../../main'; -import { PluginSettings } from '../../infrastructure/storage/SettingsManager'; -import { ApiKeyValidator } from './components/ApiKeyValidator'; -import { ShortcutSettings } from './components/ShortcutSettings'; -import { AdvancedSettings } from './components/AdvancedSettings'; -import { GeneralSettings } from './components/GeneralSettings'; -import { AudioSettings } from './components/AudioSettings'; -import { ProviderSettings } from './components/ProviderSettings'; /** - * 설정 탭 UI 컴포넌트 - * 플러그인의 모든 설정을 관리하는 메인 설정 페이지 + * 간소화된 설정 탭 UI + * 핵심 기능만 포함하여 안정성을 높인 버전 */ export class SettingsTab extends PluginSettingTab { plugin: SpeechToTextPlugin; - private apiKeyValidator: ApiKeyValidator; - private shortcutSettings: ShortcutSettings; - private advancedSettings: AdvancedSettings; - private generalSettings: GeneralSettings; - private audioSettings: AudioSettings; - private providerSettings: ProviderSettings; constructor(app: App, plugin: SpeechToTextPlugin) { super(app, plugin); this.plugin = plugin; - - // 컴포넌트 초기화 - this.apiKeyValidator = new ApiKeyValidator(plugin); - this.shortcutSettings = new ShortcutSettings(app, plugin); - this.advancedSettings = new AdvancedSettings(plugin); - this.generalSettings = new GeneralSettings(plugin); - this.audioSettings = new AudioSettings(plugin); - this.providerSettings = new ProviderSettings(plugin); } display(): void { const { containerEl } = this; + + // Clear existing content containerEl.empty(); - containerEl.addClass('speech-to-text-settings'); - - // 헤더 - this.createHeader(containerEl); - - // 섹션별 설정 - this.createGeneralSection(containerEl); - this.createProviderSection(containerEl); // 새로운 Provider 섹션 - this.createAudioSection(containerEl); - this.createAdvancedSection(containerEl); - this.createShortcutSection(containerEl); - // 푸터 - this.createFooter(containerEl); - } - - /** - * 헤더 생성 - */ - private createHeader(containerEl: HTMLElement): void { - const headerEl = containerEl.createDiv({ cls: 'settings-header' }); + // Add main title + containerEl.createEl('h2', { text: 'Speech to Text Settings' }); - headerEl.createEl('h2', { - text: 'Speech to Text 설정', - cls: 'settings-title' - }); - - headerEl.createEl('p', { - text: '음성을 텍스트로 변환하는 플러그인 설정을 구성합니다.', - cls: 'settings-description' - }); - - // 상태 표시 - const statusEl = headerEl.createDiv({ cls: 'settings-status' }); - this.updateStatus(statusEl); - } - - /** - * 일반 설정 섹션 - */ - private createGeneralSection(containerEl: HTMLElement): void { - const sectionEl = this.createSection(containerEl, 'General', '기본 동작 설정'); - this.generalSettings.render(sectionEl); - } - - /** - * Provider 설정 섹션 - */ - private createProviderSection(containerEl: HTMLElement): void { - const sectionEl = this.createSection(containerEl, 'Provider', 'Transcription Provider 설정'); - this.providerSettings.render(sectionEl); - } - - /** - * 오디오 설정 섹션 - */ - private createAudioSection(containerEl: HTMLElement): void { - const sectionEl = this.createSection(containerEl, 'Audio', '음성 변환 설정'); - this.audioSettings.render(sectionEl); - } - - /** - * 고급 설정 섹션 - */ - private createAdvancedSection(containerEl: HTMLElement): void { - const sectionEl = this.createSection(containerEl, 'Advanced', '고급 설정'); - this.advancedSettings.render(sectionEl); - } - - /** - * 단축키 설정 섹션 - */ - private createShortcutSection(containerEl: HTMLElement): void { - const sectionEl = this.createSection(containerEl, 'Shortcuts', '단축키 설정'); - this.shortcutSettings.render(sectionEl); - } - - /** - * 푸터 생성 - */ - private createFooter(containerEl: HTMLElement): void { - const footerEl = containerEl.createDiv({ cls: 'settings-footer' }); - - // 설정 내보내기/가져오기 - const exportImportEl = footerEl.createDiv({ cls: 'settings-export-import' }); - - new ButtonComponent(exportImportEl) - .setButtonText('설정 내보내기') - .onClick(async () => { - await this.exportSettings(); - }); - - new ButtonComponent(exportImportEl) - .setButtonText('설정 가져오기') - .onClick(async () => { - await this.importSettings(); - }); - - // 초기화 버튼 - new ButtonComponent(footerEl) - .setButtonText('기본값으로 초기화') - .setWarning() - .onClick(async () => { - const confirmed = await this.confirmReset(); - if (confirmed) { - await this.resetSettings(); - } - }); - - // 버전 정보 - const versionEl = footerEl.createDiv({ cls: 'settings-version' }); - versionEl.createEl('small', { - text: `Version ${this.plugin.manifest.version} | `, - cls: 'version-text' - }); - - const linkEl = versionEl.createEl('a', { - text: '도움말', - href: 'https://github.com/yourusername/obsidian-speech-to-text', - cls: 'help-link' - }); - linkEl.setAttribute('target', '_blank'); - } - - /** - * 섹션 생성 헬퍼 - */ - private createSection(containerEl: HTMLElement, title: string, desc: string): HTMLElement { - const sectionEl = containerEl.createDiv({ cls: `settings-section settings-section-${title.toLowerCase()}` }); - - const headerEl = sectionEl.createDiv({ cls: 'section-header' }); - headerEl.createEl('h3', { text: title }); - headerEl.createEl('p', { text: desc, cls: 'section-description' }); - - const contentEl = sectionEl.createDiv({ cls: 'section-content' }); - - return contentEl; - } - - /** - * API 사용량 표시 - */ - private createApiUsageDisplay(containerEl: HTMLElement): void { - const usageEl = containerEl.createDiv({ cls: 'api-usage-display' }); - - usageEl.createEl('h4', { text: 'API 사용량' }); - - const statsEl = usageEl.createDiv({ cls: 'usage-stats' }); - - // 사용량 통계 (예시) - statsEl.createEl('div', { - text: '이번 달 사용량: 0 / 무제한', - cls: 'usage-item' - }); - - statsEl.createEl('div', { - text: '예상 비용: $0.00', - cls: 'usage-item' - }); - - // 새로고침 버튼 - new ButtonComponent(usageEl) - .setButtonText('사용량 새로고침') - .onClick(async () => { - // API 사용량 조회 로직 - new Notice('사용량 정보를 업데이트했습니다'); - }); - } - - /** - * 상태 업데이트 - */ - private updateStatus(statusEl: HTMLElement): void { - statusEl.empty(); - - const settings = this.plugin.settings; - const statusItems: Array<{ label: string; value: string; status: 'success' | 'warning' | 'error' }> = []; - - // API 키 상태 - if (settings.apiKey) { - statusItems.push({ - label: 'API 키', - value: '구성됨', - status: 'success' - }); - } else { - statusItems.push({ - label: 'API 키', - value: '미구성', - status: 'error' + try { + // API Settings Section + this.createApiSection(containerEl); + + // General Settings Section + this.createGeneralSection(containerEl); + + // Audio Settings Section + this.createAudioSection(containerEl); + + // Advanced Settings Section + this.createAdvancedSection(containerEl); + + } catch (error) { + console.error('Error displaying settings:', error); + containerEl.empty(); + containerEl.createEl('p', { + text: 'Error loading settings. Please reload the plugin.', + cls: 'mod-warning' }); } - - // 캐시 상태 - statusItems.push({ - label: '캐시', - value: settings.enableCache ? '활성화' : '비활성화', - status: settings.enableCache ? 'success' : 'warning' - }); - - // 언어 설정 - statusItems.push({ - label: '언어', - value: this.getLanguageLabel(settings.language), - status: 'success' - }); - - // 상태 아이템 렌더링 - statusItems.forEach(item => { - const itemEl = statusEl.createDiv({ cls: `status-item status-${item.status}` }); - itemEl.createEl('span', { text: `${item.label}: `, cls: 'status-label' }); - itemEl.createEl('span', { text: item.value, cls: 'status-value' }); - }); } - /** - * API 키 마스킹 - */ + private createApiSection(containerEl: HTMLElement): void { + containerEl.createEl('h3', { text: 'API Configuration' }); + + // API Key setting + new Setting(containerEl) + .setName('OpenAI API Key') + .setDesc('Enter your OpenAI API key for Whisper transcription') + .addText(text => { + text + .setPlaceholder('sk-...') + .setValue(this.maskApiKey(this.plugin.settings.apiKey || '')) + .onChange(async (value) => { + // Only update if it's a new key (not masked) + if (value && !value.includes('*')) { + this.plugin.settings.apiKey = value; + await this.plugin.saveSettings(); + + // Re-display to show masked version + text.setValue(this.maskApiKey(value)); + new Notice('API key saved'); + } + }); + + // Add input type password for security + text.inputEl.type = 'password'; + + // Show actual value on focus + text.inputEl.addEventListener('focus', () => { + if (this.plugin.settings.apiKey) { + text.setValue(this.plugin.settings.apiKey); + } + }); + + // Mask on blur + text.inputEl.addEventListener('blur', () => { + if (this.plugin.settings.apiKey) { + text.setValue(this.maskApiKey(this.plugin.settings.apiKey)); + } + }); + }); + + // API Endpoint (if custom endpoint is supported) + new Setting(containerEl) + .setName('API Endpoint') + .setDesc('OpenAI API endpoint (leave default unless using custom endpoint)') + .addText(text => text + .setPlaceholder('https://api.openai.com/v1') + .setValue(this.plugin.settings.apiEndpoint || 'https://api.openai.com/v1') + .onChange(async (value) => { + this.plugin.settings.apiEndpoint = value || 'https://api.openai.com/v1'; + await this.plugin.saveSettings(); + })); + } + + private createGeneralSection(containerEl: HTMLElement): void { + containerEl.createEl('h3', { text: 'General Settings' }); + + // Language setting + new Setting(containerEl) + .setName('Language') + .setDesc('Primary language for transcription') + .addDropdown(dropdown => dropdown + .addOption('auto', 'Auto-detect') + .addOption('en', 'English') + .addOption('ko', '한국어') + .addOption('ja', '日本語') + .addOption('zh', '中文') + .addOption('es', 'Español') + .addOption('fr', 'Français') + .addOption('de', 'Deutsch') + .setValue(this.plugin.settings.language || 'auto') + .onChange(async (value) => { + this.plugin.settings.language = value; + await this.plugin.saveSettings(); + })); + + // Auto-insert setting + new Setting(containerEl) + .setName('Auto-insert transcription') + .setDesc('Automatically insert transcribed text into the active note') + .addToggle(toggle => toggle + .setValue(this.plugin.settings.autoInsert || false) + .onChange(async (value) => { + this.plugin.settings.autoInsert = value; + await this.plugin.saveSettings(); + })); + + // Insert position + new Setting(containerEl) + .setName('Insert position') + .setDesc('Where to insert transcribed text') + .addDropdown(dropdown => dropdown + .addOption('cursor', 'At cursor position') + .addOption('end', 'At end of note') + .addOption('beginning', 'At beginning of note') + .setValue(this.plugin.settings.insertPosition || 'cursor') + .onChange(async (value) => { + this.plugin.settings.insertPosition = value as 'cursor' | 'end' | 'beginning'; + await this.plugin.saveSettings(); + })); + + // Show format options + new Setting(containerEl) + .setName('Show format options') + .setDesc('Show formatting options before inserting text') + .addToggle(toggle => toggle + .setValue(this.plugin.settings.showFormatOptions || false) + .onChange(async (value) => { + this.plugin.settings.showFormatOptions = value; + await this.plugin.saveSettings(); + })); + } + + private createAudioSection(containerEl: HTMLElement): void { + containerEl.createEl('h3', { text: 'Audio Settings' }); + + // Model selection + new Setting(containerEl) + .setName('Whisper Model') + .setDesc('Select the Whisper model to use (larger models are more accurate but slower)') + .addDropdown(dropdown => dropdown + .addOption('whisper-1', 'Whisper v1 (Default)') + .setValue(this.plugin.settings.model || 'whisper-1') + .onChange(async (value) => { + this.plugin.settings.model = value; + await this.plugin.saveSettings(); + })); + + // Temperature setting + new Setting(containerEl) + .setName('Temperature') + .setDesc('Sampling temperature (0-1). Lower values make output more focused and deterministic') + .addText(text => text + .setPlaceholder('0.0') + .setValue(String(this.plugin.settings.temperature || 0)) + .onChange(async (value) => { + const temp = parseFloat(value); + if (!isNaN(temp) && temp >= 0 && temp <= 1) { + this.plugin.settings.temperature = temp; + await this.plugin.saveSettings(); + } + })); + + // Add timestamp + new Setting(containerEl) + .setName('Add timestamp') + .setDesc('Add timestamp to transcribed text') + .addToggle(toggle => toggle + .setValue(this.plugin.settings.addTimestamp || false) + .onChange(async (value) => { + this.plugin.settings.addTimestamp = value; + await this.plugin.saveSettings(); + })); + } + + private createAdvancedSection(containerEl: HTMLElement): void { + containerEl.createEl('h3', { text: 'Advanced Settings' }); + + // Enable cache + new Setting(containerEl) + .setName('Enable cache') + .setDesc('Cache transcription results to avoid re-processing') + .addToggle(toggle => toggle + .setValue(this.plugin.settings.enableCache !== false) + .onChange(async (value) => { + this.plugin.settings.enableCache = value; + await this.plugin.saveSettings(); + })); + + // Debug mode + new Setting(containerEl) + .setName('Debug mode') + .setDesc('Enable debug logging in console') + .addToggle(toggle => toggle + .setValue(this.plugin.settings.debugMode || false) + .onChange(async (value) => { + this.plugin.settings.debugMode = value; + await this.plugin.saveSettings(); + + if (value) { + new Notice('Debug mode enabled. Check console for logs.'); + } + })); + + // Reset settings button + new Setting(containerEl) + .setName('Reset to defaults') + .setDesc('Reset all settings to their default values') + .addButton(button => button + .setButtonText('Reset') + .setWarning() + .onClick(async () => { + const confirmed = confirm('Are you sure you want to reset all settings to defaults?'); + if (confirmed) { + // Import default settings + const { DEFAULT_SETTINGS } = await import('../../domain/models/Settings'); + this.plugin.settings = { ...DEFAULT_SETTINGS }; + await this.plugin.saveSettings(); + + // Refresh the display + this.display(); + new Notice('Settings reset to defaults'); + } + })); + } + private maskApiKey(key: string): string { - if (!key || key.length < 10) return '***'; + if (!key || key.length < 10) { + return ''; + } + + // Show first 7 and last 4 characters const visibleStart = 7; const visibleEnd = 4; - const masked = '*'.repeat(Math.max(0, key.length - visibleStart - visibleEnd)); + + if (key.length <= visibleStart + visibleEnd) { + return key; + } + + const masked = '*'.repeat(key.length - visibleStart - visibleEnd); return key.substring(0, visibleStart) + masked + key.substring(key.length - visibleEnd); } - - /** - * 언어 레이블 가져오기 - */ - private getLanguageLabel(code: string): string { - const languages: Record = { - 'auto': '자동 감지', - 'en': 'English', - 'ko': '한국어', - 'ja': '日本語', - 'zh': '中文', - 'es': 'Español', - 'fr': 'Français', - 'de': 'Deutsch' - }; - return languages[code] || code; - } - - /** - * 설정 내보내기 - */ - private async exportSettings(): Promise { - try { - const settings = { ...this.plugin.settings }; - // API 키 제외 - delete (settings as any).apiKey; - delete (settings as any).encryptedApiKey; - - const json = JSON.stringify(settings, null, 2); - const blob = new Blob([json], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - - const a = document.createElement('a'); - a.href = url; - a.download = `speech-to-text-settings-${Date.now()}.json`; - a.click(); - - URL.revokeObjectURL(url); - new Notice('설정을 내보냈습니다'); - } catch (error) { - new Notice('설정 내보내기 실패'); - console.error(error); - } - } - - /** - * 설정 가져오기 - */ - private async importSettings(): Promise { - const input = document.createElement('input'); - input.type = 'file'; - input.accept = '.json'; - - input.onchange = async (e) => { - const file = (e.target as HTMLInputElement).files?.[0]; - if (!file) return; - - try { - const text = await file.text(); - const settings = JSON.parse(text); - - // 기존 API 키 보존 - const currentApiKey = this.plugin.settings.apiKey; - - // 설정 병합 - Object.assign(this.plugin.settings, settings); - - // API 키 복원 - if (currentApiKey) { - this.plugin.settings.apiKey = currentApiKey; - } - - await this.plugin.saveSettings(); - - new Notice('설정을 가져왔습니다'); - this.display(); // UI 새로고침 - } catch (error) { - new Notice('설정 가져오기 실패'); - console.error(error); - } - }; - - input.click(); - } - - /** - * 설정 초기화 확인 - */ - private async confirmReset(): Promise { - return new Promise((resolve) => { - const modal = new ConfirmModal( - this.app, - '설정 초기화', - '모든 설정을 기본값으로 초기화하시겠습니까? API 키도 삭제됩니다.', - resolve - ); - modal.open(); - }); - } - - /** - * 설정 초기화 - */ - private async resetSettings(): Promise { - // 기본 설정으로 초기화 - const { DEFAULT_SETTINGS } = await import('../../domain/models/Settings'); - this.plugin.settings = { ...DEFAULT_SETTINGS }; - await this.plugin.saveSettings(); - - new Notice('설정이 초기화되었습니다'); - this.display(); // UI 새로고침 - } -} - -/** - * 확인 모달 - */ -class ConfirmModal extends Modal { - constructor( - app: App, - private title: string, - private message: string, - private onConfirm: (confirmed: boolean) => void - ) { - super(app); - } - - onOpen() { - const { contentEl } = this; - - contentEl.createEl('h2', { text: this.title }); - contentEl.createEl('p', { text: this.message }); - - const buttonContainer = contentEl.createDiv({ cls: 'modal-button-container' }); - - new ButtonComponent(buttonContainer) - .setButtonText('취소') - .onClick(() => { - this.onConfirm(false); - this.close(); - }); - - new ButtonComponent(buttonContainer) - .setButtonText('확인') - .setWarning() - .onClick(() => { - this.onConfirm(true); - this.close(); - }); - } - - onClose() { - const { contentEl } = this; - contentEl.empty(); - } } \ No newline at end of file