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:
asyouplz 2026-02-19 10:38:05 +09:00 committed by GitHub
parent 6cbe785fcc
commit f62f8eec5a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 223 additions and 8091 deletions

9
.gitignore vendored
View file

@ -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/

View file

@ -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.

View file

@ -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>

View file

@ -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
## 릴리즈 방법

View file

@ -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
}

View file

@ -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();
}
}

View file

@ -235,7 +235,6 @@ export const ServiceTokens = {
ErrorHandler: Symbol.for('ErrorHandler'),
NotificationService: Symbol.for('NotificationService'),
StatusBarManager: Symbol.for('StatusBarManager'),
SettingsTabManager: Symbol.for('SettingsTabManager'),
};
/**

View file

@ -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));
}
}

View file

@ -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,

View file

@ -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);
}
/**

View file

@ -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';
}
/**

View file

@ -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(),

View file

@ -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

View file

@ -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();

View file

@ -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();
}
}

View file

@ -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 {
// 추가 정리 로직
}
}

View file

@ -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');

View file

@ -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 });
});
}
}

View file

@ -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;
}
}

View file

@ -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

View file

@ -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');

View file

@ -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;
}
}

View file

@ -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;
}

View file

@ -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;

View file

@ -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>;

View file

@ -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();
});
});
});