mirror of
https://github.com/asyouplz/SpeechNote.git
synced 2026-07-22 06:43:33 +00:00
refactor(ui): consolidate settings architecture and wave3 fixes (#67)
* refactor(ui): consolidate settings architecture and review fixes Remove redundant settings/provider implementations to reduce maintenance overhead and align the UI with Obsidian helpers and English-only labels. Also ignore local review docs and update the release guide header section to English. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore(repo): ignore local claude and changelog docs --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6cbe785fcc
commit
f62f8eec5a
28 changed files with 223 additions and 8091 deletions
9
.gitignore
vendored
9
.gitignore
vendored
|
|
@ -159,6 +159,13 @@ requirment*.md
|
|||
|
||||
# Local review notes
|
||||
REVIEW_NOTES_PR8004.md
|
||||
docs/BRANCH_PROTECTION.md
|
||||
docs/LINT_RULES.md
|
||||
docs/obsidian-review-8004-issuecomment-3839142330.md
|
||||
docs/obsidian-review-8004-issuecomment-3839840641.md
|
||||
docs/obsidian-review-8004-issuecomment-3885815068-detailed-response.md
|
||||
docs/obsidian-review-8004-issuecomment-3885815068.md
|
||||
docs/obsidian-review-8004.md
|
||||
|
||||
# Lint and typecheck output files
|
||||
lint-output.txt
|
||||
|
|
@ -173,6 +180,8 @@ typecheck_output.txt
|
|||
AGENTS.ko.md
|
||||
AGENTS.md
|
||||
CODE_OF_CONDUCT.md
|
||||
CLAUDE.md
|
||||
CHANGELOG.md
|
||||
|
||||
# Semantic Release
|
||||
.semantic-release-cache/
|
||||
|
|
|
|||
4
LICENSE
4
LICENSE
|
|
@ -1,6 +1,6 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2025 Taesun Lee
|
||||
Copyright (c) 2026 Tesun Lee
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
|
@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
SOFTWARE.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Obsidian Speech-to-Text Plugin
|
||||
# Speech to Text for Obsidian
|
||||
|
||||
<!-- Test: Verifying Claude Code Review with new OAuth token - 2026-01-20 -->
|
||||
|
||||
|
|
@ -1138,6 +1138,6 @@ If a bad release is pushed:
|
|||
|
||||
**Made with ❤️ for the Obsidian community**
|
||||
|
||||
[⬆ Back to top](#obsidian-speech-to-text-plugin)
|
||||
[⬆ Back to top](#speech-to-text-for-obsidian)
|
||||
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,24 +1,24 @@
|
|||
# 릴리즈 및 버전 관리 가이드
|
||||
# Release and Version Management Guide
|
||||
|
||||
이 문서는 SpeechNote 플러그인의 릴리즈 프로세스와 버전 관리 방법을 설명합니다.
|
||||
This document describes the release process and version management approach for the SpeechNote plugin.
|
||||
|
||||
## 버전 관리 시스템
|
||||
## Versioning System
|
||||
|
||||
### 버전 형식
|
||||
### Version Format
|
||||
|
||||
Semantic Versioning (x.y.z) 사용:
|
||||
Use Semantic Versioning (`x.y.z`):
|
||||
|
||||
- **Major (x)**: Breaking changes
|
||||
- **Minor (y)**: 새로운 기능 추가 (하위 호환)
|
||||
- **Patch (z)**: 버그 수정
|
||||
- **Minor (y)**: New backward-compatible features
|
||||
- **Patch (z)**: Bug fixes
|
||||
|
||||
### 버전 관리 파일
|
||||
### Versioned Files
|
||||
|
||||
다음 파일들이 버전을 포함하고 있으며, 버전 bump 시 자동으로 업데이트됩니다:
|
||||
The following files contain version information and are updated automatically during a version bump:
|
||||
|
||||
- `manifest.json` - Obsidian 플러그인 버전
|
||||
- `package.json` - npm 패키지 버전
|
||||
- `versions.json` - Obsidian 버전 호환성 정보
|
||||
- `manifest.json` - Obsidian plugin version
|
||||
- `package.json` - npm package version
|
||||
- `versions.json` - Obsidian version compatibility map
|
||||
|
||||
## 릴리즈 방법
|
||||
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@
|
|||
"name": "Speech to Text",
|
||||
"version": "4.0.7",
|
||||
"minAppVersion": "0.15.0",
|
||||
"description": "Convert audio recordings to text in Obsidian using multiple AI providers (OpenAI Whisper, Deepgram)",
|
||||
"description": "Convert audio recordings to text using multiple AI providers (OpenAI Whisper, Deepgram)",
|
||||
"author": "Taesun Lee",
|
||||
"authorUrl": "https://github.com/asyouplz",
|
||||
"fundingUrl": "",
|
||||
"fundingUrl": "https://buymeacoffee.com/asyouplz",
|
||||
"isDesktopOnly": false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,12 @@
|
|||
import { App, MarkdownView, Editor, EditorPosition, EditorRange, Notice } from 'obsidian';
|
||||
import {
|
||||
App,
|
||||
Component,
|
||||
MarkdownView,
|
||||
Editor,
|
||||
EditorPosition,
|
||||
EditorRange,
|
||||
Notice,
|
||||
} from 'obsidian';
|
||||
import { Logger } from '../infrastructure/logging/Logger';
|
||||
import { EventManager } from './EventManager';
|
||||
|
||||
|
|
@ -12,7 +20,7 @@ import { EventManager } from './EventManager';
|
|||
* - 에디터 상태 관리
|
||||
* - Undo/Redo 지원
|
||||
*/
|
||||
export class EditorService {
|
||||
export class EditorService extends Component {
|
||||
private activeEditor: Editor | null = null;
|
||||
private activeView: MarkdownView | null = null;
|
||||
private readonly logger: Logger;
|
||||
|
|
@ -20,16 +28,28 @@ export class EditorService {
|
|||
private readonly redoHistory: EditorAction[] = [];
|
||||
private readonly maxHistorySize = 50;
|
||||
private destroyed = false;
|
||||
private readonly eventRefs: Array<{ event: string; callback: (...args: unknown[]) => void }> =
|
||||
[];
|
||||
|
||||
constructor(
|
||||
private app: App,
|
||||
private eventManager: EventManager = EventManager.getInstance(),
|
||||
logger?: Logger
|
||||
) {
|
||||
super();
|
||||
this.logger = logger || new Logger('EditorService');
|
||||
}
|
||||
|
||||
onload(): void {
|
||||
this.destroyed = false;
|
||||
this.setupEventListeners();
|
||||
this.updateActiveEditor();
|
||||
}
|
||||
|
||||
onunload(): void {
|
||||
this.destroyed = true;
|
||||
this.clearHistory();
|
||||
this.activeEditor = null;
|
||||
this.activeView = null;
|
||||
this.logger.debug('EditorService unloaded');
|
||||
}
|
||||
|
||||
private normalizeError(error: unknown): Error {
|
||||
|
|
@ -65,8 +85,7 @@ export class EditorService {
|
|||
}
|
||||
this.updateActiveEditor();
|
||||
};
|
||||
workspace.on('active-leaf-change', activeLeafCallback);
|
||||
this.eventRefs.push({ event: 'active-leaf-change', callback: activeLeafCallback });
|
||||
this.registerEvent(workspace.on('active-leaf-change', activeLeafCallback));
|
||||
|
||||
// 에디터 변경 감지
|
||||
const editorChangeCallback = (...args: unknown[]) => {
|
||||
|
|
@ -80,8 +99,7 @@ export class EditorService {
|
|||
this.activeEditor = editor;
|
||||
this.eventManager.emit('editor:changed', { editor });
|
||||
};
|
||||
workspace.on('editor-change', editorChangeCallback);
|
||||
this.eventRefs.push({ event: 'editor-change', callback: editorChangeCallback });
|
||||
this.registerEvent(workspace.on('editor-change', editorChangeCallback));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -667,20 +685,7 @@ export class EditorService {
|
|||
* 클린업
|
||||
*/
|
||||
destroy(): void {
|
||||
this.destroyed = true;
|
||||
|
||||
// Remove all event listeners
|
||||
for (const ref of this.eventRefs) {
|
||||
if (typeof this.app.workspace.off === 'function') {
|
||||
this.app.workspace.off(ref.event, ref.callback);
|
||||
}
|
||||
}
|
||||
this.eventRefs.length = 0;
|
||||
|
||||
this.clearHistory();
|
||||
this.activeEditor = null;
|
||||
this.activeView = null;
|
||||
this.logger.debug('EditorService destroyed');
|
||||
this.unload();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -235,7 +235,6 @@ export const ServiceTokens = {
|
|||
ErrorHandler: Symbol.for('ErrorHandler'),
|
||||
NotificationService: Symbol.for('NotificationService'),
|
||||
StatusBarManager: Symbol.for('StatusBarManager'),
|
||||
SettingsTabManager: Symbol.for('SettingsTabManager'),
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,295 +0,0 @@
|
|||
/**
|
||||
* LazyLoader - Phase 4 Performance Optimization
|
||||
*
|
||||
* 동적 모듈 로딩을 통한 번들 사이즈 최적화
|
||||
* - 초기 로딩 시간 단축
|
||||
* - 메모리 사용량 감소
|
||||
* - 코드 스플리팅 지원
|
||||
*/
|
||||
|
||||
export interface LazyLoadOptions {
|
||||
preload?: boolean;
|
||||
timeout?: number;
|
||||
fallback?: unknown;
|
||||
onError?: (error: Error) => void;
|
||||
onLoad?: (module: unknown) => void;
|
||||
}
|
||||
|
||||
import { requestUrl } from 'obsidian';
|
||||
|
||||
export interface LoadingState {
|
||||
isLoading: boolean;
|
||||
error?: Error;
|
||||
module?: unknown;
|
||||
}
|
||||
|
||||
function normalizeError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error('Unknown error');
|
||||
}
|
||||
|
||||
export class LazyLoader {
|
||||
private static loadedModules = new Map<string, unknown>();
|
||||
private static loadingPromises = new Map<string, Promise<unknown>>();
|
||||
private static preloadQueue = new Set<string>();
|
||||
private static loadingStates = new Map<string, LoadingState>();
|
||||
|
||||
/**
|
||||
* 모듈을 동적으로 로드
|
||||
*/
|
||||
static async loadModule<T>(modulePath: string, options: LazyLoadOptions = {}): Promise<T> {
|
||||
// 이미 로드된 모듈 반환
|
||||
if (this.loadedModules.has(modulePath)) {
|
||||
return this.loadedModules.get(modulePath) as T;
|
||||
}
|
||||
|
||||
// 현재 로딩 중인 경우 기존 Promise 반환
|
||||
if (this.loadingPromises.has(modulePath)) {
|
||||
return this.loadingPromises.get(modulePath) as Promise<T>;
|
||||
}
|
||||
|
||||
// 로딩 상태 업데이트
|
||||
this.updateLoadingState(modulePath, { isLoading: true });
|
||||
|
||||
// 타임아웃 설정
|
||||
const timeoutMs = options.timeout || 30000;
|
||||
const loadPromise = this.performLoad<T>(modulePath, options);
|
||||
|
||||
const timeoutPromise = new Promise<T>((_, reject) => {
|
||||
setTimeout(() => {
|
||||
reject(new Error(`Module loading timeout: ${modulePath}`));
|
||||
}, timeoutMs);
|
||||
});
|
||||
|
||||
// 로딩 Promise 저장
|
||||
const promise = Promise.race([loadPromise, timeoutPromise]);
|
||||
this.loadingPromises.set(modulePath, promise);
|
||||
|
||||
try {
|
||||
const module = await promise;
|
||||
this.loadedModules.set(modulePath, module);
|
||||
this.updateLoadingState(modulePath, {
|
||||
isLoading: false,
|
||||
module,
|
||||
});
|
||||
|
||||
if (options.onLoad) {
|
||||
options.onLoad(module);
|
||||
}
|
||||
|
||||
return module;
|
||||
} catch (error) {
|
||||
this.updateLoadingState(modulePath, {
|
||||
isLoading: false,
|
||||
error: normalizeError(error),
|
||||
});
|
||||
|
||||
if (options.onError) {
|
||||
options.onError(normalizeError(error));
|
||||
}
|
||||
|
||||
if (options.fallback !== undefined) {
|
||||
return options.fallback as T;
|
||||
}
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
this.loadingPromises.delete(modulePath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 실제 모듈 로딩 수행
|
||||
*/
|
||||
private static async performLoad<T>(modulePath: string, _options: LazyLoadOptions): Promise<T> {
|
||||
try {
|
||||
// 동적 import를 통한 모듈 로드
|
||||
const module = await this.dynamicImport(modulePath);
|
||||
const resolved = (module as { default?: unknown }).default ?? module;
|
||||
return resolved as T;
|
||||
} catch (error) {
|
||||
console.error(`Failed to load module: ${modulePath}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 동적 import 래퍼 (컴포넌트별 최적화)
|
||||
*/
|
||||
private static dynamicImport(modulePath: string): Promise<unknown> {
|
||||
// UI 컴포넌트 매핑
|
||||
const moduleMap: Record<string, () => Promise<unknown>> = {
|
||||
// Dashboard components (낮은 우선순위)
|
||||
StatisticsDashboard: () => import('../ui/dashboard/StatisticsDashboard'),
|
||||
|
||||
// Settings components (중간 우선순위)
|
||||
AdvancedSettings: () => import('../ui/settings/components/AdvancedSettings'),
|
||||
ShortcutSettings: () => import('../ui/settings/components/ShortcutSettings'),
|
||||
AudioSettings: () => import('../ui/settings/components/AudioSettings'),
|
||||
|
||||
// Modal components (높은 우선순위)
|
||||
FilePickerModal: () => import('../ui/modals/FilePickerModal'),
|
||||
|
||||
// Progress components
|
||||
CircularProgress: () => import('../ui/progress/CircularProgress'),
|
||||
ProgressBar: () => import('../ui/progress/ProgressBar'),
|
||||
|
||||
// Notification components
|
||||
NotificationSystem: () => import('../ui/notifications/NotificationSystem'),
|
||||
};
|
||||
|
||||
const importFn = moduleMap[modulePath];
|
||||
if (!importFn) {
|
||||
throw new Error(`Unknown module: ${modulePath}`);
|
||||
}
|
||||
|
||||
return importFn();
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 모듈을 미리 로드 (백그라운드)
|
||||
*/
|
||||
static preloadModules(modulePaths: string[]): void {
|
||||
modulePaths.forEach((path) => {
|
||||
if (!this.loadedModules.has(path) && !this.preloadQueue.has(path)) {
|
||||
this.preloadQueue.add(path);
|
||||
}
|
||||
});
|
||||
|
||||
// Idle 시간에 백그라운드 로드
|
||||
if ('requestIdleCallback' in window) {
|
||||
requestIdleCallback(() => void this.processPreloadQueue());
|
||||
} else {
|
||||
setTimeout(() => void this.processPreloadQueue(), 1000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload 큐 처리
|
||||
*/
|
||||
private static async processPreloadQueue(): Promise<void> {
|
||||
for (const modulePath of this.preloadQueue) {
|
||||
try {
|
||||
await this.loadModule(modulePath, { preload: true });
|
||||
this.preloadQueue.delete(modulePath);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to preload module: ${modulePath}`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 리소스 프리로딩 (link prefetch)
|
||||
*/
|
||||
static preloadResources(resources: string[]): void {
|
||||
resources.forEach((resource) => {
|
||||
void requestUrl({ url: resource, method: 'GET' }).catch((error) => {
|
||||
console.warn(`Failed to preload resource: ${resource}`, error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 모듈 언로드 (메모리 절약)
|
||||
*/
|
||||
static unloadModule(modulePath: string): void {
|
||||
this.loadedModules.delete(modulePath);
|
||||
this.loadingStates.delete(modulePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 로드된 모듈 언로드
|
||||
*/
|
||||
static unloadAll(): void {
|
||||
this.loadedModules.clear();
|
||||
this.loadingPromises.clear();
|
||||
this.preloadQueue.clear();
|
||||
this.loadingStates.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 로딩 상태 업데이트
|
||||
*/
|
||||
private static updateLoadingState(modulePath: string, state: LoadingState): void {
|
||||
this.loadingStates.set(modulePath, state);
|
||||
}
|
||||
|
||||
/**
|
||||
* 로딩 상태 조회
|
||||
*/
|
||||
static getLoadingState(modulePath: string): LoadingState | undefined {
|
||||
return this.loadingStates.get(modulePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 모듈 로드 여부 확인
|
||||
*/
|
||||
static isLoaded(modulePath: string): boolean {
|
||||
return this.loadedModules.has(modulePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 로드된 모듈 통계
|
||||
*/
|
||||
static getStats(): {
|
||||
loadedCount: number;
|
||||
loadingCount: number;
|
||||
preloadCount: number;
|
||||
totalMemory: number;
|
||||
} {
|
||||
return {
|
||||
loadedCount: this.loadedModules.size,
|
||||
loadingCount: this.loadingPromises.size,
|
||||
preloadCount: this.preloadQueue.size,
|
||||
totalMemory: this.estimateMemoryUsage(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 메모리 사용량 추정
|
||||
*/
|
||||
private static estimateMemoryUsage(): number {
|
||||
// 간단한 추정: 각 모듈당 평균 50KB
|
||||
return this.loadedModules.size * 50 * 1024;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* React-style lazy loading wrapper
|
||||
*/
|
||||
export function lazy<T>(loader: () => Promise<{ default: T }>): () => Promise<T> {
|
||||
let component: T | null = null;
|
||||
|
||||
return async () => {
|
||||
if (!component) {
|
||||
const module = await loader();
|
||||
component = module.default;
|
||||
}
|
||||
return component;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Suspense-like loading boundary
|
||||
*/
|
||||
export class LoadingBoundary {
|
||||
private loading = new Set<Promise<unknown>>();
|
||||
|
||||
async wrap<T>(promise: Promise<T>): Promise<T> {
|
||||
this.loading.add(promise);
|
||||
|
||||
try {
|
||||
const result = await promise;
|
||||
return result;
|
||||
} finally {
|
||||
this.loading.delete(promise);
|
||||
}
|
||||
}
|
||||
|
||||
get isLoading(): boolean {
|
||||
return this.loading.size > 0;
|
||||
}
|
||||
|
||||
async waitForAll(): Promise<void> {
|
||||
await Promise.all(Array.from(this.loading));
|
||||
}
|
||||
}
|
||||
|
|
@ -77,10 +77,7 @@ export default class SpeechToTextPlugin extends Plugin {
|
|||
this.cleanupEventHandlers();
|
||||
this.cancelPendingOperations();
|
||||
|
||||
// Clean up editor services
|
||||
if (this.editorService) {
|
||||
this.editorService.destroy();
|
||||
}
|
||||
// EditorService is registered as a child component and unloaded automatically.
|
||||
if (this.textInsertionHandler) {
|
||||
this.textInsertionHandler.destroy();
|
||||
}
|
||||
|
|
@ -142,6 +139,7 @@ export default class SpeechToTextPlugin extends Plugin {
|
|||
|
||||
// Initialize editor services
|
||||
this.editorService = new EditorService(this.app, this.eventManager, this.logger);
|
||||
this.addChild(this.editorService);
|
||||
|
||||
this.textInsertionHandler = new TextInsertionHandler(
|
||||
this.editorService,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { debounce as obsidianDebounce } from 'obsidian';
|
||||
|
||||
/**
|
||||
* UI 이벤트 핸들러
|
||||
* - 파일 선택 이벤트
|
||||
|
|
@ -342,18 +344,11 @@ export class EventHandlers {
|
|||
/**
|
||||
* 디바운스된 이벤트 핸들러 생성
|
||||
*/
|
||||
debounce<T extends (...args: unknown[]) => void>(
|
||||
func: T,
|
||||
debounce<TArgs extends unknown[]>(
|
||||
func: (...args: [...TArgs]) => void,
|
||||
wait: number
|
||||
): (...args: Parameters<T>) => void {
|
||||
let timeout: NodeJS.Timeout | null = null;
|
||||
|
||||
return (...args: Parameters<T>) => {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
timeout = setTimeout(() => func(...args), wait);
|
||||
};
|
||||
): (...args: [...TArgs]) => void {
|
||||
return obsidianDebounce((...args: [...TArgs]) => func(...args), wait, true);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { App, TFile } from 'obsidian';
|
||||
import { App, TFile, setIcon } from 'obsidian';
|
||||
|
||||
interface RecentFileEntry {
|
||||
path: string;
|
||||
|
|
@ -51,13 +51,13 @@ export class RecentFiles {
|
|||
|
||||
// 헤더
|
||||
const header = this.container.createDiv('recent-files-header');
|
||||
header.createEl('h3', { text: '최근 사용 파일' });
|
||||
header.createEl('h3', { text: 'Recent files' });
|
||||
|
||||
// 초기화 버튼
|
||||
const clearBtn = header.createEl('button', {
|
||||
cls: 'clear-recent-btn',
|
||||
text: '초기화',
|
||||
title: '최근 사용 목록 초기화',
|
||||
text: 'Clear',
|
||||
title: 'Clear recent files',
|
||||
});
|
||||
clearBtn.addEventListener('click', () => {
|
||||
this.clearRecentFiles();
|
||||
|
|
@ -87,7 +87,7 @@ export class RecentFiles {
|
|||
if (validFiles.length === 0) {
|
||||
listContainer.createDiv({
|
||||
cls: 'empty-state',
|
||||
text: '최근 사용한 파일이 없습니다',
|
||||
text: 'No recent files',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -157,9 +157,9 @@ export class RecentFiles {
|
|||
// 선택 버튼
|
||||
const selectBtn = actions.createEl('button', {
|
||||
cls: 'select-btn',
|
||||
title: '파일 선택',
|
||||
title: 'Select file',
|
||||
});
|
||||
selectBtn.appendChild(this.createSelectIcon());
|
||||
setIcon(selectBtn, 'check');
|
||||
selectBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (this.fileCallback) {
|
||||
|
|
@ -170,9 +170,9 @@ export class RecentFiles {
|
|||
// 제거 버튼
|
||||
const removeBtn = actions.createEl('button', {
|
||||
cls: 'remove-btn',
|
||||
title: '목록에서 제거',
|
||||
title: 'Remove from list',
|
||||
});
|
||||
removeBtn.appendChild(this.createRemoveIcon());
|
||||
setIcon(removeBtn, 'x');
|
||||
removeBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.removeRecentFile(entry.path);
|
||||
|
|
@ -188,7 +188,7 @@ export class RecentFiles {
|
|||
// 파일이 존재하지 않는 경우 표시
|
||||
if (!this.app.vault.getAbstractFileByPath(entry.path)) {
|
||||
fileItem.addClass('file-not-found');
|
||||
fileName.setText(`${file.basename} (찾을 수 없음)`);
|
||||
fileName.setText(`${file.basename} (Not found)`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -251,9 +251,14 @@ export class RecentFiles {
|
|||
this.recentFiles = [];
|
||||
return;
|
||||
}
|
||||
const stored = this.app.loadLocalStorage(this.STORAGE_KEY);
|
||||
if (stored) {
|
||||
this.recentFiles = JSON.parse(stored);
|
||||
const stored = this.app.loadLocalStorage(this.STORAGE_KEY) as unknown;
|
||||
if (typeof stored === 'string') {
|
||||
const parsed: unknown = JSON.parse(stored);
|
||||
if (Array.isArray(parsed)) {
|
||||
this.recentFiles = parsed.filter((item): item is RecentFileEntry =>
|
||||
this.isRecentFileEntry(item)
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load recent files:', error);
|
||||
|
|
@ -294,21 +299,21 @@ export class RecentFiles {
|
|||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (seconds < 60) {
|
||||
return '방금 전';
|
||||
return 'Just now';
|
||||
} else if (minutes < 60) {
|
||||
return `${minutes}분 전`;
|
||||
return `${minutes} minute${minutes === 1 ? '' : 's'} ago`;
|
||||
} else if (hours < 24) {
|
||||
return `${hours}시간 전`;
|
||||
return `${hours} hour${hours === 1 ? '' : 's'} ago`;
|
||||
} else if (days === 1) {
|
||||
return '어제';
|
||||
return 'Yesterday';
|
||||
} else if (days < 7) {
|
||||
return `${days}일 전`;
|
||||
return `${days} days ago`;
|
||||
} else if (days < 30) {
|
||||
const weeks = Math.floor(days / 7);
|
||||
return `${weeks}주 전`;
|
||||
return `${weeks} week${weeks === 1 ? '' : 's'} ago`;
|
||||
} else {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleDateString();
|
||||
return date.toLocaleDateString('en-US');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -337,42 +342,13 @@ export class RecentFiles {
|
|||
return icons[extension.toLowerCase()] || '📄';
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택 아이콘
|
||||
*/
|
||||
private createSelectIcon(): SVGElement {
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('width', '16');
|
||||
svg.setAttribute('height', '16');
|
||||
svg.setAttribute('viewBox', '0 0 16 16');
|
||||
svg.setAttribute('fill', 'none');
|
||||
private isRecentFileEntry(value: unknown): value is RecentFileEntry {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
path.setAttribute('d', 'M6 12L2 8L3.4 6.6L6 9.2L12.6 2.6L14 4L6 12Z');
|
||||
path.setAttribute('fill', 'currentColor');
|
||||
svg.appendChild(path);
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* 제거 아이콘
|
||||
*/
|
||||
private createRemoveIcon(): SVGElement {
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('width', '16');
|
||||
svg.setAttribute('height', '16');
|
||||
svg.setAttribute('viewBox', '0 0 16 16');
|
||||
svg.setAttribute('fill', 'none');
|
||||
|
||||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
path.setAttribute('d', 'M12 4L4 12M4 4L12 12');
|
||||
path.setAttribute('stroke', 'currentColor');
|
||||
path.setAttribute('stroke-width', '2');
|
||||
path.setAttribute('stroke-linecap', 'round');
|
||||
svg.appendChild(path);
|
||||
|
||||
return svg;
|
||||
const candidate = value as Partial<RecentFileEntry>;
|
||||
return typeof candidate.path === 'string' && typeof candidate.timestamp === 'number';
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -133,8 +133,10 @@ export class StatisticsStore {
|
|||
const cancelledRecords = this.records.filter((r) => r.status === 'cancelled');
|
||||
|
||||
const processingTimes = completedRecords
|
||||
.filter((r) => r.endTime)
|
||||
.map((r) => (r.endTime! - r.startTime) / 1000); // 초 단위
|
||||
.filter(
|
||||
(r): r is TranscriptionRecord & { endTime: number } => typeof r.endTime === 'number'
|
||||
)
|
||||
.map((r) => (r.endTime - r.startTime) / 1000); // 초 단위
|
||||
|
||||
const totalProcessingTime = processingTimes.reduce((sum, time) => sum + time, 0);
|
||||
const averageProcessingTime =
|
||||
|
|
@ -190,9 +192,12 @@ export class StatisticsStore {
|
|||
return;
|
||||
}
|
||||
try {
|
||||
const data = this.app.loadLocalStorage(this.STORAGE_KEY);
|
||||
if (data) {
|
||||
this.records = JSON.parse(data);
|
||||
const data = this.app.loadLocalStorage(this.STORAGE_KEY) as unknown;
|
||||
if (typeof data === 'string') {
|
||||
const parsed: unknown = JSON.parse(data);
|
||||
if (Array.isArray(parsed)) {
|
||||
this.records = parsed as TranscriptionRecord[];
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load statistics:', e);
|
||||
|
|
@ -232,7 +237,7 @@ export class StatisticsDashboard {
|
|||
create(container: HTMLElement): HTMLElement {
|
||||
this.element = createEl('div', { cls: 'statistics-dashboard' });
|
||||
this.element.setAttribute('role', 'region');
|
||||
this.element.setAttribute('aria-label', '통계 대시보드');
|
||||
this.element.setAttribute('aria-label', 'Statistics dashboard');
|
||||
|
||||
// 헤더
|
||||
const header = this.createHeader();
|
||||
|
|
@ -270,23 +275,23 @@ export class StatisticsDashboard {
|
|||
private createHeader(): HTMLElement {
|
||||
const header = createEl('div', { cls: 'dashboard__header' });
|
||||
|
||||
const title = createEl('h2', { text: '변환 통계' });
|
||||
const title = createEl('h2', { text: 'Transcription statistics' });
|
||||
header.appendChild(title);
|
||||
|
||||
const controls = createEl('div', { cls: 'dashboard__controls' });
|
||||
|
||||
// 새로고침 버튼
|
||||
const refreshBtn = createEl('button', { cls: 'dashboard__refresh', text: '새로고침' });
|
||||
const refreshBtn = createEl('button', { cls: 'dashboard__refresh', text: 'Refresh' });
|
||||
refreshBtn.addEventListener('click', () => this.refresh());
|
||||
controls.appendChild(refreshBtn);
|
||||
|
||||
// 데이터 초기화 버튼
|
||||
const clearBtn = createEl('button', { cls: 'dashboard__clear', text: '데이터 초기화' });
|
||||
const clearBtn = createEl('button', { cls: 'dashboard__clear', text: 'Clear data' });
|
||||
clearBtn.addEventListener('click', () => this.clearData());
|
||||
controls.appendChild(clearBtn);
|
||||
|
||||
// 내보내기 버튼
|
||||
const exportBtn = createEl('button', { cls: 'dashboard__export', text: 'CSV 내보내기' });
|
||||
const exportBtn = createEl('button', { cls: 'dashboard__export', text: 'Export CSV' });
|
||||
exportBtn.addEventListener('click', () => this.exportToCSV());
|
||||
controls.appendChild(exportBtn);
|
||||
|
||||
|
|
@ -303,14 +308,14 @@ export class StatisticsDashboard {
|
|||
|
||||
// 통계 카드들
|
||||
const cards = [
|
||||
{ id: 'total', label: '전체 변환', value: '0', icon: '📊' },
|
||||
{ id: 'success', label: '성공', value: '0', icon: '✅', color: 'success' },
|
||||
{ id: 'failure', label: '실패', value: '0', icon: '❌', color: 'error' },
|
||||
{ id: 'rate', label: '성공률', value: '0%', icon: '📈', color: 'info' },
|
||||
{ id: 'avg-time', label: '평균 처리 시간', value: '0초', icon: '⏱️' },
|
||||
{ id: 'data', label: '처리된 데이터', value: '0 MB', icon: '💾' },
|
||||
{ id: 'today', label: '오늘', value: '0', icon: '📅' },
|
||||
{ id: 'api-cost', label: 'API 비용', value: '$0.00', icon: '💰' },
|
||||
{ id: 'total', label: 'Total', value: '0', icon: '📊' },
|
||||
{ id: 'success', label: 'Success', value: '0', icon: '✅', color: 'success' },
|
||||
{ id: 'failure', label: 'Failed', value: '0', icon: '❌', color: 'error' },
|
||||
{ id: 'rate', label: 'Success rate', value: '0%', icon: '📈', color: 'info' },
|
||||
{ id: 'avg-time', label: 'Average processing time', value: '0s', icon: '⏱️' },
|
||||
{ id: 'data', label: 'Processed data', value: '0 MB', icon: '💾' },
|
||||
{ id: 'today', label: 'Today', value: '0', icon: '📅' },
|
||||
{ id: 'api-cost', label: 'API cost', value: '$0.00', icon: '💰' },
|
||||
];
|
||||
|
||||
cards.forEach((card) => {
|
||||
|
|
@ -346,7 +351,7 @@ export class StatisticsDashboard {
|
|||
// 시간대별 차트
|
||||
const timeChart = createEl('div', { cls: 'chart-container' });
|
||||
|
||||
const timeChartTitle = createEl('h3', { text: '시간대별 변환 횟수' });
|
||||
const timeChartTitle = createEl('h3', { text: 'Transcriptions by hour' });
|
||||
timeChart.appendChild(timeChartTitle);
|
||||
|
||||
const timeChartCanvas = createEl('div', { cls: 'chart-canvas' });
|
||||
|
|
@ -358,7 +363,7 @@ export class StatisticsDashboard {
|
|||
// 성공률 차트
|
||||
const successChart = createEl('div', { cls: 'chart-container' });
|
||||
|
||||
const successChartTitle = createEl('h3', { text: '성공률 추이' });
|
||||
const successChartTitle = createEl('h3', { text: 'Success rate trend' });
|
||||
successChart.appendChild(successChartTitle);
|
||||
|
||||
const successChartCanvas = createEl('div', { cls: 'chart-canvas' });
|
||||
|
|
@ -378,18 +383,18 @@ export class StatisticsDashboard {
|
|||
|
||||
const header = createEl('div', { cls: 'history__header' });
|
||||
|
||||
const title = createEl('h3', { text: '최근 변환 기록' });
|
||||
const title = createEl('h3', { text: 'Recent transcriptions' });
|
||||
header.appendChild(title);
|
||||
|
||||
// 필터
|
||||
const filter = createEl('select', { cls: 'history__filter' });
|
||||
|
||||
const filterOptions = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'completed', label: '성공' },
|
||||
{ value: 'failed', label: '실패' },
|
||||
{ value: 'processing', label: '처리 중' },
|
||||
{ value: 'cancelled', label: '취소됨' },
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'completed', label: 'Completed' },
|
||||
{ value: 'failed', label: 'Failed' },
|
||||
{ value: 'processing', label: 'Processing' },
|
||||
{ value: 'cancelled', label: 'Cancelled' },
|
||||
];
|
||||
|
||||
filterOptions.forEach((optionInfo) => {
|
||||
|
|
@ -407,7 +412,7 @@ export class StatisticsDashboard {
|
|||
|
||||
const thead = createEl('thead');
|
||||
const headerRow = createEl('tr');
|
||||
['시간', '파일명', '크기', '처리 시간', '상태', '단어 수', '작업'].forEach((text) => {
|
||||
['Time', 'File name', 'Size', 'Duration', 'Status', 'Words', 'Action'].forEach((text) => {
|
||||
const th = createEl('th', { text });
|
||||
headerRow.appendChild(th);
|
||||
});
|
||||
|
|
@ -450,7 +455,7 @@ export class StatisticsDashboard {
|
|||
};
|
||||
|
||||
Object.entries(updates).forEach(([id, value]) => {
|
||||
const card = this.element!.querySelector(`[data-stat-id="${id}"] .stats-card__value`);
|
||||
const card = this.element?.querySelector(`[data-stat-id="${id}"] .stats-card__value`);
|
||||
if (card) {
|
||||
card.textContent = value;
|
||||
}
|
||||
|
|
@ -476,14 +481,14 @@ export class StatisticsDashboard {
|
|||
if (!chartEl) return;
|
||||
|
||||
const records = StatisticsStore.getAllRecords();
|
||||
const hourCounts = new Array(24).fill(0);
|
||||
const hourCounts = Array.from({ length: 24 }, () => 0);
|
||||
|
||||
records.forEach((record) => {
|
||||
const hour = new Date(record.startTime).getHours();
|
||||
hourCounts[hour]++;
|
||||
});
|
||||
|
||||
const maxCount = Math.max(...hourCounts, 1);
|
||||
const maxCount = hourCounts.reduce((max, count) => Math.max(max, count), 1);
|
||||
|
||||
chartEl.replaceChildren();
|
||||
const chartContainer = createEl('div', { cls: 'bar-chart' });
|
||||
|
|
@ -496,7 +501,7 @@ export class StatisticsDashboard {
|
|||
const valueLabel = createEl('span', { cls: 'bar-chart__value', text: String(count) });
|
||||
bar.appendChild(valueLabel);
|
||||
|
||||
const barLabel = createEl('span', { cls: 'bar-chart__label', text: `${hour}시` });
|
||||
const barLabel = createEl('span', { cls: 'bar-chart__label', text: `${hour}:00` });
|
||||
bar.appendChild(barLabel);
|
||||
|
||||
chartContainer.appendChild(bar);
|
||||
|
|
@ -517,7 +522,7 @@ export class StatisticsDashboard {
|
|||
const dailyStats: { [date: string]: { success: number; total: number } } = {};
|
||||
|
||||
records.forEach((record) => {
|
||||
const date = new Date(record.startTime).toLocaleDateString();
|
||||
const date = new Date(record.startTime).toLocaleDateString('en-US');
|
||||
if (!dailyStats[date]) {
|
||||
dailyStats[date] = { success: 0, total: 0 };
|
||||
}
|
||||
|
|
@ -583,7 +588,7 @@ export class StatisticsDashboard {
|
|||
const row = createEl('tr');
|
||||
|
||||
const startTimeCell = createEl('td', {
|
||||
text: new Date(record.startTime).toLocaleString(),
|
||||
text: new Date(record.startTime).toLocaleString('en-US'),
|
||||
});
|
||||
row.appendChild(startTimeCell);
|
||||
|
||||
|
|
@ -614,7 +619,7 @@ export class StatisticsDashboard {
|
|||
row.appendChild(wordCountCell);
|
||||
|
||||
const actionCell = createEl('td');
|
||||
const actionBtn = createEl('button', { cls: 'action-btn', text: '보기' });
|
||||
const actionBtn = createEl('button', { cls: 'action-btn', text: 'View' });
|
||||
actionBtn.dataset.recordId = record.id;
|
||||
actionBtn.dataset.action = 'view';
|
||||
actionBtn.addEventListener('click', () => {
|
||||
|
|
@ -652,11 +657,11 @@ export class StatisticsDashboard {
|
|||
*/
|
||||
private getStatusText(status: TranscriptionRecord['status']): string {
|
||||
const texts = {
|
||||
pending: '대기 중',
|
||||
processing: '처리 중',
|
||||
completed: '완료',
|
||||
failed: '실패',
|
||||
cancelled: '취소됨',
|
||||
pending: 'Pending',
|
||||
processing: 'Processing',
|
||||
completed: 'Completed',
|
||||
failed: 'Failed',
|
||||
cancelled: 'Cancelled',
|
||||
};
|
||||
return texts[status] || status;
|
||||
}
|
||||
|
|
@ -670,11 +675,11 @@ export class StatisticsDashboard {
|
|||
const hours = Math.floor(minutes / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}시간 ${minutes % 60}분`;
|
||||
return `${hours}h ${minutes % 60}m`;
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}분 ${seconds % 60}초`;
|
||||
return `${minutes}m ${seconds % 60}s`;
|
||||
} else {
|
||||
return `${seconds}초`;
|
||||
return `${seconds}s`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -698,14 +703,14 @@ export class StatisticsDashboard {
|
|||
// StatisticsStore에 app이 설정되어 있어야 함
|
||||
const app = StatisticsStore['app']; // private 필드 접근
|
||||
if (!app) {
|
||||
new Notice('앱 인스턴스를 찾을 수 없습니다');
|
||||
new Notice('App instance not found');
|
||||
return;
|
||||
}
|
||||
|
||||
new ConfirmationModal(
|
||||
app,
|
||||
'Clear statistics',
|
||||
'모든 통계 데이터를 초기화하시겠습니까? 이 작업은 되돌릴 수 없습니다.',
|
||||
'Do you want to clear all statistics data? This action cannot be undone.',
|
||||
() => {
|
||||
StatisticsStore.clear();
|
||||
this.refresh();
|
||||
|
|
@ -720,13 +725,13 @@ export class StatisticsDashboard {
|
|||
const records = StatisticsStore.getAllRecords();
|
||||
|
||||
const headers = [
|
||||
'시간',
|
||||
'파일명',
|
||||
'크기(bytes)',
|
||||
'처리시간(ms)',
|
||||
'상태',
|
||||
'단어수',
|
||||
'API비용',
|
||||
'time',
|
||||
'file_name',
|
||||
'size_bytes',
|
||||
'duration_ms',
|
||||
'status',
|
||||
'word_count',
|
||||
'api_cost',
|
||||
];
|
||||
const rows = records.map((r) => [
|
||||
new Date(r.startTime).toISOString(),
|
||||
|
|
|
|||
|
|
@ -1,253 +0,0 @@
|
|||
import { App, PluginSettingTab, Setting } from 'obsidian';
|
||||
import type SpeechToTextPlugin from '../../main';
|
||||
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 = false;
|
||||
|
||||
constructor(private app: App, private plugin: SpeechToTextPlugin) {
|
||||
this.logger = new Logger('SettingsTabManager');
|
||||
}
|
||||
|
||||
/**
|
||||
* SettingsTab 초기화
|
||||
*/
|
||||
public initialize(): void {
|
||||
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 instanceof Error ? error : undefined
|
||||
);
|
||||
// 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 instanceof Error ? error : undefined
|
||||
);
|
||||
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);
|
||||
|
||||
// 생성된 객체 검증
|
||||
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 instanceof Error ? error : undefined
|
||||
);
|
||||
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 instanceof Error ? error : undefined
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 초기화 에러 처리
|
||||
*/
|
||||
private handleInitializationError(error: unknown): void {
|
||||
// 에러 타입에 따른 처리
|
||||
const message = error instanceof Error ? error.message : '';
|
||||
if (message.includes('toLowerCase')) {
|
||||
this.logger.error(
|
||||
'String method error detected, possibly due to incorrect parameter types'
|
||||
);
|
||||
} else if (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 instanceof Error ? error : undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 instanceof Error ? error : undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: SpeechToTextPlugin) {
|
||||
super(app, plugin);
|
||||
}
|
||||
|
||||
display(): void {
|
||||
const { containerEl } = this;
|
||||
containerEl.empty();
|
||||
|
||||
new Setting(containerEl).setName('Transcription').setHeading();
|
||||
containerEl.createEl('p', {
|
||||
text: 'Settings are temporarily unavailable. Please restart Obsidian if this persists.',
|
||||
cls: 'settings-error-message',
|
||||
});
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -46,6 +46,7 @@ export class SettingsTab extends PluginSettingTab {
|
|||
|
||||
// Clear existing content
|
||||
containerEl.empty();
|
||||
containerEl.addClass('speech-to-text-settings');
|
||||
|
||||
// Add main title
|
||||
new Setting(containerEl).setName('Speech note').setHeading();
|
||||
|
|
|
|||
|
|
@ -1,765 +0,0 @@
|
|||
import { App, PluginSettingTab, Setting, Notice } from 'obsidian';
|
||||
import type SpeechToTextPlugin from '../../main';
|
||||
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 { EventListenerManager } from '../../utils/memory/MemoryManager';
|
||||
import { debounceAsync } from '../../utils/async/AsyncManager';
|
||||
import { GlobalErrorManager, ErrorType, ErrorSeverity } from '../../utils/error/ErrorManager';
|
||||
import { DEFAULT_SETTINGS } from '../../domain/models/Settings';
|
||||
import { ConfirmationModal } from '../modals/ConfirmationModal';
|
||||
import { isPlainRecord } from '../../types/guards';
|
||||
|
||||
/**
|
||||
* 최적화된 설정 탭 컴포넌트
|
||||
* - AutoDisposable 패턴 적용
|
||||
* - 설정 섹션 모듈화
|
||||
* - 비동기 처리 개선
|
||||
* - 에러 경계 구현
|
||||
*/
|
||||
export class SettingsTabOptimized extends PluginSettingTab {
|
||||
plugin: SpeechToTextPlugin;
|
||||
|
||||
// Components
|
||||
private components: SettingsComponents;
|
||||
|
||||
// Memory Management
|
||||
private eventManager: EventListenerManager;
|
||||
private disposed = false;
|
||||
|
||||
// State
|
||||
private state: SettingsState;
|
||||
|
||||
// Error Manager
|
||||
private errorManager: GlobalErrorManager;
|
||||
|
||||
constructor(app: App, plugin: SpeechToTextPlugin) {
|
||||
super(app, plugin);
|
||||
this.plugin = plugin;
|
||||
|
||||
// Initialize managers
|
||||
this.eventManager = new EventListenerManager();
|
||||
this.errorManager = GlobalErrorManager.getInstance();
|
||||
|
||||
// Initialize state
|
||||
this.state = {
|
||||
isDirty: false,
|
||||
isSaving: false,
|
||||
apiKeyVisible: false,
|
||||
validationStatus: new Map(),
|
||||
};
|
||||
|
||||
// Initialize components with error boundaries
|
||||
this.components = this.initializeComponents();
|
||||
|
||||
// Setup auto-save
|
||||
this.setupAutoSave();
|
||||
}
|
||||
|
||||
private normalizeError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트 초기화 - 에러 경계 포함
|
||||
*/
|
||||
private initializeComponents(): SettingsComponents {
|
||||
try {
|
||||
return {
|
||||
apiKeyValidator: new ApiKeyValidator(this.plugin),
|
||||
generalSettings: new GeneralSettings(this.plugin),
|
||||
audioSettings: new AudioSettings(this.plugin),
|
||||
advancedSettings: new AdvancedSettings(this.plugin),
|
||||
shortcutSettings: new ShortcutSettings(this.app, this.plugin),
|
||||
sectionRenderers: new Map(),
|
||||
};
|
||||
} catch (error) {
|
||||
void this.errorManager.handleError(this.normalizeError(error), {
|
||||
type: ErrorType.RESOURCE,
|
||||
severity: ErrorSeverity.HIGH,
|
||||
context: { component: 'SettingsTab' },
|
||||
});
|
||||
|
||||
// Return minimal components on error
|
||||
return {
|
||||
apiKeyValidator: null,
|
||||
generalSettings: null,
|
||||
audioSettings: null,
|
||||
advancedSettings: null,
|
||||
shortcutSettings: null,
|
||||
sectionRenderers: new Map(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 자동 저장 설정 - 디바운스 적용
|
||||
*/
|
||||
private setupAutoSave(): void {
|
||||
const saveFunction = async (): Promise<void> => {
|
||||
if (!this.state.isDirty || this.state.isSaving) return;
|
||||
|
||||
this.state.isSaving = true;
|
||||
|
||||
try {
|
||||
await this.plugin.saveSettings();
|
||||
this.state.isDirty = false;
|
||||
this.updateSaveStatus('saved');
|
||||
} catch (error) {
|
||||
void this.errorManager.handleError(this.normalizeError(error), {
|
||||
type: ErrorType.RESOURCE,
|
||||
severity: ErrorSeverity.MEDIUM,
|
||||
userMessage: '설정 저장 실패',
|
||||
});
|
||||
this.updateSaveStatus('error');
|
||||
} finally {
|
||||
this.state.isSaving = false;
|
||||
}
|
||||
};
|
||||
|
||||
this.saveSettings = debounceAsync(saveFunction, 1000);
|
||||
}
|
||||
|
||||
display(): void {
|
||||
const { containerEl } = this;
|
||||
|
||||
// Clear and setup container
|
||||
this.prepareContainer(containerEl);
|
||||
|
||||
// Create sections with error boundaries
|
||||
const sections = [
|
||||
{ id: 'header', builder: () => this.createHeader(containerEl) },
|
||||
{ id: 'general', builder: () => this.createGeneralSection(containerEl) },
|
||||
{ id: 'api', builder: () => this.createApiSection(containerEl) },
|
||||
{ id: 'audio', builder: () => this.createAudioSection(containerEl) },
|
||||
{ id: 'advanced', builder: () => this.createAdvancedSection(containerEl) },
|
||||
{ id: 'shortcuts', builder: () => this.createShortcutSection(containerEl) },
|
||||
{ id: 'footer', builder: () => this.createFooter(containerEl) },
|
||||
];
|
||||
|
||||
// Render each section with error boundary
|
||||
sections.forEach((section) => {
|
||||
this.renderSectionWithErrorBoundary(section.id, section.builder);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 컨테이너 준비
|
||||
*/
|
||||
private prepareContainer(containerEl: HTMLElement): void {
|
||||
containerEl.empty();
|
||||
containerEl.addClass('speech-to-text-settings', 'optimized-settings');
|
||||
|
||||
// Add loading indicator
|
||||
const loadingEl = containerEl.createDiv('settings-loading');
|
||||
loadingEl.addClass('sn-hidden');
|
||||
}
|
||||
|
||||
/**
|
||||
* 에러 경계로 섹션 렌더링
|
||||
*/
|
||||
private renderSectionWithErrorBoundary(sectionId: string, builder: () => void): void {
|
||||
try {
|
||||
builder();
|
||||
} catch (error) {
|
||||
this.handleSectionError(sectionId, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 섹션 에러 처리
|
||||
*/
|
||||
private handleSectionError(sectionId: string, error: unknown): void {
|
||||
const normalizedError = error instanceof Error ? error : new Error(String(error));
|
||||
void this.errorManager.handleError(normalizedError, {
|
||||
type: ErrorType.UNKNOWN,
|
||||
severity: ErrorSeverity.MEDIUM,
|
||||
context: { section: sectionId },
|
||||
});
|
||||
|
||||
// Show fallback UI
|
||||
const fallback = this.containerEl.createDiv('section-error');
|
||||
fallback.createEl('p', {
|
||||
text: `${sectionId} 섹션 로드 실패`,
|
||||
cls: 'error-message',
|
||||
});
|
||||
|
||||
const retryBtn = fallback.createEl('button', {
|
||||
text: '다시 시도',
|
||||
cls: 'retry-button',
|
||||
});
|
||||
|
||||
this.eventManager.add(retryBtn, 'click', () => {
|
||||
fallback.remove();
|
||||
this.display();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 헤더 생성 - 단순화
|
||||
*/
|
||||
private createHeader(containerEl: HTMLElement): void {
|
||||
const header = new SettingsHeader(containerEl, this.state);
|
||||
header.render();
|
||||
|
||||
// Register for disposal
|
||||
this.components.sectionRenderers.set('header', header);
|
||||
}
|
||||
|
||||
/**
|
||||
* API 섹션 생성 - 모듈화 및 보안 강화
|
||||
*/
|
||||
private createApiSection(containerEl: HTMLElement): void {
|
||||
const section = new ApiSettingsSection(
|
||||
containerEl,
|
||||
this.plugin,
|
||||
this.components.apiKeyValidator,
|
||||
this.eventManager,
|
||||
this.state
|
||||
);
|
||||
|
||||
section.render();
|
||||
section.onSettingsChange(() => {
|
||||
this.state.isDirty = true;
|
||||
void this.saveSettings();
|
||||
});
|
||||
|
||||
this.components.sectionRenderers.set('api', section);
|
||||
}
|
||||
|
||||
/**
|
||||
* 일반 설정 섹션 - 단순화
|
||||
*/
|
||||
private createGeneralSection(containerEl: HTMLElement): void {
|
||||
const section = this.createSection(containerEl, 'General', '기본 동작 설정');
|
||||
|
||||
if (this.components.generalSettings) {
|
||||
this.components.generalSettings.render(section);
|
||||
this.setupChangeTracking(section);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 오디오 설정 섹션
|
||||
*/
|
||||
private createAudioSection(containerEl: HTMLElement): void {
|
||||
const section = this.createSection(containerEl, 'Audio', '음성 변환 설정');
|
||||
|
||||
if (this.components.audioSettings) {
|
||||
this.components.audioSettings.render(section);
|
||||
this.setupChangeTracking(section);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 고급 설정 섹션
|
||||
*/
|
||||
private createAdvancedSection(containerEl: HTMLElement): void {
|
||||
const section = this.createSection(containerEl, 'Advanced', '고급 설정');
|
||||
|
||||
if (this.components.advancedSettings) {
|
||||
this.components.advancedSettings.render(section);
|
||||
this.setupChangeTracking(section);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 단축키 설정 섹션
|
||||
*/
|
||||
private createShortcutSection(containerEl: HTMLElement): void {
|
||||
const section = this.createSection(containerEl, 'Shortcuts', '단축키 설정');
|
||||
|
||||
if (this.components.shortcutSettings) {
|
||||
this.components.shortcutSettings.render(section);
|
||||
this.setupChangeTracking(section);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 푸터 생성
|
||||
*/
|
||||
private createFooter(containerEl: HTMLElement): void {
|
||||
const footer = new SettingsFooter(containerEl, this.plugin, this.eventManager);
|
||||
|
||||
footer.render();
|
||||
this.components.sectionRenderers.set('footer', footer);
|
||||
}
|
||||
|
||||
/**
|
||||
* 섹션 생성 헬퍼
|
||||
*/
|
||||
private createSection(container: HTMLElement, title: string, description: string): HTMLElement {
|
||||
const section = container.createDiv('settings-section');
|
||||
new Setting(section).setName(title).setHeading();
|
||||
|
||||
if (description) {
|
||||
section.createEl('p', {
|
||||
text: description,
|
||||
cls: 'setting-item-description',
|
||||
});
|
||||
}
|
||||
|
||||
return section;
|
||||
}
|
||||
|
||||
/**
|
||||
* 변경 추적 설정
|
||||
*/
|
||||
private setupChangeTracking(section: HTMLElement): void {
|
||||
// Track all input changes
|
||||
const inputs = section.querySelectorAll('input, select, textarea');
|
||||
|
||||
inputs.forEach((input) => {
|
||||
if (input instanceof HTMLElement) {
|
||||
this.eventManager.add(input, 'change', () => {
|
||||
this.state.isDirty = true;
|
||||
void this.saveSettings();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장 상태 업데이트
|
||||
*/
|
||||
private updateSaveStatus(status: 'saving' | 'saved' | 'error'): void {
|
||||
const statusEl = this.containerEl.querySelector('.save-status');
|
||||
if (!statusEl) return;
|
||||
|
||||
statusEl.className = `save-status ${status}`;
|
||||
|
||||
const messages = {
|
||||
saving: '저장 중...',
|
||||
saved: '저장됨',
|
||||
error: '저장 실패',
|
||||
};
|
||||
|
||||
statusEl.textContent = messages[status];
|
||||
|
||||
// Auto-hide success message
|
||||
if (status === 'saved') {
|
||||
setTimeout(() => {
|
||||
statusEl.textContent = '';
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정 저장 (디바운스됨)
|
||||
*/
|
||||
private saveSettings!: () => Promise<void>;
|
||||
|
||||
/**
|
||||
* 리소스 정리
|
||||
*/
|
||||
dispose(): void {
|
||||
if (this.disposed) return;
|
||||
|
||||
this.disposed = true;
|
||||
|
||||
// Dispose all section renderers
|
||||
this.components.sectionRenderers.forEach((renderer) => {
|
||||
if (renderer && typeof renderer.dispose === 'function') {
|
||||
renderer.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
// Clear event listeners
|
||||
this.eventManager.removeAll();
|
||||
|
||||
// Clear state
|
||||
this.state.validationStatus.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* AutoDisposable 구현
|
||||
*/
|
||||
onDispose(): void {
|
||||
this.dispose();
|
||||
}
|
||||
|
||||
isDisposed(): boolean {
|
||||
return this.disposed;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정 컴포넌트 인터페이스
|
||||
*/
|
||||
interface SettingsComponents {
|
||||
apiKeyValidator: ApiKeyValidator | null;
|
||||
generalSettings: GeneralSettings | null;
|
||||
audioSettings: AudioSettings | null;
|
||||
advancedSettings: AdvancedSettings | null;
|
||||
shortcutSettings: ShortcutSettings | null;
|
||||
sectionRenderers: Map<string, SectionRenderer>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정 상태 인터페이스
|
||||
*/
|
||||
interface SettingsState {
|
||||
isDirty: boolean;
|
||||
isSaving: boolean;
|
||||
apiKeyVisible: boolean;
|
||||
validationStatus: Map<string, boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 섹션 렌더러 기본 클래스
|
||||
*/
|
||||
abstract class SectionRenderer {
|
||||
constructor(protected container: HTMLElement, protected eventManager?: EventListenerManager) {}
|
||||
|
||||
abstract render(): void;
|
||||
|
||||
dispose(): void {
|
||||
// Override in subclasses if needed
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정 헤더 렌더러
|
||||
*/
|
||||
class SettingsHeader extends SectionRenderer {
|
||||
constructor(container: HTMLElement, private state: SettingsState) {
|
||||
super(container);
|
||||
}
|
||||
|
||||
render(): void {
|
||||
const headerEl = this.container.createDiv({ cls: 'settings-header' });
|
||||
|
||||
new Setting(headerEl).setName('음성 전사 설정').setHeading();
|
||||
|
||||
headerEl.createEl('p', {
|
||||
text: '음성을 텍스트로 변환하는 플러그인 설정을 구성합니다.',
|
||||
cls: 'settings-description',
|
||||
});
|
||||
|
||||
// Save status indicator
|
||||
const statusEl = headerEl.createDiv({ cls: 'save-status' });
|
||||
statusEl.classList.toggle('sn-hidden', !this.state.isDirty);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API 설정 섹션 렌더러
|
||||
*/
|
||||
class ApiSettingsSection extends SectionRenderer {
|
||||
private changeCallbacks: Set<() => void> = new Set();
|
||||
|
||||
constructor(
|
||||
container: HTMLElement,
|
||||
private plugin: SpeechToTextPlugin,
|
||||
private validator: ApiKeyValidator | null,
|
||||
eventManager: EventListenerManager,
|
||||
private state: SettingsState
|
||||
) {
|
||||
super(container, eventManager);
|
||||
}
|
||||
|
||||
render(): void {
|
||||
const sectionEl = this.createSection();
|
||||
this.createApiKeyInput(sectionEl);
|
||||
this.createApiUsageDisplay(sectionEl);
|
||||
}
|
||||
|
||||
private createSection(): HTMLElement {
|
||||
const section = this.container.createDiv('settings-section');
|
||||
new Setting(section).setName('API').setHeading();
|
||||
section.createEl('p', {
|
||||
text: 'Openai API 설정',
|
||||
cls: 'setting-item-description',
|
||||
});
|
||||
return section;
|
||||
}
|
||||
|
||||
private createApiKeyInput(section: HTMLElement): void {
|
||||
const setting = new Setting(section)
|
||||
.setName('API key')
|
||||
.setDesc('Openai API 키를 입력하세요. (sk-로 시작)');
|
||||
|
||||
const inputContainer = setting.controlEl.createDiv('api-key-container');
|
||||
const eventManager = this.eventManager;
|
||||
if (!eventManager) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create secure input
|
||||
const input = new SecureApiKeyInput(
|
||||
inputContainer,
|
||||
this.plugin.settings.apiKey,
|
||||
eventManager
|
||||
);
|
||||
|
||||
input.onChange((value) => {
|
||||
void (async () => {
|
||||
if (this.validator) {
|
||||
const isValid = await this.validator.validate(value);
|
||||
this.state.validationStatus.set('apiKey', isValid);
|
||||
|
||||
if (isValid) {
|
||||
this.plugin.settings.apiKey = value;
|
||||
this.notifyChange();
|
||||
new Notice('✅ API 키가 검증되었습니다');
|
||||
} else {
|
||||
new Notice('❌ 유효하지 않은 API 키입니다');
|
||||
}
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
input.render();
|
||||
}
|
||||
|
||||
private createApiUsageDisplay(section: HTMLElement): void {
|
||||
const usageEl = section.createDiv('api-usage');
|
||||
new Setting(usageEl).setName('API 사용량').setHeading();
|
||||
|
||||
// Placeholder for usage stats
|
||||
const statsEl = usageEl.createDiv('usage-stats');
|
||||
statsEl.createEl('p', { text: '이번 달 사용량: 0 / 1000 요청' });
|
||||
}
|
||||
|
||||
onSettingsChange(callback: () => void): void {
|
||||
this.changeCallbacks.add(callback);
|
||||
}
|
||||
|
||||
private notifyChange(): void {
|
||||
this.changeCallbacks.forEach((callback) => callback());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 보안 API 키 입력 컴포넌트
|
||||
*/
|
||||
class SecureApiKeyInput {
|
||||
private inputEl!: HTMLInputElement;
|
||||
private toggleBtn!: HTMLButtonElement;
|
||||
private validateBtn!: HTMLButtonElement;
|
||||
private isVisible = false;
|
||||
private changeCallbacks: Set<(value: string) => void> = new Set();
|
||||
|
||||
constructor(
|
||||
private container: HTMLElement,
|
||||
private initialValue: string,
|
||||
private eventManager: EventListenerManager
|
||||
) {}
|
||||
|
||||
render(): void {
|
||||
// Create masked input
|
||||
this.inputEl = this.container.createEl('input', {
|
||||
type: 'password',
|
||||
placeholder: 'sk-...',
|
||||
cls: 'api-key-input',
|
||||
});
|
||||
|
||||
if (this.initialValue) {
|
||||
this.inputEl.value = this.maskApiKey(this.initialValue);
|
||||
}
|
||||
|
||||
// Create toggle button
|
||||
this.toggleBtn = this.container.createEl('button', {
|
||||
text: '👁',
|
||||
cls: 'api-key-toggle',
|
||||
});
|
||||
|
||||
// Create validate button
|
||||
this.validateBtn = this.container.createEl('button', {
|
||||
text: '검증',
|
||||
cls: 'mod-cta api-key-validate',
|
||||
});
|
||||
|
||||
this.setupEventHandlers();
|
||||
}
|
||||
|
||||
private setupEventHandlers(): void {
|
||||
// Toggle visibility
|
||||
this.eventManager.add(this.toggleBtn, 'click', () => {
|
||||
this.toggleVisibility();
|
||||
});
|
||||
|
||||
// Validate on button click
|
||||
this.eventManager.add(this.validateBtn, 'click', () => {
|
||||
void this.validate();
|
||||
});
|
||||
|
||||
// Track changes
|
||||
this.eventManager.add(this.inputEl, 'change', () => {
|
||||
const value = this.inputEl.value;
|
||||
if (value && value !== this.maskApiKey(this.initialValue)) {
|
||||
this.notifyChange(value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private toggleVisibility(): void {
|
||||
this.isVisible = !this.isVisible;
|
||||
|
||||
if (this.isVisible) {
|
||||
this.inputEl.type = 'text';
|
||||
this.inputEl.value = this.initialValue || '';
|
||||
this.toggleBtn.textContent = '🙈';
|
||||
} else {
|
||||
this.inputEl.type = 'password';
|
||||
this.inputEl.value = this.initialValue ? this.maskApiKey(this.initialValue) : '';
|
||||
this.toggleBtn.textContent = '👁';
|
||||
}
|
||||
}
|
||||
|
||||
private validate(): void {
|
||||
const value = this.inputEl.value;
|
||||
|
||||
if (!value || value === this.maskApiKey(this.initialValue)) {
|
||||
new Notice('API 키를 입력해주세요');
|
||||
return;
|
||||
}
|
||||
|
||||
this.validateBtn.disabled = true;
|
||||
this.validateBtn.textContent = '검증 중...';
|
||||
|
||||
try {
|
||||
this.notifyChange(value);
|
||||
} finally {
|
||||
this.validateBtn.disabled = false;
|
||||
this.validateBtn.textContent = '검증';
|
||||
}
|
||||
}
|
||||
|
||||
private maskApiKey(key: string): string {
|
||||
if (!key) return '';
|
||||
if (key.length <= 8) return key;
|
||||
return key.substring(0, 7) + '...' + key.substring(key.length - 4);
|
||||
}
|
||||
|
||||
onChange(callback: (value: string) => void): void {
|
||||
this.changeCallbacks.add(callback);
|
||||
}
|
||||
|
||||
private notifyChange(value: string): void {
|
||||
this.changeCallbacks.forEach((callback) => callback(value));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정 푸터 렌더러
|
||||
*/
|
||||
class SettingsFooter extends SectionRenderer {
|
||||
constructor(
|
||||
container: HTMLElement,
|
||||
private plugin: SpeechToTextPlugin,
|
||||
eventManager: EventListenerManager
|
||||
) {
|
||||
super(container, eventManager);
|
||||
}
|
||||
|
||||
render(): void {
|
||||
const footer = this.container.createDiv('settings-footer');
|
||||
|
||||
// Export/Import buttons
|
||||
new Setting(footer)
|
||||
.addButton((btn) =>
|
||||
btn.setButtonText('설정 내보내기').onClick(() => {
|
||||
void this.exportSettings();
|
||||
})
|
||||
)
|
||||
.addButton((btn) =>
|
||||
btn.setButtonText('설정 가져오기').onClick(() => {
|
||||
void this.importSettings();
|
||||
})
|
||||
);
|
||||
|
||||
// Reset button
|
||||
new Setting(footer).addButton((btn) =>
|
||||
btn
|
||||
.setButtonText('기본값으로 재설정')
|
||||
.setWarning()
|
||||
.onClick(() => {
|
||||
void this.resetSettings();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private exportSettings(): void {
|
||||
try {
|
||||
const settings = this.plugin.settings;
|
||||
const blob = new Blob([JSON.stringify(settings, null, 2)], {
|
||||
type: 'application/json',
|
||||
});
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = createEl('a');
|
||||
a.href = url;
|
||||
a.download = 'speech-to-text-settings.json';
|
||||
a.click();
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
new Notice('설정을 내보냈습니다');
|
||||
} catch {
|
||||
new Notice('설정 내보내기 실패');
|
||||
}
|
||||
}
|
||||
|
||||
private importSettings(): void {
|
||||
const input = createEl('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.json';
|
||||
|
||||
input.onchange = (e) => {
|
||||
void (async () => {
|
||||
const target = e.target;
|
||||
if (!(target instanceof HTMLInputElement)) {
|
||||
return;
|
||||
}
|
||||
const file = target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const text = await file.text();
|
||||
const parsed = JSON.parse(text) as unknown;
|
||||
if (!isPlainRecord(parsed)) {
|
||||
throw new Error('Invalid settings data');
|
||||
}
|
||||
|
||||
// Validate and merge settings
|
||||
Object.assign(this.plugin.settings, parsed);
|
||||
await this.plugin.saveSettings();
|
||||
|
||||
new Notice('설정을 가져왔습니다');
|
||||
|
||||
// Refresh UI - need to trigger parent component refresh
|
||||
// This should be handled via event or callback
|
||||
} catch {
|
||||
new Notice('설정 가져오기 실패');
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
input.click();
|
||||
}
|
||||
|
||||
private resetSettings(): void {
|
||||
new ConfirmationModal(
|
||||
this.plugin.app,
|
||||
'Reset settings',
|
||||
'모든 설정을 기본값으로 재설정하시겠습니까?',
|
||||
async () => {
|
||||
try {
|
||||
// Reset to defaults
|
||||
this.plugin.settings = { ...DEFAULT_SETTINGS };
|
||||
await this.plugin.saveSettings();
|
||||
|
||||
new Notice('설정을 재설정했습니다');
|
||||
|
||||
// Refresh UI
|
||||
// Note: We need to refresh the main settings tab, not from within footer
|
||||
// This should be handled by the parent component
|
||||
} catch {
|
||||
new Notice('설정 재설정 실패');
|
||||
}
|
||||
}
|
||||
).open();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,741 +0,0 @@
|
|||
import { App, PluginSettingTab, Setting, Notice, Modal, ButtonComponent } from 'obsidian';
|
||||
import type SpeechToTextPlugin from '../../main';
|
||||
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 {
|
||||
AutoDisposable,
|
||||
EventListenerManager,
|
||||
ResourceManager,
|
||||
} from '../../utils/memory/MemoryManager';
|
||||
import { CancellablePromise, debounceAsync, withTimeout } from '../../utils/async/AsyncManager';
|
||||
import {
|
||||
GlobalErrorManager,
|
||||
ErrorType,
|
||||
ErrorSeverity,
|
||||
tryCatchAsync,
|
||||
} from '../../utils/error/ErrorManager';
|
||||
import { isPlainRecord } from '../../types/guards';
|
||||
|
||||
/**
|
||||
* 개선된 설정 탭 UI 컴포넌트
|
||||
* - 메모리 누수 방지
|
||||
* - 비동기 처리 최적화
|
||||
* - 에러 처리 강화
|
||||
*/
|
||||
export class SettingsTabRefactored extends PluginSettingTab {
|
||||
plugin: SpeechToTextPlugin;
|
||||
|
||||
// 컴포넌트 관리
|
||||
private components: Map<string, AutoDisposable> = new Map();
|
||||
private resourceManager = new ResourceManager();
|
||||
private eventManager = new EventListenerManager();
|
||||
private errorManager = GlobalErrorManager.getInstance();
|
||||
|
||||
// 비동기 작업 관리
|
||||
private pendingOperations = new Set<CancellablePromise<boolean>>();
|
||||
|
||||
// 디바운스된 저장 함수
|
||||
private debouncedSave = debounceAsync(() => this.plugin.saveSettings(), 500);
|
||||
|
||||
constructor(app: App, plugin: SpeechToTextPlugin) {
|
||||
super(app, plugin);
|
||||
this.plugin = plugin;
|
||||
this.initializeComponents();
|
||||
this.setupErrorHandling();
|
||||
}
|
||||
|
||||
private normalizeError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
|
||||
private getComponent<T extends AutoDisposable>(
|
||||
key: string,
|
||||
ctor: new (...args: never[]) => T
|
||||
): T | null {
|
||||
const component = this.components.get(key);
|
||||
return component instanceof ctor ? component : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트 초기화
|
||||
*/
|
||||
private initializeComponents(): void {
|
||||
// 각 컴포넌트를 AutoDisposable로 래핑
|
||||
this.components.set('apiKeyValidator', new ApiKeyValidatorWrapper(this.plugin));
|
||||
this.components.set('shortcutSettings', new ShortcutSettingsWrapper(this.app, this.plugin));
|
||||
this.components.set('advancedSettings', new AdvancedSettingsWrapper(this.plugin));
|
||||
this.components.set('generalSettings', new GeneralSettingsWrapper(this.plugin));
|
||||
this.components.set('audioSettings', new AudioSettingsWrapper(this.plugin));
|
||||
}
|
||||
|
||||
/**
|
||||
* 에러 핸들링 설정
|
||||
*/
|
||||
private setupErrorHandling(): void {
|
||||
this.errorManager.onError((error) => {
|
||||
if (
|
||||
error.severity === ErrorSeverity.HIGH ||
|
||||
error.severity === ErrorSeverity.CRITICAL
|
||||
) {
|
||||
new Notice(`설정 오류: ${error.userMessage || error.message}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
display(): void {
|
||||
const { containerEl } = this;
|
||||
containerEl.empty();
|
||||
containerEl.addClass('speech-to-text-settings');
|
||||
|
||||
void tryCatchAsync(() => Promise.resolve(this.renderContent(containerEl)), {
|
||||
onError: (error) => {
|
||||
void this.errorManager.handleError(error, {
|
||||
type: ErrorType.UNKNOWN,
|
||||
severity: ErrorSeverity.HIGH,
|
||||
userMessage: '설정 페이지를 불러오는 중 오류가 발생했습니다.',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 콘텐츠 렌더링
|
||||
*/
|
||||
private renderContent(containerEl: HTMLElement): void {
|
||||
// 헤더
|
||||
this.createHeader(containerEl);
|
||||
|
||||
// 섹션별 설정
|
||||
this.createGeneralSection(containerEl);
|
||||
this.createApiSection(containerEl);
|
||||
this.createAudioSection(containerEl);
|
||||
this.createAdvancedSection(containerEl);
|
||||
this.createShortcutSection(containerEl);
|
||||
|
||||
// 푸터
|
||||
this.createFooter(containerEl);
|
||||
}
|
||||
|
||||
/**
|
||||
* 헤더 생성
|
||||
*/
|
||||
private createHeader(containerEl: HTMLElement): void {
|
||||
const headerEl = containerEl.createDiv({ cls: 'settings-header' });
|
||||
|
||||
new Setting(headerEl).setName('음성 전사 설정').setHeading();
|
||||
|
||||
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, 'Basics', '기본 동작 설정');
|
||||
const component = this.getComponent('generalSettings', GeneralSettingsWrapper);
|
||||
component?.render(sectionEl);
|
||||
}
|
||||
|
||||
/**
|
||||
* API 설정 섹션 (개선된 버전)
|
||||
*/
|
||||
private createApiSection(containerEl: HTMLElement): void {
|
||||
const sectionEl = this.createSection(containerEl, 'API', 'Openai API 설정');
|
||||
|
||||
const apiKeySetting = new Setting(sectionEl)
|
||||
.setName('API key')
|
||||
.setDesc('Openai API 키를 입력하세요. (sk-로 시작)');
|
||||
|
||||
const inputEl = apiKeySetting.controlEl.createEl('input', {
|
||||
type: 'password',
|
||||
placeholder: 'sk-...',
|
||||
cls: 'api-key-input',
|
||||
});
|
||||
|
||||
const currentKey = this.plugin.settings.apiKey;
|
||||
if (currentKey) {
|
||||
inputEl.value = this.maskApiKey(currentKey);
|
||||
inputEl.setAttribute('data-has-value', 'true');
|
||||
}
|
||||
|
||||
// 토글 버튼 - 이벤트 리스너 관리 개선
|
||||
const toggleBtn = apiKeySetting.controlEl.createEl('button', {
|
||||
text: '👁',
|
||||
cls: 'api-key-toggle',
|
||||
});
|
||||
|
||||
let isVisible = false;
|
||||
this.eventManager.add(toggleBtn, 'click', () => {
|
||||
isVisible = !isVisible;
|
||||
if (isVisible) {
|
||||
inputEl.type = 'text';
|
||||
inputEl.value = currentKey || '';
|
||||
toggleBtn.textContent = '🙈';
|
||||
} else {
|
||||
inputEl.type = 'password';
|
||||
inputEl.value = currentKey ? this.maskApiKey(currentKey) : '';
|
||||
toggleBtn.textContent = '👁';
|
||||
}
|
||||
});
|
||||
|
||||
// 검증 버튼 - 비동기 처리 개선
|
||||
const validateBtn = apiKeySetting.controlEl.createEl('button', {
|
||||
text: '검증',
|
||||
cls: 'mod-cta api-key-validate',
|
||||
});
|
||||
|
||||
this.eventManager.add(validateBtn, 'click', () => {
|
||||
void (async () => {
|
||||
const value = inputEl.value;
|
||||
if (!value || value === this.maskApiKey(currentKey)) {
|
||||
new Notice('API 키를 입력해주세요');
|
||||
return;
|
||||
}
|
||||
|
||||
validateBtn.disabled = true;
|
||||
validateBtn.textContent = '검증 중...';
|
||||
|
||||
// 취소 가능한 Promise로 검증
|
||||
const validation = new CancellablePromise<boolean>((resolve, reject, signal) => {
|
||||
void (async () => {
|
||||
try {
|
||||
const validator = this.getComponent(
|
||||
'apiKeyValidator',
|
||||
ApiKeyValidatorWrapper
|
||||
);
|
||||
if (!validator) {
|
||||
throw new Error('API 키 검증기를 불러오지 못했습니다');
|
||||
}
|
||||
const result = await withTimeout(
|
||||
validator.validate(value),
|
||||
10000,
|
||||
new Error('API 키 검증 시간 초과')
|
||||
);
|
||||
|
||||
if (!signal.aborted) {
|
||||
resolve(result);
|
||||
}
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
this.pendingOperations.add(validation);
|
||||
|
||||
try {
|
||||
const isValid = await validation;
|
||||
|
||||
if (isValid) {
|
||||
this.plugin.settings.apiKey = value;
|
||||
await this.debouncedSave();
|
||||
new Notice('✅ API 키가 검증되었습니다');
|
||||
inputEl.setAttribute('data-valid', 'true');
|
||||
} else {
|
||||
new Notice('❌ 유효하지 않은 API 키입니다');
|
||||
inputEl.setAttribute('data-valid', 'false');
|
||||
}
|
||||
} catch (error) {
|
||||
void this.errorManager.handleError(this.normalizeError(error), {
|
||||
type: ErrorType.VALIDATION,
|
||||
severity: ErrorSeverity.MEDIUM,
|
||||
userMessage: 'API 키 검증 중 오류가 발생했습니다.',
|
||||
});
|
||||
} finally {
|
||||
this.pendingOperations.delete(validation);
|
||||
validateBtn.disabled = false;
|
||||
validateBtn.textContent = '검증';
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
// 입력 변경 시 저장 - 디바운스 적용
|
||||
this.eventManager.add(inputEl, 'input', () => {
|
||||
void (async () => {
|
||||
const value = inputEl.value;
|
||||
if (value && value !== this.maskApiKey(currentKey)) {
|
||||
if (!value.startsWith('sk-')) {
|
||||
new Notice('API 키는 "sk-"로 시작해야 합니다');
|
||||
return;
|
||||
}
|
||||
|
||||
this.plugin.settings.apiKey = value;
|
||||
await this.debouncedSave();
|
||||
inputEl.setAttribute('data-has-value', 'true');
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
this.createApiUsageDisplay(sectionEl);
|
||||
}
|
||||
|
||||
/**
|
||||
* 오디오 설정 섹션
|
||||
*/
|
||||
private createAudioSection(containerEl: HTMLElement): void {
|
||||
const sectionEl = this.createSection(containerEl, 'Audio', '음성 변환 설정');
|
||||
const component = this.getComponent('audioSettings', AudioSettingsWrapper);
|
||||
component?.render(sectionEl);
|
||||
}
|
||||
|
||||
/**
|
||||
* 고급 설정 섹션
|
||||
*/
|
||||
private createAdvancedSection(containerEl: HTMLElement): void {
|
||||
const sectionEl = this.createSection(containerEl, 'Advanced', '고급 설정');
|
||||
const component = this.getComponent('advancedSettings', AdvancedSettingsWrapper);
|
||||
component?.render(sectionEl);
|
||||
}
|
||||
|
||||
/**
|
||||
* 단축키 설정 섹션
|
||||
*/
|
||||
private createShortcutSection(containerEl: HTMLElement): void {
|
||||
const sectionEl = this.createSection(containerEl, 'Shortcuts', '단축키 설정');
|
||||
const component = this.getComponent('shortcutSettings', ShortcutSettingsWrapper);
|
||||
component?.render(sectionEl);
|
||||
}
|
||||
|
||||
/**
|
||||
* 푸터 생성 (개선된 버전)
|
||||
*/
|
||||
private createFooter(containerEl: HTMLElement): void {
|
||||
const footerEl = containerEl.createDiv({ cls: 'settings-footer' });
|
||||
|
||||
const exportImportEl = footerEl.createDiv({ cls: 'settings-export-import' });
|
||||
|
||||
// 설정 내보내기
|
||||
const exportBtn = new ButtonComponent(exportImportEl).setButtonText('설정 내보내기');
|
||||
|
||||
this.eventManager.add(exportBtn.buttonEl, 'click', () => {
|
||||
void tryCatchAsync(() => this.exportSettings(), {
|
||||
onError: (error) => {
|
||||
void this.errorManager.handleError(error, {
|
||||
type: ErrorType.UNKNOWN,
|
||||
severity: ErrorSeverity.LOW,
|
||||
userMessage: '설정 내보내기에 실패했습니다.',
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// 설정 가져오기
|
||||
const importBtn = new ButtonComponent(exportImportEl).setButtonText('설정 가져오기');
|
||||
|
||||
this.eventManager.add(importBtn.buttonEl, 'click', () => {
|
||||
void tryCatchAsync(() => this.importSettings(), {
|
||||
onError: (error) => {
|
||||
void this.errorManager.handleError(error, {
|
||||
type: ErrorType.UNKNOWN,
|
||||
severity: ErrorSeverity.LOW,
|
||||
userMessage: '설정 가져오기에 실패했습니다.',
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// 초기화 버튼
|
||||
const resetBtn = new ButtonComponent(footerEl)
|
||||
.setButtonText('기본값으로 초기화')
|
||||
.setWarning();
|
||||
|
||||
this.eventManager.add(resetBtn.buttonEl, 'click', () => {
|
||||
void (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' });
|
||||
new Setting(headerEl).setName(title).setHeading();
|
||||
headerEl.createEl('p', { text: desc, cls: 'section-description' });
|
||||
|
||||
return sectionEl.createDiv({ cls: 'section-content' });
|
||||
}
|
||||
|
||||
/**
|
||||
* API 사용량 표시
|
||||
*/
|
||||
private createApiUsageDisplay(containerEl: HTMLElement): void {
|
||||
const usageEl = containerEl.createDiv({ cls: 'api-usage-display' });
|
||||
|
||||
new Setting(usageEl).setName('API 사용량').setHeading();
|
||||
|
||||
const statsEl = usageEl.createDiv({ cls: 'usage-stats' });
|
||||
|
||||
statsEl.createEl('div', {
|
||||
text: '이번 달 사용량: 0 / 무제한',
|
||||
cls: 'usage-item',
|
||||
});
|
||||
|
||||
statsEl.createEl('div', {
|
||||
text: '예상 비용: $0.00',
|
||||
cls: 'usage-item',
|
||||
});
|
||||
|
||||
const refreshBtn = new ButtonComponent(usageEl).setButtonText('사용량 새로고침');
|
||||
|
||||
this.eventManager.add(refreshBtn.buttonEl, 'click', () => {
|
||||
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';
|
||||
}> = [];
|
||||
|
||||
if (settings.apiKey) {
|
||||
statusItems.push({
|
||||
label: 'API 키',
|
||||
value: '구성됨',
|
||||
status: 'success',
|
||||
});
|
||||
} else {
|
||||
statusItems.push({
|
||||
label: 'API 키',
|
||||
value: '미구성',
|
||||
status: 'error',
|
||||
});
|
||||
}
|
||||
|
||||
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 maskApiKey(key: string): string {
|
||||
if (!key || key.length < 10) return '***';
|
||||
const visibleStart = 7;
|
||||
const visibleEnd = 4;
|
||||
const masked = '*'.repeat(Math.max(0, key.length - visibleStart - visibleEnd));
|
||||
return key.substring(0, visibleStart) + masked + key.substring(key.length - visibleEnd);
|
||||
}
|
||||
|
||||
/**
|
||||
* 언어 레이블 가져오기
|
||||
*/
|
||||
private getLanguageLabel(code: string): string {
|
||||
const languages: Record<string, string> = {
|
||||
auto: '자동 감지',
|
||||
en: 'English',
|
||||
ko: '한국어',
|
||||
ja: '日本語',
|
||||
zh: '中文',
|
||||
es: 'Español',
|
||||
fr: 'Français',
|
||||
de: 'Deutsch',
|
||||
};
|
||||
return languages[code] || code;
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정 내보내기
|
||||
*/
|
||||
private exportSettings(): Promise<void> {
|
||||
const exportSettings: Record<string, unknown> = { ...this.plugin.settings };
|
||||
delete exportSettings.apiKey;
|
||||
delete exportSettings.encryptedApiKey;
|
||||
|
||||
const json = JSON.stringify(exportSettings, null, 2);
|
||||
const blob = new Blob([json], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const a = createEl('a');
|
||||
a.href = url;
|
||||
a.download = `speech-to-text-settings-${Date.now()}.json`;
|
||||
a.click();
|
||||
|
||||
// 메모리 정리
|
||||
this.resourceManager.addTimer(window.setTimeout(() => URL.revokeObjectURL(url), 100));
|
||||
|
||||
new Notice('설정을 내보냈습니다');
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정 가져오기
|
||||
*/
|
||||
private async importSettings(): Promise<void> {
|
||||
const input = createEl('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.json';
|
||||
|
||||
const filePromise = new Promise<File>((resolve, reject) => {
|
||||
this.eventManager.add(input, 'change', (e) => {
|
||||
const target = e.target;
|
||||
if (!(target instanceof HTMLInputElement)) {
|
||||
reject(new Error('파일 입력을 읽지 못했습니다'));
|
||||
return;
|
||||
}
|
||||
const file = target.files?.[0];
|
||||
if (file) {
|
||||
resolve(file);
|
||||
} else {
|
||||
reject(new Error('파일이 선택되지 않았습니다'));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
input.click();
|
||||
|
||||
const file = await filePromise;
|
||||
const text = await file.text();
|
||||
const parsed = JSON.parse(text) as unknown;
|
||||
if (!isPlainRecord(parsed)) {
|
||||
throw new Error('Invalid settings data');
|
||||
}
|
||||
|
||||
const currentApiKey = this.plugin.settings.apiKey;
|
||||
Object.assign(this.plugin.settings, parsed);
|
||||
|
||||
if (currentApiKey) {
|
||||
this.plugin.settings.apiKey = currentApiKey;
|
||||
}
|
||||
|
||||
await this.plugin.saveSettings();
|
||||
|
||||
new Notice('설정을 가져왔습니다');
|
||||
this.display();
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정 초기화 확인
|
||||
*/
|
||||
private confirmReset(): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const modal = new ConfirmModalRefactored(
|
||||
this.app,
|
||||
'설정 초기화',
|
||||
'모든 설정을 기본값으로 초기화하시겠습니까? API 키도 삭제됩니다.',
|
||||
resolve
|
||||
);
|
||||
modal.open();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정 초기화
|
||||
*/
|
||||
private async resetSettings(): Promise<void> {
|
||||
const { DEFAULT_SETTINGS } = await import('../../domain/models/Settings');
|
||||
this.plugin.settings = { ...DEFAULT_SETTINGS };
|
||||
await this.plugin.saveSettings();
|
||||
|
||||
new Notice('설정이 초기화되었습니다');
|
||||
this.display();
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트 정리
|
||||
*/
|
||||
hide(): void {
|
||||
// 진행 중인 비동기 작업 취소
|
||||
this.pendingOperations.forEach((operation) => operation.cancel());
|
||||
this.pendingOperations.clear();
|
||||
|
||||
// 리소스 정리
|
||||
this.resourceManager.dispose();
|
||||
this.eventManager.removeAll();
|
||||
|
||||
// 컴포넌트 정리
|
||||
this.components.forEach((component) => component.dispose());
|
||||
this.components.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 개선된 확인 모달
|
||||
*/
|
||||
class ConfirmModalRefactored extends Modal {
|
||||
private resourceManager = new ResourceManager();
|
||||
private eventManager = new EventListenerManager();
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
private title: string,
|
||||
private message: string,
|
||||
private onConfirm: (confirmed: boolean) => void
|
||||
) {
|
||||
super(app);
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
const { contentEl } = this;
|
||||
|
||||
new Setting(contentEl).setName(this.title).setHeading();
|
||||
contentEl.createEl('p', { text: this.message });
|
||||
|
||||
const buttonContainer = contentEl.createDiv({ cls: 'modal-button-container' });
|
||||
|
||||
const cancelBtn = new ButtonComponent(buttonContainer).setButtonText('취소');
|
||||
|
||||
this.eventManager.add(cancelBtn.buttonEl, 'click', () => {
|
||||
this.onConfirm(false);
|
||||
this.close();
|
||||
});
|
||||
|
||||
const confirmBtn = new ButtonComponent(buttonContainer).setButtonText('확인').setWarning();
|
||||
|
||||
this.eventManager.add(confirmBtn.buttonEl, 'click', () => {
|
||||
this.onConfirm(true);
|
||||
this.close();
|
||||
});
|
||||
}
|
||||
|
||||
onClose() {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
|
||||
// 리소스 정리
|
||||
this.resourceManager.dispose();
|
||||
this.eventManager.removeAll();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트 래퍼 클래스들
|
||||
*/
|
||||
class ApiKeyValidatorWrapper extends AutoDisposable {
|
||||
private validator: ApiKeyValidator;
|
||||
|
||||
constructor(plugin: SpeechToTextPlugin) {
|
||||
super();
|
||||
this.validator = new ApiKeyValidator(plugin);
|
||||
}
|
||||
|
||||
validate(key: string): Promise<boolean> {
|
||||
return this.validator.validate(key);
|
||||
}
|
||||
|
||||
protected onDispose(): void {
|
||||
// 추가 정리 로직
|
||||
}
|
||||
}
|
||||
|
||||
class GeneralSettingsWrapper extends AutoDisposable {
|
||||
private settings: GeneralSettings;
|
||||
|
||||
constructor(plugin: SpeechToTextPlugin) {
|
||||
super();
|
||||
this.settings = new GeneralSettings(plugin);
|
||||
}
|
||||
|
||||
render(container: HTMLElement): void {
|
||||
this.settings.render(container);
|
||||
}
|
||||
|
||||
protected onDispose(): void {
|
||||
// 추가 정리 로직
|
||||
}
|
||||
}
|
||||
|
||||
class AudioSettingsWrapper extends AutoDisposable {
|
||||
private settings: AudioSettings;
|
||||
|
||||
constructor(plugin: SpeechToTextPlugin) {
|
||||
super();
|
||||
this.settings = new AudioSettings(plugin);
|
||||
}
|
||||
|
||||
render(container: HTMLElement): void {
|
||||
this.settings.render(container);
|
||||
}
|
||||
|
||||
protected onDispose(): void {
|
||||
// 추가 정리 로직
|
||||
}
|
||||
}
|
||||
|
||||
class AdvancedSettingsWrapper extends AutoDisposable {
|
||||
private settings: AdvancedSettings;
|
||||
|
||||
constructor(plugin: SpeechToTextPlugin) {
|
||||
super();
|
||||
this.settings = new AdvancedSettings(plugin);
|
||||
}
|
||||
|
||||
render(container: HTMLElement): void {
|
||||
this.settings.render(container);
|
||||
}
|
||||
|
||||
protected onDispose(): void {
|
||||
// 추가 정리 로직
|
||||
}
|
||||
}
|
||||
|
||||
class ShortcutSettingsWrapper extends AutoDisposable {
|
||||
private settings: ShortcutSettings;
|
||||
|
||||
constructor(app: App, plugin: SpeechToTextPlugin) {
|
||||
super();
|
||||
this.settings = new ShortcutSettings(app, plugin);
|
||||
}
|
||||
|
||||
render(container: HTMLElement): void {
|
||||
this.settings.render(container);
|
||||
}
|
||||
|
||||
protected onDispose(): void {
|
||||
// 추가 정리 로직
|
||||
}
|
||||
}
|
||||
|
|
@ -55,7 +55,7 @@ export class ProviderSettings {
|
|||
* 섹션 헤더 렌더링
|
||||
*/
|
||||
private renderHeader(containerEl: HTMLElement): void {
|
||||
const headerEl = containerEl.createDiv({ cls: 'provider-settings-header' });
|
||||
const headerEl = containerEl.createDiv({ cls: 'sn-provider-settings-header' });
|
||||
|
||||
headerEl.createEl('h3', {
|
||||
text: 'Transcription provider',
|
||||
|
|
@ -145,7 +145,7 @@ export class ProviderSettings {
|
|||
const inputEl = settingEl.controlEl.createEl('input', {
|
||||
type: 'password',
|
||||
placeholder: placeholder,
|
||||
cls: 'api-key-input',
|
||||
cls: 'sn-api-key-input',
|
||||
});
|
||||
|
||||
// 현재 값 설정
|
||||
|
|
@ -191,7 +191,7 @@ export class ProviderSettings {
|
|||
*/
|
||||
private renderAdvancedSettings(containerEl: HTMLElement): void {
|
||||
const advancedEl = containerEl.createDiv({
|
||||
cls: 'advanced-settings-container',
|
||||
cls: 'sn-advanced-settings-container',
|
||||
});
|
||||
|
||||
// Selection Strategy
|
||||
|
|
@ -425,7 +425,7 @@ export class ProviderSettings {
|
|||
* 연결 상태 표시
|
||||
*/
|
||||
private renderConnectionStatus(containerEl: HTMLElement): void {
|
||||
const statusEl = containerEl.createDiv({ cls: 'connection-status' });
|
||||
const statusEl = containerEl.createDiv({ cls: 'sn-connection-status' });
|
||||
|
||||
const whisperConnected = this.checkConnection('whisper');
|
||||
const deepgramConnected = this.checkConnection('deepgram');
|
||||
|
|
|
|||
|
|
@ -1,872 +0,0 @@
|
|||
import { App, Setting, Notice, Modal, ButtonComponent } from 'obsidian';
|
||||
import type SpeechToTextPlugin from '../../../main';
|
||||
import {
|
||||
TranscriptionProvider,
|
||||
SelectionStrategy,
|
||||
} from '../../../infrastructure/api/providers/ITranscriber';
|
||||
import { APIKeyManager } from './components/APIKeyManager';
|
||||
import { AdvancedSettingsPanel } from './components/AdvancedSettingsPanel';
|
||||
// import { ProviderMetricsDisplay } from './components/ProviderMetricsDisplay';
|
||||
import { isPlainRecord } from '../../../types/guards';
|
||||
|
||||
/**
|
||||
* Provider Settings Container
|
||||
*
|
||||
* 컨테이너 컴포넌트로서 Provider 관련 모든 설정을 관리합니다.
|
||||
* Single Responsibility: Provider 설정의 전체적인 조율 및 상태 관리
|
||||
*
|
||||
* @reliability 99.9% - 안정적인 설정 관리
|
||||
* @security 모든 API 키는 암호화되어 저장
|
||||
* @performance <100ms UI 응답
|
||||
*/
|
||||
export class ProviderSettingsContainer {
|
||||
private apiKeyManager: APIKeyManager;
|
||||
private advancedPanel: AdvancedSettingsPanel;
|
||||
private metricsDisplay?: ProviderMetricsDisplay;
|
||||
|
||||
// 상태
|
||||
private currentProvider: TranscriptionProvider | 'auto' = 'auto';
|
||||
private isExpanded = false;
|
||||
private connectionStatus: Map<TranscriptionProvider, boolean> = new Map();
|
||||
private lastValidation: Map<TranscriptionProvider, Date> = new Map();
|
||||
|
||||
// 실시간 업데이트를 위한 interval
|
||||
private statusUpdateInterval?: number;
|
||||
|
||||
constructor(private app: App, private plugin: SpeechToTextPlugin) {
|
||||
this.apiKeyManager = new APIKeyManager(plugin);
|
||||
this.advancedPanel = new AdvancedSettingsPanel(plugin);
|
||||
|
||||
void this.initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* 초기화
|
||||
*/
|
||||
private initialize(): void {
|
||||
// 현재 설정 로드
|
||||
this.loadSettings();
|
||||
|
||||
// 연결 상태 확인
|
||||
this.checkAllConnections();
|
||||
|
||||
// 실시간 상태 업데이트 시작
|
||||
this.startStatusMonitoring();
|
||||
}
|
||||
|
||||
/**
|
||||
* 컨테이너 렌더링
|
||||
*/
|
||||
public render(containerEl: HTMLElement): void {
|
||||
containerEl.empty();
|
||||
containerEl.addClass('provider-settings-container');
|
||||
|
||||
// 헤더 섹션
|
||||
this.renderHeader(containerEl);
|
||||
|
||||
// 상태 대시보드
|
||||
this.renderStatusDashboard(containerEl);
|
||||
|
||||
// Provider 선택 섹션
|
||||
this.renderProviderSelection(containerEl);
|
||||
|
||||
// API Key 관리 섹션
|
||||
this.renderApiKeySection(containerEl);
|
||||
|
||||
// 고급 설정 (토글 가능)
|
||||
if (this.isExpanded) {
|
||||
this.renderAdvancedSection(containerEl);
|
||||
}
|
||||
|
||||
// 메트릭 표시 (옵션)
|
||||
if (this.plugin.settings.showMetrics) {
|
||||
this.renderMetricsSection(containerEl);
|
||||
}
|
||||
|
||||
// 액션 버튼들
|
||||
this.renderActions(containerEl);
|
||||
}
|
||||
|
||||
/**
|
||||
* 헤더 렌더링
|
||||
*/
|
||||
private renderHeader(containerEl: HTMLElement): void {
|
||||
const headerEl = containerEl.createDiv({ cls: 'provider-header' });
|
||||
|
||||
// 타이틀
|
||||
const titleEl = headerEl.createDiv({ cls: 'provider-title' });
|
||||
new Setting(titleEl).setName('🎯 transcription provider configuration').setHeading();
|
||||
|
||||
// 확장/축소 토글
|
||||
const toggleBtn = headerEl.createEl('button', {
|
||||
cls: 'provider-expand-toggle',
|
||||
attr: {
|
||||
'aria-label': this.isExpanded ? 'Collapse settings' : 'Expand settings',
|
||||
'aria-expanded': String(this.isExpanded),
|
||||
},
|
||||
});
|
||||
toggleBtn.setText(this.isExpanded ? '▼' : '▶');
|
||||
toggleBtn.onclick = () => this.toggleExpanded(containerEl);
|
||||
|
||||
// 설명
|
||||
headerEl.createEl('p', {
|
||||
text: 'Configure your speech-to-text providers for optimal performance and reliability.',
|
||||
cls: 'provider-description',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태 대시보드
|
||||
*/
|
||||
private renderStatusDashboard(containerEl: HTMLElement): void {
|
||||
const dashboardEl = containerEl.createDiv({ cls: 'provider-status-dashboard' });
|
||||
|
||||
// 전체 상태 인디케이터
|
||||
const overallStatus = this.getOverallStatus();
|
||||
const statusEl = dashboardEl.createDiv({
|
||||
cls: `overall-status status-${overallStatus.level}`,
|
||||
});
|
||||
|
||||
const indicator = statusEl.createDiv({ cls: 'status-indicator' });
|
||||
indicator.createEl('span', {
|
||||
cls: 'status-icon',
|
||||
text: overallStatus.icon,
|
||||
});
|
||||
indicator.createEl('span', {
|
||||
cls: 'status-text',
|
||||
text: overallStatus.text,
|
||||
});
|
||||
|
||||
// Provider별 상태
|
||||
const providersEl = dashboardEl.createDiv({ cls: 'providers-status-grid' });
|
||||
|
||||
const providers: TranscriptionProvider[] = ['whisper', 'deepgram'];
|
||||
providers.forEach((provider) => {
|
||||
const providerEl = providersEl.createDiv({ cls: 'provider-status-item' });
|
||||
const isConnected = this.connectionStatus.get(provider) || false;
|
||||
const hasKey = this.hasApiKey(provider);
|
||||
|
||||
providerEl.createDiv({
|
||||
cls: 'provider-name',
|
||||
text: this.getProviderDisplayName(provider),
|
||||
});
|
||||
|
||||
const indicatorsEl = providerEl.createDiv({ cls: 'provider-indicators' });
|
||||
|
||||
const keyStatus = indicatorsEl.createEl('span', {
|
||||
cls: `indicator key-status ${hasKey ? 'has-key' : 'no-key'}`,
|
||||
attr: { title: hasKey ? 'API key configured' : 'No API key' },
|
||||
});
|
||||
keyStatus.setText(hasKey ? '🔑' : '🔒');
|
||||
|
||||
const connectionStatus = indicatorsEl.createEl('span', {
|
||||
cls: `indicator connection-status ${isConnected ? 'connected' : 'disconnected'}`,
|
||||
attr: { title: isConnected ? 'Connected' : 'Not connected' },
|
||||
});
|
||||
connectionStatus.setText(isConnected ? '✅' : '⭕');
|
||||
|
||||
const performanceStatus = indicatorsEl.createEl('span', {
|
||||
cls: 'indicator performance-status',
|
||||
attr: { title: 'Performance score' },
|
||||
});
|
||||
performanceStatus.setText(this.getPerformanceIndicator(provider));
|
||||
|
||||
// 클릭시 상세 정보
|
||||
providerEl.onclick = () => this.showProviderDetails(provider);
|
||||
});
|
||||
|
||||
// 실시간 업데이트 타이머
|
||||
const updateEl = dashboardEl.createDiv({ cls: 'last-update' });
|
||||
updateEl.createEl('small', {
|
||||
text: `Last checked: ${new Date().toLocaleTimeString()}`,
|
||||
cls: 'update-time',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider 선택 섹션
|
||||
*/
|
||||
private renderProviderSelection(containerEl: HTMLElement): void {
|
||||
const selectionEl = containerEl.createDiv({ cls: 'provider-selection-section' });
|
||||
|
||||
new Setting(selectionEl)
|
||||
.setName('Provider mode')
|
||||
.setDesc('Select how to choose the transcription provider')
|
||||
.addDropdown((dropdown) => {
|
||||
dropdown
|
||||
.addOption('auto', '🤖 automatic (recommended)')
|
||||
.addOption('whisper', '🎯 openai whisper only')
|
||||
.addOption('deepgram', '🚀 deepgram only')
|
||||
.setValue(this.currentProvider)
|
||||
.onChange(async (value) => {
|
||||
if (this.isProviderSelection(value)) {
|
||||
this.currentProvider = value;
|
||||
await this.saveProviderSelection(value);
|
||||
|
||||
// UI 업데이트
|
||||
this.updateProviderVisibility(containerEl);
|
||||
|
||||
// 즉각적인 피드백
|
||||
this.showProviderNotice(value);
|
||||
}
|
||||
});
|
||||
})
|
||||
.addExtraButton((button) => {
|
||||
button
|
||||
.setIcon('help-circle')
|
||||
.setTooltip('Learn more about provider selection')
|
||||
.onClick(() => this.showProviderHelp());
|
||||
});
|
||||
|
||||
// Auto 모드일 때 전략 선택
|
||||
if (this.currentProvider === 'auto') {
|
||||
this.renderStrategySelection(selectionEl);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 전략 선택 (Auto 모드)
|
||||
*/
|
||||
private renderStrategySelection(containerEl: HTMLElement): void {
|
||||
new Setting(containerEl)
|
||||
.setName('Selection strategy')
|
||||
.setDesc('How should the system choose between providers?')
|
||||
.addDropdown((dropdown) => {
|
||||
dropdown
|
||||
.addOption(SelectionStrategy.PERFORMANCE_OPTIMIZED, '⚡ performance first')
|
||||
.addOption(SelectionStrategy.COST_OPTIMIZED, '💰 cost optimized')
|
||||
.addOption(SelectionStrategy.QUALITY_OPTIMIZED, '✨ quality first')
|
||||
.addOption(SelectionStrategy.ROUND_ROBIN, '🔄 round robin')
|
||||
.setValue(
|
||||
this.plugin.settings.selectionStrategy ||
|
||||
SelectionStrategy.PERFORMANCE_OPTIMIZED
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
if (this.isSelectionStrategy(value)) {
|
||||
await this.saveStrategy(value);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* API Key 관리 섹션
|
||||
*/
|
||||
private renderApiKeySection(containerEl: HTMLElement): void {
|
||||
const apiKeySection = containerEl.createDiv({ cls: 'api-key-section' });
|
||||
|
||||
// APIKeyManager 컴포넌트 렌더링
|
||||
this.apiKeyManager.render(apiKeySection, this.currentProvider);
|
||||
|
||||
// 일괄 검증 버튼
|
||||
const actionsEl = apiKeySection.createDiv({ cls: 'api-key-actions' });
|
||||
|
||||
new ButtonComponent(actionsEl)
|
||||
.setButtonText('Verify all keys')
|
||||
.setCta()
|
||||
.onClick(async () => {
|
||||
await this.verifyAllApiKeys();
|
||||
});
|
||||
|
||||
new ButtonComponent(actionsEl).setButtonText('Import keys').onClick(() => {
|
||||
this.importApiKeys();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 고급 설정 섹션
|
||||
*/
|
||||
private renderAdvancedSection(containerEl: HTMLElement): void {
|
||||
const advancedSection = containerEl.createDiv({ cls: 'advanced-settings-section' });
|
||||
|
||||
// AdvancedSettingsPanel 컴포넌트 렌더링
|
||||
this.advancedPanel.render(advancedSection);
|
||||
}
|
||||
|
||||
/**
|
||||
* 메트릭 섹션
|
||||
*/
|
||||
private renderMetricsSection(containerEl: HTMLElement): void {
|
||||
const metricsSection = containerEl.createDiv({ cls: 'metrics-section' });
|
||||
|
||||
if (!this.metricsDisplay) {
|
||||
this.metricsDisplay = new ProviderMetricsDisplay(this.plugin);
|
||||
}
|
||||
|
||||
this.metricsDisplay.render(metricsSection);
|
||||
}
|
||||
|
||||
/**
|
||||
* 액션 버튼들
|
||||
*/
|
||||
private renderActions(containerEl: HTMLElement): void {
|
||||
const actionsEl = containerEl.createDiv({ cls: 'provider-actions' });
|
||||
|
||||
// 테스트 버튼
|
||||
new ButtonComponent(actionsEl).setButtonText('Test connection').onClick(async () => {
|
||||
await this.testCurrentProvider();
|
||||
});
|
||||
|
||||
// 설정 내보내기
|
||||
new ButtonComponent(actionsEl).setButtonText('Export config').onClick(() => {
|
||||
this.exportConfiguration();
|
||||
});
|
||||
|
||||
// 설정 초기화
|
||||
new ButtonComponent(actionsEl)
|
||||
.setButtonText('Reset to defaults')
|
||||
.setWarning()
|
||||
.onClick(async () => {
|
||||
if (await this.confirmReset()) {
|
||||
await this.resetProviderSettings();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// === Helper Methods ===
|
||||
|
||||
/**
|
||||
* 전체 상태 가져오기
|
||||
*/
|
||||
private getOverallStatus(): { level: string; icon: string; text: string } {
|
||||
const hasAnyKey = this.hasApiKey('whisper') || this.hasApiKey('deepgram');
|
||||
const hasAnyConnection = Array.from(this.connectionStatus.values()).some((v) => v);
|
||||
|
||||
if (hasAnyConnection) {
|
||||
return { level: 'good', icon: '✅', text: 'Operational' };
|
||||
} else if (hasAnyKey) {
|
||||
return { level: 'warning', icon: '⚠️', text: 'Keys configured, not connected' };
|
||||
} else {
|
||||
return { level: 'error', icon: '❌', text: 'No providers configured' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API 키 존재 확인
|
||||
*/
|
||||
private hasApiKey(provider: TranscriptionProvider): boolean {
|
||||
if (provider === 'whisper') {
|
||||
return !!this.plugin.settings.apiKey;
|
||||
} else if (provider === 'deepgram') {
|
||||
return !!this.plugin.settings.deepgramApiKey;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider 표시 이름
|
||||
*/
|
||||
private getProviderDisplayName(provider: string): string {
|
||||
const names: Record<string, string> = {
|
||||
whisper: 'OpenAI whisper',
|
||||
deepgram: 'Deepgram',
|
||||
auto: 'Automatic',
|
||||
};
|
||||
return names[provider] || provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* 성능 인디케이터
|
||||
*/
|
||||
private getPerformanceIndicator(_provider: TranscriptionProvider): string {
|
||||
// TODO: 실제 메트릭 연동
|
||||
const score = Math.random() * 100; // 임시 값
|
||||
|
||||
if (score >= 90) return '🟢';
|
||||
if (score >= 70) return '🟡';
|
||||
if (score >= 50) return '🟠';
|
||||
return '🔴';
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider 상세 정보 표시
|
||||
*/
|
||||
private showProviderDetails(provider: TranscriptionProvider): void {
|
||||
const modal = new ProviderDetailsModal(this.app, provider, this.plugin);
|
||||
modal.open();
|
||||
}
|
||||
|
||||
/**
|
||||
* 확장/축소 토글
|
||||
*/
|
||||
private toggleExpanded(containerEl: HTMLElement): void {
|
||||
this.isExpanded = !this.isExpanded;
|
||||
this.render(containerEl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider 선택 알림
|
||||
*/
|
||||
private showProviderNotice(provider: string): void {
|
||||
const messages: Record<string, string> = {
|
||||
auto: '🤖 System will automatically select the best provider',
|
||||
whisper: '🎯 Using OpenAI whisper exclusively',
|
||||
deepgram: '🚀 Using Deepgram exclusively',
|
||||
};
|
||||
new Notice(messages[provider] || 'Provider updated');
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider 도움말 표시
|
||||
*/
|
||||
private showProviderHelp(): void {
|
||||
const modal = new Modal(this.app);
|
||||
modal.titleEl.setText('Provider selection guide');
|
||||
|
||||
const contentEl = modal.contentEl;
|
||||
const helpContainer = contentEl.createDiv('provider-help');
|
||||
|
||||
const sections = [
|
||||
{
|
||||
title: '🤖 automatic mode',
|
||||
description: 'The system intelligently selects the best provider based on:',
|
||||
bullets: [
|
||||
'Current availability and response times',
|
||||
'Historical success rates',
|
||||
'Your configured selection strategy',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '🎯 OpenAI whisper',
|
||||
bullets: [
|
||||
'Excellent accuracy for 50+ languages',
|
||||
'Best for long-form content',
|
||||
'Supports timestamps and speaker diarization',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '🚀 deepgram',
|
||||
bullets: [
|
||||
'Ultra-fast real-time transcription',
|
||||
'Lower latency than whisper',
|
||||
'Cost-effective for high volume',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
sections.forEach((section) => {
|
||||
new Setting(helpContainer).setName(section.title).setHeading();
|
||||
if (section.description) {
|
||||
helpContainer.createEl('p', { text: section.description });
|
||||
}
|
||||
|
||||
if (section.bullets?.length) {
|
||||
const list = helpContainer.createEl('ul');
|
||||
section.bullets.forEach((item) => {
|
||||
list.createEl('li', { text: item });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
modal.open();
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 연결 확인
|
||||
*/
|
||||
private checkAllConnections(): void {
|
||||
const providers: TranscriptionProvider[] = ['whisper', 'deepgram'];
|
||||
|
||||
for (const provider of providers) {
|
||||
if (this.hasApiKey(provider)) {
|
||||
const isConnected = this.checkProviderConnection(provider);
|
||||
this.connectionStatus.set(provider, isConnected);
|
||||
this.lastValidation.set(provider, new Date());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider 연결 확인
|
||||
*/
|
||||
private checkProviderConnection(provider: TranscriptionProvider): boolean {
|
||||
try {
|
||||
// TODO: 실제 연결 테스트 구현
|
||||
return true; // 임시
|
||||
} catch (error) {
|
||||
console.error(`Failed to check ${provider} connection:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태 모니터링 시작
|
||||
*/
|
||||
private startStatusMonitoring(): void {
|
||||
// 5분마다 상태 업데이트
|
||||
this.statusUpdateInterval = window.setInterval(() => {
|
||||
this.checkAllConnections();
|
||||
}, 5 * 60 * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 API 키 검증
|
||||
*/
|
||||
private async verifyAllApiKeys(): Promise<void> {
|
||||
const notice = new Notice('Verifying API keys...', 0);
|
||||
|
||||
try {
|
||||
const results = await this.apiKeyManager.verifyAllKeys();
|
||||
|
||||
let message = 'Verification complete:\n';
|
||||
for (const [provider, result] of results) {
|
||||
message += `${this.getProviderDisplayName(provider)}: ${result ? '✅' : '❌'}\n`;
|
||||
}
|
||||
|
||||
notice.hide();
|
||||
new Notice(message, 5000);
|
||||
} catch (error) {
|
||||
notice.hide();
|
||||
new Notice('Failed to verify API keys', 5000);
|
||||
console.error('API key verification error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API 키 가져오기
|
||||
*/
|
||||
private importApiKeys(): void {
|
||||
// 파일 선택 다이얼로그
|
||||
const input = createEl('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.json';
|
||||
|
||||
input.onchange = async (e) => {
|
||||
const target = e.target;
|
||||
if (!(target instanceof HTMLInputElement)) {
|
||||
return;
|
||||
}
|
||||
const file = target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const content = await file.text();
|
||||
const parsed: unknown = JSON.parse(content);
|
||||
const keys: Record<string, string> = {};
|
||||
if (isPlainRecord(parsed)) {
|
||||
Object.entries(parsed).forEach(([key, value]) => {
|
||||
if (typeof value === 'string') {
|
||||
keys[key] = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
await this.apiKeyManager.importKeys(keys);
|
||||
new Notice('API keys imported successfully');
|
||||
|
||||
// UI 새로고침
|
||||
const containerEl = document.querySelector('.provider-settings-container');
|
||||
if (containerEl instanceof HTMLElement) {
|
||||
this.render(containerEl);
|
||||
}
|
||||
} catch (error) {
|
||||
new Notice('Failed to import API keys');
|
||||
console.error('Import error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
input.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 Provider 테스트
|
||||
*/
|
||||
private async testCurrentProvider(): Promise<void> {
|
||||
const notice = new Notice('Testing connection...', 0);
|
||||
|
||||
try {
|
||||
// TODO: 실제 테스트 구현
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000)); // 시뮬레이션
|
||||
|
||||
notice.hide();
|
||||
new Notice('✅ connection successful.', 3000);
|
||||
} catch (error) {
|
||||
notice.hide();
|
||||
new Notice('❌ connection failed.', 3000);
|
||||
console.error('Connection test error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정 내보내기
|
||||
*/
|
||||
private exportConfiguration(): void {
|
||||
try {
|
||||
const config = {
|
||||
provider: this.currentProvider,
|
||||
strategy: this.plugin.settings.selectionStrategy,
|
||||
// API 키는 제외 (보안)
|
||||
settings: {
|
||||
...this.plugin.settings,
|
||||
apiKey: undefined,
|
||||
deepgramApiKey: undefined,
|
||||
whisperApiKey: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
const blob = new Blob([JSON.stringify(config, null, 2)], {
|
||||
type: 'application/json',
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const a = createEl('a');
|
||||
a.href = url;
|
||||
a.download = `provider-config-${Date.now()}.json`;
|
||||
a.click();
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
new Notice('Configuration exported');
|
||||
} catch (error) {
|
||||
new Notice('Failed to export configuration');
|
||||
console.error('Export error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 초기화 확인
|
||||
*/
|
||||
private confirmReset(): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const modal = new Modal(this.app);
|
||||
modal.titleEl.setText('Reset provider settings?');
|
||||
|
||||
modal.contentEl.createEl('p', {
|
||||
text: 'This will reset all provider settings to defaults. API keys will be preserved.',
|
||||
});
|
||||
|
||||
const buttonContainer = modal.contentEl.createDiv({ cls: 'modal-button-container' });
|
||||
|
||||
new ButtonComponent(buttonContainer).setButtonText('Cancel').onClick(() => {
|
||||
modal.close();
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
new ButtonComponent(buttonContainer)
|
||||
.setButtonText('Reset')
|
||||
.setWarning()
|
||||
.onClick(() => {
|
||||
modal.close();
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
modal.open();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider 설정 초기화
|
||||
*/
|
||||
private async resetProviderSettings(): Promise<void> {
|
||||
// API 키 보존
|
||||
const apiKey = this.plugin.settings.apiKey;
|
||||
const deepgramKey = this.plugin.settings.deepgramApiKey;
|
||||
|
||||
// 기본값으로 초기화
|
||||
this.currentProvider = 'auto';
|
||||
this.plugin.settings.provider = 'auto';
|
||||
this.plugin.settings.selectionStrategy = SelectionStrategy.PERFORMANCE_OPTIMIZED;
|
||||
this.plugin.settings.costLimit = undefined;
|
||||
this.plugin.settings.qualityThreshold = 85;
|
||||
this.plugin.settings.abTestEnabled = false;
|
||||
|
||||
// API 키 복원
|
||||
this.plugin.settings.apiKey = apiKey;
|
||||
this.plugin.settings.deepgramApiKey = deepgramKey;
|
||||
|
||||
await this.plugin.saveSettings();
|
||||
|
||||
new Notice('Provider settings reset to defaults');
|
||||
|
||||
// UI 새로고침
|
||||
const containerEl = document.querySelector('.provider-settings-container');
|
||||
if (containerEl instanceof HTMLElement) {
|
||||
this.render(containerEl);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider 가시성 업데이트
|
||||
*/
|
||||
private updateProviderVisibility(_containerEl: HTMLElement): void {
|
||||
// APIKeyManager에게 가시성 업데이트 요청
|
||||
this.apiKeyManager.updateVisibility(this.currentProvider);
|
||||
}
|
||||
|
||||
// === Save Methods ===
|
||||
|
||||
private async saveProviderSelection(provider: TranscriptionProvider | 'auto'): Promise<void> {
|
||||
this.plugin.settings.provider = provider;
|
||||
await this.plugin.saveSettings();
|
||||
}
|
||||
|
||||
private async saveStrategy(strategy: SelectionStrategy): Promise<void> {
|
||||
this.plugin.settings.selectionStrategy = strategy;
|
||||
await this.plugin.saveSettings();
|
||||
}
|
||||
|
||||
private loadSettings(): void {
|
||||
this.currentProvider = this.plugin.settings.provider || 'auto';
|
||||
}
|
||||
|
||||
private isProviderSelection(value: string): value is TranscriptionProvider | 'auto' {
|
||||
return value === 'auto' || value === 'whisper' || value === 'deepgram';
|
||||
}
|
||||
|
||||
private isSelectionStrategy(value: string): value is SelectionStrategy {
|
||||
return (
|
||||
value === (SelectionStrategy.MANUAL as string) ||
|
||||
value === (SelectionStrategy.COST_OPTIMIZED as string) ||
|
||||
value === (SelectionStrategy.PERFORMANCE_OPTIMIZED as string) ||
|
||||
value === (SelectionStrategy.QUALITY_OPTIMIZED as string) ||
|
||||
value === (SelectionStrategy.ROUND_ROBIN as string) ||
|
||||
value === (SelectionStrategy.AB_TEST as string)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 정리
|
||||
*/
|
||||
public destroy(): void {
|
||||
if (this.statusUpdateInterval) {
|
||||
window.clearInterval(this.statusUpdateInterval);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider 상세 정보 모달
|
||||
*/
|
||||
class ProviderDetailsModal extends Modal {
|
||||
constructor(
|
||||
app: App,
|
||||
private provider: TranscriptionProvider,
|
||||
private plugin: SpeechToTextPlugin
|
||||
) {
|
||||
super(app);
|
||||
}
|
||||
|
||||
onOpen(): void {
|
||||
const { contentEl, titleEl } = this;
|
||||
|
||||
titleEl.setText(`${this.getProviderName()} details`);
|
||||
|
||||
// 상태 정보
|
||||
const statusEl = contentEl.createDiv({ cls: 'provider-details-status' });
|
||||
this.renderStatus(statusEl);
|
||||
|
||||
// 통계
|
||||
const statsEl = contentEl.createDiv({ cls: 'provider-details-stats' });
|
||||
this.renderStatistics(statsEl);
|
||||
|
||||
// 설정
|
||||
const configEl = contentEl.createDiv({ cls: 'provider-details-config' });
|
||||
this.renderConfiguration(configEl);
|
||||
|
||||
// 액션 버튼
|
||||
const actionsEl = contentEl.createDiv({ cls: 'modal-button-container' });
|
||||
|
||||
new ButtonComponent(actionsEl)
|
||||
.setButtonText('Test connection')
|
||||
.setCta()
|
||||
.onClick(async () => {
|
||||
await this.testConnection();
|
||||
});
|
||||
|
||||
new ButtonComponent(actionsEl).setButtonText('Close').onClick(() => this.close());
|
||||
}
|
||||
|
||||
private getProviderName(): string {
|
||||
return this.provider === 'whisper' ? 'OpenAI whisper' : 'Deepgram';
|
||||
}
|
||||
|
||||
private renderStatus(containerEl: HTMLElement): void {
|
||||
containerEl.createEl('h4', { text: 'Status' });
|
||||
|
||||
// TODO: 실제 상태 가져오기
|
||||
const statusItems = [
|
||||
{ label: 'Connection', value: '✅ Connected', cls: 'status-good' },
|
||||
{ label: 'API key', value: '🔑 Configured', cls: 'status-good' },
|
||||
{ label: 'Last used', value: '5 minutes ago', cls: 'status-info' },
|
||||
{ label: 'Health', value: '100%', cls: 'status-good' },
|
||||
];
|
||||
|
||||
const gridEl = containerEl.createDiv({ cls: 'status-grid' });
|
||||
statusItems.forEach((item) => {
|
||||
const itemEl = gridEl.createDiv({ cls: `status-item ${item.cls}` });
|
||||
itemEl.createEl('span', { text: item.label, cls: 'status-label' });
|
||||
itemEl.createEl('span', { text: item.value, cls: 'status-value' });
|
||||
});
|
||||
}
|
||||
|
||||
private renderStatistics(containerEl: HTMLElement): void {
|
||||
containerEl.createEl('h4', { text: 'Statistics (last 30 days)' });
|
||||
|
||||
// TODO: 실제 통계 가져오기
|
||||
const stats = [
|
||||
{ label: 'Total requests', value: '1,234' },
|
||||
{ label: 'Success rate', value: '99.5%' },
|
||||
{ label: 'Avg. latency', value: '1.2s' },
|
||||
{ label: 'Total cost', value: '$12.34' },
|
||||
];
|
||||
|
||||
const gridEl = containerEl.createDiv({ cls: 'stats-grid' });
|
||||
stats.forEach((stat) => {
|
||||
const statEl = gridEl.createDiv({ cls: 'stat-item' });
|
||||
statEl.createEl('div', { text: stat.value, cls: 'stat-value' });
|
||||
statEl.createEl('div', { text: stat.label, cls: 'stat-label' });
|
||||
});
|
||||
}
|
||||
|
||||
private renderConfiguration(containerEl: HTMLElement): void {
|
||||
containerEl.createEl('h4', { text: 'Configuration' });
|
||||
|
||||
const configEl = containerEl.createEl('pre', { cls: 'config-display' });
|
||||
|
||||
const config = {
|
||||
provider: this.provider,
|
||||
model: this.provider === 'whisper' ? 'whisper-1' : 'nova-2',
|
||||
language: this.plugin.settings.language || 'auto',
|
||||
maxRetries: 3,
|
||||
timeout: 30000,
|
||||
};
|
||||
|
||||
configEl.textContent = JSON.stringify(config, null, 2);
|
||||
}
|
||||
|
||||
private async testConnection(): Promise<void> {
|
||||
const notice = new Notice(`Testing ${this.getProviderName()} connection...`, 0);
|
||||
|
||||
try {
|
||||
// TODO: 실제 연결 테스트
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
notice.hide();
|
||||
new Notice('✅ connection test successful.', 3000);
|
||||
} catch {
|
||||
notice.hide();
|
||||
new Notice('❌ connection test failed.', 3000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider 메트릭 표시 컴포넌트
|
||||
*/
|
||||
class ProviderMetricsDisplay {
|
||||
constructor(private plugin: SpeechToTextPlugin) {}
|
||||
|
||||
render(containerEl: HTMLElement): void {
|
||||
new Setting(containerEl).setName('📊 performance metrics').setHeading();
|
||||
|
||||
// TODO: 실제 메트릭 구현
|
||||
const metricsEl = containerEl.createDiv({ cls: 'metrics-display' });
|
||||
const placeholder = metricsEl.createDiv('metrics-placeholder');
|
||||
placeholder.createEl('p', {
|
||||
text: 'Metrics will be displayed here once transcription starts.',
|
||||
});
|
||||
const list = placeholder.createEl('ul');
|
||||
['Request latency', 'Success rate', 'Cost tracking', 'Quality scores'].forEach((item) => {
|
||||
list.createEl('li', { text: item });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,851 +0,0 @@
|
|||
import { App, Modal, ButtonComponent } from 'obsidian';
|
||||
import type SpeechToTextPlugin from '../../../main';
|
||||
import {
|
||||
TranscriptionProvider,
|
||||
SelectionStrategy,
|
||||
} from '../../../infrastructure/api/providers/ITranscriber';
|
||||
import { APIKeyManager } from './components/APIKeyManager';
|
||||
import { AdvancedSettingsPanel } from './components/AdvancedSettingsPanel';
|
||||
import { BaseSettingsComponent, SettingsState } from '../base/BaseSettingsComponent';
|
||||
import { UIComponentFactory } from '../base/CommonUIComponents';
|
||||
|
||||
/**
|
||||
* Provider 설정 상태 타입
|
||||
*/
|
||||
interface ProviderSettingsState {
|
||||
currentProvider: TranscriptionProvider | 'auto';
|
||||
isExpanded: boolean;
|
||||
connectionStatus: Map<TranscriptionProvider, boolean>;
|
||||
lastValidation: Map<TranscriptionProvider, Date>;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface ProviderMetrics {
|
||||
totalRequests: number;
|
||||
successRate: number;
|
||||
avgLatency: number;
|
||||
totalCost: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider Settings Container (리팩토링 버전)
|
||||
*
|
||||
* 개선사항:
|
||||
* - 상태 관리 중앙화 (SettingsState 사용)
|
||||
* - 메모이제이션을 통한 불필요한 re-render 방지
|
||||
* - 컴포넌트 분리로 관심사 분리
|
||||
* - 에러 바운더리 추가
|
||||
* - 접근성 개선
|
||||
*/
|
||||
export class ProviderSettingsContainerRefactored extends BaseSettingsComponent {
|
||||
private apiKeyManager: APIKeyManager;
|
||||
private advancedPanel: AdvancedSettingsPanel;
|
||||
private state: SettingsState<ProviderSettingsState>;
|
||||
|
||||
// 메모이제이션을 위한 캐시
|
||||
private memoCache = new Map<string, unknown>();
|
||||
|
||||
// 실시간 업데이트 간격
|
||||
private statusUpdateInterval?: number;
|
||||
|
||||
// 디바운스 타이머
|
||||
private debounceTimers = new Map<string, number>();
|
||||
|
||||
constructor(app: App, plugin: SpeechToTextPlugin) {
|
||||
super(plugin, app);
|
||||
|
||||
// 상태 초기화
|
||||
this.state = new SettingsState<ProviderSettingsState>({
|
||||
currentProvider: plugin.settings.provider || 'auto',
|
||||
isExpanded: false,
|
||||
connectionStatus: new Map(),
|
||||
lastValidation: new Map(),
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
// 컴포넌트 초기화
|
||||
this.apiKeyManager = new APIKeyManager(plugin);
|
||||
this.advancedPanel = new AdvancedSettingsPanel(plugin);
|
||||
|
||||
// Fire-and-forget initialization
|
||||
void this.initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* 초기화 (비동기 작업 처리)
|
||||
*/
|
||||
private initialize(): void {
|
||||
void this.withErrorHandling(async () => {
|
||||
this.state.set((prev) => ({ ...prev, isLoading: true }));
|
||||
|
||||
// 설정 로드
|
||||
this.loadSettings();
|
||||
|
||||
// 연결 상태 확인 (병렬 처리, fire-and-forget)
|
||||
void this.checkAllConnectionsOptimized();
|
||||
|
||||
// 실시간 모니터링 시작
|
||||
this.startStatusMonitoring();
|
||||
|
||||
this.state.set((prev) => ({ ...prev, isLoading: false }));
|
||||
return Promise.resolve();
|
||||
}, 'Provider 초기화 실패');
|
||||
}
|
||||
|
||||
/**
|
||||
* 렌더링 구현
|
||||
*/
|
||||
protected doRender(containerEl: HTMLElement): void {
|
||||
containerEl.addClass('provider-settings-container');
|
||||
|
||||
const currentState = this.state.get();
|
||||
|
||||
// 로딩 상태
|
||||
if (currentState.isLoading) {
|
||||
UIComponentFactory.createLoadingSpinner(containerEl, 'Provider 설정 로드 중...');
|
||||
return;
|
||||
}
|
||||
|
||||
// 에러 상태
|
||||
if (currentState.error) {
|
||||
UIComponentFactory.showErrorMessage(
|
||||
containerEl,
|
||||
'설정 로드 실패',
|
||||
currentState.error,
|
||||
() => this.initialize()
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 탭 UI로 구성 (성능 개선)
|
||||
this.renderTabUI(containerEl);
|
||||
}
|
||||
|
||||
/**
|
||||
* 탭 UI 렌더링 (개선된 구조)
|
||||
*/
|
||||
private renderTabUI(containerEl: HTMLElement): void {
|
||||
const tabs = [
|
||||
{
|
||||
id: 'overview',
|
||||
label: '개요',
|
||||
content: () => this.createOverviewTab(),
|
||||
},
|
||||
{
|
||||
id: 'providers',
|
||||
label: 'Provider 설정',
|
||||
content: () => this.createProvidersTab(),
|
||||
},
|
||||
{
|
||||
id: 'advanced',
|
||||
label: '고급 설정',
|
||||
content: () => this.createAdvancedTab(),
|
||||
},
|
||||
{
|
||||
id: 'metrics',
|
||||
label: '메트릭',
|
||||
content: () => this.createMetricsTab(),
|
||||
},
|
||||
];
|
||||
|
||||
UIComponentFactory.createTabs(containerEl, tabs, 'overview');
|
||||
}
|
||||
|
||||
/**
|
||||
* 개요 탭 생성
|
||||
*/
|
||||
private createOverviewTab(): HTMLElement {
|
||||
const container = createEl('div', { cls: 'overview-tab' });
|
||||
|
||||
// 전체 상태 대시보드
|
||||
this.renderStatusDashboard(container);
|
||||
|
||||
// 빠른 설정
|
||||
this.renderQuickSettings(container);
|
||||
|
||||
// 액션 버튼
|
||||
this.renderQuickActions(container);
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider 탭 생성
|
||||
*/
|
||||
private createProvidersTab(): HTMLElement {
|
||||
const container = createEl('div', { cls: 'providers-tab' });
|
||||
|
||||
// Provider 선택
|
||||
this.renderProviderSelection(container);
|
||||
|
||||
// API 키 관리
|
||||
const apiKeySection = container.createDiv({ cls: 'api-key-section' });
|
||||
this.apiKeyManager.render(apiKeySection, this.state.get().currentProvider);
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
/**
|
||||
* 고급 설정 탭 생성
|
||||
*/
|
||||
private createAdvancedTab(): HTMLElement {
|
||||
const container = createEl('div', { cls: 'advanced-tab' });
|
||||
|
||||
this.advancedPanel.render(container);
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
/**
|
||||
* 메트릭 탭 생성
|
||||
*/
|
||||
private createMetricsTab(): HTMLElement {
|
||||
const container = createEl('div', { cls: 'metrics-tab' });
|
||||
|
||||
this.renderMetricsDisplay(container);
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태 대시보드 렌더링 (최적화)
|
||||
*/
|
||||
private renderStatusDashboard(containerEl: HTMLElement): void {
|
||||
const dashboardEl = this.createSection(
|
||||
containerEl,
|
||||
'Status dashboard',
|
||||
'System overall status'
|
||||
);
|
||||
|
||||
// 메모이제이션된 상태 가져오기
|
||||
const overallStatus = this.memoized('overallStatus', () => this.calculateOverallStatus());
|
||||
|
||||
// 전체 상태 표시
|
||||
UIComponentFactory.createStatusIndicator(
|
||||
dashboardEl,
|
||||
overallStatus.level,
|
||||
overallStatus.text,
|
||||
overallStatus.icon
|
||||
);
|
||||
|
||||
// Provider별 상태 그리드
|
||||
const gridEl = dashboardEl.createDiv({ cls: 'providers-status-grid' });
|
||||
this.renderProviderStatusCards(gridEl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider 상태 카드 렌더링
|
||||
*/
|
||||
private renderProviderStatusCards(containerEl: HTMLElement): void {
|
||||
const providers: TranscriptionProvider[] = ['whisper', 'deepgram'];
|
||||
const state = this.state.get();
|
||||
|
||||
providers.forEach((provider) => {
|
||||
const hasKey = this.hasApiKey(provider);
|
||||
const isConnected = state.connectionStatus.get(provider) || false;
|
||||
// const lastValidation = state.lastValidation.get(provider); // Unused variable
|
||||
|
||||
UIComponentFactory.createCard(
|
||||
containerEl,
|
||||
this.getProviderDisplayName(provider),
|
||||
this.getProviderStatusText(provider, hasKey, isConnected),
|
||||
[
|
||||
{
|
||||
text: 'Details',
|
||||
onClick: () => {
|
||||
// Fire-and-forget 상세 표시
|
||||
void this.showProviderDetails(provider);
|
||||
},
|
||||
type: 'secondary',
|
||||
},
|
||||
{
|
||||
text: 'Test',
|
||||
onClick: () => {
|
||||
// Fire-and-forget 테스트 실행
|
||||
void this.testProvider(provider);
|
||||
},
|
||||
type: 'primary',
|
||||
},
|
||||
]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 빠른 설정 렌더링
|
||||
*/
|
||||
private renderQuickSettings(containerEl: HTMLElement): void {
|
||||
const section = this.createSection(containerEl, 'Quick settings');
|
||||
|
||||
// Provider 모드
|
||||
this.createSetting(
|
||||
section,
|
||||
'Provider mode',
|
||||
'Select how to choose the transcription provider'
|
||||
).addDropdown((dropdown) => {
|
||||
dropdown
|
||||
.addOption('auto', '🤖 automatic')
|
||||
.addOption('whisper', '🎯 openai whisper')
|
||||
.addOption('deepgram', '🚀 deepgram')
|
||||
.setValue(this.state.get().currentProvider)
|
||||
.onChange((value) => {
|
||||
void this.handleProviderChange(value);
|
||||
});
|
||||
});
|
||||
|
||||
// 자동 모드 전략
|
||||
if (this.state.get().currentProvider === 'auto') {
|
||||
this.createSetting(section, '선택 전략', 'Provider 선택 방법').addDropdown(
|
||||
(dropdown) => {
|
||||
dropdown
|
||||
.addOption(SelectionStrategy.PERFORMANCE_OPTIMIZED, '⚡ performance first')
|
||||
.addOption(SelectionStrategy.COST_OPTIMIZED, '💰 cost optimized')
|
||||
.addOption(SelectionStrategy.QUALITY_OPTIMIZED, '✨ quality first')
|
||||
.setValue(
|
||||
this.plugin.settings.selectionStrategy ||
|
||||
SelectionStrategy.PERFORMANCE_OPTIMIZED
|
||||
)
|
||||
.onChange((value) => {
|
||||
void this.handleStrategyChange(value);
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider 선택 렌더링 (개선)
|
||||
*/
|
||||
private renderProviderSelection(containerEl: HTMLElement): void {
|
||||
const section = this.createSection(
|
||||
containerEl,
|
||||
'Provider selection',
|
||||
'Transcription provider configuration'
|
||||
);
|
||||
|
||||
// Collapsible 섹션으로 구성
|
||||
UIComponentFactory.createCollapsibleSection(
|
||||
section,
|
||||
'Provider 설정',
|
||||
this.state.get().isExpanded,
|
||||
(expanded) => this.state.set((prev) => ({ ...prev, isExpanded: expanded }))
|
||||
);
|
||||
|
||||
// Provider 선택 UI
|
||||
this.renderProviderSelectionContent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider 선택 내용
|
||||
*/
|
||||
private renderProviderSelectionContent(): void {
|
||||
// 여기에 기존 Provider 선택 UI 로직
|
||||
// 코드 간결성을 위해 생략
|
||||
}
|
||||
|
||||
/**
|
||||
* 빠른 액션 렌더링
|
||||
*/
|
||||
private renderQuickActions(containerEl: HTMLElement): void {
|
||||
const actionsEl = containerEl.createDiv({ cls: 'quick-actions' });
|
||||
|
||||
const actions = [
|
||||
{ text: 'Verify all keys', onClick: () => void this.verifyAllApiKeys(), primary: true },
|
||||
{ text: 'Test connection', onClick: () => void this.testAllConnections() },
|
||||
{ text: 'Export config', onClick: () => void this.exportConfiguration() },
|
||||
{
|
||||
text: 'Reset settings',
|
||||
onClick: () => void this.resetProviderSettings(),
|
||||
danger: true,
|
||||
},
|
||||
];
|
||||
|
||||
actions.forEach((action) => {
|
||||
const btn = new ButtonComponent(actionsEl)
|
||||
.setButtonText(action.text)
|
||||
.onClick(action.onClick);
|
||||
|
||||
if (action.primary) btn.setCta();
|
||||
if (action.danger) btn.setWarning();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 메트릭 표시
|
||||
*/
|
||||
private renderMetricsDisplay(containerEl: HTMLElement): void {
|
||||
this.createSection(containerEl, '성능 메트릭', '최근 30일간 통계');
|
||||
|
||||
// 메트릭 데이터 (예시)
|
||||
this.memoized('metrics', () => this.calculateMetrics());
|
||||
|
||||
// 차트나 그래프로 표시
|
||||
this.renderMetricsCharts();
|
||||
}
|
||||
|
||||
/**
|
||||
* 메트릭 차트 렌더링
|
||||
*/
|
||||
private renderMetricsCharts(): void {
|
||||
// 여기에 차트 렌더링 로직
|
||||
// 실제 구현은 차트 라이브러리 사용
|
||||
}
|
||||
|
||||
// === 헬퍼 메서드 (최적화) ===
|
||||
|
||||
/**
|
||||
* 메모이제이션 헬퍼
|
||||
*/
|
||||
private memoized<T>(key: string, compute: () => T): T {
|
||||
if (!this.memoCache.has(key)) {
|
||||
this.memoCache.set(key, compute());
|
||||
}
|
||||
return this.memoCache.get(key) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* 디바운스 헬퍼
|
||||
*/
|
||||
private debounce(key: string, fn: () => void | Promise<void>, delay = 300): void {
|
||||
const existing = this.debounceTimers.get(key);
|
||||
if (existing) {
|
||||
window.clearTimeout(existing);
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
void fn();
|
||||
this.debounceTimers.delete(key);
|
||||
}, delay);
|
||||
|
||||
this.debounceTimers.set(key, timer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider 변경 처리 (디바운스 적용)
|
||||
*/
|
||||
private handleProviderChange(value: string): void {
|
||||
this.debounce('provider-change', async () => {
|
||||
if (!this.isProviderSelection(value)) {
|
||||
return;
|
||||
}
|
||||
this.state.set((prev) => ({
|
||||
...prev,
|
||||
currentProvider: value,
|
||||
}));
|
||||
|
||||
this.plugin.settings.provider = value;
|
||||
await this.saveSettings();
|
||||
|
||||
// 캐시 무효화
|
||||
this.memoCache.clear();
|
||||
|
||||
// UI 업데이트
|
||||
if (this.containerEl) {
|
||||
this.render(this.containerEl);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 전략 변경 처리
|
||||
*/
|
||||
private handleStrategyChange(strategy: string): void {
|
||||
this.debounce('strategy-change', async () => {
|
||||
if (this.isSelectionStrategy(strategy)) {
|
||||
this.plugin.settings.selectionStrategy = strategy;
|
||||
await this.saveSettings();
|
||||
this.showNotice(`전략이 ${strategy}로 변경되었습니다`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 상태 계산
|
||||
*/
|
||||
private calculateOverallStatus(): {
|
||||
level: 'success' | 'warning' | 'error';
|
||||
icon: string;
|
||||
text: string;
|
||||
} {
|
||||
const state = this.state.get();
|
||||
const hasAnyKey = this.hasApiKey('whisper') || this.hasApiKey('deepgram');
|
||||
const hasAnyConnection = Array.from(state.connectionStatus.values()).some((v) => v);
|
||||
|
||||
if (hasAnyConnection) {
|
||||
return { level: 'success', icon: '✅', text: '정상 작동' };
|
||||
} else if (hasAnyKey) {
|
||||
return { level: 'warning', icon: '⚠️', text: '키 구성됨, 연결 안됨' };
|
||||
} else {
|
||||
return { level: 'error', icon: '❌', text: 'Provider 미구성' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 최적화된 연결 확인 (병렬 처리)
|
||||
*/
|
||||
private async checkAllConnectionsOptimized(): Promise<void> {
|
||||
const providers: TranscriptionProvider[] = ['whisper', 'deepgram'];
|
||||
|
||||
const connectionPromises = providers.map((provider) => {
|
||||
if (this.hasApiKey(provider)) {
|
||||
const isConnected = this.checkProviderConnection(provider);
|
||||
return Promise.resolve({ provider, isConnected });
|
||||
}
|
||||
return Promise.resolve({ provider, isConnected: false });
|
||||
});
|
||||
|
||||
const results = await Promise.all(connectionPromises);
|
||||
|
||||
this.state.set((prev) => {
|
||||
const newStatus = new Map(prev.connectionStatus);
|
||||
const newValidation = new Map(prev.lastValidation);
|
||||
|
||||
results.forEach(({ provider, isConnected }) => {
|
||||
newStatus.set(provider, isConnected);
|
||||
if (isConnected) {
|
||||
newValidation.set(provider, new Date());
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
...prev,
|
||||
connectionStatus: newStatus,
|
||||
lastValidation: newValidation,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider 연결 확인
|
||||
*/
|
||||
private checkProviderConnection(_provider: TranscriptionProvider): boolean {
|
||||
// 실제 연결 테스트 로직
|
||||
return true; // 임시
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태 모니터링
|
||||
*/
|
||||
private startStatusMonitoring(): void {
|
||||
// 기존 interval 정리
|
||||
this.stopStatusMonitoring();
|
||||
|
||||
// 5분마다 상태 업데이트
|
||||
this.statusUpdateInterval = window.setInterval(() => {
|
||||
void this.checkAllConnectionsOptimized();
|
||||
}, 5 * 60 * 1000);
|
||||
|
||||
this.disposables.push(() => this.stopStatusMonitoring());
|
||||
}
|
||||
|
||||
/**
|
||||
* 모니터링 중지
|
||||
*/
|
||||
private stopStatusMonitoring(): void {
|
||||
if (this.statusUpdateInterval) {
|
||||
window.clearInterval(this.statusUpdateInterval);
|
||||
this.statusUpdateInterval = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API 키 존재 확인
|
||||
*/
|
||||
private hasApiKey(provider: TranscriptionProvider): boolean {
|
||||
if (provider === 'whisper') {
|
||||
return !!this.plugin.settings.apiKey || !!this.plugin.settings.whisperApiKey;
|
||||
} else if (provider === 'deepgram') {
|
||||
return !!this.plugin.settings.deepgramApiKey;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider 표시 이름
|
||||
*/
|
||||
private getProviderDisplayName(provider: string): string {
|
||||
const names: Record<string, string> = {
|
||||
whisper: 'OpenAI whisper',
|
||||
deepgram: 'Deepgram',
|
||||
auto: '자동',
|
||||
};
|
||||
return names[provider] || provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider 상태 텍스트
|
||||
*/
|
||||
private getProviderStatusText(
|
||||
_provider: TranscriptionProvider,
|
||||
hasKey: boolean,
|
||||
isConnected: boolean
|
||||
): string {
|
||||
if (isConnected) return '✅ 연결됨';
|
||||
if (hasKey) return '🔑 키 구성됨';
|
||||
return '❌ 미구성';
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider 상세 정보 표시
|
||||
*/
|
||||
private showProviderDetails(provider: TranscriptionProvider): void {
|
||||
if (!this.app) return;
|
||||
// Modal로 상세 정보 표시
|
||||
const modal = new ProviderDetailsModal(this.app, provider, this.plugin);
|
||||
modal.open();
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider 테스트
|
||||
*/
|
||||
private testProvider(provider: TranscriptionProvider): void {
|
||||
void this.withErrorHandling(async () => {
|
||||
this.showNotice(`${this.getProviderDisplayName(provider)} 테스트 중...`);
|
||||
await Promise.resolve(); // Ensure await
|
||||
|
||||
// 테스트 로직
|
||||
const success = this.checkProviderConnection(provider);
|
||||
|
||||
if (success) {
|
||||
this.showNotice(`✅ ${this.getProviderDisplayName(provider)} 테스트 성공`);
|
||||
} else {
|
||||
this.showNotice(`❌ ${this.getProviderDisplayName(provider)} 테스트 실패`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 API 키 검증
|
||||
*/
|
||||
private async verifyAllApiKeys(): Promise<void> {
|
||||
await this.withErrorHandling(async () => {
|
||||
const results = await this.apiKeyManager.verifyAllKeys();
|
||||
|
||||
let message = '검증 결과:\n';
|
||||
for (const [provider, result] of results) {
|
||||
message += `${this.getProviderDisplayName(provider)}: ${result ? '✅' : '❌'}\n`;
|
||||
}
|
||||
|
||||
this.showNotice(message);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 연결 테스트
|
||||
*/
|
||||
private async testAllConnections(): Promise<void> {
|
||||
await this.checkAllConnectionsOptimized();
|
||||
this.showNotice('연결 테스트 완료');
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정 내보내기
|
||||
*/
|
||||
private async exportConfiguration(): Promise<void> {
|
||||
await this.withErrorHandling(() => {
|
||||
const config = {
|
||||
provider: this.state.get().currentProvider,
|
||||
strategy: this.plugin.settings.selectionStrategy,
|
||||
settings: {
|
||||
...this.plugin.settings,
|
||||
// API 키 제외 (보안)
|
||||
apiKey: undefined,
|
||||
deepgramApiKey: undefined,
|
||||
whisperApiKey: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
const blob = new Blob([JSON.stringify(config, null, 2)], {
|
||||
type: 'application/json',
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const a = createEl('a');
|
||||
a.href = url;
|
||||
a.download = `provider-config-${Date.now()}.json`;
|
||||
a.click();
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
this.showNotice('설정을 내보냈습니다');
|
||||
return Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider 설정 초기화
|
||||
*/
|
||||
private async resetProviderSettings(): Promise<void> {
|
||||
const confirmed = await UIComponentFactory.showConfirmDialog(
|
||||
'Provider 설정 초기화',
|
||||
'Provider 설정을 기본값으로 초기화하시겠습니까? API 키는 보존됩니다.',
|
||||
'초기화',
|
||||
'취소'
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
await this.withErrorHandling(async () => {
|
||||
// API 키 보존
|
||||
const apiKey = this.plugin.settings.apiKey;
|
||||
const whisperKey = this.plugin.settings.whisperApiKey;
|
||||
const deepgramKey = this.plugin.settings.deepgramApiKey;
|
||||
|
||||
// 기본값으로 초기화
|
||||
this.plugin.settings.provider = 'auto';
|
||||
this.plugin.settings.selectionStrategy = SelectionStrategy.PERFORMANCE_OPTIMIZED;
|
||||
|
||||
// API 키 복원
|
||||
this.plugin.settings.apiKey = apiKey;
|
||||
this.plugin.settings.whisperApiKey = whisperKey;
|
||||
this.plugin.settings.deepgramApiKey = deepgramKey;
|
||||
|
||||
await this.saveSettings();
|
||||
|
||||
// 상태 초기화
|
||||
this.state.set({
|
||||
currentProvider: 'auto',
|
||||
isExpanded: false,
|
||||
connectionStatus: new Map(),
|
||||
lastValidation: new Map(),
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
// 캐시 초기화
|
||||
this.memoCache.clear();
|
||||
|
||||
// UI 새로고침
|
||||
if (this.containerEl) {
|
||||
this.render(this.containerEl);
|
||||
}
|
||||
|
||||
this.showNotice('Provider 설정이 초기화되었습니다');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 메트릭 계산
|
||||
*/
|
||||
private calculateMetrics(): ProviderMetrics {
|
||||
// 실제 메트릭 계산 로직
|
||||
return {
|
||||
totalRequests: 0,
|
||||
successRate: 100,
|
||||
avgLatency: 0,
|
||||
totalCost: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정 로드
|
||||
*/
|
||||
private loadSettings(): void {
|
||||
this.state.set((prev) => ({
|
||||
...prev,
|
||||
currentProvider: this.plugin.settings.provider || 'auto',
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 정리
|
||||
*/
|
||||
public destroy(): void {
|
||||
super.destroy();
|
||||
|
||||
// 타이머 정리
|
||||
this.stopStatusMonitoring();
|
||||
this.debounceTimers.forEach((timer) => window.clearTimeout(timer));
|
||||
this.debounceTimers.clear();
|
||||
|
||||
// 상태 정리
|
||||
this.state.destroy();
|
||||
|
||||
// 캐시 정리
|
||||
this.memoCache.clear();
|
||||
|
||||
// 하위 컴포넌트 정리
|
||||
this.apiKeyManager.destroy();
|
||||
this.advancedPanel.destroy();
|
||||
}
|
||||
|
||||
private isProviderSelection(value: string): value is TranscriptionProvider | 'auto' {
|
||||
return value === 'auto' || value === 'whisper' || value === 'deepgram';
|
||||
}
|
||||
|
||||
private isSelectionStrategy(value: string): value is SelectionStrategy {
|
||||
const strategies: string[] = [
|
||||
SelectionStrategy.MANUAL,
|
||||
SelectionStrategy.COST_OPTIMIZED,
|
||||
SelectionStrategy.PERFORMANCE_OPTIMIZED,
|
||||
SelectionStrategy.QUALITY_OPTIMIZED,
|
||||
SelectionStrategy.ROUND_ROBIN,
|
||||
SelectionStrategy.AB_TEST,
|
||||
];
|
||||
return strategies.includes(value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider 상세 정보 모달 (개선)
|
||||
*/
|
||||
class ProviderDetailsModal extends Modal {
|
||||
constructor(
|
||||
app: App,
|
||||
private provider: TranscriptionProvider,
|
||||
private plugin: SpeechToTextPlugin
|
||||
) {
|
||||
super(app);
|
||||
}
|
||||
|
||||
onOpen(): void {
|
||||
const { titleEl } = this;
|
||||
const contentEl = this.contentEl;
|
||||
|
||||
titleEl.setText(`${this.getProviderName()} 상세 정보`);
|
||||
|
||||
// 탭으로 구성
|
||||
const tabs = [
|
||||
{
|
||||
id: 'status',
|
||||
label: '상태',
|
||||
content: () => this.createStatusContent(),
|
||||
},
|
||||
{
|
||||
id: 'stats',
|
||||
label: '통계',
|
||||
content: () => this.createStatsContent(),
|
||||
},
|
||||
{
|
||||
id: 'config',
|
||||
label: '설정',
|
||||
content: () => this.createConfigContent(),
|
||||
},
|
||||
];
|
||||
|
||||
UIComponentFactory.createTabs(contentEl, tabs);
|
||||
}
|
||||
|
||||
private getProviderName(): string {
|
||||
return this.provider === 'whisper' ? 'OpenAI whisper' : 'Deepgram';
|
||||
}
|
||||
|
||||
private createStatusContent(): HTMLElement {
|
||||
const container = createEl('div');
|
||||
// 상태 정보 렌더링
|
||||
return container;
|
||||
}
|
||||
|
||||
private createStatsContent(): HTMLElement {
|
||||
const container = createEl('div');
|
||||
// 통계 정보 렌더링
|
||||
return container;
|
||||
}
|
||||
|
||||
private createConfigContent(): HTMLElement {
|
||||
const container = createEl('div');
|
||||
// 설정 정보 렌더링
|
||||
return container;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,951 +0,0 @@
|
|||
import { Setting, Notice, ButtonComponent, Modal, requestUrl } from 'obsidian';
|
||||
import type SpeechToTextPlugin from '../../../../main';
|
||||
import { TranscriptionProvider } from '../../../../infrastructure/api/providers/ITranscriber';
|
||||
import { Encryptor, isEncryptedData } from '../../../../infrastructure/security/Encryptor';
|
||||
|
||||
/**
|
||||
* API Key Manager Component
|
||||
*
|
||||
* API 키의 안전한 입력, 저장, 검증을 담당합니다.
|
||||
*
|
||||
* Security Features:
|
||||
* - 키 마스킹 및 가시성 토글
|
||||
* - 실시간 형식 검증
|
||||
* - 암호화된 저장
|
||||
* - 안전한 가져오기/내보내기
|
||||
*
|
||||
* @security 모든 키는 암호화되어 저장
|
||||
* @reliability 99.9% - 안정적인 키 관리
|
||||
*/
|
||||
export class APIKeyManager {
|
||||
private encryptor: Encryptor;
|
||||
private apiKeys: Map<TranscriptionProvider, string> = new Map();
|
||||
private keyVisibility: Map<TranscriptionProvider, boolean> = new Map();
|
||||
private validationStatus: Map<TranscriptionProvider, ValidationStatus> = new Map();
|
||||
|
||||
// 검증 캐시 (5분 유효)
|
||||
private validationCache: Map<string, { valid: boolean; timestamp: number }> = new Map();
|
||||
private readonly CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
constructor(private plugin: SpeechToTextPlugin) {
|
||||
this.encryptor = new Encryptor();
|
||||
void this.loadApiKeys();
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트 렌더링
|
||||
*/
|
||||
public render(containerEl: HTMLElement, currentProvider: TranscriptionProvider | 'auto'): void {
|
||||
containerEl.empty();
|
||||
containerEl.addClass('api-key-manager');
|
||||
|
||||
// 헤더
|
||||
this.renderHeader(containerEl);
|
||||
|
||||
// Provider별 API 키 입력
|
||||
this.renderApiKeyInputs(containerEl, currentProvider);
|
||||
|
||||
// 보안 정보
|
||||
this.renderSecurityInfo(containerEl);
|
||||
}
|
||||
|
||||
/**
|
||||
* 헤더 렌더링
|
||||
*/
|
||||
private renderHeader(containerEl: HTMLElement): void {
|
||||
const headerEl = containerEl.createDiv({ cls: 'api-key-header' });
|
||||
|
||||
headerEl.createEl('h4', {
|
||||
text: '🔐 API key management',
|
||||
cls: 'api-key-title',
|
||||
});
|
||||
|
||||
headerEl.createEl('p', {
|
||||
text: 'Securely manage your provider API keys. Keys are encrypted and never exposed in logs.',
|
||||
cls: 'api-key-description',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* API 키 입력 필드들 렌더링
|
||||
*/
|
||||
private renderApiKeyInputs(
|
||||
containerEl: HTMLElement,
|
||||
currentProvider: TranscriptionProvider | 'auto'
|
||||
): void {
|
||||
const inputsEl = containerEl.createDiv({ cls: 'api-key-inputs' });
|
||||
|
||||
// Whisper API Key
|
||||
if (currentProvider === 'auto' || currentProvider === 'whisper') {
|
||||
this.renderSingleKeyInput(
|
||||
inputsEl,
|
||||
'whisper',
|
||||
'OpenAI API key',
|
||||
'Your OpenAI API key for whisper',
|
||||
'sk-...',
|
||||
/^sk-[A-Za-z0-9]{48,}$/
|
||||
);
|
||||
}
|
||||
|
||||
// Deepgram API Key
|
||||
if (currentProvider === 'auto' || currentProvider === 'deepgram') {
|
||||
this.renderSingleKeyInput(
|
||||
inputsEl,
|
||||
'deepgram',
|
||||
'Deepgram API key',
|
||||
'Your Deepgram API key',
|
||||
'Enter your Deepgram key',
|
||||
/^[a-f0-9]{32,}$/
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 API 키 입력 필드
|
||||
*/
|
||||
private renderSingleKeyInput(
|
||||
containerEl: HTMLElement,
|
||||
provider: TranscriptionProvider,
|
||||
title: string,
|
||||
desc: string,
|
||||
placeholder: string,
|
||||
validationRegex: RegExp
|
||||
): void {
|
||||
const keyEl = containerEl.createDiv({ cls: `api-key-input-container ${provider}` });
|
||||
|
||||
// 상태 인디케이터
|
||||
const statusEl = keyEl.createDiv({ cls: 'key-status-indicator' });
|
||||
this.updateStatusIndicator(statusEl, provider);
|
||||
|
||||
const setting = new Setting(keyEl).setName(title).setDesc(desc);
|
||||
|
||||
// 입력 필드 컨테이너
|
||||
const inputContainer = setting.controlEl.createDiv({ cls: 'key-input-group' });
|
||||
|
||||
// 입력 필드
|
||||
const inputEl = inputContainer.createEl('input', {
|
||||
type: 'password',
|
||||
placeholder: placeholder,
|
||||
cls: 'api-key-input',
|
||||
attr: {
|
||||
'data-provider': provider,
|
||||
autocomplete: 'off',
|
||||
spellcheck: 'false',
|
||||
},
|
||||
});
|
||||
|
||||
// 현재 값 설정
|
||||
const currentKey = this.apiKeys.get(provider);
|
||||
if (currentKey) {
|
||||
inputEl.value = this.maskApiKey(currentKey);
|
||||
inputEl.addClass('has-value');
|
||||
}
|
||||
|
||||
// 가시성 토글 버튼
|
||||
this.createVisibilityToggle(inputContainer, inputEl, provider);
|
||||
|
||||
// 검증 버튼
|
||||
const validateBtn = this.createValidationButton(
|
||||
inputContainer,
|
||||
inputEl,
|
||||
provider,
|
||||
validationRegex
|
||||
);
|
||||
|
||||
// 복사 버튼
|
||||
this.createCopyButton(inputContainer, provider);
|
||||
|
||||
// 삭제 버튼
|
||||
this.createDeleteButton(inputContainer, provider);
|
||||
|
||||
// 입력 이벤트 핸들러
|
||||
this.attachInputHandlers(inputEl, provider, validationRegex, validateBtn);
|
||||
|
||||
// 최근 검증 시간 표시
|
||||
this.renderLastValidation(keyEl, provider);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태 인디케이터 업데이트
|
||||
*/
|
||||
private updateStatusIndicator(statusEl: HTMLElement, provider: TranscriptionProvider): void {
|
||||
statusEl.empty();
|
||||
|
||||
const status = this.validationStatus.get(provider);
|
||||
const hasKey = this.apiKeys.has(provider);
|
||||
|
||||
let icon = '⭕'; // Empty
|
||||
let text = 'No key';
|
||||
let className = 'status-empty';
|
||||
|
||||
if (status) {
|
||||
switch (status.status) {
|
||||
case 'valid':
|
||||
icon = '✅';
|
||||
text = 'Valid';
|
||||
className = 'status-valid';
|
||||
break;
|
||||
case 'invalid':
|
||||
icon = '❌';
|
||||
text = 'Invalid';
|
||||
className = 'status-invalid';
|
||||
break;
|
||||
case 'checking':
|
||||
icon = '🔄';
|
||||
text = 'Checking...';
|
||||
className = 'status-checking';
|
||||
break;
|
||||
case 'error':
|
||||
icon = '⚠️';
|
||||
text = 'Error';
|
||||
className = 'status-error';
|
||||
break;
|
||||
}
|
||||
} else if (hasKey) {
|
||||
icon = '🔑';
|
||||
text = 'Not verified';
|
||||
className = 'status-unverified';
|
||||
}
|
||||
|
||||
statusEl.className = `key-status-indicator ${className}`;
|
||||
statusEl.empty();
|
||||
statusEl.createEl('span', { cls: 'status-icon', text: icon });
|
||||
statusEl.createEl('span', { cls: 'status-text', text });
|
||||
}
|
||||
|
||||
/**
|
||||
* 가시성 토글 버튼 생성
|
||||
*/
|
||||
private createVisibilityToggle(
|
||||
containerEl: HTMLElement,
|
||||
inputEl: HTMLInputElement,
|
||||
provider: TranscriptionProvider
|
||||
): HTMLButtonElement {
|
||||
const btn = containerEl.createEl('button', {
|
||||
cls: 'key-visibility-toggle',
|
||||
attr: {
|
||||
'aria-label': 'Toggle visibility',
|
||||
title: 'Show/hide API key',
|
||||
},
|
||||
});
|
||||
|
||||
const updateIcon = () => {
|
||||
const isVisible = this.keyVisibility.get(provider) || false;
|
||||
const icon = this.createVisibilityIcon(isVisible);
|
||||
btn.replaceChildren(icon);
|
||||
};
|
||||
|
||||
updateIcon();
|
||||
|
||||
btn.onclick = () => {
|
||||
const isVisible = !(this.keyVisibility.get(provider) || false);
|
||||
this.keyVisibility.set(provider, isVisible);
|
||||
|
||||
const actualKey = this.apiKeys.get(provider) || '';
|
||||
|
||||
if (isVisible) {
|
||||
inputEl.type = 'text';
|
||||
inputEl.value = actualKey;
|
||||
} else {
|
||||
inputEl.type = 'password';
|
||||
inputEl.value = actualKey ? this.maskApiKey(actualKey) : '';
|
||||
}
|
||||
|
||||
updateIcon();
|
||||
};
|
||||
|
||||
return btn;
|
||||
}
|
||||
|
||||
private createVisibilityIcon(isVisible: boolean): SVGElement {
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('width', '16');
|
||||
svg.setAttribute('height', '16');
|
||||
svg.setAttribute('viewBox', '0 0 16 16');
|
||||
|
||||
const eyePath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
eyePath.setAttribute(
|
||||
'd',
|
||||
'M8 3C4.5 3 1.5 8 1.5 8s3 5 6.5 5 6.5-5 6.5-5-3-5-6.5-5zm0 8c-1.7 0-3-1.3-3-3s1.3-3 3-3 3 1.3 3 3-1.3 3-3 3z'
|
||||
);
|
||||
svg.appendChild(eyePath);
|
||||
|
||||
if (!isVisible) {
|
||||
const slashPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
slashPath.setAttribute('d', 'M2 2l12 12');
|
||||
slashPath.setAttribute('stroke', 'currentColor');
|
||||
slashPath.setAttribute('stroke-width', '2');
|
||||
slashPath.setAttribute('fill', 'none');
|
||||
slashPath.setAttribute('stroke-linecap', 'round');
|
||||
svg.appendChild(slashPath);
|
||||
}
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증 버튼 생성
|
||||
*/
|
||||
private createValidationButton(
|
||||
containerEl: HTMLElement,
|
||||
inputEl: HTMLInputElement,
|
||||
provider: TranscriptionProvider,
|
||||
validationRegex: RegExp
|
||||
): HTMLButtonElement {
|
||||
const btn = containerEl.createEl('button', {
|
||||
text: 'Verify',
|
||||
cls: 'key-validate-btn mod-cta',
|
||||
attr: {
|
||||
'aria-label': 'Verify API key',
|
||||
title: 'Test API key validity',
|
||||
},
|
||||
});
|
||||
|
||||
btn.onclick = async () => {
|
||||
const value = inputEl.value;
|
||||
|
||||
// 마스킹된 값 체크
|
||||
if (value.includes('*')) {
|
||||
new Notice('Please enter a new API key to verify');
|
||||
return;
|
||||
}
|
||||
|
||||
// 형식 검증
|
||||
if (!validationRegex.test(value)) {
|
||||
new Notice(`Invalid ${this.getProviderName(provider)} key format`);
|
||||
this.updateValidationStatus(provider, 'invalid');
|
||||
return;
|
||||
}
|
||||
|
||||
// API 검증
|
||||
await this.validateApiKey(provider, value, btn);
|
||||
};
|
||||
|
||||
return btn;
|
||||
}
|
||||
|
||||
/**
|
||||
* 복사 버튼 생성
|
||||
*/
|
||||
private createCopyButton(
|
||||
containerEl: HTMLElement,
|
||||
provider: TranscriptionProvider
|
||||
): HTMLButtonElement {
|
||||
const btn = containerEl.createEl('button', {
|
||||
cls: 'key-copy-btn',
|
||||
attr: {
|
||||
'aria-label': 'Copy API key',
|
||||
title: 'Copy to clipboard',
|
||||
},
|
||||
});
|
||||
|
||||
btn.setText('📋');
|
||||
|
||||
btn.onclick = async () => {
|
||||
const key = this.apiKeys.get(provider);
|
||||
if (!key) {
|
||||
new Notice('No API key to copy');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(key);
|
||||
new Notice('API key copied to clipboard');
|
||||
|
||||
// Visual feedback
|
||||
btn.setText('✅');
|
||||
setTimeout(() => {
|
||||
btn.setText('📋');
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
new Notice('Failed to copy API key');
|
||||
console.error('Copy error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return btn;
|
||||
}
|
||||
|
||||
/**
|
||||
* 삭제 버튼 생성
|
||||
*/
|
||||
private createDeleteButton(
|
||||
containerEl: HTMLElement,
|
||||
provider: TranscriptionProvider
|
||||
): HTMLButtonElement {
|
||||
const btn = containerEl.createEl('button', {
|
||||
cls: 'key-delete-btn',
|
||||
attr: {
|
||||
'aria-label': 'Delete API key',
|
||||
title: 'Remove API key',
|
||||
},
|
||||
});
|
||||
|
||||
btn.setText('🗑️');
|
||||
|
||||
btn.onclick = async () => {
|
||||
if (await this.confirmDelete(provider)) {
|
||||
await this.deleteApiKey(provider);
|
||||
|
||||
// UI 업데이트
|
||||
const input = containerEl.querySelector(`input[data-provider="${provider}"]`);
|
||||
if (input instanceof HTMLInputElement) {
|
||||
input.value = '';
|
||||
input.removeClass('has-value');
|
||||
}
|
||||
|
||||
// 상태 업데이트
|
||||
const statusEl = containerEl
|
||||
.closest('.api-key-input-container')
|
||||
?.querySelector('.key-status-indicator');
|
||||
if (statusEl instanceof HTMLElement) {
|
||||
this.updateStatusIndicator(statusEl, provider);
|
||||
}
|
||||
|
||||
new Notice(`${this.getProviderName(provider)} API key deleted`);
|
||||
}
|
||||
};
|
||||
|
||||
return btn;
|
||||
}
|
||||
|
||||
/**
|
||||
* 입력 핸들러 연결
|
||||
*/
|
||||
private attachInputHandlers(
|
||||
inputEl: HTMLInputElement,
|
||||
provider: TranscriptionProvider,
|
||||
validationRegex: RegExp,
|
||||
validateBtn: HTMLButtonElement
|
||||
): void {
|
||||
// 실시간 형식 검증
|
||||
inputEl.addEventListener('input', () => {
|
||||
const value = inputEl.value;
|
||||
|
||||
if (value && !value.includes('*')) {
|
||||
if (validationRegex.test(value)) {
|
||||
inputEl.classList.remove('invalid');
|
||||
inputEl.classList.add('valid-format');
|
||||
validateBtn.disabled = false;
|
||||
} else {
|
||||
inputEl.classList.remove('valid-format');
|
||||
inputEl.classList.add('invalid');
|
||||
validateBtn.disabled = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 포커스 아웃시 저장
|
||||
inputEl.addEventListener('blur', () => {
|
||||
void (async () => {
|
||||
try {
|
||||
const value = inputEl.value;
|
||||
|
||||
// 마스킹된 값이거나 빈 값이면 무시
|
||||
if (value.includes('*') || !value) return;
|
||||
|
||||
if (validationRegex.test(value)) {
|
||||
await this.saveApiKey(provider, value);
|
||||
inputEl.classList.add('has-value');
|
||||
|
||||
// 자동 검증 (옵션)
|
||||
if (this.plugin.settings.autoValidateKeys) {
|
||||
await this.validateApiKey(provider, value, validateBtn);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('APIKeyManager: Error saving API key on blur:', error);
|
||||
new Notice('Failed to save API key. Please check console for details.');
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
// Enter 키로 검증
|
||||
inputEl.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter' && !validateBtn.disabled) {
|
||||
validateBtn.click();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 최근 검증 시간 표시
|
||||
*/
|
||||
private renderLastValidation(containerEl: HTMLElement, provider: TranscriptionProvider): void {
|
||||
const status = this.validationStatus.get(provider);
|
||||
if (status && status.lastValidated) {
|
||||
containerEl.createEl('div', {
|
||||
cls: 'last-validation-time',
|
||||
text: `Last verified: ${this.formatTime(status.lastValidated)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 보안 정보 렌더링
|
||||
*/
|
||||
private renderSecurityInfo(containerEl: HTMLElement): void {
|
||||
const securityEl = containerEl.createDiv({ cls: 'api-key-security-info' });
|
||||
const securityNotice = securityEl.createDiv('security-notice');
|
||||
securityNotice.createEl('span', { cls: 'security-icon', text: '🔒' });
|
||||
|
||||
const securityText = securityNotice.createDiv('security-text');
|
||||
securityText.createEl('strong', { text: 'Security notice:' });
|
||||
|
||||
const list = securityText.createEl('ul');
|
||||
const items = [
|
||||
'API keys are encrypted using AES-256-GCM',
|
||||
'Keys are never logged or exposed in debug output',
|
||||
'Keys are stored locally and never transmitted except to their respective APIs',
|
||||
'Use environment variables for additional security in shared environments',
|
||||
];
|
||||
|
||||
items.forEach((item) => {
|
||||
list.createEl('li', { text: item });
|
||||
});
|
||||
|
||||
// 보안 설정 버튼
|
||||
new ButtonComponent(securityEl)
|
||||
.setButtonText('Security settings')
|
||||
.setIcon('shield')
|
||||
.onClick(() => this.showSecuritySettings());
|
||||
}
|
||||
|
||||
/**
|
||||
* API 키 검증
|
||||
*/
|
||||
private async validateApiKey(
|
||||
provider: TranscriptionProvider,
|
||||
key: string,
|
||||
btn?: HTMLButtonElement
|
||||
): Promise<boolean> {
|
||||
// 캐시 확인
|
||||
const cacheKey = `${provider}:${key}`;
|
||||
const cached = this.validationCache.get(cacheKey);
|
||||
|
||||
if (cached && Date.now() - cached.timestamp < this.CACHE_DURATION) {
|
||||
this.updateValidationStatus(provider, cached.valid ? 'valid' : 'invalid');
|
||||
return cached.valid;
|
||||
}
|
||||
|
||||
// UI 업데이트
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Verifying...';
|
||||
}
|
||||
|
||||
this.updateValidationStatus(provider, 'checking');
|
||||
|
||||
try {
|
||||
let isValid = false;
|
||||
|
||||
if (provider === 'whisper') {
|
||||
isValid = await this.validateWhisperKey(key);
|
||||
} else if (provider === 'deepgram') {
|
||||
isValid = await this.validateDeepgramKey(key);
|
||||
}
|
||||
|
||||
// 캐시 저장
|
||||
this.validationCache.set(cacheKey, {
|
||||
valid: isValid,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
// 상태 업데이트
|
||||
this.updateValidationStatus(provider, isValid ? 'valid' : 'invalid');
|
||||
|
||||
// 성공시 저장
|
||||
if (isValid) {
|
||||
await this.saveApiKey(provider, key);
|
||||
new Notice(`✅ ${this.getProviderName(provider)} API key verified!`);
|
||||
} else {
|
||||
new Notice(`❌ Invalid ${this.getProviderName(provider)} API key`);
|
||||
}
|
||||
|
||||
return isValid;
|
||||
} catch (error) {
|
||||
console.error(`API key validation error for ${provider}:`, error);
|
||||
this.updateValidationStatus(provider, 'error');
|
||||
new Notice(`Failed to verify ${this.getProviderName(provider)} API key`);
|
||||
return false;
|
||||
} finally {
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Verify';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whisper API 키 검증
|
||||
*/
|
||||
private async validateWhisperKey(key: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await requestUrl({
|
||||
url: 'https://api.openai.com/v1/models',
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${key}`,
|
||||
},
|
||||
});
|
||||
|
||||
return response.status >= 200 && response.status < 300;
|
||||
} catch (error) {
|
||||
console.error('Whisper key validation error:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deepgram API 키 검증
|
||||
*/
|
||||
private async validateDeepgramKey(key: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await requestUrl({
|
||||
url: 'https://api.deepgram.com/v1/projects',
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Token ${key}`,
|
||||
},
|
||||
});
|
||||
|
||||
return response.status >= 200 && response.status < 300;
|
||||
} catch (error) {
|
||||
console.error('Deepgram key validation error:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 키 검증
|
||||
*/
|
||||
public async verifyAllKeys(): Promise<Map<TranscriptionProvider, boolean>> {
|
||||
const results = new Map<TranscriptionProvider, boolean>();
|
||||
|
||||
for (const [provider, key] of this.apiKeys) {
|
||||
const isValid = await this.validateApiKey(provider, key);
|
||||
results.set(provider, isValid);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 가시성 업데이트
|
||||
*/
|
||||
public updateVisibility(currentProvider: TranscriptionProvider | 'auto'): void {
|
||||
const containers = document.querySelectorAll('.api-key-input-container');
|
||||
|
||||
containers.forEach((container) => {
|
||||
if (container instanceof HTMLElement) {
|
||||
const provider = container.classList.contains('whisper') ? 'whisper' : 'deepgram';
|
||||
|
||||
const shouldShow = currentProvider === 'auto' || currentProvider === provider;
|
||||
container.classList.toggle('sn-hidden', !shouldShow);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 키 가져오기
|
||||
*/
|
||||
public async importKeys(keys: Record<string, string>): Promise<void> {
|
||||
for (const [provider, key] of Object.entries(keys)) {
|
||||
if (this.isProvider(provider)) {
|
||||
await this.saveApiKey(provider, key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private isProvider(value: string): value is TranscriptionProvider {
|
||||
return value === 'whisper' || value === 'deepgram';
|
||||
}
|
||||
|
||||
// === Helper Methods ===
|
||||
|
||||
/**
|
||||
* API 키 마스킹
|
||||
*/
|
||||
private maskApiKey(key: string): string {
|
||||
if (!key || key.length < 10) return '***';
|
||||
|
||||
const visibleStart = 7;
|
||||
const visibleEnd = 4;
|
||||
const maskedLength = Math.max(3, key.length - visibleStart - visibleEnd);
|
||||
|
||||
return (
|
||||
key.substring(0, visibleStart) +
|
||||
'*'.repeat(maskedLength) +
|
||||
key.substring(key.length - visibleEnd)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider 이름 가져오기
|
||||
*/
|
||||
private getProviderName(provider: TranscriptionProvider): string {
|
||||
return provider === 'whisper' ? 'OpenAI whisper' : 'Deepgram';
|
||||
}
|
||||
|
||||
/**
|
||||
* 시간 포맷팅
|
||||
*/
|
||||
private formatTime(date: Date): string {
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const days = Math.floor(diff / 86400000);
|
||||
|
||||
if (minutes < 1) return 'Just now';
|
||||
if (minutes < 60) return `${minutes} minute${minutes > 1 ? 's' : ''} ago`;
|
||||
if (hours < 24) return `${hours} hour${hours > 1 ? 's' : ''} ago`;
|
||||
return `${days} day${days > 1 ? 's' : ''} ago`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 삭제 확인
|
||||
*/
|
||||
private confirmDelete(provider: TranscriptionProvider): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const modal = new Modal(this.plugin.app);
|
||||
modal.titleEl.setText('Delete API key?');
|
||||
|
||||
modal.contentEl.createEl('p', {
|
||||
text: `Are you sure you want to delete the ${this.getProviderName(
|
||||
provider
|
||||
)} API key?`,
|
||||
});
|
||||
|
||||
const buttonContainer = modal.contentEl.createDiv({ cls: 'modal-button-container' });
|
||||
|
||||
new ButtonComponent(buttonContainer).setButtonText('Cancel').onClick(() => {
|
||||
modal.close();
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
new ButtonComponent(buttonContainer)
|
||||
.setButtonText('Delete')
|
||||
.setWarning()
|
||||
.onClick(() => {
|
||||
modal.close();
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
modal.open();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 보안 설정 표시
|
||||
*/
|
||||
private showSecuritySettings(): void {
|
||||
const modal = new Modal(this.plugin.app);
|
||||
modal.titleEl.setText('API key security settings');
|
||||
|
||||
const contentEl = modal.contentEl;
|
||||
|
||||
// 암호화 설정
|
||||
new Setting(contentEl)
|
||||
.setName('Encryption')
|
||||
.setDesc('API keys are always encrypted')
|
||||
.addToggle((toggle) => {
|
||||
toggle.setValue(true).setDisabled(true);
|
||||
});
|
||||
|
||||
// 자동 검증
|
||||
new Setting(contentEl)
|
||||
.setName('Auto-validate on save')
|
||||
.setDesc('Automatically verify API keys when saving')
|
||||
.addToggle((toggle) => {
|
||||
toggle
|
||||
.setValue(this.plugin.settings.autoValidateKeys || false)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.autoValidateKeys = value;
|
||||
await this.plugin.saveSettings();
|
||||
});
|
||||
});
|
||||
|
||||
// 환경 변수 사용
|
||||
new Setting(contentEl)
|
||||
.setName('Use environment variables')
|
||||
.setDesc('Load API keys from environment variables (more secure)')
|
||||
.addToggle((toggle) => {
|
||||
toggle
|
||||
.setValue(this.plugin.settings.useEnvVars || false)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.useEnvVars = value;
|
||||
await this.plugin.saveSettings();
|
||||
|
||||
if (value) {
|
||||
this.showEnvVarInstructions();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
modal.open();
|
||||
}
|
||||
|
||||
/**
|
||||
* 환경 변수 안내
|
||||
*/
|
||||
private showEnvVarInstructions(): void {
|
||||
const modal = new Modal(this.plugin.app);
|
||||
modal.titleEl.setText('Environment variable setup');
|
||||
|
||||
const contentEl = modal.contentEl;
|
||||
const instructions = contentEl.createDiv('env-var-instructions');
|
||||
instructions.createEl('p', { text: 'To use environment variables for API keys:' });
|
||||
|
||||
const orderedList = instructions.createEl('ol');
|
||||
|
||||
const envItem = orderedList.createEl('li');
|
||||
envItem.setText('Set the following environment variables:');
|
||||
const envList = envItem.createEl('ul');
|
||||
|
||||
const whisperItem = envList.createEl('li');
|
||||
whisperItem.createEl('code', { text: 'OPENAI_API_KEY' });
|
||||
whisperItem.appendText(' for whisper');
|
||||
|
||||
const deepgramItem = envList.createEl('li');
|
||||
deepgramItem.createEl('code', { text: 'DEEPGRAM_API_KEY' });
|
||||
deepgramItem.appendText(' for Deepgram');
|
||||
|
||||
orderedList.createEl('li', { text: 'Restart Obsidian' });
|
||||
orderedList.createEl('li', {
|
||||
text: 'The plugin will automatically load keys from environment',
|
||||
});
|
||||
|
||||
instructions.createEl('p', {
|
||||
cls: 'warning',
|
||||
text: '⚠️ environment variables take precedence over saved keys',
|
||||
});
|
||||
|
||||
modal.open();
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증 상태 업데이트
|
||||
*/
|
||||
private updateValidationStatus(
|
||||
provider: TranscriptionProvider,
|
||||
status: 'valid' | 'invalid' | 'checking' | 'error'
|
||||
): void {
|
||||
this.validationStatus.set(provider, {
|
||||
status,
|
||||
lastValidated: status === 'valid' ? new Date() : undefined,
|
||||
});
|
||||
|
||||
// UI 업데이트
|
||||
const container = document.querySelector(`.api-key-input-container.${provider}`);
|
||||
if (container) {
|
||||
const statusEl = container.querySelector<HTMLElement>('.key-status-indicator');
|
||||
if (statusEl) {
|
||||
this.updateStatusIndicator(statusEl, provider);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API 키 저장
|
||||
*/
|
||||
private async saveApiKey(provider: TranscriptionProvider, key: string): Promise<void> {
|
||||
// 암호화
|
||||
const encrypted = await this.encryptor.encrypt(key);
|
||||
|
||||
// 메모리에 저장
|
||||
this.apiKeys.set(provider, key);
|
||||
|
||||
// 플러그인 설정에 저장
|
||||
const encryptedString = JSON.stringify(encrypted);
|
||||
if (provider === 'whisper') {
|
||||
this.plugin.settings.apiKey = encryptedString;
|
||||
this.plugin.settings.whisperApiKey = encryptedString;
|
||||
} else if (provider === 'deepgram') {
|
||||
this.plugin.settings.deepgramApiKey = encryptedString;
|
||||
}
|
||||
|
||||
await this.plugin.saveSettings();
|
||||
}
|
||||
|
||||
/**
|
||||
* API 키 삭제
|
||||
*/
|
||||
private async deleteApiKey(provider: TranscriptionProvider): Promise<void> {
|
||||
this.apiKeys.delete(provider);
|
||||
this.validationStatus.delete(provider);
|
||||
|
||||
if (provider === 'whisper') {
|
||||
this.plugin.settings.apiKey = '';
|
||||
this.plugin.settings.whisperApiKey = '';
|
||||
} else if (provider === 'deepgram') {
|
||||
this.plugin.settings.deepgramApiKey = '';
|
||||
}
|
||||
|
||||
await this.plugin.saveSettings();
|
||||
}
|
||||
|
||||
/**
|
||||
* API 키 로드
|
||||
*/
|
||||
private async loadApiKeys(): Promise<void> {
|
||||
// 환경 변수 확인
|
||||
if (this.plugin.settings.useEnvVars) {
|
||||
const whisperKey = process.env.OPENAI_API_KEY;
|
||||
const deepgramKey = process.env.DEEPGRAM_API_KEY;
|
||||
|
||||
if (whisperKey) this.apiKeys.set('whisper', whisperKey);
|
||||
if (deepgramKey) this.apiKeys.set('deepgram', deepgramKey);
|
||||
}
|
||||
|
||||
// 저장된 키 로드
|
||||
if (this.plugin.settings.apiKey || this.plugin.settings.whisperApiKey) {
|
||||
try {
|
||||
const key = this.plugin.settings.whisperApiKey || this.plugin.settings.apiKey;
|
||||
const encryptedData: unknown = JSON.parse(key);
|
||||
if (isEncryptedData(encryptedData)) {
|
||||
const decrypted = await this.encryptor.decrypt(encryptedData);
|
||||
this.apiKeys.set('whisper', decrypted);
|
||||
}
|
||||
} catch {
|
||||
// 암호화되지 않은 키일 수 있음 (마이그레이션)
|
||||
const key = this.plugin.settings.apiKey;
|
||||
if (key && !key.includes('*')) {
|
||||
this.apiKeys.set('whisper', key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.plugin.settings.deepgramApiKey) {
|
||||
try {
|
||||
const encryptedData = JSON.parse(this.plugin.settings.deepgramApiKey) as unknown;
|
||||
if (isEncryptedData(encryptedData)) {
|
||||
const decrypted = await this.encryptor.decrypt(encryptedData);
|
||||
this.apiKeys.set('deepgram', decrypted);
|
||||
}
|
||||
} catch {
|
||||
// 암호화되지 않은 키일 수 있음
|
||||
const key = this.plugin.settings.deepgramApiKey;
|
||||
if (key && !key.includes('*')) {
|
||||
this.apiKeys.set('deepgram', key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.validationCache.clear();
|
||||
this.validationStatus.clear();
|
||||
this.keyVisibility.clear();
|
||||
this.apiKeys.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증 상태 인터페이스
|
||||
*/
|
||||
interface ValidationStatus {
|
||||
status: 'valid' | 'invalid' | 'checking' | 'error';
|
||||
lastValidated?: Date;
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,7 +1,6 @@
|
|||
/* Settings view aggregator for Speech-to-Text plugin */
|
||||
/* Imports existing UI styles so a single stylesheet can be referenced. */
|
||||
|
||||
@import url('../styles/provider-settings.css');
|
||||
@import url('../styles/progress.css');
|
||||
@import url('../styles/notifications.css');
|
||||
@import url('../styles/file-picker.css');
|
||||
|
|
|
|||
|
|
@ -1,841 +0,0 @@
|
|||
/**
|
||||
* Provider Settings Styles
|
||||
*
|
||||
* Multi-Provider 설정 UI를 위한 스타일시트
|
||||
* - 반응형 디자인
|
||||
* - 다크/라이트 테마 지원
|
||||
* - 접근성 고려
|
||||
* - 애니메이션 및 전환 효과
|
||||
*/
|
||||
|
||||
/* === Container Styles === */
|
||||
.provider-settings-container {
|
||||
padding: 20px 0;
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.provider-header {
|
||||
margin-bottom: 25px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.provider-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.provider-title-text {
|
||||
font-size: 1.2em;
|
||||
font-weight: 600;
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.provider-expand-toggle {
|
||||
margin-left: auto;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.provider-expand-toggle:hover {
|
||||
background: var(--background-modifier-hover);
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.provider-description {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9em;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* === Status Dashboard === */
|
||||
.provider-status-dashboard {
|
||||
background: var(--background-secondary);
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
|
||||
.overall-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 15px;
|
||||
padding: 10px;
|
||||
background: var(--background-primary);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Status Levels */
|
||||
.status-good {
|
||||
color: var(--text-success, #4caf50);
|
||||
}
|
||||
.status-warning {
|
||||
color: var(--text-warning, #ff9800);
|
||||
}
|
||||
.status-error {
|
||||
color: var(--text-error, #f44336);
|
||||
}
|
||||
|
||||
/* Providers Grid */
|
||||
.providers-status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.provider-status-item {
|
||||
background: var(--background-primary);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.provider-status-item:hover {
|
||||
border-color: var(--interactive-accent);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.provider-name {
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.provider-indicators {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.indicator.has-key {
|
||||
background: rgba(76, 175, 80, 0.1);
|
||||
}
|
||||
.indicator.no-key {
|
||||
background: rgba(244, 67, 54, 0.1);
|
||||
}
|
||||
.indicator.connected {
|
||||
background: rgba(76, 175, 80, 0.1);
|
||||
}
|
||||
.indicator.disconnected {
|
||||
background: rgba(255, 152, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Last Update */
|
||||
.last-update {
|
||||
text-align: right;
|
||||
margin-top: 10px;
|
||||
color: var(--text-faint);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
/* === Provider Selection === */
|
||||
.provider-selection-section {
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.strategy-weights {
|
||||
margin-top: 15px;
|
||||
padding: 15px;
|
||||
background: var(--background-secondary-alt);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.strategy-weights h5 {
|
||||
margin-bottom: 15px;
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.weight-display {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.weight-bar {
|
||||
display: flex;
|
||||
height: 30px;
|
||||
border-radius: 15px;
|
||||
overflow: hidden;
|
||||
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.weight-segment {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 0.85em;
|
||||
font-weight: 500;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.weight-segment.latency {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
.weight-segment.success {
|
||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
}
|
||||
.weight-segment.cost {
|
||||
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
||||
}
|
||||
|
||||
/* === API Key Section === */
|
||||
.api-key-section {
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.api-key-manager {
|
||||
background: var(--background-secondary);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.api-key-header {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.api-key-title {
|
||||
font-size: 1.1em;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.api-key-description {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.api-key-inputs {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.api-key-input-container {
|
||||
margin-bottom: 20px;
|
||||
padding: 15px;
|
||||
background: var(--background-primary);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.api-key-input-container:hover {
|
||||
border-color: var(--interactive-accent-hover);
|
||||
}
|
||||
|
||||
/* Key Status Indicator */
|
||||
.key-status-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.key-status-indicator.status-empty {
|
||||
background: var(--background-modifier-border);
|
||||
}
|
||||
.key-status-indicator.status-valid {
|
||||
background: rgba(76, 175, 80, 0.1);
|
||||
color: var(--text-success);
|
||||
}
|
||||
.key-status-indicator.status-invalid {
|
||||
background: rgba(244, 67, 54, 0.1);
|
||||
color: var(--text-error);
|
||||
}
|
||||
.key-status-indicator.status-checking {
|
||||
background: rgba(33, 150, 243, 0.1);
|
||||
color: var(--text-accent);
|
||||
}
|
||||
.key-status-indicator.status-unverified {
|
||||
background: rgba(255, 152, 0, 0.1);
|
||||
color: var(--text-warning);
|
||||
}
|
||||
|
||||
/* Input Group */
|
||||
.key-input-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.api-key-input {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 4px;
|
||||
background: var(--background-primary);
|
||||
color: var(--text-normal);
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.api-key-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--interactive-accent);
|
||||
box-shadow: 0 0 0 2px var(--interactive-accent-hover);
|
||||
}
|
||||
|
||||
.api-key-input.has-value {
|
||||
background: var(--background-primary-alt);
|
||||
}
|
||||
|
||||
.api-key-input.valid-format {
|
||||
border-color: var(--text-success);
|
||||
}
|
||||
|
||||
.api-key-input.invalid {
|
||||
border-color: var(--text-error);
|
||||
}
|
||||
|
||||
/* Action Buttons */
|
||||
.key-visibility-toggle,
|
||||
.key-copy-btn,
|
||||
.key-delete-btn {
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 4px;
|
||||
background: var(--background-primary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.key-visibility-toggle:hover,
|
||||
.key-copy-btn:hover,
|
||||
.key-delete-btn:hover {
|
||||
background: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.key-validate-btn {
|
||||
padding: 8px 16px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.key-validate-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Last Validation Time */
|
||||
.last-validation-time {
|
||||
margin-top: 8px;
|
||||
font-size: 0.85em;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
/* Security Info */
|
||||
.api-key-security-info {
|
||||
margin-top: 20px;
|
||||
padding: 15px;
|
||||
background: var(--background-primary-alt);
|
||||
border-radius: 6px;
|
||||
border-left: 3px solid var(--interactive-accent);
|
||||
}
|
||||
|
||||
.security-notice {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.security-icon {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.security-text ul {
|
||||
margin: 8px 0 0 20px;
|
||||
font-size: 0.9em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* API Key Actions */
|
||||
.api-key-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
/* === Advanced Settings === */
|
||||
.advanced-settings-section {
|
||||
margin-top: 30px;
|
||||
padding-top: 30px;
|
||||
border-top: 2px solid var(--background-modifier-border);
|
||||
}
|
||||
|
||||
.advanced-settings-panel {
|
||||
background: var(--background-secondary);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.advanced-panel-header {
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.advanced-title {
|
||||
font-size: 1.1em;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.advanced-warning {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
background: rgba(255, 152, 0, 0.1);
|
||||
border-radius: 6px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.warning-icon {
|
||||
color: var(--text-warning);
|
||||
}
|
||||
|
||||
/* Advanced Sections */
|
||||
.advanced-section {
|
||||
margin-bottom: 25px;
|
||||
padding-bottom: 25px;
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
|
||||
.advanced-section:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.section-header h5 {
|
||||
font-size: 1em;
|
||||
font-weight: 600;
|
||||
margin-bottom: 15px;
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.section-content {
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
/* Spending Display */
|
||||
.spending-display {
|
||||
padding: 15px;
|
||||
background: var(--background-primary);
|
||||
border-radius: 6px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.spending-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.spending-label {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.spending-value {
|
||||
font-weight: 600;
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.spending-bar {
|
||||
height: 8px;
|
||||
background: var(--background-modifier-border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.spending-progress {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #4facfe 0%, #00f2fe 100%);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.spending-progress.warning {
|
||||
background: linear-gradient(90deg, #fa709a 0%, #fee140 100%);
|
||||
}
|
||||
|
||||
/* Comparison Chart */
|
||||
.comparison-chart {
|
||||
margin-top: 20px;
|
||||
padding: 16px;
|
||||
background: var(--background-primary);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.chart-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.chart-bars {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
height: 160px;
|
||||
}
|
||||
|
||||
.chart-bar {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
border-radius: 8px 8px 0 0;
|
||||
height: 100%;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.chart-bar--whisper {
|
||||
height: 80%;
|
||||
background: linear-gradient(180deg, #4facfe 0%, #00f2fe 100%);
|
||||
}
|
||||
|
||||
.chart-bar--deepgram {
|
||||
height: 95%;
|
||||
background: linear-gradient(180deg, #f093fb 0%, #f5576c 100%);
|
||||
}
|
||||
|
||||
.chart-bar .bar-label {
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
color: var(--text-on-accent, #ffffff);
|
||||
}
|
||||
|
||||
.chart-legend {
|
||||
font-size: 0.85em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Split Display (A/B Testing) */
|
||||
.split-display {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.split-visualization {
|
||||
padding: 10px;
|
||||
background: var(--background-primary);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.split-bar {
|
||||
display: flex;
|
||||
height: 40px;
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.split-a,
|
||||
.split-b {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 0.9em;
|
||||
font-weight: 500;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.split-a {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.split-b {
|
||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
}
|
||||
|
||||
/* Cache Settings */
|
||||
.cache-settings {
|
||||
margin-top: 15px;
|
||||
padding: 15px;
|
||||
background: var(--background-primary-alt);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* Circuit Breaker Settings */
|
||||
.circuit-breaker-settings {
|
||||
margin-top: 15px;
|
||||
padding: 15px;
|
||||
background: var(--background-primary-alt);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.notice-action-row {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.notice-action-button {
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 4px;
|
||||
padding: 6px 12px;
|
||||
background: var(--background-primary);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.notice-action-button:hover {
|
||||
background: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
/* === Metrics Section === */
|
||||
.metrics-section {
|
||||
margin-top: 25px;
|
||||
padding: 20px;
|
||||
background: var(--background-secondary);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.metrics-section h4 {
|
||||
margin-bottom: 15px;
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.metrics-display {
|
||||
padding: 15px;
|
||||
background: var(--background-primary);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.metrics-placeholder {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.metrics-placeholder ul {
|
||||
margin: 10px 0 0 20px;
|
||||
}
|
||||
|
||||
/* === Provider Actions === */
|
||||
.provider-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 25px;
|
||||
padding-top: 25px;
|
||||
border-top: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
|
||||
/* === Modals === */
|
||||
.provider-details-status,
|
||||
.provider-details-stats,
|
||||
.provider-details-config {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.status-grid,
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.status-item,
|
||||
.stat-item {
|
||||
padding: 10px;
|
||||
background: var(--background-secondary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.2em;
|
||||
font-weight: 600;
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.stat-label,
|
||||
.status-label {
|
||||
font-size: 0.85em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.config-display {
|
||||
background: var(--background-secondary);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
font-family: monospace;
|
||||
font-size: 0.85em;
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
/* === Animations === */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* === Responsive Design === */
|
||||
@media (max-width: 768px) {
|
||||
.providers-status-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.status-grid,
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.provider-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.api-key-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.key-input-group {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.api-key-input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* === Dark Theme Adjustments === */
|
||||
.theme-dark .provider-settings-container {
|
||||
--background-primary: #202020;
|
||||
--background-primary-alt: #1a1a1a;
|
||||
--background-secondary: #2b2b2b;
|
||||
--background-secondary-alt: #262626;
|
||||
}
|
||||
|
||||
/* === Light Theme Adjustments === */
|
||||
.theme-light .provider-settings-container {
|
||||
--background-primary: #ffffff;
|
||||
--background-primary-alt: #f7f7f7;
|
||||
--background-secondary: #f3f3f3;
|
||||
--background-secondary-alt: #ebebeb;
|
||||
}
|
||||
|
||||
/* === Accessibility === */
|
||||
.provider-settings-container button:focus-visible,
|
||||
.provider-settings-container input:focus-visible {
|
||||
outline: 2px solid var(--interactive-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.provider-settings-container [aria-label] {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Deepgram Setting Helpers */
|
||||
.deepgram-description {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.deepgram-warning-box {
|
||||
padding: 8px;
|
||||
margin-bottom: 16px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.deepgram-info-container {
|
||||
background: var(--background-secondary);
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
margin: 10px 0;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.deepgram-metrics-row {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.deepgram-languages-row {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.deepgram-cost-container {
|
||||
background: var(--background-modifier-border);
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.deepgram-error-container {
|
||||
padding: 12px;
|
||||
margin-bottom: 20px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.deepgram-error-details {
|
||||
font-size: 0.85em;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.deepgram-note {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
/* High Contrast Mode */
|
||||
@media (prefers-contrast: high) {
|
||||
.provider-settings-container {
|
||||
--background-modifier-border: #000;
|
||||
}
|
||||
|
||||
.api-key-input {
|
||||
border-width: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reduced Motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.provider-settings-container * {
|
||||
animation-duration: 0.01ms !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
|
@ -9,24 +9,24 @@
|
|||
*/
|
||||
|
||||
/* === Container Styles === */
|
||||
.provider-settings-header {
|
||||
.speech-to-text-settings .sn-provider-settings-header {
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
|
||||
.provider-settings-header h3 {
|
||||
.speech-to-text-settings .sn-provider-settings-header h3 {
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.provider-settings-header .setting-item-description {
|
||||
.speech-to-text-settings .sn-provider-settings-header .setting-item-description {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* === Connection Status === */
|
||||
.connection-status {
|
||||
.speech-to-text-settings .sn-connection-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.75rem;
|
||||
|
|
@ -36,12 +36,12 @@
|
|||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.connection-status.connected {
|
||||
.speech-to-text-settings .sn-connection-status.connected {
|
||||
background-color: var(--interactive-success);
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
|
||||
.connection-status.disconnected {
|
||||
.speech-to-text-settings .sn-connection-status.disconnected {
|
||||
background-color: var(--background-modifier-error);
|
||||
color: var(--text-error);
|
||||
}
|
||||
|
|
@ -51,7 +51,7 @@
|
|||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.api-key-input {
|
||||
.speech-to-text-settings .sn-api-key-input {
|
||||
flex-grow: 1;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
|
|
@ -61,21 +61,21 @@
|
|||
min-width: 300px;
|
||||
}
|
||||
|
||||
.api-key-input:focus {
|
||||
.speech-to-text-settings .sn-api-key-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--interactive-accent);
|
||||
box-shadow: 0 0 0 2px var(--interactive-accent-hover);
|
||||
}
|
||||
|
||||
.api-key-input.valid {
|
||||
.speech-to-text-settings .sn-api-key-input.valid {
|
||||
border-color: var(--interactive-success);
|
||||
}
|
||||
|
||||
.api-key-input.invalid {
|
||||
.speech-to-text-settings .sn-api-key-input.invalid {
|
||||
border-color: var(--text-error);
|
||||
}
|
||||
|
||||
.api-key-input[data-has-value='true'] {
|
||||
.speech-to-text-settings .sn-api-key-input[data-has-value='true'] {
|
||||
background-color: var(--background-secondary);
|
||||
}
|
||||
|
||||
|
|
@ -116,7 +116,7 @@
|
|||
}
|
||||
|
||||
/* === Advanced Settings === */
|
||||
.advanced-settings-container {
|
||||
.speech-to-text-settings .sn-advanced-settings-container {
|
||||
margin-top: 2rem;
|
||||
padding: 1.5rem;
|
||||
background: var(--background-secondary);
|
||||
|
|
@ -124,7 +124,7 @@
|
|||
border: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
|
||||
.advanced-settings-container .setting-item {
|
||||
.speech-to-text-settings .sn-advanced-settings-container .setting-item {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
|
|
@ -302,7 +302,7 @@
|
|||
|
||||
/* === Responsive Design === */
|
||||
@media (max-width: 768px) {
|
||||
.api-key-input {
|
||||
.speech-to-text-settings .sn-api-key-input {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
|
|
@ -320,17 +320,17 @@
|
|||
}
|
||||
|
||||
/* === Dark Mode Adjustments === */
|
||||
.theme-dark .connection-status.connected {
|
||||
.theme-dark .speech-to-text-settings .sn-connection-status.connected {
|
||||
background-color: rgba(16, 163, 127, 0.2);
|
||||
color: #10a37f;
|
||||
}
|
||||
|
||||
.theme-dark .connection-status.disconnected {
|
||||
.theme-dark .speech-to-text-settings .sn-connection-status.disconnected {
|
||||
background-color: rgba(255, 87, 87, 0.2);
|
||||
color: #ff5757;
|
||||
}
|
||||
|
||||
.theme-dark .advanced-settings-container {
|
||||
.theme-dark .speech-to-text-settings .sn-advanced-settings-container {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
|
|
@ -339,7 +339,7 @@
|
|||
}
|
||||
|
||||
/* === Accessibility === */
|
||||
.setting-item:focus-within {
|
||||
.speech-to-text-settings .setting-item:focus-within {
|
||||
outline: 2px solid var(--interactive-accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
|
|
@ -353,11 +353,11 @@ select:focus-visible {
|
|||
}
|
||||
|
||||
/* === Tooltips === */
|
||||
.setting-item[aria-describedby] {
|
||||
.speech-to-text-settings .setting-item[aria-describedby] {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
.speech-to-text-settings .sn-tooltip {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
|
|
@ -373,6 +373,6 @@ select:focus-visible {
|
|||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.setting-item:hover .tooltip {
|
||||
.speech-to-text-settings .setting-item:hover .sn-tooltip {
|
||||
opacity: 1;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,49 @@
|
|||
import { fn } from 'jest-mock';
|
||||
|
||||
class MockComponent {
|
||||
private readonly events: Array<{ off?: () => void }> = [];
|
||||
private readonly children: MockComponent[] = [];
|
||||
|
||||
onload(): void {}
|
||||
|
||||
onunload(): void {}
|
||||
|
||||
load(): void {
|
||||
this.onload();
|
||||
}
|
||||
|
||||
unload(): void {
|
||||
for (const eventRef of this.events) {
|
||||
if (typeof eventRef.off === 'function') {
|
||||
eventRef.off();
|
||||
}
|
||||
}
|
||||
this.events.length = 0;
|
||||
|
||||
while (this.children.length > 0) {
|
||||
const child = this.children.pop();
|
||||
child?.unload();
|
||||
}
|
||||
|
||||
this.onunload();
|
||||
}
|
||||
|
||||
registerEvent(eventRef: { off?: () => void }): { off?: () => void } {
|
||||
this.events.push(eventRef);
|
||||
return eventRef;
|
||||
}
|
||||
|
||||
addChild(child: MockComponent): MockComponent {
|
||||
this.children.push(child);
|
||||
child.load();
|
||||
return child;
|
||||
}
|
||||
}
|
||||
|
||||
const obsidianMock = {
|
||||
requestUrl: fn(),
|
||||
Plugin: fn(),
|
||||
Component: MockComponent,
|
||||
Modal: fn(),
|
||||
Setting: fn(),
|
||||
PluginSettingTab: fn(),
|
||||
|
|
@ -17,6 +58,8 @@ const obsidianMock = {
|
|||
DropdownComponent: fn(),
|
||||
ToggleComponent: fn(),
|
||||
TextComponent: fn(),
|
||||
setIcon: fn(),
|
||||
getIcon: fn(),
|
||||
};
|
||||
|
||||
export = obsidianMock;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
* 성능 최적화 구현 테스트
|
||||
*/
|
||||
|
||||
import { LazyLoader } from '../../src/core/LazyLoader';
|
||||
import { MemoryCache, globalCache } from '../../src/infrastructure/cache/MemoryCache';
|
||||
import { BatchRequestManager } from '../../src/infrastructure/api/BatchRequestManager';
|
||||
import { MemoryProfiler } from '../../src/utils/memory/MemoryProfiler';
|
||||
|
|
@ -12,33 +11,6 @@ import { ObjectPool, bufferPool } from '../../src/utils/memory/ObjectPool';
|
|||
import { PerformanceBenchmark } from '../../src/utils/performance/PerformanceBenchmark';
|
||||
|
||||
describe('Phase 4 Performance Optimization', () => {
|
||||
describe('Bundle Size Optimization', () => {
|
||||
test('Lazy loading should reduce initial bundle size', async () => {
|
||||
// 초기 로드된 모듈 수 확인
|
||||
const initialStats = LazyLoader.getStats();
|
||||
expect(initialStats.loadedCount).toBe(0);
|
||||
|
||||
// 모듈 동적 로드
|
||||
const module = await LazyLoader.loadModule('StatisticsDashboard');
|
||||
expect(module).toBeDefined();
|
||||
|
||||
// 로드 후 상태 확인
|
||||
const afterStats = LazyLoader.getStats();
|
||||
expect(afterStats.loadedCount).toBe(1);
|
||||
});
|
||||
|
||||
test('Module preloading should work in background', (done) => {
|
||||
LazyLoader.preloadModules(['AdvancedSettings', 'AudioSettings']);
|
||||
|
||||
// 백그라운드 로드 확인
|
||||
setTimeout(() => {
|
||||
const stats = LazyLoader.getStats();
|
||||
expect(stats.preloadCount).toBeGreaterThanOrEqual(0);
|
||||
done();
|
||||
}, 100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Memory Cache System', () => {
|
||||
let cache: MemoryCache<any>;
|
||||
|
||||
|
|
|
|||
|
|
@ -380,13 +380,12 @@ describe('EditorService', () => {
|
|||
|
||||
describe('destroy', () => {
|
||||
it('should clean up resources', () => {
|
||||
const unloadSpy = jest
|
||||
.spyOn(editorService as unknown as { unload: () => void }, 'unload')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
editorService.destroy();
|
||||
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith('EditorService destroyed');
|
||||
|
||||
// Should return null after destroy
|
||||
const editor = editorService.getActiveEditor();
|
||||
expect(editor).toBeNull();
|
||||
expect(unloadSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue